diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f41be449..bada42c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -817,6 +817,7 @@ jobs: run: ./scripts/policy/run-platform-coverage-gate.sh --base-ref "$COVERAGE_BASE_REF" --profile coverage-windows.txt - name: Upload Windows coverage artifact + if: always() uses: actions/upload-artifact@v4 with: name: coverage-windows diff --git a/Makefile b/Makefile index 41aefc5f..e7d6b405 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ SCHEMA_META_INDEX_OUTPUT ?= artifacts/schema_meta_index.gob POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)" GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go' -.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks +.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks all: setup-hooks fmt lint build test rebuild @@ -33,6 +33,7 @@ help: @printf " make schema-compatibility BASE_REF= - Check the complete Schema contract against the PR merge-base\n" @printf " make skill-command-integrity - Check dws commands referenced by skills exist\n" @printf " make skill-context-budget - Check generated Skill drift and common-path context budgets\n" + @printf " make multi-im-skill-chain-integrity - Check reviewed IM intents keep one default Skill route\n" @printf " make cli-smoke - Verify help for every public top-level command\n" @printf " make mock-mcp-smoke - Verify HTTP and stdio MCP request/response transport\n" @printf " make test-schema-agent-examples - Contract-check all Agent examples and dry-run the eligible subset\n" @@ -87,6 +88,7 @@ policy: test-auth-legacy-compat @mkdir -p "$(POLICY_GOTMPDIR)" @$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh @$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh + @$(POLICY_ENV) ./scripts/policy/check-multi-im-skill-chain.sh @$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict @$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh @$(POLICY_ENV) ./scripts/policy/check-param-concepts.sh @@ -126,6 +128,9 @@ skill-command-integrity: skill-context-budget: @./scripts/policy/check-skill-context-budget.sh +multi-im-skill-chain-integrity: + @./scripts/policy/check-multi-im-skill-chain.sh + cli-smoke: @./scripts/policy/check-cli-smoke.sh diff --git a/internal/app/command_path_fallback_badcase_replay_test.go b/internal/app/command_path_fallback_badcase_replay_test.go index ba78ebcd..9329a4b7 100644 --- a/internal/app/command_path_fallback_badcase_replay_test.go +++ b/internal/app/command_path_fallback_badcase_replay_test.go @@ -153,7 +153,7 @@ func TestShortcutHallucinationMerged20260720Replay(t *testing.T) { id: "dws_im_v2_0013-search-group", raw: `dws chat +search-group --name "dws测试群02" --format json`, occurrences: 1, - outcome: "rewrite", + outcome: "official_alias", target: "chat +chat-search", }, { @@ -183,6 +183,8 @@ func TestShortcutHallucinationMerged20260720Replay(t *testing.T) { switch test.outcome { case "rewrite": assertHistoricalShortcutRewrite(t, badcase, test.target, ctx, err) + case "official_alias": + assertHistoricalOfficialAlias(t, badcase, test.target, ctx, err) case "ambiguous": assertHistoricalShortcutReason(t, badcase, ctx, err, "ambiguous_command_fallback") default: @@ -195,6 +197,30 @@ func TestShortcutHallucinationMerged20260720Replay(t *testing.T) { } } +func assertHistoricalOfficialAlias( + t *testing.T, + badcase shortcutHallucinationReplayBadcase, + wantTarget string, + ctx *pipeline.Context, + err error, +) { + t.Helper() + if err != nil { + t.Fatalf("historical official alias %q returned error: %v", badcase.SourcePath, err) + } + if wantTarget == "" || ctx == nil || ctx.Command != "dws "+wantTarget { + t.Fatalf("historical official alias context = %#v; want command dws %s", ctx, wantTarget) + } + for _, correction := range ctx.Corrections { + if correction.Handler == "command-path-fallback" { + t.Fatalf("historical official alias received fallback correction: %#v", ctx.Corrections) + } + } + if entry, ok := cli.LookupCommandPathFallback(badcase.SourcePath); ok { + t.Fatalf("official alias unexpectedly remains in fallback table: %#v", entry) + } +} + func historicalShortcutBadcaseArgv(t *testing.T, raw string) []string { t.Helper() command := strings.TrimSpace(raw) diff --git a/internal/app/command_path_fallback_e2e_test.go b/internal/app/command_path_fallback_e2e_test.go index 8d1805d7..32dd8ff0 100644 --- a/internal/app/command_path_fallback_e2e_test.go +++ b/internal/app/command_path_fallback_e2e_test.go @@ -26,49 +26,18 @@ func TestReviewedCommandFallbacksReachCanonicalDryRunPayload(t *testing.T) { if err != nil || len(canonicalQueryAttempts) != 0 { t.Fatalf("canonical query preview = %#v, attempts=%v, error=%v", canonicalQuery, canonicalQueryAttempts, err) } - _, canonicalKeyword, canonicalKeywordAttempts, err := executeParamAliasDryRunE2E(t, - "chat", "+chat-search", "--keyword", "project", "--dry-run", - ) - if err != nil || len(canonicalKeywordAttempts) != 0 { - t.Fatalf("canonical keyword preview = %#v, attempts=%v, error=%v", canonicalKeyword, canonicalKeywordAttempts, err) - } - _, nativeCanonical, nativeCanonicalAttempts, err := executeParamAliasDryRunE2E(t, - "chat", "search", "--query", "project", "--dry-run", - ) - if err != nil || len(nativeCanonicalAttempts) != 0 { - t.Fatalf("native canonical preview = %#v, attempts=%v, error=%v", nativeCanonical, nativeCanonicalAttempts, err) - } - tests := []struct { name string args []string wantCommand string wantPreview paramAliasDryRunPreview }{ - { - name: "search group keyword", - args: []string{"chat", "+search-group", "--keyword", "project", "--dry-run"}, - wantCommand: "dws chat +chat-search", - wantPreview: canonicalKeyword, - }, { name: "group search query", args: []string{"chat", "+group-search", "--query", "project", "--dry-run"}, wantCommand: "dws chat +chat-search", wantPreview: canonicalQuery, }, - { - name: "chat group search query", - args: []string{"chat", "+chat-group-search", "--query", "project", "--dry-run"}, - wantCommand: "dws chat +chat-search", - wantPreview: canonicalQuery, - }, - { - name: "native group search", - args: []string{"chat", "group", "search", "--query", "project", "--dry-run"}, - wantCommand: "dws chat search", - wantPreview: nativeCanonical, - }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -93,17 +62,77 @@ func TestReviewedCommandFallbacksReachCanonicalDryRunPayload(t *testing.T) { } } +func TestOfficialCommandAliasesBypassFallbackAndReachEquivalentPayload(t *testing.T) { + _, canonicalQuery, canonicalQueryAttempts, err := executeParamAliasDryRunE2E(t, + "chat", "+chat-search", "--query", "project", "--dry-run", + ) + if err != nil || len(canonicalQueryAttempts) != 0 { + t.Fatalf("canonical query preview = %#v, attempts=%v, error=%v", canonicalQuery, canonicalQueryAttempts, err) + } + _, canonicalKeyword, canonicalKeywordAttempts, err := executeParamAliasDryRunE2E(t, + "chat", "+chat-search", "--keyword", "project", "--dry-run", + ) + if err != nil || len(canonicalKeywordAttempts) != 0 { + t.Fatalf("canonical keyword preview = %#v, attempts=%v, error=%v", canonicalKeyword, canonicalKeywordAttempts, err) + } + _, nativeCanonical, nativeCanonicalAttempts, err := executeParamAliasDryRunE2E(t, + "chat", "search", "--query", "project", "--dry-run", + ) + if err != nil || len(nativeCanonicalAttempts) != 0 { + t.Fatalf("native canonical preview = %#v, attempts=%v, error=%v", nativeCanonical, nativeCanonicalAttempts, err) + } + + tests := []struct { + name string + args []string + wantCommand string + wantPreview paramAliasDryRunPreview + }{ + { + name: "search group shortcut alias", + args: []string{"chat", "+search-group", "--keyword", "project", "--dry-run"}, + wantCommand: "dws chat +chat-search", + wantPreview: canonicalKeyword, + }, + { + name: "chat group search shortcut alias", + args: []string{"chat", "+chat-group-search", "--query", "project", "--dry-run"}, + wantCommand: "dws chat +chat-search", + wantPreview: canonicalQuery, + }, + { + name: "hidden native compatibility leaf", + args: []string{"chat", "group", "search", "--query", "project", "--dry-run"}, + wantCommand: "dws chat group search", + wantPreview: nativeCanonical, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx, preview, attempts, executeErr := executeParamAliasDryRunE2E(t, test.args...) + if executeErr != nil { + t.Fatalf("official alias execute error = %v", executeErr) + } + if len(attempts) != 0 { + t.Fatalf("official alias crossed dry-run dispatch boundary: %#v", attempts) + } + if !reflect.DeepEqual(preview, test.wantPreview) { + t.Fatalf("official alias preview = %#v, want canonical %#v", preview, test.wantPreview) + } + if ctx == nil || ctx.Command != test.wantCommand { + t.Fatalf("official alias context = %#v, want command %q", ctx, test.wantCommand) + } + assertNoCommandPathFallbackCorrection(t, ctx) + }) + } +} + func TestReviewedReadFallbacksResolveCanonicalLeafBeforeParameterValidation(t *testing.T) { tests := []struct { name string args []string target string }{ - { - name: "full group members", - args: []string{"chat", "+chat-group-members", "--conversation-id", "cid-fixture", "--member-types", "user"}, - target: "chat +chat-members-list", - }, { name: "members", args: []string{"chat", "+members", "--group", "Fixture Group"}, @@ -151,6 +180,22 @@ func TestReviewedReadFallbacksResolveCanonicalLeafBeforeParameterValidation(t *t } } +func TestOfficialGroupMembersAliasBypassesCommandFallback(t *testing.T) { + args := []string{"chat", "+chat-group-members", "--conversation-id", "cid-fixture", "--member-types", "user"} + root := NewSchemaSourceRootCommand() + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetArgs(args) + ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args) + if err != nil { + t.Fatal(err) + } + if ctx == nil || ctx.Command != "dws chat +chat-members-list" { + t.Fatalf("official group-members alias context = %#v", ctx) + } + assertNoCommandPathFallbackCorrection(t, ctx) +} + func TestReviewedRenameFallbackResolvesCanonicalLeafBeforeParameterValidation(t *testing.T) { args := []string{"chat", "+rename-group", "--id", "cid-fixture", "--name", "Fixture Group"} root := NewSchemaSourceRootCommand() @@ -245,12 +290,12 @@ func TestRewrittenShortcutStillUsesCanonicalParameterErrors(t *testing.T) { }{ { name: "missing required query", - args: []string{"chat", "+search-group", "--dry-run"}, + args: []string{"chat", "+group-search", "--dry-run"}, want: "请至少指定 --query、--keyword 之一", }, { name: "unknown canonical flag", - args: []string{"chat", "+search-group", "--definitely-not-a-real-flag", "project"}, + args: []string{"chat", "+group-search", "--definitely-not-a-real-flag", "project"}, want: "unknown flag", }, } @@ -277,29 +322,26 @@ func TestRewrittenShortcutStillUsesCanonicalParameterErrors(t *testing.T) { func TestCommandFallbackNamesStayOutOfHelpSchemaAndShortcutCatalog(t *testing.T) { invalidShortcuts := map[string]bool{ - "+search-group": true, - "+group-search": true, - "+chat-group-search": true, - "+chat-group-members": true, - "+members": true, - "+group-member-list": true, - "+list-group-bots": true, - "+list-robot": true, - "+list-robots": true, - "+message-list": true, - "+read-single": true, - "+rename-group": true, - "+send": true, - "+send-by-bot": true, - "+send-dm": true, - "+send-message": true, - "+send-single": true, - "+send-text": true, - "+send-to": true, - "+send-file": true, - "+send-image": true, - "+send-media": true, - "+group-send-text": true, + "+group-search": true, + "+members": true, + "+group-member-list": true, + "+list-group-bots": true, + "+list-robot": true, + "+list-robots": true, + "+message-list": true, + "+read-single": true, + "+rename-group": true, + "+send": true, + "+send-by-bot": true, + "+send-dm": true, + "+send-message": true, + "+send-single": true, + "+send-text": true, + "+send-to": true, + "+send-file": true, + "+send-image": true, + "+send-media": true, + "+group-send-text": true, } root := NewSchemaSourceRootCommand() chat := exactAppCommand(root, "chat") @@ -336,9 +378,9 @@ func TestCommandFallbackNamesStayOutOfHelpSchemaAndShortcutCatalog(t *testing.T) } group := exactAppCommand(root, "chat group") - searchHint := exactAppCommand(root, "chat group search") - if group == nil || searchHint == nil || !searchHint.Hidden || !cmdutil.IsHintOnlyCommand(searchHint) { - t.Fatalf("chat group search must remain a hidden hint-only node: group=%v search=%v", group, searchHint) + searchCompatibility := exactAppCommand(root, "chat group search") + if group == nil || searchCompatibility == nil || !searchCompatibility.Hidden || cmdutil.IsHintOnlyCommand(searchCompatibility) { + t.Fatalf("chat group search must be a hidden executable compatibility leaf: group=%v search=%v", group, searchCompatibility) } var help bytes.Buffer group.SetOut(&help) @@ -350,6 +392,15 @@ func TestCommandFallbackNamesStayOutOfHelpSchemaAndShortcutCatalog(t *testing.T) } } +func assertNoCommandPathFallbackCorrection(t *testing.T, ctx *pipeline.Context) { + t.Helper() + for _, correction := range ctx.Corrections { + if correction.Handler == "command-path-fallback" { + t.Fatalf("official command unexpectedly received fallback correction: %#v", ctx.Corrections) + } + } +} + func exactAppCommand(root *cobra.Command, path string) *cobra.Command { current := root parts := strings.Fields(path) diff --git a/internal/app/event_command.go b/internal/app/event_command.go index 8c1acba6..94fc6e10 100644 --- a/internal/app/event_command.go +++ b/internal/app/event_command.go @@ -99,6 +99,7 @@ func newEventCommand() *cobra.Command { RunE: func(c *cobra.Command, _ []string) error { return c.Help() }, } cmd.AddCommand( + newEventListenIMCommand(), newEventConsumeCommand(), newEventListCommand(), newEventSchemaCommand(), diff --git a/internal/app/event_listen_im.go b/internal/app/event_listen_im.go new file mode 100644 index 00000000..c99d0edd --- /dev/null +++ b/internal/app/event_listen_im.go @@ -0,0 +1,295 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package app + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" + "github.com/spf13/cobra" +) + +type listenIMOptions struct { + Kind string + Events []string + UserID string + OpenDingTalkID string + UserQuery string + ChatID string + ChatQuery string + QueryCSV string + MaxEvents int + Duration time.Duration + DryRun bool + ControlBaseURL string + StreamTicketMode string + StreamTicketURL string + StreamSourceID string +} + +type listenIMPlan struct { + EventKeys []string + UserID string + OpenDingTalkID string + GroupID string + ResolvedTargets []any +} + +type eventTargetReader struct{} + +func (eventTargetReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) { + text, err := helpers.CallMCPReadToolTextOnServer(product, tool, params) + if err != nil { + return nil, err + } + if strings.TrimSpace(text) == "" { + return map[string]any{}, nil + } + var data map[string]any + if err := json.Unmarshal([]byte(text), &data); err != nil { + return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err)) + } + return data, nil +} + +var eventListenIMReader = func() targetresolver.Reader { return eventTargetReader{} } + +func newEventListenIMCommand() *cobra.Command { + var opts listenIMOptions + cmd := &cobra.Command{ + Use: "+listen-im", + Short: "按 IM 意图解析目标并监听一个或多个个人消息事件", + Long: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey," + + "自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期。", + Args: cobra.NoArgs, + DisableAutoGenTag: true, + RunE: func(c *cobra.Command, _ []string) error { + plan, err := compileListenIMPlan(eventListenIMReader(), opts) + if err != nil { + return fmt.Errorf("event +listen-im: %w", err) + } + consumeOpts := personalConsumeOptions{ + EventKey: firstArg(plan.EventKeys), + EventKeys: plan.EventKeys, + Flatten: true, + UserID: plan.UserID, + OpenDingTalkID: plan.OpenDingTalkID, + GroupID: plan.GroupID, + QueryCSV: opts.QueryCSV, + ControlBaseURL: opts.ControlBaseURL, + StreamTicketMode: opts.StreamTicketMode, + StreamTicketURL: opts.StreamTicketURL, + StreamSourceID: opts.StreamSourceID, + Common: commonConsumeOptions{ + FormatRaw: "ndjson", + MaxEvents: opts.MaxEvents, + Duration: opts.Duration, + DryRun: opts.DryRun, + }, + } + return eventRunPersonalConsume(c, consumeOpts) + }, + } + f := cmd.Flags() + f.StringVar(&opts.Kind, "kind", "at-me", "监听意图: at-me|sender|group|all-direct|all-group") + f.StringSliceVar(&opts.Events, "events", []string{"message"}, "事件种类: message,reaction,read,recall") + f.StringVar(&opts.UserID, "user", "", "指定发送人/单聊对端 userId") + f.StringVar(&opts.OpenDingTalkID, "open-dingtalk-id", "", "指定发送人/单聊对端 openDingTalkId") + f.StringVar(&opts.UserQuery, "user-query", "", "按姓名/花名唯一解析指定发送人") + f.StringVar(&opts.ChatID, "chat-id", "", "指定群 openConversationId") + f.StringVar(&opts.ChatQuery, "chat-query", "", "按群名唯一解析指定群") + f.StringVar(&opts.QueryCSV, "query", "", "消息文本关键词过滤,逗号分隔;仅 message 事件") + f.IntVar(&opts.MaxEvents, "max-events", 0, "收到 N 条后退出 (0 = 不限)") + f.DurationVar(&opts.Duration, "duration", 0, "运行时长上限 (Go duration,如 30s/5m;0 = 不限)") + f.BoolVar(&opts.DryRun, "dry-run", false, "解析目标并打印订阅计划,不创建订阅或连接 bus") + f.StringVar(&opts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base 派生 /dws") + f.StringVar(&opts.StreamTicketMode, "stream-ticket-mode", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_MODE")), "个人 Stream 建联模式;默认 normal") + f.StringVar(&opts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")), "个人 Stream sourceId;开源版默认 open") + f.StringVar(&opts.StreamTicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")), "个人 Stream 取票 URL") + hideEventInternalFlags(cmd, "personal-event-base-url", "stream-ticket-mode", "stream-source-id", "stream-ticket-url") + cli.AnnotateRuntimeFlagEnum(cmd, "kind", "at-me", "sender", "group", "all-direct", "all-group") + cli.AnnotateRuntimeFlagEnum(cmd, "events", "message", "reaction", "read", "recall") + cli.AnnotateRuntimeConstraints(cmd, cli.RuntimeSchemaConstraints{ + MutuallyExclusive: [][]string{{"user", "open-dingtalk-id", "user-query", "chat-id", "chat-query"}}, + }) + helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: helpers.LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "event", + Name: "listen_im", + CanonicalPath: "event.listen_im", + CLIPath: "event +listen-im", + PrimaryCLIPath: "event +listen-im", + }, + Description: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey,自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期。", + Interface: &contract.InterfaceSpec{ + Mode: "composite", + Availability: "available", + 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 消息事件", + UseWhen: []string{ + "已知要监听 @我、指定发送人、指定群、全部单聊或全部群聊的 message/reaction/read/recall 事件时使用;姓名用 --user-query、群名用 --chat-query,CLI 会唯一解析目标并把多个兼容事件合并到一个消费生命周期。", + }, + AvoidWhen: []string{ + "需要群标题/成员/解散等生命周期事件、显式 EventKey、复用 subscribe_id、Filter DSL、原始 transport envelope 或其它底层 consume 控制时使用 event consume;只查历史消息时使用 chat 查询入口", + }, + Examples: []string{ + "dws event +listen-im --kind at-me --max-events 1", + "dws event +listen-im --kind group --events message,reaction --chat-id --duration 10m", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "chat-id", Property: "chatId"}, + {Name: "chat-query", Property: "chatQuery"}, + {Name: "dry-run", Property: "dryRun"}, + {Name: "duration", Property: "duration"}, + {Name: "events", Property: "events"}, + {Name: "kind", Property: "kind"}, + {Name: "max-events", Property: "maxEvents"}, + {Name: "open-dingtalk-id", Property: "openDingtalkId"}, + {Name: "query", Property: "query"}, + {Name: "user", Property: "user"}, + {Name: "user-query", Property: "userQuery"}, + }, + }, + }) + return cmd +} + +func compileListenIMPlan(reader targetresolver.Reader, opts listenIMOptions) (listenIMPlan, error) { + kind := strings.ToLower(strings.TrimSpace(opts.Kind)) + if kind == "" { + kind = "at-me" + } + events := uniqueListenIMValues(opts.Events) + if len(events) == 0 { + return listenIMPlan{}, apperrors.NewValidation("--events 至少包含一个事件种类") + } + if strings.TrimSpace(opts.QueryCSV) != "" { + for _, eventName := range events { + if eventName != "message" { + return listenIMPlan{}, apperrors.NewValidation("--query 只支持 message 事件") + } + } + } + + plan := listenIMPlan{} + var err error + switch kind { + case "at-me", "all-direct", "all-group": + if listenIMTargetCount(opts) != 0 { + return listenIMPlan{}, apperrors.NewValidation(fmt.Sprintf("--kind %s 不接受用户或群目标", kind)) + } + case "sender": + if listenIMUserTargetCount(opts) != 1 || listenIMChatTargetCount(opts) != 0 { + return listenIMPlan{}, apperrors.NewValidation("--kind sender 必须且只能指定 --user、--open-dingtalk-id 或 --user-query 之一") + } + plan.UserID = strings.TrimSpace(opts.UserID) + plan.OpenDingTalkID = strings.TrimSpace(opts.OpenDingTalkID) + if query := strings.TrimSpace(opts.UserQuery); query != "" { + resolved, resolveErr := targetresolver.ResolveUser(reader, query, targetresolver.IdentityAny) + if resolveErr != nil { + return listenIMPlan{}, resolveErr + } + plan.ResolvedTargets = append(plan.ResolvedTargets, resolved) + plan.UserID = resolved.Selected.UserID + if plan.UserID == "" { + plan.OpenDingTalkID = resolved.Selected.OpenDingTalkID + } + } + case "group": + if listenIMChatTargetCount(opts) != 1 || listenIMUserTargetCount(opts) != 0 { + return listenIMPlan{}, apperrors.NewValidation("--kind group 必须且只能指定 --chat-id 或 --chat-query 之一") + } + plan.GroupID = strings.TrimSpace(opts.ChatID) + if query := strings.TrimSpace(opts.ChatQuery); query != "" { + resolved, resolveErr := targetresolver.ResolveChat(reader, query) + if resolveErr != nil { + return listenIMPlan{}, resolveErr + } + plan.ResolvedTargets = append(plan.ResolvedTargets, resolved) + plan.GroupID = resolved.Selected.OpenConversationID + } + default: + return listenIMPlan{}, apperrors.NewValidation("--kind 必须是 at-me、sender、group、all-direct 或 all-group") + } + + plan.EventKeys, err = listenIMEventKeys(kind, events) + if err != nil { + return listenIMPlan{}, err + } + return plan, nil +} + +func listenIMEventKeys(kind string, events []string) ([]string, error) { + mapping := map[string]map[string]string{ + "at-me": {"message": personal.EventMention}, + "sender": {"message": personal.EventFromUser, "reaction": personal.EventReactionO2O, "read": personal.EventReadO2O, "recall": personal.EventRecallO2O}, + "group": {"message": personal.EventInChat, "reaction": personal.EventReactionGroup, "read": personal.EventReadGroup, "recall": personal.EventRecallGroup}, + "all-direct": {"message": personal.EventAllSingleChat}, + "all-group": {"message": personal.EventAllGroupChat}, + } + byEvent := mapping[kind] + keys := make([]string, 0, len(events)) + for _, eventName := range events { + key := byEvent[eventName] + if key == "" { + return nil, apperrors.NewValidation(fmt.Sprintf("--kind %s 不支持 event %s", kind, eventName)) + } + keys = append(keys, key) + } + return keys, nil +} + +func listenIMUserTargetCount(opts listenIMOptions) int { + return nonEmptyListenIMCount(opts.UserID, opts.OpenDingTalkID, opts.UserQuery) +} + +func listenIMChatTargetCount(opts listenIMOptions) int { + return nonEmptyListenIMCount(opts.ChatID, opts.ChatQuery) +} + +func listenIMTargetCount(opts listenIMOptions) int { + return listenIMUserTargetCount(opts) + listenIMChatTargetCount(opts) +} + +func nonEmptyListenIMCount(values ...string) int { + count := 0 + for _, value := range values { + if strings.TrimSpace(value) != "" { + count++ + } + } + return count +} + +func uniqueListenIMValues(values []string) []string { + out := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" || seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + return out +} diff --git a/internal/app/event_listen_im_test.go b/internal/app/event_listen_im_test.go new file mode 100644 index 00000000..c82c70f7 --- /dev/null +++ b/internal/app/event_listen_im_test.go @@ -0,0 +1,360 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package app + +import ( + "bytes" + "context" + "errors" + "fmt" + "reflect" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "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/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type listenIMFakeReader struct { + responses map[string]map[string]any + calls []string +} + +type listenIMErrorReader struct{ err error } + +func (r listenIMErrorReader) CallMCPData(string, string, map[string]any) (map[string]any, error) { + return nil, r.err +} + +type listenIMHelperCaller struct { + text string + err error +} + +func (c listenIMHelperCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) { + return c.result() +} + +func (c listenIMHelperCaller) CallReadTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) { + return c.result() +} + +func (c listenIMHelperCaller) result() (*edition.ToolResult, error) { + if c.err != nil { + return nil, c.err + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: c.text}}}, nil +} + +func (listenIMHelperCaller) Format() string { return "json" } +func (listenIMHelperCaller) DryRun() bool { return false } +func (listenIMHelperCaller) Fields() string { return "" } +func (listenIMHelperCaller) JQ() string { return "" } + +func (f *listenIMFakeReader) CallMCPData(product, tool string, _ map[string]any) (map[string]any, error) { + key := product + "/" + tool + f.calls = append(f.calls, key) + if response, ok := f.responses[key]; ok { + return response, nil + } + return map[string]any{}, nil +} + +func TestCrossPlatformCoverageCompileListenIMPlanResolvesGroupAndMapsMultipleEvents(t *testing.T) { + reader := &listenIMFakeReader{responses: map[string]map[string]any{ + "im/search_groups": { + "result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}}, + }, + }} + plan, err := compileListenIMPlan(reader, listenIMOptions{ + Kind: "group", + Events: []string{"message", "reaction", "recall"}, + ChatQuery: "项目群", + }) + if err != nil { + t.Fatal(err) + } + wantKeys := []string{personal.EventInChat, personal.EventReactionGroup, personal.EventRecallGroup} + if !reflect.DeepEqual(plan.EventKeys, wantKeys) || plan.GroupID != "cid-1" { + t.Fatalf("plan = %#v, want keys=%v group=cid-1", plan, wantKeys) + } + if !reflect.DeepEqual(reader.calls, []string{"im/search_groups"}) { + t.Fatalf("resolver calls = %#v", reader.calls) + } +} + +func TestCrossPlatformCoverageCompileListenIMPlanReturnsStructuredAmbiguityBeforeSubscription(t *testing.T) { + reader := &listenIMFakeReader{responses: map[string]map[string]any{ + "contact/search_contact_by_key_word": { + "result": []any{ + map[string]any{"name": "张三", "userId": "u1"}, + map[string]any{"name": "张三", "userId": "u2"}, + }, + }, + }} + _, err := compileListenIMPlan(reader, listenIMOptions{ + Kind: "sender", + Events: []string{"message"}, + UserQuery: "张三", + }) + if err == nil { + t.Fatal("ambiguous sender unexpectedly compiled") + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "resolution_ambiguous" { + t.Fatalf("ambiguity error = %#v", err) + } +} + +func TestCrossPlatformCoverageEventListenIMCommandDelegatesOneCompiledConsumeLifecycle(t *testing.T) { + reader := &listenIMFakeReader{responses: map[string]map[string]any{ + "im/search_groups": { + "result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}}, + }, + }} + oldReader := eventListenIMReader + oldRun := eventRunPersonalConsume + t.Cleanup(func() { + eventListenIMReader = oldReader + eventRunPersonalConsume = oldRun + }) + eventListenIMReader = func() targetresolver.Reader { return reader } + var captured personalConsumeOptions + var calls int + eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error { + calls++ + captured = opts + return nil + } + + cmd := newEventListenIMCommand() + cmd.SetArgs([]string{ + "--kind", "group", + "--events", "message,reaction", + "--chat-query", "项目群", + "--max-events", "2", + "--duration", "30s", + "--dry-run", + }) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if calls != 1 { + t.Fatalf("consume lifecycle calls = %d, want 1", calls) + } + if !reflect.DeepEqual(captured.EventKeys, []string{personal.EventInChat, personal.EventReactionGroup}) || + captured.GroupID != "cid-1" || !captured.Flatten || !captured.Common.DryRun || + captured.Common.MaxEvents != 2 || captured.Common.Duration.String() != "30s" { + t.Fatalf("captured options = %#v", captured) + } +} + +func TestCrossPlatformCoverageCompileListenIMPlanRejectsIncompatibleKindAndTargets(t *testing.T) { + reader := &listenIMFakeReader{} + cases := []listenIMOptions{ + {Kind: "at-me", Events: []string{"reaction"}}, + {Kind: "all-group", Events: []string{"message"}, ChatID: "cid"}, + {Kind: "sender", Events: []string{"message"}}, + {Kind: "group", Events: []string{"message"}, ChatID: "cid", ChatQuery: "群"}, + {Kind: "group", Events: []string{"message", "reaction"}, ChatID: "cid", QueryCSV: "关键词"}, + } + for _, opts := range cases { + if _, err := compileListenIMPlan(reader, opts); err == nil { + t.Errorf("options unexpectedly accepted: %#v", opts) + } + } +} + +func TestCrossPlatformCoverageListenIMCompletionBranches(t *testing.T) { + for _, tc := range []struct { + name string + text string + err error + ok bool + }{ + {name: "transport", err: errors.New("transport")}, + {name: "empty", text: " ", ok: true}, + {name: "invalid json", text: "{invalid"}, + {name: "valid", text: `{"result":{"ok":true}}`, ok: true}, + } { + t.Run("reader "+tc.name, func(t *testing.T) { + helpers.InitDeps(listenIMHelperCaller{text: tc.text, err: tc.err}) + data, err := (eventTargetReader{}).CallMCPData("im", "search_groups", nil) + if (err == nil) != tc.ok { + t.Fatalf("data=%#v error=%v ok=%v", data, err, tc.ok) + } + }) + } + + if plan, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Events: []string{" MESSAGE ", "message"}}); err != nil || len(plan.EventKeys) != 1 { + t.Fatalf("default/deduplicated plan = %#v, %v", plan, err) + } + if _, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Kind: "at-me"}); err == nil { + t.Fatal("empty event set unexpectedly accepted") + } + if _, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Kind: "unknown", Events: []string{"message"}}); err == nil { + t.Fatal("unknown kind unexpectedly accepted") + } + + reader := &listenIMFakeReader{responses: map[string]map[string]any{ + "contact/search_contact_by_key_word": { + "result": []any{map[string]any{"name": "甲", "openDingTalkId": "D-user"}}, + }, + }} + plan, err := compileListenIMPlan(reader, listenIMOptions{Kind: "sender", Events: []string{"message"}, UserQuery: "甲"}) + if err != nil || plan.UserID != "" || plan.OpenDingTalkID != "D-user" { + t.Fatalf("open-id sender plan = %#v, %v", plan, err) + } + wantErr := errors.New("resolution failed") + if _, err := compileListenIMPlan(listenIMErrorReader{err: wantErr}, listenIMOptions{Kind: "sender", Events: []string{"message"}, UserQuery: "甲"}); !errors.Is(err, wantErr) { + t.Fatalf("sender resolution error = %v", err) + } + if _, err := compileListenIMPlan(listenIMErrorReader{err: wantErr}, listenIMOptions{Kind: "group", Events: []string{"message"}, ChatQuery: "群"}); !errors.Is(err, wantErr) { + t.Fatalf("group resolution error = %v", err) + } + + cmd := newEventListenIMCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{"--kind", "sender"}) + if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "event +listen-im") { + t.Fatalf("command compile error = %v", err) + } +} + +func TestCrossPlatformCoverageEventListenIME2ELifecycleCleansAndRollsBack(t *testing.T) { + newReader := func() *listenIMFakeReader { + return &listenIMFakeReader{responses: map[string]map[string]any{ + "im/search_groups": { + "result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}}, + }, + }} + } + installFacade := func(t *testing.T, reader *listenIMFakeReader) { + t.Helper() + oldReader := eventListenIMReader + oldRun := eventRunPersonalConsume + t.Cleanup(func() { + eventListenIMReader = oldReader + eventRunPersonalConsume = oldRun + }) + eventListenIMReader = func() targetresolver.Reader { return reader } + eventRunPersonalConsume = runPersonalEventConsume + } + installLifecycle := func(t *testing.T) { + t.Helper() + 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", CorpID: "corp", UserID: "user", + ClientID: "client", SourceID: "open", + }, nil + } + personalUpsertRunState = func(string, personal.RunState) error { return nil } + personalValidateConsumeConfig = func(consume.Config) error { return nil } + personalValidateNoOutputConflict = func(consume.Config, string) error { return nil } + } + + t.Run("ready then clean every created subscription", func(t *testing.T) { + reader := newReader() + installFacade(t, reader) + installLifecycle(t) + var created, deleted, removed []string + personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) { + created = append(created, opts.EventKey) + return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}, opts.EventKey, "group", nil + } + personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error { + deleted = append(deleted, id) + return nil + } + personalRemoveRunStates = func(_ string, ids []string) error { + removed = append(removed, ids...) + return nil + } + personalConsumeRunMany = func(_ context.Context, cfg consume.Config, specs []consume.ConsumerSpec) error { + if len(specs) != 2 || !cfg.Flatten { + t.Fatalf("consume specs/config = %#v / %#v", specs, cfg) + } + fmt.Fprintf(cfg.Stderr, "[event] ready event_count=%d bus_pid=123\n", len(specs)) + return nil + } + + cmd := newEventListenIMCommand() + var stderr bytes.Buffer + cmd.SetErr(&stderr) + cmd.SetArgs([]string{ + "--kind", "group", "--events", "message,reaction", + "--chat-query", "项目群", "--max-events", "1", + }) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + wantEvents := []string{personal.EventInChat, personal.EventReactionGroup} + if !reflect.DeepEqual(created, wantEvents) { + t.Fatalf("created = %#v, want %#v", created, wantEvents) + } + wantDeleted := []string{"sub-" + personal.EventReactionGroup, "sub-" + personal.EventInChat} + if !reflect.DeepEqual(deleted, wantDeleted) || !reflect.DeepEqual(removed, wantDeleted) { + t.Fatalf("deleted=%#v removed=%#v want=%#v", deleted, removed, wantDeleted) + } + if !strings.Contains(stderr.String(), "[event] ready event_count=2") { + t.Fatalf("missing ready marker: %s", stderr.String()) + } + if !reflect.DeepEqual(reader.calls, []string{"im/search_groups"}) { + t.Fatalf("resolver calls = %#v", reader.calls) + } + }) + + t.Run("second create failure rolls back first without starting consumer", func(t *testing.T) { + reader := newReader() + installFacade(t, reader) + installLifecycle(t) + wantErr := errors.New("second subscription failed") + calls := 0 + personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) { + calls++ + if calls == 2 { + return nil, "", "", wantErr + } + return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "group", nil + } + var deleted, removed []string + personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error { + deleted = append(deleted, id) + return nil + } + personalRemoveRunStates = func(_ string, ids []string) error { + removed = append(removed, ids...) + return nil + } + personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error { + t.Fatal("consumer started after partial subscription failure") + return nil + } + + cmd := newEventListenIMCommand() + var stderr bytes.Buffer + cmd.SetErr(&stderr) + cmd.SilenceUsage = true + cmd.SetArgs([]string{ + "--kind", "group", "--events", "message,reaction", + "--chat-query", "项目群", + }) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), wantErr.Error()) { + t.Fatalf("error = %v, want %v", err, wantErr) + } + if !reflect.DeepEqual(deleted, []string{"sub-first"}) || !reflect.DeepEqual(removed, []string{"sub-first"}) { + t.Fatalf("rollback deleted=%#v removed=%#v", deleted, removed) + } + }) +} diff --git a/internal/app/event_registration_test.go b/internal/app/event_registration_test.go index f70c9d47..a92c68ca 100644 --- a/internal/app/event_registration_test.go +++ b/internal/app/event_registration_test.go @@ -10,7 +10,7 @@ import ( "github.com/spf13/cobra" ) -func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) { +func TestCrossPlatformCoverageEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) { root := &cobra.Command{Use: "dws"} event := newEventCommand() markdown := &cobra.Command{Use: "markdown"} @@ -37,7 +37,7 @@ func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) { leaves = append(leaves, command.Name()) } sort.Strings(leaves) - want := []string{"consume", "list", "schema", "status", "stop"} + want := []string{"+listen-im", "consume", "list", "schema", "status", "stop"} if len(leaves) != len(want) { t.Fatalf("public event leaves = %v, want %v", leaves, want) } @@ -48,7 +48,7 @@ func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) { } } -func TestPluginCannotReplaceBuiltInEventCommand(t *testing.T) { +func TestCrossPlatformCoveragePluginCannotReplaceBuiltInEventCommand(t *testing.T) { root := &cobra.Command{Use: "dws"} builtIn := newEventCommand() root.AddCommand(builtIn) diff --git a/internal/app/param_alias_payload_equivalence_test.go b/internal/app/param_alias_payload_equivalence_test.go index 9c5e0098..151fe123 100644 --- a/internal/app/param_alias_payload_equivalence_test.go +++ b/internal/app/param_alias_payload_equivalence_test.go @@ -29,6 +29,7 @@ var paramAliasCompleteCommands = map[string][]string{ "attendance check result": {"attendance", "check", "result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"}, "attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"}, "calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"}, + "chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"}, "chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"}, "chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"}, "chat +category-create": {"chat", "+category-create", "--title", "Fixture Cat", "--yes"}, @@ -37,6 +38,7 @@ var paramAliasCompleteCommands = map[string][]string{ "chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"}, "chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"}, "chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"}, + "chat +search-msg": {"chat", "+search-msg", "--group", "fixture-conversation", "--query", "fixture", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--no-enrich"}, "chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--text", "hello fixture", "--yes"}, "chat +unread-chats": {"chat", "+unread-chats", "--count", "7", "--exclude-muted"}, "chat bot find": {"chat", "bot", "find", "--query", "fixture", "--limit", "7"}, @@ -146,6 +148,7 @@ var paramAliasNewIMCases = []struct { emitted string canonical string }{ + {command: "chat +chat-messages", emitted: "chat", canonical: "group"}, {command: "chat +bot-find", emitted: "name", canonical: "query"}, {command: "chat bot find", emitted: "name", canonical: "query"}, {command: "chat +bot-search", emitted: "query", canonical: "name"}, @@ -156,6 +159,7 @@ var paramAliasNewIMCases = []struct { {command: "chat +messages-list-unread-conversations", emitted: "limit", canonical: "count"}, {command: "chat +messages-list-unread-conversations", emitted: "size", canonical: "count"}, {command: "chat +messages-send-by-webhook", emitted: "at-user-ids", canonical: "at-users"}, + {command: "chat +search-msg", emitted: "chat", canonical: "group"}, {command: "chat +unread-chats", emitted: "limit", canonical: "count"}, {command: "chat +unread-chats", emitted: "size", canonical: "count"}, {command: "chat bot search", emitted: "query", canonical: "name"}, @@ -205,7 +209,7 @@ var paramAliasRepresentativePayloadCases = map[string]bool{ paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias } -func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) { +func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) { concepts, err := cli.LoadParamConcepts() if err != nil { t.Fatalf("LoadParamConcepts() error = %v", err) @@ -256,6 +260,7 @@ func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads if ctx == nil { t.Fatal("complete alias command skipped PreParse") } + normalizeParamAliasVolatileDefaults(fixture.Command, canonicalCaller, aliasCaller) if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) { t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls) } @@ -288,7 +293,7 @@ func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads } } -func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) { +func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) { activeAliases := 0 for _, test := range paramAliasNewIMCases { test := test @@ -328,6 +333,7 @@ func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) { if ctx == nil { t.Fatal("complete alias command skipped PreParse") } + normalizeParamAliasVolatileDefaults(test.command, canonicalCaller, aliasCaller) if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) { t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls) } @@ -338,6 +344,23 @@ func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) { } } +// +chat-messages supplies the current wall-clock time when callers omit +// --time. Alias equivalence concerns the resolved target and transport shape; +// a suite crossing a second boundary must not make that default appear +// alias-dependent. +func normalizeParamAliasVolatileDefaults(command string, callers ...*paramAliasCaptureCaller) { + if command != "chat +chat-messages" { + return + } + for _, caller := range callers { + for i := range caller.calls { + if caller.calls[i].tool == "list_conversation_message_v2" || caller.calls[i].tool == "list_individual_chat_message" { + delete(caller.calls[i].args, "time") + } + } + } +} + func paramAliasCompleteCommand(command, canonical string) ([]string, bool) { complete, ok := paramAliasCompleteCommands[command] if variants := paramAliasCompleteCommandVariants[command]; variants != nil { diff --git a/internal/app/runner.go b/internal/app/runner.go index a6ef75d4..3e9290c4 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -413,6 +413,33 @@ func multiProfileErrorPayload(err error) map[string]any { if typed.Operation != "" { payload["operation"] = typed.Operation } + if typed.Origin != "" { + payload["origin"] = typed.Origin + } + if typed.FailureStage != "" { + payload["stage"] = typed.FailureStage + } + if typed.ExecutionStarted != nil { + payload["execution_started"] = *typed.ExecutionStarted + } + if typed.RetryableSet { + payload["retryable"] = typed.Retryable + } + if typed.Hint != "" { + payload["hint"] = typed.Hint + } + if len(typed.Actions) > 0 { + payload["actions"] = append([]string(nil), typed.Actions...) + } + if len(typed.Details) > 0 { + payload["details"] = typed.Details + } + if typed.ServerDiag.TraceID != "" { + payload["trace_id"] = typed.ServerDiag.TraceID + } + if typed.ServerDiag.ServerErrorCode != "" { + payload["server_error_code"] = typed.ServerDiag.ServerErrorCode + } if code := typed.ExitCode(); code != 0 { payload["exitCode"] = code } @@ -678,7 +705,6 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, if callResult.IsError { diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content) - logBusinessError(r.transport.FileLogger, "mcp_tool_error", invocation, callResult.Content, diag) // ClassifyToolResult hook: let the overlay intercept known error // patterns (PAT permission, gateway-auth) before generic handling. @@ -695,14 +721,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, } } - mcpErr := apperrors.NewAPI( + mcpErr := newServerFailureAPIError( extractMCPErrorMessage(callResult), - apperrors.WithOperation("tools/call"), - apperrors.WithReason("mcp_tool_error"), - apperrors.WithServerKey(invocation.CanonicalProduct), - apperrors.WithHint("MCP tool returned a business error; check tool parameters and refer to skill documentation."), - apperrors.WithServerDiag(diag), + "mcp_tool_error", + "MCP tool returned a business error; check tool parameters and refer to skill documentation.", + invocation.CanonicalProduct, + diag, ) + logBusinessError(r.transport.FileLogger, serverFailureReason(mcpErr, "mcp_tool_error"), invocation, callResult.Content, diag) // PAT scope error in business response: offer human-readable output and retry if isPatScopeError(mcpErr) { scopeErr := extractPatScopeError(mcpErr) @@ -720,14 +746,15 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, if bizErr := detectBusinessError(callResult.Content); bizErr != "" { diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content) - logBusinessError(r.transport.FileLogger, "business_error", invocation, callResult.Content, diag) - return executor.Result{}, apperrors.NewAPI(bizErr, - apperrors.WithOperation("tools/call"), - apperrors.WithReason("business_error"), - apperrors.WithServerKey(invocation.CanonicalProduct), - apperrors.WithHint("The API returned a business-level error. Check required parameters and values."), - apperrors.WithServerDiag(diag), + classifiedErr := newServerFailureAPIError( + bizErr, + "business_error", + "The API returned a business-level error. Check required parameters and values.", + invocation.CanonicalProduct, + diag, ) + logBusinessError(r.transport.FileLogger, serverFailureReason(classifiedErr, "business_error"), invocation, callResult.Content, diag) + return executor.Result{}, classifiedErr } invocation.Implemented = true diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index 20cc7066..2f3a2149 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -19,9 +19,9 @@ const ( publicShortcutCount = 266 // schemaPublishedShortcutCount counts every delivered *.shortcut_* tool, // including hidden leaves such as minutes.shortcut_minutes_search. - schemaPublishedShortcutCount = 216 + schemaPublishedShortcutCount = 267 // publiclyDeliveredShortcutCount is the public-catalog subset of that surface. - publiclyDeliveredShortcutCount = 215 + publiclyDeliveredShortcutCount = 266 ) func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) { @@ -114,7 +114,7 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T) product := executeShortcutSchemaQuery(t, "chat") productPayload, _ := product["product"].(map[string]any) - if got, want := int(product["count"].(float64)), 129; got != want { + if got, want := int(product["count"].(float64)), 180; got != want { t.Fatalf("schema chat count = %d, want %d", got, want) } summaries := schemaContractObjectSlice(productPayload["tools"]) @@ -124,8 +124,8 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T) shortcutCount++ } } - if shortcutCount != 47 { - t.Fatalf("schema chat shortcut summaries = %d, want 47", shortcutCount) + if shortcutCount != 98 { + t.Fatalf("schema chat shortcut summaries = %d, want 98", shortcutCount) } } diff --git a/internal/app/server_failure_classifier.go b/internal/app/server_failure_classifier.go new file mode 100644 index 00000000..6ab9d8ea --- /dev/null +++ b/internal/app/server_failure_classifier.go @@ -0,0 +1,105 @@ +// 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 ( + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +type serverFailureClass struct { + message string + reason string + origin string + stage string + hint string + actions []string +} + +func classifyServerFailure(message string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) { + code := strings.ToUpper(strings.TrimSpace(diag.ServerErrorCode)) + detail := strings.ToLower(strings.TrimSpace(diag.TechnicalDetail)) + text := strings.ToLower(strings.TrimSpace(message)) + + if code == "NETWORK_ERROR" || + strings.Contains(detail, "statuscode.unavailable") || + strings.Contains(detail, "connection refused") { + classified := serverFailureClass{ + message: "MCP 后端依赖暂时不可用", + reason: "backend_dependency_unavailable", + origin: "mcp_gateway", + stage: "backend_dependency", + hint: "请求参数无需修改;请使用相同参数稍后重试。持续失败时请提供 Trace ID 排查 MCP 服务。", + actions: []string{ + "使用相同参数重试一次", + "持续失败时保留 Trace ID 并排查 MCP 后端依赖", + }, + } + if strings.Contains(detail, "querytoolmeta") { + classified.message = "MCP 后端元数据服务暂时不可用" + classified.stage = "tool_metadata_lookup" + } + return classified, true + } + + if code == "PARAM_ERROR" || + strings.Contains(text, "opencid or cid is required") || + strings.Contains(text, "openconversationid") && strings.Contains(text, "required") { + return serverFailureClass{ + message: message, + reason: "invalid_request", + origin: "dingtalk_api", + stage: "tool_validation", + hint: "请求未通过后端参数校验;请核对当前 leaf Help/Schema 和稳定 ID 类型后重试。", + }, true + } + + return serverFailureClass{}, false +} + +func newServerFailureAPIError( + message string, + fallbackReason string, + fallbackHint string, + serverKey string, + diag apperrors.ServerDiagnostics, +) error { + opts := []apperrors.Option{ + apperrors.WithOperation("tools/call"), + apperrors.WithReason(fallbackReason), + apperrors.WithServerKey(serverKey), + apperrors.WithHint(fallbackHint), + apperrors.WithServerDiag(diag), + } + if classified, ok := classifyServerFailure(message, diag); ok { + message = classified.message + opts = append(opts, + apperrors.WithReason(classified.reason), + apperrors.WithOrigin(classified.origin), + apperrors.WithFailureStage(classified.stage), + apperrors.WithHint(classified.hint), + apperrors.WithActions(classified.actions...), + ) + } + return apperrors.NewAPI(message, opts...) +} + +func serverFailureReason(err error, fallback string) string { + typed, ok := err.(*apperrors.Error) + if ok && strings.TrimSpace(typed.Reason) != "" { + return typed.Reason + } + return fallback +} diff --git a/internal/app/server_failure_classifier_test.go b/internal/app/server_failure_classifier_test.go new file mode 100644 index 00000000..489ecad0 --- /dev/null +++ b/internal/app/server_failure_classifier_test.go @@ -0,0 +1,223 @@ +// 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" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" +) + +func TestCrossPlatformCoverageServerFailureClassifierBackendMetadataUnavailable(t *testing.T) { + retryable := true + err := newServerFailureAPIError( + "business error: success=false", + "business_error", + "check parameters", + "im", + apperrors.ServerDiagnostics{ + TraceID: "trace-local", + ServerErrorCode: "NETWORK_ERROR", + TechnicalDetail: "调用 McpService.queryToolMeta 失败: status = StatusCode.UNAVAILABLE; connect: Connection refused (111)", + ServerRetryable: &retryable, + }, + ) + + var typed *apperrors.Error + if !errors.As(err, &typed) { + t.Fatalf("error = %T, want *errors.Error", err) + } + if typed.Reason != "backend_dependency_unavailable" || typed.Origin != "mcp_gateway" || typed.FailureStage != "tool_metadata_lookup" { + t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage) + } + if typed.ExecutionStarted != nil { + t.Fatalf("execution_started = %v, want unknown until the backend publishes it", typed.ExecutionStarted) + } + if !typed.RetryableSet || !typed.Retryable { + t.Fatalf("retryability = (%v, %v), want explicit true", typed.RetryableSet, typed.Retryable) + } + if strings.Contains(strings.ToLower(typed.Hint), "parameter") || strings.Contains(typed.Hint, "认证") { + t.Fatalf("misleading hint = %q", typed.Hint) + } +} + +func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *testing.T) { + err := newServerFailureAPIError( + "openCid or cid is required", + "business_error", + "check parameters", + "chat", + apperrors.ServerDiagnostics{ServerErrorCode: "1001"}, + ) + var typed *apperrors.Error + if !errors.As(err, &typed) { + t.Fatalf("error = %T, want *errors.Error", err) + } + if typed.Reason != "invalid_request" || typed.FailureStage != "tool_validation" { + t.Fatalf("classification = reason %q stage %q", typed.Reason, typed.FailureStage) + } + if typed.ExecutionStarted != nil { + t.Fatalf("execution_started = %v, want unknown until the backend publishes it", typed.ExecutionStarted) + } +} + +func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing.T) { + err := newServerFailureAPIError( + "business error: success=false", + "business_error", + "check parameters", + "im", + apperrors.ServerDiagnostics{}, + ) + var typed *apperrors.Error + if !errors.As(err, &typed) { + t.Fatalf("error = %T, want *errors.Error", err) + } + if typed.Reason != "business_error" || typed.Origin != "" || typed.FailureStage != "" || typed.ExecutionStarted != nil { + t.Fatalf("unexpected fallback classification: %#v", typed) + } +} + +func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *testing.T) { + err := newServerFailureAPIError( + "business error: success=false", + "business_error", + "check parameters", + "im", + apperrors.ServerDiagnostics{ServerErrorCode: "NETWORK_ERROR"}, + ) + if got := serverFailureReason(err, "business_error"); got != "backend_dependency_unavailable" { + t.Fatalf("reason = %q", got) + } + if got := serverFailureReason(errors.New("plain"), "fallback"); got != "fallback" { + t.Fatalf("fallback reason = %q", got) + } +} + +func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesFailureSemantics(t *testing.T) { + retryable := true + err := newServerFailureAPIError( + "business error: success=false", + "business_error", + "check parameters", + "im", + apperrors.ServerDiagnostics{ + TraceID: "trace-multi", + ServerErrorCode: "NETWORK_ERROR", + TechnicalDetail: "McpService.queryToolMeta: StatusCode.UNAVAILABLE", + ServerRetryable: &retryable, + }, + ) + payload := multiProfileErrorPayload(err) + for key, want := range map[string]any{ + "reason": "backend_dependency_unavailable", + "origin": "mcp_gateway", + "stage": "tool_metadata_lookup", + "retryable": true, + "trace_id": "trace-multi", + "server_error_code": "NETWORK_ERROR", + } { + if got := payload[key]; got != want { + t.Errorf("payload[%q] = %#v, want %#v", key, got, want) + } + } + if _, ok := payload["execution_started"]; ok { + t.Fatalf("payload must not invent execution_started: %#v", payload) + } +} + +func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesResolutionDetails(t *testing.T) { + err := apperrors.NewValidation( + "群目标不唯一", + apperrors.WithReason("resolution_ambiguous"), + apperrors.WithOrigin("client"), + apperrors.WithFailureStage("target_resolution"), + apperrors.WithExecutionStarted(false), + apperrors.WithHint("请选择候选"), + apperrors.WithActions("使用稳定 ID 重试"), + apperrors.WithDetails(map[string]any{ + "type": "resolution", + "candidates": []string{"cid-1", "cid-2"}, + }), + ) + payload := multiProfileErrorPayload(err) + details, ok := payload["details"].(map[string]any) + if !ok || details["type"] != "resolution" { + t.Fatalf("details = %#v", payload["details"]) + } + if payload["execution_started"] != false || payload["origin"] != "client" || payload["stage"] != "target_resolution" { + t.Fatalf("payload = %#v", payload) + } + if actions, ok := payload["actions"].([]string); !ok || len(actions) != 1 { + t.Fatalf("actions = %#v", payload["actions"]) + } +} + +func TestCrossPlatformCoverageExecuteInvocationClassifiesObservedMCPMetadataFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + ID int `json:"id"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Errorf("decode request: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": request.ID, + "result": map[string]any{ + "structuredContent": map[string]any{ + "success": false, + "code": "NETWORK_ERROR", + "trace_id": "trace-replay", + "technical_detail": "调用 McpService.queryToolMeta 失败: status = StatusCode.UNAVAILABLE; connect: Connection refused (111)", + "retryable": true, + }, + }, + }) + })) + defer server.Close() + + client := transport.NewClient(server.Client()) + client.TrustedDomains = []string{strings.TrimPrefix(server.URL, "http://")} + runner := &runtimeRunner{ + transport: client, + globalFlags: &GlobalFlags{Token: "local-test-token"}, + } + _, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{ + CanonicalProduct: "im", + Tool: "list_conversations", + Params: map[string]any{"pageSize": 100}, + }) + var typed *apperrors.Error + if !errors.As(err, &typed) { + t.Fatalf("executeInvocation() error = %T %v, want typed API error", err, err) + } + if typed.Reason != "backend_dependency_unavailable" || typed.Origin != "mcp_gateway" || typed.FailureStage != "tool_metadata_lookup" { + t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage) + } + if typed.ServerDiag.TraceID != "trace-replay" || !typed.RetryableSet || !typed.Retryable { + t.Fatalf("diagnostics = %#v retryable=(%v,%v)", typed.ServerDiag, typed.RetryableSet, typed.Retryable) + } + if typed.ExecutionStarted != nil { + t.Fatalf("execution_started must remain unknown: %v", typed.ExecutionStarted) + } +} diff --git a/internal/auth/profiles.go b/internal/auth/profiles.go index acf21020..589dd3c2 100644 --- a/internal/auth/profiles.go +++ b/internal/auth/profiles.go @@ -23,11 +23,11 @@ import ( "path/filepath" "sort" "strings" - "sync" "time" "github.com/google/uuid" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/profilectx" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) @@ -117,23 +117,14 @@ type Profile struct { UpdatedAt string `json:"updatedAt,omitempty"` } -var ( - runtimeProfileMu sync.RWMutex - runtimeProfile string -) - // SetRuntimeProfile sets a process-local one-shot profile override. func SetRuntimeProfile(profile string) { - runtimeProfileMu.Lock() - defer runtimeProfileMu.Unlock() - runtimeProfile = strings.TrimSpace(profile) + profilectx.Set(profile) } // RuntimeProfile returns the process-local one-shot profile override. func RuntimeProfile() string { - runtimeProfileMu.RLock() - defer runtimeProfileMu.RUnlock() - return runtimeProfile + return profilectx.Get() } // ProfilesPath returns the profile metadata path for a config dir. diff --git a/internal/cli/command_path_fallbacks.json b/internal/cli/command_path_fallbacks.json index 3a661704..83a0140d 100644 --- a/internal/cli/command_path_fallbacks.json +++ b/internal/cli/command_path_fallbacks.json @@ -2,13 +2,6 @@ "$schema": "./command_path_fallbacks.schema.json", "version": 1, "entries": [ - { - "from": "chat +search-group", - "mode": "rewrite", - "to": "chat +chat-search", - "reviewed": true, - "review_reason": "0803 evaluation badcase: the model emitted +search-group for group-name search; +chat-search is the exact reviewed shortcut and accepts the observed --keyword spelling." - }, { "from": "chat +group-search", "mode": "rewrite", @@ -16,20 +9,6 @@ "reviewed": true, "review_reason": "0803 evaluation badcase: the model emitted +group-search with --query; +chat-search provides the same group-name search operation." }, - { - "from": "chat +chat-group-search", - "mode": "rewrite", - "to": "chat +chat-search", - "reviewed": true, - "review_reason": "H12 evaluation badcase: +chat-group-search was invented while resolving a group; +chat-search is the unique equivalent shortcut." - }, - { - "from": "chat +chat-group-members", - "mode": "rewrite", - "to": "chat +chat-members-list", - "reviewed": true, - "review_reason": "H10/H19 evaluation badcases repeatedly emitted +chat-group-members; +chat-members-list is the reviewed full group-member shortcut." - }, { "from": "chat +members", "mode": "rewrite", @@ -218,13 +197,6 @@ ], "reviewed": true, "review_reason": "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf." - }, - { - "from": "chat group search", - "mode": "rewrite", - "to": "chat search", - "reviewed": true, - "review_reason": "0803 evaluation report observed this non-runnable hint path 60 times; chat search is the exact native group-name search leaf." } ] } diff --git a/internal/cli/command_path_fallbacks_generated.go b/internal/cli/command_path_fallbacks_generated.go index c574db11..0772f2f0 100644 --- a/internal/cli/command_path_fallbacks_generated.go +++ b/internal/cli/command_path_fallbacks_generated.go @@ -8,20 +8,6 @@ package cli var generatedCommandPathFallbacks = []CommandPathFallback{ - { - From: "chat +chat-group-members", - Mode: "rewrite", - To: "chat +chat-members-list", - Reviewed: true, - ReviewReason: "H10/H19 evaluation badcases repeatedly emitted +chat-group-members; +chat-members-list is the reviewed full group-member shortcut.", - }, - { - From: "chat +chat-group-search", - Mode: "rewrite", - To: "chat +chat-search", - Reviewed: true, - ReviewReason: "H12 evaluation badcase: +chat-group-search was invented while resolving a group; +chat-search is the unique equivalent shortcut.", - }, { From: "chat +group-member-list", Mode: "rewrite", @@ -92,13 +78,6 @@ var generatedCommandPathFallbacks = []CommandPathFallback{ Reviewed: true, ReviewReason: "20260728 evaluation emitted +rename-group for the unique group-name update intent; +chat-update is the reviewed shortcut with that exact command-level operation. Parameter compatibility remains the canonical target's responsibility.", }, - { - From: "chat +search-group", - Mode: "rewrite", - To: "chat +chat-search", - Reviewed: true, - ReviewReason: "0803 evaluation badcase: the model emitted +search-group for group-name search; +chat-search is the exact reviewed shortcut and accepts the observed --keyword spelling.", - }, { From: "chat +send", Mode: "ambiguous", @@ -169,13 +148,6 @@ var generatedCommandPathFallbacks = []CommandPathFallback{ Reviewed: true, ReviewReason: "20260728 evaluation emitted +send-to without proving whether the recipient denotes a user, group, or low-level identifier; the write operation must not choose a target workflow automatically.", }, - { - From: "chat group search", - Mode: "rewrite", - To: "chat search", - Reviewed: true, - ReviewReason: "0803 evaluation report observed this non-runnable hint path 60 times; chat search is the exact native group-name search leaf.", - }, { From: "oa +list-processes", Mode: "ambiguous", diff --git a/internal/cli/command_path_fallbacks_test.go b/internal/cli/command_path_fallbacks_test.go index 0360fd31..89ebb951 100644 --- a/internal/cli/command_path_fallbacks_test.go +++ b/internal/cli/command_path_fallbacks_test.go @@ -119,10 +119,10 @@ func TestCommandPathFallbackSchemaAndEmbeddedSource(t *testing.T) { t.Fatalf("schema header = %#v", schema) } entries, err := LoadCommandPathFallbacks() - if err != nil || len(entries) != 25 { + if err != nil || len(entries) != 21 { t.Fatalf("LoadCommandPathFallbacks() = %#v, %v", entries, err) } - if got, ok := LookupCommandPathFallback("dws chat +search-group"); !ok || got.To != "chat +chat-search" { + if got, ok := LookupCommandPathFallback("dws chat +group-search"); !ok || got.To != "chat +chat-search" { t.Fatalf("generated lookup = %#v, %v", got, ok) } if _, ok := LookupCommandPathFallback("chat +missing"); ok { @@ -132,17 +132,13 @@ func TestCommandPathFallbackSchemaAndEmbeddedSource(t *testing.T) { func TestCommandPathFallbackAuditCoverage(t *testing.T) { rewrites := map[string]string{ - "chat +search-group": "chat +chat-search", - "chat +group-search": "chat +chat-search", - "chat +chat-group-search": "chat +chat-search", - "chat +chat-group-members": "chat +chat-members-list", - "chat +members": "chat +group-members", - "chat +group-member-list": "chat +group-members", - "chat +list-group-bots": "chat +chat-bots", - "chat +list-robot": "chat +chat-bots", - "chat +list-robots": "chat +chat-bots", - "chat +rename-group": "chat +chat-update", - "chat group search": "chat search", + "chat +group-search": "chat +chat-search", + "chat +members": "chat +group-members", + "chat +group-member-list": "chat +group-members", + "chat +list-group-bots": "chat +chat-bots", + "chat +list-robot": "chat +chat-bots", + "chat +list-robots": "chat +chat-bots", + "chat +rename-group": "chat +chat-update", } for from, to := range rewrites { entry, ok := LookupCommandPathFallback(from) diff --git a/internal/cli/param_aliases_generated.go b/internal/cli/param_aliases_generated.go index 3ed11114..6bd810ce 100644 --- a/internal/cli/param_aliases_generated.go +++ b/internal/cli/param_aliases_generated.go @@ -158,6 +158,12 @@ var generatedParamAliases = []ParamAliasEntry{ }, Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, }, + { + CLIPath: "chat +chat-messages", + Aliases: map[string]string{ + "chat": "group", + }, + }, { CLIPath: "chat +chat-mute", Aliases: map[string]string{ @@ -336,6 +342,12 @@ var generatedParamAliases = []ParamAliasEntry{ "at-user-ids": "at-users", }, }, + { + CLIPath: "chat +search-msg", + Aliases: map[string]string{ + "chat": "group", + }, + }, { CLIPath: "chat +send-to-group", Aliases: map[string]string{ diff --git a/internal/cli/param_concepts.json b/internal/cli/param_concepts.json index 47dddadd..d53088e3 100644 --- a/internal/cli/param_concepts.json +++ b/internal/cli/param_concepts.json @@ -60,6 +60,8 @@ "mail message search": {"scoped_aliases": {"subject": "query"}, "scope_strict": true, "note": "never globalize: mail template create has a real and different --subject"}, "calendar event list": {"scoped_aliases": {"date": "start"}, "note": "reviewed against ParseISOTimeToMillis and final list_calendar_events payload; --date is normalized centrally while the command's existing hidden compatibility flags remain native fallbacks"}, "chat +bot-find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search shortcut, --name and --query denote the same search keyword; --name must not become a global search alias."}, + "chat +chat-messages": {"scoped_aliases": {"chat": "group"}, "scope_strict": true, "note": "Evaluation compatibility: --chat carries one stable openConversationId and normalizes to the existing --group identifier route."}, + "chat +search-msg": {"scoped_aliases": {"chat": "group"}, "scope_strict": true, "note": "Evaluation compatibility: scalar --chat carries one stable openConversationId and normalizes to the existing scalar --group filter."}, "chat bot find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search command, --name and --query denote the same search keyword; --name must not become a global search alias."}, "chat +bot-search": {"scoped_aliases": {"query": "name", "current-page": "page"}, "block": ["cursor"], "scope_strict": true, "note": "Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."}, "chat bot search": {"scoped_aliases": {"query": "name", "current-page": "page"}, "block": ["cursor"], "scope_strict": true, "note": "Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."}, @@ -307,7 +309,9 @@ {"command": "doc comment create-inline", "emitted": "body", "expect": "content", "via": "concept:content_text"}, {"command": "doc comment delete", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"}, {"command": "doc comment update", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"}, - {"command": "doc version revert", "emitted": "version-no", "expect": "version", "via": "concept:doc_version_number"} + {"command": "doc version revert", "emitted": "version-no", "expect": "version", "via": "concept:doc_version_number"}, + {"command": "chat +chat-messages", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"}, + {"command": "chat +search-msg", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"} ] } } diff --git a/internal/cli/runtime_schema.go b/internal/cli/runtime_schema.go index e433e37b..8680d87a 100644 --- a/internal/cli/runtime_schema.go +++ b/internal/cli/runtime_schema.go @@ -1142,6 +1142,12 @@ func inferredRuntimeFlagFormat(flag *pflag.Flag) string { } usage := strings.ToLower(strings.TrimSpace(flag.Usage)) if strings.Contains(usage, "iso-8601") || strings.Contains(usage, "rfc3339") { + // JSON Schema's date-time format means one RFC3339 value. Do not publish + // that narrower wire contract when the CLI also accepts local timestamps + // or date-only values alongside RFC3339. + if strings.Contains(usage, "yyyy-mm-dd") { + return "" + } return "date-time" } if strings.Contains(usage, "a1") { diff --git a/internal/cli/schema_command_exclusions.go b/internal/cli/schema_command_exclusions.go index a4cf009f..6720289c 100644 --- a/internal/cli/schema_command_exclusions.go +++ b/internal/cli/schema_command_exclusions.go @@ -171,62 +171,4 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{ "todo task remove-attachment", }, }, - { - ID: "chat-shortcuts-pending-schema-curation", - Reason: "These reviewed public Chat shortcuts remain executable and discoverable through the Shortcut catalog while their typed Schema selection and metadata records are curated; exact paths keep reverse completeness strict without hiding future shortcuts.", - Reviewed: true, - Commands: []string{ - "chat +category-add-conversation", - "chat +category-list-conversations", - "chat +category-remove-conversation", - "chat +chat-add-bot", - "chat +chat-audit-join", - "chat +chat-create", - "chat +chat-list", - "chat +chat-get-by-id", - "chat +chat-members-get", - "chat +chat-members-list", - "chat +chat-mute-member", - "chat +chat-quit", - "chat +chat-remove-bot", - "chat +chat-role-remove", - "chat +chat-role-remove-user", - "chat +chat-transfer-owner", - "chat +chat-update", - "chat +chat-update-icon", - "chat +chat-update-settings", - "chat +conversation-clear-messages", - "chat +conversation-clear-red-point", - "chat +conversation-hide", - "chat +conversation-mark-read", - "chat +conversation-mark-unread", - "chat +conversation-mute", - "chat +conversation-set-top", - "chat +feed-group-query-item", - "chat +flag-cancel", - "chat +flag-create", - "chat +flag-list", - "chat +messages-add-emoji", - "chat +messages-add-text-emotion", - "chat +messages-batch-recall-by-bot", - "chat +messages-batch-send-by-bot", - "chat +messages-combine-forward", - "chat +messages-create-text-emotion", - "chat +messages-forward", - "chat +messages-forward-topic", - "chat +messages-list", - "chat +messages-recall", - "chat +messages-recall-by-bot", - "chat +messages-remove-emoji", - "chat +messages-remove-text-emotion", - "chat +messages-reply", - "chat +messages-resource-download", - "chat +messages-resource-url", - "chat +messages-send-by-bot", - "chat +messages-set-pin", - "chat +messages-set-top", - "chat +messages-unset-pin", - "chat +messages-unset-top", - }, - }, } diff --git a/internal/corecmd/corecmd.go b/internal/corecmd/corecmd.go index 182b3e9a..56fc1b1c 100644 --- a/internal/corecmd/corecmd.go +++ b/internal/corecmd/corecmd.go @@ -103,11 +103,12 @@ const ValidationShortcut FlagValidationMode = "shortcut" // fields intentionally mirror the former helpers.LeafFlag one-for-one so that // helpers can alias to it without touching any call site. type FlagSpec struct { - Name string // flag name (kebab-case) - Usage string // registration usage text - Kind FlagKind // value type, defaults to KindString - Default string // registration default for every Kind; also the fallback-chain tail when aliases/env are empty - Hidden bool // hide the real flag from help/Schema while keeping it invocable + Name string // flag name (kebab-case) + Shorthand string // optional one-character Cobra shorthand + Usage string // registration usage text + Kind FlagKind // value type, defaults to KindString + Default string // registration default for every Kind; also the fallback-chain tail when aliases/env are empty + Hidden bool // hide the real flag from help/Schema while keeping it invocable // Required, when true, validates a non-empty effective value in RunE. Plain // Required flags aggregate into a cmdutil.ValidateRequiredFlags-compatible @@ -551,7 +552,7 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) { "flag %q: MarkRequired cannot be combined with Aliases: cobra MarkFlagRequired only recognizes the main name, so a value passed via an alias would be rejected", flag.Name)) } - RegisterFlag(cmd, flag.Kind, flag.Name, flag.Default, flag.Usage) + registerFlagP(cmd, flag.Kind, flag.Name, flag.Shorthand, flag.Default, flag.Usage) // Aliases are registered with the main flag's Kind, otherwise an integer // alias's value would never be readable (silently dropped). for _, alias := range flag.Aliases { @@ -572,6 +573,10 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) { // Malformed KindInt / KindBool Default values panic at registration (fail-closed) // instead of silently degrading to 0 / false. func RegisterFlag(cmd *cobra.Command, kind FlagKind, name, def, usage string) { + registerFlagP(cmd, kind, name, "", def, usage) +} + +func registerFlagP(cmd *cobra.Command, kind FlagKind, name, shorthand, def, usage string) { switch kind { case KindInt: defInt := 0 @@ -582,7 +587,7 @@ func RegisterFlag(cmd *cobra.Command, kind FlagKind, name, def, usage string) { } defInt = v } - cmd.Flags().Int(name, defInt, usage) + cmd.Flags().IntP(name, shorthand, defInt, usage) case KindBool: defBool := false if def != "" { @@ -595,15 +600,15 @@ func RegisterFlag(cmd *cobra.Command, kind FlagKind, name, def, usage string) { panic(fmt.Sprintf("flag %q: invalid KindBool Default %q (want \"true\" or \"false\")", name, def)) } } - cmd.Flags().Bool(name, defBool, usage) + cmd.Flags().BoolP(name, shorthand, defBool, usage) case KindStringSlice: var defaults []string if value := strings.TrimSpace(def); value != "" { defaults = strings.Split(value, ",") } - cmd.Flags().StringSlice(name, defaults, usage) + cmd.Flags().StringSliceP(name, shorthand, defaults, usage) default: - cmd.Flags().String(name, def, usage) + cmd.Flags().StringP(name, shorthand, def, usage) } } diff --git a/internal/corecmd/corecmd_test.go b/internal/corecmd/corecmd_test.go index b13f284e..b82a9b57 100644 --- a/internal/corecmd/corecmd_test.go +++ b/internal/corecmd/corecmd_test.go @@ -67,10 +67,10 @@ func testDestructiveSafety() contract.SafetySpec { func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) { cmd := newTestCommand() RegisterFlags(cmd, []FlagSpec{ - {Name: "s", Usage: "S", Default: "d"}, - {Name: "i", Usage: "I", Kind: KindInt, Aliases: []string{"i-alias"}}, - {Name: "b", Usage: "B", Kind: KindBool}, - {Name: "sl", Usage: "SL", Kind: KindStringSlice, Default: "a,b", Aliases: []string{"sl-alias"}}, + {Name: "s", Shorthand: "s", Usage: "S", Default: "d"}, + {Name: "i", Shorthand: "i", Usage: "I", Kind: KindInt, Aliases: []string{"i-alias"}}, + {Name: "b", Shorthand: "b", Usage: "B", Kind: KindBool}, + {Name: "sl", Shorthand: "l", Usage: "SL", Kind: KindStringSlice, Default: "a,b", Aliases: []string{"sl-alias"}}, {Name: "req", Usage: "R", MarkRequired: true}, {Name: "hidden", Usage: "H", Hidden: true}, }) @@ -78,6 +78,11 @@ func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) { if f := cmd.Flags().Lookup("s"); f == nil || f.DefValue != "d" || f.Usage != "S" { t.Fatalf("string flag = %#v", f) } + for shorthand, name := range map[string]string{"s": "s", "i": "i", "b": "b", "l": "sl"} { + if flag := cmd.Flags().ShorthandLookup(shorthand); flag == nil || flag.Name != name { + t.Fatalf("shorthand -%s = %#v, want --%s", shorthand, flag, name) + } + } for name, wantType := range map[string]string{"i": "int", "b": "bool", "sl": "stringSlice"} { f := cmd.Flags().Lookup(name) if f == nil || f.Value.Type() != wantType { diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 0514cac6..cb24dd25 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -46,6 +46,9 @@ type Error struct { Message string Operation string ServerKey string + Origin string + FailureStage string + ExecutionStarted *bool Retryable bool RetryableSet bool RetryAfterSeconds *int64 @@ -55,6 +58,7 @@ type Error struct { Actions []string AvailableFlags []string Snapshot string + Details map[string]any RPCCode int `json:"rpc_code,omitempty"` RPCData json.RawMessage `json:"rpc_data,omitempty"` ServerDiag ServerDiagnostics `json:"-"` @@ -107,6 +111,32 @@ func WithServerKey(serverKey string) Option { } } +// WithOrigin records the component that produced the failure, such as the +// client, MCP gateway, or DingTalk API. It is independent from Category, +// which remains the stable exit-code contract. +func WithOrigin(origin string) Option { + return func(err *Error) { + err.Origin = strings.TrimSpace(origin) + } +} + +// WithFailureStage records the execution stage at which the failure occurred. +func WithFailureStage(stage string) Option { + return func(err *Error) { + err.FailureStage = strings.TrimSpace(stage) + } +} + +// WithExecutionStarted records whether the downstream business operation was +// known to have started. Unknown state must be represented by omitting this +// option, which is important for safe retry decisions on write operations. +func WithExecutionStarted(started bool) Option { + return func(err *Error) { + value := started + err.ExecutionStarted = &value + } +} + // WithRetryable marks whether the error can be retried safely. func WithRetryable(retryable bool) Option { return func(err *Error) { @@ -186,6 +216,21 @@ func WithSnapshot(path string) Option { } } +// WithDetails records an additive machine-readable payload for errors whose +// recovery needs typed context, such as ambiguous target-resolution +// candidates. Callers must keep credentials and other secrets out of details. +func WithDetails(details map[string]any) Option { + return func(err *Error) { + if len(details) == 0 { + return + } + err.Details = make(map[string]any, len(details)) + for key, value := range details { + err.Details[key] = value + } + } +} + // WithRPCCode records the original JSON-RPC error code. func WithRPCCode(code int) Option { return func(err *Error) { @@ -295,6 +340,15 @@ func PrintJSON(w io.Writer, err error) error { if typed.ServerKey != "" { errorPayload["server_key"] = typed.ServerKey } + if typed.Origin != "" { + errorPayload["origin"] = typed.Origin + } + if typed.FailureStage != "" { + errorPayload["stage"] = typed.FailureStage + } + if typed.ExecutionStarted != nil { + errorPayload["execution_started"] = *typed.ExecutionStarted + } if typed.RetryableSet { errorPayload["retryable"] = typed.Retryable } @@ -316,6 +370,9 @@ func PrintJSON(w io.Writer, err error) error { if typed.Snapshot != "" { errorPayload["snapshot_path"] = typed.Snapshot } + if len(typed.Details) > 0 { + errorPayload["details"] = typed.Details + } if typed.RPCCode != 0 { errorPayload["rpc_code"] = typed.RPCCode } @@ -447,6 +504,15 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error { if typed.ServerKey != "" { lines = append(lines, tui.Dim(fmt.Sprintf("Server: %s", typed.ServerKey))) } + if typed.Origin != "" { + lines = append(lines, tui.Dim(fmt.Sprintf("Origin: %s", typed.Origin))) + } + if typed.FailureStage != "" { + lines = append(lines, tui.Dim(fmt.Sprintf("Stage: %s", typed.FailureStage))) + } + if typed.ExecutionStarted != nil { + lines = append(lines, tui.Dim(fmt.Sprintf("Execution Started: %t", *typed.ExecutionStarted))) + } if typed.Snapshot != "" { lines = append(lines, tui.Dim(fmt.Sprintf("Snapshot: %s", typed.Snapshot))) } diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go index e09bfdb1..d25e223f 100644 --- a/internal/errors/errors_test.go +++ b/internal/errors/errors_test.go @@ -20,7 +20,7 @@ import ( "time" ) -func TestExitCodeByCategory(t *testing.T) { +func TestCrossPlatformCoverageExitCodeByCategory(t *testing.T) { t.Parallel() cases := []struct { @@ -42,17 +42,24 @@ func TestExitCodeByCategory(t *testing.T) { } } -func TestPrintJSON(t *testing.T) { +func TestCrossPlatformCoveragePrintJSON(t *testing.T) { t.Parallel() var b strings.Builder if err := PrintJSON(&b, NewValidation( "bad flag", WithReason("missing_required_flag"), + WithOrigin("client"), + WithFailureStage("request_validation"), + WithExecutionStarted(false), WithHint("Pass the required flag and retry."), WithRetryable(true), WithActions("dws schema doc.create_document", "retry command"), WithSnapshot("/tmp/dws-recovery/snapshot.json"), + WithDetails(map[string]any{ + "type": "resolution", + "query": "项目群", + }), )); err != nil { t.Fatalf("PrintJSON() error = %v", err) } @@ -67,6 +74,11 @@ func TestPrintJSON(t *testing.T) { if !strings.Contains(got, "\"reason\": \"missing_required_flag\"") { t.Fatalf("expected reason in output, got %q", got) } + if !strings.Contains(got, "\"origin\": \"client\"") || + !strings.Contains(got, "\"stage\": \"request_validation\"") || + !strings.Contains(got, "\"execution_started\": false") { + t.Fatalf("expected failure provenance in output, got %q", got) + } if !strings.Contains(got, "\"retryable\": true") { t.Fatalf("expected retryable in output, got %q", got) } @@ -76,6 +88,9 @@ func TestPrintJSON(t *testing.T) { if !strings.Contains(got, "\"snapshot_path\": \"/tmp/dws-recovery/snapshot.json\"") { t.Fatalf("expected snapshot path in output, got %q", got) } + if !strings.Contains(got, "\"type\": \"resolution\"") || !strings.Contains(got, "\"query\": \"项目群\"") { + t.Fatalf("expected structured details in output, got %q", got) + } } func TestCrossPlatformCoverageRetryabilityTriStateAndRetryTiming(t *testing.T) { @@ -171,7 +186,7 @@ func TestCrossPlatformCoverageRetryTimingOptionsIgnoreInvalidValues(t *testing.T } } -func TestPrintJSON_AvailableFlags(t *testing.T) { +func TestCrossPlatformCoveragePrintJSON_AvailableFlags(t *testing.T) { t.Parallel() var b strings.Builder @@ -192,7 +207,7 @@ func TestPrintJSON_AvailableFlags(t *testing.T) { } } -func TestPrintHuman(t *testing.T) { +func TestCrossPlatformCoveragePrintHuman(t *testing.T) { t.Parallel() var b strings.Builder @@ -201,6 +216,9 @@ func TestPrintHuman(t *testing.T) { WithReason("missing_required_flag"), WithOperation("calendar.list"), WithServerKey("calendar"), + WithOrigin("client"), + WithFailureStage("request_validation"), + WithExecutionStarted(false), WithHint("Pass the required flag and retry."), WithRetryable(true), WithActions("retry command"), @@ -228,9 +246,19 @@ func TestPrintHuman(t *testing.T) { if !strings.Contains(got, "Retryable: true") { t.Fatalf("expected retryable marker in output, got %q", got) } + for _, want := range []string{"Origin: client", "Stage: request_validation", "Execution Started: false"} { + if !strings.Contains(got, want) { + t.Fatalf("expected %q in verbose output, got %q", want, got) + } + } + + withoutDetails := NewValidation("empty", WithDetails(nil)).(*Error) + if withoutDetails.Details != nil { + t.Fatalf("empty details were retained: %#v", withoutDetails.Details) + } } -func TestPrintHuman_NormalMode(t *testing.T) { +func TestCrossPlatformCoveragePrintHuman_NormalMode(t *testing.T) { t.Parallel() var b strings.Builder @@ -254,7 +282,7 @@ func TestPrintHuman_NormalMode(t *testing.T) { } } -func TestPrintJSONIncludesServerDiag(t *testing.T) { +func TestCrossPlatformCoveragePrintJSONIncludesServerDiag(t *testing.T) { t.Parallel() var b strings.Builder @@ -289,7 +317,7 @@ func TestPrintJSONIncludesServerDiag(t *testing.T) { } } -func TestPrintHumanIncludesServerGuidance(t *testing.T) { +func TestCrossPlatformCoveragePrintHumanIncludesServerGuidance(t *testing.T) { t.Parallel() var b strings.Builder @@ -313,7 +341,7 @@ func TestPrintHumanIncludesServerGuidance(t *testing.T) { } } -func TestPrintJSONIncludesRPCCodeAndData(t *testing.T) { +func TestCrossPlatformCoveragePrintJSONIncludesRPCCodeAndData(t *testing.T) { t.Parallel() var b strings.Builder @@ -335,7 +363,7 @@ func TestPrintJSONIncludesRPCCodeAndData(t *testing.T) { } } -func TestPrintHumanIncludesRPCCode_Debug(t *testing.T) { +func TestCrossPlatformCoveragePrintHumanIncludesRPCCode_Debug(t *testing.T) { t.Parallel() var b strings.Builder @@ -356,7 +384,7 @@ func TestPrintHumanIncludesRPCCode_Debug(t *testing.T) { } } -func TestPrintHumanHidesRPCCode_Normal(t *testing.T) { +func TestCrossPlatformCoveragePrintHumanHidesRPCCode_Normal(t *testing.T) { t.Parallel() var b strings.Builder diff --git a/internal/helpers/aisearch.go b/internal/helpers/aisearch.go index 6746e0ac..00e11a35 100644 --- a/internal/helpers/aisearch.go +++ b/internal/helpers/aisearch.go @@ -3,6 +3,7 @@ package helpers import ( "strings" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/spf13/cobra" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" @@ -78,6 +79,8 @@ func addAisearchPersonFlags(cmd *cobra.Command) { cmd.Flags().String(alias, "", "") _ = cmd.Flags().MarkHidden(alias) } + cmd.Flags().String("type", "", "兼容选择器;person/search 路径仅接受 person/user/people") + _ = cmd.Flags().MarkHidden("type") } func addAisearchKeywordCompatibilityFlag(cmd *cobra.Command) { @@ -90,6 +93,9 @@ func addAisearchKeywordCompatibilityFlag(cmd *cobra.Command) { // runAisearchPerson 是 aisearch person 的实际执行体,被 personCmd 和 root // 的智能 RunE(裸调兜底)共享调用。 func runAisearchPerson(cmd *cobra.Command, _ []string) error { + if selector := strings.ToLower(strings.TrimSpace(flagValue(cmd, "type"))); selector != "" && selector != "person" && selector != "user" && selector != "people" { + return apperrors.NewValidation("aisearch person/search 的 --type 仅接受 person、user 或 people") + } keyword := resolveAisearchKeyword(cmd) if keyword == "" { // 复用原有报错文案("keyword is required") diff --git a/internal/helpers/aisearch_coverage_more_test.go b/internal/helpers/aisearch_coverage_more_test.go index a92d38b4..61419741 100644 --- a/internal/helpers/aisearch_coverage_more_test.go +++ b/internal/helpers/aisearch_coverage_more_test.go @@ -28,3 +28,13 @@ func TestCrossPlatformCoverageAISearchRemainingFallbackBranches(t *testing.T) { } } } + +func TestCrossPlatformCoverageAisearchPersonAcceptsRedundantTypeSelector(t *testing.T) { + installScriptedCaller(t, &scriptedToolCaller{dry: true}) + if err := executeFilterCoverage(t, newAisearchCommand(), "search", "--query", "张三", "--type", "person"); err != nil { + t.Fatal(err) + } + if err := executeFilterCoverage(t, newAisearchCommand(), "search", "--query", "张三", "--type", "document"); err == nil { + t.Fatal("invalid person type selector unexpectedly succeeded") + } +} diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 205d9190..7c32a2b6 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -18,6 +18,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" @@ -53,6 +54,35 @@ func resolveMessageForward(cmd *cobra.Command, defaultForward bool) (bool, error } } +type nativeChatTargetReader struct{} + +func (nativeChatTargetReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) { + text, err := CallMCPReadToolTextOnServer(product, tool, params) + if err != nil { + return nil, err + } + if strings.TrimSpace(text) == "" { + return map[string]any{}, nil + } + var data map[string]any + if err := json.Unmarshal([]byte(text), &data); err != nil { + return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err)) + } + return data, nil +} + +func resolveNativeChatTarget(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if targetresolver.LooksLikeOpenConversationID(raw) { + return raw, nil + } + resolved, err := targetresolver.ResolveChat(nativeChatTargetReader{}, raw) + if err != nil { + return "", err + } + return resolved.Selected.OpenConversationID, nil +} + const maxConversationCategoryTitleRunes = 15 func validatedConversationCategoryTitle(raw string) (string, error) { @@ -78,6 +108,61 @@ func chatIntFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string return v } +func runChatGroupSearch(cmd *cobra.Command, args []string) error { + keyword := flagOrFallback(cmd, "query", "keyword", "name", "group") + if len(args) == 1 { + if keyword != "" { + return apperrors.NewValidation("群搜索位置参数与 --query/--keyword 不能同时指定") + } + keyword = strings.TrimSpace(args[0]) + } + if keyword == "" { + return apperrors.NewValidation("flag --query is required\n hint: dws chat search --query \"test\"") + } + limit := chatIntFlagOrFallback(cmd, "limit", "size") + cursor, _ := cmd.Flags().GetString("cursor") + toolArgs := map[string]any{ + "keyword": keyword, + "limit": limit, + "cursor": cursor, + } + if v, _ := cmd.Flags().GetBool("exclude-muted"); v { + toolArgs["excludeMuted"] = true + } + return callMCPToolOnServer("im", "search_groups", toolArgs) +} + +func newChatGroupSearchCommand(hidden bool) *cobra.Command { + cmd := &cobra.Command{ + Use: "search [query]", + Short: "根据关键词搜索群聊", + Hidden: hidden, + Long: `根据关键词搜索群聊列表。分页参数 --limit(默认 20)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。 + +注意: +1. query 不要拆分得太细,应使用群名称中连续的核心词作为关键词(如群名"项目冲刺群"应搜"项目冲刺"而非拆成"项目"+"冲刺"分别搜索)。 +2. 当搜索结果返回多个群聊时,应列出候选群让用户确认目标群聊,不要自行假定并直接进行后续操作。`, + Example: ` dws chat search --query "项目冲刺" + dws chat search "项目冲刺" + dws chat search --query "项目冲刺" --limit 20 --cursor 0`, + Args: cobra.MaximumNArgs(1), + RunE: runChatGroupSearch, + } + cmd.Flags().String("query", "", "搜索关键词 (必填)") + cmd.Flags().String("keyword", "", "--query 的别名") + _ = cmd.Flags().MarkHidden("keyword") + cmd.Flags().String("name", "", "--query 的兼容别名") + _ = cmd.Flags().MarkHidden("name") + cmd.Flags().String("group", "", "--query 的兼容别名") + _ = cmd.Flags().MarkHidden("group") + cmd.Flags().Int("limit", 20, "每页返回数量(默认 20)") + cmd.Flags().Int("size", 0, "--limit 的旧版别名") + _ = cmd.Flags().MarkHidden("size") + cmd.Flags().String("cursor", "0", "分页游标(默认 \"0\",翻页传 nextCursor)") + cmd.Flags().Bool("exclude-muted", false, "是否排除已设置免打扰的群聊(默认 false)") + return cmd +} + func runChatSearchCommon(cmd *cobra.Command, _ []string) error { if err := validateRequiredFlags(cmd, "nicks"); err != nil { return err @@ -1277,34 +1362,23 @@ func newChatCommand() *cobra.Command { }, }) - chatSearchCmd := &cobra.Command{ - Use: "search", - Short: "根据关键词搜索群聊", - Long: `根据关键词搜索群聊列表。分页参数 --limit(默认 20)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。 - -注意: -1. query 不要拆分得太细,应使用群名称中连续的核心词作为关键词(如群名"项目冲刺群"应搜"项目冲刺"而非拆成"项目"+"冲刺"分别搜索)。 -2. 当搜索结果返回多个群聊时,应列出候选群让用户确认目标群聊,不要自行假定并直接进行后续操作。`, - Example: ` dws chat search --query "项目冲刺" - dws chat search --query "项目冲刺" --limit 20 --cursor 0`, - RunE: func(cmd *cobra.Command, args []string) error { - keyword := flagOrFallback(cmd, "query", "keyword") - if keyword == "" { - return fmt.Errorf("flag --query is required\n hint: dws chat search --query \"test\"") - } - limit := chatIntFlagOrFallback(cmd, "limit", "size") - cursor, _ := cmd.Flags().GetString("cursor") - toolArgs := map[string]any{ - "keyword": keyword, - "limit": limit, - "cursor": cursor, - } - if v, _ := cmd.Flags().GetBool("exclude-muted"); v { - toolArgs["excludeMuted"] = true - } - return callMCPToolOnServer("im", "search_groups", toolArgs) + chatSearchCmd := newChatGroupSearchCommand(false) + chatGroupSearchCompatibilityCmd := newChatGroupSearchCommand(true) + cli.AttachRuntimeSchema( + chatGroupSearchCompatibilityCmd, + "chat", + "search_groups", + "reviewed-compatibility:chat-group-search", + ) + cli.AnnotateRuntimeCompatibilityEquivalence( + chatSearchCmd, + chatGroupSearchCompatibilityCmd, + cli.RuntimeCompatibilityEquivalence{ + ID: "chat-group-search-compatibility-v1", + Reason: "Both leaves share the same constructor, flags, positional normalization, read-only search_groups transport, and result contract.", + Reviewed: true, }, - } + ) DeclareLeafMetadata(chatSearchCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", @@ -3353,15 +3427,6 @@ func newChatCommand() *cobra.Command { chatGroupCreateCmd.Flags().String("type", "INTERNAL", "群类型: INTERNAL(内部群,默认)/EXTERNAL(外部群)/NORMAL(普通群)") chatGroupCreateCmd.Flags().Bool("thread", false, "开启话题模式,将创建话题圈") - chatSearchCmd.Flags().String("query", "", "搜索关键词 (必填)") - chatSearchCmd.Flags().String("keyword", "", "--query 的别名") - _ = chatSearchCmd.Flags().MarkHidden("keyword") - chatSearchCmd.Flags().Int("limit", 20, "每页返回数量(默认 20)") - chatSearchCmd.Flags().Int("size", 0, "--limit 的旧版别名") - _ = chatSearchCmd.Flags().MarkHidden("size") - chatSearchCmd.Flags().String("cursor", "0", "分页游标(默认 \"0\",翻页传 nextCursor)") - chatSearchCmd.Flags().Bool("exclude-muted", false, "是否排除已设置免打扰的群聊(默认 false)") - chatGroupMembersCmd.Flags().String("id", "", "群 ID / openconversation_id (必填)") _ = chatGroupMembersCmd.MarkFlagRequired("id") chatGroupMembersCmd.Flags().String("cursor", "", "分页游标,首次从 0 开始") @@ -3387,7 +3452,7 @@ func newChatCommand() *cobra.Command { _ = chatGroupMemberRemoveCmd.MarkFlagRequired("users") chatGroupCmd.AddCommand(chatGroupCreateCmd, chatGroupMembersCmd, chatGroupRenameCmd) - chatGroupCmd.AddCommand(hintSubCmd("search", "use: dws chat search --query <关键词>")) + chatGroupCmd.AddCommand(chatGroupSearchCompatibilityCmd) chatGroupMembersCmd.AddCommand(chatGroupMemberAddCmd, chatGroupMemberRemoveCmd, chatGroupMembersAddBotCmd) // message 子命令 flags @@ -3940,6 +4005,7 @@ func newChatCommand() *cobra.Command { chatCategoryDeleteCmd := &cobra.Command{ Use: "delete", Short: "删除用户自定义会话分组", + Long: "删除用户自定义会话分组。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。", Example: ` dws chat category delete --category-id <分组ID> # 分组ID 可通过 dws chat category list 获取`, RunE: func(cmd *cobra.Command, args []string) error { @@ -3947,6 +4013,14 @@ func newChatCommand() *cobra.Command { if categoryId == 0 { return fmt.Errorf("flag --category-id is required") } + if !commandBoolFlag(cmd, "yes") { + return apperrors.NewValidation( + "删除会话分组不可逆;获得用户确认后加 --yes 执行", + apperrors.WithReason("confirmation_required"), + apperrors.WithHint("先确认目标分组及影响范围;用户明确同意后以相同参数追加 --yes"), + apperrors.WithActions("确认目标会话分组", "获得用户确认后使用 --yes 执行"), + ) + } return callMCPToolOnServer("im", "delete_conv_category", map[string]any{ "categoryId": categoryId, }) @@ -6261,8 +6335,12 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 if err := validateRequiredFlags(cmd, "group"); err != nil { return err } + groupID, err := resolveNativeChatTarget(mustGetFlag(cmd, "group")) + if err != nil { + return err + } return callMCPToolOnServer("bot", "list_group_bots", map[string]any{ - "openConversationId": mustGetFlag(cmd, "group"), + "openConversationId": groupID, }) }, } @@ -6296,7 +6374,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 }, }, }) - chatGroupBotsCmd.Flags().String("group", "", "群聊 openConversationId (必填)") + chatGroupBotsCmd.Flags().String("group", "", "群聊 openConversationId 或需唯一解析的群名 (必填)") _ = chatGroupBotsCmd.MarkFlagRequired("group") chatGroupMembersRemoveBotCmd := &cobra.Command{ @@ -6432,7 +6510,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 Use: "dismiss", Short: "解散群聊", Long: `解散指定群聊。该操作不可逆,需要群主权限;必须先获得用户确认,再追加 --yes 执行。`, - Example: ` dws chat group dismiss --group --yes + Example: ` dws chat group dismiss --group # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "group"); err != nil { @@ -7276,7 +7354,7 @@ status 可选值: chatClearMessagesCmd := &cobra.Command{ Use: "clear-messages", Short: "清空当前用户指定会话的聊天记录", - Long: `清空当前用户在指定会话中的聊天记录。仅清空当前用户视角的消息,不影响其他成员。 + Long: `清空当前用户在指定会话中的聊天记录。仅清空当前用户视角的消息,不影响其他成员。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。 如何获取 openConversationId(如果上层已有则直接使用,不必再查): - 群聊:dws chat search --query "群名" @@ -7288,6 +7366,14 @@ status 可选值: if convID == "" { return fmt.Errorf("flag --conversation-id is required\n hint: dws chat clear-messages --conversation-id ") } + if !commandBoolFlag(cmd, "yes") { + return apperrors.NewValidation( + "清空会话聊天记录不可逆;获得用户确认后加 --yes 执行", + apperrors.WithReason("confirmation_required"), + apperrors.WithHint("先确认目标会话及影响范围;用户明确同意后以相同参数追加 --yes"), + apperrors.WithActions("确认目标会话", "获得用户确认后使用 --yes 执行"), + ) + } return callMCPToolOnServer("im", "clear_conversation_messages", map[string]any{ "openConversationId": convID, }) @@ -7763,8 +7849,8 @@ status 可选值: 本命令升级已有普通群;新建外部群请使用 chat group create --type EXTERNAL。 该操作不可逆,仅群主可执行。正式执行必须通过 --yes 显式确认,可先使用 --dry-run 预览。`, - Example: ` dws chat group upgrade-to-external --group --yes - dws chat group upgrade-to-external --group --extension '{"source":"dws"}' --yes + Example: ` dws chat group upgrade-to-external --group --dry-run + dws chat group upgrade-to-external --group --extension '{"source":"dws"}' --dry-run # 查询群 ID: dws chat search --query "群名"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "group"); err != nil { @@ -8080,10 +8166,5 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`, root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd) - // hint: dws chat send → dws chat message send - root.AddCommand(hintSubCmd("send", "use: dws chat message send")) - // hint: dws chat history → dws chat message list - root.AddCommand(hintSubCmd("history", "use: dws chat message list --group ")) - return root } diff --git a/internal/helpers/chat_command_edges_test.go b/internal/helpers/chat_command_edges_test.go index 53ad7d8a..4d85258a 100644 --- a/internal/helpers/chat_command_edges_test.go +++ b/internal/helpers/chat_command_edges_test.go @@ -43,6 +43,66 @@ func runChatCoverageDirect(t *testing.T, path []string, flags map[string]string) return command.RunE(command, nil) } +func TestCrossPlatformCoverageEvaluationRegressionChatSearchSpellingsAndNaturalBotTarget(t *testing.T) { + if got, err := resolveNativeChatTarget(" cid123456789 "); err != nil || got != "cid123456789" { + t.Fatalf("stable native chat target = %q, %v", got, err) + } + t.Run("group search path accepts query", func(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}} + if err := runChatCoverageCommand(t, caller, "group", "search", "--query", "项目群"); err != nil { + t.Fatal(err) + } + if caller.calls != 1 { + t.Fatalf("calls = %d", caller.calls) + } + }) + + t.Run("group search accepts positional", func(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}} + if err := runChatCoverageCommand(t, caller, "group", "search", "项目群"); err != nil { + t.Fatal(err) + } + if caller.calls != 1 { + t.Fatalf("calls = %d", caller.calls) + } + }) + + t.Run("native bots resolves group name", func(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"result":[{"openConversationId":"cid-project","title":"项目群"}],"hasMore":false}`}, + {text: `{"result":{"bots":[]}}`}, + }} + if err := runChatCoverageCommand(t, caller, "group", "bots", "--group", "项目群"); err != nil { + t.Fatal(err) + } + if caller.calls != 2 { + t.Fatalf("calls = %d", caller.calls) + } + }) +} + +func TestCrossPlatformCoverageChatMisroutedPathsRemainUnknownSubcommands(t *testing.T) { + for _, tc := range []struct { + path string + flag string + }{ + {path: "send", flag: "--group"}, + {path: "history", flag: "--group"}, + } { + caller := &productExampleCaller{} + err := runChatCoverageCommand(t, caller, tc.path, tc.flag, "cid") + if err == nil || !strings.Contains(err.Error(), "unknown command") || !strings.Contains(err.Error(), tc.path) { + t.Fatalf("chat %s error = %v, want unknown command", tc.path, err) + } + if strings.Contains(err.Error(), "unknown flag") { + t.Fatalf("chat %s was misreported as a flag error: %v", tc.path, err) + } + if caller.calls != 0 { + t.Fatalf("chat %s tool calls = %d, want 0", tc.path, caller.calls) + } + } +} + func TestCrossPlatformCoverageChatGroupUpdateIconAcceptsUploadedMediaIDPrefixes(t *testing.T) { previousDeps, previousArgs := deps, os.Args os.Args = []string{"dws", "chat"} @@ -112,6 +172,7 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T) {"message", "search", "--query=q", "--start=2026-01-02T00:00:00Z", "--end=2026-01-01T00:00:00Z"}, {"message", "search", "--query=q", "--start=2026-01-01T00:00:00Z", "--end=2026-01-02T00:00:00Z", "--group=cid"}, {"message", "recall", "--conversation-id=cid", "--msg-id=mid"}, + {"category", "delete", "--category-id=1"}, {"category", "rename", "--category-id=1", "--title=renamed"}, {"category", "add-conv", "--group=cid", "--category-ids=1,2"}, {"category", "remove-conv", "--group=cid", "--category-ids=1,2"}, diff --git a/internal/helpers/gap_fill_test.go b/internal/helpers/gap_fill_test.go index ac727a7f..e33bbd65 100644 --- a/internal/helpers/gap_fill_test.go +++ b/internal/helpers/gap_fill_test.go @@ -64,6 +64,7 @@ func TestCrossPlatformCoverageReadToolNameContractAndHelperBoundary(t *testing.T "LIST_MESSAGES": true, "query_send_status": true, "search_messages": true, + "enterprise_person_search": true, "unread_message_conversation_list": true, "send_personal_message": false, "": false, diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go index fd0c8c20..9729a287 100644 --- a/internal/helpers/helpers.go +++ b/internal/helpers/helpers.go @@ -190,6 +190,9 @@ func callMCPReadToolReturnTextOnServer(ctx context.Context, serverID, toolName s // ReadToolCaller. func IsReadToolName(toolName string) bool { toolName = strings.TrimSpace(strings.ToLower(toolName)) + if toolName == "enterprise_person_search" { + return true + } for _, prefix := range []string{ "get_", "list_", "query_", "search_", "unread_", } { diff --git a/internal/helpers/remaining_unit_coverage_test.go b/internal/helpers/remaining_unit_coverage_test.go index 238d7f75..3e28defa 100644 --- a/internal/helpers/remaining_unit_coverage_test.go +++ b/internal/helpers/remaining_unit_coverage_test.go @@ -271,6 +271,10 @@ func TestCrossPlatformCoverageProtectSheetMutationCommandPanics(t *testing.T) { func TestSheetMutationGuardRejectsPipedYesEvenWithContractConfirmSafety(t *testing.T) { // Sheet agent hardening: outer --yes-only gate must win over ConfirmSafety // honoring piped stdin yes (review: delete-sheet / range clear / version revert). + // This fixture exercises the no-caller fallback. Isolate it from commands + // built by earlier tests, which may initialize the package-level deps. + testseam.Protect(t, &deps) + deps = nil ran := false cmd := &cobra.Command{ Use: "delete-sheet", diff --git a/internal/profilectx/profile.go b/internal/profilectx/profile.go new file mode 100644 index 00000000..68fc2492 --- /dev/null +++ b/internal/profilectx/profile.go @@ -0,0 +1,30 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +// Package profilectx owns the process-local profile selector without importing +// authentication or transport packages. +package profilectx + +import ( + "strings" + "sync" +) + +var ( + runtimeProfileMu sync.RWMutex + runtimeProfile string +) + +// Set records the explicit profile selector for the current process. +func Set(profile string) { + runtimeProfileMu.Lock() + defer runtimeProfileMu.Unlock() + runtimeProfile = strings.TrimSpace(profile) +} + +// Get returns the explicit process-local profile selector. +func Get() string { + runtimeProfileMu.RLock() + defer runtimeProfileMu.RUnlock() + return runtimeProfile +} diff --git a/internal/profilectx/profile_test.go b/internal/profilectx/profile_test.go new file mode 100644 index 00000000..8b82a5d0 --- /dev/null +++ b/internal/profilectx/profile_test.go @@ -0,0 +1,20 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package profilectx + +import "testing" + +func TestCrossPlatformCoverageSetAndGet(t *testing.T) { + t.Cleanup(func() { Set("") }) + + Set(" fixture-profile ") + if got := Get(); got != "fixture-profile" { + t.Fatalf("Get() = %q, want fixture-profile", got) + } + + Set("") + if got := Get(); got != "" { + t.Fatalf("Get() after reset = %q, want empty", got) + } +} diff --git a/internal/shortcut/adapter.go b/internal/shortcut/adapter.go index f83f3daf..dcbc3fd0 100644 --- a/internal/shortcut/adapter.go +++ b/internal/shortcut/adapter.go @@ -36,6 +36,16 @@ func FromShortcut(s Shortcut) corecmd.Spec { if !safetySpecDeclared(safety) { safety = shortcutSafetySpec(s.risk()) } + declaredContract := s.Contract + if !declaredContract.Empty() && len(s.Aliases) > 0 && len(declaredContract.Identity.Aliases) == 0 { + declaredContract.Identity.Aliases = make([]string, 0, len(s.Aliases)) + for _, alias := range s.Aliases { + declaredContract.Identity.Aliases = append( + declaredContract.Identity.Aliases, + s.Service+" "+strings.TrimSpace(alias), + ) + } + } return corecmd.Spec{ Use: s.Command, Short: s.Description, @@ -47,11 +57,12 @@ func FromShortcut(s Shortcut) corecmd.Spec { Flags: fromShortcutFlags(s.Flags), Constraints: fromShortcutConstraints(s.Constraints), Safety: safety, - Contract: s.Contract, + Contract: declaredContract, // Preserve the shipped Shortcut Catalog provenance: Cobra remains the // source for type/default/usage, while command adds Required/Enum/rules. ParameterProjection: corecmd.ProjectCobraParameters, Validate: fromShortcutValidate(s), + PostMount: fromShortcutPostMount(s), // Multi-step body: command stays backend-agnostic, so the shortcut's own // RuntimeContext — which owns CallMCPData/CallMCPWriteData/Output — is // built here from the Ctx's command. @@ -65,6 +76,40 @@ func FromShortcut(s Shortcut) corecmd.Spec { } } +func fromShortcutPostMount(s Shortcut) func(*cobra.Command) { + if len(s.Aliases) == 0 && strings.TrimSpace(s.SinglePositionalAliasFor) == "" { + return nil + } + return func(cmd *cobra.Command) { + cmd.Aliases = append([]string(nil), s.Aliases...) + name := strings.TrimSpace(s.SinglePositionalAliasFor) + if name == "" { + return + } + cmd.Args = func(cmd *cobra.Command, args []string) error { + if err := cobra.MaximumNArgs(1)(cmd, args); err != nil { + return err + } + if len(args) == 0 { + return nil + } + flag := cmd.Flags().Lookup(name) + if flag == nil { + return apperrors.NewInternal(fmt.Sprintf( + "shortcut %s %s positional alias flag --%s is not registered", + s.Service, s.Command, name)) + } + if flag.Changed { + return apperrors.NewValidation(fmt.Sprintf("位置参数与 --%s 不能同时提供", name)) + } + if err := cmd.Flags().Set(name, args[0]); err != nil { + return apperrors.NewValidation(fmt.Sprintf("位置参数无法写入 --%s: %v", name, err)) + } + return nil + } + } +} + func safetySpecDeclared(safety contract.SafetySpec) bool { return strings.TrimSpace(safety.Effect) != "" || strings.TrimSpace(safety.Risk) != "" || @@ -133,6 +178,7 @@ func fromShortcutFlags(flags []Flag) []corecmd.FlagSpec { for _, f := range flags { out = append(out, corecmd.FlagSpec{ Name: f.Name, + Shorthand: f.Shorthand, Usage: flagHelp(f), Kind: fromShortcutFlagKind(f.Type), Default: f.Default, diff --git a/internal/shortcut/adapter_test.go b/internal/shortcut/adapter_test.go index 2a736f0a..8c795fff 100644 --- a/internal/shortcut/adapter_test.go +++ b/internal/shortcut/adapter_test.go @@ -20,6 +20,7 @@ import ( "github.com/spf13/pflag" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" ) // TestCrossPlatformCoverageFromShortcutMapsSharedBase verifies FromShortcut @@ -33,7 +34,7 @@ func TestCrossPlatformCoverageFromShortcutMapsSharedBase(t *testing.T) { Hidden: true, Tips: []string{"dws chat +demo --name a"}, Flags: []Flag{ - {Name: "name", Type: FlagString, Desc: "名称", Required: true, Default: "d", Enum: []string{"a", "b"}, Hidden: true}, + {Name: "name", Shorthand: "n", Type: FlagString, Desc: "名称", Required: true, Default: "d", Enum: []string{"a", "b"}, Hidden: true}, {Name: "count", Type: FlagInt, Desc: "数量"}, {Name: "flag", Type: FlagBool, Desc: "开关"}, {Name: "ids", Type: FlagStringSlice, Desc: "列表"}, @@ -85,7 +86,7 @@ func TestCrossPlatformCoverageFromShortcutMapsSharedBase(t *testing.T) { } } name := cs.Flags[0] - if name.Name != "name" || !name.Required || name.Default != "d" || + if name.Name != "name" || name.Shorthand != "n" || !name.Required || name.Default != "d" || !name.Hidden || name.ValidationMode != corecmd.ValidationShortcut || name.RequiredError != "缺少必填参数 --name:名称" || strings.Join(name.Enum, ",") != "a,b" { @@ -120,6 +121,72 @@ func TestCrossPlatformCoverageFromShortcutMapsSharedBase(t *testing.T) { } } +func TestCrossPlatformCoverageFromShortcutAliasesAndPositionalAlias(t *testing.T) { + executed := "" + s := Shortcut{ + Service: "chat", + Command: "+search", + Aliases: []string{"+search-group"}, + SinglePositionalAliasFor: "query", + Description: "搜索群", + Intent: "按名称搜索群", + Contract: corecmd.ContractDecl{ + Description: "按名称搜索群", + Interface: &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: "test composite", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "搜索群", UseWhen: []string{"按名称搜索群"}, AvoidWhen: []string{"不要用于成员搜索"}, Examples: []string{"dws chat +search --query demo"}, + }, + Identity: contract.ToolIdentitySpec{ + ProductID: "chat", Name: "shortcut_search", CanonicalPath: "chat.shortcut_search", CLIPath: "chat +search", PrimaryCLIPath: "chat +search", + }, + }, + Flags: []Flag{{Name: "query", Desc: "关键词", Required: true}}, + Execute: func(rt *RuntimeContext) error { executed = rt.Str("query"); return nil }, + } + spec := FromShortcut(s) + if got := spec.Contract.Identity.Aliases; len(got) != 1 || got[0] != "chat +search-group" { + t.Fatalf("contract aliases = %#v", got) + } + cmd := mount(s) + if !cmd.HasAlias("+search-group") { + t.Fatalf("cobra aliases = %#v", cmd.Aliases) + } + cmd.SetArgs([]string{"项目群"}) + if err := cmd.Execute(); err != nil || executed != "项目群" { + t.Fatalf("positional execute err=%v value=%q", err, executed) + } + + conflict := mount(s) + conflict.SetArgs([]string{"项目群", "--query", "另一个群"}) + if err := conflict.Execute(); err == nil || !strings.Contains(err.Error(), "不能同时提供") { + t.Fatalf("positional/flag conflict err = %v", err) + } + tooMany := mount(s) + tooMany.SetArgs([]string{"one", "two"}) + if err := tooMany.Execute(); err == nil { + t.Fatal("multiple positional aliases unexpectedly accepted") + } + + missing := mount(Shortcut{ + Service: "chat", Command: "+missing", Description: "missing", + SinglePositionalAliasFor: "query", Execute: func(*RuntimeContext) error { return nil }, + }) + missing.SetArgs([]string{"value"}) + if err := missing.Execute(); err == nil || !strings.Contains(err.Error(), "is not registered") { + t.Fatalf("missing positional flag err = %v", err) + } + invalid := mount(Shortcut{ + Service: "chat", Command: "+invalid", Description: "invalid", + SinglePositionalAliasFor: "query", Flags: []Flag{{Name: "query", Type: FlagInt}}, Execute: func(*RuntimeContext) error { return nil }, + }) + invalid.SetArgs([]string{"not-an-int"}) + if err := invalid.Execute(); err == nil || !strings.Contains(err.Error(), "无法写入") { + t.Fatalf("invalid positional value err = %v", err) + } +} + // TestCrossPlatformCoverageFromShortcutMatchesMountSurface pins the live // adapter surface: flag set (names/types/usage) and rendered Long must agree. // This catches a double-rendered 参数约束 or lost flagHelp decoration. diff --git a/internal/shortcut/chat/batch_write.go b/internal/shortcut/chat/batch_write.go index 71fa6e3b..27f22bdc 100644 --- a/internal/shortcut/chat/batch_write.go +++ b/internal/shortcut/chat/batch_write.go @@ -5,6 +5,9 @@ package chat import ( + "fmt" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -28,14 +31,15 @@ func executeShortcutBatchWrite(rt *shortcut.RuntimeContext, product, tool string }) } return rt.Output(map[string]any{ - "dry_run": true, - "executed": false, - "preview_kind": "plan", - "tool": tool, - "actionCount": len(actions), - "failedCount": 0, - "actions": actions, - "requestedCount": len(items), + "contractVersion": "im.batch-write.v1", + "dry_run": true, + "executed": false, + "preview_kind": "plan", + "tool": tool, + "actionCount": len(actions), + "failedCount": 0, + "actions": actions, + "requestedCount": len(items), }) } @@ -56,13 +60,33 @@ func executeShortcutBatchWrite(rt *shortcut.RuntimeContext, product, tool string } succeeded = append(succeeded, entry) } - return rt.Output(map[string]any{ - "ok": len(failures) == 0, - "partial": len(succeeded) > 0 && len(failures) > 0, - "requestedCount": len(items), - "succeededCount": len(succeeded), - "failedCount": len(failures), - "succeeded": succeeded, - "failures": failures, - }) + result := map[string]any{ + "contractVersion": "im.batch-write.v1", + "ok": len(failures) == 0, + "partial": len(succeeded) > 0 && len(failures) > 0, + "requestedCount": len(items), + "succeededCount": len(succeeded), + "failedCount": len(failures), + "succeeded": succeeded, + "failures": failures, + } + if err := rt.Output(result); err != nil { + return err + } + if len(failures) > 0 { + return apperrors.NewAPI( + fmt.Sprintf("批量执行 %s 失败:%d/%d 个目标未完成", tool, len(failures), len(items)), + apperrors.WithOperation(product+"/"+tool), + apperrors.WithReason("batch_write_failed"), + apperrors.WithExecutionStarted(true), + apperrors.WithRetryable(false), + apperrors.WithDetails(map[string]any{ + "requestedCount": len(items), + "succeededCount": len(succeeded), + "failedCount": len(failures), + "partial": len(succeeded) > 0, + }), + ) + } + return nil } diff --git a/internal/shortcut/chat/chat_bot.go b/internal/shortcut/chat/chat_bot.go index 68d1d150..5cc808f2 100644 --- a/internal/shortcut/chat/chat_bot.go +++ b/internal/shortcut/chat/chat_bot.go @@ -228,8 +228,8 @@ func botFindProject(data map[string]any) []map[string]any { // SearchCommonGroups searches groups shared with given people (search_common_groups, chat server). func init() { - shortcut.Register( + shortcut.Register(withReviewedChatShortcutContracts( BotSearch, BotFind, - ) + )...) } diff --git a/internal/shortcut/chat/chat_conversation.go b/internal/shortcut/chat/chat_conversation.go index fd0ca9b7..a45094b7 100644 --- a/internal/shortcut/chat/chat_conversation.go +++ b/internal/shortcut/chat/chat_conversation.go @@ -15,6 +15,7 @@ package chat import ( "fmt" + "strconv" "strings" "unicode/utf8" @@ -88,8 +89,8 @@ var ConversationSetTop = shortcut.Shortcut{ Intent: "当你想把一个或多个单聊/群聊置顶到会话列表顶部、或取消置顶时使用;支持 1-10 个 openConversationId,逐项执行并返回成功/失败 ledger,某一项失败不阻断其余项。", Risk: shortcut.RiskWrite, Flags: []shortcut.Flag{ - {Name: "conversation-id", Type: shortcut.FlagString, Desc: "单个会话 openConversationId"}, - {Name: "conversation-ids", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId(最多 10 个)"}, + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "单个会话 openConversationId;会话 ID 去重后必须为 1-10 个"}, + {Name: "conversation-ids", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId;会话 ID 去重后必须为 1-10 个"}, {Name: "off", Type: shortcut.FlagBool, Desc: "取消置顶(不传则设置置顶)"}, }, Constraints: []shortcut.Constraint{ @@ -164,7 +165,7 @@ var ConversationMuteAtAll = shortcut.Shortcut{ Command: "+conversation-mute-at-all", Product: "im", Description: "关闭/开启 @所有人消息提醒", - Intent: "当你在某个群里不想再被'@所有人'打扰、或想恢复该提醒时使用;会实际修改该会话的@所有人提醒开关,需传 openConversationId。", + Intent: "当你已对某个会话开启消息免打扰,并希望额外关闭或恢复'@所有人'提醒时使用;这是免打扰的子开关,若尚未开启总免打扰,先执行 +conversation-mute,否则平台会返回 NotificationOffNotEnabled。", Risk: shortcut.RiskWrite, Flags: []shortcut.Flag{ {Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, @@ -185,7 +186,7 @@ var ConversationMuteRedEnvelope = shortcut.Shortcut{ Command: "+conversation-mute-red-envelope", Product: "im", Description: "关闭/开启红包消息提醒", - Intent: "当你想在某个会话里关闭或恢复红包消息提醒时使用;会实际修改该会话的红包提醒开关,需传 openConversationId。", + Intent: "当你已对某个会话开启消息免打扰,并希望额外关闭或恢复红包提醒时使用;这是免打扰的子开关,若尚未开启总免打扰,或刚恢复过@所有人提醒,先执行 +conversation-mute,否则平台会返回 NotificationOffNotEnabled。", Risk: shortcut.RiskWrite, Flags: []shortcut.Flag{ {Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, @@ -284,8 +285,8 @@ var ConversationList = shortcut.Shortcut{ Service: "chat", Command: "+conversation-list", Product: "im", - Description: "分页获取当前用户的全部会话列表(单聊+群聊)", - Intent: "当你想遍历当前用户的所有会话(单聊+群聊)做统计、清理或批量处理时使用;只读分页返回,可用 --exclude-muted 排除已免打扰会话。", + Description: "分页或一键全量获取当前用户的会话列表(单聊+群聊)", + Intent: "当你想遍历当前用户的所有会话(单聊+群聊)做统计、清理或批量处理时使用;默认读取一页,明确要求全部时使用 --page-all,CLI 会按服务端每页上限自动翻页并公开完整性 ledger;可用 --exclude-muted 排除已免打扰会话。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", @@ -299,47 +300,143 @@ var ConversationList = shortcut.Shortcut{ CLIPath: "chat +conversation-list", PrimaryCLIPath: "chat +conversation-list", }, - Description: "分页获取当前用户的全部会话列表(单聊+群聊)", + Description: "分页或一键全量获取当前用户的会话列表(单聊+群聊)", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", }, Selection: contract.SelectionSpec{ - AgentSummary: "分页获取当前用户的全部会话列表(单聊+群聊)", - UseWhen: []string{"当你想遍历当前用户的所有会话(单聊+群聊)做统计、清理或批量处理时使用;只读分页返回,可用 --exclude-muted 排除已免打扰会话。"}, + AgentSummary: "分页或一键全量获取当前用户的会话列表(单聊+群聊)", + UseWhen: []string{"当你想遍历当前用户的所有会话(单聊+群聊)做统计、清理或批量处理时使用;默认读取一页,明确要求全部时使用 --page-all,CLI 会按服务端每页上限自动翻页并公开完整性 ledger;可用 --exclude-muted 排除已免打扰会话。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws chat +conversation-list --limit 50"}, }, }, Flags: []shortcut.Flag{ - {Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页数量(1-100)"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页数量;--limit 必须在 1-100"}, {Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标(首次不传或 0)"}, {Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"}, + {Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部分页;--page-limit 仅与 --page-all 一起使用且范围 1-500"}, + {Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"}, + }, + Tips: []string{ + `dws chat +conversation-list --limit 50`, + `dws chat +conversation-list --page-all --limit 100`, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if limit := rt.Int("limit"); limit < 1 || limit > 100 { + return apperrors.NewValidation("--limit 必须在 1-100 之间;读取全部会话请使用 --page-all") + } + if !rt.Bool("page-all") && rt.Changed("page-limit") { + return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用") + } + if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > 500 { + return apperrors.NewValidation("--page-limit 必须在 1-500 之间") + } + return nil }, - Tips: []string{`dws chat +conversation-list --limit 50`}, Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{} - if rt.Int("limit") > 0 { - params["limit"] = rt.Int("limit") + cursor := int64(rt.Int("cursor")) + pageLimit := 1 + if rt.Bool("page-all") { + pageLimit = rt.Int("page-limit") } - if rt.Int("cursor") > 0 { - params["cursor"] = rt.Int("cursor") + convs := make([]map[string]any, 0) + seenConversations := map[string]bool{} + seenCursors := map[int64]bool{cursor: true} + pagesFetched := 0 + complete := false + hasMore := false + nextCursor := int64(0) + failures := make([]map[string]any, 0) + for pagesFetched < pageLimit { + params := map[string]any{"limit": rt.Int("limit")} + if cursor > 0 { + params["cursor"] = cursor + } + if rt.Bool("exclude-muted") { + params["excludeMuted"] = true + } + data, err := rt.CallMCPData("im", "list_all_conversations", params) + if err != nil { + if pagesFetched == 0 { + return err + } + failures = append(failures, map[string]any{"stage": "conversation-page", "cursor": cursor, "error": err.Error()}) + break + } + pagesFetched++ + for _, conversation := range conversationListProject(data) { + id := strings.TrimSpace(fmt.Sprint(conversation["openConversationId"])) + if id != "" && id != "" { + if seenConversations[id] { + continue + } + seenConversations[id] = true + } + convs = append(convs, conversation) + } + page := chatmsg.Pagination(data) + hasMoreValue, known := page["hasMore"].(bool) + hasMore = hasMoreValue + if !known { + failures = append(failures, map[string]any{"stage": "conversation-pagination", "error": "下层未返回 hasMore,无法证明结果完整"}) + break + } + if !hasMore { + complete = true + break + } + nextCursor, err = conversationPaginationCursor(page["nextCursor"]) + if err != nil || nextCursor == 0 || seenCursors[nextCursor] { + failures = append(failures, map[string]any{"stage": "conversation-pagination", "error": "hasMore=true 但 nextCursor 缺失、无效或未前进"}) + break + } + if !rt.Bool("page-all") { + break + } + seenCursors[nextCursor] = true + cursor = nextCursor } - if rt.Bool("exclude-muted") { - params["excludeMuted"] = true + if rt.Bool("page-all") && hasMore && pagesFetched == pageLimit { + failures = append(failures, map[string]any{"stage": "conversation-page-limit", "error": fmt.Sprintf("达到 --page-limit=%d,仍有更多会话", pageLimit)}) } - data, err := rt.CallMCPData("im", "list_all_conversations", params) - if err != nil { - return err + payload := map[string]any{ + "count": len(convs), + "conversations": convs, + "pagesFetched": pagesFetched, + "complete": complete, + "hasMore": hasMore, + "nextCursor": nextCursor, + "paginationKnown": len(failures) == 0 || hasMore, + "failedCount": len(failures), + "failures": failures, + "partial": len(failures) > 0, } - convs := conversationListProject(data) - payload := map[string]any{"count": len(convs), "conversations": convs} - chatmsg.ApplyPagination(payload, data) return rt.Output(payload) }, } +func conversationPaginationCursor(value any) (int64, error) { + switch typed := value.(type) { + case int: + return int64(typed), nil + case int64: + return typed, nil + case float64: + return int64(typed), nil + case string: + return strconv.ParseInt(strings.TrimSpace(typed), 10, 64) + default: + return 0, fmt.Errorf("unsupported cursor type %T", value) + } +} + // conversationListProject reshapes the raw list_all_conversations response into a // clean conversation list — clean output projection. Both the list // container and the per-item field names are probed defensively across candidate @@ -379,12 +476,12 @@ func conversationListResolveList(data map[string]any) []any { continue } if arr, ok := v.([]any); ok { - return arr + return unwrapConversationTuple(arr) } if inner, ok := v.(map[string]any); ok { for _, ik := range []string{"conversationList", "conversations", "list", "items", "result", "data"} { if arr, ok := inner[ik].([]any); ok { - return arr + return unwrapConversationTuple(arr) } } } @@ -392,6 +489,19 @@ func conversationListResolveList(data map[string]any) []any { return []any{} } +// unwrapConversationTuple handles gateway responses shaped as +// result:[conversationList,nextCursor,hasMore] while leaving ordinary arrays +// untouched. This prevents the first list from being mistaken for one row. +func unwrapConversationTuple(values []any) []any { + if len(values) == 0 { + return values + } + if nested, ok := values[0].([]any); ok { + return nested + } + return values +} + // conversationListFirst returns the first present candidate key's value. func conversationListFirst(m map[string]any, keys ...string) (any, bool) { for _, k := range keys { @@ -576,12 +686,12 @@ func conversationListTopResolveList(data map[string]any) []any { continue } if arr, ok := v.([]any); ok { - return arr + return unwrapConversationTuple(arr) } if inner, ok := v.(map[string]any); ok { for _, ik := range []string{"conversationList", "conversations", "topConversations", "list", "items", "result", "data"} { if arr, ok := inner[ik].([]any); ok { - return arr + return unwrapConversationTuple(arr) } } } @@ -1070,7 +1180,7 @@ var CategoryRemoveConversation = shortcut.Shortcut{ } func init() { - shortcut.Register( + shortcut.Register(withReviewedChatShortcutContracts( ConversationInfo, ConversationSetTop, ConversationMute, @@ -1091,5 +1201,5 @@ func init() { CategoryRename, CategoryAddConversation, CategoryRemoveConversation, - ) + )...) } diff --git a/internal/shortcut/chat/chat_conversation_projection_test.go b/internal/shortcut/chat/chat_conversation_projection_test.go index 26c25dfc..dee998aa 100644 --- a/internal/shortcut/chat/chat_conversation_projection_test.go +++ b/internal/shortcut/chat/chat_conversation_projection_test.go @@ -14,13 +14,15 @@ package chat import ( + "bytes" + "encoding/json" "reflect" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" ) -func TestConversationListTopProjectNormalizesAndFiltersType(t *testing.T) { +func TestCrossPlatformCoverageConversationListTopProjectNormalizesAndFiltersType(t *testing.T) { data := map[string]any{ "result": map[string]any{ "items": []any{ @@ -75,7 +77,7 @@ func TestConversationListTopProjectNormalizesAndFiltersType(t *testing.T) { } } -func TestConversationListTopRejectsInvalidType(t *testing.T) { +func TestCrossPlatformCoverageConversationListTopRejectsInvalidType(t *testing.T) { fake := &platformCoverageCaller{} helpers.InitDeps(fake) root := newPlatformCoverageRoot() @@ -87,3 +89,82 @@ func TestConversationListTopRejectsInvalidType(t *testing.T) { t.Fatalf("invalid --type reached lower tool %s/%s", fake.product, fake.tool) } } + +func TestCrossPlatformCoverageConversationListProjectUnwrapsGatewayTuple(t *testing.T) { + data := map[string]any{ + "result": []any{ + []any{map[string]any{"openConversationId": "cid-1", "title": "项目群"}}, + float64(2), + true, + }, + } + if got := conversationListProject(data); len(got) != 1 || got[0]["openConversationId"] != "cid-1" { + t.Fatalf("conversation tuple projection = %#v", got) + } + if got := conversationListTopProject(data); len(got) != 1 || got[0]["openConversationId"] != "cid-1" { + t.Fatalf("top tuple projection = %#v", got) + } +} + +func TestCrossPlatformCoverageConversationListPageAllFollowsTypedCursor(t *testing.T) { + fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{ + "im/list_all_conversations": { + `{"result":{"conversationList":[{"openConversationId":"cid-1","title":"一"}],"hasMore":true,"nextCursor":2}}`, + `{"result":{"conversationList":[{"openConversationId":"cid-2","title":"二"}],"hasMore":false}}`, + }, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+conversation-list", "--page-all"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 2 || fake.calls[1].args["cursor"] != int64(2) { + t.Fatalf("calls = %#v", fake.calls) + } +} + +func TestCrossPlatformCoverageConversationListSinglePagePreservesTypedCursor(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1","title":"一"}],"hasMore":true,"nextCursor":2}}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{"chat", "+conversation-list"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 1 { + t.Fatalf("calls = %#v, want exactly one page", fake.calls) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["hasMore"] != true || payload["nextCursor"] != float64(2) { + t.Fatalf("pagination payload = %#v", payload) + } +} + +func TestCrossPlatformCoverageConversationListDeduplicatesStableIDs(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1","title":"一"},{"openConversationId":"cid-1","title":"重复"}],"hasMore":false}}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{"chat", "+conversation-list"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["count"] != float64(1) { + t.Fatalf("deduplicated payload = %#v", payload) + } +} diff --git a/internal/shortcut/chat/chat_group.go b/internal/shortcut/chat/chat_group.go index 3f6fdb6a..3b596e06 100644 --- a/internal/shortcut/chat/chat_group.go +++ b/internal/shortcut/chat/chat_group.go @@ -23,16 +23,19 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" ) // ChatSearch searches groups by keyword (search_groups on the im server). var ChatSearch = shortcut.Shortcut{ - Service: "chat", - Command: "+chat-search", - Product: "im", - Description: "按关键词搜索群聊", - Intent: "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。", - Risk: shortcut.RiskRead, + Service: "chat", + Command: "+chat-search", + Aliases: []string{"+chat-group-search", "+search-group"}, + SinglePositionalAliasFor: "query", + Product: "im", + Description: "按关键词搜索群聊", + Intent: "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。", + Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", @@ -147,7 +150,7 @@ var ChatInviteURL = shortcut.Shortcut{ Command: "+chat-invite-url", Product: "im", Description: "获取群邀请链接", - Intent: "当你想拿到一条群邀请链接分享给别人加群时使用;只读生成链接,需传群 openConversationId,可用 --expires-seconds 设置有效期(0 表示永久)。", + Intent: "当你想拿到一条群邀请链接分享给别人加群时使用;--group 可传群 openConversationId 或群名,多命中会安全停止。可用 --expires-seconds 设置有效期(0 表示永久)。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", @@ -169,20 +172,32 @@ var ChatInviteURL = shortcut.Shortcut{ }, Selection: contract.SelectionSpec{ AgentSummary: "获取群邀请链接", - UseWhen: []string{"当你想拿到一条群邀请链接分享给别人加群时使用;只读生成链接,需传群 openConversationId,可用 --expires-seconds 设置有效期(0 表示永久)。"}, + UseWhen: []string{"当你想拿到一条群邀请链接分享给别人加群时使用;--group 可传群 openConversationId 或群名,多命中会安全停止。可用 --expires-seconds 设置有效期(0 表示永久)。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws chat +chat-invite-url --group "}, }, }, Flags: []shortcut.Flag{ - {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId;兼容直接传群名并唯一解析"}, + {Name: "chat-query", Type: shortcut.FlagString, Desc: "--group 的旧版自然名称入口", Hidden: true}, + {Name: "group-query", Type: shortcut.FlagString, Desc: "--chat-query 的兼容别名", Hidden: true}, {Name: "expires-seconds", Type: shortcut.FlagInt, Desc: "链接有效期(秒),0 表示永久"}, }, - Tips: []string{`dws chat +chat-invite-url --group `}, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"group", "chat-query", "group-query"}}, + }, + Tips: []string{ + `dws chat +chat-invite-url --group `, + `dws chat +chat-invite-url --group "项目群"`, + }, Execute: func(rt *shortcut.RuntimeContext) error { + groupID, err := resolveStableOrNamedChat(rt) + if err != nil { + return err + } params := map[string]any{ - "openConversationId": rt.Str("group"), - "cid": rt.Str("group"), + "openConversationId": groupID, + "cid": groupID, } if rt.Changed("expires-seconds") { params["expiresSeconds"] = rt.Int("expires-seconds") @@ -777,7 +792,7 @@ var ChatBots = shortcut.Shortcut{ Command: "+chat-bots", Product: "bot", Description: "查看群内所有机器人", - Intent: "当你想查看某个群里已添加了哪些机器人时使用;需传群 openConversationId,只读返回群内机器人列表(含 openBotId,供后续移除)。", + Intent: "当你想查看某个群里已添加了哪些机器人时使用;--group 可传群 openConversationId 或群名,多命中会安全停止。只读返回机器人列表(含 openBotId,供后续移除)。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", @@ -799,17 +814,29 @@ var ChatBots = shortcut.Shortcut{ }, Selection: contract.SelectionSpec{ AgentSummary: "查看群内所有机器人", - UseWhen: []string{"当你想查看某个群里已添加了哪些机器人时使用;需传群 openConversationId,只读返回群内机器人列表(含 openBotId,供后续移除)。"}, + UseWhen: []string{"当你想查看某个群里已添加了哪些机器人时使用;--group 可传群 openConversationId 或群名,多命中会安全停止。只读返回机器人列表(含 openBotId,供后续移除)。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws chat +chat-bots --group "}, }, }, Flags: []shortcut.Flag{ - {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId;兼容直接传群名并唯一解析"}, + {Name: "chat-query", Type: shortcut.FlagString, Desc: "--group 的旧版自然名称入口", Hidden: true}, + {Name: "group-query", Type: shortcut.FlagString, Desc: "--chat-query 的兼容别名", Hidden: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"group", "chat-query", "group-query"}}, + }, + Tips: []string{ + `dws chat +chat-bots --group `, + `dws chat +chat-bots --group "项目群"`, }, - Tips: []string{`dws chat +chat-bots --group `}, Execute: func(rt *shortcut.RuntimeContext) error { - data, err := rt.CallMCPData("bot", "list_group_bots", map[string]any{"openConversationId": rt.Str("group")}) + groupID, err := resolveStableOrNamedChat(rt) + if err != nil { + return err + } + data, err := rt.CallMCPData("bot", "list_group_bots", map[string]any{"openConversationId": groupID}) if err != nil { return err } @@ -818,6 +845,21 @@ var ChatBots = shortcut.Shortcut{ }, } +// resolveStableOrNamedChat gives read-only group shortcuts one safe target +// contract. Stable cid values bypass search; natural names always go through +// the shared exact-match, full-pagination and ambiguity rules. +func resolveStableOrNamedChat(rt *shortcut.RuntimeContext) (string, error) { + resolved, err := targetresolver.ResolveChatTarget( + rt, + strings.TrimSpace(rt.Str("group")), + strings.TrimSpace(rt.StrFirst("chat-query", "group-query")), + ) + if err != nil { + return "", err + } + return resolved.Selected.OpenConversationID, nil +} + // chatBotsProject reshapes list_group_bots into a clean bot list // ({openBotId, name}) — clean output projection. List container and // per-item field names are probed defensively across candidate keys so shape @@ -1461,7 +1503,7 @@ var ChatRoleQueryUser = shortcut.Shortcut{ } func init() { - shortcut.Register( + shortcut.Register(withReviewedChatShortcutContracts( ChatSearch, ChatMembersGet, ChatTransferOwner, @@ -1491,5 +1533,5 @@ func init() { ChatRoleSetUser, ChatRoleRemoveUser, ChatRoleQueryUser, - ) + )...) } diff --git a/internal/shortcut/chat/chat_message.go b/internal/shortcut/chat/chat_message.go index 5a578901..352d454f 100644 --- a/internal/shortcut/chat/chat_message.go +++ b/internal/shortcut/chat/chat_message.go @@ -21,8 +21,8 @@ import ( "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" ) @@ -110,8 +110,8 @@ var MessagesSendByWebhook = shortcut.Shortcut{ Service: "chat", Command: "+messages-send-by-webhook", Product: "bot", - Description: "自定义机器人 Webhook 发送群消息", - Intent: "当你只有自定义机器人的 Webhook token、想往其所在群推送消息时使用;会实际通过 Webhook 发群消息,需传 token、标题、正文,可 @手机号/userId 或 @所有人。", + Description: "兼容旧入口的自定义机器人 Webhook 群消息发送", + Intent: "只有既有自动化明确依赖 +messages-send-by-webhook 兼容路径、暂时不能迁移统一身份入口时使用", Risk: shortcut.RiskWrite, Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", @@ -125,15 +125,15 @@ var MessagesSendByWebhook = shortcut.Shortcut{ CLIPath: "chat +messages-send-by-webhook", PrimaryCLIPath: "chat +messages-send-by-webhook", }, - Description: "自定义机器人 Webhook 发送群消息", + Description: "兼容旧入口的自定义机器人 Webhook 群消息发送", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", }, Selection: contract.SelectionSpec{ - AgentSummary: "自定义机器人 Webhook 发送群消息", - UseWhen: []string{"当你只有自定义机器人的 Webhook token、想往其所在群推送消息时使用;会实际通过 Webhook 发群消息,需传 token、标题、正文,可 @手机号/userId 或 @所有人。"}, + AgentSummary: "兼容旧入口的自定义机器人 Webhook 群消息发送", + UseWhen: []string{"只有既有自动化明确依赖 +messages-send-by-webhook 兼容路径、暂时不能迁移统一身份入口时使用"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws chat +messages-send-by-webhook --token --title \"告警\" --text \"CPU 超 90%\" --at-all"}, }, @@ -172,17 +172,56 @@ var MessagesRecall = shortcut.Shortcut{ Command: "+messages-recall", Product: "im", Description: "撤回当前用户发送的消息", - Intent: "当你想撤回当前用户刚发出的某条消息时使用;会实际撤回消息,需传会话 openConversationId 和消息 openMessageId。", + Intent: "当你想撤回当前用户刚发出的某条消息时使用;会实际撤回消息。推荐同时传会话 openConversationId 和消息 openMessageId;若只传一个消息 ID,CLI 会先只读查询消息详情并补齐会话 ID。兼容 --message-id/--message-ids 的单值写法。", Risk: shortcut.RiskWrite, Flags: []shortcut.Flag{ - {Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, - {Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true}, + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId;省略时从消息详情解析"}, + {Name: "group", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true}, + {Name: "id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true}, + {Name: "chat", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true}, + {Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId;一次只能撤回一个消息 ID;--message-ids 仅接受单值"}, + {Name: "message-id", Type: shortcut.FlagString, Desc: "--msg-id 的兼容别名", Hidden: true}, + {Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "--msg-id 的兼容单值别名;不支持批量撤回", Hidden: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"msg-id", "message-id", "message-ids"}}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"msg-id"}, Description: "一次只能撤回一个消息 ID;--message-ids 仅接受单值"}, }, Tips: []string{`dws chat +messages-recall --conversation-id --msg-id `}, + Validate: func(rt *shortcut.RuntimeContext) error { + messageIDs := uniqueShortcutStrings(append( + []string{rt.StrFirst("msg-id", "message-id")}, + rt.StrSlice("message-ids")..., + )) + if len(messageIDs) != 1 { + return apperrors.NewValidation("撤回一次只接受一个消息 ID;请通过 --msg-id 或单值 --message-ids 传入") + } + return nil + }, Execute: func(rt *shortcut.RuntimeContext) error { + messageIDs := uniqueShortcutStrings(append( + []string{rt.StrFirst("msg-id", "message-id")}, + rt.StrSlice("message-ids")..., + )) + messageID := messageIDs[0] + conversationID := strings.TrimSpace(rt.StrFirst("conversation-id", "group", "id", "chat")) + if conversationID == "" { + data, err := rt.CallMCPData("im", "list_messages_by_ids", map[string]any{"openMsgIds": []string{messageID}}) + if err != nil { + return err + } + messages := listMessagesResolveMaps(data) + if len(messages) == 0 { + return apperrors.NewValidation("无法根据消息 ID 查询到会话;请补充 --conversation-id") + } + conversationID = strings.TrimSpace(fmt.Sprint(chatmsg.ConversationID(messages[0]))) + if conversationID == "" || conversationID == "" { + return apperrors.NewValidation("消息详情未返回会话 ID;请补充 --conversation-id") + } + } return rt.CallMCP("recall_message", map[string]any{ - "openConversationId": rt.Str("conversation-id"), - "openMessageId": rt.Str("msg-id"), + "openConversationId": conversationID, + "openMessageId": messageID, }) }, } @@ -314,46 +353,22 @@ func listMessageProjectOne(m map[string]any) map[string]any { } func listMessageProjectOneWithReactions(m map[string]any, includeReactions bool) map[string]any { - row := map[string]any{} - if v, ok := listMessagesFirst(m, "openMessageId", "openMsgId", "messageId", "msgId"); ok { - row["messageId"] = v - } - if v, ok := listMessagesFirst(m, "senderOpenDingTalkId", "senderUserId", "senderId", "senderStaffId"); ok { - row["senderId"] = v - } - if v, ok := listMessagesFirst(m, "msgType", "messageType", "type"); ok { - row["msgType"] = v - } - if v, ok := listMessagesFirst(m, "createTime", "sendTime", "gmtCreate", "messageTime"); ok { - row["createTime"] = v - } - if text := chatmsg.Text(m); text != nil { - row["text"] = text - } - if conversationID := chatmsg.ConversationID(m); conversationID != nil { - row["conversationId"] = conversationID - } - if threadID := chatmsg.ThreadID(m); threadID != nil { - row["threadId"] = threadID - } - if updateTime := chatmsg.UpdateTime(m); updateTime != nil { - row["updateTime"] = updateTime - } - if includeReactions { - if reactions := chatmsg.Reactions(m); len(reactions) > 0 { - row["reactions"] = reactions + row := chatmsg.ProjectMessageV1(m, includeReactions) + // The established mget/list projection omits absent scalar fields; keep + // that wire behavior even though the shared chat/search view retains them. + for _, key := range []string{"sender", "text", "createTime"} { + if row[key] == nil { + delete(row, key) } } - if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 { - row["quotedMessage"] = quoted + // Keep the historical mget/list msgType alias while adding the canonical + // messageType field from MessageViewV1. + if messageType := chatmsg.MessageType(m); messageType != nil { + row["msgType"] = messageType } - if resources := chatmsg.ResourcesDeep(m); len(resources) > 0 { - row["resourceRefs"] = resources - } - projectForwarded := func(item map[string]any) map[string]any { + if forwarded := chatmsg.Forwarded(m, func(item map[string]any) map[string]any { return listMessageProjectOneWithReactions(item, includeReactions) - } - if forwarded := chatmsg.Forwarded(m, projectForwarded); len(forwarded) > 0 { + }); len(forwarded) > 0 { row["forwarded"] = forwarded } return row @@ -665,20 +680,35 @@ var MessagesMget = shortcut.Shortcut{ } } notFound := make([]string, 0) + failures := make([]map[string]any, 0) for _, id := range ids { if !found[id] { notFound = append(notFound, id) + failures = append(failures, map[string]any{ + "stage": "mget", + "messageId": id, + "error": "下层未返回该消息", + }) } } payload := map[string]any{ + "contractVersion": chatmsg.MessageListContractVersion, "requestedCount": len(ids), "foundCount": len(ids) - len(notFound), "notFoundCount": len(notFound), "notFoundMessageIds": notFound, "messages": messages, + "complete": len(notFound) == 0, + "hasMore": false, + "nextCursor": "", + "paginationKnown": true, + "pagesFetched": 1, + "enrichedCount": 0, + "failedCount": len(failures), + "failures": failures, } if rt.Bool("download-resources") { - payload["resourceDownloads"] = DownloadMessageResources(rt, rawMessages, "") + AttachMessageResourceDownloads(payload, DownloadMessageResources(rt, rawMessages, "")) } return rt.Output(payload) }, @@ -690,7 +720,7 @@ func MessageResourceDownloadFlags() []shortcut.Flag { return []shortcut.Flag{ {Name: "download-resources", Type: shortcut.FlagBool, Desc: "自动下载消息中的全部可识别 mediaId/fileId 资源"}, {Name: "output-dir", Type: shortcut.FlagString, Default: "./downloads", Desc: "资源输出目录;必须是工作目录内的相对路径,禁止绝对路径和 .. 逃逸"}, - {Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖同名资源文件(默认拒绝)"}, + {Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖工作目录内已存在的本地输出文件(默认拒绝)"}, } } @@ -902,6 +932,47 @@ func DownloadMessageResources( } } +// AttachMessageResourceDownloads publishes the download ledger and folds any +// resource failure into the task-level completeness contract without dropping +// successfully read messages or downloaded files. +func AttachMessageResourceDownloads(payload, ledger map[string]any) { + payload["resourceDownloads"] = ledger + failed := messageLedgerInt(ledger["failedCount"]) + if failed == 0 { + return + } + payload["complete"] = false + payload["failedCount"] = messageLedgerInt(payload["failedCount"]) + failed + taskFailures, _ := payload["failures"].([]map[string]any) + resourceFailures, _ := ledger["failures"].([]map[string]any) + for _, failure := range resourceFailures { + row := make(map[string]any, len(failure)+1) + row["stage"] = "resource-download" + for key, value := range failure { + row[key] = value + } + taskFailures = append(taskFailures, row) + } + payload["failures"] = taskFailures +} + +func messageLedgerInt(value any) int { + switch typed := value.(type) { + case int: + return typed + case int32: + return int(typed) + case int64: + return int(typed) + case float32: + return int(typed) + case float64: + return int(typed) + default: + return 0 + } +} + func disambiguateResourceDownloadFilename(filename string, used map[string]bool) string { if !used[strings.ToLower(filename)] { return filename @@ -1168,7 +1239,7 @@ var MessagesSendCard = shortcut.Shortcut{ Command: "+messages-send-card", Product: "im", Description: "创建流式卡片,可在同一次调用中写入内容并结束", - Intent: "当你要发送一张流式卡片消息时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成,避免卡片停留在加载中。", + Intent: "当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。", Risk: shortcut.RiskWrite, Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", @@ -1190,7 +1261,7 @@ var MessagesSendCard = shortcut.Shortcut{ }, Selection: contract.SelectionSpec{ AgentSummary: "创建流式卡片,可在同一次调用中写入内容并结束", - UseWhen: []string{"当你要发送一张流式卡片消息时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成,避免卡片停留在加载中。"}, + UseWhen: []string{"当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。"}, AvoidWhen: []string{"已有 bizId、只需要追加或更新现有卡片内容时使用 +messages-update-card"}, Examples: []string{ "dws chat +messages-send-card --group --content \"任务已完成\"", @@ -1221,7 +1292,7 @@ var MessagesSendCard = shortcut.Shortcut{ `dws chat +messages-send-card --group --content "任务已完成"`, }, Validate: func(rt *shortcut.RuntimeContext) error { - if status := rt.Int("flow-status"); status < 1 || status > 5 { + if status := rt.Int("flow-status"); !validCardFlowStatus(status) { return fmt.Errorf("--flow-status 必须在 1-5 之间") } if rt.Changed("flow-status") && rt.Str("content") == "" { @@ -1253,11 +1324,12 @@ var MessagesSendCard = shortcut.Shortcut{ status := rt.Int("flow-status") if rt.DryRun() { return rt.Output(map[string]any{ - "dry_run": true, - "executed": false, - "preview_kind": "plan", - "actionCount": 2, - "failedCount": 0, + "contractVersion": currentCardWorkflowContract.Version, + "dry_run": true, + "executed": false, + "preview_kind": "plan", + "actionCount": 2, + "failedCount": 0, "actions": []map[string]any{ { "tool": "create_and_send_card", @@ -1291,11 +1363,12 @@ var MessagesSendCard = shortcut.Shortcut{ return fmt.Errorf("卡片已创建(bizId=%s),但自动更新失败: %w", bizID, err) } return rt.Output(map[string]any{ - "ok": true, - "bizId": bizID, - "flowStatus": status, - "created": created, - "updated": updated, + "contractVersion": currentCardWorkflowContract.Version, + "ok": true, + "bizId": bizID, + "flowStatus": status, + "created": created, + "updated": updated, }) }, } @@ -1362,7 +1435,7 @@ var MessagesUpdateCard = shortcut.Shortcut{ Command: "+messages-update-card", Product: "im", Description: "流式更新卡片内容(最后一次 --flow-status 应为 3)", - Intent: "当你要向已发送的流式卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus(最后一次应为 3 表示完成)。", + Intent: "当你要向已发送的流式文本卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus 1-5(最后一次应为 3 表示完成)。当前不支持 Card JSON 组件或 action callback。", Risk: shortcut.RiskWrite, Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", @@ -1384,7 +1457,7 @@ var MessagesUpdateCard = shortcut.Shortcut{ }, Selection: contract.SelectionSpec{ AgentSummary: "流式更新卡片内容(最后一次 --flow-status 应为 3)", - UseWhen: []string{"当你要向已发送的流式卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus(最后一次应为 3 表示完成)。"}, + UseWhen: []string{"当你要向已发送的流式文本卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus 1-5(最后一次应为 3 表示完成)。当前不支持 Card JSON 组件或 action callback。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws chat +messages-update-card --biz-id --content \"内容\" --flow-status 3"}, }, @@ -1392,9 +1465,18 @@ var MessagesUpdateCard = shortcut.Shortcut{ Flags: []shortcut.Flag{ {Name: "biz-id", Type: shortcut.FlagString, Desc: "send-card 返回的卡片业务 ID", Required: true}, {Name: "content", Type: shortcut.FlagString, Desc: "卡片消息内容", Required: true}, - {Name: "flow-status", Type: shortcut.FlagInt, Desc: "流式状态 1处理中/2输入中/3完成/4执行中/5错误", Required: true}, + {Name: "flow-status", Type: shortcut.FlagInt, Desc: "流式状态 1处理中/2输入中/3完成/4执行中/5错误;--flow-status 必须在 1-5 之间", Required: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"flow-status"}, Description: "--flow-status 必须在 1-5 之间"}, }, Tips: []string{`dws chat +messages-update-card --biz-id --content "内容" --flow-status 3`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if !validCardFlowStatus(rt.Int("flow-status")) { + return fmt.Errorf("--flow-status 必须在 1-5 之间") + } + return nil + }, Execute: func(rt *shortcut.RuntimeContext) error { return rt.CallMCP("update_streaming_card", map[string]any{ "bizId": rt.Str("biz-id"), @@ -1735,7 +1817,7 @@ var MessagesUnsetTop = shortcut.Shortcut{ } func init() { - shortcut.Register( + shortcut.Register(withReviewedChatShortcutContracts( MessagesSendByBot, MessagesBatchSendByBot, MessagesSendByWebhook, @@ -1764,5 +1846,5 @@ func init() { MessagesListPin, MessagesSetTop, MessagesUnsetTop, - ) + )...) } diff --git a/internal/shortcut/chat/chat_message_project_test.go b/internal/shortcut/chat/chat_message_project_test.go index 41065aea..2f623873 100644 --- a/internal/shortcut/chat/chat_message_project_test.go +++ b/internal/shortcut/chat/chat_message_project_test.go @@ -20,7 +20,7 @@ import ( const testCipher = "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP||2||1||1" -func TestListMessageProjectOne(t *testing.T) { +func TestCrossPlatformCoverageListMessageProjectOne(t *testing.T) { // full field mapping + forwarded expansion; an encrypted body is marked (no // cross-conversation recovery), not leaked as base64. row := listMessageProjectOne(map[string]any{ @@ -75,7 +75,35 @@ func TestListMessageProjectOne(t *testing.T) { } } -func TestListPinProjectPreservesThreadIdentity(t *testing.T) { +func TestCrossPlatformCoverageAttachMessageResourceDownloadsPreservesMessagesAndMarksIncomplete(t *testing.T) { + payload := map[string]any{ + "messages": []map[string]any{{"messageId": "msg-1"}}, + "complete": true, + "failedCount": 0, + "failures": []map[string]any{}, + } + ledger := map[string]any{ + "failedCount": 1, + "failures": []map[string]any{{ + "messageId": "msg-1", + "error": "download failed", + }}, + } + AttachMessageResourceDownloads(payload, ledger) + if payload["complete"] != false || payload["failedCount"] != 1 { + t.Fatalf("task completeness = %#v", payload) + } + messages, _ := payload["messages"].([]map[string]any) + if len(messages) != 1 || messages[0]["messageId"] != "msg-1" { + t.Fatalf("messages were dropped: %#v", messages) + } + failures, _ := payload["failures"].([]map[string]any) + if len(failures) != 1 || failures[0]["stage"] != "resource-download" { + t.Fatalf("resource failures = %#v", failures) + } +} + +func TestCrossPlatformCoverageListPinProjectPreservesThreadIdentity(t *testing.T) { got := listPinProject(map[string]any{ "result": map[string]any{ "messages": []any{ diff --git a/internal/shortcut/chat/compatibility_coverage_test.go b/internal/shortcut/chat/compatibility_coverage_test.go index e7d1d057..82b3defd 100644 --- a/internal/shortcut/chat/compatibility_coverage_test.go +++ b/internal/shortcut/chat/compatibility_coverage_test.go @@ -94,6 +94,13 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) { wantTool: "search_groups", wantArgs: map[string]any{"keyword": "树莓派", "limit": 5}, }, + { + name: "chat search command alias and positional query", + argv: []string{"chat", "+search-group", "树莓派", "--yes"}, + wantProduct: "im", + wantTool: "search_groups", + wantArgs: map[string]any{"keyword": "树莓派", "limit": 20}, + }, { name: "bot find keyword", argv: []string{"chat", "+bot-find", "--keyword", "日报", "--yes"}, @@ -175,6 +182,16 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) { "openConversationId": "cid-1", }, }, + { + name: "message recall plural single id alias", + argv: []string{"chat", "+messages-recall", "--conversation-id", "cid-1", "--message-ids", "msg-1", "--yes"}, + wantProduct: "im", + wantTool: "recall_message", + wantArgs: map[string]any{ + "openConversationId": "cid-1", + "openMessageId": "msg-1", + }, + }, } for _, tc := range tests { @@ -203,6 +220,15 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) { } } +func TestCrossPlatformCoverageConversationNotificationSubSwitchesPublishMutePrerequisite(t *testing.T) { + for _, command := range []shortcut.Shortcut{ConversationMuteAtAll, ConversationMuteRedEnvelope} { + if !strings.Contains(command.Intent, "+conversation-mute") || + !strings.Contains(command.Intent, "NotificationOffNotEnabled") { + t.Errorf("%s intent does not publish the live platform prerequisite: %q", command.Command, command.Intent) + } + } +} + func TestCrossPlatformCoverageChatIDHelpers(t *testing.T) { t.Run("recognize open DingTalk IDs", func(t *testing.T) { tests := []struct { @@ -248,7 +274,7 @@ func TestCrossPlatformCoverageChatIDHelpers(t *testing.T) { }) } -func TestChatMuteMemberResolvesUserIDToOpenDingTalkID(t *testing.T) { +func TestCrossPlatformCoverageChatMuteMemberResolvesUserIDToOpenDingTalkID(t *testing.T) { fake := &muteMemberResolutionCaller{} helpers.InitDeps(fake) root := newPlatformCoverageRoot() @@ -277,7 +303,7 @@ func TestChatMuteMemberResolvesUserIDToOpenDingTalkID(t *testing.T) { } } -func TestConversationCategoryTitleValidation(t *testing.T) { +func TestCrossPlatformCoverageConversationCategoryTitleValidation(t *testing.T) { tests := []struct { name string argv []string diff --git a/internal/shortcut/chat/coverage_completion_test.go b/internal/shortcut/chat/coverage_completion_test.go index 8f8b47f1..b19af0de 100644 --- a/internal/shortcut/chat/coverage_completion_test.go +++ b/internal/shortcut/chat/coverage_completion_test.go @@ -7,6 +7,8 @@ import ( "context" "errors" "fmt" + "os" + "path/filepath" "reflect" "strconv" "strings" @@ -91,6 +93,62 @@ func TestCrossPlatformCoverageConversationAndGroupListExecution(t *testing.T) { } } +func TestCrossPlatformCoverageConversationListFailureBoundaries(t *testing.T) { + for _, tail := range [][]string{ + {"--limit", "0"}, + {"--limit", "101"}, + {"--page-limit", "2"}, + {"--page-all", "--page-limit", "0"}, + {"--page-all", "--page-limit", "501"}, + } { + root := newPlatformCoverageRoot() + root.SetArgs(append([]string{"chat", "+conversation-list"}, tail...)) + if err := root.Execute(); err == nil { + t.Errorf("invalid conversation args succeeded: %v", tail) + } + } + + for _, tc := range []struct { + name string + caller *larkAlignmentCaller + args []string + wantError bool + }{ + {name: "first read failure", caller: &larkAlignmentCaller{failProductTool: "im/list_all_conversations"}, wantError: true}, + {name: "later read failure", caller: &larkAlignmentCaller{ + sequenceResponses: map[string][]string{"im/list_all_conversations": {`{"result":{"conversationList":[],"hasMore":true,"nextCursor":2}}`}}, + failProductToolAt: map[string]int{"im/list_all_conversations": 2}, + }, args: []string{"--page-all"}}, + {name: "missing pagination", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_all_conversations": `{"result":{"conversationList":[]}}`}}}, + {name: "invalid cursor", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_all_conversations": `{"result":{"conversationList":[],"hasMore":true,"nextCursor":"bad"}}`}}}, + {name: "stalled cursor", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_all_conversations": `{"result":{"conversationList":[],"hasMore":true,"nextCursor":2}}`}}, args: []string{"--page-all", "--cursor", "2"}}, + {name: "page limit", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_all_conversations": `{"result":{"conversationList":[],"hasMore":true,"nextCursor":2}}`}}, args: []string{"--page-all", "--page-limit", "1"}}, + } { + t.Run(tc.name, func(t *testing.T) { + helpers.InitDeps(tc.caller) + root := newPlatformCoverageRoot() + root.SetArgs(append([]string{"chat", "+conversation-list"}, tc.args...)) + err := root.Execute() + if (err != nil) != tc.wantError { + t.Fatalf("error = %v, wantError=%v", err, tc.wantError) + } + }) + } + + for value, want := range map[any]int64{int(1): 1, int64(2): 2, float64(3): 3, "4": 4} { + got, err := conversationPaginationCursor(value) + if err != nil || got != want { + t.Fatalf("cursor %#v = %d, %v; want %d", value, got, err, want) + } + } + if _, err := conversationPaginationCursor(struct{}{}); err == nil { + t.Fatal("unsupported cursor unexpectedly accepted") + } + if got := unwrapConversationTuple(nil); got != nil { + t.Fatalf("empty tuple = %#v", got) + } +} + func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) { tests := []struct { name string @@ -98,6 +156,12 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) { args []string wantError string }{ + { + name: "owner query resolution", + caller: &larkAlignmentCaller{failProductTool: "contact/search_contact_by_key_word"}, + args: []string{"chat", "+chat-create", "--name", "群", "--users", "u1", "--owner-query", "群主", "--yes"}, + wantError: "fixture lower call failed", + }, { name: "current profile call", caller: &larkAlignmentCaller{failProductTool: "contact/get_current_user_profile"}, @@ -152,6 +216,18 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) { args: []string{"chat", "+feed-group-query-item", "--category-id", "1", "--conversation-ids", "cid"}, wantError: "fixture lower call failed", }, + { + name: "chat update resolution", + caller: &larkAlignmentCaller{responses: map[string]string{"im/search_groups": `{"result":[]}`}}, + args: []string{"chat", "+chat-update", "--group", "missing", "--name", "新群", "--yes"}, + wantError: "没有找到", + }, + { + name: "reply write", + caller: &larkAlignmentCaller{failProductTool: "chat/send_personal_message"}, + args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--ref-sender", "D-sender", "--text", "收到", "--yes"}, + wantError: "fixture lower call failed", + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -177,6 +253,27 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) { dry.calls[1].tool != "create_group_conversation" { t.Fatalf("chat-create dry-run calls = %#v", dry.calls) } + + external := &larkAlignmentCaller{responses: map[string]string{ + "contact/search_contact_by_key_word": `{"result":[{"name":"外部联系人","openDingTalkId":"D-external"}]}`, + }} + helpers.InitDeps(external) + root = newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+chat-create", "--name", "群", "--owner-open-dingtalk-id", "D-owner", "--member-query", "外部联系人", "--yes"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } +} + +func TestCrossPlatformCoverageNaturalGroupReadFailures(t *testing.T) { + for _, command := range []string{"+chat-invite-url", "+chat-bots"} { + helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{"im/search_groups": `{"result":[]}`}}) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", command, "--group", "missing"}) + if err := root.Execute(); err == nil { + t.Errorf("%s missing natural group unexpectedly resolved", command) + } + } } func TestCrossPlatformCoverageReplyShapeHelpers(t *testing.T) { @@ -240,6 +337,45 @@ func TestCrossPlatformCoverageFlagAndMgetValidation(t *testing.T) { } } +func TestCrossPlatformCoverageRecallCardAndLedgerBoundaries(t *testing.T) { + for _, tc := range []struct { + name string + caller *larkAlignmentCaller + args []string + }{ + {name: "multiple recall ids", caller: &larkAlignmentCaller{}, args: []string{"chat", "+messages-recall", "--message-ids", "m1,m2", "--yes"}}, + {name: "recall lookup failure", caller: &larkAlignmentCaller{failProductTool: "im/list_messages_by_ids"}, args: []string{"chat", "+messages-recall", "--msg-id", "m1", "--yes"}}, + {name: "recall lookup empty", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_messages_by_ids": `{"result":[]}`}}, args: []string{"chat", "+messages-recall", "--msg-id", "m1", "--yes"}}, + {name: "recall lookup missing conversation", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_messages_by_ids": `{"result":[{"openMessageId":"m1"}]}`}}, args: []string{"chat", "+messages-recall", "--msg-id", "m1", "--yes"}}, + {name: "card status low", caller: &larkAlignmentCaller{}, args: []string{"chat", "+messages-update-card", "--biz-id", "b", "--content", "x", "--flow-status", "0", "--yes"}}, + {name: "card status high", caller: &larkAlignmentCaller{}, args: []string{"chat", "+messages-update-card", "--biz-id", "b", "--content", "x", "--flow-status", "6", "--yes"}}, + } { + t.Run(tc.name, func(t *testing.T) { + helpers.InitDeps(tc.caller) + root := newPlatformCoverageRoot() + root.SetArgs(tc.args) + if err := root.Execute(); err == nil { + t.Fatal("failure boundary unexpectedly succeeded") + } + }) + } + + helpers.InitDeps(&larkAlignmentCaller{}) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+messages-update-card", "--biz-id", "b", "--content", "x", "--flow-status", "3", "--yes"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + for value, want := range map[any]int{ + int(1): 1, int32(2): 2, int64(3): 3, float32(4): 4, float64(5): 5, "bad": 0, + } { + if got := messageLedgerInt(value); got != want { + t.Fatalf("messageLedgerInt(%#v) = %d, want %d", value, got, want) + } + } +} + func TestCrossPlatformCoverageFeedCompleteAndExcludeMuted(t *testing.T) { fake := &larkAlignmentCaller{category: `{"result":{"hasMore":false,"list":[{"openConversationId":"cid"}]}}`} helpers.InitDeps(fake) @@ -388,6 +524,90 @@ func TestCrossPlatformCoverageUnifiedSendUnsupportedIdentityGuard(t *testing.T) } } +func TestCrossPlatformCoverageUnifiedSendGroupFileAndBatchBoundaries(t *testing.T) { + readGroupFile := messagesSendReadGroupFile + t.Cleanup(func() { messagesSendReadGroupFile = readGroupFile }) + + for _, tail := range [][]string{ + {"--identity", "bot", "--robot-code", "r", "--group", "cid", "--groups", "c1", "--text", "x"}, + {"--identity", "bot", "--robot-code", "r", "--groups", "c1", "--groups-file", "groups.txt", "--text", "x"}, + {"--identity", "webhook", "--webhook-token", "token", "--chat-query", "群", "--text", "x"}, + {"--identity", "bot", "--robot-code", "r", "--groups", "", "--text", "x"}, + {"--identity", "bot", "--robot-code", "r", "--groups", strings.Join(makeIDs(101), ","), "--text", "x"}, + {"--identity", "bot", "--robot-code", "r", "--groups-file", "/absolute.txt", "--text", "x"}, + {"--identity", "bot", "--robot-code", "r", "--groups-file", "../escape.txt", "--text", "x"}, + {"--identity", "bot", "--robot-code", "r", "--groups-file", "missing.txt", "--text", "x"}, + } { + helpers.InitDeps(&larkAlignmentCaller{}) + root := newPlatformCoverageRoot() + root.SetArgs(append([]string{"chat", "+messages-send"}, append(tail, "--yes")...)) + if err := root.Execute(); err == nil { + t.Errorf("invalid unified send args succeeded: %v", tail) + } + } + + temp := t.TempDir() + if err := os.Mkdir(filepath.Join(temp, "groups-dir"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(temp, "large.txt"), make([]byte, messagesSendMaxGroupFileSize+1), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(temp, "unreadable.txt"), []byte("cid"), 0o600); err != nil { + t.Fatal(err) + } + messagesSendReadGroupFile = func(path string) ([]byte, error) { + if filepath.Base(path) == "unreadable.txt" { + return nil, os.ErrPermission + } + return readGroupFile(path) + } + t.Chdir(temp) + for _, path := range []string{"groups-dir", "large.txt", "unreadable.txt"} { + helpers.InitDeps(&larkAlignmentCaller{}) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+messages-send", "--identity", "bot", "--robot-code", "r", "--groups-file", path, "--text", "x", "--yes"}) + if err := root.Execute(); err == nil { + t.Errorf("invalid group file %q succeeded", path) + } + } + + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+messages-send", "--identity", "bot", "--robot-code", "r", + "--groups", "c1,c2", "--text", "x", "--at-user-ids", "u1", + "--at-open-dingtalk-ids", "D1", "--at-all", "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 2 { + t.Fatalf("batch calls = %#v", fake.calls) + } + for _, call := range fake.calls { + if !reflect.DeepEqual(call.args["atUserIds"], []string{"u1"}) || + !reflect.DeepEqual(call.args["atOpendingtalkIds"], []string{"D1"}) || + call.args["isAtAll"] != "true" { + t.Fatalf("batch mention args = %#v", call.args) + } + } + + shortcut.Register(shortcut.Shortcut{ + Service: "chat", Command: "+coverage-unified-execute-error", + Flags: MessagesSend.Flags, Execute: executeMessagesSend, + }) + root = newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+coverage-unified-execute-error", "--identity", "bot", "--robot-code", "r", + "--groups-file", "missing-again.txt", "--text", "x", + }) + if err := root.Execute(); err == nil { + t.Fatal("execute-time group file error was swallowed") + } +} + func TestCrossPlatformCoverageMuteMemberResolutionFailures(t *testing.T) { tests := []struct { name string diff --git a/internal/shortcut/chat/file_replace.go b/internal/shortcut/chat/file_replace.go new file mode 100644 index 00000000..6570c725 --- /dev/null +++ b/internal/shortcut/chat/file_replace.go @@ -0,0 +1,11 @@ +// 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. + +//go:build !windows + +package chat + +import "os" + +var replaceFileAtomically = os.Rename diff --git a/internal/shortcut/chat/file_replace_windows.go b/internal/shortcut/chat/file_replace_windows.go new file mode 100644 index 00000000..9c208ccb --- /dev/null +++ b/internal/shortcut/chat/file_replace_windows.go @@ -0,0 +1,11 @@ +// 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. + +//go:build windows + +package chat + +import "golang.org/x/sys/windows" + +var replaceFileAtomically = windows.Rename diff --git a/internal/shortcut/chat/gap_fill_test.go b/internal/shortcut/chat/gap_fill_test.go index 0af89941..d2c618b9 100644 --- a/internal/shortcut/chat/gap_fill_test.go +++ b/internal/shortcut/chat/gap_fill_test.go @@ -17,13 +17,22 @@ import ( "strings" "testing" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) -func TestMessagesSendPublishesCompleteIdentityConstraintInputs(t *testing.T) { +type chatOutputErrorWriter struct { + err error +} + +func (w chatOutputErrorWriter) Write([]byte) (int, error) { + return 0, w.err +} + +func TestCrossPlatformCoverageMessagesSendPublishesCompleteIdentityConstraintInputs(t *testing.T) { want := []string{ - "identity", "as", "group", "chat-id", "user", "open-dingtalk-id", + "identity", "as", "group", "chat-id", "groups", "groups-file", "chat-query", "user", "user-query", "open-dingtalk-id", "users", "open-dingtalk-ids", "robot-code", "webhook-token", "uuid", "idempotency-key", } @@ -54,6 +63,251 @@ func TestMessagesSendPublishesCompleteIdentityConstraintInputs(t *testing.T) { } } +func TestCrossPlatformCoverageMessagesSendIdentityDescriptorMatchesRuntimeSurface(t *testing.T) { + capabilities := MessageIdentityCapabilities() + if len(capabilities) != 3 { + t.Fatalf("identity capabilities = %#v", capabilities) + } + byIdentity := make(map[string]MessageIdentityCapability, len(capabilities)) + for _, capability := range capabilities { + byIdentity[capability.Identity] = capability + } + if !byIdentity["user"].IdempotencyKeys || byIdentity["user"].BatchLedger { + t.Fatalf("user capability = %#v", byIdentity["user"]) + } + if !byIdentity["bot"].BatchLedger || byIdentity["bot"].IdempotencyKeys || + !reflect.DeepEqual(byIdentity["bot"].ContentTypes, []string{"text", "markdown"}) { + t.Fatalf("bot capability = %#v", byIdentity["bot"]) + } + if byIdentity["webhook"].BatchLedger || byIdentity["webhook"].IdempotencyKeys { + t.Fatalf("webhook capability = %#v", byIdentity["webhook"]) + } + capabilities[0].ContentTypes[0] = "mutated" + if MessageIdentityCapabilities()[0].ContentTypes[0] == "mutated" { + t.Fatal("identity capability descriptor leaked mutable storage") + } + if !messageIdentitySupportsContent("user", "audio") || + !messageIdentitySupportsContent("user", "video") || + messageIdentitySupportsContent("missing", "text") { + t.Fatal("identity content normalization or unknown-identity guard drifted") + } +} + +func TestCrossPlatformCoverageIMWorkflowContractsPublishRealPositiveAndNegativeBoundaries(t *testing.T) { + card := CurrentCardWorkflowContract() + if card.Version != "im.streaming-card.v1" || card.CallbackSupported || + !reflect.DeepEqual(card.ContentTypes, []string{"streaming-text"}) || len(card.FlowStatuses) != 5 { + t.Fatalf("card contract = %#v", card) + } + card.Targets[0] = "mutated" + if CurrentCardWorkflowContract().Targets[0] == "mutated" { + t.Fatal("card contract leaked mutable storage") + } + + boundaries := CurrentIMCapabilityBoundaries() + byName := make(map[string]bool, len(boundaries)) + for _, boundary := range boundaries { + byName[boundary.Capability] = boundary.Supported + if boundary.Alternative == "" { + t.Errorf("boundary %s lacks alternative", boundary.Capability) + } + } + for _, unsupported := range []string{"thread-write", "bot-rich-media", "card-action-callback", "resource-resume"} { + if byName[unsupported] { + t.Errorf("unsupported boundary %s was advertised", unsupported) + } + } + for _, supported := range []string{"group-member-full-pagination", "group-owner-selection"} { + if !byName[supported] { + t.Errorf("supported boundary %s was hidden", supported) + } + } +} + +func TestCrossPlatformCoverageMessagesSendBotMultiGroupPublishesPerTargetLedger(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{ + "chat", "+messages-send", "--as", "bot", "--robot-code", "robot", + "--groups", "cid-a,cid-b,cid-a", "--markdown", "通知", "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 2 { + t.Fatalf("multi-group calls = %#v", fake.calls) + } + for index, target := range []string{"cid-a", "cid-b"} { + if fake.calls[index].tool != "send_robot_group_message" || + fake.calls[index].args["openConversationId"] != target { + t.Fatalf("multi-group call[%d] = %#v", index, fake.calls[index]) + } + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["contractVersion"] != "im.batch-write.v1" || payload["ok"] != true || + payload["requestedCount"] != float64(2) || payload["succeededCount"] != float64(2) || + payload["failedCount"] != float64(0) { + t.Fatalf("multi-group ledger = %#v", payload) + } +} + +func TestCrossPlatformCoverageMessagesSendBotMultiGroupFailuresReturnNonzero(t *testing.T) { + tests := []struct { + name string + fake *larkAlignmentCaller + wantSucceeded float64 + wantFailed float64 + wantPartial bool + }{ + { + name: "partial failure", + fake: &larkAlignmentCaller{failProductToolAt: map[string]int{ + "bot/send_robot_group_message": 2, + }}, + wantSucceeded: 1, + wantFailed: 1, + wantPartial: true, + }, + { + name: "all failed", + fake: &larkAlignmentCaller{failProductTool: "bot/send_robot_group_message"}, + wantSucceeded: 0, + wantFailed: 2, + wantPartial: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + helpers.InitDeps(tt.fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{ + "chat", "+messages-send", "--as", "bot", "--robot-code", "robot", + "--groups", "cid-a,cid-b", "--markdown", "通知", "--yes", + }) + + err := root.Execute() + if err == nil { + t.Fatal("failed multi-group delivery returned success") + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Category != apperrors.CategoryAPI || + typed.Reason != "batch_write_failed" || typed.ExitCode() == 0 { + t.Fatalf("batch error = %#v (%v)", typed, err) + } + + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["ok"] != false || payload["partial"] != tt.wantPartial || + payload["succeededCount"] != tt.wantSucceeded || + payload["failedCount"] != tt.wantFailed { + t.Fatalf("failure ledger = %#v", payload) + } + }) + } +} + +func TestCrossPlatformCoverageMessagesSendBotMultiGroupPropagatesOutputFailure(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + wantErr := errors.New("fixture output failed") + root := newPlatformCoverageRoot() + root.SetOut(chatOutputErrorWriter{err: wantErr}) + root.SetArgs([]string{ + "chat", "+messages-send", "--as", "bot", "--robot-code", "robot", + "--groups", "cid-a,cid-b", "--markdown", "通知", "--yes", + }) + + if err := root.Execute(); !errors.Is(err, wantErr) { + t.Fatalf("output error = %v, want %v", err, wantErr) + } + if len(fake.calls) != 2 { + t.Fatalf("multi-group calls = %#v", fake.calls) + } +} + +func TestCrossPlatformCoverageMessagesSendBotGroupsFileUsesSafeDeduplicatedTargets(t *testing.T) { + t.Chdir(t.TempDir()) + if err := os.WriteFile("groups.txt", []byte("# comment\ncid-a,cid-b\ncid-a\n"), 0o600); err != nil { + t.Fatal(err) + } + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetOut(&bytes.Buffer{}) + root.SetArgs([]string{ + "chat", "+messages-send", "--as", "bot", "--robot-code", "robot", + "--groups-file", "groups.txt", "--text", "通知", "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 2 { + t.Fatalf("groups-file calls = %#v", fake.calls) + } +} + +func TestCrossPlatformCoverageChatCreateExplicitOwnerSkipsCurrentProfileAndDeduplicatesMember(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetOut(&bytes.Buffer{}) + root.SetArgs([]string{ + "chat", "+chat-create", "--name", "测试群", "--users", "D-owner,user-1", + "--owner-open-dingtalk-id", "D-owner", "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 1 || fake.calls[0].tool != "create_group_conversation" { + t.Fatalf("explicit owner calls = %#v", fake.calls) + } + create := fake.calls[0].args + if create["ownerOpenDingTalkId"] != "D-owner" { + t.Fatalf("ownerOpenDingTalkId = %#v", create["ownerOpenDingTalkId"]) + } + if got, want := create["groupMembers"], []string{"D-owner", "user-1"}; !reflect.DeepEqual(got, want) { + t.Fatalf("groupMembers = %#v, want %#v", got, want) + } +} + +func TestCrossPlatformCoverageChatCreateOwnerQueryResolvesBeforeSingleCreate(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "contact/search_contact_by_key_word": `{"result":[{"name":"张三","userId":"owner-user","openDingTalkId":"D-owner"}]}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetOut(&bytes.Buffer{}) + root.SetArgs([]string{ + "chat", "+chat-create", "--name", "测试群", "--users", "user-1", + "--owner-query", "张三", "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 2 || fake.calls[0].tool != "search_contact_by_key_word" || + fake.calls[1].tool != "create_group_conversation" { + t.Fatalf("owner query calls = %#v", fake.calls) + } + create := fake.calls[1].args + if create["ownerOpenDingTalkId"] != "D-owner" { + t.Fatalf("ownerOpenDingTalkId = %#v", create["ownerOpenDingTalkId"]) + } + if got, want := create["groupMembers"], []string{"D-owner", "user-1"}; !reflect.DeepEqual(got, want) { + t.Fatalf("groupMembers = %#v, want %#v", got, want) + } +} + func TestCrossPlatformCoverageSafeResourceDownloadsStayReadOnly(t *testing.T) { for _, command := range []shortcut.Shortcut{MessagesMget, MessagesResourceDownload} { if command.Risk != shortcut.RiskRead { diff --git a/internal/shortcut/chat/identity_contract.go b/internal/shortcut/chat/identity_contract.go new file mode 100644 index 00000000..d531c22f --- /dev/null +++ b/internal/shortcut/chat/identity_contract.go @@ -0,0 +1,81 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package chat + +// MessageIdentityCapability is the reviewed Runtime capability descriptor for +// +messages-send. Validation and Skill drift checks consume this same typed +// source; it describes only behavior that the current lower transports expose. +type MessageIdentityCapability struct { + Identity string + Targets []string + ContentTypes []string + NaturalTargets []string + MentionTargets []string + IdempotencyKeys bool + BatchLedger bool +} + +var messageIdentityCapabilities = []MessageIdentityCapability{ + { + Identity: "user", + Targets: []string{"group", "direct-user", "direct-open-dingtalk-id"}, + ContentTypes: []string{"text", "markdown", "image-media-id", "file", "audio-as-file", "video-as-file"}, + NaturalTargets: []string{"chat-query", "user-query"}, + MentionTargets: []string{"open-dingtalk-id", "all"}, + IdempotencyKeys: true, + BatchLedger: false, + }, + { + Identity: "bot", + Targets: []string{"group", "groups", "direct-users", "direct-open-dingtalk-ids"}, + ContentTypes: []string{"text", "markdown"}, + NaturalTargets: []string{}, + MentionTargets: []string{"user-id", "open-dingtalk-id", "all"}, + IdempotencyKeys: false, + BatchLedger: true, + }, + { + Identity: "webhook", + Targets: []string{"token-owned-group"}, + ContentTypes: []string{"text", "markdown"}, + NaturalTargets: []string{}, + MentionTargets: []string{"user-id", "mobile", "all"}, + IdempotencyKeys: false, + BatchLedger: false, + }, +} + +// MessageIdentityCapabilities returns defensive copies of the public matrix. +func MessageIdentityCapabilities() []MessageIdentityCapability { + out := make([]MessageIdentityCapability, len(messageIdentityCapabilities)) + for i, capability := range messageIdentityCapabilities { + out[i] = capability + out[i].Targets = append([]string(nil), capability.Targets...) + out[i].ContentTypes = append([]string(nil), capability.ContentTypes...) + out[i].NaturalTargets = append([]string(nil), capability.NaturalTargets...) + out[i].MentionTargets = append([]string(nil), capability.MentionTargets...) + } + return out +} + +func messageIdentitySupportsContent(identity, contentType string) bool { + if contentType == "audio" || contentType == "video" { + contentType += "-as-file" + } else if contentType == "image" { + contentType = "image-media-id" + } + for _, capability := range messageIdentityCapabilities { + if capability.Identity != identity { + continue + } + for _, supported := range capability.ContentTypes { + if supported == contentType { + return true + } + } + return false + } + return false +} diff --git a/internal/shortcut/chat/im_contract.go b/internal/shortcut/chat/im_contract.go new file mode 100644 index 00000000..d98ae3d5 --- /dev/null +++ b/internal/shortcut/chat/im_contract.go @@ -0,0 +1,76 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package chat + +// CardFlowStatus is one reviewed state accepted by DingTalk's streaming-card +// update transport. +type CardFlowStatus struct { + Value int + Name string +} + +// CardWorkflowContract describes the real card subset exposed by the current +// Runtime. It intentionally does not claim Lark card JSON/component compilation +// or callback consumption, neither of which exists in the lower interface. +type CardWorkflowContract struct { + Version string + Targets []string + ContentTypes []string + FlowStatuses []CardFlowStatus + CallbackSupported bool +} + +var currentCardWorkflowContract = CardWorkflowContract{ + Version: "im.streaming-card.v1", + Targets: []string{"group", "direct-user", "direct-open-dingtalk-id"}, + ContentTypes: []string{"streaming-text"}, + FlowStatuses: []CardFlowStatus{ + {Value: 1, Name: "processing"}, + {Value: 2, Name: "typing"}, + {Value: 3, Name: "completed"}, + {Value: 4, Name: "executing"}, + {Value: 5, Name: "error"}, + }, + CallbackSupported: false, +} + +// CurrentCardWorkflowContract returns a defensive copy for policy and docs. +func CurrentCardWorkflowContract() CardWorkflowContract { + contract := currentCardWorkflowContract + contract.Targets = append([]string(nil), contract.Targets...) + contract.ContentTypes = append([]string(nil), contract.ContentTypes...) + contract.FlowStatuses = append([]CardFlowStatus(nil), contract.FlowStatuses...) + return contract +} + +func validCardFlowStatus(value int) bool { + for _, status := range currentCardWorkflowContract.FlowStatuses { + if status.Value == value { + return true + } + } + return false +} + +// IMCapabilityBoundary makes unsupported Lark-parity requests explicit and +// testable instead of leaving them to prose or transport guessing. +type IMCapabilityBoundary struct { + Capability string + Supported bool + Alternative string +} + +var currentIMCapabilityBoundaries = []IMCapabilityBoundary{ + {Capability: "thread-write", Supported: false, Alternative: "quote reply with +messages-reply; thread reading with +thread-replies"}, + {Capability: "bot-rich-media", Supported: false, Alternative: "bot text/markdown, or current-user file/image send"}, + {Capability: "card-action-callback", Supported: false, Alternative: "streaming text card create/update only"}, + {Capability: "resource-resume", Supported: false, Alternative: "atomic whole-file download with explicit retry"}, + {Capability: "group-member-full-pagination", Supported: true, Alternative: "+chat-members-list or +group-members"}, + {Capability: "group-owner-selection", Supported: true, Alternative: "+chat-create owner flags"}, +} + +// CurrentIMCapabilityBoundaries returns the reviewed positive/negative matrix. +func CurrentIMCapabilityBoundaries() []IMCapabilityBoundary { + return append([]IMCapabilityBoundary(nil), currentIMCapabilityBoundaries...) +} diff --git a/internal/shortcut/chat/lark_alignment.go b/internal/shortcut/chat/lark_alignment.go index 3fe67b6f..2ba9d4b6 100644 --- a/internal/shortcut/chat/lark_alignment.go +++ b/internal/shortcut/chat/lark_alignment.go @@ -13,44 +13,81 @@ import ( apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" ) -// ChatCreate creates a DingTalk group as the current user. It intentionally -// does not advertise Lark-only owner, description, initial-bot, or visibility -// semantics. +// ChatCreate creates a DingTalk group after resolving every natural member and +// the optional owner to stable DingTalk identities. Description, initial-bot, +// idempotency, and Lark visibility semantics remain deliberately unsupported. var ChatCreate = shortcut.Shortcut{ Service: "chat", Command: "+chat-create", Product: "im", - Description: "以当前用户身份创建钉钉群聊", - Intent: "当你要创建一个基础钉钉群聊时使用;自动把当前用户加入成员列表并作为群主,支持 INTERNAL、EXTERNAL、NORMAL 和话题模式。它不支持指定其他 owner、群 description、初始机器人或 Lark public/private 语义。", + Description: "按成员和可选群主全量预检后创建一个钉钉群聊", + Intent: "当你要创建钉钉群聊时使用;成员可传稳定 ID 或 --member-query 姓名,群主默认当前用户,也可用 --owner-open-dingtalk-id 或 --owner-query 明确指定。所有自然身份会在唯一解析并去重后才执行一次创建,任一零命中或多命中都会整体停止。", Risk: shortcut.RiskWrite, Flags: []shortcut.Flag{ {Name: "name", Type: shortcut.FlagString, Desc: "群名称", Required: true}, - {Name: "users", Type: shortcut.FlagStringSlice, Desc: "初始成员 userId 或 openDingTalkId 列表", Required: true}, + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "初始成员 userId 或 openDingTalkId 列表"}, + {Name: "member-query", Type: shortcut.FlagStringSlice, Desc: "按姓名/花名唯一解析的初始成员,可逗号分隔或重复传入"}, + {Name: "owner-open-dingtalk-id", Type: shortcut.FlagString, Desc: "明确指定群主 openDingTalkId(与 --owner-query 互斥;省略时群主为当前用户)"}, + {Name: "owner-query", Type: shortcut.FlagString, Desc: "按姓名唯一解析群主 openDingTalkId(与 --owner-open-dingtalk-id 互斥)"}, {Name: "type", Type: shortcut.FlagString, Default: "INTERNAL", Desc: "群类型", Enum: []string{"INTERNAL", "EXTERNAL", "NORMAL"}}, {Name: "thread", Type: shortcut.FlagBool, Desc: "创建为话题群"}, }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"users", "member-query"}}, + {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"owner-open-dingtalk-id", "owner-query"}}, + }, Tips: []string{ `dws chat +chat-create --name "项目冲刺群" --users userId1,userId2`, - `dws chat +chat-create --name "合作群" --users userId1,userId2 --type EXTERNAL`, + `dws chat +chat-create --name "合作群" --member-query "张三,李四" --type EXTERNAL`, }, Execute: func(rt *shortcut.RuntimeContext) error { - profile, err := rt.CallMCPData("contact", "get_current_user_profile", nil) + resolvedMembers, err := targetresolver.ResolveUsers( + rt, + rt.StrSlice("member-query"), + targetresolver.IdentityAny, + ) if err != nil { - return fmt.Errorf("读取当前用户以设置群主失败: %w", err) + return err } - currentUserID := currentProfileUserID(profile) - if currentUserID == "" { - return apperrors.NewValidation("当前用户资料缺少 userId,无法保证群主属于初始成员列表") + ownerOpenID := rt.Str("owner-open-dingtalk-id") + if query := rt.Str("owner-query"); query != "" { + resolvedOwner, resolveErr := targetresolver.ResolveUser( + rt, query, targetresolver.IdentityOpenDingTalkID) + if resolveErr != nil { + return resolveErr + } + ownerOpenID = resolvedOwner.Selected.OpenDingTalkID + } + members := make([]string, 0, len(rt.StrSlice("users"))+len(resolvedMembers)+1) + if ownerOpenID != "" { + members = append(members, ownerOpenID) + } else { + profile, profileErr := rt.CallMCPData("contact", "get_current_user_profile", nil) + if profileErr != nil { + return fmt.Errorf("读取当前用户以设置群主失败: %w", profileErr) + } + currentUserID := currentProfileUserID(profile) + if currentUserID == "" { + return apperrors.NewValidation("当前用户资料缺少 userId,无法保证群主属于初始成员列表") + } + members = append(members, currentUserID) } - members := []string{currentUserID} for _, member := range rt.StrSlice("users") { member = strings.TrimSpace(member) if member != "" { members = appendUniqueShortcutString(members, member) } } + for _, resolved := range resolvedMembers { + member := resolved.Selected.UserID + if member == "" { + member = resolved.Selected.OpenDingTalkID + } + members = appendUniqueShortcutString(members, member) + } params := map[string]any{ "groupName": rt.Str("name"), "groupMembers": members, @@ -59,6 +96,9 @@ var ChatCreate = shortcut.Shortcut{ if rt.Bool("thread") { params["convThreadEnabled"] = true } + if ownerOpenID != "" { + params["ownerOpenDingTalkId"] = ownerOpenID + } if rt.DryRun() { return rt.CallMCP("create_group_conversation", params) } @@ -114,18 +154,23 @@ func normalizeCreatedConversation(data map[string]any) { var ChatUpdate = shortcut.Shortcut{ Service: "chat", Command: "+chat-update", + Aliases: []string{"+chat-rename"}, Product: "chat", Description: "更新群名称(仅名称,不支持 description)", - Intent: "当你只需要修改群名称时使用;这是 lark-cli +chat-update 的诚实子集,只接受群 openConversationId 和新名称。修改群 description、个人备注、群昵称或其他群设置时不要使用。", + Intent: "当你只需要修改群名称时使用;--group 可传群名或 openConversationId,群名必须唯一解析后才会写入。修改群 description、个人备注、群昵称或其他群设置时不要使用。", Risk: shortcut.RiskWrite, Flags: []shortcut.Flag{ - {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "group", Type: shortcut.FlagString, Desc: "群名称或 openConversationId", Required: true}, {Name: "name", Type: shortcut.FlagString, Desc: "新的群名称", Required: true}, }, - Tips: []string{`dws chat +chat-update --group --name "新群名"`}, + Tips: []string{`dws chat +chat-update --group <群名或openConversationId> --name "新群名"`}, Execute: func(rt *shortcut.RuntimeContext) error { + resolved, err := targetresolver.ResolveChatTarget(rt, rt.Str("group"), "") + if err != nil { + return err + } return rt.CallMCP("update_group_name", map[string]any{ - "openconversation_id": rt.Str("group"), + "openconversation_id": resolved.Selected.OpenConversationID, "group_name": rt.Str("name"), }) }, @@ -138,8 +183,8 @@ var MessagesReply = shortcut.Shortcut{ Service: "chat", Command: "+messages-reply", Product: "chat", - Description: "以当前用户身份引用回复消息(自动补全原发送者)", - Intent: "当你要以当前用户身份对已有消息发送纯文本引用回复时使用;提供会话和被引用消息即可,默认通过 mget 自动读取原发送者,也可显式传 openDingTalkId/userId;userId 会通过通讯录搜索精确匹配 openDingTalkId。它不支持 bot 身份、富媒体、卡片或 thread 内回复。", + Description: "引用回复一条已有消息,并返回可继续查询或撤回的发送上下文", + Intent: "当你要以当前用户身份对一条已有消息发送纯文本引用回复时使用;传会话和原消息 ID,CLI 会先读取原发送者,也可显式传 --ref-sender。成功结果在保留下层响应的同时增量返回 messageId(下层提供时)、conversationId、threadId(适用时)、deliveryStatus、idempotencyKey 和 referencedMessage 来源上下文。", Risk: shortcut.RiskWrite, Flags: []shortcut.Flag{ {Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true}, @@ -175,10 +220,79 @@ var MessagesReply = shortcut.Shortcut{ if value := rt.StrFirst("idempotency-key", "uuid"); value != "" { params["uuid"] = value } - return rt.CallMCP("send_personal_message", params) + if rt.DryRun() { + return rt.Output(map[string]any{ + "contractVersion": "im.message-reply.v1", + "dryRun": true, + "willSend": false, + "transport": "chat/send_personal_message", + "arguments": params, + "conversationId": rt.Str("conversation-id"), + "referencedMessage": map[string]any{ + "messageId": replyMessageID(rt), + "senderOpenDingTalkId": refSender, + }, + }) + } + data, err := rt.CallMCPWriteData("chat", "send_personal_message", params) + if err != nil { + return err + } + enrichReplyResult(data, rt, refSender) + return rt.Output(data) }, } +func enrichReplyResult(data map[string]any, rt *shortcut.RuntimeContext, refSender string) { + data["contractVersion"] = "im.message-reply.v1" + data["conversationId"] = rt.Str("conversation-id") + data["referencedMessage"] = map[string]any{ + "messageId": replyMessageID(rt), + "senderOpenDingTalkId": refSender, + "resolutionSource": func() string { + if rt.Str("ref-sender") != "" { + return "explicit" + } + return "message_lookup" + }(), + } + if key := rt.StrFirst("idempotency-key", "uuid"); key != "" { + data["idempotencyKey"] = key + } + if value := replyResponseValue(data, "openMessageId", "openMsgId", "messageId", "msgId"); value != nil { + data["messageId"] = value + } + if value := replyResponseValue(data, "openConvThreadId", "threadId", "topicId"); value != nil { + data["threadId"] = value + } + if value := replyResponseValue(data, "deliveryStatus", "sendStatus", "status"); value != nil { + data["deliveryStatus"] = value + data["deliveryStatusKnown"] = true + } else { + data["deliveryStatus"] = "unknown" + data["deliveryStatusKnown"] = false + } +} + +func replyResponseValue(data map[string]any, keys ...string) any { + scopes := []map[string]any{data} + for _, wrapper := range []string{"result", "data"} { + if nested, ok := data[wrapper].(map[string]any); ok { + scopes = append(scopes, nested) + } + } + for _, scope := range scopes { + for _, key := range keys { + if value, ok := scope[key]; ok && value != nil { + if text, isString := value.(string); !isString || strings.TrimSpace(text) != "" { + return value + } + } + } + } + return nil +} + func resolveReplySender(rt *shortcut.RuntimeContext) (string, error) { if value := rt.Str("ref-sender"); value != "" { if isOpenID(value) { @@ -303,8 +417,8 @@ var FlagCreate = shortcut.Shortcut{ Intent: "当你要把同一会话中的一条或多条消息加入当前用户的个人收藏时使用;逐项返回成功/失败 ledger。这是消息 favorite,不是消息 Pin、会话置顶或 feed-layer thread flag。", Risk: shortcut.RiskWrite, Flags: []shortcut.Flag{ - {Name: "message-id", Type: shortcut.FlagString, Desc: "单条消息 openMessageId"}, - {Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "多条消息 openMessageId(最多 10 条)"}, + {Name: "message-id", Type: shortcut.FlagString, Desc: "单条消息 openMessageId;消息 ID 去重后必须为 1-10 条"}, + {Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "多条消息 openMessageId;消息 ID 去重后必须为 1-10 条"}, {Name: "conversation-id", Type: shortcut.FlagString, Desc: "消息所在会话 openConversationId", Required: true}, }, Constraints: []shortcut.Constraint{ @@ -330,8 +444,8 @@ var FlagCancel = shortcut.Shortcut{ Intent: "当你要移除当前用户对同一会话中一条或多条消息的个人收藏标记时使用;逐项返回成功/失败 ledger,只影响 message favorite,不删除原消息,也不会修改 Pin 或会话置顶。", Risk: shortcut.RiskWrite, Flags: []shortcut.Flag{ - {Name: "message-id", Type: shortcut.FlagString, Desc: "单条消息 openMessageId"}, - {Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "多条消息 openMessageId(最多 10 条)"}, + {Name: "message-id", Type: shortcut.FlagString, Desc: "单条消息 openMessageId;消息 ID 去重后必须为 1-10 条"}, + {Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "多条消息 openMessageId;消息 ID 去重后必须为 1-10 条"}, {Name: "conversation-id", Type: shortcut.FlagString, Desc: "消息所在会话 openConversationId", Required: true}, }, Constraints: []shortcut.Constraint{ @@ -389,8 +503,8 @@ var FlagList = shortcut.Shortcut{ Intent: "当你要查看当前用户的 DingTalk message favorite 列表时使用;返回下层分页结果,不把 message favorite 与 Pin、会话置顶或 Lark feed-layer thread flag 混为一谈。", Risk: shortcut.RiskRead, Flags: []shortcut.Flag{ - {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "数字分页游标,首次传 0"}, - {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量,范围 1-100"}, + {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "数字分页游标;--cursor 必须大于等于 0,--size 必须在 1-100 之间"}, + {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量;--cursor 必须大于等于 0,--size 必须在 1-100 之间"}, }, Constraints: []shortcut.Constraint{{ Kind: shortcut.ConstraintCustom, @@ -516,10 +630,10 @@ var ChatList = shortcut.Shortcut{ "不支持 lark 的 sort/sort-type,也不模拟 bot 身份剥离 p2p。", Risk: shortcut.RiskRead, Flags: []shortcut.Flag{ - {Name: "types", Type: shortcut.FlagStringSlice, Desc: "会话类型:group、p2p;省略时默认只返回群聊"}, - {Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量(1-100)"}, - {Name: "limit", Type: shortcut.FlagInt, Desc: "--page-size 的别名"}, - {Name: "page-token", Type: shortcut.FlagString, Desc: "分页游标(钉钉为整数游标的字符串形式)"}, + {Name: "types", Type: shortcut.FlagStringSlice, Desc: "会话类型只能包含 group 和/或 p2p;省略时默认只返回群聊"}, + {Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量,必须在 1-100 之间"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "--page-size 的别名,必须在 1-100 之间"}, + {Name: "page-token", Type: shortcut.FlagString, Desc: "分页游标;若提供则必须是非负整数"}, {Name: "cursor", Type: shortcut.FlagInt, Desc: "--page-token 的整数别名"}, {Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"}, }, @@ -721,7 +835,7 @@ func chatListFilterTypes(chats []map[string]any, types []string) []map[string]an } func init() { - shortcut.Register( + shortcut.Register(withReviewedChatShortcutContracts( ChatCreate, ChatList, ChatUpdate, @@ -730,5 +844,5 @@ func init() { FlagCancel, FlagList, FeedGroupQueryItem, - ) + )...) } diff --git a/internal/shortcut/chat/lark_alignment_test.go b/internal/shortcut/chat/lark_alignment_test.go index b011d517..f6cb0eed 100644 --- a/internal/shortcut/chat/lark_alignment_test.go +++ b/internal/shortcut/chat/lark_alignment_test.go @@ -23,11 +23,14 @@ type larkAlignmentCall struct { } type larkAlignmentCaller struct { - calls []larkAlignmentCall - failTarget string - failProductTool string - category string - responses map[string]string + calls []larkAlignmentCall + failTarget string + failProductTool string + failProductToolAt map[string]int + callCounts map[string]int + category string + responses map[string]string + sequenceResponses map[string][]string } func (f *larkAlignmentCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) { @@ -36,9 +39,16 @@ func (f *larkAlignmentCaller) CallTool(_ context.Context, product, tool string, return nil, errors.New("fixture write failed") } key := product + "/" + tool + if f.callCounts == nil { + f.callCounts = map[string]int{} + } + f.callCounts[key]++ if f.failProductTool == key { return nil, errors.New("fixture lower call failed") } + if f.failProductToolAt[key] == f.callCounts[key] { + return nil, errors.New("fixture sequenced lower call failed") + } text := `{"success":true}` switch key { case "contact/get_current_user_profile": @@ -75,6 +85,10 @@ func (f *larkAlignmentCaller) CallTool(_ context.Context, product, tool string, if response, ok := f.responses[key]; ok { text = response } + if responses := f.sequenceResponses[key]; len(responses) > 0 { + text = responses[0] + f.sequenceResponses[key] = responses[1:] + } return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil } @@ -87,7 +101,67 @@ func (f *larkAlignmentCaller) DryRun() bool { return false } func (f *larkAlignmentCaller) Fields() string { return "" } func (f *larkAlignmentCaller) JQ() string { return "" } -func TestChatCreateAddsCurrentUserAndNormalizesResult(t *testing.T) { +func TestCrossPlatformCoverageEvaluationRegressionNaturalGroupTargetsAndRecallInference(t *testing.T) { + t.Run("group name to bots", func(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/search_groups": `{"result":[{"openConversationId":"cid-project","title":"项目群"}],"hasMore":false}`, + "bot/list_group_bots": `{"result":{"bots":[]}}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+chat-bots", "--group", "项目群"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 2 || fake.calls[1].tool != "list_group_bots" || fake.calls[1].args["openConversationId"] != "cid-project" { + t.Fatalf("calls = %#v", fake.calls) + } + }) + + t.Run("group query to invite url", func(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/search_groups": `{"result":[{"openConversationId":"cid-project","title":"项目群"}],"hasMore":false}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+chat-invite-url", "--chat-query", "项目群"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 2 || fake.calls[1].tool != "get_group_invite_url" || fake.calls[1].args["openConversationId"] != "cid-project" { + t.Fatalf("calls = %#v", fake.calls) + } + }) + + t.Run("stable id in group query bypasses search", func(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+chat-invite-url", "--chat-query", "cid-fixture-chat-0001"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 1 || fake.calls[0].tool != "get_group_invite_url" || + fake.calls[0].args["openConversationId"] != "cid-fixture-chat-0001" { + t.Fatalf("calls = %#v", fake.calls) + } + }) + + t.Run("message id fills conversation before recall", func(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+messages-recall", "--message-ids", "msg", "--yes"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 2 || fake.calls[0].tool != "list_messages_by_ids" || fake.calls[1].tool != "recall_message" || fake.calls[1].args["openConversationId"] != "cid" { + t.Fatalf("calls = %#v", fake.calls) + } + }) +} + +func TestCrossPlatformCoverageChatCreateAddsCurrentUserAndNormalizesResult(t *testing.T) { fake := &larkAlignmentCaller{} helpers.InitDeps(fake) root := newPlatformCoverageRoot() @@ -127,7 +201,80 @@ func TestChatCreateAddsCurrentUserAndNormalizesResult(t *testing.T) { } } -func TestMessagesSendRoutesIdentitySpecificTransports(t *testing.T) { +func TestCrossPlatformCoverageChatCreateResolvesEveryNaturalMemberBeforeCreating(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "contact/search_contact_by_key_word": `{"result":[{"name":"张三","userId":"resolved-user","openDingTalkId":"D-resolved"}]}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+chat-create", + "--name", "测试群", + "--users", "explicit-user", + "--member-query", "张三", + "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 3 { + t.Fatalf("calls = %#v, want member resolve + current profile + create", fake.calls) + } + if fake.calls[0].tool != "search_contact_by_key_word" || + fake.calls[1].tool != "get_current_user_profile" || + fake.calls[2].tool != "create_group_conversation" { + t.Fatalf("call order = %#v", fake.calls) + } + if got, want := fake.calls[2].args["groupMembers"], []string{"self-user", "explicit-user", "resolved-user"}; !reflect.DeepEqual(got, want) { + t.Fatalf("groupMembers = %#v, want %#v", got, want) + } +} + +func TestCrossPlatformCoverageChatCreateNaturalMemberAmbiguityStopsBeforeProfileAndCreate(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "contact/search_contact_by_key_word": `{"result":[{"name":"张三","userId":"u1"},{"name":"张三","userId":"u2"}]}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+chat-create", + "--name", "测试群", + "--member-query", "张三", + "--yes", + }) + if err := root.Execute(); err == nil { + t.Fatal("ambiguous member unexpectedly created a group") + } + if len(fake.calls) != 1 || fake.calls[0].tool != "search_contact_by_key_word" { + t.Fatalf("ambiguous member reached profile/create: %#v", fake.calls) + } +} + +func TestCrossPlatformCoverageChatCreateNaturalMemberDryRunUsesSameResolutionChain(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "contact/search_contact_by_key_word": `{"result":[{"name":"张三","userId":"resolved-user"}]}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+chat-create", + "--name", "测试群", + "--member-query", "张三", + "--dry-run", + "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 3 || + fake.calls[0].tool != "search_contact_by_key_word" || + fake.calls[1].tool != "get_current_user_profile" || + fake.calls[2].tool != "create_group_conversation" { + t.Fatalf("dry-run calls = %#v", fake.calls) + } +} + +func TestCrossPlatformCoverageMessagesSendRoutesIdentitySpecificTransports(t *testing.T) { tests := []struct { name string args []string @@ -201,7 +348,7 @@ func TestMessagesSendRoutesIdentitySpecificTransports(t *testing.T) { } } -func TestMessagesSendRejectsUnsupportedIdentityCapability(t *testing.T) { +func TestCrossPlatformCoverageMessagesSendRejectsUnsupportedIdentityCapability(t *testing.T) { fake := &larkAlignmentCaller{} helpers.InitDeps(fake) root := newPlatformCoverageRoot() @@ -222,7 +369,7 @@ func TestMessagesSendRejectsUnsupportedIdentityCapability(t *testing.T) { } } -func TestLarkAlignmentWriteMappings(t *testing.T) { +func TestCrossPlatformCoverageLarkAlignmentWriteMappings(t *testing.T) { tests := []struct { name string args []string @@ -232,11 +379,11 @@ func TestLarkAlignmentWriteMappings(t *testing.T) { }{ { name: "chat-update-name-only", - args: []string{"chat", "+chat-update", "--group", "cid", "--name", "新群名", "--yes"}, + args: []string{"chat", "+chat-update", "--group", "cid-fixture-chat-0001", "--name", "新群名", "--yes"}, product: "chat", tool: "update_group_name", wantArgs: map[string]any{ - "openconversation_id": "cid", + "openconversation_id": "cid-fixture-chat-0001", "group_name": "新群名", }, }, @@ -291,10 +438,32 @@ func TestLarkAlignmentWriteMappings(t *testing.T) { } } -func TestMessagesReplyPublishesPlainTextBoundary(t *testing.T) { - fake := &larkAlignmentCaller{} +func TestCrossPlatformCoverageObservedChatRenameAliasResolvesNameBeforeWrite(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/search_groups": `{"result":[{"openConversationId":"cid-project","title":"项目评测群"}],"hasMore":false}`, + }} helpers.InitDeps(fake) root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+chat-rename", "--group", "项目评测群", "--name", "项目讨论群", "--yes"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 2 || fake.calls[0].tool != "search_groups" || fake.calls[1].tool != "update_group_name" { + t.Fatalf("calls = %#v", fake.calls) + } + if fake.calls[1].args["openconversation_id"] != "cid-project" || fake.calls[1].args["group_name"] != "项目讨论群" { + t.Fatalf("write args = %#v", fake.calls[1].args) + } +} + +func TestCrossPlatformCoverageMessagesReplyPublishesPlainTextBoundary(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "chat/send_personal_message": `{"result":{"openMessageId":"new-msg","openConvThreadId":"thread-1","sendStatus":"accepted"}}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) root.SetArgs([]string{ "chat", "+messages-reply", "--conversation-id", "cid", @@ -327,9 +496,46 @@ func TestMessagesReplyPublishesPlainTextBoundary(t *testing.T) { content["content"] != "收到" { t.Fatalf("reply content = %#v", content) } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["contractVersion"] != "im.message-reply.v1" || + payload["messageId"] != "new-msg" || + payload["conversationId"] != "cid" || + payload["threadId"] != "thread-1" || + payload["deliveryStatus"] != "accepted" || + payload["idempotencyKey"] != "reply-uuid" { + t.Fatalf("reply result context = %#v", payload) + } + referenced, _ := payload["referencedMessage"].(map[string]any) + if referenced["messageId"] != "msg" || referenced["resolutionSource"] != "explicit" { + t.Fatalf("referenced message context = %#v", referenced) + } } -func TestFlagBatchContinuesAndPublishesFailureLedger(t *testing.T) { +func TestCrossPlatformCoverageMessagesReplyDryRunStopsBeforeWrite(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+messages-reply", + "--conversation-id", "cid", + "--message-id", "msg", + "--ref-sender", "D-sender", + "--text", "收到", + "--dry-run", + "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 0 { + t.Fatalf("reply dry-run reached write transport: %#v", fake.calls) + } +} + +func TestCrossPlatformCoverageFlagBatchContinuesAndPublishesFailureLedger(t *testing.T) { fake := &larkAlignmentCaller{failTarget: "m2"} helpers.InitDeps(fake) root := newPlatformCoverageRoot() @@ -341,8 +547,8 @@ func TestFlagBatchContinuesAndPublishesFailureLedger(t *testing.T) { "--conversation-id", "cid", "--yes", }) - if err := root.Execute(); err != nil { - t.Fatal(err) + if err := root.Execute(); err == nil { + t.Fatal("partial batch failure returned success") } if len(fake.calls) != 2 { t.Fatalf("calls = %#v", fake.calls) @@ -359,7 +565,7 @@ func TestFlagBatchContinuesAndPublishesFailureLedger(t *testing.T) { } } -func TestConversationSetTopBatchDryRunPublishesActionsWithoutWrites(t *testing.T) { +func TestCrossPlatformCoverageConversationSetTopBatchDryRunPublishesActionsWithoutWrites(t *testing.T) { fake := &larkAlignmentCaller{} helpers.InitDeps(fake) root := newPlatformCoverageRoot() @@ -390,7 +596,7 @@ func TestConversationSetTopBatchDryRunPublishesActionsWithoutWrites(t *testing.T } } -func TestMessagesMgetDryRunPublishesMultiResourceDownloadPlan(t *testing.T) { +func TestCrossPlatformCoverageMessagesMgetDryRunPublishesMultiResourceDownloadPlan(t *testing.T) { fake := &larkAlignmentCaller{} helpers.InitDeps(fake) root := newPlatformCoverageRoot() @@ -450,7 +656,7 @@ func TestCrossPlatformCoverageMessagesReplyResolvesUserIDBeforeExecution(t *test } } -func TestMessagesReplyInfersSenderFromReferencedMessage(t *testing.T) { +func TestCrossPlatformCoverageMessagesReplyInfersSenderFromReferencedMessage(t *testing.T) { fake := &larkAlignmentCaller{} helpers.InitDeps(fake) root := newPlatformCoverageRoot() @@ -479,7 +685,7 @@ func TestMessagesReplyInfersSenderFromReferencedMessage(t *testing.T) { } } -func TestFindMessageSenderOpenDingTalkIDIgnoresUnrelatedNestedIdentity(t *testing.T) { +func TestCrossPlatformCoverageFindMessageSenderOpenDingTalkIDIgnoresUnrelatedNestedIdentity(t *testing.T) { message := map[string]any{ "content": map[string]any{ "mentions": []any{ @@ -627,7 +833,7 @@ func TestChatListP2POnlyDropsGroups(t *testing.T) { } } -func TestFeedGroupQueryProjectPreservesRequestOrderAndMissingLedger(t *testing.T) { +func TestCrossPlatformCoverageFeedGroupQueryProjectPreservesRequestOrderAndMissingLedger(t *testing.T) { conversations := []map[string]any{ {"openConversationId": "cid-a", "conversationName": "A"}, {"openConversationId": "cid-b", "conversationName": "B"}, @@ -645,7 +851,7 @@ func TestFeedGroupQueryProjectPreservesRequestOrderAndMissingLedger(t *testing.T } } -func TestFeedGroupQueryDoesNotMisreportMissingItemWhenSourceHasMore(t *testing.T) { +func TestCrossPlatformCoverageFeedGroupQueryDoesNotMisreportMissingItemWhenSourceHasMore(t *testing.T) { fake := &larkAlignmentCaller{ category: `{"result":{"hasMore":true,"list":[{"openConversationId":"cid-a","conversationName":"A"}]}}`, } diff --git a/internal/shortcut/chat/message_export.go b/internal/shortcut/chat/message_export.go new file mode 100644 index 00000000..76fa2610 --- /dev/null +++ b/internal/shortcut/chat/message_export.go @@ -0,0 +1,120 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package chat + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +// ValidateMessageExportOutput applies the same workspace-relative and +// no-symlink boundary used by message resource downloads. The export target is +// always a file; directory-shaped paths are rejected instead of inventing a +// name. +func ValidateMessageExportOutput(output string) error { + trimmed := strings.TrimSpace(output) + if strings.HasSuffix(trimmed, "/") || + strings.HasSuffix(trimmed, string(os.PathSeparator)) { + return apperrors.NewValidation("--output 必须是 JSON 文件路径,不能是目录") + } + if !strings.EqualFold(filepath.Ext(trimmed), ".json") { + return apperrors.NewValidation("--output 必须使用 .json 文件扩展名") + } + return validateResourceDownloadOutputFlag(output, "--output") +} + +// WriteMessageExportJSON atomically publishes the exact structured message +// ledger. It defaults to no-clobber and never follows a symlink outside the +// current working directory. +func WriteMessageExportJSON(output string, overwrite bool, payload any) (relativePath string, size int, err error) { + if err := ValidateMessageExportOutput(output); err != nil { + return "", 0, err + } + cwd, err := resourceGetwd() + if err != nil { + return "", 0, apperrors.NewInternal(fmt.Sprintf("读取工作目录失败: %v", err)) + } + base, err := resourceAbs(cwd) + if err != nil { + return "", 0, apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err)) + } + realBase, err := resourceEvalSymlinks(base) + if err != nil { + return "", 0, apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err)) + } + + target := filepath.Join(realBase, filepath.Clean(strings.TrimSpace(output))) + parent := filepath.Dir(target) + if err := ensureResourceDownloadParent(realBase, parent); err != nil { + return "", 0, err + } + realParent, err := resourceEvalSymlinks(parent) + if err != nil { + return "", 0, apperrors.NewInternal(fmt.Sprintf("解析输出目录失败: %v", err)) + } + parentRel, err := resourceRel(realBase, realParent) + if err != nil || parentRel == ".." || strings.HasPrefix(parentRel, ".."+string(os.PathSeparator)) { + return "", 0, apperrors.NewValidation("--output 解析后逃逸工作目录") + } + target = filepath.Join(realParent, filepath.Base(target)) + if info, statErr := resourceLstat(target); statErr == nil { + if info.Mode()&os.ModeSymlink != 0 { + return "", 0, apperrors.NewValidation("--output 目标不能是符号链接") + } + if info.IsDir() { + return "", 0, apperrors.NewValidation("--output 目标是目录,无法写入 JSON 文件") + } + if !overwrite { + return "", 0, apperrors.NewValidation("目标文件已存在;如确认覆盖请显式传 --overwrite") + } + } else if !errors.Is(statErr, os.ErrNotExist) { + return "", 0, apperrors.NewInternal(fmt.Sprintf("检查输出文件失败: %v", statErr)) + } + + rendered, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return "", 0, apperrors.NewInternal(fmt.Sprintf("编码消息导出失败: %v", err)) + } + rendered = append(rendered, '\n') + temp, err := resourceCreateTemp(realParent, "."+filepath.Base(target)+".part-*") + if err != nil { + return "", 0, apperrors.NewInternal(fmt.Sprintf("创建消息导出临时文件失败: %v", err)) + } + tempPath := temp.Name() + defer func() { + _ = resourceTempClose(temp) + _ = os.Remove(tempPath) + }() + if _, err := temp.Write(rendered); err != nil { + return "", 0, apperrors.NewInternal(fmt.Sprintf("写入消息导出失败: %v", err)) + } + if err := resourceTempSync(temp); err != nil { + return "", 0, apperrors.NewInternal(fmt.Sprintf("同步消息导出失败: %v", err)) + } + if err := resourceTempClose(temp); err != nil { + return "", 0, apperrors.NewInternal(fmt.Sprintf("关闭消息导出失败: %v", err)) + } + if overwrite { + if err := resourceRename(tempPath, target); err != nil { + return "", 0, apperrors.NewInternal(fmt.Sprintf("发布消息导出失败: %v", err)) + } + } else if err := resourceLink(tempPath, target); err != nil { + if errors.Is(err, os.ErrExist) { + return "", 0, apperrors.NewValidation("目标文件已存在;如确认覆盖请显式传 --overwrite") + } + return "", 0, apperrors.NewInternal(fmt.Sprintf("发布消息导出失败: %v", err)) + } + relativePath, err = resourceRel(realBase, target) + if err != nil { + return "", 0, apperrors.NewInternal(fmt.Sprintf("解析输出相对路径失败: %v", err)) + } + return filepath.ToSlash(relativePath), len(rendered), nil +} diff --git a/internal/shortcut/chat/resource_download.go b/internal/shortcut/chat/resource_download.go index 280b69bd..45d103ab 100644 --- a/internal/shortcut/chat/resource_download.go +++ b/internal/shortcut/chat/resource_download.go @@ -22,6 +22,7 @@ import ( "net/http" "net/url" "os" + pathpkg "path" "path/filepath" "strings" "time" @@ -44,7 +45,7 @@ var ( resourceCopy = io.Copy resourceTempSync = (*os.File).Sync resourceTempClose = (*os.File).Close - resourceRename = os.Rename + resourceRename = replaceFileAtomically resourceLink = os.Link resourceDownload = downloadResourceAtomically ) @@ -58,15 +59,15 @@ var MessagesResourceDownload = shortcut.Shortcut{ Description: "安全下载消息资源(图片/视频/语音/文件)到本地", Intent: "当你需要拿到消息里的实际图片、视频、语音或钉盘文件,而不只是资源 ID 时使用;" + "mediaId 用消息和会话身份换取下载地址,fileId 复用钉盘下载能力,再安全写入工作目录内的相对路径。" + - "默认不覆盖已有文件,只有显式传 --overwrite 才覆盖;按既有安全本地下载约定无需交互确认。", + "默认不覆盖已有文件,只有显式传 --overwrite 才覆盖;下载采用整文件临时落盘后原子发布,不支持 Range 断点续传。按既有安全本地下载约定无需交互确认。", Risk: shortcut.RiskRead, Flags: []shortcut.Flag{ - {Name: "type", Type: shortcut.FlagString, Default: "mediaId", Desc: "资源类型", Enum: []string{"mediaId", "fileId"}}, + {Name: "type", Type: shortcut.FlagString, Default: "mediaId", Desc: "资源类型;--type mediaId 时必须同时提供 --message-id 和 --open-conversation-id;fileId 不需要消息上下文", Enum: []string{"mediaId", "fileId"}}, {Name: "resource-id", Type: shortcut.FlagString, Desc: "消息中的 mediaId 或 fileId", Required: true}, - {Name: "message-id", Type: shortcut.FlagString, Desc: "mediaId 所属消息的 openMessageId"}, - {Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "mediaId 所属会话的 openConversationId"}, - {Name: "output", Type: shortcut.FlagString, Default: ".", Desc: "工作目录内的相对文件或目录路径"}, - {Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖已存在的目标文件(默认拒绝)"}, + {Name: "message-id", Type: shortcut.FlagString, Desc: "mediaId 所属消息的 openMessageId;--type mediaId 时必须同时提供 --message-id 和 --open-conversation-id;fileId 不需要消息上下文"}, + {Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "mediaId 所属会话的 openConversationId;--type mediaId 时必须同时提供 --message-id 和 --open-conversation-id;fileId 不需要消息上下文"}, + {Name: "output", Type: shortcut.FlagString, Default: ".", Desc: "工作目录内的相对路径;不允许绝对路径或 .. 逃逸"}, + {Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖工作目录内已存在的目标文件(默认拒绝)"}, }, Constraints: []shortcut.Constraint{ { @@ -204,24 +205,35 @@ func validateResourceDownloadOutputFlag(output, flagName string) error { if output == "" { return apperrors.NewValidation(flagName + " 不能为空") } - // Reject OS-absolute paths and Unix-rooted forms ("/x") even on Windows, - // where filepath.IsAbs("/x") is false but the path is still not a cwd-relative output. - if filepath.IsAbs(output) || strings.HasPrefix(filepath.ToSlash(output), "/") { + if resourcePathIsAbsolute(output) { return apperrors.NewValidation(flagName + " 只接受工作目录内的相对路径") } - clean := filepath.Clean(filepath.FromSlash(output)) - if resourcePathEscapesBase(clean) { + if resourcePathEscapesBase(output) { return apperrors.NewValidation(flagName + " 不允许使用 .. 逃逸工作目录") } return nil } -// resourcePathEscapesBase reports whether rel escapes its base directory. -// Rel values may use '/' or the OS separator (for example mocked filepath.Rel -// results), so normalize before comparing against "..". -func resourcePathEscapesBase(rel string) bool { - rel = filepath.Clean(filepath.FromSlash(strings.TrimSpace(rel))) - return rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) +func resourcePathIsAbsolute(value string) bool { + if filepath.IsAbs(value) { + return true + } + portable := strings.ReplaceAll(strings.TrimSpace(value), "\\", "/") + if pathpkg.IsAbs(portable) { + return true + } + return len(portable) >= 2 && + ((portable[0] >= 'a' && portable[0] <= 'z') || + (portable[0] >= 'A' && portable[0] <= 'Z')) && + portable[1] == ':' +} + +// resourcePathEscapesBase reports whether a relative path escapes its base. +// Normalize both separators so the check remains portable on every host OS. +func resourcePathEscapesBase(value string) bool { + portable := strings.ReplaceAll(strings.TrimSpace(value), "\\", "/") + clean := pathpkg.Clean(portable) + return clean == ".." || strings.HasPrefix(clean, "../") } func resourceDownloadInfo(data map[string]any) (string, map[string]string, error) { @@ -600,5 +612,5 @@ func downloadResourceAtomically( } func init() { - shortcut.Register(MessagesResourceDownload) + shortcut.Register(withReviewedChatShortcutContracts(MessagesResourceDownload)...) } diff --git a/internal/shortcut/chat/resource_download_coverage_test.go b/internal/shortcut/chat/resource_download_coverage_test.go index 6849f6c6..05cded28 100644 --- a/internal/shortcut/chat/resource_download_coverage_test.go +++ b/internal/shortcut/chat/resource_download_coverage_test.go @@ -599,3 +599,221 @@ func TestCrossPlatformCoverageDownloadResourceCopySuccessWithBuffer(t *testing.T t.Fatalf("copied = %q", copied.String()) } } + +func TestCrossPlatformCoverageMessageExportFailureBoundaries(t *testing.T) { + for _, output := range []string{"exports/", "exports/messages.txt"} { + if err := ValidateMessageExportOutput(output); err == nil { + t.Fatalf("ValidateMessageExportOutput(%q) succeeded", output) + } + } + if _, _, err := WriteMessageExportJSON("not-json.txt", false, map[string]any{}); err == nil { + t.Fatal("invalid export path reached write pipeline") + } + + baseSetup := func(t *testing.T) string { + t.Helper() + resetResourceDownloadHooks(t) + base := t.TempDir() + resourceGetwd = func() (string, error) { return base, nil } + resourceAbs = filepath.Abs + resourceEvalSymlinks = filepath.EvalSymlinks + resourceLstat = os.Lstat + resourceRel = filepath.Rel + resourceCreateTemp = os.CreateTemp + resourceTempSync = (*os.File).Sync + resourceTempClose = (*os.File).Close + resourceRename = os.Rename + resourceLink = os.Link + return base + } + + t.Run("path setup", func(t *testing.T) { + baseSetup(t) + resourceGetwd = func() (string, error) { return "", errors.New("getwd") } + if _, _, err := WriteMessageExportJSON("out.json", false, map[string]any{}); err == nil { + t.Fatal("getwd failure ignored") + } + }) + t.Run("absolute path", func(t *testing.T) { + baseSetup(t) + resourceAbs = func(string) (string, error) { return "", errors.New("abs") } + if _, _, err := WriteMessageExportJSON("out.json", false, map[string]any{}); err == nil { + t.Fatal("abs failure ignored") + } + }) + t.Run("base symlink", func(t *testing.T) { + baseSetup(t) + resourceEvalSymlinks = func(string) (string, error) { return "", errors.New("eval") } + if _, _, err := WriteMessageExportJSON("out.json", false, map[string]any{}); err == nil { + t.Fatal("base symlink failure ignored") + } + }) + t.Run("parent symlink", func(t *testing.T) { + base := baseSetup(t) + calls := 0 + resourceEvalSymlinks = func(path string) (string, error) { + calls++ + if calls == 1 { + return base, nil + } + return "", errors.New("parent") + } + if _, _, err := WriteMessageExportJSON("exports/out.json", false, map[string]any{}); err == nil { + t.Fatal("parent symlink failure ignored") + } + }) + t.Run("parent creation", func(t *testing.T) { + baseSetup(t) + resourceLstat = func(string) (os.FileInfo, error) { + return os.Stat(filepath.Join(t.TempDir(), "missing-parent")) + } + resourceMkdir = func(string, os.FileMode) error { return errors.New("mkdir") } + if _, _, err := WriteMessageExportJSON("exports/out.json", false, map[string]any{}); err == nil { + t.Fatal("parent creation failure ignored") + } + }) + t.Run("parent escapes", func(t *testing.T) { + base := baseSetup(t) + resourceEvalSymlinks = func(path string) (string, error) { + if path == base { + return base, nil + } + return filepath.Dir(base), nil + } + if _, _, err := WriteMessageExportJSON("exports/out.json", false, map[string]any{}); err == nil { + t.Fatal("parent escape accepted") + } + }) + + for _, tc := range []struct { + name string + setup func(t *testing.T, base string) + }{ + {name: "existing symlink", setup: func(t *testing.T, base string) { + target := filepath.Join(base, "out.json") + if err := os.Symlink("missing", target); err != nil { + t.Fatal(err) + } + }}, + {name: "existing directory", setup: func(t *testing.T, base string) { + if err := os.Mkdir(filepath.Join(base, "out.json"), 0o755); err != nil { + t.Fatal(err) + } + }}, + {name: "existing no clobber", setup: func(t *testing.T, base string) { + if err := os.WriteFile(filepath.Join(base, "out.json"), []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + }}, + {name: "lstat error", setup: func(t *testing.T, _ string) { + resourceLstat = func(string) (os.FileInfo, error) { return nil, errors.New("lstat") } + }}, + } { + t.Run(tc.name, func(t *testing.T) { + base := baseSetup(t) + tc.setup(t, base) + if _, _, err := WriteMessageExportJSON("out.json", false, map[string]any{}); err == nil { + t.Fatal("invalid existing target accepted") + } + }) + } + + for _, tc := range []struct { + name string + overwrite bool + payload any + setup func(t *testing.T, base string) + }{ + {name: "marshal", payload: make(chan int)}, + {name: "create temp", payload: map[string]any{}, setup: func(t *testing.T, _ string) { + resourceCreateTemp = func(string, string) (*os.File, error) { return nil, errors.New("create") } + }}, + {name: "write", payload: map[string]any{}, setup: func(t *testing.T, base string) { + resourceCreateTemp = func(string, string) (*os.File, error) { + file, err := os.CreateTemp(base, "closed-*") + if err == nil { + _ = file.Close() + } + return file, err + } + }}, + {name: "sync", payload: map[string]any{}, setup: func(t *testing.T, _ string) { + resourceTempSync = func(*os.File) error { return errors.New("sync") } + }}, + {name: "close", payload: map[string]any{}, setup: func(t *testing.T, _ string) { + resourceTempClose = func(file *os.File) error { + _ = file.Close() + return errors.New("close") + } + }}, + {name: "rename", overwrite: true, payload: map[string]any{}, setup: func(t *testing.T, base string) { + if err := os.WriteFile(filepath.Join(base, "out.json"), []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + resourceRename = func(string, string) error { return errors.New("rename") } + }}, + {name: "link exists", payload: map[string]any{}, setup: func(t *testing.T, _ string) { + resourceLink = func(string, string) error { return os.ErrExist } + }}, + {name: "link", payload: map[string]any{}, setup: func(t *testing.T, _ string) { + resourceLink = func(string, string) error { return errors.New("link") } + }}, + {name: "final rel", overwrite: true, payload: map[string]any{}, setup: func(t *testing.T, base string) { + if err := os.WriteFile(filepath.Join(base, "out.json"), []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + calls := 0 + resourceRel = func(from, to string) (string, error) { + calls++ + if calls < 3 { + return filepath.Rel(from, to) + } + return "", errors.New("rel") + } + }}, + } { + t.Run(tc.name, func(t *testing.T) { + base := baseSetup(t) + if tc.setup != nil { + tc.setup(t, base) + } + if _, _, err := WriteMessageExportJSON("out.json", tc.overwrite, tc.payload); err == nil { + t.Fatal("failure hook was ignored") + } + }) + } +} + +func TestCrossPlatformCoverageMessageExportOverwriteReplacesExistingFile(t *testing.T) { + resetResourceDownloadHooks(t) + base := t.TempDir() + resourceGetwd = func() (string, error) { return base, nil } + resourceAbs = filepath.Abs + resourceEvalSymlinks = filepath.EvalSymlinks + resourceLstat = os.Lstat + resourceRel = filepath.Rel + resourceCreateTemp = os.CreateTemp + resourceTempSync = (*os.File).Sync + resourceTempClose = (*os.File).Close + resourceRename = replaceFileAtomically + resourceLink = os.Link + + target := filepath.Join(base, "out.json") + if err := os.WriteFile(target, []byte("old\n"), 0o600); err != nil { + t.Fatal(err) + } + relative, size, err := WriteMessageExportJSON( + "out.json", true, map[string]any{"value": "new"}, + ) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + const want = "{\n \"value\": \"new\"\n}\n" + if relative != "out.json" || size != len(want) || string(data) != want { + t.Fatalf("relative=%q size=%d data=%q", relative, size, data) + } +} diff --git a/internal/shortcut/chat/resource_download_test.go b/internal/shortcut/chat/resource_download_test.go index 813fae48..7acc3711 100644 --- a/internal/shortcut/chat/resource_download_test.go +++ b/internal/shortcut/chat/resource_download_test.go @@ -95,7 +95,7 @@ func TestCrossPlatformCoverageResourceDownloadInfo(t *testing.T) { } } -func TestResolveResourceDownloadPath(t *testing.T) { +func TestCrossPlatformCoverageResolveResourceDownloadPath(t *testing.T) { base := t.TempDir() if err := os.Mkdir(filepath.Join(base, "downloads"), 0o755); err != nil { t.Fatal(err) @@ -122,14 +122,30 @@ func TestResolveResourceDownloadPath(t *testing.T) { } } -func TestValidateResourceDownloadOutputUsesOwningFlagName(t *testing.T) { +func TestCrossPlatformCoverageValidateResourceDownloadOutputUsesOwningFlagName(t *testing.T) { err := validateResourceDownloadOutputFlag("../escape", "--output-dir") if err == nil || !strings.Contains(err.Error(), "--output-dir") { t.Fatalf("error = %v, want --output-dir", err) } } -func TestResolveResourceDownloadPathRejectsSymlinkParentBeforeCreatingOutside(t *testing.T) { +func TestCrossPlatformCoverageValidateResourceDownloadOutputRejectsPortableAbsolutePaths(t *testing.T) { + for _, output := range []string{ + "/absolute", `\\absolute`, `C:\\absolute`, "C:/absolute", + "C:relative", "c:relative", + } { + if err := validateResourceDownloadOutput(output); err == nil { + t.Errorf("portable absolute output %q unexpectedly accepted", output) + } + } + for _, output := range []string{"../escape", `..\\escape`} { + if err := validateResourceDownloadOutput(output); err == nil { + t.Errorf("portable parent escape %q unexpectedly accepted", output) + } + } +} + +func TestCrossPlatformCoverageResolveResourceDownloadPathRejectsSymlinkParentBeforeCreatingOutside(t *testing.T) { base := t.TempDir() outside := t.TempDir() link := filepath.Join(base, "linked") diff --git a/internal/shortcut/chat/schema_contracts.go b/internal/shortcut/chat/schema_contracts.go new file mode 100644 index 00000000..6b9d7074 --- /dev/null +++ b/internal/shortcut/chat/schema_contracts.go @@ -0,0 +1,160 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package chat + +import ( + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +const reviewedChatShortcutInterfaceReason = "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref." + +// reviewedChatShortcutContractCommands is the exact reviewed migration ledger +// for Chat shortcuts that were delivered by the former committed Schema +// catalog but did not yet carry #830's typed Contract declaration. Exact names +// deliberately prevent a future shortcut from entering Schema by accident. +var reviewedChatShortcutContractCommands = map[string]struct{}{ + "+category-add-conversation": {}, + "+category-list-conversations": {}, + "+category-remove-conversation": {}, + "+chat-add-bot": {}, + "+chat-audit-join": {}, + "+chat-create": {}, + "+chat-get-by-id": {}, + "+chat-list": {}, + "+chat-members-get": {}, + "+chat-mute-member": {}, + "+chat-quit": {}, + "+chat-remove-bot": {}, + "+chat-role-remove": {}, + "+chat-role-remove-user": {}, + "+chat-transfer-owner": {}, + "+chat-update": {}, + "+chat-update-icon": {}, + "+chat-update-settings": {}, + "+conversation-clear-messages": {}, + "+conversation-clear-red-point": {}, + "+conversation-hide": {}, + "+conversation-mark-read": {}, + "+conversation-mark-unread": {}, + "+conversation-mute": {}, + "+conversation-set-top": {}, + "+feed-group-query-item": {}, + "+flag-cancel": {}, + "+flag-create": {}, + "+flag-list": {}, + "+messages-add-emoji": {}, + "+messages-add-text-emotion": {}, + "+messages-batch-recall-by-bot": {}, + "+messages-batch-send-by-bot": {}, + "+messages-combine-forward": {}, + "+messages-create-text-emotion": {}, + "+messages-forward": {}, + "+messages-forward-topic": {}, + "+messages-list": {}, + "+messages-recall": {}, + "+messages-recall-by-bot": {}, + "+messages-remove-emoji": {}, + "+messages-remove-text-emotion": {}, + "+messages-reply": {}, + "+messages-resource-download": {}, + "+messages-resource-url": {}, + "+messages-send-by-bot": {}, + "+messages-set-pin": {}, + "+messages-set-top": {}, + "+messages-unset-pin": {}, + "+messages-unset-top": {}, +} + +// withReviewedChatShortcutContracts ports the previously reviewed Chat Schema +// records into #830's typed declaration model. Existing explicit Contracts are +// preserved. Missing Contracts must be listed in the exact ledger above. +func withReviewedChatShortcutContracts(values ...shortcut.Shortcut) []shortcut.Shortcut { + out := make([]shortcut.Shortcut, len(values)) + for i, value := range values { + out[i] = value + if !value.Contract.Empty() { + continue + } + if _, reviewed := reviewedChatShortcutContractCommands[value.Command]; !reviewed { + continue + } + out[i].Safety = reviewedChatShortcutSafety(value.Risk) + out[i].Contract = reviewedChatShortcutContract(value) + } + return out +} + +func reviewedChatShortcutSafety(risk shortcut.Risk) contract.SafetySpec { + switch risk { + case shortcut.RiskWrite: + return contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "user_required", Idempotency: "unknown", + } + case shortcut.RiskHighWrite: + return contract.SafetySpec{ + Effect: "destructive", Risk: "high", + Confirmation: "user_required", Idempotency: "unknown", + } + default: + return contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + } + } +} + +func reviewedChatShortcutContract(value shortcut.Shortcut) corecmd.ContractDecl { + name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(value.Command, "+"), "-", "_") + cliPath := value.Service + " " + value.Command + aliases := make([]string, 0, len(value.Aliases)) + for _, alias := range value.Aliases { + aliases = append(aliases, value.Service+" "+alias) + } + description := strings.TrimSpace(value.Intent) + if description == "" { + description = strings.TrimSpace(value.Description) + } + examples := append([]string(nil), value.Tips...) + if len(examples) > 2 { + examples = examples[:2] + } + return corecmd.ContractDecl{ + Title: value.Description, + Description: description, + Interface: &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: reviewedChatShortcutInterfaceReason, + }, + Selection: contract.SelectionSpec{ + AgentSummary: value.Description, + UseWhen: []string{description}, + AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, + Examples: examples, + }, + Identity: contract.ToolIdentitySpec{ + ProductID: value.Service, + Name: name, + CanonicalPath: value.Service + "." + name, + CLIPath: cliPath, + PrimaryCLIPath: cliPath, + Aliases: aliases, + }, + } +} diff --git a/internal/shortcut/chat/schema_contracts_test.go b/internal/shortcut/chat/schema_contracts_test.go new file mode 100644 index 00000000..5a709fb9 --- /dev/null +++ b/internal/shortcut/chat/schema_contracts_test.go @@ -0,0 +1,77 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package chat + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageReviewedChatShortcutContracts(t *testing.T) { + base := shortcut.Shortcut{ + Service: "chat", + Command: "+chat-create", + Aliases: []string{"+chat-create-alias"}, + Description: "创建群聊", + Intent: "需要创建群聊时使用", + Risk: shortcut.RiskWrite, + Tips: []string{ + "dws chat +chat-create --title demo", + "dws chat +chat-create --title demo2", + "dws chat +chat-create --title ignored", + }, + } + got := withReviewedChatShortcutContracts(base) + if len(got) != 1 || got[0].Contract.Empty() { + t.Fatalf("reviewed contract = %#v", got) + } + declared := got[0] + if declared.Safety.Effect != "write" || declared.Safety.Confirmation != "user_required" { + t.Fatalf("write safety = %#v", declared.Safety) + } + if declared.Contract.Identity.CanonicalPath != "chat.shortcut_chat_create" || + declared.Contract.Identity.PrimaryCLIPath != "chat +chat-create" || + len(declared.Contract.Identity.Aliases) != 1 || + declared.Contract.Identity.Aliases[0] != "chat +chat-create-alias" { + t.Fatalf("identity = %#v", declared.Contract.Identity) + } + if len(declared.Contract.Selection.Examples) != 2 { + t.Fatalf("examples = %#v, want reviewed maximum of two", declared.Contract.Selection.Examples) + } + + high := base + high.Risk = shortcut.RiskHighWrite + if safety := withReviewedChatShortcutContracts(high)[0].Safety; safety.Effect != "destructive" || safety.Risk != "high" { + t.Fatalf("high-write safety = %#v", safety) + } + read := base + read.Risk = shortcut.RiskRead + read.Intent = "" + if declared := withReviewedChatShortcutContracts(read)[0]; declared.Safety.Effect != "read" || declared.Contract.Description != read.Description { + t.Fatalf("read/fallback declaration = %#v", declared) + } + + unreviewed := base + unreviewed.Command = "+future-unreviewed" + if declared := withReviewedChatShortcutContracts(unreviewed)[0]; !declared.Contract.Empty() { + t.Fatalf("unreviewed future shortcut entered Schema: %#v", declared.Contract) + } + explicit := base + explicit.Contract = corecmd.ContractDecl{Description: "preserve"} + if declared := withReviewedChatShortcutContracts(explicit)[0]; declared.Contract.Description != "preserve" { + t.Fatalf("explicit contract overwritten: %#v", declared.Contract) + } +} diff --git a/internal/shortcut/chat/unified_send.go b/internal/shortcut/chat/unified_send.go index a29987eb..6c52f203 100644 --- a/internal/shortcut/chat/unified_send.go +++ b/internal/shortcut/chat/unified_send.go @@ -8,6 +8,7 @@ import ( "context" "encoding/json" "fmt" + "os" "strings" "time" "unicode/utf8" @@ -18,10 +19,18 @@ import ( apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" ) const messagesSendFileUploadTimeout = 10 * time.Minute +const ( + messagesSendMaxBotGroups = 100 + messagesSendMaxGroupFileSize = 1 << 20 +) + +var messagesSendReadGroupFile = os.ReadFile + // MessagesSend is the identity-aware common sending entry point. The current // user branch reuses the native message leaf's reviewed file-upload flow and // existing-mediaId image path. Bot and webhook remain text/Markdown-only @@ -30,8 +39,8 @@ var MessagesSend = shortcut.Shortcut{ Service: "chat", Command: "+messages-send", Product: "chat", - Description: "统一发送文本、Markdown、当前用户文件或已有 mediaId 图片", - Intent: "当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;--user 传 userId 时包括在 --dry-run 中也会先通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId。bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。", + Description: "按身份和目标统一发送消息,Bot 多群返回逐目标 ledger", + Intent: "当你需要文件、复杂 @、幂等,或选择 current-user、bot、webhook 身份发送消息时使用;current-user 可直接传稳定 ID,也可用 --user-query/--chat-query 在 CLI 内唯一解析自然目标,dry-run 与真实执行使用同一解析链。Bot 可用 --groups/--groups-file 向最多 100 个稳定群 ID 发送文本或 Markdown,去重后返回 im.batch-write.v1 逐目标 ledger;webhook 目标由 token 所在群决定。文件上传和已有 mediaId 图片仅 current-user 支持,bot/webhook 不支持富媒体。", Risk: shortcut.RiskWrite, Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", @@ -45,15 +54,15 @@ var MessagesSend = shortcut.Shortcut{ CLIPath: "chat +messages-send", PrimaryCLIPath: "chat +messages-send", }, - Description: "统一发送文本、Markdown、当前用户文件或已有 mediaId 图片", + Description: "按身份和目标统一发送消息,Bot 多群返回逐目标 ledger", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed composite send adapter: it selects current-user, bot, or webhook transport; current-user additionally supports live-compatible contact search with exact userId matching, mediaId images, and the native init/upload/commit local-file flow.", }, Selection: contract.SelectionSpec{ - AgentSummary: "统一发送文本、Markdown、当前用户文件或已有 mediaId 图片", - UseWhen: []string{"当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;--user 传 userId 时包括在 --dry-run 中也会先通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId。bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。"}, + AgentSummary: "按身份和目标统一发送消息,Bot 多群返回逐目标 ledger", + UseWhen: []string{"当你需要文件、复杂 @、幂等,或选择 current-user、bot、webhook 身份发送消息时使用;current-user 可直接传稳定 ID,也可用 --user-query/--chat-query 在 CLI 内唯一解析自然目标,dry-run 与真实执行使用同一解析链。Bot 可用 --groups/--groups-file 向最多 100 个稳定群 ID 发送文本或 Markdown,去重后返回 im.batch-write.v1 逐目标 ledger;webhook 目标由 token 所在群决定。文件上传和已有 mediaId 图片仅 current-user 支持,bot/webhook 不支持富媒体。"}, AvoidWhen: []string{"需要 bot/webhook 发送媒体、卡片或 thread 回复时不要假设等价支持;改用真实存在的专用下层命令,缺少下层能力时停止"}, Examples: []string{ "dws chat +messages-send --as user --chat-id --markdown \"## 周报\" --idempotency-key ", @@ -66,7 +75,11 @@ var MessagesSend = shortcut.Shortcut{ {Name: "as", Type: shortcut.FlagString, Enum: []string{"user", "bot", "webhook"}, Desc: "--identity 的 lark-cli 对齐别名;受发送身份能力矩阵约束"}, {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(user/bot 群聊);受发送身份能力矩阵约束"}, {Name: "chat-id", Type: shortcut.FlagString, Desc: "--group 的 lark-cli 对齐别名;受发送身份能力矩阵约束"}, + {Name: "groups", Type: shortcut.FlagStringSlice, Desc: "多个群 openConversationId(仅 bot;受发送身份能力矩阵约束,逐群返回 typed ledger,最多 100 个)"}, + {Name: "groups-file", Type: shortcut.FlagString, Desc: "工作目录内相对文本文件(仅 bot;受发送身份能力矩阵约束),每行或逗号分隔一个群 openConversationId"}, + {Name: "chat-query", Type: shortcut.FlagString, Desc: "按群名解析唯一群聊(仅 user 的高级发送场景);受发送身份能力矩阵约束"}, {Name: "user", Type: shortcut.FlagString, Desc: "单聊接收者 userId(user;包括 --dry-run 也会先通过通讯录搜索精确匹配 openDingTalkId);受发送身份能力矩阵约束"}, + {Name: "user-query", Type: shortcut.FlagString, Desc: "按姓名解析唯一 openDingTalkId(仅 user 的高级发送场景);受发送身份能力矩阵约束"}, {Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊接收者 openDingTalkId(user);受发送身份能力矩阵约束"}, {Name: "users", Type: shortcut.FlagStringSlice, Desc: "批量单聊接收者 userId(bot);受发送身份能力矩阵约束"}, {Name: "open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "批量单聊接收者 openDingTalkId(bot);受发送身份能力矩阵约束"}, @@ -99,14 +112,14 @@ var MessagesSend = shortcut.Shortcut{ {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"uuid", "idempotency-key"}}, { Kind: shortcut.ConstraintCustom, - Flags: []string{"identity", "as", "group", "chat-id", "user", "open-dingtalk-id", "users", "open-dingtalk-ids", "robot-code", "webhook-token", "uuid", "idempotency-key"}, - Description: "目标、凭据和幂等参数受发送身份能力矩阵约束:user 必须指定一个群聊或单聊目标;bot 必须指定 robot-code 和一类目标;webhook 必须指定 webhook-token;幂等键仅 user 支持", + Flags: []string{"identity", "as", "group", "chat-id", "groups", "groups-file", "chat-query", "user", "user-query", "open-dingtalk-id", "users", "open-dingtalk-ids", "robot-code", "webhook-token", "uuid", "idempotency-key"}, + Description: "目标、凭据和幂等参数受发送身份能力矩阵约束:user 必须指定一个群聊或单聊目标;bot 必须指定 robot-code 和一类目标,多群最多 100 个并逐项返回 ledger;webhook 必须指定 webhook-token;幂等键仅 user 支持", }, }, Tips: []string{ `dws chat +messages-send --as user --chat-id --markdown "## 周报" --idempotency-key `, `dws chat +messages-send --as user --user --msg-type file --file ./report.pdf --idempotency-key `, - `dws chat +messages-send --as bot --robot-code --users userId1,userId2 --text "请提交周报"`, + `dws chat +messages-send --as bot --robot-code --groups , --text "请提交周报"`, }, Validate: validateMessagesSend, Execute: executeMessagesSend, @@ -115,7 +128,13 @@ var MessagesSend = shortcut.Shortcut{ func validateMessagesSend(rt *shortcut.RuntimeContext) error { identity := messagesSendIdentity(rt) group := rt.StrFirst("chat-id", "group") + botGroups, botGroupsErr := messagesSendBotGroups(rt) + if botGroupsErr != nil { + return botGroupsErr + } + chatQuery := rt.Str("chat-query") userID := rt.Str("user") + userQuery := rt.Str("user-query") openID := rt.Str("open-dingtalk-id") users := uniqueShortcutStrings(rt.StrSlice("users")) openIDs := uniqueShortcutStrings(rt.StrSlice("open-dingtalk-ids")) @@ -134,29 +153,36 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error { } switch identity { case "user": - targetCount := nonEmptyStringCount(group, userID, openID) + targetCount := nonEmptyStringCount(group, chatQuery, userID, userQuery, openID) if targetCount != 1 { - return apperrors.NewValidation("--identity user 时 --group、--user、--open-dingtalk-id 必须且只能指定一个") + return apperrors.NewValidation("--identity user 时 --group/--chat-id、--chat-query、--user、--user-query、--open-dingtalk-id 必须且只能指定一个") } - if len(users) > 0 || len(openIDs) > 0 || rt.Str("robot-code") != "" || rt.Str("webhook-token") != "" { + if len(users) > 0 || len(openIDs) > 0 || len(botGroups) > 0 || rt.Str("robot-code") != "" || rt.Str("webhook-token") != "" { return apperrors.NewValidation("--identity user 不接受 bot/webhook 凭据或批量目标") } if len(atUserIDs) > 0 || len(atMobiles) > 0 { return apperrors.NewValidation("--identity user 只接受 --at-open-dingtalk-ids") } - if (userID != "" || openID != "") && (len(atOpenIDs) > 0 || rt.Bool("at-all")) { + if (userID != "" || userQuery != "" || openID != "") && (len(atOpenIDs) > 0 || rt.Bool("at-all")) { return apperrors.NewValidation("user 单聊不接受 @ 参数;@ 只适用于群聊") } if contentType != "text" && contentType != "markdown" && (len(atOpenIDs) > 0 || rt.Bool("at-all")) { return apperrors.NewValidation("user image/file/audio/video 当前不接受 @ 参数") } case "bot": + if chatQuery != "" || userQuery != "" { + return apperrors.NewValidation("--identity bot 当前不接受 --chat-query 或 --user-query;请传真实群 ID 或批量用户 ID") + } if rt.Str("robot-code") == "" { return apperrors.NewValidation("--identity bot 必须指定 --robot-code") } hasDirect := len(users)+len(openIDs) > 0 - if (group != "") == hasDirect { - return apperrors.NewValidation("--identity bot 时 --group 与批量单聊目标必须且只能指定一类") + hasGroup := group != "" || len(botGroups) > 0 + if hasGroup == hasDirect { + return apperrors.NewValidation("--identity bot 时单群/多群与批量单聊目标必须且只能指定一类") + } + if group != "" && len(botGroups) > 0 { + return apperrors.NewValidation("--identity bot 时 --group/--chat-id 与 --groups/--groups-file 不能同时使用") } if userID != "" || openID != "" || rt.Str("webhook-token") != "" { return apperrors.NewValidation("--identity bot 不接受 --user、--open-dingtalk-id 或 --webhook-token") @@ -170,14 +196,17 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error { if messagesSendIdempotencyKey(rt) != "" { return apperrors.NewValidation("--uuid 当前仅 user 身份的下层支持") } - if contentType != "text" && contentType != "markdown" { + if !messageIdentitySupportsContent(identity, contentType) { return apperrors.NewValidation("--identity bot 当前下层只支持 text/markdown") } case "webhook": + if chatQuery != "" || userQuery != "" { + return apperrors.NewValidation("--identity webhook 的目标由 token 所在群决定,不接受 --chat-query 或 --user-query") + } if rt.Str("webhook-token") == "" { return apperrors.NewValidation("--identity webhook 必须指定 --webhook-token") } - if group != "" || userID != "" || openID != "" || len(users) > 0 || len(openIDs) > 0 || rt.Str("robot-code") != "" { + if group != "" || len(botGroups) > 0 || userID != "" || openID != "" || len(users) > 0 || len(openIDs) > 0 || rt.Str("robot-code") != "" { return apperrors.NewValidation("--identity webhook 的目标由 token 所在群决定,不接受其他目标或 bot Code") } if len(atOpenIDs) > 0 { @@ -186,7 +215,7 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error { if messagesSendIdempotencyKey(rt) != "" { return apperrors.NewValidation("--uuid 当前仅 user 身份的下层支持") } - if contentType != "text" && contentType != "markdown" { + if !messageIdentitySupportsContent(identity, contentType) { return apperrors.NewValidation("--identity webhook 当前下层只支持 text/markdown") } } @@ -225,33 +254,10 @@ func executeMessagesSend(rt *shortcut.RuntimeContext) error { if contentType == "file" || contentType == "audio" || contentType == "video" { return executeMessagesSendUserFile(rt, group, openID, contentType) } - if group != "" { - body = helpers.NormalizeMessageMentions( - body, - uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")), - rt.Bool("at-all"), - true, - ) - } - content, _ := json.Marshal(map[string]string{"title": title, "text": body}) - params := rt.AddAIMessageTag(map[string]any{ - "msgType": "markdown", - "content": string(content), - }) - if group != "" { - params["openConversationId"] = group - if values := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")); len(values) > 0 { - params["atOpenDingTalkIds"] = values - } - if rt.Bool("at-all") { - params["atAll"] = true - } - } else { - params["receiverOpenDingTalkId"] = openID - } - if value := messagesSendIdempotencyKey(rt); value != "" { - params["uuid"] = value - } + params := resolvedUserMarkdownParams(rt, ResolvedUserMessageTarget{ + GroupID: group, + OpenDingTalkID: openID, + }, title, body, uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")), rt.Bool("at-all"), messagesSendIdempotencyKey(rt)) return executeUnifiedMessageWrite(rt, "chat", "send_personal_message", params) case "bot": body = helpers.NormalizeMessageMentions( @@ -281,6 +287,31 @@ func executeMessagesSend(rt *shortcut.RuntimeContext) error { } return executeUnifiedMessageWrite(rt, "bot", "send_robot_group_message", params) } + groups, err := messagesSendBotGroups(rt) + if err != nil { + return err + } + if len(groups) > 0 { + items := make([]shortcutBatchWrite, 0, len(groups)) + for _, group := range groups { + arguments := make(map[string]any, len(params)+1) + for key, value := range params { + arguments[key] = value + } + arguments["openConversationId"] = group + if values := uniqueShortcutStrings(rt.StrSlice("at-user-ids")); len(values) > 0 { + arguments["atUserIds"] = values + } + if values := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")); len(values) > 0 { + arguments["atOpendingtalkIds"] = values + } + if rt.Bool("at-all") { + arguments["isAtAll"] = "true" + } + items = append(items, shortcutBatchWrite{target: group, arguments: arguments}) + } + return executeShortcutBatchWrite(rt, "bot", "send_robot_group_message", items) + } if values := uniqueShortcutStrings(rt.StrSlice("users")); len(values) > 0 { params["userIds"] = values } @@ -393,6 +424,20 @@ func messagesSendUserTarget(rt *shortcut.RuntimeContext) (group, openID string, if openID != "" || group != "" { return group, openID, nil } + if query := rt.Str("chat-query"); query != "" { + resolved, resolveErr := targetresolver.ResolveChat(rt, query) + if resolveErr != nil { + return "", "", resolveErr + } + return resolved.Selected.OpenConversationID, "", nil + } + if query := rt.Str("user-query"); query != "" { + resolved, resolveErr := targetresolver.ResolveUser(rt, query, targetresolver.IdentityOpenDingTalkID) + if resolveErr != nil { + return "", "", resolveErr + } + return "", resolved.Selected.OpenDingTalkID, nil + } openID, err = resolveUserOpenDingTalkID(rt, rt.Str("user")) if err != nil { return "", "", err @@ -400,6 +445,64 @@ func messagesSendUserTarget(rt *shortcut.RuntimeContext) (group, openID string, return "", openID, nil } +// ResolvedUserMessageTarget is the stable target accepted by the shared user +// send engine after natural-name resolution has completed. +type ResolvedUserMessageTarget struct { + GroupID string + OpenDingTalkID string +} + +// ExecuteResolvedUserMarkdown lets narrow semantic shortcuts such as +dm and +// +send-to-group reuse the same target/content/AI-tag parameter builder while +// preserving their existing raw lower-response output contract. +func ExecuteResolvedUserMarkdown( + rt *shortcut.RuntimeContext, + target ResolvedUserMessageTarget, + text string, +) error { + params := resolvedUserMarkdownParams( + rt, + target, + text, + text, + nil, + false, + "", + ) + return rt.CallMCP("send_personal_message", params) +} + +func resolvedUserMarkdownParams( + rt *shortcut.RuntimeContext, + target ResolvedUserMessageTarget, + title, body string, + atOpenIDs []string, + atAll bool, + idempotencyKey string, +) map[string]any { + if target.GroupID != "" { + body = helpers.NormalizeMessageMentions(body, atOpenIDs, atAll, true) + } + content, _ := json.Marshal(map[string]string{"title": title, "text": body}) + params := rt.AddAIMessageTag(map[string]any{ + "msgType": "markdown", + "content": string(content), + }) + addMessagesSendUserTarget(params, target.GroupID, target.OpenDingTalkID) + if target.GroupID != "" { + if len(atOpenIDs) > 0 { + params["atOpenDingTalkIds"] = atOpenIDs + } + if atAll { + params["atAll"] = true + } + } + if idempotencyKey != "" { + params["uuid"] = idempotencyKey + } + return params +} + func resolveUserOpenDingTalkID(rt *shortcut.RuntimeContext, userID string) (string, error) { userID = strings.TrimSpace(userID) data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{ @@ -546,6 +649,52 @@ func messagesSendIdempotencyKey(rt *shortcut.RuntimeContext) string { return rt.StrFirst("idempotency-key", "uuid") } +func messagesSendBotGroups(rt *shortcut.RuntimeContext) ([]string, error) { + if rt.Changed("groups") && rt.Changed("groups-file") { + return nil, apperrors.NewValidation("--groups 与 --groups-file 不能同时指定") + } + groups := uniqueShortcutStrings(rt.StrSlice("groups")) + if path := rt.Str("groups-file"); path != "" { + safePath, err := apperrors.SafeInputPath(path) + if err != nil { + return nil, fmt.Errorf("校验 --groups-file 失败: %w", err) + } + info, err := os.Stat(safePath) + if err != nil { + return nil, fmt.Errorf("读取 --groups-file 失败: %w", err) + } + if !info.Mode().IsRegular() { + return nil, apperrors.NewValidation("--groups-file 必须是普通文本文件") + } + if info.Size() > messagesSendMaxGroupFileSize { + return nil, apperrors.NewValidation("--groups-file 不能超过 1 MiB") + } + raw, err := messagesSendReadGroupFile(safePath) + if err != nil { + return nil, fmt.Errorf("读取 --groups-file 失败: %w", err) + } + values := make([]string, 0) + for _, line := range strings.Split(string(raw), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + values = append(values, strings.Split(line, ",")...) + } + groups = uniqueShortcutStrings(values) + } + if len(groups) > messagesSendMaxBotGroups { + return nil, apperrors.NewValidation(fmt.Sprintf( + "bot 多群发送最多支持 %d 个群,当前 %d 个", + messagesSendMaxBotGroups, len(groups), + )) + } + if (rt.Changed("groups") || rt.Changed("groups-file")) && len(groups) == 0 { + return nil, apperrors.NewValidation("bot 多群发送至少需要一个 openConversationId") + } + return groups, nil +} + func shortcutMessageTitle(text string) string { text = strings.TrimSpace(strings.SplitN(text, "\n", 2)[0]) if utf8.RuneCountInString(text) <= 40 { @@ -556,5 +705,5 @@ func shortcutMessageTitle(text string) string { } func init() { - shortcut.Register(MessagesSend) + shortcut.Register(withReviewedChatShortcutContracts(MessagesSend)...) } diff --git a/internal/shortcut/chat/unified_send_resolution_test.go b/internal/shortcut/chat/unified_send_resolution_test.go new file mode 100644 index 00000000..0925fdab --- /dev/null +++ b/internal/shortcut/chat/unified_send_resolution_test.go @@ -0,0 +1,177 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package chat + +import ( + stderrors "errors" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" +) + +func TestCrossPlatformCoverageMessagesSendResolvesNaturalUserAndChatTargets(t *testing.T) { + tests := []struct { + name string + args []string + responses map[string]string + wantTarget string + wantValue string + }{ + { + name: "user query", + args: []string{ + "chat", "+messages-send", "--as", "user", + "--user-query", "张三", "--text", "你好", "--yes", + }, + responses: map[string]string{ + "contact/search_contact_by_key_word": `{"result":[{"name":"张三","userId":"u1","openDingTalkId":"D1"}]}`, + }, + wantTarget: "receiverOpenDingTalkId", + wantValue: "D1", + }, + { + name: "chat query exact wins", + args: []string{ + "chat", "+messages-send", "--as", "user", + "--chat-query", "项目群", "--text", "你好", "--yes", + }, + responses: map[string]string{ + "im/search_groups": `{"result":[{"title":"项目群-归档","openConversationId":"c2"},{"title":"项目群","openConversationId":"c1"}]}`, + }, + wantTarget: "openConversationId", + wantValue: "c1", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := &larkAlignmentCaller{responses: tt.responses} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs(tt.args) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 2 { + t.Fatalf("calls = %#v, want resolve + send", fake.calls) + } + send := fake.calls[1] + if send.product != "chat" || send.tool != "send_personal_message" { + t.Fatalf("send = %#v", send) + } + if send.args[tt.wantTarget] != tt.wantValue { + t.Fatalf("%s = %#v, want %q", tt.wantTarget, send.args[tt.wantTarget], tt.wantValue) + } + }) + } +} + +func TestCrossPlatformCoverageMessagesSendNaturalTargetAmbiguityHasNoWrite(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "contact/search_contact_by_key_word": `{"result":[{"name":"张三","userId":"u1","openDingTalkId":"D1"},{"name":"张三","userId":"u2","openDingTalkId":"D2"}]}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+messages-send", "--as", "user", + "--user-query", "张三", "--text", "你好", "--yes", + }) + err := root.Execute() + if err == nil { + t.Fatal("ambiguous user unexpectedly sent") + } + if len(fake.calls) != 1 || fake.calls[0].tool != "search_contact_by_key_word" { + t.Fatalf("ambiguous resolution reached write: %#v", fake.calls) + } + var typed *apperrors.Error + if !stderrors.As(err, &typed) { + t.Fatalf("error type = %T", err) + } + if typed.Reason != "resolution_ambiguous" || typed.Details["type"] != "resolution" { + t.Fatalf("structured error = %#v", typed) + } +} + +func TestCrossPlatformCoverageMessagesSendChatQueryResolvesAllPagesBeforeWrite(t *testing.T) { + fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{ + "im/search_groups": { + `{"result":[{"title":"项目群-归档","openConversationId":"archive"}],"hasMore":true,"nextCursor":"page-2"}`, + `{"result":[{"title":"项目群","openConversationId":"active"}],"hasMore":false}`, + }, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+messages-send", "--as", "user", + "--chat-query", "项目群", "--text", "你好", "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 3 || fake.calls[0].tool != "search_groups" || + fake.calls[1].tool != "search_groups" || fake.calls[2].tool != "send_personal_message" { + t.Fatalf("calls = %#v, want two resolution pages then one write", fake.calls) + } + if fake.calls[1].args["cursor"] != "page-2" || fake.calls[2].args["openConversationId"] != "active" { + t.Fatalf("calls = %#v", fake.calls) + } +} + +func TestCrossPlatformCoverageMessagesSendIncompleteChatResolutionHasNoWrite(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/search_groups": `{"result":[{"title":"项目群","openConversationId":"c1"}],"hasMore":true}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+messages-send", "--as", "user", + "--chat-query", "项目群", "--text", "你好", "--yes", + }) + err := root.Execute() + var typed *apperrors.Error + if !stderrors.As(err, &typed) || typed.Reason != "resolution_incomplete" { + t.Fatalf("error = %#v", err) + } + if len(fake.calls) != 1 || fake.calls[0].tool != "search_groups" { + t.Fatalf("incomplete resolution reached write: %#v", fake.calls) + } +} + +func TestCrossPlatformCoverageMessagesSendDryRunUsesRealNaturalTargetResolution(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/search_groups": `{"result":[{"title":"项目群","openConversationId":"c1"}]}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+messages-send", "--as", "user", + "--chat-query", "项目群", "--text", "你好", "--dry-run", "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 1 { + t.Fatalf("dry-run calls = %#v, want one read-only resolution", fake.calls) + } + if fake.calls[0].product != "im" || fake.calls[0].tool != "search_groups" { + t.Fatalf("dry-run resolution = %#v", fake.calls[0]) + } +} + +func TestCrossPlatformCoverageMessagesSendRejectsNaturalTargetForUnsupportedIdentity(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+messages-send", "--as", "bot", "--robot-code", "r", + "--chat-query", "项目群", "--text", "你好", "--yes", + }) + if err := root.Execute(); err == nil { + t.Fatal("bot natural target unexpectedly accepted") + } + if len(fake.calls) != 0 { + t.Fatalf("invalid identity reached lower service: %#v", fake.calls) + } +} diff --git a/internal/shortcut/chatmsg/chatmsg.go b/internal/shortcut/chatmsg/chatmsg.go index 030ce8ae..d2d6ee04 100644 --- a/internal/shortcut/chatmsg/chatmsg.go +++ b/internal/shortcut/chatmsg/chatmsg.go @@ -38,6 +38,98 @@ import ( "strings" ) +// MessageListContractVersion identifies the additive, compatibility-preserving +// public envelope shared by message list/search/mget/thread projections. +const MessageListContractVersion = "im.message-list.v1" + +// MessageResultContract is the reviewed additive contract shared by message +// list, search, mget, @me and thread projections. Keep this descriptor small: +// Runtime owns the values, while Skill references and policy checks consume a +// copy of these field names so prose cannot silently invent another result +// shape. +type MessageResultContract struct { + Version string + MessageFields []string + EnvelopeFields []string +} + +var messageResultContractV1 = MessageResultContract{ + Version: MessageListContractVersion, + MessageFields: []string{ + "messageId", + "conversationId", + "threadId", + "sender", + "senderId", + "senderType", + "messageType", + "text", + "createTime", + "updateTime", + "reactions", + "quotedMessage", + "forwarded", + "resourceRefs", + }, + EnvelopeFields: []string{ + "contractVersion", + "messages", + "count", + "pagesFetched", + "paginationKnown", + "complete", + "hasMore", + "nextPage", + "stopReason", + "truncatedByPageLimit", + "truncatedByResultLimit", + "failedCount", + "failures", + "partial", + "resourceDownloads", + }, +} + +// CurrentMessageResultContract returns defensive copies so callers cannot +// mutate the process-wide reviewed descriptor. +func CurrentMessageResultContract() MessageResultContract { + contract := messageResultContractV1 + contract.MessageFields = append([]string(nil), contract.MessageFields...) + contract.EnvelopeFields = append([]string(nil), contract.EnvelopeFields...) + return contract +} + +// NewMessageListPayload initializes the common result ledger before a caller +// adds pagination or resource-download facts. +func NewMessageListPayload(messages []map[string]any) map[string]any { + if messages == nil { + messages = []map[string]any{} + } + return map[string]any{ + "contractVersion": MessageListContractVersion, + "messages": messages, + "count": len(messages), + "pagesFetched": 0, + "paginationKnown": false, + "complete": false, + "hasMore": false, + "failedCount": 0, + "failures": []map[string]any{}, + "partial": false, + } +} + +// StableMessageID returns the normalized message identity used for +// cross-page deduplication. An empty value means the lower response did not +// publish a stable identity; callers must keep that row rather than guessing. +func StableMessageID(message map[string]any) string { + value := MessageID(message) + if value == nil { + return "" + } + return strings.TrimSpace(fmt.Sprint(value)) +} + // Sender reads a message's speaker display name, tolerating common sender-name // keys. The message-list responses carry the display name under the bare // "sender" key (verified live), so it is probed first; the remaining aliases and @@ -150,6 +242,88 @@ func MessageType(m map[string]any) any { return firstMessageValue(m, "msgType", "messageType", "message_type", "type") } +// SenderID preserves the stable sender identity without replacing the legacy +// scalar sender display field. Nested sender records and both userId families +// are accepted because list/search/mget currently expose different shapes. +func SenderID(m map[string]any) any { + for _, key := range []string{"sender", "from", "senderUser"} { + if nested, ok := m[key].(map[string]any); ok { + if value := firstMessageValue(nested, + "openDingTalkId", "openDingtalkId", "userId", "senderId", "id"); value != nil { + return value + } + } + } + return firstMessageValue(m, + "senderOpenDingTalkId", "senderOpenDingtalkId", "senderUserId", + "senderId", "sender_id", "senderStaffId", "openDingTalkId", "userId") +} + +// SenderType returns only an explicitly published lower sender type. It does +// not guess that every sender identity is a user because bot/system messages +// can share the same generic senderId key. +func SenderType(m map[string]any) any { + for _, key := range []string{"sender", "from", "senderUser"} { + if nested, ok := m[key].(map[string]any); ok { + if value := firstMessageValue(nested, "senderType", "type", "entityType"); value != nil { + return value + } + } + } + return firstMessageValue(m, "senderType", "sender_type", "fromType", "from_type") +} + +// ProjectMessageV1 is the single compatibility-preserving core projection for +// list, search, mget, @me, and thread readers. Public wrappers may retain +// legacy aliases such as time or msgType, but the underlying identity, +// context, reaction, quote, forward, and resource semantics come from here. +func ProjectMessageV1(m map[string]any, includeReactions bool) map[string]any { + row := map[string]any{ + "sender": Sender(m), + "text": Text(m), + "createTime": CreateTime(m), + } + if value := MessageID(m); value != nil { + row["messageId"] = value + } + if value := ConversationID(m); value != nil { + row["conversationId"] = value + } + if value := ThreadID(m); value != nil { + row["threadId"] = value + } + if value := SenderID(m); value != nil { + row["senderId"] = value + } + if value := SenderType(m); value != nil { + row["senderType"] = value + } + if value := MessageType(m); value != nil { + row["messageType"] = value + } + if value := UpdateTime(m); value != nil { + row["updateTime"] = value + } + if includeReactions { + if reactions := Reactions(m); len(reactions) > 0 { + row["reactions"] = reactions + } + } + if quoted := QuotedMessage(m); len(quoted) > 0 { + row["quotedMessage"] = quoted + } + if resources := ResourcesDeep(m); len(resources) > 0 { + row["resourceRefs"] = resources + } + projectForwarded := func(item map[string]any) map[string]any { + return ProjectMessageV1(item, includeReactions) + } + if forwarded := Forwarded(m, projectForwarded); len(forwarded) > 0 { + row["forwarded"] = forwarded + } + return row +} + // QuotedMessage projects one level of quoted/replied-to context. It is // deliberately non-recursive: a reply chain may be arbitrarily deep or even // cyclic after gateway reshaping, while an Agent primarily needs the quoted @@ -605,13 +779,35 @@ func ApplyPagination(payload, data map[string]any) { // message-list contract paginates with the boundary message createTime, so the // resume object uses exactly that accepted parameter. func ApplyMessagePagination(payload, data map[string]any, messages []map[string]any, direction string) { + payload["contractVersion"] = MessageListContractVersion + payload["pagesFetched"] = 1 + payload["enrichedCount"] = 0 + payload["failedCount"] = 0 + payload["failures"] = []map[string]any{} + payload["hasMore"] = false + payload["complete"] = false page := Pagination(data) if len(page) == 0 { + payload["paginationKnown"] = false + payload["failedCount"] = 1 + payload["failures"] = []map[string]any{{ + "stage": "pagination", + "error": "下层未返回可靠的 hasMore/nextCursor,无法证明结果完整", + }} return } - if value, ok := page["hasMore"]; ok { - payload["hasMore"] = value + value, hasMoreKnown := page["hasMore"] + if !hasMoreKnown { + payload["paginationKnown"] = false + payload["failedCount"] = 1 + payload["failures"] = []map[string]any{{ + "stage": "pagination", + "error": "下层仅返回 cursor、未返回 hasMore,无法证明结果完整", + }} + return } + payload["paginationKnown"] = true + payload["hasMore"] = value if value, ok := page["complete"]; ok { payload["complete"] = value } @@ -621,6 +817,11 @@ func ApplyMessagePagination(payload, data map[string]any, messages []map[string] } boundary := CreateTime(messages[len(messages)-1]) if boundary == nil { + payload["failedCount"] = 1 + payload["failures"] = []map[string]any{{ + "stage": "pagination", + "error": "下层返回 hasMore=true,但末条消息缺少可继续读取的 createTime", + }} return } next := map[string]any{"time": boundary} diff --git a/internal/shortcut/chatmsg/chatmsg_test.go b/internal/shortcut/chatmsg/chatmsg_test.go index 701b4088..63d0b797 100644 --- a/internal/shortcut/chatmsg/chatmsg_test.go +++ b/internal/shortcut/chatmsg/chatmsg_test.go @@ -18,7 +18,7 @@ import ( "testing" ) -func TestSender(t *testing.T) { +func TestCrossPlatformCoverageSender(t *testing.T) { // The display name lives under the bare "sender" key. if got := Sender(map[string]any{"sender": "念晨", "senderOpenDingTalkId": "D1"}); got != "念晨" { t.Fatalf("sender = %v, want 念晨", got) @@ -48,7 +48,37 @@ func TestSender(t *testing.T) { } } -func TestCleanText(t *testing.T) { +func TestCrossPlatformCoverageProjectMessageV1PublishesSharedIdentityAndContext(t *testing.T) { + row := ProjectMessageV1(map[string]any{ + "openMessageId": "msg-1", + "openConversationId": "cid-1", + "openConvThreadId": "thread-1", + "sender": map[string]any{ + "name": "张三", + "openDingTalkId": "D1", + "senderType": "user", + }, + "msgType": "text", + "content": "你好", + "createTime": "2026-08-03 10:00:00", + }, true) + for key, want := range map[string]any{ + "messageId": "msg-1", + "conversationId": "cid-1", + "threadId": "thread-1", + "sender": "张三", + "senderId": "D1", + "senderType": "user", + "messageType": "text", + "text": "你好", + } { + if row[key] != want { + t.Errorf("%s = %#v, want %#v; row=%#v", key, row[key], want, row) + } + } +} + +func TestCrossPlatformCoverageCleanText(t *testing.T) { // Out-of-office auto-reply: readable body lives in items[].data.text; the // decorative preview/config JSON lines and "empty" placeholder are dropped. autoReply := "* 仅你和对方可见\n" + @@ -98,7 +128,7 @@ func TestCleanText(t *testing.T) { } } -func TestIsEncryptedAndMarker(t *testing.T) { +func TestCrossPlatformCoverageIsEncryptedAndMarker(t *testing.T) { cipher := "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP\n" + "7oDptklFO+YzDltH+myErV6rkc8URHYykpeSDsMP6kznFa9E320NsIntfY771dx+\n" + "||2||1||196" @@ -121,7 +151,7 @@ func TestIsEncryptedAndMarker(t *testing.T) { } } -func TestText(t *testing.T) { +func TestCrossPlatformCoverageText(t *testing.T) { if got := Text(map[string]any{"content": "你好"}); got != "你好" { t.Errorf("Text string = %v", got) } @@ -136,7 +166,7 @@ func TestText(t *testing.T) { } } -func TestCreateTime(t *testing.T) { +func TestCrossPlatformCoverageCreateTime(t *testing.T) { if got := CreateTime(map[string]any{"sendTime": "2026-07-19 13:37:03"}); got != "2026-07-19 13:37:03" { t.Errorf("CreateTime = %v", got) } @@ -145,7 +175,7 @@ func TestCreateTime(t *testing.T) { } } -func TestStableMessageIdentity(t *testing.T) { +func TestCrossPlatformCoverageStableMessageIdentity(t *testing.T) { message := map[string]any{ "openMessageId": "msg-1", "openConversationId": "cid-1", @@ -166,7 +196,31 @@ func TestStableMessageIdentity(t *testing.T) { } } -func TestQuotedMessageIsBoundedAndSemantic(t *testing.T) { +func TestCrossPlatformCoverageMessageLedgerNilAndCursorOnlyBoundaries(t *testing.T) { + contract := CurrentMessageResultContract() + if contract.Version != MessageListContractVersion || len(contract.MessageFields) == 0 || len(contract.EnvelopeFields) == 0 { + t.Fatalf("message result contract = %#v", contract) + } + contract.MessageFields[0] = "mutated" + contract.EnvelopeFields[0] = "mutated" + second := CurrentMessageResultContract() + if second.MessageFields[0] == "mutated" || second.EnvelopeFields[0] == "mutated" { + t.Fatal("message result contract leaked mutable storage") + } + payload := NewMessageListPayload(nil) + if payload["count"] != 0 || payload["messages"] == nil { + t.Fatalf("nil message ledger = %#v", payload) + } + if StableMessageID(map[string]any{}) != "" { + t.Fatal("missing message identity was fabricated") + } + ApplyMessagePagination(payload, map[string]any{"result": map[string]any{"nextCursor": "next"}}, nil, "older") + if payload["paginationKnown"] != false || payload["failedCount"] != 1 { + t.Fatalf("cursor-only pagination = %#v", payload) + } +} + +func TestCrossPlatformCoverageQuotedMessageIsBoundedAndSemantic(t *testing.T) { got := QuotedMessage(map[string]any{ "quotedMessage": map[string]any{ "openMessageId": "quoted-1", @@ -294,7 +348,7 @@ func TestCrossPlatformCoverageResourceBoundaryHelpers(t *testing.T) { } } -func TestUpdateTimeOmitsUneditedEcho(t *testing.T) { +func TestCrossPlatformCoverageUpdateTimeOmitsUneditedEcho(t *testing.T) { if got := UpdateTime(map[string]any{ "createTime": "2026-07-19 13:37:03", "updateTime": "2026-07-19 13:37:03", @@ -309,7 +363,7 @@ func TestUpdateTimeOmitsUneditedEcho(t *testing.T) { } } -func TestReactionsNormalizesEmotionReplyList(t *testing.T) { +func TestCrossPlatformCoverageReactionsNormalizesEmotionReplyList(t *testing.T) { got := Reactions(map[string]any{ "emotionReplyList": []any{ map[string]any{ @@ -345,7 +399,7 @@ func TestReactionsNormalizesEmotionReplyList(t *testing.T) { } } -func TestApplyPaginationReadsNestedEnvelope(t *testing.T) { +func TestCrossPlatformCoverageApplyPaginationReadsNestedEnvelope(t *testing.T) { payload := map[string]any{"count": 98} ApplyPagination(payload, map[string]any{ "result": map[string]any{ @@ -369,7 +423,7 @@ func TestApplyPaginationReadsNestedEnvelope(t *testing.T) { } } -func TestApplyMessagePaginationUsesExecutableTimeBoundary(t *testing.T) { +func TestCrossPlatformCoverageApplyMessagePaginationUsesExecutableTimeBoundary(t *testing.T) { payload := map[string]any{} ApplyMessagePagination(payload, map[string]any{ "result": map[string]any{ @@ -389,7 +443,21 @@ func TestApplyMessagePaginationUsesExecutableTimeBoundary(t *testing.T) { } } -func TestResourcesBuildsActionableDownloadReferences(t *testing.T) { +func TestCrossPlatformCoverageApplyMessagePaginationFailsClosedWhenCompletenessIsUnknown(t *testing.T) { + payload := map[string]any{} + ApplyMessagePagination(payload, map[string]any{"result": map[string]any{"messages": []any{}}}, nil, "older") + if payload["contractVersion"] != MessageListContractVersion || + payload["complete"] != false || payload["paginationKnown"] != false || + payload["failedCount"] != 1 { + t.Fatalf("unknown pagination contract = %#v", payload) + } + failures, _ := payload["failures"].([]map[string]any) + if len(failures) != 1 || failures[0]["stage"] != "pagination" { + t.Fatalf("unknown pagination failures = %#v", failures) + } +} + +func TestCrossPlatformCoverageResourcesBuildsActionableDownloadReferences(t *testing.T) { resources := Resources(map[string]any{ "openMessageId": "msg-1", "openConversationId": "cid-1", @@ -416,7 +484,7 @@ func TestResourcesBuildsActionableDownloadReferences(t *testing.T) { } } -func TestResourcesReportsMissingDownloadContext(t *testing.T) { +func TestCrossPlatformCoverageResourcesReportsMissingDownloadContext(t *testing.T) { resources := Resources(map[string]any{"content": `{"mediaId":"@image-a"}`}) if len(resources) != 1 { t.Fatalf("resources = %#v", resources) @@ -431,7 +499,7 @@ func TestResourcesReportsMissingDownloadContext(t *testing.T) { } } -func TestResourcesTextMediaIDRequiresWordBoundary(t *testing.T) { +func TestCrossPlatformCoverageResourcesTextMediaIDRequiresWordBoundary(t *testing.T) { resources := Resources(map[string]any{ "openMessageId": "msg-1", "openConversationId": "cid-1", @@ -442,7 +510,7 @@ func TestResourcesTextMediaIDRequiresWordBoundary(t *testing.T) { } } -func TestForwarded(t *testing.T) { +func TestCrossPlatformCoverageForwarded(t *testing.T) { var project func(m map[string]any) map[string]any project = func(m map[string]any) map[string]any { row := map[string]any{"text": Text(m)} diff --git a/internal/shortcut/smart/at_me.go b/internal/shortcut/smart/at_me.go index e5c86b73..c7d3b4b5 100644 --- a/internal/shortcut/smart/at_me.go +++ b/internal/shortcut/smart/at_me.go @@ -24,6 +24,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" ) // AtMe: pull the messages that recently @-mentioned ME across chats in one step. @@ -86,18 +87,37 @@ var AtMe = shortcut.Shortcut{ }, }, Flags: append([]shortcut.Flag{ - {Name: "days", Type: shortcut.FlagInt, Desc: "回溯天数(可选,默认 7)", Default: "7", Required: false}, - {Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 50)", Default: "50"}, + {Name: "group", Type: shortcut.FlagString, Desc: "仅查看指定群;可传 openConversationId 或群名"}, + {Name: "chat-query", Type: shortcut.FlagString, Desc: "--group 的旧版自然名称入口", Hidden: true}, + {Name: "group-query", Type: shortcut.FlagString, Desc: "--chat-query 的兼容别名", Hidden: true}, + {Name: "days", Type: shortcut.FlagInt, Desc: "回溯天数(默认 7);--days 必须在 1-3650 之间", Default: "7", Required: false}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 50);--limit 必须大于 0", Default: "50"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor", Default: "0"}, {Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"}, }, chatshortcut.MessageResourceDownloadFlags()...), - Constraints: chatshortcut.MessageResourceDownloadConstraints(), + Constraints: append([]shortcut.Constraint{ + {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"group", "chat-query", "group-query"}}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"days"}, Description: "--days 必须在 1-3650 之间"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0"}, + }, chatshortcut.MessageResourceDownloadConstraints()...), Tips: []string{ `dws chat +at-me`, `dws chat +at-me --days 3`, + `dws chat +at-me --group "项目群"`, }, - Validate: chatshortcut.ValidateMessageResourceDownload, + Validate: validateAtMe, Execute: func(rt *shortcut.RuntimeContext) error { + groupID := "" + directTarget := strings.TrimSpace(rt.Str("group")) + queryTarget := strings.TrimSpace(rt.StrFirst("chat-query", "group-query")) + if directTarget != "" || queryTarget != "" { + resolved, err := targetresolver.ResolveChatTarget(rt, directTarget, queryTarget) + if err != nil { + return err + } + groupID = resolved.Selected.OpenConversationID + } + // Step 1 — look-back window [now-Nd, now] in epoch millis. days defaults // to 7; guard against non-positive overrides so the window stays sane. days := rt.Int("days") @@ -111,35 +131,75 @@ var AtMe = shortcut.Shortcut{ // Step 2 — search @me messages. startTime/endTime/limit/cursor and the // first-page defaults (limit 50, cursor "0") mirror // helpers.chatMessageListMentionsCmd's search_at_me_message call. - data, err := rt.CallMCPData("chat", "search_at_me_message", map[string]any{ + params := map[string]any{ "startTime": startMs, "endTime": endMs, "limit": rt.Int("limit"), "cursor": rt.Str("cursor"), - }) + } + if groupID != "" { + params["openConversationId"] = groupID + } + data, err := rt.CallMCPData("chat", "search_at_me_message", params) if err != nil { return err } - // Step 3 — project matched messages; fall back to the raw payload when we - // cannot locate a recognisable message list. + // Step 3 — always publish the stable list envelope, including for an empty + // or newly-shaped response. This keeps common .messages[]/.items[] jq + // projections deterministic instead of turning absence into null. items := atMeMessageItems(data) - if len(items) == 0 { - return rt.Output(data) - } results := make([]map[string]any, 0, len(items)) for _, m := range items { results = append(results, atMeProjectWithReactions(m, !rt.Bool("no-reactions"))) } - payload := map[string]any{"messages": results} + payload := chatmsg.NewMessageListPayload(results) + payload["items"] = atMeCompatibilityItems(results) chatmsg.ApplyPagination(payload, data) if rt.Bool("download-resources") { - payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, "") + payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, groupID) } return rt.Output(payload) }, } +func validateAtMe(rt *shortcut.RuntimeContext) error { + if err := chatshortcut.ValidateMessageResourceDownload(rt); err != nil { + return err + } + days := rt.Int("days") + if days < 1 || days > 3650 { + return localChatOptionError("invalid_lookback_window", "+at-me 的 --days 必须在 1-3650 之间", "--days") + } + if rt.Int("limit") <= 0 { + return localChatOptionError("invalid_page_size", "+at-me 的 --limit 必须大于 0", "--limit") + } + return nil +} + +// atMeCompatibilityItems preserves the common list/items projection used by +// older Agent snippets while keeping messages as the canonical v1 contract. +// Its conversation field is an object so `.items[].conversation.name` is safe. +func atMeCompatibilityItems(messages []map[string]any) []map[string]any { + items := make([]map[string]any, 0, len(messages)) + for _, message := range messages { + item := make(map[string]any, len(message)) + for key, value := range message { + item[key] = value + } + conversation := map[string]any{} + if name := atMeString(message["conversation"]); name != "" { + conversation["name"] = name + } + if id := atMeString(message["conversationId"]); id != "" { + conversation["openConversationId"] = id + } + item["conversation"] = conversation + items = append(items, item) + } + return items +} + // atMeMessageItems locates the message list inside a search_at_me_message // response, probing common container keys at the top level and nested under // "result". Returns nil when no list is found. diff --git a/internal/shortcut/smart/at_me_search_msg_test.go b/internal/shortcut/smart/at_me_search_msg_test.go index 0b620796..81d403c3 100644 --- a/internal/shortcut/smart/at_me_search_msg_test.go +++ b/internal/shortcut/smart/at_me_search_msg_test.go @@ -20,7 +20,7 @@ import ( const testCipher = "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP||2||1||1" -func TestAtMeProject(t *testing.T) { +func TestCrossPlatformCoverageAtMeProject(t *testing.T) { // nested sender object + plain text row := atMeProject(map[string]any{ "sender": map[string]any{"name": "念晨"}, @@ -87,7 +87,7 @@ func TestAtMeProject(t *testing.T) { } } -func TestSearchMsgProject(t *testing.T) { +func TestCrossPlatformCoverageSearchMsgProject(t *testing.T) { // nested sender + plain text + messageId row := searchMsgProject(map[string]any{ "sender": map[string]any{"nick": "千启"}, @@ -129,7 +129,7 @@ func TestSearchMsgProject(t *testing.T) { // TestSenderHelpers exercises the atMe/searchMsg sender key families directly: // a senderName-family key (first probe loop), a flat string under "sender" // (second loop), and the "null" sentinel normalisation. -func TestSenderHelpers(t *testing.T) { +func TestCrossPlatformCoverageSenderHelpers(t *testing.T) { cases := []struct { fn func(map[string]any) any name string diff --git a/internal/shortcut/smart/broadcast.go b/internal/shortcut/smart/broadcast.go index 2e9cc1e4..6c9852f8 100644 --- a/internal/shortcut/smart/broadcast.go +++ b/internal/shortcut/smart/broadcast.go @@ -23,6 +23,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" ) // Broadcast: send the SAME single-chat message to several people by NAME. @@ -96,14 +97,28 @@ var Broadcast = shortcut.Shortcut{ // Step 1 — resolve this name to a unique userId. On failure // (unknown / ambiguous) record it and keep going. - user, err := resolveOpenDingTalkUser(rt, name) + resolved, err := targetresolver.ResolveEnterpriseUser(rt, name, targetresolver.IdentityAny) if err != nil { failed = append(failed, fmt.Sprintf("%s(%s)", name, err.Error())) continue } - if user.openDingTalkID == "" { - failed = append(failed, fmt.Sprintf("%s(通讯录结果缺少 openDingTalkId)", name)) - continue + user := resolved.Selected + targetArgs := map[string]any{} + if user.OpenDingTalkID != "" { + targetArgs["receiverOpenDingTalkId"] = user.OpenDingTalkID + } else { + targetArgs["receiverUserId"] = user.UserID + } + recipient := user.Name + if recipient == "" { + recipient = name + } + messageArgs := rt.AddAIMessageTag(map[string]any{ + "msgType": "markdown", + "content": string(content), + }) + for key, value := range targetArgs { + messageArgs[key] = value } // Step 2 — send the single-chat message to this recipient. Under @@ -111,26 +126,18 @@ var Broadcast = shortcut.Shortcut{ // resolved recipient as "would send" and move on. if rt.DryRun() { plans = append(plans, map[string]any{ - "recipient": user.name, + "recipient": recipient, "tool": "send_personal_message", - "arguments": rt.AddAIMessageTag(map[string]any{ - "receiverOpenDingTalkId": user.openDingTalkID, - "msgType": "markdown", - "content": string(content), - }), + "arguments": messageArgs, }) - sent = append(sent, user.name) + sent = append(sent, recipient) continue } - if _, err := rt.CallMCPWriteData("chat", "send_personal_message", rt.AddAIMessageTag(map[string]any{ - "receiverOpenDingTalkId": user.openDingTalkID, - "msgType": "markdown", - "content": string(content), - })); err != nil { + if _, err := rt.CallMCPWriteData("chat", "send_personal_message", messageArgs); err != nil { failed = append(failed, fmt.Sprintf("%s(发送失败:%s)", name, err.Error())) continue } - sent = append(sent, user.name) + sent = append(sent, recipient) } // Summarize via rt.Output (structured, honours --format/--jq/--fields) diff --git a/internal/shortcut/smart/chat_messages.go b/internal/shortcut/smart/chat_messages.go index 1d36a424..3348c822 100644 --- a/internal/shortcut/smart/chat_messages.go +++ b/internal/shortcut/smart/chat_messages.go @@ -14,40 +14,34 @@ package smart import ( + "fmt" "strings" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" ) var dingTalkMessageLocation = time.FixedZone("CST", 8*60*60) +const ( + chatMessagesDefaultPageLimit = 50 + chatMessagesHardPageLimit = 500 + chatMessagesAllPageSize = 100 +) + func formatDingTalkMessageBoundary(now time.Time) string { return now.In(dingTalkMessageLocation).Format("2006-01-02 15:04:05") } -// ChatMessages: fetch the message list of one conversation (group OR single -// chat) and print a clean projected list (speaker / text / time) instead of a -// raw dump. -// -// Steps: -// 1. depending on whether --group or --user is given, call either -// list_conversation_message_v2 (group; param openconversation_id) or -// list_individual_chat_message (single chat; param userId) on the chat -// server — tool names and param keys copied verbatim from chat.go's -// `dws chat message list` call sites; -// 2. defensively unwrap the message list (multiple candidate container keys) -// and project each message to {sender, text, createTime} tolerating field -// aliases and one level of nesting; -// 3. print via rt.Output as {messages, count} so it honours --format/--jq/--fields. -// -// The default path only reads and reshapes conversation messages; -// --download-resources additionally writes resource files locally. +// ChatMessages resolves one conversation, projects messages into the shared +// typed result contract, and optionally follows bounded continuation pages, +// downloads resources, or atomically exports the complete ledger as JSON. // // dws chat +chat-messages --group --time "2025-03-01 00:00:00" // dws chat +chat-messages --user --time "2025-03-01 00:00:00" --limit 50 @@ -55,12 +49,12 @@ var ChatMessages = shortcut.Shortcut{ Service: "chat", Command: "+chat-messages", Product: "chat", - Description: "拉取某个会话(群聊或单聊)的消息列表并投影出发言人/文本/时间", - Intent: "当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;" + - "群聊传 --group(群会话 ID,openConversationId),单聊传 --user(对方 userId),两者互斥且必须二选一。" + + Description: "按会话 ID、群名或用户 ID 读取消息,支持有界全量分页与原子 JSON 导出", + Intent: "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;" + + "群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" + "省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。" + - "内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。" + - "默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。", + "全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" + + "默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", @@ -74,16 +68,20 @@ var ChatMessages = shortcut.Shortcut{ CLIPath: "chat +chat-messages", PrimaryCLIPath: "chat +chat-messages", }, - Description: "拉取某个会话(群聊或单聊)的消息列表并投影出发言人/文本/时间", + Description: "按会话 ID、群名或用户 ID 读取消息,支持有界全量分页与原子 JSON 导出", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed built-in Shortcut adapter: it routes group or direct-message history reads, projects a stable message shape, and optionally orchestrates safe resource downloads with a failure ledger.", }, Selection: contract.SelectionSpec{ - AgentSummary: "拉取某个会话(群聊或单聊)的消息列表并投影出发言人/文本/时间", - UseWhen: []string{"当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;群聊传 --group(群会话 ID,openConversationId),单聊传 --user(对方 userId),两者互斥且必须二选一。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"}, - AvoidWhen: []string{"要跨多个会话按关键词、发送者或消息类型检索时使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget"}, + AgentSummary: "按会话 ID、群名或用户 ID 读取消息,支持有界全量分页与原子 JSON 导出", + UseWhen: []string{"当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;" + + "群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" + + "省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。" + + "全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" + + "默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。"}, + AvoidWhen: []string{"要跨多个会话按关键词、发送者或消息类型检索时使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget"}, Examples: []string{ "dws chat +chat-messages --group --direction older", "dws chat +chat-messages --open-dingtalk-id --download-resources --output-dir ./downloads", @@ -91,92 +89,382 @@ var ChatMessages = shortcut.Shortcut{ }, }, Flags: append([]shortcut.Flag{ - {Name: "group", Type: shortcut.FlagString, Desc: "群会话 ID(openConversationId),与 --user 互斥"}, + {Name: "group", Type: shortcut.FlagString, Desc: "群名称或 openConversationId,与单聊目标互斥"}, {Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true}, {Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true}, + {Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true}, + {Name: "chat-query", Type: shortcut.FlagString, Desc: "--group 的旧版自然名称入口", Hidden: true}, {Name: "user", Type: shortcut.FlagString, Desc: "单聊对方的 userId,与 --group 互斥"}, + {Name: "user-query", Type: shortcut.FlagString, Desc: "按姓名解析唯一 openDingTalkId 的兼容入口", Hidden: true}, {Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊对方的 openDingTalkId,与 --group/--user 互斥"}, - {Name: "time", Type: shortcut.FlagString, Desc: "时间边界,如 \"2025-03-01 00:00:00\";省略时从当前时间向前读取最近消息"}, - {Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的消息条数(可选)"}, + {Name: "time", Type: shortcut.FlagString, Desc: "时间边界,如 \"2025-03-01 00:00:00\";--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD;省略时从当前时间向前读取最近消息"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的消息条数;显式页大小必须大于 0"}, {Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true}, + {Name: "page-size", Type: shortcut.FlagInt, Desc: "--limit 的兼容别名", Hidden: true}, {Name: "direction", Type: shortcut.FlagString, Enum: []string{"newer", "older"}, Desc: "时间方向 newer/older;省略时为 older,从时间边界向前读取"}, {Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"}, + {Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 typed nextPage.time 自动读取后续页;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-results 仅与 --page-all 一起使用且不能为负数"}, + {Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"}, + {Name: "max-results", Type: shortcut.FlagInt, Desc: "--max-results 仅与 --page-all 一起使用且不能为负数;0 表示仅受页数上限约束"}, + {Name: "output", Shorthand: "o", Type: shortcut.FlagString, Desc: "把完整结构化 ledger 原子写入工作目录内的相对 JSON 文件"}, }, chatshortcut.MessageResourceDownloadFlags()...), Constraints: append([]shortcut.Constraint{ - {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "id", "user", "open-dingtalk-id"}}, + {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "id", "open-conversation-id", "chat-query", "user", "user-query", "open-dingtalk-id"}}, + {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"limit", "size", "page-size"}}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"time"}, Description: "--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式页大小必须大于 0"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "max-results"}, Description: "--max-results 仅与 --page-all 一起使用且不能为负数"}, + { + Kind: shortcut.ConstraintCustom, + Flags: []string{"output", "overwrite"}, + Description: "--output 必须是工作目录内的相对 JSON 文件;默认不覆盖,--overwrite 仅与 --output 一起使用", + }, }, chatshortcut.MessageResourceDownloadConstraints()...), Tips: []string{ `dws chat +chat-messages --group --time "2025-03-01 00:00:00"`, - `dws chat +chat-messages --user --time "2025-03-01 00:00:00" --limit 50`, - `dws chat +chat-messages --group --direction older`, + `dws chat +chat-messages --user --time "2025-03-01 00:00:00" --page-all --page-limit 50`, + `dws chat +chat-messages --group --direction older --page-all --output ./exports/messages.json`, }, - Validate: chatshortcut.ValidateMessageResourceDownload, - Execute: func(rt *shortcut.RuntimeContext) error { - // Step 1 — build params and pick the right tool. Param keys - // (openconversation_id / userId / time / forward / limit) match the MCP server - // schema for group and direct message listing. - var tool string - params := map[string]any{} - fallbackConversationID := "" + Validate: validateChatMessages, + Execute: executeChatMessages, +} - if rt.Changed("time") && rt.Str("time") != "" { - params["time"] = rt.Str("time") - } else { - params["time"] = formatDingTalkMessageBoundary(time.Now()) +func validateChatMessages(rt *shortcut.RuntimeContext) error { + if err := chatshortcut.ValidateMessageResourceDownload(rt); err != nil { + return err + } + if rt.Changed("time") && strings.TrimSpace(rt.Str("time")) != "" && !validChatTime(rt.Str("time")) { + return localChatOptionError("invalid_time_boundary", "+chat-messages 的 --time 格式无效", "--time") + } + for _, name := range []string{"limit", "size", "page-size"} { + if rt.Changed(name) && rt.Int(name) <= 0 { + return localChatOptionError("invalid_page_size", "+chat-messages 的 --"+name+" 必须大于 0", "--"+name) } - if limit := rt.IntFirst("limit", "size"); limit > 0 { - params["limit"] = limit + } + if !rt.Bool("page-all") && (rt.Changed("page-limit") || rt.Changed("max-results")) { + return apperrors.NewValidation("--page-limit/--max-results 仅与 --page-all 一起使用") + } + if rt.Bool("page-all") { + if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > chatMessagesHardPageLimit { + return apperrors.NewValidation("--page-limit 必须在 1-500 之间") } - // direction newer/older maps to the tools' boolean `forward` param - // (newer -> forward=true, older -> forward=false), matching chat.go's - // resolveMessageForward. - if rt.Changed("direction") { - switch strings.TrimSpace(strings.ToLower(rt.Str("direction"))) { - case "newer": - params["forward"] = true - case "older": - params["forward"] = false - } - } else { - params["forward"] = false + if rt.Int("max-results") < 0 { + return apperrors.NewValidation("--max-results 不能小于 0") } - - if group := rt.StrFirst("group", "conversation-id", "id"); group != "" { - tool = "list_conversation_message_v2" - params["openconversation_id"] = group - fallbackConversationID = group - } else if openID := strings.TrimSpace(rt.Str("open-dingtalk-id")); openID != "" { - tool = "list_individual_chat_message" - params["openDingTalkId"] = openID - } else { - tool = "list_individual_chat_message" - params["userId"] = rt.Str("user") - } - - data, err := rt.CallMCPData("chat", tool, params) - if err != nil { + } + if rt.Changed("output") { + if err := chatshortcut.ValidateMessageExportOutput(rt.Str("output")); err != nil { return err } + } else if rt.Bool("overwrite") { + return apperrors.NewValidation("--overwrite 仅与 --output 一起使用") + } + return nil +} - // Step 2 — defensively unwrap and project. Response shape has no - // contract, so probe multiple candidate container/field keys. +type chatMessagesRequest struct { + tool string + params map[string]any + direction string + fallbackConversationID string +} + +func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesRequest, error) { + groupID := strings.TrimSpace(rt.StrFirst("conversation-id", "id", "open-conversation-id")) + userID := rt.Str("user") + openID := rt.Str("open-dingtalk-id") + if targetresolver.LooksLikeOpenConversationID(openID) { + return chatMessagesRequest{}, apperrors.NewValidation( + "--open-dingtalk-id 收到的是群 openConversationId;群聊请改用 --group(兼容别名 --chat)", + ) + } + if groupID == "" && (rt.Str("group") != "" || rt.Str("chat-query") != "") { + resolved, err := targetresolver.ResolveChatTarget(rt, rt.Str("group"), rt.Str("chat-query")) + if err != nil { + return chatMessagesRequest{}, err + } + groupID = resolved.Selected.OpenConversationID + } + if query := rt.Str("user-query"); query != "" { + resolved, err := targetresolver.ResolveUser(rt, query, targetresolver.IdentityOpenDingTalkID) + if err != nil { + return chatMessagesRequest{}, err + } + openID = resolved.Selected.OpenDingTalkID + } + + direction := strings.TrimSpace(strings.ToLower(rt.Str("direction"))) + if direction == "" { + direction = "older" + } + params := map[string]any{ + "time": formatDingTalkMessageBoundary(time.Now()), + "forward": direction == "newer", + } + if rt.Changed("time") && rt.Str("time") != "" { + params["time"] = rt.Str("time") + } + if limit := rt.IntFirst("limit", "size", "page-size"); limit > 0 { + params["limit"] = limit + } else if rt.Bool("page-all") { + params["limit"] = chatMessagesAllPageSize + } + + request := chatMessagesRequest{params: params, direction: direction} + switch { + case groupID != "": + request.tool = "list_conversation_message_v2" + request.params["openconversation_id"] = groupID + request.fallbackConversationID = groupID + case openID != "": + request.tool = "list_individual_chat_message" + request.params["openDingTalkId"] = openID + default: + request.tool = "list_individual_chat_message" + request.params["userId"] = userID + } + return request, nil +} + +func executeChatMessages(rt *shortcut.RuntimeContext) error { + request, err := resolveChatMessagesRequest(rt) + if err != nil { + return err + } + var payload map[string]any + var rawItems []map[string]any + if rt.Bool("page-all") { + payload, rawItems, err = collectAllChatMessages(rt, request) + } else { + payload, rawItems, err = collectOneChatMessagesPage(rt, request) + } + if err != nil { + // Full-page collection returns its failure ledger together with a + // non-zero error. Publish that ledger for diagnosis, but stop before + // resource downloads or a requested export can look successful. + if payload != nil { + if outputErr := rt.Output(payload); outputErr != nil { + return outputErr + } + } + return err + } + if rt.Bool("download-resources") { + chatshortcut.AttachMessageResourceDownloads( + payload, + chatshortcut.DownloadMessageResources(rt, rawItems, request.fallbackConversationID), + ) + } + if rt.Changed("output") { + if rt.DryRun() { + payload["export"] = map[string]any{ + "dryRun": true, + "format": "json", + "localPath": rt.Str("output"), + "overwrite": rt.Bool("overwrite"), + } + } else { + path, size, writeErr := chatshortcut.WriteMessageExportJSON( + rt.Str("output"), rt.Bool("overwrite"), payload) + if writeErr != nil { + return writeErr + } + payload["export"] = map[string]any{ + "format": "json", + "localPath": path, + "sizeBytes": size, + } + } + } + return rt.Output(payload) +} + +func collectOneChatMessagesPage(rt *shortcut.RuntimeContext, request chatMessagesRequest) (map[string]any, []map[string]any, error) { + data, err := rt.CallMCPData("chat", request.tool, request.params) + if err != nil { + return nil, nil, err + } + items := chatMessageItems(data) + results := projectChatMessages(items, !rt.Bool("no-reactions")) + payload := chatmsg.NewMessageListPayload(results) + chatmsg.ApplyMessagePagination(payload, data, items, request.direction) + if payload["complete"] == true { + payload["stopReason"] = "source_complete" + } else { + payload["stopReason"] = "single_page" + } + return payload, items, nil +} + +func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesRequest) (map[string]any, []map[string]any, error) { + pageLimit := defaultChatPageLimit(rt.Int("page-limit"), chatMessagesDefaultPageLimit) + maxResults := rt.Int("max-results") + seenIDs := map[string]bool{} + seenBoundaries := map[string]bool{fmt.Sprint(request.params["time"]): true} + allItems := make([]map[string]any, 0) + failures := make([]map[string]any, 0) + pagesFetched := 0 + paginationKnown := true + complete := false + hasMore := false + stopReason := "source_complete" + truncatedByPageLimit := false + truncatedByResultLimit := false + var nextPage map[string]any + + for pagesFetched < pageLimit { + data, err := rt.CallMCPData("chat", request.tool, request.params) + if err != nil { + failures = append(failures, map[string]any{ + "page": pagesFetched + 1, + "stage": "read", + "error": err.Error(), + }) + stopReason = "read_failure" + break + } + pagesFetched++ items := chatMessageItems(data) - results := make([]map[string]any, 0, len(items)) - for _, m := range items { - results = append(results, projectChatMessageWithReactions(m, !rt.Bool("no-reactions"))) + keptOnPage := make([]map[string]any, 0, len(items)) + for _, item := range items { + stableID := chatmsg.StableMessageID(item) + if stableID != "" && seenIDs[stableID] { + continue + } + if stableID != "" { + seenIDs[stableID] = true + } + allItems = append(allItems, item) + keptOnPage = append(keptOnPage, item) + if maxResults > 0 && len(allItems) >= maxResults { + break + } } - payload := map[string]any{ - "messages": results, - "count": len(results), + page := chatmsg.Pagination(data) + pageHasMore, hasMoreKnown := page["hasMore"].(bool) + if !hasMoreKnown { + paginationKnown = false + failures = append(failures, map[string]any{ + "page": pagesFetched, + "stage": "pagination", + "error": "下层未返回可靠的 hasMore,无法证明全量结果完整", + }) + stopReason = "pagination_error" + break } - direction := strings.TrimSpace(strings.ToLower(rt.Str("direction"))) - chatmsg.ApplyMessagePagination(payload, data, items, direction) - if rt.Bool("download-resources") { - payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, fallbackConversationID) + hasMore = pageHasMore + + if maxResults > 0 && len(allItems) >= maxResults { + truncatedByResultLimit = pageHasMore || len(keptOnPage) < len(items) + if truncatedByResultLimit { + hasMore = true + stopReason = "result_limit" + if len(keptOnPage) > 0 { + nextPage = messageNextPage(keptOnPage[len(keptOnPage)-1], request.direction) + boundary := strings.TrimSpace(fmt.Sprint(nextPage["time"])) + if boundary == "" || boundary == "" { + failures = append(failures, map[string]any{ + "page": pagesFetched, + "stage": "pagination", + "error": "达到结果上限但无法生成可靠的 nextPage.time", + }) + stopReason = "pagination_error" + nextPage = nil + } + } + break + } } - return rt.Output(payload) - }, + if !pageHasMore { + complete = true + hasMore = false + stopReason = "source_complete" + break + } + if len(items) == 0 { + failures = append(failures, map[string]any{ + "page": pagesFetched, + "stage": "pagination", + "error": "下层返回 hasMore=true 但当前页没有消息", + }) + stopReason = "pagination_error" + break + } + nextPage = messageNextPage(items[len(items)-1], request.direction) + boundary := strings.TrimSpace(fmt.Sprint(nextPage["time"])) + if boundary == "" || boundary == "" || seenBoundaries[boundary] { + failures = append(failures, map[string]any{ + "page": pagesFetched, + "stage": "pagination", + "error": "hasMore=true 但 nextPage.time 缺失或停滞", + }) + stopReason = "pagination_error" + break + } + seenBoundaries[boundary] = true + request.params["time"] = boundary + } + if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit { + truncatedByPageLimit = true + stopReason = "page_limit" + } + + results := projectChatMessages(allItems, !rt.Bool("no-reactions")) + payload := chatmsg.NewMessageListPayload(results) + payload["pagesFetched"] = pagesFetched + payload["paginationKnown"] = paginationKnown + payload["complete"] = complete && len(failures) == 0 + payload["hasMore"] = hasMore + payload["stopReason"] = stopReason + payload["truncatedByPageLimit"] = truncatedByPageLimit + payload["truncatedByResultLimit"] = truncatedByResultLimit + payload["failedCount"] = len(failures) + payload["failures"] = failures + payload["partial"] = len(failures) > 0 && len(results) > 0 + if hasMore && nextPage != nil { + payload["nextPage"] = nextPage + } + if len(failures) > 0 { + failureStage := "pagination" + if stopReason == "read_failure" { + failureStage = "read" + } + return payload, allItems, apperrors.NewAPI( + fmt.Sprintf("全量消息读取未完成:%d 页成功,%d 个页面失败", pagesFetched, len(failures)), + apperrors.WithOperation("chat/"+request.tool), + apperrors.WithReason("chat_messages_incomplete"), + apperrors.WithOrigin("mcp_gateway"), + apperrors.WithFailureStage(failureStage), + apperrors.WithExecutionStarted(true), + apperrors.WithRetryable(true), + apperrors.WithHint("请根据 failures 和 nextPage 重试;失败 ledger 不会写入 --output 文件"), + apperrors.WithDetails(map[string]any{ + "pagesFetched": pagesFetched, + "failedCount": len(failures), + "failures": failures, + "partial": len(results) > 0, + "stopReason": stopReason, + }), + ) + } + return payload, allItems, nil +} + +func projectChatMessages(items []map[string]any, includeReactions bool) []map[string]any { + results := make([]map[string]any, 0, len(items)) + for _, item := range items { + results = append(results, projectChatMessageWithReactions(item, includeReactions)) + } + return results +} + +func messageNextPage(message map[string]any, direction string) map[string]any { + return map[string]any{ + "time": chatmsg.CreateTime(message), + "direction": direction, + } } // chatMessageItems defensively unwraps the message list from the response, @@ -219,44 +507,7 @@ func projectChatMessage(m map[string]any) map[string]any { } func projectChatMessageWithReactions(m map[string]any, includeReactions bool) map[string]any { - row := map[string]any{ - "sender": chatmsg.Sender(m), - "text": chatmsg.Text(m), - "createTime": chatmsg.CreateTime(m), - } - if messageID := chatmsg.MessageID(m); messageID != nil { - row["messageId"] = messageID - } - if conversationID := chatmsg.ConversationID(m); conversationID != nil { - row["conversationId"] = conversationID - } - if threadID := chatmsg.ThreadID(m); threadID != nil { - row["threadId"] = threadID - } - if messageType := chatmsg.MessageType(m); messageType != nil { - row["messageType"] = messageType - } - if updateTime := chatmsg.UpdateTime(m); updateTime != nil { - row["updateTime"] = updateTime - } - if includeReactions { - if reactions := chatmsg.Reactions(m); len(reactions) > 0 { - row["reactions"] = reactions - } - } - if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 { - row["quotedMessage"] = quoted - } - if resources := chatmsg.ResourcesDeep(m); len(resources) > 0 { - row["resourceRefs"] = resources - } - projectForwarded := func(item map[string]any) map[string]any { - return projectChatMessageWithReactions(item, includeReactions) - } - if forwarded := chatmsg.Forwarded(m, projectForwarded); len(forwarded) > 0 { - row["forwarded"] = forwarded - } - return row + return chatmsg.ProjectMessageV1(m, includeReactions) } func init() { diff --git a/internal/shortcut/smart/chat_messages_test.go b/internal/shortcut/smart/chat_messages_test.go index 21b40ad8..3200faca 100644 --- a/internal/shortcut/smart/chat_messages_test.go +++ b/internal/shortcut/smart/chat_messages_test.go @@ -13,14 +13,25 @@ package smart -import "testing" +import ( + "bytes" + "context" + "encoding/json" + stderrors "errors" + "os" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) // TestProjectChatMessageExpandsForwarded guards that a forwarded chat record // ("聊天记录") exposes its nested messages under "forwarded" instead of // collapsing to the lossy top-level "[卡片]" summary, recursing through nested // forwards, and that the string-"null" sender is nulled out. The per-field // behaviour (sender/text/encryption) is covered in the chatmsg package tests. -func TestProjectChatMessageExpandsForwarded(t *testing.T) { +func TestCrossPlatformCoverageProjectChatMessageExpandsForwarded(t *testing.T) { row := projectChatMessage(map[string]any{ "sender": "hugozhu", "openMessageId": "msg-top", @@ -86,3 +97,238 @@ func TestProjectChatMessageExpandsForwarded(t *testing.T) { t.Errorf("no-reactions projection leaked reactions: %#v", withoutReactions) } } + +type chatMessagesPagingCaller struct { + responses []string + args []map[string]any + failAt int +} + +type chatMessagesFailWriter struct{} + +func (chatMessagesFailWriter) Write([]byte) (int, error) { + return 0, stderrors.New("fixture output failure") +} + +func (c *chatMessagesPagingCaller) CallTool( + _ context.Context, + _, _ string, + args map[string]any, +) (*edition.ToolResult, error) { + copied := make(map[string]any, len(args)) + for key, value := range args { + copied[key] = value + } + c.args = append(c.args, copied) + index := len(c.args) - 1 + if c.failAt > 0 && len(c.args) == c.failAt { + return nil, stderrors.New("fixture read failure") + } + if index >= len(c.responses) { + index = len(c.responses) - 1 + } + return &edition.ToolResult{Content: []edition.ContentBlock{{ + Type: "text", + Text: c.responses[index], + }}}, nil +} + +func (c *chatMessagesPagingCaller) CallReadTool( + ctx context.Context, + product, tool string, + args map[string]any, +) (*edition.ToolResult, error) { + return c.CallTool(ctx, product, tool, args) +} + +func (*chatMessagesPagingCaller) Format() string { return "json" } +func (*chatMessagesPagingCaller) DryRun() bool { return false } +func (*chatMessagesPagingCaller) Fields() string { return "" } +func (*chatMessagesPagingCaller) JQ() string { return "" } + +func TestCrossPlatformCoverageChatMessagesPageAllUsesTypedBoundaryAndDeduplicates(t *testing.T) { + caller := &chatMessagesPagingCaller{responses: []string{ + `{"result":{"hasMore":true,"messages":[{"openMessageId":"m2","createTime":"2026-01-02 00:00:00"},{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`, + `{"result":{"hasMore":false,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"},{"openMessageId":"m0","createTime":"2025-12-31 00:00:00"}]}}`, + }} + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{ + "chat", "+chat-messages", "--conversation-id", "cid", + "--time", "2026-01-03 00:00:00", "--page-all", "--page-limit", "5", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(caller.args) != 2 || caller.args[1]["time"] != "2026-01-01 00:00:00" { + t.Fatalf("pagination calls = %#v", caller.args) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["complete"] != true || payload["hasMore"] != false || + payload["count"] != float64(3) || payload["pagesFetched"] != float64(2) || + payload["stopReason"] != "source_complete" { + t.Fatalf("all-page payload = %#v", payload) + } +} + +func TestCrossPlatformCoverageChatMessagesPageAllPublishesBoundedContinuation(t *testing.T) { + caller := &chatMessagesPagingCaller{responses: []string{ + `{"result":{"hasMore":true,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`, + }} + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{ + "chat", "+chat-messages", "--conversation-id", "cid", + "--page-all", "--page-limit", "1", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + next, _ := payload["nextPage"].(map[string]any) + if payload["complete"] != false || payload["hasMore"] != true || + payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" || + next["time"] != "2026-01-01 00:00:00" { + t.Fatalf("bounded payload = %#v", payload) + } +} + +func TestCrossPlatformCoverageChatMessagesPageAllFailsClosedOnStalledBoundary(t *testing.T) { + caller := &chatMessagesPagingCaller{responses: []string{ + `{"result":{"hasMore":true,"messages":[{"openMessageId":"m1"}]}}`, + }} + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--page-all"}) + err := root.Execute() + var typed *apperrors.Error + if !stderrors.As(err, &typed) || typed.Category != apperrors.CategoryAPI || + typed.Reason != "chat_messages_incomplete" || !typed.Retryable || + typed.ExecutionStarted == nil || !*typed.ExecutionStarted { + t.Fatalf("error = %#v", err) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["complete"] != false || payload["failedCount"] != float64(1) || + payload["stopReason"] != "pagination_error" { + t.Fatalf("stalled payload = %#v", payload) + } +} + +func TestCrossPlatformCoverageChatMessagesFailedPageDoesNotExportPartialLedger(t *testing.T) { + t.Chdir(t.TempDir()) + caller := &chatMessagesPagingCaller{ + responses: []string{ + `{"result":{"hasMore":true,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`, + }, + failAt: 2, + } + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{ + "chat", "+chat-messages", "--conversation-id", "cid", "--page-all", + "--output", "exports/partial.json", + }) + err := root.Execute() + var typed *apperrors.Error + if !stderrors.As(err, &typed) || typed.Reason != "chat_messages_incomplete" { + t.Fatalf("error = %#v", err) + } + if _, statErr := os.Lstat("exports/partial.json"); !os.IsNotExist(statErr) { + t.Fatalf("partial export exists: %v", statErr) + } + var ledger map[string]any + if err := json.Unmarshal(output.Bytes(), &ledger); err != nil { + t.Fatal(err) + } + if ledger["partial"] != true || ledger["failedCount"] != float64(1) || ledger["count"] != float64(1) { + t.Fatalf("failure ledger = %#v", ledger) + } +} + +func TestCrossPlatformCoverageChatMessagesFailureLedgerOutputErrorIsNonZero(t *testing.T) { + caller := &chatMessagesPagingCaller{responses: []string{ + `{"result":{"hasMore":true,"messages":[{"openMessageId":"m1"}]}}`, + }} + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + root.SetOut(chatMessagesFailWriter{}) + root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--page-all"}) + if err := root.Execute(); err == nil || err.Error() != "fixture output failure" { + t.Fatalf("error = %v", err) + } +} + +func TestCrossPlatformCoverageChatMessagesExportIsAtomicAndNoClobber(t *testing.T) { + t.Chdir(t.TempDir()) + newCaller := func() *chatMessagesPagingCaller { + return &chatMessagesPagingCaller{responses: []string{ + `{"result":{"hasMore":false,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`, + }} + } + run := func(overwrite bool) error { + helpers.InitDeps(newCaller()) + root := newPlatformCoverageRoot() + root.SetOut(&bytes.Buffer{}) + args := []string{"chat", "+chat-messages", "--conversation-id", "cid", "--page-all", "--output", "exports/messages.json"} + if overwrite { + args = append(args, "--overwrite") + } + root.SetArgs(args) + return root.Execute() + } + if err := run(false); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile("exports/messages.json") + if err != nil { + t.Fatal(err) + } + var exported map[string]any + if err := json.Unmarshal(raw, &exported); err != nil { + t.Fatal(err) + } + if exported["complete"] != true || exported["count"] != float64(1) { + t.Fatalf("exported ledger = %#v", exported) + } + if err := run(false); err == nil { + t.Fatal("existing export was overwritten without --overwrite") + } + if err := run(true); err != nil { + t.Fatal(err) + } +} + +func TestCrossPlatformCoverageChatMessagesExportRejectsNonJSONPlaceholder(t *testing.T) { + t.Chdir(t.TempDir()) + helpers.InitDeps(&chatMessagesPagingCaller{responses: []string{ + `{"result":{"hasMore":false,"messages":[]}}`, + }}) + root := newPlatformCoverageRoot() + root.SetOut(&bytes.Buffer{}) + root.SetArgs([]string{ + "chat", "+chat-messages", "--conversation-id", "cid", "--output", "{}", + }) + if err := root.Execute(); err == nil { + t.Fatal("non-JSON placeholder output unexpectedly succeeded") + } + if _, err := os.Lstat("{}"); !os.IsNotExist(err) { + t.Fatalf("placeholder output was created: %v", err) + } +} diff --git a/internal/shortcut/smart/compatibility_coverage_test.go b/internal/shortcut/smart/compatibility_coverage_test.go index 4bfd3fb5..175eab7f 100644 --- a/internal/shortcut/smart/compatibility_coverage_test.go +++ b/internal/shortcut/smart/compatibility_coverage_test.go @@ -17,6 +17,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "io" "reflect" "strings" @@ -39,10 +40,15 @@ type platformCoverageCaller struct { calls []platformCoverageCall dry bool contactSearchResult string + aisearchResult string + failTool string } func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) { f.calls = append(f.calls, platformCoverageCall{product: product, tool: tool, args: args}) + if product+"/"+tool == f.failTool { + return nil, errors.New("fixture failure") + } text := `{"result":[]}` switch product + "/" + tool { case "contact/search_contact_by_key_word": @@ -50,6 +56,11 @@ func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool strin if text == "" { text = `{"result":[{"userId":"u1","name":"张三","openDingTalkId":"open1"}]}` } + case "aisearch/enterprise_person_search": + text = f.aisearchResult + if text == "" { + text = `{"result":[{"userId":"u1","openDingTalkId":"open1","meta":{"name":"张三","nick":"张三"}}]}` + } case "contact/get_current_user_profile": text = `{"result":{"userId":"u1"}}` case "im/search_groups": @@ -99,6 +110,92 @@ func newPlatformCoverageRoot() *cobra.Command { return root } +func TestCrossPlatformCoverageIMObservedCompatibilityAliasesReachCanonicalInvocation(t *testing.T) { + tests := []struct { + name string + args []string + wantTool string + wantKey string + wantValue any + }{ + { + name: "chat messages open conversation id", + args: []string{"chat", "+chat-messages", "--open-conversation-id", "cid-placeholder", "--page-size", "5"}, + wantTool: "list_conversation_message_v2", + wantKey: "limit", + wantValue: 5, + }, + { + name: "search text query", + args: []string{"chat", "+search-msg", "--text-query", "评测", "--no-enrich"}, + wantTool: "search_messages", + wantKey: "keyword", + wantValue: "评测", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := &platformCoverageCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs(tt.args) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 1 || fake.calls[0].tool != tt.wantTool || !reflect.DeepEqual(fake.calls[0].args[tt.wantKey], tt.wantValue) { + t.Fatalf("calls = %#v", fake.calls) + } + }) + } +} + +func TestCrossPlatformCoverageIMObservedCompatibilityAliasesConflictWithCanonicalFlags(t *testing.T) { + for _, args := range [][]string{ + {"chat", "+chat-messages", "--limit", "5", "--page-size", "5", "--conversation-id", "cid"}, + {"chat", "+search-msg", "--query", "评测", "--text", "评测", "--no-enrich"}, + } { + fake := &platformCoverageCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs(args) + if err := root.Execute(); err == nil { + t.Fatalf("conflicting aliases unexpectedly succeeded: %#v", args) + } + if len(fake.calls) != 0 { + t.Fatalf("conflicting aliases reached transport: %#v", fake.calls) + } + } +} + +func TestCrossPlatformCoverageChatShortcutsRejectInvalidLocalOptions(t *testing.T) { + for _, tc := range []struct { + name string + argv []string + }{ + {name: "at me non-positive days", argv: []string{"chat", "+at-me", "--days", "0"}}, + {name: "at me excessive days", argv: []string{"chat", "+at-me", "--days", "3651"}}, + {name: "at me non-positive limit", argv: []string{"chat", "+at-me", "--limit", "0"}}, + {name: "chat messages invalid time", argv: []string{"chat", "+chat-messages", "--group", "cid-1", "--time", "not-a-time"}}, + {name: "chat messages non-positive limit", argv: []string{"chat", "+chat-messages", "--group", "cid-1", "--limit", "0"}}, + {name: "thread replies invalid time", argv: []string{"chat", "+thread-replies", "--group", "cid-1", "--thread-id", "thread-1", "--time", "not-a-time"}}, + {name: "thread replies non-positive limit", argv: []string{"chat", "+thread-replies", "--group", "cid-1", "--thread-id", "thread-1", "--limit", "0"}}, + {name: "unread chats non-positive count", argv: []string{"chat", "+unread-chats", "--count", "0"}}, + } { + t.Run(tc.name, func(t *testing.T) { + fake := &platformCoverageCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs(tc.argv) + if err := root.Execute(); err == nil { + t.Fatalf("invalid options unexpectedly succeeded: %v", tc.argv) + } + if len(fake.calls) != 0 { + t.Fatalf("invalid options reached transport: %#v", fake.calls) + } + }) + } +} + func TestCrossPlatformCoverageAIMessageTag(t *testing.T) { t.Setenv(agentproduct.EnvName, "qwenwork") @@ -163,12 +260,12 @@ func TestCrossPlatformCoverageBroadcastDryRunPublishesExecutablePlan(t *testing. "--yes", }) if err := root.Execute(); err != nil { - t.Fatal(err) + t.Fatalf("execute: %v; calls = %#v", err, fake.calls) } if len(fake.calls) != 1 || - fake.calls[0].product != "contact" || - fake.calls[0].tool != "search_contact_by_key_word" { - t.Fatalf("dry-run calls = %#v, want one read-only contact lookup", fake.calls) + fake.calls[0].product != "aisearch" || + fake.calls[0].tool != "enterprise_person_search" { + t.Fatalf("dry-run calls = %#v, want one read-only enterprise-person lookup", fake.calls) } var payload map[string]any @@ -194,6 +291,86 @@ func TestCrossPlatformCoverageBroadcastDryRunPublishesExecutablePlan(t *testing. } } +func TestCrossPlatformCoverageBroadcastUsesEnterpriseAliasAndUserIDFallback(t *testing.T) { + fake := &platformCoverageCaller{ + dry: true, + aisearchResult: `{"result":[{ + "userId":"user-alias", + "meta":{"name":"柏荣","nick":"大柚"} + }]}`, + } + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{ + "chat", "+broadcast", + "--to", "大柚", + "--text", "你好", + "--dry-run", + "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatalf("execute: %v; calls = %#v", err, fake.calls) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + actions, _ := payload["actions"].([]any) + action, _ := actions[0].(map[string]any) + arguments, _ := action["arguments"].(map[string]any) + if arguments["receiverUserId"] != "user-alias" { + t.Fatalf("arguments = %#v", arguments) + } + if _, ok := arguments["receiverOpenDingTalkId"]; ok { + t.Fatalf("unexpected open id fallback = %#v", arguments) + } +} + +func TestCrossPlatformCoverageBroadcastRecipientFallbackAndSendFailure(t *testing.T) { + t.Run("missing display name falls back to query", func(t *testing.T) { + fake := &platformCoverageCaller{ + dry: true, + aisearchResult: `{"result":[{"userId":"user-no-name"}]}`, + } + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{ + "chat", "+broadcast", "--to", "无名用户", "--text", "你好", "--dry-run", "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + actions, _ := payload["actions"].([]any) + action, _ := actions[0].(map[string]any) + if action["recipient"] != "无名用户" { + t.Fatalf("action = %#v", action) + } + }) + + t.Run("send failure remains non-zero", func(t *testing.T) { + fake := &platformCoverageCaller{failTool: "chat/send_personal_message"} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+broadcast", "--to", "张三", "--text", "你好", "--yes", + }) + if err := root.Execute(); err == nil { + t.Fatal("send failure unexpectedly succeeded") + } + if len(fake.calls) != 2 || fake.calls[1].tool != "send_personal_message" { + t.Fatalf("calls = %#v", fake.calls) + } + }) +} + func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) { tests := []struct { name string diff --git a/internal/shortcut/smart/coverage_completion_test.go b/internal/shortcut/smart/coverage_completion_test.go index 155b9b3d..fc522911 100644 --- a/internal/shortcut/smart/coverage_completion_test.go +++ b/internal/shortcut/smart/coverage_completion_test.go @@ -12,6 +12,7 @@ import ( "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -153,6 +154,106 @@ func TestCrossPlatformCoverageChatMembersListOutcomes(t *testing.T) { } } +func TestCrossPlatformCoverageChatMembersListPaginatesUserBucketAndDeduplicates(t *testing.T) { + caller := &smartCoverageCaller{responses: map[string][]string{ + "chat/get_group_members": { + `{"result":{"hasMore":true,"nextCursor":"2","list":[{"memberEmpName":"A","openDingtalkId":"D1"}]}}`, + `{"result":{"hasMore":false,"list":[{"memberEmpName":"A","openDingtalkId":"D1"},{"memberEmpName":"B","openDingtalkId":"D2"}]}}`, + }, + "bot/list_group_bots": {`{"result":{"bots":[{"robotName":"R","robotCode":"robot"}]}}`}, + }} + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{"chat", "+chat-members-list", "--conversation-id", "cid"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if caller.counts["chat/get_group_members"] != 2 { + t.Fatalf("member page calls = %#v", caller.counts) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + counts, _ := payload["counts"].(map[string]any) + buckets, _ := payload["buckets"].(map[string]any) + users, _ := buckets["users"].(map[string]any) + if payload["contractVersion"] != groupMembersContractVersion || payload["complete"] != true || + counts["users"] != float64(2) || counts["bots"] != float64(1) || + users["pagesFetched"] != float64(2) || users["complete"] != true { + t.Fatalf("member buckets = %#v", payload) + } +} + +func TestCrossPlatformCoverageChatMembersListPageLimitPublishesContinuation(t *testing.T) { + caller := &smartCoverageCaller{responses: map[string][]string{ + "chat/get_group_members": { + `{"result":{"hasMore":true,"nextCursor":"2","list":[{"memberEmpName":"A","openDingtalkId":"D1"}]}}`, + }, + }} + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{ + "chat", "+chat-members-list", "--conversation-id", "cid", + "--member-types", "user", "--page-limit", "1", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["complete"] != false || payload["hasMore"] != true || payload["nextCursor"] != "2" { + t.Fatalf("bounded member payload = %#v", payload) + } + buckets := payload["buckets"].(map[string]any) + users := buckets["users"].(map[string]any) + if users["stopReason"] != "page_limit" || users["failedCount"] != float64(0) { + t.Fatalf("bounded user bucket = %#v", users) + } +} + +func TestCrossPlatformCoverageChatMembersListKeepsEarlierPagesWhenLaterReadFails(t *testing.T) { + caller := &smartCoverageCaller{ + responses: map[string][]string{ + "chat/get_group_members": { + `{"result":{"hasMore":true,"nextCursor":"2","list":[{"memberEmpName":"A","openDingtalkId":"D1"}]}}`, + }, + }, + failAt: map[string]int{"chat/get_group_members": 2}, + } + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{ + "chat", "+chat-members-list", "--conversation-id", "cid", "--member-types", "user", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["complete"] != false || payload["partial"] != true || + payload["hasMore"] != true || payload["nextCursor"] != "2" || + payload["failedCount"] != float64(1) { + t.Fatalf("partial member payload = %#v", payload) + } + buckets := payload["buckets"].(map[string]any) + users := buckets["users"].(map[string]any) + if users["count"] != float64(1) || users["pagesFetched"] != float64(1) || + users["stopReason"] != "read_failure" || users["partial"] != true { + t.Fatalf("partial user bucket = %#v", users) + } +} + func TestCrossPlatformCoverageChatMembersGroupResolutionAndProjection(t *testing.T) { cases := []struct { name string @@ -274,3 +375,173 @@ func TestCrossPlatformCoverageSearchPaginationFailureModes(t *testing.T) { }) } } + +func TestCrossPlatformCoverageSmartResolutionAdapters(t *testing.T) { + if defaultChatPageLimit(0, 50) != 50 || defaultChatPageLimit(7, 50) != 7 { + t.Fatal("default page limit normalization failed") + } + if err := localChatOptionError("fixture", "fixture"); err == nil { + t.Fatal("local validation error unexpectedly nil") + } + users := []contactUser{ + {userID: "u1", openDingTalkID: "d1", name: "甲"}, + {openDingTalkID: "d2", name: "乙"}, + } + if got := usersWithUserID(users); len(got) != 1 || got[0].userID != "u1" { + t.Fatalf("usersWithUserID = %#v", got) + } + extracted := extractUsers(map[string]any{"result": []any{ + map[string]any{"userId": "u1", "openDingTalkId": "d1", "name": "甲"}, + }}) + if len(extracted) != 1 || extracted[0].openDingTalkID != "d1" { + t.Fatalf("extractUsers = %#v", extracted) + } + if extractUsers(map[string]any{}) != nil { + t.Fatal("empty extraction should return nil") + } + labels := userLabels(users) + if len(labels) != 2 || !strings.Contains(labels[0], "甲") { + t.Fatalf("user labels = %#v", labels) + } + resolved := targetresolver.User{UserID: "u", OpenDingTalkID: "d", Name: "姓名"} + if got := toResolvedUser(fromResolvedUser(resolved)); got != resolved { + t.Fatalf("user adapter round trip = %#v", got) + } + + groups := extractGroupsForSend(map[string]any{"result": []any{ + map[string]any{"openConversationId": "cid-1", "title": "项目群"}, + map[string]any{"openConversationId": "cid-2", "title": "项目群备份"}, + }}) + if len(groups) != 2 || groups[0].id != "cid-1" { + t.Fatalf("extractGroupsForSend = %#v", groups) + } + preferred := preferExactGroupMatches(groups, "项目群") + if len(preferred) != 1 || preferred[0].id != "cid-1" { + t.Fatalf("preferExactGroupMatches = %#v", preferred) + } + if labels := sendGroupLabels(groups); len(labels) != 2 || !strings.Contains(labels[0], "项目群") { + t.Fatalf("group labels = %#v", labels) + } +} + +func TestCrossPlatformCoverageChatMessagesValidationAndFailureBoundaries(t *testing.T) { + invalid := [][]string{ + {"--group", "cid", "--page-limit", "2"}, + {"--group", "cid", "--page-all", "--page-limit", "0"}, + {"--group", "cid", "--page-all", "--page-limit", "501"}, + {"--group", "cid", "--page-all", "--max-results", "-1"}, + {"--group", "cid", "--overwrite"}, + {"--group", "cid", "--output", "/absolute.json"}, + {"--group", "missing"}, + {"--user-query", "missing"}, + } + for _, tail := range invalid { + helpers.InitDeps(&smartCoverageCaller{}) + root := newPlatformCoverageRoot() + root.SetArgs(append([]string{"chat", "+chat-messages"}, tail...)) + if err := root.Execute(); err == nil { + t.Errorf("invalid chat messages args succeeded: %v", tail) + } + } + + for _, tc := range []struct { + name string + responses []string + failAt int + args []string + wantError bool + }{ + {name: "single read failure", failAt: 1, args: []string{"--group", "cid123456789"}, wantError: true}, + {name: "all first read failure", failAt: 1, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true}, + {name: "all later read failure", responses: []string{`{"result":{"messages":[{"openMessageId":"m1","createTime":"2"}],"hasMore":true}}`}, failAt: 2, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true}, + {name: "missing pagination", responses: []string{`{"result":{"messages":[]}}`}, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true}, + {name: "empty page with continuation", responses: []string{`{"result":{"messages":[],"hasMore":true}}`}, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true}, + {name: "result limit without boundary", responses: []string{`{"result":{"messages":[{"openMessageId":"m1"},{"openMessageId":"m2"}],"hasMore":true}}`}, args: []string{"--group", "cid123456789", "--page-all", "--max-results", "1"}, wantError: true}, + {name: "result limit with boundary", responses: []string{`{"result":{"messages":[{"openMessageId":"m1","createTime":"2"},{"openMessageId":"m2","createTime":"1"}],"hasMore":true}}`}, args: []string{"--group", "cid123456789", "--page-all", "--max-results", "1"}}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &smartCoverageCaller{ + responses: map[string][]string{"chat/list_conversation_message_v2": tc.responses}, + failAt: map[string]int{"chat/list_conversation_message_v2": tc.failAt}, + } + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + root.SetArgs(append([]string{"chat", "+chat-messages"}, tc.args...)) + err := root.Execute() + if (err != nil) != tc.wantError { + t.Fatalf("error = %v, wantError=%v", err, tc.wantError) + } + }) + } + + caller := &smartCoverageCaller{responses: map[string][]string{ + "chat/list_conversation_message_v2": {`{"result":{"messages":[],"hasMore":false}}`}, + }} + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+chat-messages", "--group", "cid123456789", "--output", "messages.json", "--dry-run"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } +} + +func TestCrossPlatformCoverageGroupMemberAndAtMeFailureBoundaries(t *testing.T) { + for _, args := range [][]string{ + {"chat", "+group-members", "--group", "missing"}, + {"chat", "+group-members", "--group", "cid", "--page-limit", "0"}, + {"chat", "+chat-members-list", "--conversation-id", "cid", "--page-limit", "501"}, + {"chat", "+at-me", "--group", "missing"}, + } { + helpers.InitDeps(&smartCoverageCaller{}) + root := newPlatformCoverageRoot() + root.SetArgs(args) + if err := root.Execute(); err == nil { + t.Errorf("invalid args succeeded: %v", args) + } + } + + caller := &smartCoverageCaller{ + responses: map[string][]string{ + "im/search_groups": {`{"result":[{"openConversationId":"cid","title":"群"}]}`}, + "chat/get_group_members": {`{"result":{"hasMore":true,"list":[]}}`}, + }, + } + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+group-members", "--group", "群"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + for _, args := range [][]string{ + {"chat", "+chat-messages", "--user", "u1"}, + {"chat", "+unread-chats", "--count", "1"}, + } { + helpers.InitDeps(&smartCoverageCaller{responses: map[string][]string{ + "chat/list_individual_chat_message": {`{"result":{"messages":[],"hasMore":false}}`}, + "chat/list_unread_conversation_messages": {`{"result":[]}`}, + }}) + root := newPlatformCoverageRoot() + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatalf("args %v: %v", args, err) + } + } + + helpers.InitDeps(&smartCoverageCaller{}) + root = newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+search-msg", "--query", "x", "--chat-query", "missing", "--yes"}) + if err := root.Execute(); err == nil { + t.Fatal("missing search chat query unexpectedly resolved") + } + + helpers.InitDeps(&smartCoverageCaller{responses: map[string][]string{ + "contact/search_contact_by_key_word": {`{"result":[{"userId":"u1","name":"甲"}]}`}, + "im/search_messages": {`{"result":{"messages":[],"hasMore":false}}`}, + }}) + root = newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+search-msg", "--query", "x", "--sender-query", "甲", "--yes"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/shortcut/smart/dm.go b/internal/shortcut/smart/dm.go index 2d6a6194..1a7ef16a 100644 --- a/internal/shortcut/smart/dm.go +++ b/internal/shortcut/smart/dm.go @@ -18,13 +18,11 @@ package smart import ( - "encoding/json" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" ) // DM: message a person by NAME, no ID juggling. @@ -38,7 +36,7 @@ var DM = shortcut.Shortcut{ Service: "chat", Command: "+dm", Product: "chat", - Description: "按姓名直接给某人发单聊消息(自动解析 userId)", + Description: "按姓名直接给某人发单聊消息(自动解析唯一 openDingTalkId)", Intent: "当你只知道对方姓名、想直接发一条单聊消息而不想先查 userId 时使用;" + "内部先按姓名搜通讯录解析出唯一用户,并用其 openDingTalkId 发送,姓名匹配到多人时会列出候选让你区分。会真实发出消息。", Risk: shortcut.RiskWrite, @@ -54,14 +52,14 @@ var DM = shortcut.Shortcut{ CLIPath: "chat +dm", PrimaryCLIPath: "chat +dm", }, - Description: "按姓名直接给某人发单聊消息(自动解析 userId)", + Description: "按姓名直接给某人发单聊消息(自动解析唯一 openDingTalkId)", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", }, Selection: contract.SelectionSpec{ - AgentSummary: "按姓名直接给某人发单聊消息(自动解析 userId)", + AgentSummary: "按姓名直接给某人发单聊消息(自动解析唯一 openDingTalkId)", UseWhen: []string{"当你只知道对方姓名、想直接发一条单聊消息而不想先查 userId 时使用;内部先按姓名搜通讯录解析出唯一用户,并用其 openDingTalkId 发送,姓名匹配到多人时会列出候选让你区分。会真实发出消息。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws chat +dm --to 张三 --text \"周报发我一下\""}, @@ -76,22 +74,21 @@ var DM = shortcut.Shortcut{ Execute: func(rt *shortcut.RuntimeContext) error { text := rt.Str("text") - // Step 1 — resolve the recipient name to a unique userId. - user, err := resolveOpenDingTalkUser(rt, rt.Str("to")) + resolved, err := targetresolver.ResolveUser( + rt, + rt.Str("to"), + targetresolver.IdentityOpenDingTalkID, + ) if err != nil { return err } - if user.openDingTalkID == "" { - return apperrors.NewValidation("通讯录结果缺少 openDingTalkId,无法发送单聊消息;请改用 chat +messages-send --open-dingtalk-id") - } - - // Step 2 — send the single-chat message. - content, _ := json.Marshal(map[string]string{"title": text, "text": text}) - return rt.CallMCP("send_personal_message", rt.AddAIMessageTag(map[string]any{ - "receiverOpenDingTalkId": user.openDingTalkID, - "msgType": "markdown", - "content": string(content), - })) + return chatshortcut.ExecuteResolvedUserMarkdown( + rt, + chatshortcut.ResolvedUserMessageTarget{ + OpenDingTalkID: resolved.Selected.OpenDingTalkID, + }, + text, + ) }, } diff --git a/internal/shortcut/smart/gap_fill_test.go b/internal/shortcut/smart/gap_fill_test.go index 049e1894..0cf8e338 100644 --- a/internal/shortcut/smart/gap_fill_test.go +++ b/internal/shortcut/smart/gap_fill_test.go @@ -21,6 +21,28 @@ func TestCrossPlatformCoverageSafeResourceQueryDownloadsStayReadOnly(t *testing. } } +func TestCrossPlatformCoverageAtMeEmptyResultKeepsMessagesAndItemsIterable(t *testing.T) { + caller := &platformCoverageCaller{} + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{"chat", "+at-me", "--format", "json"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatalf("decode output: %v\n%s", err, output.String()) + } + for _, key := range []string{"messages", "items"} { + rows, ok := payload[key].([]any) + if !ok || len(rows) != 0 { + t.Fatalf("%s = %#v, want empty array", key, payload[key]) + } + } +} + func TestCrossPlatformCoverageMessageReadShortcutsPublishResourceDownloadPlans(t *testing.T) { message := `{"openMessageId":"msg","openConversationId":"cid","content":"{\"mediaId\":\"@image\"}","quotedMessage":{"openMessageId":"quoted","content":"{\"fileId\":\"@quoted-file\"}"}}` tests := []struct { @@ -34,7 +56,7 @@ func TestCrossPlatformCoverageMessageReadShortcutsPublishResourceDownloadPlans(t name: "chat messages", tool: "chat/list_conversation_message_v2", response: `{"result":{"messages":[` + message + `]}}`, - args: []string{"chat", "+chat-messages", "--group", "cid"}, + args: []string{"chat", "+chat-messages", "--conversation-id", "cid"}, resultKey: "messages", }, { @@ -102,7 +124,7 @@ func TestCrossPlatformCoverageMessageReadShortcutsPublishResourceDownloadPlans(t func TestCrossPlatformCoverageMessageReadShortcutResourceOutputValidation(t *testing.T) { for _, args := range [][]string{ - {"chat", "+chat-messages", "--group", "cid"}, + {"chat", "+chat-messages", "--conversation-id", "cid"}, {"chat", "+search-msg", "--query", "x", "--no-enrich"}, {"chat", "+at-me"}, {"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread"}, @@ -121,7 +143,7 @@ func TestCrossPlatformCoverageChatMessagesDefaultsToRecentHistory(t *testing.T) helpers.InitDeps(caller) root := newPlatformCoverageRoot() before := time.Now().Add(-2 * time.Second) - root.SetArgs([]string{"chat", "+chat-messages", "--group", "cid", "--limit", "5"}) + root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--limit", "5"}) if err := root.Execute(); err != nil { t.Fatal(err) } @@ -146,7 +168,7 @@ func TestCrossPlatformCoverageChatMessagesDefaultsToRecentHistory(t *testing.T) } } -func TestFormatDingTalkMessageBoundaryDoesNotDependOnProcessTimezone(t *testing.T) { +func TestCrossPlatformCoverageFormatDingTalkMessageBoundaryDoesNotDependOnProcessTimezone(t *testing.T) { now := time.Date(2026, time.July, 29, 1, 2, 3, 0, time.UTC) if got := formatDingTalkMessageBoundary(now); got != "2026-07-29 09:02:03" { t.Fatalf("UTC process boundary = %q, want DingTalk UTC+8 wall time", got) @@ -159,7 +181,7 @@ func TestCrossPlatformCoverageChatMessagesPreservesExplicitTime(t *testing.T) { root := newPlatformCoverageRoot() root.SetArgs([]string{ "chat", "+chat-messages", - "--group", "cid", + "--conversation-id", "cid", "--time", "2026-07-01 12:34:56", "--yes", }) diff --git a/internal/shortcut/smart/group_members.go b/internal/shortcut/smart/group_members.go index e9cecd10..be61d903 100644 --- a/internal/shortcut/smart/group_members.go +++ b/internal/shortcut/smart/group_members.go @@ -23,6 +23,13 @@ import ( apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" +) + +const ( + groupMembersDefaultPageLimit = 50 + groupMembersHardPageLimit = 500 + groupMembersContractVersion = "im.group-members.v1" ) // GroupMembers: list a group's members by its NAME, no openConversationId juggling. @@ -41,10 +48,10 @@ var GroupMembers = shortcut.Shortcut{ Service: "chat", Command: "+group-members", Product: "chat", - Description: "按群名列出群成员(自动搜群解析 openConversationId)", + Description: "按群名唯一解析后全量列出用户成员并公开分页完整性", Intent: "当你只知道群的名字、想看看这个群里有哪些成员,而不想先手动查群 ID 时使用;" + "内部先按群名搜索群聊解析出唯一 openConversationId,再拉取该群的成员列表。" + - "群名匹配到多个群时会列出候选让你区分、绝不自行假定。只读,不改动任何数据。", + "群名匹配到多个群时会列出候选让你区分、绝不自行假定。用户成员会自动翻页、稳定 ID 去重,并公开 complete/hasMore/nextCursor;--page-limit 保证有界。只读,不改动任何数据。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", @@ -58,65 +65,38 @@ var GroupMembers = shortcut.Shortcut{ CLIPath: "chat +group-members", PrimaryCLIPath: "chat +group-members", }, - Description: "按群名列出群成员(自动搜群解析 openConversationId)", + Description: "按群名唯一解析后全量列出用户成员并公开分页完整性", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", }, Selection: contract.SelectionSpec{ - AgentSummary: "按群名列出群成员(自动搜群解析 openConversationId)", - UseWhen: []string{"当你只知道群的名字、想看看这个群里有哪些成员,而不想先手动查群 ID 时使用;内部先按群名搜索群聊解析出唯一 openConversationId,再拉取该群的成员列表。群名匹配到多个群时会列出候选让你区分、绝不自行假定。只读,不改动任何数据。"}, + AgentSummary: "按群名唯一解析后全量列出用户成员并公开分页完整性", + UseWhen: []string{"当你只知道群的名字、想看看这个群里有哪些成员,而不想先手动查群 ID 时使用;内部先按群名搜索群聊解析出唯一 openConversationId,再拉取该群的成员列表。群名匹配到多个群时会列出候选让你区分、绝不自行假定。用户成员会自动翻页、稳定 ID 去重,并公开 complete/hasMore/nextCursor;--page-limit 保证有界。只读,不改动任何数据。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws chat +group-members --group 项目冲刺"}, }, }, Flags: []shortcut.Flag{ {Name: "group", Type: shortcut.FlagString, Desc: "群名称(搜群关键词,用群名里连续的核心词)", Required: true}, + {Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "最大用户成员页数;--page-limit 必须在 1-500 之间"}, }, - Tips: []string{`dws chat +group-members --group 项目冲刺`}, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page-limit"}, Description: "--page-limit 必须在 1-500 之间"}, + }, + Tips: []string{`dws chat +group-members --group 项目冲刺`}, + Validate: validateGroupMembersPageLimit, Execute: func(rt *shortcut.RuntimeContext) error { - groupName := rt.Str("group") - - // Step 1 — search groups by name (keyword) on the im server. - data, err := rt.CallMCPData("im", "search_groups", map[string]any{ - "keyword": groupName, - "limit": 10, - "cursor": "0", - }) + resolved, err := targetresolver.ResolveChat(rt, rt.Str("group")) if err != nil { return err } - groups := preferExactGroupMatches(extractGroupsForSend(data), groupName) - switch { - case len(groups) == 0: - return apperrors.NewValidation(fmt.Sprintf( - "没找到名字匹配 %q 的群;换用群名里连续的核心词再试。", groupName)) - case len(groups) > 1: - return apperrors.NewValidation(fmt.Sprintf( - "%q 匹配到 %d 个群:%s。请用更精确的群名,或直接用 dws chat group members --id 指定群。", - groupName, len(groups), strings.Join(sendGroupLabels(groups), "、"))) - } - - // Step 2 — list the members of the unique group. The param key - // (openconversation_id) is copied verbatim from chat.go's - // get_group_members call site. Project the verbose raw member records - // (memberAvatarMediaId, memberDingtalkId, …) down to the useful fields. - mdata, err := rt.CallMCPData("chat", "get_group_members", map[string]any{ - "openconversation_id": groups[0].id, - }) + result, err := collectGroupUserMembers(rt, resolved.Selected.OpenConversationID, rt.Int("page-limit")) if err != nil { return err } - members := groupMemberProject(mdata) - if len(members) == 0 { - // Unrecognised shape — fall back to the raw payload rather than - // hiding data. - return rt.Output(mdata) - } - payload := map[string]any{"count": len(members), "members": members} - chatmsg.ApplyPagination(payload, mdata) - return rt.Output(payload) + return rt.Output(result.payload(resolved.Selected.OpenConversationID)) }, } @@ -127,34 +107,73 @@ var GroupMembers = shortcut.Shortcut{ var ChatMembersList = shortcut.Shortcut{ Service: "chat", Command: "+chat-members-list", + Aliases: []string{"+chat-group-members"}, Product: "chat", Description: "列出群成员并把用户与机器人分桶(支持群名语义解析)", Intent: "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;" + - "可直接传 --conversation-id,或传 --group 群名让命令先做唯一匹配。" + + "--group 可传群名或 openConversationId,也可用 --conversation-id 显式传稳定 ID、用 --chat-query 显式按群名解析。" + "默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;" + - "用户桶会保留下层 hasMore/complete,避免把截断列表误判为完整。", + "用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。", Risk: shortcut.RiskRead, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "chat", + Name: "shortcut_chat_members_list", + CanonicalPath: "chat.shortcut_chat_members_list", + CLIPath: "chat +chat-members-list", + PrimaryCLIPath: "chat +chat-members-list", + Aliases: []string{"chat +chat-group-members"}, + }, + Description: "列出群成员并把用户与机器人分桶(支持群名语义解析)", + Interface: &contract.InterfaceSpec{ + Mode: "composite", + Availability: "available", + Reason: "Reviewed composite member adapter: the executable CLI safely resolves a group, paginates and deduplicates user members, lists bots, and publishes per-bucket completeness and failures.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "列出群成员并把用户与机器人分桶(支持群名语义解析)", + UseWhen: []string{"当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;" + + "--group 可传群名或 openConversationId,也可用 --conversation-id 显式传稳定 ID、用 --chat-query 显式按群名解析。" + + "默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;" + + "用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。"}, + AvoidWhen: []string{"只需要用户成员且已有群名时可使用 +group-members;需要原始单页响应时使用底层原子命令"}, + Examples: []string{ + "dws chat +chat-members-list --group \"项目冲刺\"", + "dws chat +chat-members-list --conversation-id --member-types user,bot", + }, + }, + }, Flags: []shortcut.Flag{ - {Name: "group", Type: shortcut.FlagString, Desc: "群名称(与 --conversation-id 二选一)"}, - {Name: "conversation-id", Type: shortcut.FlagString, Desc: "群 openConversationId(与 --group 二选一)"}, - {Name: "member-types", Type: shortcut.FlagStringSlice, Desc: "成员类型:user,bot;不传则同时返回"}, + {Name: "group", Type: shortcut.FlagString, Desc: "群名称或 openConversationId"}, + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "显式群 openConversationId"}, + {Name: "chat-query", Type: shortcut.FlagString, Desc: "按群名解析唯一 openConversationId"}, + {Name: "chat", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true}, + {Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true}, + {Name: "member-types", Type: shortcut.FlagStringSlice, Desc: "成员类型;--member-types 仅接受 user/bot;不传则同时返回"}, + {Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "用户成员桶最大页数;--page-limit 必须在 1-500 之间"}, }, Constraints: []shortcut.Constraint{ - {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id"}}, + {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "chat-query", "chat", "open-conversation-id"}}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"member-types"}, Description: "--member-types 仅接受 user/bot"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"page-limit"}, Description: "--page-limit 必须在 1-500 之间"}, }, Tips: []string{ `dws chat +chat-members-list --group "项目冲刺"`, `dws chat +chat-members-list --conversation-id --member-types user,bot`, }, + Validate: validateGroupMembersPageLimit, Execute: func(rt *shortcut.RuntimeContext) error { - groupID := strings.TrimSpace(rt.Str("conversation-id")) - groupName := strings.TrimSpace(rt.Str("group")) + groupID := strings.TrimSpace(rt.StrFirst("conversation-id", "chat", "open-conversation-id")) if groupID == "" { - resolved, err := resolveGroupName(rt, groupName) + resolved, err := targetresolver.ResolveChatTarget(rt, rt.Str("group"), rt.Str("chat-query")) if err != nil { return err } - groupID = resolved + groupID = resolved.Selected.OpenConversationID } wantUsers, wantBots, err := resolveMemberTypes(rt.StrSlice("member-types")) @@ -163,19 +182,19 @@ var ChatMembersList = shortcut.Shortcut{ } payload := map[string]any{ - "conversationId": groupID, - "users": []map[string]any{}, - "bots": []map[string]any{}, + "contractVersion": groupMembersContractVersion, + "conversationId": groupID, + "users": []map[string]any{}, + "bots": []map[string]any{}, } + buckets := map[string]any{} var userErr, botErr error + var userResult groupUserMembersResult if wantUsers { - var data map[string]any - data, userErr = rt.CallMCPData("chat", "get_group_members", map[string]any{ - "openconversation_id": groupID, - }) + userResult, userErr = collectGroupUserMembers(rt, groupID, rt.Int("page-limit")) if userErr == nil { - payload["users"] = groupMemberProject(data) - chatmsg.ApplyPagination(payload, data) + payload["users"] = userResult.members + buckets["users"] = userResult.bucketPayload() } } if wantBots { @@ -184,7 +203,12 @@ var ChatMembersList = shortcut.Shortcut{ "openConversationId": groupID, }) if botErr == nil { - payload["bots"] = groupBotProject(data) + bots := groupBotProject(data) + payload["bots"] = bots + buckets["bots"] = map[string]any{ + "complete": true, + "count": len(bots), + } } } if wantUsers && userErr != nil && !wantBots { @@ -196,16 +220,19 @@ var ChatMembersList = shortcut.Shortcut{ if userErr != nil && botErr != nil { return fmt.Errorf("读取用户成员失败: %v;读取机器人失败: %v", userErr, botErr) } - errors := make([]map[string]any, 0, 1) + failures := make([]map[string]any, 0, 1+len(userResult.failures)) + failures = append(failures, userResult.failures...) if userErr != nil { - errors = append(errors, map[string]any{"bucket": "users", "error": userErr.Error()}) + failures = append(failures, map[string]any{"bucket": "users", "stage": "read", "error": userErr.Error()}) } if botErr != nil { - errors = append(errors, map[string]any{"bucket": "bots", "error": botErr.Error()}) + failures = append(failures, map[string]any{"bucket": "bots", "stage": "read", "error": botErr.Error()}) } - if len(errors) > 0 { - payload["errors"] = errors + if len(failures) > 0 { + payload["errors"] = failures payload["partial"] = true + } else { + payload["partial"] = false } users, _ := payload["users"].([]map[string]any) @@ -215,31 +242,134 @@ var ChatMembersList = shortcut.Shortcut{ "bots": len(bots), "total": len(users) + len(bots), } + payload["buckets"] = buckets + payload["failedCount"] = len(failures) + payload["failures"] = failures + payload["complete"] = len(failures) == 0 && (!wantUsers || userResult.complete) + payload["hasMore"] = wantUsers && userResult.hasMore + if wantUsers && userResult.nextCursor != "" { + payload["nextCursor"] = userResult.nextCursor + } return rt.Output(payload) }, } -func resolveGroupName(rt *shortcut.RuntimeContext, groupName string) (string, error) { - data, err := rt.CallMCPData("im", "search_groups", map[string]any{ - "keyword": groupName, - "limit": 10, - "cursor": "0", - }) - if err != nil { - return "", err +func validateGroupMembersPageLimit(rt *shortcut.RuntimeContext) error { + if limit := rt.Int("page-limit"); limit < 1 || limit > groupMembersHardPageLimit { + return apperrors.NewValidation("--page-limit 必须在 1-500 之间") } - groups := preferExactGroupMatches(extractGroupsForSend(data), groupName) - switch { - case len(groups) == 0: - return "", apperrors.NewValidation(fmt.Sprintf( - "没找到名字匹配 %q 的群;换用群名里连续的核心词再试。", groupName)) - case len(groups) > 1: - return "", apperrors.NewValidation(fmt.Sprintf( - "%q 匹配到 %d 个群:%s。请用更精确的群名,或传 --conversation-id 精确指定。", - groupName, len(groups), strings.Join(sendGroupLabels(groups), "、"))) - default: - return groups[0].id, nil + return nil +} + +type groupUserMembersResult struct { + members []map[string]any + pagesFetched int + complete bool + hasMore bool + nextCursor string + stopReason string + failures []map[string]any +} + +func collectGroupUserMembers(rt *shortcut.RuntimeContext, groupID string, pageLimit int) (groupUserMembersResult, error) { + pageLimit = defaultChatPageLimit(pageLimit, groupMembersDefaultPageLimit) + result := groupUserMembersResult{ + members: []map[string]any{}, + failures: []map[string]any{}, + stopReason: "source_complete", } + cursor := "0" + seenCursors := map[string]bool{cursor: true} + seenIDs := map[string]bool{} + for result.pagesFetched < pageLimit { + data, err := rt.CallMCPData("chat", "get_group_members", map[string]any{ + "openconversation_id": groupID, + "cursor": cursor, + }) + if err != nil { + if result.pagesFetched > 0 { + result.failures = append(result.failures, map[string]any{ + "bucket": "users", + "page": result.pagesFetched + 1, + "stage": "read", + "error": err.Error(), + }) + result.stopReason = "read_failure" + return result, nil + } + return result, err + } + result.pagesFetched++ + for _, member := range groupMemberProject(data) { + stableID := strings.TrimSpace(fmt.Sprint(member["openDingtalkId"])) + if stableID != "" && stableID != "" && seenIDs[stableID] { + continue + } + if stableID != "" && stableID != "" { + seenIDs[stableID] = true + } + result.members = append(result.members, member) + } + page := chatmsg.Pagination(data) + hasMore, known := page["hasMore"].(bool) + if !known { + result.failures = append(result.failures, map[string]any{ + "bucket": "users", + "page": result.pagesFetched, + "stage": "pagination", + "error": "群成员下层未返回可靠的 hasMore,无法证明用户桶完整", + }) + result.stopReason = "pagination_error" + return result, nil + } + result.hasMore = hasMore + if !hasMore { + result.complete = true + result.stopReason = "source_complete" + return result, nil + } + nextCursor := strings.TrimSpace(fmt.Sprint(page["nextCursor"])) + if nextCursor == "" || nextCursor == "" || seenCursors[nextCursor] { + result.failures = append(result.failures, map[string]any{ + "bucket": "users", + "page": result.pagesFetched, + "stage": "pagination", + "error": "群成员 hasMore=true 但 nextCursor 缺失或停滞", + }) + result.stopReason = "pagination_error" + return result, nil + } + result.nextCursor = nextCursor + seenCursors[nextCursor] = true + cursor = nextCursor + } + result.stopReason = "page_limit" + return result, nil +} + +func (result groupUserMembersResult) bucketPayload() map[string]any { + payload := map[string]any{ + "count": len(result.members), + "pagesFetched": result.pagesFetched, + "complete": result.complete && len(result.failures) == 0, + "hasMore": result.hasMore, + "partial": len(result.failures) > 0 && len(result.members) > 0, + "stopReason": result.stopReason, + "failedCount": len(result.failures), + "failures": result.failures, + } + if result.nextCursor != "" { + payload["nextCursor"] = result.nextCursor + } + return payload +} + +func (result groupUserMembersResult) payload(groupID string) map[string]any { + payload := result.bucketPayload() + payload["contractVersion"] = groupMembersContractVersion + payload["conversationId"] = groupID + payload["members"] = result.members + return payload } func resolveMemberTypes(raw []string) (bool, bool, error) { diff --git a/internal/shortcut/smart/local_chat_validation.go b/internal/shortcut/smart/local_chat_validation.go new file mode 100644 index 00000000..71a2bb8c --- /dev/null +++ b/internal/shortcut/smart/local_chat_validation.go @@ -0,0 +1,41 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package smart + +import ( + "strings" + "time" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +func defaultChatPageLimit(value, fallback int) int { + if value == 0 { + return fallback + } + return value +} + +func validChatTime(value string) bool { + value = strings.TrimSpace(value) + for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} { + if _, err := time.Parse(layout, value); err == nil { + return true + } + } + return false +} + +func localChatOptionError(reason, message string, flags ...string) error { + action := "修正参数后重试,或查看当前命令帮助" + if flagText := strings.Join(flags, "、"); flagText != "" { + action = "检查 " + flagText + " 后重试,或查看当前命令帮助" + } + return apperrors.NewValidation( + message, + apperrors.WithReason(reason), + apperrors.WithActions(action), + ) +} diff --git a/internal/shortcut/smart/natural_target_resolution_test.go b/internal/shortcut/smart/natural_target_resolution_test.go new file mode 100644 index 00000000..7e3419b0 --- /dev/null +++ b/internal/shortcut/smart/natural_target_resolution_test.go @@ -0,0 +1,235 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package smart + +import ( + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" +) + +func TestCrossPlatformCoverageChatMessagesResolvesNaturalChatAndUserTargets(t *testing.T) { + tests := []struct { + name string + args []string + wantTool string + wantKey string + wantValue string + }{ + { + name: "chat query", + args: []string{"chat", "+chat-messages", "--chat-query", "项目冲刺"}, + wantTool: "list_conversation_message_v2", + wantKey: "openconversation_id", + wantValue: "cid-1", + }, + { + name: "natural group through group flag", + args: []string{"chat", "+chat-messages", "--group", "项目冲刺"}, + wantTool: "list_conversation_message_v2", + wantKey: "openconversation_id", + wantValue: "cid-1", + }, + { + name: "user query", + args: []string{"chat", "+chat-messages", "--user-query", "张三"}, + wantTool: "list_individual_chat_message", + wantKey: "openDingTalkId", + wantValue: "open1", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := &platformCoverageCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs(tt.args) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 2 { + t.Fatalf("calls = %#v, want resolve + read", fake.calls) + } + read := fake.calls[1] + if read.tool != tt.wantTool || read.args[tt.wantKey] != tt.wantValue { + t.Fatalf("read = %#v", read) + } + }) + } +} + +func TestCrossPlatformCoverageChatMessagesStableGroupBypassesNaturalResolution(t *testing.T) { + fake := &platformCoverageCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+chat-messages", "--group", "cid-fixture-chat-0001"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 1 || fake.calls[0].tool != "list_conversation_message_v2" || + fake.calls[0].args["openconversation_id"] != "cid-fixture-chat-0001" { + t.Fatalf("calls = %#v", fake.calls) + } +} + +func TestCrossPlatformCoverageChatMessagesNaturalUserAmbiguityStopsBeforeMessageRead(t *testing.T) { + fake := &platformCoverageCaller{contactSearchResult: `{"result":[{"name":"张三","userId":"u1","openDingTalkId":"D1"},{"name":"张三","userId":"u2","openDingTalkId":"D2"}]}`} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+chat-messages", "--user-query", "张三"}) + if err := root.Execute(); err == nil { + t.Fatal("ambiguous user unexpectedly reached message read") + } + if len(fake.calls) != 1 || fake.calls[0].tool != "search_contact_by_key_word" { + t.Fatalf("calls = %#v", fake.calls) + } +} + +func TestCrossPlatformCoverageChatMessagesRejectsConversationIDInPeerIdentityFlag(t *testing.T) { + fake := &platformCoverageCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+chat-messages", "--open-dingtalk-id", "cid-fixture-chat-0001"}) + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), "--group") { + t.Fatalf("error = %v", err) + } + if len(fake.calls) != 0 { + t.Fatalf("invalid identity reached lower API: %#v", fake.calls) + } +} + +func TestCrossPlatformCoverageAtMeResolvesNaturalGroupBeforeSearch(t *testing.T) { + fake := &platformCoverageCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+at-me", "--chat-query", "项目冲刺"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 2 || fake.calls[1].tool != "search_at_me_message" || fake.calls[1].args["openConversationId"] != "cid-1" { + t.Fatalf("calls = %#v", fake.calls) + } +} + +func TestCrossPlatformCoverageAtMeStableIDInQueryBypassesNaturalResolution(t *testing.T) { + fake := &platformCoverageCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+at-me", "--chat-query", "cid-fixture-chat-0001"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 1 || fake.calls[0].tool != "search_at_me_message" || + fake.calls[0].args["openConversationId"] != "cid-fixture-chat-0001" { + t.Fatalf("calls = %#v", fake.calls) + } +} + +func TestCrossPlatformCoverageSendToGroupStableIDBypassesNaturalResolution(t *testing.T) { + fake := &platformCoverageCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+send-to-group", "--group", "cid-fixture-chat-0001", "--text", "评测", "--yes"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 1 || fake.calls[0].tool != "send_personal_message" || + fake.calls[0].args["openConversationId"] != "cid-fixture-chat-0001" { + t.Fatalf("calls = %#v", fake.calls) + } +} + +func TestCrossPlatformCoverageSearchMsgResolvesNaturalChatAndSenderBeforeSearch(t *testing.T) { + fake := &platformCoverageCaller{contactSearchResult: `{"result":[{"name":"张三","userId":"u1","openDingTalkId":"D1"}]}`} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+search-msg", + "--chat-query", "项目冲刺", + "--sender-query", "张三", + "--no-enrich", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 3 { + t.Fatalf("calls = %#v, want chat resolve + user resolve + search", fake.calls) + } + search := fake.calls[2] + if search.product != "im" || search.tool != "search_messages" { + t.Fatalf("search = %#v", search) + } + if got, want := search.args["openConversationIds"], []string{"cid-1"}; !reflect.DeepEqual(got, want) { + t.Fatalf("openConversationIds = %#v, want %#v", got, want) + } + if got, want := search.args["senderOpenDingTakIds"], []string{"D1"}; !reflect.DeepEqual(got, want) { + t.Fatalf("senderOpenDingTakIds = %#v, want %#v", got, want) + } +} + +func TestCrossPlatformCoverageSearchMsgAcceptsStableIDInChatQuery(t *testing.T) { + fake := &platformCoverageCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+search-msg", + "--chat-query", "cid-fixture-chat-0002", + "--text", "评测", + "--no-enrich", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 1 || fake.calls[0].tool != "search_messages" { + t.Fatalf("calls = %#v", fake.calls) + } + if got, want := fake.calls[0].args["openConversationIds"], []string{"cid-fixture-chat-0002"}; !reflect.DeepEqual(got, want) { + t.Fatalf("openConversationIds = %#v, want %#v", got, want) + } + if fake.calls[0].args["keyword"] != "评测" { + t.Fatalf("keyword = %#v", fake.calls[0].args["keyword"]) + } +} + +func TestCrossPlatformCoverageChatMembersListGroupAcceptsNameAndStableID(t *testing.T) { + for _, tt := range []struct { + name string + args []string + wantCalls int + }{ + {name: "name", args: []string{"chat", "+chat-members-list", "--group", "项目冲刺", "--member-types", "user"}, wantCalls: 2}, + {name: "stable id", args: []string{"chat", "+chat-members-list", "--group", "cid-fixture-chat-0001", "--member-types", "user"}, wantCalls: 1}, + {name: "compat alias", args: []string{"chat", "+chat-members-list", "--open-conversation-id", "cid-short-placeholder", "--member-types", "user"}, wantCalls: 1}, + } { + t.Run(tt.name, func(t *testing.T) { + fake := &platformCoverageCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs(tt.args) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != tt.wantCalls || fake.calls[len(fake.calls)-1].tool != "get_group_members" { + t.Fatalf("calls = %#v", fake.calls) + } + }) + } +} + +func TestCrossPlatformCoverageSearchMsgNaturalSenderAmbiguityStopsBeforeSearch(t *testing.T) { + fake := &platformCoverageCaller{contactSearchResult: `{"result":[{"name":"张三","userId":"u1","openDingTalkId":"D1"},{"name":"张三","userId":"u2","openDingTalkId":"D2"}]}`} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+search-msg", "--sender-query", "张三", "--no-enrich"}) + if err := root.Execute(); err == nil { + t.Fatal("ambiguous sender unexpectedly reached search") + } + if len(fake.calls) != 1 || fake.calls[0].tool != "search_contact_by_key_word" { + t.Fatalf("calls = %#v", fake.calls) + } +} diff --git a/internal/shortcut/smart/resolve.go b/internal/shortcut/smart/resolve.go index f688134b..b9478758 100644 --- a/internal/shortcut/smart/resolve.go +++ b/internal/shortcut/smart/resolve.go @@ -1,131 +1,87 @@ // 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 smart import ( - "fmt" - "strings" - - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" ) -// contactUser is the minimal identity a smart shortcut needs after resolving a -// name. +// contactUser preserves the smart package's internal shape while resolution +// semantics live in targetresolver for reuse by chat and future facades. type contactUser struct { userID string openDingTalkID string name string } -// resolveUser turns a human name into a single unique contact, the "name → ID" -// intelligence that distinguishes a smart shortcut from a raw tool call -// (mirrors the shared name→ID resolver). It searches the directory and: -// - errors with a clear message if nobody matches; -// - errors listing the candidates if the name is ambiguous (never guesses). func resolveUser(rt *shortcut.RuntimeContext, name string) (contactUser, error) { return resolveUserByName(rt, name, false) } -// resolveOpenDingTalkUser also accepts external / cross-org contacts that have -// an openDingTalkId but no organization-scoped userId. Use it only for flows -// whose downstream interface consumes openDingTalkId. func resolveOpenDingTalkUser(rt *shortcut.RuntimeContext, name string) (contactUser, error) { return resolveUserByName(rt, name, true) } func resolveUserByName(rt *shortcut.RuntimeContext, name string, includeOpenIDOnly bool) (contactUser, error) { - data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{ - "keyword": name, - }) + requirement := targetresolver.IdentityUserID + if includeOpenIDOnly { + requirement = targetresolver.IdentityOpenDingTalkID + } + resolved, err := targetresolver.ResolveUser(rt, name, requirement) if err != nil { return contactUser{}, err } - users := extractUsers(data) - if !includeOpenIDOnly { - users = usersWithUserID(users) - } - switch { - case len(users) == 0: - return contactUser{}, apperrors.NewValidation( - fmt.Sprintf("通讯录里没找到叫 %q 的人;换个更完整的姓名再试。", name)) - case len(users) > 1: - return contactUser{}, apperrors.NewValidation(fmt.Sprintf( - "%q 匹配到 %d 个人:%s。请用更精确的姓名,或改用对应命令直接传用户 ID。", - name, len(users), strings.Join(userLabels(users), "、"))) - } - return users[0], nil + return fromResolvedUser(resolved.Selected), nil } func usersWithUserID(users []contactUser) []contactUser { - out := make([]contactUser, 0, len(users)) + resolved := make([]targetresolver.User, 0, len(users)) for _, user := range users { - if user.userID != "" { - out = append(out, user) - } + resolved = append(resolved, toResolvedUser(user)) + } + filtered := targetresolver.UsersWithUserID(resolved) + out := make([]contactUser, 0, len(filtered)) + for _, user := range filtered { + out = append(out, fromResolvedUser(user)) } return out } -// extractUsers pulls {userId, openDingTalkId, name} out of a -// search_contact_by_key_word response ({"result": [ {userId, name, ...} ]}). func extractUsers(data map[string]any) []contactUser { - raw, ok := data["result"].([]any) - if !ok { + resolved := targetresolver.ExtractUsers(data) + if len(resolved) == 0 { return nil } - var out []contactUser - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - id, _ := m["userId"].(string) - openID, _ := m["openDingTalkId"].(string) - if openID == "" { - openID, _ = m["openDingtalkId"].(string) - } - // External / cross-org contacts come back with an empty userId and only - // an openDingTalkId (verified live). Dropping them here made name→ID - // resolution miss those people, or collapse to the wrong single match - // when an in-org namesake also existed. Keep any row with at least one - // usable identity; downstream (e.g. +dm) can act on the openDingTalkId. - if id == "" && openID == "" { - continue - } - nm, _ := m["name"].(string) - if nm == "" { - for _, k := range []string{"nick", "showName", "flowerName", "staffName", "userName"} { - if v, _ := m[k].(string); v != "" { - nm = v - break - } - } - } - out = append(out, contactUser{userID: id, openDingTalkID: openID, name: nm}) + out := make([]contactUser, 0, len(resolved)) + for _, user := range resolved { + out = append(out, fromResolvedUser(user)) } return out } func userLabels(users []contactUser) []string { - out := make([]string, 0, len(users)) - for _, u := range users { - id := u.userID - if id == "" { - id = u.openDingTalkID - } - out = append(out, fmt.Sprintf("%s(%s)", u.name, id)) + resolved := make([]targetresolver.User, 0, len(users)) + for _, user := range users { + resolved = append(resolved, toResolvedUser(user)) + } + return targetresolver.UserLabels(resolved) +} + +func fromResolvedUser(user targetresolver.User) contactUser { + return contactUser{ + userID: user.UserID, + openDingTalkID: user.OpenDingTalkID, + name: user.Name, + } +} + +func toResolvedUser(user contactUser) targetresolver.User { + return targetresolver.User{ + UserID: user.userID, + OpenDingTalkID: user.openDingTalkID, + Name: user.name, } - return out } diff --git a/internal/shortcut/smart/search_msg.go b/internal/shortcut/smart/search_msg.go index 37248b64..f9ffae27 100644 --- a/internal/shortcut/smart/search_msg.go +++ b/internal/shortcut/smart/search_msg.go @@ -26,6 +26,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" ) // SearchMsg is the semantic message-search entry point. It exposes the native @@ -37,10 +38,10 @@ var SearchMsg = shortcut.Shortcut{ Service: "chat", Command: "+search-msg", Product: "im", - Description: "多维搜索消息,可全量翻页并批量富化详情", - Intent: "当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;默认查询近 7 天," + - "也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。" + - "--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。", + Description: "按稳定 ID 和内容等条件跨会话搜索消息,可全量翻页并批量富化", + Intent: "当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;会话与发送者过滤使用稳定 ID。默认查询近 7 天,也可指定精确起止时间。" + + "--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。" + + "--download-resources 使用安全本地路径、默认不覆盖和原子落盘。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", @@ -54,15 +55,15 @@ var SearchMsg = shortcut.Shortcut{ CLIPath: "chat +search-msg", PrimaryCLIPath: "chat +search-msg", }, - Description: "多维搜索消息,可全量翻页并批量富化详情", + Description: "按稳定 ID 和内容等条件跨会话搜索消息,可全量翻页并批量富化", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed search adapter: it combines filters, cursor pagination, batched mget enrichment, stable projection, completeness accounting, and optional safe resource downloads.", }, Selection: contract.SelectionSpec{ - AgentSummary: "多维搜索消息,可全量翻页并批量富化详情", - UseWhen: []string{"当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"}, + AgentSummary: "按稳定 ID 和内容等条件跨会话搜索消息,可全量翻页并批量富化", + UseWhen: []string{"当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;会话与发送者过滤使用稳定 ID。默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用安全本地路径、默认不覆盖和原子落盘。"}, AvoidWhen: []string{"只想读取一个已知会话的连续历史时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"}, Examples: []string{ "dws chat +search-msg --query \"周报\" --senders --days 3 --page-all", @@ -73,13 +74,17 @@ var SearchMsg = shortcut.Shortcut{ Flags: append([]shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词"}, {Name: "keyword", Type: shortcut.FlagString, Desc: "--query 的别名", Hidden: true}, + {Name: "text", Type: shortcut.FlagString, Desc: "--query 的兼容别名", Hidden: true}, + {Name: "text-query", Type: shortcut.FlagString, Desc: "--query 的兼容别名", Hidden: true}, {Name: "group", Type: shortcut.FlagString, Desc: "单个会话 openConversationId"}, {Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true}, {Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true}, {Name: "groups", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId"}, {Name: "chat-id", Type: shortcut.FlagStringSlice, Desc: "--groups 的 lark-cli 对齐别名"}, + {Name: "chat-query", Type: shortcut.FlagStringSlice, Desc: "按群名解析会话的兼容入口", Hidden: true}, {Name: "senders", Type: shortcut.FlagStringSlice, Desc: "发送者 userId/openDingTalkId 列表"}, {Name: "sender", Type: shortcut.FlagStringSlice, Desc: "--senders 的 lark-cli 对齐别名"}, + {Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "按姓名解析发送者的兼容入口", Hidden: true}, {Name: "at-me", Type: shortcut.FlagBool, Desc: "只搜索 @我 的消息"}, {Name: "is-at-me", Type: shortcut.FlagBool, Desc: "--at-me 的 lark-cli 对齐别名"}, {Name: "at-ids", Type: shortcut.FlagStringSlice, Desc: "@对象 userId/openDingTalkId 列表"}, @@ -102,7 +107,7 @@ var SearchMsg = shortcut.Shortcut{ Constraints: append([]shortcut.Constraint{ { Kind: shortcut.ConstraintAtLeastOne, - Flags: []string{"query", "keyword", "group", "conversation-id", "id", "groups", "chat-id", "senders", "sender", "at-me", "is-at-me", "at-ids", "message-type", "only-robot", "conversation-type", "chat-type"}, + Flags: []string{"query", "keyword", "text", "text-query", "group", "conversation-id", "id", "groups", "chat-id", "chat-query", "senders", "sender", "sender-query", "at-me", "is-at-me", "at-ids", "message-type", "only-robot", "conversation-type", "chat-type"}, Description: "至少指定一个内容、身份、会话或消息类型过滤条件", }, { @@ -119,6 +124,7 @@ var SearchMsg = shortcut.Shortcut{ {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"senders", "sender"}}, {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"at-me", "is-at-me"}}, {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"conversation-type", "chat-type"}}, + {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"query", "keyword", "text", "text-query"}}, {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"limit", "page-size"}}, {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"cursor", "page-token"}}, }, chatshortcut.MessageResourceDownloadConstraints()...), @@ -145,6 +151,7 @@ var SearchMsg = shortcut.Shortcut{ complete := true hasMore := false nextCursor := "" + paginationKnown := true for pagesFetched < pageLimit { params["cursor"] = cursor @@ -184,6 +191,7 @@ var SearchMsg = shortcut.Shortcut{ "stage": "search-pagination", "error": "下层未返回 hasMore 或 nextCursor,无法证明结果完整", }) + paginationKnown = false complete = false break } @@ -226,20 +234,26 @@ var SearchMsg = shortcut.Shortcut{ results = append(results, searchMsgProjectWithReactions(m, !rt.Bool("no-reactions"))) } payload := map[string]any{ - "count": len(results), - "messages": results, - "pagesFetched": pagesFetched, - "enrichedCount": enrichedCount, - "complete": complete, - "hasMore": hasMore, - "failedCount": len(failures), - "failures": failures, + "contractVersion": chatmsg.MessageListContractVersion, + "count": len(results), + "messages": results, + "pagesFetched": pagesFetched, + "enrichedCount": enrichedCount, + "complete": complete, + "hasMore": hasMore, + "nextCursor": "", + "paginationKnown": paginationKnown, + "failedCount": len(failures), + "failures": failures, } if hasMore && nextCursor != "" && nextCursor != "" { payload["nextCursor"] = nextCursor } if rt.Bool("download-resources") { - payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, messages, "") + chatshortcut.AttachMessageResourceDownloads( + payload, + chatshortcut.DownloadMessageResources(rt, messages, ""), + ) } return rt.Output(payload) }, @@ -253,11 +267,13 @@ func validateSearchMsgWithResources(rt *shortcut.RuntimeContext) error { } func validateSearchMsg(rt *shortcut.RuntimeContext) error { - hasFilter := rt.StrFirst("query", "keyword", "group", "conversation-id", "id", "message-type", "conversation-type", "chat-type") != "" || + hasFilter := rt.StrFirst("query", "keyword", "text", "text-query", "group", "conversation-id", "id", "message-type", "conversation-type", "chat-type") != "" || len(rt.StrSlice("groups")) > 0 || len(rt.StrSlice("chat-id")) > 0 || + len(rt.StrSlice("chat-query")) > 0 || len(rt.StrSlice("senders")) > 0 || len(rt.StrSlice("sender")) > 0 || + len(rt.StrSlice("sender-query")) > 0 || len(rt.StrSlice("at-ids")) > 0 || rt.Bool("at-me") || rt.Bool("is-at-me") || @@ -283,7 +299,7 @@ func validateSearchMsg(rt *shortcut.RuntimeContext) error { func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, error) { params := map[string]any{"limit": rt.IntFirst("limit", "page-size")} - if value := rt.StrFirst("query", "keyword"); value != "" { + if value := rt.StrFirst("query", "keyword", "text", "text-query"); value != "" { params["keyword"] = value } conversationIDs := append([]string{}, rt.StrSlice("groups")...) @@ -291,11 +307,33 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, error) { if value := rt.StrFirst("group", "conversation-id", "id"); value != "" { conversationIDs = append(conversationIDs, value) } + if queries := rt.StrSlice("chat-query"); len(queries) > 0 { + for _, query := range queries { + resolved, err := targetresolver.ResolveChatTarget(rt, "", query) + if err != nil { + return nil, err + } + conversationIDs = append(conversationIDs, resolved.Selected.OpenConversationID) + } + } if values := uniqueSearchStrings(conversationIDs); len(values) > 0 { params["openConversationIds"] = values } senders := append([]string{}, rt.StrSlice("senders")...) senders = append(senders, rt.StrSlice("sender")...) + if queries := rt.StrSlice("sender-query"); len(queries) > 0 { + resolvedUsers, err := targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny) + if err != nil { + return nil, err + } + for _, resolved := range resolvedUsers { + identity := resolved.Selected.OpenDingTalkID + if identity == "" { + identity = resolved.Selected.UserID + } + senders = append(senders, identity) + } + } appendSearchActorIDs(params, senders, "senderUserIds", "senderOpenDingTakIds") appendSearchActorIDs(params, rt.StrSlice("at-ids"), "atUserIds", "atOpenDingTakIds") if rt.Bool("at-me") || rt.Bool("is-at-me") { @@ -530,39 +568,16 @@ func searchMsgProject(m map[string]any) map[string]any { } func searchMsgProjectWithReactions(m map[string]any, includeReactions bool) map[string]any { - row := map[string]any{ - "sender": searchMsgSender(m), - "time": searchMsgTime(m), - "text": searchMsgCleanText(m), - "messageId": searchMsgMessageID(m), - } - if conversationID := chatmsg.ConversationID(m); conversationID != nil { - row["conversationId"] = conversationID - } - if threadID := chatmsg.ThreadID(m); threadID != nil { - row["threadId"] = threadID - } - if messageType := chatmsg.MessageType(m); messageType != nil { - row["messageType"] = messageType - } - if updateTime := chatmsg.UpdateTime(m); updateTime != nil { - row["updateTime"] = updateTime - } - if includeReactions { - if reactions := chatmsg.Reactions(m); len(reactions) > 0 { - row["reactions"] = reactions - } - } - if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 { - row["quotedMessage"] = quoted - } - if resources := chatmsg.ResourcesDeep(m); len(resources) > 0 { - row["resourceRefs"] = resources - } - projectForwarded := func(item map[string]any) map[string]any { + row := chatmsg.ProjectMessageV1(m, includeReactions) + // Preserve the established search aliases while also publishing the V1 + // canonical fields used by other message readers. + row["sender"] = searchMsgSender(m) + row["time"] = searchMsgTime(m) + row["text"] = searchMsgCleanText(m) + row["messageId"] = searchMsgMessageID(m) + if forwarded := chatmsg.Forwarded(m, func(item map[string]any) map[string]any { return searchMsgProjectWithReactions(item, includeReactions) - } - if forwarded := chatmsg.Forwarded(m, projectForwarded); len(forwarded) > 0 { + }); len(forwarded) > 0 { row["forwarded"] = forwarded } return row diff --git a/internal/shortcut/smart/send_to_group.go b/internal/shortcut/smart/send_to_group.go index 5fc7ae31..0232ba52 100644 --- a/internal/shortcut/smart/send_to_group.go +++ b/internal/shortcut/smart/send_to_group.go @@ -14,21 +14,18 @@ package smart import ( - "encoding/json" - "fmt" - "strings" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" ) -// SendToGroup: message a group by its NAME, no openConversationId juggling. +// SendToGroup: message a group by its name or stable openConversationId. // -// Steps: search groups by name → resolve to a single openConversationId -// (disambiguate on multiple matches, never guess) → send a markdown message. +// Steps: normalize a stable ID, or search a name and resolve it to a single +// openConversationId (disambiguate on multiple matches, never guess), then +// send a markdown message. // Replaces `chat search --query <群名>` (copy openConversationId) → // `chat +messages-send --group `. // @@ -41,10 +38,9 @@ var SendToGroup = shortcut.Shortcut{ Service: "chat", Command: "+send-to-group", Product: "chat", - Description: "按群名直接给群发消息(自动搜群解析 openConversationId)", - Intent: "当你只知道群的名字、想直接往这个群里发一条消息而不想先手动查群 ID 时使用;" + - "内部先按群名搜索群聊解析出唯一 openConversationId 再发送,群名匹配到多个群时会列出候选让你区分、绝不自行假定。会真实发出群消息。", - Risk: shortcut.RiskWrite, + Description: "按群名或 openConversationId 直接给群发消息", + Intent: "当你有群名或 openConversationId、想直接往该群发送简单文本或 Markdown 时使用;稳定 ID 不进入搜索,群名则必须唯一解析,零命中或多候选都会在发送前停止。会真实发出群消息。", + Risk: shortcut.RiskWrite, Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown", @@ -57,21 +53,21 @@ var SendToGroup = shortcut.Shortcut{ CLIPath: "chat +send-to-group", PrimaryCLIPath: "chat +send-to-group", }, - Description: "按群名直接给群发消息(自动搜群解析 openConversationId)", + Description: "按群名或 openConversationId 直接给群发消息", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", }, Selection: contract.SelectionSpec{ - AgentSummary: "按群名直接给群发消息(自动搜群解析 openConversationId)", - UseWhen: []string{"当你只知道群的名字、想直接往这个群里发一条消息而不想先手动查群 ID 时使用;内部先按群名搜索群聊解析出唯一 openConversationId 再发送,群名匹配到多个群时会列出候选让你区分、绝不自行假定。会真实发出群消息。"}, + AgentSummary: "按群名或 openConversationId 直接给群发消息", + UseWhen: []string{"当你有群名或 openConversationId、想直接往该群发送简单文本或 Markdown 时使用;稳定 ID 不进入搜索,群名则必须唯一解析,零命中或多候选都会在发送前停止。会真实发出群消息。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws chat +send-to-group --group 项目冲刺 --text \"今天 5 点前提交进度\""}, }, }, Flags: []shortcut.Flag{ - {Name: "group", Type: shortcut.FlagString, Desc: "群名称(搜群关键词,用群名里连续的核心词)", Required: true}, + {Name: "group", Type: shortcut.FlagString, Desc: "群名称或 openConversationId", Required: true}, {Name: "text", Type: shortcut.FlagString, Desc: "消息内容(支持 Markdown)", Required: true}, shortcut.AIMessageTagFlag(), }, @@ -80,33 +76,17 @@ var SendToGroup = shortcut.Shortcut{ groupName := rt.Str("group") text := rt.Str("text") - // Step 1 — search groups by name (keyword) on the im server. - data, err := rt.CallMCPData("im", "search_groups", map[string]any{ - "keyword": groupName, - "limit": 10, - "cursor": "0", - }) + resolved, err := targetresolver.ResolveChatTarget(rt, groupName, "") if err != nil { return err } - groups := preferExactGroupMatches(extractGroupsForSend(data), groupName) - switch { - case len(groups) == 0: - return apperrors.NewValidation(fmt.Sprintf( - "没找到名字匹配 %q 的群;换用群名里连续的核心词再试。", groupName)) - case len(groups) > 1: - return apperrors.NewValidation(fmt.Sprintf( - "%q 匹配到 %d 个群:%s。请用更精确的群名,或直接用 dws chat +messages-send --group 指定群。", - groupName, len(groups), strings.Join(sendGroupLabels(groups), "、"))) - } - - // Step 2 — send the markdown message to the unique group. - content, _ := json.Marshal(map[string]string{"title": text, "text": text}) - return rt.CallMCP("send_personal_message", rt.AddAIMessageTag(map[string]any{ - "openConversationId": groups[0].id, - "msgType": "markdown", - "content": string(content), - })) + return chatshortcut.ExecuteResolvedUserMarkdown( + rt, + chatshortcut.ResolvedUserMessageTarget{ + GroupID: resolved.Selected.OpenConversationID, + }, + text, + ) }, } @@ -121,41 +101,10 @@ type sendGroupMatch struct { // result/result.items/result.groups (field names per chat search's real // response shape), and the name field may be "title" or "name". func extractGroupsForSend(data map[string]any) []sendGroupMatch { - var list []any - switch inner := data["result"].(type) { - case []any: - list = inner - case map[string]any: - if v, ok := inner["items"].([]any); ok { - list = v - } else if v, ok := inner["groups"].([]any); ok { - list = v - } - } - if list == nil { - if v, ok := data["items"].([]any); ok { - list = v - } - } - - var out []sendGroupMatch - for _, item := range list { - m, ok := item.(map[string]any) - if !ok { - continue - } - id, _ := m["openConversationId"].(string) - if id == "" { - id, _ = m["id"].(string) - } - if id == "" { - continue - } - name, _ := m["title"].(string) - if name == "" { - name, _ = m["name"].(string) - } - out = append(out, sendGroupMatch{id: id, name: name}) + chats := targetresolver.ExtractChats(data) + out := make([]sendGroupMatch, 0, len(chats)) + for _, chat := range chats { + out = append(out, sendGroupMatch{id: chat.OpenConversationID, name: chat.Name}) } return out } @@ -166,38 +115,24 @@ func extractGroupsForSend(data map[string]any) []sendGroupMatch { // group wins over prefix/suffix matches. Duplicate rows for the same // openConversationId are collapsed before selection. func preferExactGroupMatches(groups []sendGroupMatch, query string) []sendGroupMatch { - unique := make([]sendGroupMatch, 0, len(groups)) - seen := make(map[string]struct{}, len(groups)) + chats := make([]targetresolver.Chat, 0, len(groups)) for _, group := range groups { - if _, ok := seen[group.id]; ok { - continue - } - seen[group.id] = struct{}{} - unique = append(unique, group) + chats = append(chats, targetresolver.Chat{OpenConversationID: group.id, Name: group.name}) } - query = strings.TrimSpace(query) - exact := make([]sendGroupMatch, 0, 1) - for _, group := range unique { - if strings.EqualFold(strings.TrimSpace(group.name), query) { - exact = append(exact, group) - } + selected := targetresolver.PreferExactChats(chats, query) + out := make([]sendGroupMatch, 0, len(selected)) + for _, chat := range selected { + out = append(out, sendGroupMatch{id: chat.OpenConversationID, name: chat.Name}) } - if len(exact) > 0 { - return exact - } - return unique + return out } func sendGroupLabels(groups []sendGroupMatch) []string { - out := make([]string, 0, len(groups)) - for _, g := range groups { - name := g.name - if name == "" { - name = "(未命名群)" - } - out = append(out, fmt.Sprintf("%s(%s)", name, g.id)) + chats := make([]targetresolver.Chat, 0, len(groups)) + for _, group := range groups { + chats = append(chats, targetresolver.Chat{OpenConversationID: group.id, Name: group.name}) } - return out + return targetresolver.ChatLabels(chats) } func init() { diff --git a/internal/shortcut/smart/thread_replies.go b/internal/shortcut/smart/thread_replies.go index 4c196cc7..eedce0e9 100644 --- a/internal/shortcut/smart/thread_replies.go +++ b/internal/shortcut/smart/thread_replies.go @@ -14,6 +14,8 @@ package smart import ( + "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" @@ -78,8 +80,8 @@ var ThreadReplies = shortcut.Shortcut{ {Name: "group", Type: shortcut.FlagString, Desc: "群会话 ID(openConversationId,必填)", Required: true}, {Name: "thread-id", Type: shortcut.FlagString, Desc: "话题/线程 ID(可直接使用消息列表返回的 threadId)"}, {Name: "topic-id", Type: shortcut.FlagString, Desc: "--thread-id 的兼容别名"}, - {Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\"(可选)"}, - {Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的回复条数(可选)"}, + {Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\";--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD(可选)"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的回复条数;--limit 必须大于 0"}, {Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出回复 reaction(默认输出)"}, }, chatshortcut.MessageResourceDownloadFlags()...), Constraints: append([]shortcut.Constraint{ @@ -88,12 +90,14 @@ var ThreadReplies = shortcut.Shortcut{ Flags: []string{"thread-id", "topic-id"}, Description: "--thread-id 与兼容参数 --topic-id 必须且只能指定一个", }, + {Kind: shortcut.ConstraintCustom, Flags: []string{"time"}, Description: "--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0"}, }, chatshortcut.MessageResourceDownloadConstraints()...), Tips: []string{ `dws chat +thread-replies --group --thread-id `, `dws chat +thread-replies --group --thread-id --time "2025-03-01 00:00:00" --limit 20`, }, - Validate: chatshortcut.ValidateMessageResourceDownload, + Validate: validateThreadReplies, Execute: func(rt *shortcut.RuntimeContext) error { // Step 1 — fetch the topic replies. Param keys (openconversationId / // topicId / startTime / pageSize) are copied verbatim from chat.go's @@ -129,12 +133,28 @@ var ThreadReplies = shortcut.Shortcut{ } chatmsg.ApplyMessagePagination(payload, data, items, "older") if rt.Bool("download-resources") { - payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, rt.Str("group")) + chatshortcut.AttachMessageResourceDownloads( + payload, + chatshortcut.DownloadMessageResources(rt, items, rt.Str("group")), + ) } return rt.Output(payload) }, } +func validateThreadReplies(rt *shortcut.RuntimeContext) error { + if err := chatshortcut.ValidateMessageResourceDownload(rt); err != nil { + return err + } + if rt.Changed("limit") && rt.Int("limit") <= 0 { + return localChatOptionError("invalid_page_size", "+thread-replies 的 --limit 必须大于 0", "--limit") + } + if value := strings.TrimSpace(rt.Str("time")); value != "" && !validChatTime(value) { + return localChatOptionError("invalid_time_boundary", "+thread-replies 的 --time 格式无效", "--time") + } + return nil +} + // threadReplyItems defensively unwraps the reply list from the response, // tolerating the common container keys and one level of nesting under a // "result"/"data" wrapper. diff --git a/internal/shortcut/smart/unread_chats.go b/internal/shortcut/smart/unread_chats.go index c08c3c77..9e0f427b 100644 --- a/internal/shortcut/smart/unread_chats.go +++ b/internal/shortcut/smart/unread_chats.go @@ -82,14 +82,23 @@ var UnreadChats = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "count", Type: shortcut.FlagInt, Desc: "返回未读会话条数(可选,不传则使用服务端默认值)", Required: false}, + {Name: "count", Type: shortcut.FlagInt, Desc: "返回未读会话条数;显式 --count 必须大于 0,不传则使用服务端默认值", Required: false}, {Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "是否排除已设置免打扰的会话(可选,默认 false)", Required: false}, }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"count"}, Description: "显式 --count 必须大于 0"}, + }, Tips: []string{ `dws chat +unread-chats`, `dws chat +unread-chats --count 20`, `dws chat +unread-chats --exclude-muted`, }, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Changed("count") && rt.Int("count") <= 0 { + return localChatOptionError("invalid_page_size", "+unread-chats 的 --count 必须大于 0", "--count") + } + return nil + }, Execute: func(rt *shortcut.RuntimeContext) error { // Build params exactly like chatMessageListUnreadConversationsCmd: count is // only sent when > 0, excludeMuted only when true. diff --git a/internal/shortcut/targetresolver/resolver.go b/internal/shortcut/targetresolver/resolver.go new file mode 100644 index 00000000..a4c6bd21 --- /dev/null +++ b/internal/shortcut/targetresolver/resolver.go @@ -0,0 +1,797 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +// Package targetresolver provides deterministic natural-target resolution for +// shortcuts. It owns extraction, stable-ID de-duplication, exact-match +// preference, ambiguity handling, and the machine-readable resolution error +// envelope shared by send/read/search/create/event facades. +package targetresolver + +import ( + stderrors "errors" + "fmt" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/profilectx" +) + +// Status is the stable resolution outcome used in successful plans and errors. +type Status string + +const ( + StatusResolved Status = "resolved" + StatusAmbiguous Status = "ambiguous" + StatusNotFound Status = "not_found" + StatusIncomplete Status = "incomplete" +) + +const ( + chatResolutionPageSize = 10 + chatResolutionPageLimit = 40 + userSearchDefaultResultLimit = 20 +) + +// IdentityRequirement filters contacts to identities accepted by the +// downstream interface. +type IdentityRequirement string + +const ( + IdentityAny IdentityRequirement = "any" + IdentityUserID IdentityRequirement = "user_id" + IdentityOpenDingTalkID IdentityRequirement = "open_dingtalk_id" +) + +// Reader is the minimal read-only transport needed by natural target +// resolution. Shortcut RuntimeContext implements it, and native facades (for +// example event +listen-im) can provide an adapter without duplicating +// extraction or ambiguity rules. +type Reader interface { + CallMCPData(product, tool string, params map[string]any) (map[string]any, error) +} + +// User is the public, credential-free identity returned by contact resolution. +type User struct { + UserID string `json:"userId,omitempty"` + OpenDingTalkID string `json:"openDingTalkId,omitempty"` + Name string `json:"name,omitempty"` +} + +// UserResolution is the typed successful user-resolution result. +type UserResolution struct { + Status Status `json:"status"` + EntityType string `json:"entityType"` + Query string `json:"query"` + MatchType string `json:"matchType"` + Selected User `json:"selected"` + Profile string `json:"profile,omitempty"` +} + +// Chat is the public identity returned by group resolution. +type Chat struct { + OpenConversationID string `json:"openConversationId"` + Name string `json:"name,omitempty"` +} + +// ChatResolution is the typed successful group-resolution result. +type ChatResolution struct { + Status Status `json:"status"` + EntityType string `json:"entityType"` + Query string `json:"query"` + MatchType string `json:"matchType"` + Selected Chat `json:"selected"` + Profile string `json:"profile,omitempty"` +} + +// ResolveUser searches the current profile's directory and resolves query to +// exactly one identity accepted by the downstream interface. +func ResolveUser(rt Reader, query string, requirement IdentityRequirement) (UserResolution, error) { + query = strings.TrimSpace(query) + data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{ + "keyword": query, + }) + if err != nil { + return UserResolution{}, err + } + if cause := incompleteUserSearchCause(data, "通讯录搜索"); cause != "" { + return UserResolution{}, newIncompleteUserResolutionError( + query, + dedupeUsers(ExtractUsers(data)), + cause, + ) + } + return resolveUserCandidates(ExtractUsers(data), query, requirement) +} + +// ResolveEnterpriseUser resolves an organization member through the same +// name-calibrated enterprise search used by `dws aisearch person`. Unlike the +// contact search, this source understands organization nicknames and aliases. +func ResolveEnterpriseUser(rt Reader, query string, requirement IdentityRequirement) (UserResolution, error) { + query = strings.TrimSpace(query) + data, err := rt.CallMCPData("aisearch", "enterprise_person_search", map[string]any{ + "keyword": query, + "dimension": []string{"name"}, + }) + if err != nil { + return UserResolution{}, err + } + if cause := incompleteUserSearchCause(data, "企业人员搜索"); cause != "" { + return UserResolution{}, newIncompleteUserResolutionError( + query, + dedupeUsers(ExtractUsers(data)), + cause, + ) + } + return resolveUserCandidates(ExtractUsers(data), query, requirement) +} + +// incompleteUserSearchCause enforces completeness for the two public user +// search RPCs. Their discovered input contracts do not accept any cursor or +// page parameter, while search_contact_by_key_word is observed to cap an +// unqualified result at 20. An explicit terminal hasMore=false proves the page +// complete; otherwise a continuation marker or a full default page must fail +// closed instead of treating a first-page match as globally unique. +func incompleteUserSearchCause(data map[string]any, source string) string { + page := extractChatPagination(data) + if page.hasMoreKnown { + if page.hasMore { + return source + "返回了 hasMore=true,但公开接口不接受游标参数,无法安全读取后续候选" + } + return "" + } + if page.nextCursor != "" { + return source + "返回了后续游标,但公开接口不接受游标参数,无法安全读取后续候选" + } + if len(firstList(data, "result", "items", "users", "list")) >= userSearchDefaultResultLimit { + return fmt.Sprintf("%s返回了满额 %d 条候选且没有结束标记,无法证明后续不存在同名用户", source, userSearchDefaultResultLimit) + } + return "" +} + +func resolveUserCandidates(candidates []User, query string, requirement IdentityRequirement) (UserResolution, error) { + users := filterUsersByIdentity(dedupeUsers(candidates), requirement) + selected, matchType := selectUsers(users, query) + if len(selected) == 0 { + return UserResolution{}, newResolutionError(StatusNotFound, "user", query, users) + } + if len(selected) > 1 { + return UserResolution{}, newResolutionError(StatusAmbiguous, "user", query, selected) + } + return UserResolution{ + Status: StatusResolved, + EntityType: "user", + Query: query, + MatchType: matchType, + Selected: selected[0], + Profile: profilectx.Get(), + }, nil +} + +// ResolveChat searches groups and resolves query to exactly one stable +// openConversationId. Exact names win over substring matches, but multiple +// exact names remain ambiguous. +func ResolveChat(rt Reader, query string) (ChatResolution, error) { + query = strings.TrimSpace(query) + if LooksLikeOpenConversationID(query) { + return ChatResolution{}, apperrors.NewValidation(fmt.Sprintf( + "群目标参数类型不匹配:%q 符合 openConversationId 格式,当前参数仅接受群名称", + query, + ), + apperrors.WithReason("target_type_mismatch"), + apperrors.WithOrigin("client"), + apperrors.WithFailureStage("target_resolution"), + apperrors.WithExecutionStarted(false), + apperrors.WithHint("请使用当前命令的稳定 ID 参数;运行当前 leaf --help 可查看准确参数名。"), + apperrors.WithDetails(map[string]any{ + "type": "resolution", + "entityType": "chat", + "providedType": "openConversationId", + "expectedType": "chatName", + }), + ) + } + cursor := "0" + seenCursors := map[string]bool{cursor: true} + chats := make([]Chat, 0) + complete := false + for pageNumber := 1; pageNumber <= chatResolutionPageLimit; pageNumber++ { + data, err := rt.CallMCPData("im", "search_groups", map[string]any{ + "keyword": query, + "limit": chatResolutionPageSize, + "cursor": cursor, + }) + if err != nil { + return ChatResolution{}, err + } + rows := firstList(data, "result", "items", "groups", "list") + chats = append(chats, ExtractChats(data)...) + page := extractChatPagination(data) + switch { + case page.hasMoreKnown && !page.hasMore: + complete = true + case page.hasMoreKnown && page.hasMore: + // A usable cursor is required below. + case page.nextCursor != "": + // Some versions omit hasMore but still publish a continuation cursor. + case len(rows) < chatResolutionPageSize: + // Compatibility for older responses that return a short bare array + // without pagination metadata. + complete = true + default: + return ChatResolution{}, newIncompleteChatResolutionError( + query, + dedupeChats(chats), + "群搜索返回了满页结果,但没有 hasMore 或 nextCursor,无法证明候选已完整读取", + ) + } + if complete { + break + } + nextCursor := strings.TrimSpace(page.nextCursor) + if nextCursor == "" || seenCursors[nextCursor] { + reason := "群搜索声明仍有更多结果,但没有返回可继续的 nextCursor" + if nextCursor != "" { + reason = fmt.Sprintf("群搜索分页游标停滞在 %q,无法证明候选已完整读取", nextCursor) + } + return ChatResolution{}, newIncompleteChatResolutionError( + query, + dedupeChats(chats), + reason, + ) + } + seenCursors[nextCursor] = true + cursor = nextCursor + } + chats = dedupeChats(chats) + if !complete { + return ChatResolution{}, newIncompleteChatResolutionError( + query, + chats, + fmt.Sprintf("群搜索达到安全页数上限 %d,仍无法证明候选已完整读取", chatResolutionPageLimit), + ) + } + selected, matchType := preferExactChats(chats, query) + if len(selected) == 0 { + return ChatResolution{}, newResolutionError(StatusNotFound, "chat", query, chats) + } + if len(selected) > 1 { + return ChatResolution{}, newResolutionError(StatusAmbiguous, "chat", query, selected) + } + return ChatResolution{ + Status: StatusResolved, + EntityType: "chat", + Query: query, + MatchType: matchType, + Selected: selected[0], + Profile: profilectx.Get(), + }, nil +} + +// ResolveChatTarget accepts either a stable openConversationId or a natural +// group name and returns one normalized ChatResolution. Stable IDs never reach +// search_groups; natural names retain ResolveChat's complete-pagination and +// fail-closed ambiguity semantics. +func ResolveChatTarget(rt Reader, directValue, queryValue string) (ChatResolution, error) { + directValue = strings.TrimSpace(directValue) + queryValue = strings.TrimSpace(queryValue) + if directValue != "" && queryValue != "" { + return ChatResolution{}, apperrors.NewValidation( + "稳定群目标与群名查询参数不能同时指定", + apperrors.WithReason("target_arguments_conflict"), + apperrors.WithOrigin("client"), + apperrors.WithFailureStage("request_validation"), + apperrors.WithExecutionStarted(false), + ) + } + value := directValue + if value == "" { + value = queryValue + } + if value == "" { + return ChatResolution{}, apperrors.NewValidation( + "必须指定群名或 openConversationId", + apperrors.WithReason("missing_target"), + apperrors.WithOrigin("client"), + apperrors.WithFailureStage("request_validation"), + apperrors.WithExecutionStarted(false), + ) + } + if LooksLikeOpenConversationID(value) { + return ChatResolution{ + Status: StatusResolved, + EntityType: "chat", + Query: value, + MatchType: "stable_id", + Selected: Chat{OpenConversationID: value}, + Profile: profilectx.Get(), + }, nil + } + return ResolveChat(rt, value) +} + +// LooksLikeOpenConversationID identifies the opaque group-conversation IDs +// returned by DingTalk. It is deliberately conservative: only the stable +// "cid..." form is recognized, so ordinary short names are still resolved by +// search and ambiguity checks. +func LooksLikeOpenConversationID(value string) bool { + value = strings.TrimSpace(value) + return len(value) >= 12 && strings.HasPrefix(strings.ToLower(value), "cid") +} + +type chatPagination struct { + hasMore bool + hasMoreKnown bool + nextCursor string +} + +func extractChatPagination(data map[string]any) chatPagination { + if data == nil { + return chatPagination{} + } + scopes := []map[string]any{data} + for _, wrapper := range []string{"result", "data"} { + if nested, ok := data[wrapper].(map[string]any); ok { + scopes = append(scopes, nested) + } + } + page := chatPagination{} + for _, scope := range scopes { + if !page.hasMoreKnown { + for _, key := range []string{"hasMore", "has_more"} { + if value, ok := scope[key].(bool); ok { + page.hasMore = value + page.hasMoreKnown = true + break + } + } + } + if page.nextCursor == "" { + for _, key := range []string{"nextCursor", "next_cursor", "nextToken", "next_token"} { + if value, ok := scope[key]; ok { + page.nextCursor = paginationString(value) + if page.nextCursor != "" { + break + } + } + } + } + } + return page +} + +func paginationString(value any) string { + if value == nil { + return "" + } + text := strings.TrimSpace(fmt.Sprint(value)) + if text == "" || text == "" { + return "" + } + return text +} + +// ResolveUsers resolves every query before returning. Resolution failures are +// collected into one typed envelope; upstream/auth failures still stop +// immediately. Successful identities are de-duplicated by stable ID. +func ResolveUsers( + rt Reader, + queries []string, + requirement IdentityRequirement, +) ([]UserResolution, error) { + results := make([]UserResolution, 0, len(queries)) + failures := make([]map[string]any, 0) + for _, query := range uniqueQueries(queries) { + resolved, err := ResolveUser(rt, query, requirement) + if err != nil { + if details, ok := resolutionDetails(err); ok { + failures = append(failures, details) + continue + } + return nil, err + } + results = append(results, resolved) + } + if len(failures) > 0 { + return nil, batchResolutionError("user", failures) + } + seenUserIDs := map[string]bool{} + seenOpenIDs := map[string]bool{} + deduped := make([]UserResolution, 0, len(results)) + for _, result := range results { + user := result.Selected + if (user.UserID != "" && seenUserIDs[user.UserID]) || + (user.OpenDingTalkID != "" && seenOpenIDs[user.OpenDingTalkID]) { + continue + } + seenUserIDs[user.UserID] = user.UserID != "" + seenOpenIDs[user.OpenDingTalkID] = user.OpenDingTalkID != "" + deduped = append(deduped, result) + } + return deduped, nil +} + +// ResolveChats is the batch equivalent of ResolveChat and returns only after +// every query has been preflighted. +func ResolveChats(rt Reader, queries []string) ([]ChatResolution, error) { + results := make([]ChatResolution, 0, len(queries)) + failures := make([]map[string]any, 0) + for _, query := range uniqueQueries(queries) { + resolved, err := ResolveChat(rt, query) + if err != nil { + if details, ok := resolutionDetails(err); ok { + failures = append(failures, details) + continue + } + return nil, err + } + results = append(results, resolved) + } + seen := map[string]bool{} + deduped := make([]ChatResolution, 0, len(results)) + for _, result := range results { + if seen[result.Selected.OpenConversationID] { + continue + } + seen[result.Selected.OpenConversationID] = true + deduped = append(deduped, result) + } + if len(failures) > 0 { + return nil, batchResolutionError("chat", failures) + } + return deduped, nil +} + +// ExtractUsers accepts the contact search response shapes used by the current +// directory MCP and keeps external contacts that only expose openDingTalkId. +func ExtractUsers(data map[string]any) []User { + raw := firstList(data, "result", "items", "users", "list") + if len(raw) == 0 { + return nil + } + users := make([]User, 0, len(raw)) + for _, item := range raw { + row, ok := item.(map[string]any) + if !ok { + continue + } + userID := stringValue(row, "userId", "orgUserId") + openID := stringValue(row, "openDingTalkId", "openDingtalkId") + name := stringValue(row, + "name", "nick", "showName", "flowerName", "staffName", "userName", "author", "title") + if meta, ok := row["meta"].(map[string]any); ok { + if userID == "" { + userID = stringValue(meta, "userId", "orgUserId", "staffId") + } + if openID == "" { + openID = stringValue(meta, "openDingTalkId", "openDingtalkId") + } + if metaName := stringValue(meta, "name", "nick"); metaName != "" { + name = metaName + } + } + if userID == "" && openID == "" { + continue + } + users = append(users, User{ + UserID: userID, + OpenDingTalkID: openID, + Name: name, + }) + } + return users +} + +// ExtractChats accepts both bare and wrapped group search result lists. +func ExtractChats(data map[string]any) []Chat { + raw := firstList(data, "result", "items", "groups", "list") + if len(raw) == 0 { + return nil + } + chats := make([]Chat, 0, len(raw)) + for _, item := range raw { + row, ok := item.(map[string]any) + if !ok { + continue + } + id := stringValue(row, "openConversationId", "openconversationId", "id") + if id == "" { + continue + } + chats = append(chats, Chat{ + OpenConversationID: id, + Name: stringValue(row, "title", "name", "conversationName"), + }) + } + return chats +} + +// UsersWithUserID preserves the legacy smart-shortcut helper contract. +func UsersWithUserID(users []User) []User { + return filterUsersByIdentity(users, IdentityUserID) +} + +// PreferExactChats exposes the deterministic selection helper to legacy smart +// shortcuts while they migrate to ResolveChat. +func PreferExactChats(chats []Chat, query string) []Chat { + selected, _ := preferExactChats(dedupeChats(chats), query) + return selected +} + +// UserLabels renders credential-free disambiguation labels. +func UserLabels(users []User) []string { + labels := make([]string, 0, len(users)) + for _, user := range users { + id := user.UserID + if id == "" { + id = user.OpenDingTalkID + } + labels = append(labels, fmt.Sprintf("%s(%s)", user.Name, id)) + } + return labels +} + +// ChatLabels renders group disambiguation labels. +func ChatLabels(chats []Chat) []string { + labels := make([]string, 0, len(chats)) + for _, chat := range chats { + name := chat.Name + if name == "" { + name = "(未命名群)" + } + labels = append(labels, fmt.Sprintf("%s(%s)", name, chat.OpenConversationID)) + } + return labels +} + +func selectUsers(users []User, query string) ([]User, string) { + if len(users) != 1 { + return users, "ambiguous" + } + if strings.EqualFold(strings.TrimSpace(users[0].Name), strings.TrimSpace(query)) { + return users, "exact" + } + return users, "unique" +} + +func preferExactChats(chats []Chat, query string) ([]Chat, string) { + exact := make([]Chat, 0, len(chats)) + for _, chat := range chats { + if strings.EqualFold(strings.TrimSpace(chat.Name), strings.TrimSpace(query)) { + exact = append(exact, chat) + } + } + if len(exact) > 0 { + return exact, "exact" + } + return chats, "unique" +} + +func filterUsersByIdentity(users []User, requirement IdentityRequirement) []User { + filtered := make([]User, 0, len(users)) + for _, user := range users { + switch requirement { + case IdentityUserID: + if user.UserID == "" { + continue + } + case IdentityOpenDingTalkID: + if user.OpenDingTalkID == "" { + continue + } + } + filtered = append(filtered, user) + } + return filtered +} + +func dedupeUsers(users []User) []User { + result := make([]User, 0, len(users)) + seenUserIDs := map[string]bool{} + seenOpenIDs := map[string]bool{} + for _, user := range users { + if (user.UserID != "" && seenUserIDs[user.UserID]) || + (user.OpenDingTalkID != "" && seenOpenIDs[user.OpenDingTalkID]) { + continue + } + if user.UserID != "" { + seenUserIDs[user.UserID] = true + } + if user.OpenDingTalkID != "" { + seenOpenIDs[user.OpenDingTalkID] = true + } + result = append(result, user) + } + return result +} + +func dedupeChats(chats []Chat) []Chat { + result := make([]Chat, 0, len(chats)) + seen := map[string]bool{} + for _, chat := range chats { + if seen[chat.OpenConversationID] { + continue + } + seen[chat.OpenConversationID] = true + result = append(result, chat) + } + return result +} + +func newResolutionError(status Status, entityType, query string, candidates any) error { + details := map[string]any{ + "type": "resolution", + "subtype": status, + "entityType": entityType, + "query": query, + "candidates": candidates, + } + if profile := profilectx.Get(); profile != "" { + details["profile"] = profile + } + if status == StatusNotFound { + return apperrors.NewValidation( + fmt.Sprintf("没有找到与 %q 唯一匹配且可用于当前操作的%s", query, entityLabel(entityType)), + apperrors.WithReason("resolution_not_found"), + apperrors.WithOrigin("client"), + apperrors.WithFailureStage("target_resolution"), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(false), + apperrors.WithHint("请提供更完整的名称或直接传稳定 ID"), + apperrors.WithDetails(details), + ) + } + return apperrors.NewValidation( + fmt.Sprintf("%q 匹配到多个%s:%s;请提供更精确的名称或直接传稳定 ID", query, entityLabel(entityType), candidateLabels(candidates)), + apperrors.WithReason("resolution_ambiguous"), + apperrors.WithOrigin("client"), + apperrors.WithFailureStage("target_resolution"), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(false), + apperrors.WithHint("禁止默认选择第一个候选"), + apperrors.WithDetails(details), + ) +} + +func newIncompleteChatResolutionError(query string, candidates []Chat, cause string) error { + details := map[string]any{ + "type": "resolution", + "subtype": StatusIncomplete, + "entityType": "chat", + "query": query, + "candidates": candidates, + "cause": cause, + } + if profile := profilectx.Get(); profile != "" { + details["profile"] = profile + } + return apperrors.NewAPI( + fmt.Sprintf("群名 %q 的候选未能完整读取,已停止后续操作", query), + apperrors.WithReason("resolution_incomplete"), + apperrors.WithOrigin("mcp_gateway"), + apperrors.WithFailureStage("target_resolution"), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(true), + apperrors.WithHint("请重试,或直接传 openConversationId 跳过群名解析"), + apperrors.WithDetails(details), + ) +} + +func newIncompleteUserResolutionError(query string, candidates []User, cause string) error { + details := map[string]any{ + "type": "resolution", + "subtype": StatusIncomplete, + "entityType": "user", + "query": query, + "candidates": candidates, + "cause": cause, + } + if profile := profilectx.Get(); profile != "" { + details["profile"] = profile + } + return apperrors.NewAPI( + fmt.Sprintf("用户 %q 的候选未能完整读取,已停止后续操作", query), + apperrors.WithReason("resolution_incomplete"), + apperrors.WithOrigin("mcp_gateway"), + apperrors.WithFailureStage("target_resolution"), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(true), + apperrors.WithHint("请重试,或直接传 userId/openDingTalkId 跳过姓名解析"), + apperrors.WithDetails(details), + ) +} + +func batchResolutionError(entityType string, failures []map[string]any) error { + return apperrors.NewValidation( + fmt.Sprintf("%d 个%s目标未能唯一解析;已停止后续操作", len(failures), entityLabel(entityType)), + apperrors.WithReason("resolution_batch_failed"), + apperrors.WithOrigin("client"), + apperrors.WithFailureStage("target_resolution"), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(false), + apperrors.WithHint("请逐项消歧或直接传稳定 ID"), + apperrors.WithDetails(map[string]any{ + "type": "resolution", + "subtype": "batch_failed", + "entityType": entityType, + "failures": failures, + }), + ) +} + +func resolutionDetails(err error) (map[string]any, bool) { + var typed *apperrors.Error + if !stderrors.As(err, &typed) || typed.Details["type"] != "resolution" { + return nil, false + } + switch typed.Reason { + case "resolution_not_found", "resolution_ambiguous": + // These are client-side disambiguation outcomes and may be combined + // into one batch response. Incomplete/API/auth failures must retain + // their original category and retryability and stop immediately. + default: + return nil, false + } + return typed.Details, true +} + +func entityLabel(entityType string) string { + if entityType == "chat" { + return "群聊" + } + return "用户" +} + +func candidateLabels(candidates any) string { + switch values := candidates.(type) { + case []User: + return strings.Join(UserLabels(values), "、") + case []Chat: + return strings.Join(ChatLabels(values), "、") + default: + return "候选" + } +} + +func firstList(data map[string]any, keys ...string) []any { + if data == nil { + return nil + } + scopes := []map[string]any{data} + for _, wrapper := range []string{"result", "data"} { + if nested, ok := data[wrapper].(map[string]any); ok { + scopes = append(scopes, nested) + } + } + for _, scope := range scopes { + for _, key := range keys { + if raw, ok := scope[key].([]any); ok { + return raw + } + } + } + return nil +} + +func stringValue(row map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := row[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func uniqueQueries(queries []string) []string { + seen := map[string]bool{} + result := make([]string, 0, len(queries)) + for _, query := range queries { + query = strings.TrimSpace(query) + if query == "" || seen[query] { + continue + } + seen[query] = true + result = append(result, query) + } + return result +} diff --git a/internal/shortcut/targetresolver/resolver_test.go b/internal/shortcut/targetresolver/resolver_test.go new file mode 100644 index 00000000..a975a7ae --- /dev/null +++ b/internal/shortcut/targetresolver/resolver_test.go @@ -0,0 +1,636 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package targetresolver + +import ( + stderrors "errors" + "fmt" + "reflect" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/profilectx" +) + +type resolverReaderFunc func(product, tool string, params map[string]any) (map[string]any, error) + +func (f resolverReaderFunc) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) { + return f(product, tool, params) +} + +type chatResolutionReader struct { + responses []map[string]any + calls []map[string]any +} + +func (r *chatResolutionReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) { + r.calls = append(r.calls, params) + if product != "im" || tool != "search_groups" { + return nil, stderrors.New("unexpected resolver tool") + } + if len(r.calls) > len(r.responses) { + return nil, stderrors.New("unexpected resolver page") + } + return r.responses[len(r.calls)-1], nil +} + +func TestCrossPlatformCoverageExtractUsersKeepsUsableExternalContacts(t *testing.T) { + users := ExtractUsers(map[string]any{ + "result": []any{ + map[string]any{"userId": "u1", "openDingTalkId": "D1", "name": "张三"}, + map[string]any{"openDingtalkId": "D2", "nick": "外部张三"}, + map[string]any{"name": "无 ID"}, + "garbage", + }, + }) + if len(users) != 2 { + t.Fatalf("users = %#v", users) + } + if users[1].OpenDingTalkID != "D2" || users[1].Name != "外部张三" { + t.Fatalf("external user = %#v", users[1]) + } +} + +func TestCrossPlatformCoverageExtractUsersAcceptsEnterprisePersonMetadata(t *testing.T) { + users := ExtractUsers(map[string]any{ + "result": []any{ + map[string]any{ + "meta": map[string]any{ + "staffId": "u1", + "openDingTalkId": "D1", + "name": "柏荣", + }, + }, + map[string]any{ + "userId": "u2", + "openDingTalkId": "D2", + "title": "展示名", + }, + }, + }) + if !reflect.DeepEqual(users, []User{ + {UserID: "u1", OpenDingTalkID: "D1", Name: "柏荣"}, + {UserID: "u2", OpenDingTalkID: "D2", Name: "展示名"}, + }) { + t.Fatalf("enterprise users = %#v", users) + } +} + +func TestCrossPlatformCoverageResolveEnterpriseUserUsesCalibratedNameSearch(t *testing.T) { + reader := resolverReaderFunc(func(product, tool string, params map[string]any) (map[string]any, error) { + if product != "aisearch" || tool != "enterprise_person_search" { + t.Fatalf("tool = %s/%s", product, tool) + } + if params["keyword"] != "柏荣" || !reflect.DeepEqual(params["dimension"], []string{"name"}) { + t.Fatalf("params = %#v", params) + } + return map[string]any{"result": []any{map[string]any{ + "userId": "u1", + "openDingTalkId": "D1", + "meta": map[string]any{"name": "柏荣"}, + }}}, nil + }) + resolved, err := ResolveEnterpriseUser(reader, " 柏荣 ", IdentityAny) + if err != nil || resolved.MatchType != "exact" || resolved.Selected.UserID != "u1" { + t.Fatalf("resolved = %#v, err = %v", resolved, err) + } +} + +func TestCrossPlatformCoverageUserResolutionFailsClosedOnUnpageableContinuation(t *testing.T) { + for _, tc := range []struct { + name string + resolve func(Reader) (UserResolution, error) + data map[string]any + }{ + { + name: "contact search", + resolve: func(reader Reader) (UserResolution, error) { + return ResolveUser(reader, "张三", IdentityAny) + }, + data: map[string]any{ + "result": []any{map[string]any{"userId": "u1", "name": "张三"}}, + "hasMore": true, + "nextCursor": "page-2", + }, + }, + { + name: "enterprise search", + resolve: func(reader Reader) (UserResolution, error) { + return ResolveEnterpriseUser(reader, "张三", IdentityAny) + }, + data: map[string]any{ + "result": []any{map[string]any{"userId": "u1", "name": "张三"}}, + "nextCursor": "page-2", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := tc.resolve(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) { + return tc.data, nil + })) + var typed *apperrors.Error + if !stderrors.As(err, &typed) || typed.Reason != "resolution_incomplete" || !typed.Retryable { + t.Fatalf("error = %#v", err) + } + if typed.Details["entityType"] != "user" || typed.Details["subtype"] != StatusIncomplete { + t.Fatalf("details = %#v", typed.Details) + } + }) + } + + fullPage := make([]any, userSearchDefaultResultLimit) + fullPage[0] = map[string]any{ + "openDingTalkId": "D1", + "name": "张三", + } + for i := 1; i < len(fullPage); i++ { + // These first-page contacts do not satisfy the downstream open-ID + // requirement. A second-page namesake still could, so the one usable + // first-page candidate must not be treated as globally unique. + fullPage[i] = map[string]any{ + "userId": fmt.Sprintf("u%d", i), + "name": fmt.Sprintf("张三候选%d", i), + } + } + _, err := ResolveUser(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) { + return map[string]any{"result": fullPage}, nil + }), "张三", IdentityOpenDingTalkID) + var fullPageError *apperrors.Error + if !stderrors.As(err, &fullPageError) || fullPageError.Reason != "resolution_incomplete" { + t.Fatalf("full-page error = %#v", err) + } + if cause := fmt.Sprint(fullPageError.Details["cause"]); !strings.Contains(cause, "满额 20 条") { + t.Fatalf("full-page cause = %q", cause) + } + + terminalCursor := map[string]any{ + "result": []any{map[string]any{"userId": "u1", "name": "张三"}}, + "hasMore": false, + "nextCursor": "terminal-token", + } + resolved, err := ResolveUser(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) { + return terminalCursor, nil + }), "张三", IdentityAny) + if err != nil || resolved.Selected.UserID != "u1" { + t.Fatalf("terminal cursor resolved = %#v, err = %v", resolved, err) + } +} + +func TestCrossPlatformCoverageOpenConversationIDIsNeverSearchedAsAGroupName(t *testing.T) { + for _, value := range []string{"cid-fixture-chat-0001", " CIDO123456789 "} { + if !LooksLikeOpenConversationID(value) { + t.Fatalf("LooksLikeOpenConversationID(%q) = false", value) + } + } + for _, value := range []string{"cid", "项目cid群", "conversation-1"} { + if LooksLikeOpenConversationID(value) { + t.Fatalf("LooksLikeOpenConversationID(%q) = true", value) + } + } + + reader := &chatResolutionReader{} + _, err := ResolveChat(reader, "cid-fixture-chat-0001") + var typed *apperrors.Error + if err == nil || !stderrors.As(err, &typed) || typed.Reason != "target_type_mismatch" { + t.Fatalf("ResolveChat(stable id) error = %v", err) + } + if strings.Contains(typed.Message, "看起来是") || !strings.Contains(typed.Message, "群目标参数类型不匹配") { + t.Fatalf("ResolveChat(stable id) message = %q", typed.Message) + } + if typed.Details["providedType"] != "openConversationId" || typed.Details["expectedType"] != "chatName" { + t.Fatalf("ResolveChat(stable id) details = %#v", typed.Details) + } + if len(reader.calls) != 0 { + t.Fatalf("stable id unexpectedly reached search: %#v", reader.calls) + } +} + +func TestCrossPlatformCoverageResolveChatTargetStableIDBypassesSearch(t *testing.T) { + reader := &chatResolutionReader{} + resolved, err := ResolveChatTarget(reader, " cid-fixture-chat-0001 ", "") + if err != nil { + t.Fatal(err) + } + if resolved.Selected.OpenConversationID != "cid-fixture-chat-0001" || resolved.MatchType != "stable_id" { + t.Fatalf("resolved = %#v", resolved) + } + if len(reader.calls) != 0 { + t.Fatalf("stable id unexpectedly reached search: %#v", reader.calls) + } +} + +func TestCrossPlatformCoverageResolveChatTargetNaturalDirectValueUsesResolver(t *testing.T) { + reader := &chatResolutionReader{responses: []map[string]any{{ + "result": []any{map[string]any{"openConversationId": "cid-project-1", "title": "项目群"}}, + "hasMore": false, + }}} + resolved, err := ResolveChatTarget(reader, "项目群", "") + if err != nil { + t.Fatal(err) + } + if resolved.Selected.OpenConversationID != "cid-project-1" || len(reader.calls) != 1 { + t.Fatalf("resolved = %#v calls = %#v", resolved, reader.calls) + } +} + +func TestCrossPlatformCoverageResolveChatTargetQueryStableIDAlsoBypassesSearch(t *testing.T) { + reader := &chatResolutionReader{} + resolved, err := ResolveChatTarget(reader, "", "cid-query-123456") + if err != nil { + t.Fatal(err) + } + if resolved.Selected.OpenConversationID != "cid-query-123456" || len(reader.calls) != 0 { + t.Fatalf("resolved = %#v calls = %#v", resolved, reader.calls) + } +} + +func TestCrossPlatformCoverageUserSelectionDedupesButDoesNotHideNamesakes(t *testing.T) { + users := dedupeUsers([]User{ + {UserID: "u1", OpenDingTalkID: "D1", Name: "张三"}, + {UserID: "u1", OpenDingTalkID: "D1", Name: "duplicate"}, + {UserID: "u2", OpenDingTalkID: "D2", Name: "张三丰"}, + }) + selected, matchType := selectUsers(users, " 张三 ") + if len(selected) != 2 || matchType != "ambiguous" { + t.Fatalf("selected = %#v, matchType = %q", selected, matchType) + } +} + +func TestCrossPlatformCoverageChatSelectionKeepsMultipleExactMatchesAmbiguous(t *testing.T) { + chats := dedupeChats([]Chat{ + {OpenConversationID: "c1", Name: "项目群"}, + {OpenConversationID: "c1", Name: "项目群"}, + {OpenConversationID: "c2", Name: "项目群"}, + {OpenConversationID: "c3", Name: "项目群-归档"}, + }) + selected, matchType := preferExactChats(chats, "项目群") + if len(selected) != 2 || matchType != "exact" { + t.Fatalf("selected = %#v, matchType = %q", selected, matchType) + } +} + +func TestCrossPlatformCoverageResolveChatPagesBeforeApplyingExactPreference(t *testing.T) { + reader := &chatResolutionReader{responses: []map[string]any{ + { + "result": []any{ + map[string]any{"openConversationId": "archive", "title": "项目群-归档"}, + }, + "hasMore": true, + "nextCursor": "page-2", + }, + { + "result": []any{ + map[string]any{"openConversationId": "active", "title": "项目群"}, + }, + "hasMore": false, + }, + }} + resolved, err := ResolveChat(reader, "项目群") + if err != nil { + t.Fatal(err) + } + if resolved.Selected.OpenConversationID != "active" || resolved.MatchType != "exact" { + t.Fatalf("resolved = %#v", resolved) + } + wantCursors := []any{"0", "page-2"} + gotCursors := []any{reader.calls[0]["cursor"], reader.calls[1]["cursor"]} + if !reflect.DeepEqual(gotCursors, wantCursors) { + t.Fatalf("cursors = %#v, want %#v", gotCursors, wantCursors) + } +} + +func TestCrossPlatformCoverageResolveChatKeepsExactNamesakesAcrossPagesAmbiguous(t *testing.T) { + reader := &chatResolutionReader{responses: []map[string]any{ + { + "result": []any{ + map[string]any{"openConversationId": "c1", "title": "项目群"}, + }, + "hasMore": true, + "nextCursor": "page-2", + }, + { + "result": map[string]any{ + "items": []any{ + map[string]any{"openConversationId": "c2", "title": "项目群"}, + }, + "hasMore": false, + }, + }, + }} + _, err := ResolveChat(reader, "项目群") + var typed *apperrors.Error + if !stderrors.As(err, &typed) || typed.Reason != "resolution_ambiguous" { + t.Fatalf("error = %#v", err) + } + candidates, ok := typed.Details["candidates"].([]Chat) + if !ok || len(candidates) != 2 { + t.Fatalf("details = %#v", typed.Details) + } +} + +func TestCrossPlatformCoverageResolveChatFailsClosedWhenPaginationCannotAdvance(t *testing.T) { + for _, tc := range []struct { + name string + nextCursor any + wantFragment string + }{ + {name: "missing cursor", wantFragment: "没有返回可继续"}, + {name: "stalled cursor", nextCursor: "0", wantFragment: "游标停滞"}, + } { + t.Run(tc.name, func(t *testing.T) { + response := map[string]any{ + "result": []any{ + map[string]any{"openConversationId": "c1", "title": "项目群"}, + }, + "hasMore": true, + } + if tc.nextCursor != nil { + response["nextCursor"] = tc.nextCursor + } + reader := &chatResolutionReader{responses: []map[string]any{response}} + _, err := ResolveChat(reader, "项目群") + var typed *apperrors.Error + if !stderrors.As(err, &typed) || typed.Reason != "resolution_incomplete" || !typed.Retryable { + t.Fatalf("error = %#v", err) + } + if typed.Details["subtype"] != StatusIncomplete { + t.Fatalf("details = %#v", typed.Details) + } + if typed.Origin != "mcp_gateway" || typed.FailureStage != "target_resolution" || typed.ExecutionStarted == nil || *typed.ExecutionStarted { + t.Fatalf("failure semantics = origin %q stage %q execution_started %v", typed.Origin, typed.FailureStage, typed.ExecutionStarted) + } + cause, _ := typed.Details["cause"].(string) + if cause == "" || !strings.Contains(cause, tc.wantFragment) { + t.Fatalf("cause = %q, want fragment %q", cause, tc.wantFragment) + } + }) + } +} + +func TestCrossPlatformCoverageResolveChatAcceptsShortLegacyPageWithoutPaginationMetadata(t *testing.T) { + reader := &chatResolutionReader{responses: []map[string]any{{ + "result": []any{ + map[string]any{"openConversationId": "c1", "title": "项目群"}, + }, + }}} + resolved, err := ResolveChat(reader, "项目群") + if err != nil || resolved.Selected.OpenConversationID != "c1" { + t.Fatalf("resolved = %#v, err = %v", resolved, err) + } +} + +func TestCrossPlatformCoverageResolutionErrorCarriesStructuredCandidates(t *testing.T) { + err := newResolutionError(StatusAmbiguous, "chat", "项目群", []Chat{ + {OpenConversationID: "c1", Name: "项目群"}, + {OpenConversationID: "c2", Name: "项目群"}, + }) + var typed *apperrors.Error + if !stderrors.As(err, &typed) { + t.Fatalf("error type = %T", err) + } + if typed.Reason != "resolution_ambiguous" { + t.Fatalf("reason = %q", typed.Reason) + } + if typed.Origin != "client" || typed.FailureStage != "target_resolution" || typed.ExecutionStarted == nil || *typed.ExecutionStarted { + t.Fatalf("failure semantics = origin %q stage %q execution_started %v", typed.Origin, typed.FailureStage, typed.ExecutionStarted) + } + if typed.Details["type"] != "resolution" || typed.Details["subtype"] != StatusAmbiguous { + t.Fatalf("details = %#v", typed.Details) + } + candidates, ok := typed.Details["candidates"].([]Chat) + if !ok || len(candidates) != 2 { + t.Fatalf("candidates = %#v", typed.Details["candidates"]) + } +} + +func TestCrossPlatformCoverageResolverCompletionBranches(t *testing.T) { + t.Run("user transport and selection", func(t *testing.T) { + upstream := stderrors.New("upstream") + if _, err := ResolveUser(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) { + return nil, upstream + }), "张三", IdentityAny); !stderrors.Is(err, upstream) { + t.Fatalf("transport error = %v", err) + } + + reader := resolverReaderFunc(func(_ string, _ string, params map[string]any) (map[string]any, error) { + switch params["keyword"] { + case "missing": + return map[string]any{"result": []any{}}, nil + case "external": + return map[string]any{"result": []any{map[string]any{"openDingTalkId": "D1", "name": "外部"}}}, nil + default: + return map[string]any{"result": []any{map[string]any{"userId": "u1", "openDingTalkId": "D1", "name": "张三"}}}, nil + } + }) + if _, err := ResolveUser(reader, "missing", IdentityAny); err == nil { + t.Fatal("missing user unexpectedly resolved") + } + if resolved, err := ResolveUser(reader, "张三", IdentityUserID); err != nil || resolved.MatchType != "exact" { + t.Fatalf("exact user = %#v, %v", resolved, err) + } + if resolved, err := ResolveUser(reader, "external", IdentityOpenDingTalkID); err != nil || resolved.Selected.OpenDingTalkID != "D1" { + t.Fatalf("external user = %#v, %v", resolved, err) + } + ambiguous := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) { + return map[string]any{"result": []any{ + map[string]any{"userId": "u1", "name": "甲"}, + map[string]any{"userId": "u2", "name": "乙"}, + }}, nil + }) + if _, err := ResolveUser(ambiguous, "用户", IdentityAny); err == nil { + t.Fatal("ambiguous user unexpectedly resolved") + } + if _, err := ResolveEnterpriseUser(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) { + return nil, upstream + }), "张三", IdentityAny); !stderrors.Is(err, upstream) { + t.Fatalf("enterprise transport error = %v", err) + } + }) + + t.Run("chat transport metadata and page limit", func(t *testing.T) { + upstream := stderrors.New("upstream") + if _, err := ResolveChat(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) { + return nil, upstream + }), "群"); !stderrors.Is(err, upstream) { + t.Fatalf("transport error = %v", err) + } + + fullPage := make([]any, chatResolutionPageSize) + for i := range fullPage { + fullPage[i] = map[string]any{"openConversationId": fmt.Sprintf("c%d", i), "title": fmt.Sprintf("群%d", i)} + } + if _, err := ResolveChat(&chatResolutionReader{responses: []map[string]any{{"result": fullPage}}}, "群"); err == nil { + t.Fatal("full page without continuation unexpectedly resolved") + } + + pages := 0 + limitReader := resolverReaderFunc(func(_ string, _ string, _ map[string]any) (map[string]any, error) { + pages++ + return map[string]any{ + "result": []any{map[string]any{"openConversationId": fmt.Sprintf("c%d", pages), "title": "群"}}, + "hasMore": true, + "nextCursor": fmt.Sprintf("page-%d", pages), + }, nil + }) + if _, err := ResolveChat(limitReader, "群"); err == nil || pages != chatResolutionPageLimit { + t.Fatalf("page limit error = %v, pages=%d", err, pages) + } + }) + + t.Run("target and pagination validation", func(t *testing.T) { + reader := &chatResolutionReader{} + if _, err := ResolveChatTarget(reader, "cid-123456789", "群"); err == nil { + t.Fatal("conflicting targets unexpectedly resolved") + } + if _, err := ResolveChatTarget(reader, "", ""); err == nil { + t.Fatal("missing target unexpectedly resolved") + } + if page := extractChatPagination(nil); page.hasMoreKnown || page.nextCursor != "" { + t.Fatalf("nil pagination = %#v", page) + } + page := extractChatPagination(map[string]any{"data": map[string]any{"has_more": true, "next_token": 42}}) + if !page.hasMoreKnown || !page.hasMore || page.nextCursor != "42" { + t.Fatalf("nested pagination = %#v", page) + } + for _, value := range []any{nil, "", ""} { + if got := paginationString(value); got != "" { + t.Fatalf("paginationString(%#v) = %q", value, got) + } + } + }) + + t.Run("batch resolution", func(t *testing.T) { + reader := resolverReaderFunc(func(product, _ string, params map[string]any) (map[string]any, error) { + query := fmt.Sprint(params["keyword"]) + if query == "upstream" { + return nil, stderrors.New("upstream") + } + if product == "contact" { + if query == "missing" { + return map[string]any{"result": []any{}}, nil + } + return map[string]any{"result": []any{map[string]any{"userId": "u1", "name": query}}}, nil + } + if query == "missing" { + return map[string]any{"result": []any{}, "hasMore": false}, nil + } + return map[string]any{"result": []any{map[string]any{"openConversationId": "cid-shared-123", "title": query}}, "hasMore": false}, nil + }) + if users, err := ResolveUsers(reader, []string{"张三", " 张三 "}, IdentityUserID); err != nil || len(users) != 1 { + t.Fatalf("deduped users = %#v, %v", users, err) + } + if _, err := ResolveUsers(reader, []string{"missing"}, IdentityUserID); err == nil { + t.Fatal("batch missing user unexpectedly resolved") + } + if _, err := ResolveUsers(reader, []string{"upstream"}, IdentityUserID); err == nil { + t.Fatal("batch user transport error missing") + } + if chats, err := ResolveChats(reader, []string{"群一", "群二"}); err != nil || len(chats) != 1 { + t.Fatalf("deduped chats = %#v, %v", chats, err) + } + if _, err := ResolveChats(reader, []string{"missing"}); err == nil { + t.Fatal("batch missing chat unexpectedly resolved") + } + if _, err := ResolveChats(reader, []string{"upstream"}); err == nil { + t.Fatal("batch chat transport error missing") + } + + assertIncomplete := func(label string, err error) { + t.Helper() + var typed *apperrors.Error + if !stderrors.As(err, &typed) || typed.Category != apperrors.CategoryAPI || + typed.Reason != "resolution_incomplete" || !typed.Retryable { + t.Fatalf("%s error = %#v", label, err) + } + } + fullUsers := make([]any, userSearchDefaultResultLimit) + for i := range fullUsers { + fullUsers[i] = map[string]any{"userId": fmt.Sprintf("u%d", i), "name": "张三"} + } + _, err := ResolveUsers(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) { + return map[string]any{"result": fullUsers}, nil + }), []string{"张三", "李四"}, IdentityUserID) + assertIncomplete("batch user", err) + + fullChats := make([]any, chatResolutionPageSize) + for i := range fullChats { + fullChats[i] = map[string]any{ + "openConversationId": fmt.Sprintf("cid-%d", i), + "title": "项目群", + } + } + _, err = ResolveChats(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) { + return map[string]any{"result": fullChats}, nil + }), []string{"项目群", "研发群"}) + assertIncomplete("batch chat", err) + }) + + t.Run("public projections and internal helpers", func(t *testing.T) { + users := ExtractUsers(map[string]any{"items": []any{"bad", map[string]any{"name": "missing"}, map[string]any{"orgUserId": "u1", "staffName": "张三"}}}) + if got := UsersWithUserID(users); len(got) != 1 { + t.Fatalf("users = %#v", got) + } + chats := ExtractChats(map[string]any{"groups": []any{"bad", map[string]any{"title": "missing"}, map[string]any{"id": "c1"}}}) + if len(chats) != 1 || len(PreferExactChats(chats, "none")) != 1 { + t.Fatalf("chats = %#v", chats) + } + if labels := UserLabels([]User{{OpenDingTalkID: "D1", Name: "外部"}}); !reflect.DeepEqual(labels, []string{"外部(D1)"}) { + t.Fatalf("user labels = %#v", labels) + } + if labels := ChatLabels([]Chat{{OpenConversationID: "c1"}}); !strings.Contains(labels[0], "未命名") { + t.Fatalf("chat labels = %#v", labels) + } + if _, ok := resolutionDetails(stderrors.New("plain")); ok { + t.Fatal("plain error reported resolution details") + } + if label := candidateLabels(1); label != "候选" { + t.Fatalf("candidate label = %q", label) + } + if label := candidateLabels([]User{{UserID: "u1", Name: "甲"}}); label != "甲(u1)" { + t.Fatalf("user candidate label = %q", label) + } + if firstList(nil, "result") != nil { + t.Fatal("nil firstList should return nil") + } + if firstList(map[string]any{"result": "not-a-list"}, "result") != nil { + t.Fatal("missing firstList should return nil") + } + if got := filterUsersByIdentity([]User{{OpenDingTalkID: "D1"}}, IdentityUserID); len(got) != 0 { + t.Fatalf("user-id filter = %#v", got) + } + if got := filterUsersByIdentity([]User{{UserID: "u1"}}, IdentityOpenDingTalkID); len(got) != 0 { + t.Fatalf("open-id filter = %#v", got) + } + + profilectx.Set("fixture") + t.Cleanup(func() { profilectx.Set("") }) + for _, err := range []error{ + newResolutionError(StatusNotFound, "user", "missing", nil), + newIncompleteChatResolutionError("群", nil, "fixture"), + newIncompleteUserResolutionError("用户", nil, "fixture"), + } { + var typed *apperrors.Error + if !stderrors.As(err, &typed) || typed.Details["profile"] != "fixture" { + t.Fatalf("profile details = %#v", err) + } + } + }) + + t.Run("batch deduplicates shared open ids", func(t *testing.T) { + reader := resolverReaderFunc(func(_ string, _ string, params map[string]any) (map[string]any, error) { + query := fmt.Sprint(params["keyword"]) + return map[string]any{"result": []any{map[string]any{ + "userId": "user-" + query, + "openDingTalkId": "D-shared", + "name": query, + }}}, nil + }) + resolved, err := ResolveUsers(reader, []string{"甲", "乙"}, IdentityAny) + if err != nil || len(resolved) != 1 { + t.Fatalf("shared open-id result = %#v, %v", resolved, err) + } + }) +} diff --git a/internal/shortcut/types.go b/internal/shortcut/types.go index 899904d0..9f7c9be5 100644 --- a/internal/shortcut/types.go +++ b/internal/shortcut/types.go @@ -81,6 +81,8 @@ const ( type Flag struct { // Name is the long flag name (kebab-case), e.g. "user-ids". Name string `json:"name"` + // Shorthand is the optional one-character CLI spelling, e.g. "o" for --output. + Shorthand string `json:"shorthand,omitempty"` // Type is the value type; defaults to FlagString when empty. Type FlagType `json:"type"` // Default is the default value rendered as a string. @@ -135,6 +137,16 @@ type Shortcut struct { Service string // Command is the leaf name including its "+" prefix, e.g. "+search-user". Command string + // Aliases are hidden, compatibility-only Cobra spellings for the same + // command identity. Agent-facing Skill and Schema examples must continue to + // use Command; reviewed Schema aliases are declared separately in the + // CommandRegistry. + Aliases []string + // SinglePositionalAliasFor optionally treats one positional argument as the + // value of the named string flag. It is intended only for unambiguous search + // compatibility such as `+chat-search "群名"`; it never changes the + // canonical flag-based contract published to Agents. + SinglePositionalAliasFor string // Product is the canonical MCP product id used to build the invocation. // Defaults to Service when empty. Product string diff --git a/scripts/gen_skill_shortcut_sections.py b/scripts/gen_skill_shortcut_sections.py index f352432b..3a57491f 100644 --- a/scripts/gen_skill_shortcut_sections.py +++ b/scripts/gen_skill_shortcut_sections.py @@ -24,7 +24,10 @@ import gen_shortcut_comparison as shortcut_source # noqa: E402 CATALOG_PATH = ROOT / "docs" / "shortcut-public-catalog.json" MONO_SKILL = ROOT / "skills" / "mono" / "SKILL.md" - +SHARED_SKILL = ROOT / "skills" / "multi" / "dws-shared" / "SKILL.md" +RUNTIME_CONTRACT_SOURCE = ( + ROOT / "skills" / "multi" / "dws-shared" / "references" / "runtime-contract.md" +) SERVICE_TO_SKILL = { "aitable": ROOT / "skills" / "multi" / "dingtalk-aitable" / "SKILL.md", "attendance": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "attendance.md", @@ -48,6 +51,8 @@ MONO_START = "" MONO_END = "" PRODUCT_START = "" PRODUCT_END = "" +RUNTIME_CONTRACT_START = "" +RUNTIME_CONTRACT_END = "" # Large, high-frequency product skills should route known intents directly and # keep their full shortcut inventory in Runtime Catalog/Schema. Add services @@ -92,6 +97,29 @@ def replace_block(text: str, start: str, end: str, block: str, fallback_anchor: return text.replace(fallback_anchor, block + "\n\n" + fallback_anchor, 1) +def replace_required_block(text: str, start: str, end: str, block: str) -> str: + if text.count(start) != 1 or text.count(end) != 1: + raise RuntimeError( + f"expected exactly one generated block {start!r} ... {end!r}" + ) + before = text.split(start, 1)[0] + after = text.split(end, 1)[1] + return before + block + after + + +def runtime_contract_block() -> str: + contract = RUNTIME_CONTRACT_SOURCE.read_text(encoding="utf-8").strip() + if not contract.startswith("## 最小 DWS 执行契约"): + raise RuntimeError( + f"runtime contract must start with its canonical heading: {RUNTIME_CONTRACT_SOURCE}" + ) + return ( + f"{RUNTIME_CONTRACT_START}\n" + f"{contract}\n" + f"{RUNTIME_CONTRACT_END}" + ) + + def mono_overview(items: list[dict[str, Any]]) -> str: counts = Counter(item["service"] for item in items) rows = [] @@ -161,6 +189,26 @@ def update_mono(items: list[dict[str, Any]], check: bool) -> list[Path]: return [MONO_SKILL] if apply_update(MONO_SKILL, text, updated, check) else [] +def update_runtime_contract(check: bool) -> list[Path]: + block = runtime_contract_block() + changed = [] + targets = [ + ROOT / "skills" / "multi" / "dingtalk-chat" / "SKILL.md", + SHARED_SKILL, + ] + for path in targets: + text = path.read_text(encoding="utf-8") + updated = replace_required_block( + text, + RUNTIME_CONTRACT_START, + RUNTIME_CONTRACT_END, + block, + ) + if apply_update(path, text, updated, check): + changed.append(path) + return changed + + def update_product_skills(items: list[dict[str, Any]], check: bool) -> list[Path]: by_service: dict[str, list[dict[str, Any]]] = defaultdict(list) for item in items: @@ -190,7 +238,8 @@ def main() -> int: args = parser.parse_args() items = collect_visible() - changed = update_mono(items, args.check) + changed = update_runtime_contract(args.check) + changed.extend(update_mono(items, args.check)) changed.extend(update_product_skills(items, args.check)) if args.check and changed: print("run: python3 scripts/gen_skill_shortcut_sections.py", file=sys.stderr) diff --git a/scripts/policy/check-multi-im-skill-chain.sh b/scripts/policy/check-multi-im-skill-chain.sh new file mode 100755 index 00000000..29a023ac --- /dev/null +++ b/scripts/policy/check-multi-im-skill-chain.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -eu + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +cd "$ROOT" +. "$ROOT/scripts/policy/policy-runtime.sh" +policy_prepare_runtime "$ROOT" + +TMP_ROOT="$(policy_runtime_mktemp_dir dws-multi-im-skill-chain)" +CHECKER="$TMP_ROOT/multi-im-skill-chain" +CHECK_HOME="$TMP_ROOT/home" +mkdir -p "$CHECK_HOME" +trap 'rm -rf "$TMP_ROOT"' EXIT HUP INT TERM + +go build -o "$CHECKER" ./scripts/policy/multi-im-skill-chain +HOME="$CHECK_HOME" DWS_LANG=zh "$CHECKER" diff --git a/scripts/policy/check-skill-context-budget.sh b/scripts/policy/check-skill-context-budget.sh index 788eaea7..d840c327 100755 --- a/scripts/policy/check-skill-context-budget.sh +++ b/scripts/policy/check-skill-context-budget.sh @@ -7,8 +7,12 @@ cd "$ROOT" python3 scripts/gen_skill_shortcut_sections.py --check chat_skill="skills/multi/dingtalk-chat/SKILL.md" +event_skill="skills/multi/dingtalk-event/SKILL.md" mono_skill="skills/mono/SKILL.md" -chat_max_bytes=14000 +runtime_contract="skills/multi/dws-shared/references/runtime-contract.md" +chat_max_bytes=10000 +event_max_bytes=10000 +runtime_contract_max_bytes=3000 chat_bytes="$(wc -c < "$chat_skill" | tr -d ' ')" if [ "$chat_bytes" -gt "$chat_max_bytes" ]; then @@ -17,6 +21,20 @@ if [ "$chat_bytes" -gt "$chat_max_bytes" ]; then exit 1 fi +event_bytes="$(wc -c < "$event_skill" | tr -d ' ')" +if [ "$event_bytes" -gt "$event_max_bytes" ]; then + printf '%s\n' \ + "skill context budget exceeded: $event_skill is ${event_bytes} bytes (max ${event_max_bytes})" >&2 + exit 1 +fi + +runtime_contract_bytes="$(wc -c < "$runtime_contract" | tr -d ' ')" +if [ "$runtime_contract_bytes" -gt "$runtime_contract_max_bytes" ]; then + printf '%s\n' \ + "skill context budget exceeded: $runtime_contract is ${runtime_contract_bytes} bytes (max ${runtime_contract_max_bytes})" >&2 + exit 1 +fi + shortcut_rows="$( awk ' // { in_block = 1; next } @@ -31,6 +49,56 @@ if [ "$shortcut_rows" -ne 0 ]; then exit 1 fi +for required_heading in \ + "## 最小 DWS 执行契约" \ + "## Golden Route" \ + "## 关键结果语义" \ + "## 按需加载" \ + "## 错误最短路径" +do + if ! grep -Fq "$required_heading" "$chat_skill"; then + printf '%s\n' \ + "skill value regression: $chat_skill is missing $required_heading" >&2 + exit 1 + fi +done + +for required_route in \ + "+dm" \ + "+send-to-group" \ + "+messages-send" \ + "+chat-messages" \ + "+search-msg" \ + "--download-resources" \ + "+conversation-list-top" +do + if ! grep -Fq -- "$required_route" "$chat_skill"; then + printf '%s\n' \ + "skill route regression: $chat_skill is missing $required_route" >&2 + exit 1 + fi +done + +for forbidden_route in \ + "## 标准 SOP" \ + "dws aisearch person --keyword" \ + "dws chat message send-by-webhook" \ + "dt_media_upload" \ + "MUST 先用 Read" +do + if grep -Fq "$forbidden_route" "$chat_skill"; then + printf '%s\n' \ + "skill route regression: $chat_skill restored legacy route: $forbidden_route" >&2 + exit 1 + fi +done + +if grep -Fq "../dws-shared/SKILL.md" "$chat_skill"; then + printf '%s\n' \ + "skill context regression: $chat_skill requires full dws-shared cold-start loading" >&2 + exit 1 +fi + if grep -Fq "充分阅读产品参考文件" "$mono_skill"; then printf '%s\n' \ "skill context budget regression: $mono_skill requires full product-reference loading" >&2 @@ -38,4 +106,4 @@ if grep -Fq "充分阅读产品参考文件" "$mono_skill"; then fi printf '%s\n' \ - "skill context budget: ok (chat_bytes=$chat_bytes max=$chat_max_bytes shortcut_rows=$shortcut_rows)" + "skill context budget: ok (chat_bytes=$chat_bytes max=$chat_max_bytes event_bytes=$event_bytes event_max=$event_max_bytes runtime_contract_bytes=$runtime_contract_bytes runtime_contract_max=$runtime_contract_max_bytes shortcut_rows=$shortcut_rows)" diff --git a/scripts/policy/multi-im-skill-chain/main.go b/scripts/policy/multi-im-skill-chain/main.go new file mode 100644 index 00000000..8a3e21e5 --- /dev/null +++ b/scripts/policy/multi-im-skill-chain/main.go @@ -0,0 +1,495 @@ +// 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. +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" + "github.com/spf13/cobra" +) + +const manifestRelativePath = "scripts/policy/multi-im-skill-chain/testdata/intent_routes.json" + +var ( + intentIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`) + reasonCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) + intentMarker = regexp.MustCompile(``) + mainGetwd = os.Getwd + mainExit = os.Exit + markerOpen = os.Open + buildEffective = cli.BuildEffectiveCommandRegistry + bindEffective = cli.BindEffectiveCommandRegistry +) + +type routeManifest struct { + Version int `json:"version"` + MarkerRoots []string `json:"marker_roots"` + Intents []intentRoute `json:"intents"` + RetiredScripts []string `json:"retired_scripts"` + ContractReference string `json:"contract_reference"` + HandoffReference string `json:"handoff_reference"` +} + +type intentRoute struct { + ID string `json:"id"` + PreferredTool string `json:"preferred_tool"` + AllowedFallbacks []routeFallback `json:"allowed_fallbacks,omitempty"` + ForbiddenDefaultTools []string `json:"forbidden_default_tools,omitempty"` + References []string `json:"references"` +} + +type routeFallback struct { + Tool string `json:"tool"` + ReasonCode string `json:"reason_code"` +} + +type toolFact struct { + Canonical string + PrimaryPath string + Confirmation string + UseWhen []string + AvoidWhen []string + HasMeta bool +} + +func main() { + rootPath, err := mainGetwd() + if err != nil { + fmt.Fprintln(os.Stderr, err) + mainExit(2) + return + } + mainExit(run(rootPath, app.NewRootCommand(), os.Stdout, os.Stderr)) +} + +func run(rootPath string, root *cobra.Command, stdout, stderr io.Writer) int { + manifest, err := loadManifest(filepath.Join(rootPath, manifestRelativePath)) + if err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + effective, err := buildEffective(root) + if err != nil { + fmt.Fprintf(stderr, "build effective CommandRegistry: %v\n", err) + return 2 + } + bound, err := bindEffective(root, effective) + if err != nil { + fmt.Fprintf(stderr, "bind effective CommandRegistry: %v\n", err) + return 2 + } + + tools := make(map[string]toolFact, len(bound.ByCanonical)) + for canonical, item := range bound.ByCanonical { + meta, ok := cli.ResolveMeta(item.PrimaryCLIPath) + tools[canonical] = toolFact{ + Canonical: canonical, + PrimaryPath: item.PrimaryCLIPath, + Confirmation: meta.Safety.Confirmation, + UseWhen: append([]string(nil), meta.Selection.UseWhen...), + AvoidWhen: append([]string(nil), meta.Selection.AvoidWhen...), + HasMeta: ok, + } + } + + failures := validateManifest(rootPath, manifest, tools) + sort.Strings(failures) + if len(failures) > 0 { + fmt.Fprintf(stderr, "multi IM Skill chain check failed (%d problems):\n", len(failures)) + for _, failure := range failures { + fmt.Fprintf(stderr, " - %s\n", failure) + } + return 1 + } + fmt.Fprintf(stdout, "multi IM Skill chain check: ok (%d intents)\n", len(manifest.Intents)) + return 0 +} + +func loadManifest(path string) (routeManifest, error) { + data, err := os.ReadFile(path) + if err != nil { + return routeManifest{}, fmt.Errorf("read IM intent route manifest: %w", err) + } + decoder := json.NewDecoder(strings.NewReader(string(data))) + decoder.DisallowUnknownFields() + var manifest routeManifest + if err := decoder.Decode(&manifest); err != nil { + return routeManifest{}, fmt.Errorf("decode IM intent route manifest: %w", err) + } + return manifest, nil +} + +func validateManifest(rootPath string, manifest routeManifest, tools map[string]toolFact) []string { + var failures []string + if manifest.Version != 3 { + failures = append(failures, fmt.Sprintf("manifest version = %d, want 3", manifest.Version)) + } + if len(manifest.MarkerRoots) == 0 { + failures = append(failures, "manifest marker_roots must not be empty") + } + if len(manifest.Intents) == 0 { + failures = append(failures, "manifest intents must not be empty") + } + failures = append(failures, validateRetiredScripts(rootPath, manifest.RetiredScripts)...) + failures = append(failures, validateTypedContractReference(rootPath, manifest.ContractReference)...) + failures = append(failures, validateEventHandoffReference(rootPath, manifest.HandoffReference)...) + + intentByID := make(map[string]intentRoute, len(manifest.Intents)) + for _, route := range manifest.Intents { + if !intentIDPattern.MatchString(route.ID) { + failures = append(failures, fmt.Sprintf("intent id %q is invalid", route.ID)) + continue + } + if _, exists := intentByID[route.ID]; exists { + failures = append(failures, fmt.Sprintf("duplicate intent id %q", route.ID)) + continue + } + intentByID[route.ID] = route + preferred, ok := tools[route.PreferredTool] + if !ok { + failures = append(failures, fmt.Sprintf("intent %s preferred tool %q is absent from BoundCommandRegistry", route.ID, route.PreferredTool)) + continue + } + if !preferred.HasMeta || preferred.Confirmation == "" { + failures = append(failures, fmt.Sprintf("intent %s preferred tool %q is absent from ResolveMeta delivery", route.ID, route.PreferredTool)) + } + if len(preferred.UseWhen) == 0 || len(preferred.AvoidWhen) == 0 { + failures = append(failures, fmt.Sprintf("intent %s preferred tool %q needs non-empty use_when and avoid_when", route.ID, route.PreferredTool)) + } + + seenFallbacks := map[string]bool{} + for _, fallback := range route.AllowedFallbacks { + if fallback.Tool == "" || seenFallbacks[fallback.Tool] { + failures = append(failures, fmt.Sprintf("intent %s has empty or duplicate allowed fallback %q", route.ID, fallback.Tool)) + continue + } + seenFallbacks[fallback.Tool] = true + if !reasonCodePattern.MatchString(fallback.ReasonCode) { + failures = append(failures, fmt.Sprintf("intent %s fallback %q has invalid reason_code %q", route.ID, fallback.Tool, fallback.ReasonCode)) + } + fact, exists := tools[fallback.Tool] + if !exists { + failures = append(failures, fmt.Sprintf("intent %s fallback tool %q is absent from BoundCommandRegistry", route.ID, fallback.Tool)) + continue + } + if !fact.HasMeta || fact.Confirmation == "" { + failures = append(failures, fmt.Sprintf("intent %s fallback tool %q is absent from ResolveMeta delivery", route.ID, fallback.Tool)) + } else if fact.Confirmation != preferred.Confirmation { + failures = append(failures, fmt.Sprintf("intent %s fallback %q confirmation %q differs from preferred %q confirmation %q", route.ID, fallback.Tool, fact.Confirmation, route.PreferredTool, preferred.Confirmation)) + } + } + + seenForbidden := map[string]bool{} + for _, canonical := range route.ForbiddenDefaultTools { + if canonical == "" || seenForbidden[canonical] { + failures = append(failures, fmt.Sprintf("intent %s has empty or duplicate forbidden default %q", route.ID, canonical)) + continue + } + seenForbidden[canonical] = true + entry, exists := tools[canonical] + if !exists { + failures = append(failures, fmt.Sprintf("intent %s forbidden default tool %q is absent from BoundCommandRegistry", route.ID, canonical)) + continue + } + if !entry.HasMeta || entry.Confirmation == "" { + failures = append(failures, fmt.Sprintf("intent %s forbidden default tool %q is absent from ResolveMeta delivery", route.ID, canonical)) + } + if len(entry.UseWhen) == 0 || len(entry.AvoidWhen) == 0 { + failures = append(failures, fmt.Sprintf("intent %s forbidden default tool %q needs non-empty use_when and avoid_when", route.ID, canonical)) + } + } + + seenReferences := map[string]bool{} + for _, reference := range route.References { + if !safeRepositoryPath(reference) || filepath.Ext(reference) != ".md" { + failures = append(failures, fmt.Sprintf("intent %s has invalid reference %q", route.ID, reference)) + continue + } + if seenReferences[reference] { + failures = append(failures, fmt.Sprintf("intent %s repeats reference %q", route.ID, reference)) + continue + } + seenReferences[reference] = true + if _, err := os.Stat(filepath.Join(rootPath, filepath.FromSlash(reference))); err != nil { + failures = append(failures, fmt.Sprintf("intent %s reference %q does not exist", route.ID, reference)) + } + } + } + + markerFailures, markers := scanMarkers(rootPath, manifest.MarkerRoots, intentByID, tools) + failures = append(failures, markerFailures...) + for _, route := range manifest.Intents { + for _, reference := range route.References { + key := route.ID + "\x00" + filepath.ToSlash(reference) + switch markers[key] { + case 0: + failures = append(failures, fmt.Sprintf("intent %s reference %s is missing its dws-intent marker", route.ID, reference)) + case 1: + default: + failures = append(failures, fmt.Sprintf("intent %s reference %s has %d dws-intent markers, want 1", route.ID, reference, markers[key])) + } + } + } + return failures +} + +func validateRetiredScripts(rootPath string, paths []string) []string { + var failures []string + if len(paths) == 0 { + return []string{"manifest retired_scripts must not be empty"} + } + seen := map[string]bool{} + for _, path := range paths { + if !safeRepositoryPath(path) || filepath.Ext(path) != ".py" { + failures = append(failures, fmt.Sprintf("invalid retired script path %q", path)) + continue + } + if seen[path] { + failures = append(failures, fmt.Sprintf("duplicate retired script path %q", path)) + continue + } + seen[path] = true + if _, err := os.Stat(filepath.Join(rootPath, filepath.FromSlash(path))); err == nil { + failures = append(failures, fmt.Sprintf("retired script %s was republished", path)) + } else if !os.IsNotExist(err) { + failures = append(failures, fmt.Sprintf("inspect retired script %s: %v", path, err)) + } + } + return failures +} + +func validateTypedContractReference(rootPath, relative string) []string { + if !safeRepositoryPath(relative) || filepath.Ext(relative) != ".md" { + return []string{fmt.Sprintf("invalid contract_reference %q", relative)} + } + data, err := os.ReadFile(filepath.Join(rootPath, filepath.FromSlash(relative))) + if err != nil { + return []string{fmt.Sprintf("read typed contract reference %s: %v", relative, err)} + } + data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n")) + blocks := []struct { + name string + expected string + }{ + {name: "MESSAGE_RESULT", expected: renderMessageResultContract()}, + {name: "IDENTITY_CAPABILITY", expected: renderIdentityCapabilityContract()}, + {name: "CARD_WORKFLOW", expected: renderCardWorkflowContract()}, + {name: "CAPABILITY_BOUNDARY", expected: renderCapabilityBoundaryContract()}, + } + var failures []string + for _, block := range blocks { + start := "" + end := "" + expected := start + "\n" + block.expected + "\n" + end + if bytes.Count(data, []byte(start)) != 1 || bytes.Count(data, []byte(end)) != 1 { + failures = append(failures, fmt.Sprintf("%s must contain exactly one %s contract marker pair", relative, block.name)) + continue + } + startAt := bytes.Index(data, []byte(start)) + endAt := bytes.Index(data[startAt:], []byte(end)) + if endAt < 0 { + failures = append(failures, fmt.Sprintf("%s has malformed %s contract markers", relative, block.name)) + continue + } + actual := string(data[startAt : startAt+endAt+len(end)]) + if actual != expected { + failures = append(failures, fmt.Sprintf("%s %s contract differs from Runtime typed descriptor", relative, block.name)) + } + } + return failures +} + +func validateEventHandoffReference(rootPath, relative string) []string { + if !safeRepositoryPath(relative) || filepath.Ext(relative) != ".md" { + return []string{fmt.Sprintf("invalid handoff_reference %q", relative)} + } + data, err := os.ReadFile(filepath.Join(rootPath, filepath.FromSlash(relative))) + if err != nil { + return []string{fmt.Sprintf("read event handoff reference %s: %v", relative, err)} + } + data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n")) + const expected = ` +| event field | exact chat target | +|---|---| +| ` + "`conversation_id`" + ` | ` + "`dws chat +messages-send --as user --group `" + ` | +| ` + "`sender_open_dingtalk_id`" + ` | ` + "`dws chat +messages-send --as user --open-dingtalk-id `" + ` | +` + start := bytes.Index(data, []byte("")) + endMarker := "" + if start < 0 || bytes.Count(data, []byte("")) != 1 || + bytes.Count(data, []byte(endMarker)) != 1 { + return []string{fmt.Sprintf("%s must contain exactly one event-to-chat handoff marker pair", relative)} + } + end := bytes.Index(data[start:], []byte(endMarker)) + if end < 0 || string(data[start:start+end+len(endMarker)]) != expected { + return []string{fmt.Sprintf("%s event-to-chat handoff differs from exact stable-ID mapping", relative)} + } + return nil +} + +func renderMessageResultContract() string { + contract := chatmsg.CurrentMessageResultContract() + return fmt.Sprintf("- `version`: `%s`\n- `message_fields`: %s\n- `envelope_fields`: %s", + contract.Version, markdownCodeList(contract.MessageFields), markdownCodeList(contract.EnvelopeFields)) +} + +func renderIdentityCapabilityContract() string { + var rows []string + for _, capability := range chatshortcut.MessageIdentityCapabilities() { + rows = append(rows, fmt.Sprintf("| `%s` | %s | %s | %s | %s | `%t` | `%t` |", + capability.Identity, markdownBreakList(capability.Targets), markdownBreakList(capability.ContentTypes), + markdownBreakList(capability.NaturalTargets), markdownBreakList(capability.MentionTargets), + capability.IdempotencyKeys, capability.BatchLedger)) + } + return "| identity | targets | content types | natural targets | mention targets | idempotency keys | batch ledger |\n" + + "|---|---|---|---|---|---:|---:|\n" + strings.Join(rows, "\n") +} + +func renderCardWorkflowContract() string { + contract := chatshortcut.CurrentCardWorkflowContract() + statuses := make([]string, 0, len(contract.FlowStatuses)) + for _, status := range contract.FlowStatuses { + statuses = append(statuses, fmt.Sprintf("%d=%s", status.Value, status.Name)) + } + return fmt.Sprintf("- `version`: `%s`\n- `targets`: %s\n- `content_types`: %s\n- `flow_statuses`: %s\n- `callback_supported`: `%t`", + contract.Version, markdownCodeList(contract.Targets), markdownCodeList(contract.ContentTypes), + markdownCodeList(statuses), contract.CallbackSupported) +} + +func renderCapabilityBoundaryContract() string { + rows := make([]string, 0) + for _, boundary := range chatshortcut.CurrentIMCapabilityBoundaries() { + rows = append(rows, fmt.Sprintf("| `%s` | `%t` | %s |", boundary.Capability, boundary.Supported, boundary.Alternative)) + } + return "| capability | supported | current route / boundary |\n|---|---:|---|\n" + strings.Join(rows, "\n") +} + +func markdownCodeList(values []string) string { + if len(values) == 0 { + return "—" + } + quoted := make([]string, len(values)) + for index, value := range values { + quoted[index] = "`" + value + "`" + } + return strings.Join(quoted, ", ") +} + +func markdownBreakList(values []string) string { + if len(values) == 0 { + return "—" + } + quoted := make([]string, len(values)) + for index, value := range values { + quoted[index] = "`" + value + "`" + } + return strings.Join(quoted, "
") +} + +func scanMarkers(rootPath string, roots []string, intents map[string]intentRoute, tools map[string]toolFact) ([]string, map[string]int) { + var failures []string + markers := map[string]int{} + for _, relativeRoot := range roots { + if !safeRepositoryPath(relativeRoot) { + failures = append(failures, fmt.Sprintf("invalid marker root %q", relativeRoot)) + continue + } + absoluteRoot := filepath.Join(rootPath, filepath.FromSlash(relativeRoot)) + err := filepath.WalkDir(absoluteRoot, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || filepath.Ext(path) != ".md" { + return nil + } + file, err := markerOpen(path) + if err != nil { + return err + } + defer file.Close() + relative, _ := filepath.Rel(rootPath, path) + relative = filepath.ToSlash(relative) + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) + lineNumber := 0 + for scanner.Scan() { + lineNumber++ + line := scanner.Text() + for _, match := range intentMarker.FindAllStringSubmatch(line, -1) { + id := match[1] + route, ok := intents[id] + if !ok { + failures = append(failures, fmt.Sprintf("%s:%d uses unknown dws-intent %q", relative, lineNumber, id)) + continue + } + if !stringSliceContains(route.References, relative) { + failures = append(failures, fmt.Sprintf("%s:%d uses undeclared dws-intent %q", relative, lineNumber, id)) + } + markers[id+"\x00"+relative]++ + preferred, exists := tools[route.PreferredTool] + if !exists { + continue + } + if !containsCLIPath(line, preferred.PrimaryPath) { + failures = append(failures, fmt.Sprintf("%s:%d intent %s must contain preferred path `dws %s` on the marker line", relative, lineNumber, id, preferred.PrimaryPath)) + } + for _, canonical := range route.ForbiddenDefaultTools { + if fact, ok := tools[canonical]; ok && containsCLIPath(line, fact.PrimaryPath) { + failures = append(failures, fmt.Sprintf("%s:%d intent %s uses forbidden default `dws %s`", relative, lineNumber, id, fact.PrimaryPath)) + } + } + } + } + return scanner.Err() + }) + if err != nil { + failures = append(failures, fmt.Sprintf("scan marker root %s: %v", relativeRoot, err)) + } + } + return failures, markers +} + +func safeRepositoryPath(path string) bool { + path = filepath.ToSlash(strings.TrimSpace(path)) + return path != "" && path != "." && !filepath.IsAbs(path) && path != ".." && !strings.HasPrefix(path, "../") && !strings.Contains(path, "/../") +} + +func containsCLIPath(line, path string) bool { + needle := "dws " + strings.TrimSpace(path) + index := strings.Index(line, needle) + if index < 0 { + return false + } + end := index + len(needle) + if end == len(line) { + return true + } + next := line[end] + return next == ' ' || next == '`' || next == '<' || next == '|' || next == ',' +} + +func stringSliceContains(values []string, target string) bool { + for _, value := range values { + if filepath.ToSlash(value) == target { + return true + } + } + return false +} diff --git a/scripts/policy/multi-im-skill-chain/main_test.go b/scripts/policy/multi-im-skill-chain/main_test.go new file mode 100644 index 00000000..009219a6 --- /dev/null +++ b/scripts/policy/multi-im-skill-chain/main_test.go @@ -0,0 +1,391 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/spf13/cobra" +) + +func TestCrossPlatformCoverageRunRepositoryAndSetupFailures(t *testing.T) { + root, err := filepath.Abs("../../..") + if err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + if code := run(root, app.NewRootCommand(), &stdout, &stderr); code != 0 { + t.Fatalf("repository policy run = %d, stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stdout.String(), "multi IM Skill chain check: ok") { + t.Fatalf("success output = %q", stdout.String()) + } + + stdout.Reset() + stderr.Reset() + if code := run(t.TempDir(), &cobra.Command{Use: "dws"}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "read IM intent route manifest") { + t.Fatalf("missing-manifest run = %d, stderr=%s", code, stderr.String()) + } + + stdout.Reset() + stderr.Reset() + if code := run(root, &cobra.Command{Use: "dws"}, &stdout, &stderr); code != 1 || !strings.Contains(stderr.String(), "absent from BoundCommandRegistry") { + t.Fatalf("unbound-root run = %d, stderr=%s", code, stderr.String()) + } + + oldBuild, oldBind := buildEffective, bindEffective + t.Cleanup(func() { buildEffective, bindEffective = oldBuild, oldBind }) + buildEffective = func(*cobra.Command) (cli.EffectiveCommandRegistry, error) { + return cli.EffectiveCommandRegistry{}, os.ErrInvalid + } + stdout.Reset() + stderr.Reset() + if code := run(root, app.NewRootCommand(), &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "build effective CommandRegistry") { + t.Fatalf("registry-build-failure run = %d, stderr=%s", code, stderr.String()) + } + buildEffective = oldBuild + + bindEffective = func(*cobra.Command, cli.EffectiveCommandRegistry) (cli.BoundCommandRegistry, error) { + return cli.BoundCommandRegistry{}, os.ErrInvalid + } + stdout.Reset() + stderr.Reset() + if code := run(root, app.NewRootCommand(), &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "bind effective CommandRegistry") { + t.Fatalf("binding-failure run = %d, stderr=%s", code, stderr.String()) + } + bindEffective = oldBind + + failureRoot := t.TempDir() + writeTestFile(t, failureRoot, manifestRelativePath, `{"version":1}`) + stdout.Reset() + stderr.Reset() + if code := run(failureRoot, app.NewRootCommand(), &stdout, &stderr); code != 1 || !strings.Contains(stderr.String(), "multi IM Skill chain check failed") { + t.Fatalf("validation-failure run = %d, stderr=%s", code, stderr.String()) + } +} + +func TestCrossPlatformCoverageMainUsesInjectableProcessBoundaries(t *testing.T) { + oldGetwd, oldExit := mainGetwd, mainExit + t.Cleanup(func() { mainGetwd, mainExit = oldGetwd, oldExit }) + var exitCode int + mainExit = func(code int) { exitCode = code } + mainGetwd = func() (string, error) { return "", os.ErrPermission } + main() + if exitCode != 2 { + t.Fatalf("getwd failure exit = %d", exitCode) + } + root, err := filepath.Abs("../../..") + if err != nil { + t.Fatal(err) + } + mainGetwd = func() (string, error) { return root, nil } + main() + if exitCode != 0 { + t.Fatalf("successful main exit = %d", exitCode) + } +} + +func TestCrossPlatformCoverageValidateManifestAcceptsExactReviewedRoute(t *testing.T) { + 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-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-event/references/event-im-output.md", + Intents: []intentRoute{{ + ID: "chat.send", + PreferredTool: "chat.shortcut_send", + ForbiddenDefaultTools: []string{"chat.atomic_send"}, + References: []string{"skills/multi/dingtalk-chat/SKILL.md"}, + }}, + } + tools := map[string]toolFact{ + "chat.shortcut_send": {Canonical: "chat.shortcut_send", PrimaryPath: "chat +send", Confirmation: "user_required", UseWhen: []string{"普通发送"}, AvoidWhen: []string{"底层字段另选"}, HasMeta: true}, + "chat.atomic_send": {Canonical: "chat.atomic_send", PrimaryPath: "chat message send", Confirmation: "not_required", UseWhen: []string{"底层字段"}, AvoidWhen: []string{"普通发送使用 +send"}, HasMeta: true}, + } + if failures := validateManifest(root, manifest, tools); len(failures) != 0 { + t.Fatalf("failures = %v", failures) + } +} + +func TestCrossPlatformCoverageValidateManifestRejectsWrongMarkerAndSafetyDowngrade(t *testing.T) { + 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-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-event/references/event-im-output.md", + Intents: []intentRoute{{ + ID: "chat.send", + PreferredTool: "chat.shortcut_send", + AllowedFallbacks: []routeFallback{{Tool: "chat.atomic_send", ReasonCode: "raw_field"}}, + ForbiddenDefaultTools: []string{"chat.atomic_send"}, + References: []string{"skills/multi/dingtalk-chat/SKILL.md"}, + }}, + } + tools := map[string]toolFact{ + "chat.shortcut_send": {Canonical: "chat.shortcut_send", PrimaryPath: "chat +send", Confirmation: "user_required", UseWhen: []string{"普通发送"}, AvoidWhen: []string{"底层字段另选"}, HasMeta: true}, + "chat.atomic_send": {Canonical: "chat.atomic_send", PrimaryPath: "chat message send", Confirmation: "not_required", UseWhen: []string{"普通发送"}, HasMeta: true}, + } + failures := strings.Join(validateManifest(root, manifest, tools), "\n") + for _, want := range []string{"confirmation", "needs non-empty use_when and avoid_when", "must contain preferred path", "uses forbidden default"} { + if !strings.Contains(failures, want) { + t.Fatalf("failures = %s, want %q", failures, want) + } + } +} + +func TestCrossPlatformCoverageLoadManifestRejectsUnknownFields(t *testing.T) { + path := filepath.Join(t.TempDir(), "manifest.json") + if err := os.WriteFile(path, []byte(`{"version":1,"unknown":true}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := loadManifest(path); err == nil || !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("error = %v", err) + } +} + +func TestCrossPlatformCoverageValidateRetiredScriptsRejectsRepublishedAndDuplicatePaths(t *testing.T) { + root := t.TempDir() + writeTestFile(t, root, "skills/multi/dingtalk-chat/scripts/export.py", "republished\n") + failures := strings.Join(validateRetiredScripts(root, []string{ + "skills/multi/dingtalk-chat/scripts/export.py", + "skills/multi/dingtalk-chat/scripts/export.py", + "../unsafe.py", + }), "\n") + for _, want := range []string{"was republished", "duplicate retired", "invalid retired"} { + if !strings.Contains(failures, want) { + t.Fatalf("failures = %s, want %q", failures, want) + } + } +} + +func TestCrossPlatformCoverageValidateTypedContractReferenceRejectsDrift(t *testing.T) { + root := t.TempDir() + writeTypedContractReference(t, root, "contracts.md") + path := filepath.Join(root, "contracts.md") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + data = []byte(strings.Replace(string(data), "`im.message-list.v1`", "`drifted`", 1)) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + failures := strings.Join(validateTypedContractReference(root, "contracts.md"), "\n") + if !strings.Contains(failures, "MESSAGE_RESULT contract differs") { + t.Fatalf("failures = %s", failures) + } +} + +func TestCrossPlatformCoverageValidateEventHandoffReferenceRejectsNaturalTargetDrift(t *testing.T) { + root := t.TempDir() + writeEventHandoffReference(t, root, "handoff.md") + path := filepath.Join(root, "handoff.md") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + data = []byte(strings.Replace(string(data), "--group ", "--chat-query ", 1)) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + failures := strings.Join(validateEventHandoffReference(root, "handoff.md"), "\n") + if !strings.Contains(failures, "exact stable-ID mapping") { + t.Fatalf("failures = %s", failures) + } +} + +func TestCrossPlatformCoverageValidateReferencesAcceptCRLF(t *testing.T) { + root := t.TempDir() + for _, tc := range []struct { + path string + write func(*testing.T, string, string) + validate func(string, string) []string + }{ + {path: "contracts.md", write: writeTypedContractReference, validate: validateTypedContractReference}, + {path: "handoff.md", write: writeEventHandoffReference, validate: validateEventHandoffReference}, + } { + tc.write(t, root, tc.path) + path := filepath.Join(root, tc.path) + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + data = bytes.ReplaceAll(data, []byte("\n"), []byte("\r\n")) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + if failures := tc.validate(root, tc.path); len(failures) != 0 { + t.Errorf("%s CRLF failures = %v", tc.path, failures) + } + } +} + +func TestCrossPlatformCoverageManifestFailureMatrix(t *testing.T) { + root := t.TempDir() + writeTypedContractReference(t, root, "contracts.md") + writeEventHandoffReference(t, root, "handoff.md") + writeTestFile(t, root, "refs/declared.md", " missing preferred; dws chat forbidden\n duplicate\n") + writeTestFile(t, root, "refs/markers.md", "\n\n\n") + tools := map[string]toolFact{ + "preferred.empty": {PrimaryPath: "chat preferred", HasMeta: false}, + "fallback.empty": {PrimaryPath: "chat fallback", HasMeta: false}, + "fallback.different": {PrimaryPath: "chat fallback-different", HasMeta: true, Confirmation: "not_required"}, + "forbidden.no-route": {PrimaryPath: "chat forbidden", HasMeta: true, Confirmation: "not_required", AvoidWhen: []string{"keep using lower tool"}}, + "forbidden.no-entry": {PrimaryPath: "chat forbidden-missing"}, + } + manifest := routeManifest{ + Version: 1, + MarkerRoots: []string{"refs", "../unsafe", "missing-root"}, + RetiredScripts: nil, + ContractReference: "contracts.md", + HandoffReference: "handoff.md", + Intents: []intentRoute{ + {ID: "INVALID ID"}, + {ID: "preferred-absent", PreferredTool: "absent"}, + {ID: "preferred-no-selection", PreferredTool: "forbidden.no-entry"}, + { + ID: "declared", PreferredTool: "preferred.empty", + AllowedFallbacks: []routeFallback{ + {}, + {Tool: "fallback.empty", ReasonCode: "BAD-CODE"}, + {Tool: "fallback.empty", ReasonCode: "duplicate"}, + {Tool: "fallback.absent", ReasonCode: "missing"}, + {Tool: "fallback.different", ReasonCode: "raw_field"}, + }, + ForbiddenDefaultTools: []string{"", "forbidden.absent", "forbidden.no-entry", "forbidden.no-route", "forbidden.no-route"}, + References: []string{"../unsafe.md", "refs/wrong.txt", "refs/missing.md", "refs/declared.md", "refs/declared.md"}, + }, + {ID: "declared"}, + }, + } + failures := strings.Join(validateManifest(root, manifest, tools), "\n") + for _, want := range []string{ + "manifest version", "retired_scripts", "intent id", + "preferred tool", "ResolveMeta", "needs non-empty", "allowed fallback", "reason_code", "fallback tool", + "confirmation", "forbidden default", "invalid reference", "does not exist", "repeats reference", + "unknown dws-intent", "undeclared dws-intent", "missing its dws-intent marker", "has 2 dws-intent markers", + "invalid marker root", "scan marker root", "duplicate intent id", + } { + if !strings.Contains(failures, want) { + t.Errorf("failure matrix missing %q:\n%s", want, failures) + } + } + if failures := validateManifest(root, routeManifest{}, nil); len(failures) == 0 { + t.Fatal("empty manifest unexpectedly passed") + } +} + +func TestCrossPlatformCoverageReferenceFailureBranches(t *testing.T) { + root := t.TempDir() + if got := validateTypedContractReference(root, "../unsafe.md"); len(got) == 0 || !strings.Contains(got[0], "invalid contract_reference") { + t.Fatalf("unsafe contract result = %#v", got) + } + if got := validateTypedContractReference(root, "missing.md"); len(got) == 0 || !strings.Contains(got[0], "read typed contract") { + t.Fatalf("missing contract result = %#v", got) + } + writeTestFile(t, root, "malformed.md", "\n") + if got := strings.Join(validateTypedContractReference(root, "malformed.md"), "\n"); !strings.Contains(got, "malformed MESSAGE_RESULT") || !strings.Contains(got, "marker pair") { + t.Fatalf("malformed contract result = %s", got) + } + if got := validateEventHandoffReference(root, "../unsafe.md"); len(got) == 0 || !strings.Contains(got[0], "invalid handoff_reference") { + t.Fatalf("unsafe handoff result = %#v", got) + } + if got := validateEventHandoffReference(root, "missing-handoff.md"); len(got) == 0 || !strings.Contains(got[0], "read event handoff") { + t.Fatalf("missing handoff result = %#v", got) + } + writeTestFile(t, root, "handoff-markers.md", "no markers") + if got := strings.Join(validateEventHandoffReference(root, "handoff-markers.md"), "\n"); !strings.Contains(got, "marker pair") { + t.Fatalf("handoff marker result = %s", got) + } + + if markdownCodeList(nil) != "—" || markdownBreakList(nil) != "—" { + t.Fatal("empty markdown list rendering drifted") + } + tooLong := strings.Repeat("x", 5000) + ".py" + if got := strings.Join(validateRetiredScripts(root, []string{tooLong}), "\n"); !strings.Contains(got, "inspect retired script") { + t.Fatalf("long retired script result = %s", got) + } + for _, path := range []string{"", ".", "..", "../x", "a/../b"} { + if safeRepositoryPath(path) { + t.Errorf("unsafe repository path accepted: %q", path) + } + } + if containsCLIPath("run dws chat send-more", "chat send") || !containsCLIPath("run dws chat send`", "chat send") { + t.Fatal("CLI path boundary check drifted") + } + if stringSliceContains([]string{"a/b"}, "missing") { + t.Fatal("string slice containment false positive") + } +} + +func TestCrossPlatformCoverageMarkerOpenFailure(t *testing.T) { + root := t.TempDir() + writeTestFile(t, root, "refs/marker.md", " dws chat preferred\n") + oldOpen := markerOpen + markerOpen = func(string) (*os.File, error) { return nil, os.ErrPermission } + t.Cleanup(func() { markerOpen = oldOpen }) + failures, _ := scanMarkers(root, []string{"refs"}, map[string]intentRoute{ + "declared": {ID: "declared", PreferredTool: "preferred", References: []string{"refs/marker.md"}}, + }, map[string]toolFact{"preferred": {PrimaryPath: "chat preferred"}}) + if len(failures) == 0 || !strings.Contains(failures[0], "scan marker root") { + t.Fatalf("marker open failures = %#v", failures) + } +} + +func writeTypedContractReference(t *testing.T, root, relative string) { + t.Helper() + var content strings.Builder + for _, block := range []struct { + name string + body string + }{ + {name: "MESSAGE_RESULT", body: renderMessageResultContract()}, + {name: "IDENTITY_CAPABILITY", body: renderIdentityCapabilityContract()}, + {name: "CARD_WORKFLOW", body: renderCardWorkflowContract()}, + {name: "CAPABILITY_BOUNDARY", body: renderCapabilityBoundaryContract()}, + } { + content.WriteString("\n") + content.WriteString(block.body) + content.WriteString("\n\n") + } + writeTestFile(t, root, relative, content.String()) +} + +func writeEventHandoffReference(t *testing.T, root, relative string) { + t.Helper() + writeTestFile(t, root, relative, ` +| event field | exact chat target | +|---|---| +| `+"`conversation_id`"+` | `+"`dws chat +messages-send --as user --group `"+` | +| `+"`sender_open_dingtalk_id`"+` | `+"`dws chat +messages-send --as user --open-dingtalk-id `"+` | + +`) +} + +func writeTestFile(t *testing.T, root, relative, content string) { + t.Helper() + path := filepath.Join(root, filepath.FromSlash(relative)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/scripts/policy/multi-im-skill-chain/testdata/intent_routes.json b/scripts/policy/multi-im-skill-chain/testdata/intent_routes.json new file mode 100644 index 00000000..ca461d5c --- /dev/null +++ b/scripts/policy/multi-im-skill-chain/testdata/intent_routes.json @@ -0,0 +1,156 @@ +{ + "version": 3, + "marker_roots": [ + "skills/multi/dingtalk-chat", + "skills/multi/dingtalk-event" + ], + "retired_scripts": [ + "skills/multi/dingtalk-chat/scripts/_chat_message_export_common.py", + "skills/multi/dingtalk-chat/scripts/chat_export_messages.py", + "skills/multi/dingtalk-chat/scripts/chat_history_with_user.py", + "skills/multi/dingtalk-chat/scripts/bot_broadcast.py", + "skills/mono/scripts/_chat_message_export_common.py", + "skills/mono/scripts/chat_export_messages.py", + "skills/mono/scripts/chat_history_with_user.py", + "skills/mono/scripts/bot_broadcast.py" + ], + "contract_reference": "skills/multi/dingtalk-chat/references/contracts.md", + "handoff_reference": "skills/multi/dingtalk-event/references/event-im-output.md", + "intents": [ + { + "id": "chat.send.dm", + "preferred_tool": "chat.shortcut_dm", + "forbidden_default_tools": [ + "chat.send_personal_message" + ], + "references": [ + "skills/multi/dingtalk-chat/SKILL.md", + "skills/multi/dingtalk-chat/references/01-messaging.md", + "skills/multi/dingtalk-chat/references/chat/chat-message.md" + ] + }, + { + "id": "chat.send.group", + "preferred_tool": "chat.shortcut_send_to_group", + "forbidden_default_tools": [ + "chat.send_personal_message" + ], + "references": [ + "skills/multi/dingtalk-chat/SKILL.md", + "skills/multi/dingtalk-chat/references/01-messaging.md", + "skills/multi/dingtalk-chat/references/chat/chat-message.md" + ] + }, + { + "id": "chat.send.advanced", + "preferred_tool": "chat.shortcut_messages_send", + "forbidden_default_tools": [ + "chat.send_personal_message", + "chat.send_robot_message", + "chat.send_message_by_custom_robot", + "chat.shortcut_messages_send_by_webhook" + ], + "references": [ + "skills/multi/dingtalk-chat/SKILL.md", + "skills/multi/dingtalk-chat/references/01-messaging.md", + "skills/multi/dingtalk-chat/references/chat/chat-message.md", + "skills/multi/dingtalk-chat/references/chat/chat-bot.md" + ] + }, + { + "id": "chat.read.conversation", + "preferred_tool": "chat.shortcut_chat_messages", + "allowed_fallbacks": [ + { + "tool": "chat.list_conversation_message_v2", + "reason_code": "raw_page_control" + } + ], + "forbidden_default_tools": [ + "chat.list_conversation_message_v2" + ], + "references": [ + "skills/multi/dingtalk-chat/SKILL.md", + "skills/multi/dingtalk-chat/references/01-messaging.md", + "skills/multi/dingtalk-chat/references/chat/chat-message.md", + "skills/multi/dingtalk-chat/references/chat/chat-conversation.md" + ] + }, + { + "id": "chat.search.cross-conversation", + "preferred_tool": "chat.shortcut_search_msg", + "allowed_fallbacks": [ + { + "tool": "chat.search_messages", + "reason_code": "raw_filter_control" + } + ], + "forbidden_default_tools": [ + "chat.search_messages", + "chat.search_messages_by_keyword" + ], + "references": [ + "skills/multi/dingtalk-chat/SKILL.md", + "skills/multi/dingtalk-chat/references/chat/chat-message.md" + ] + }, + { + "id": "chat.create.group", + "preferred_tool": "chat.shortcut_chat_create", + "forbidden_default_tools": [ + "chat.create_group_conversation" + ], + "references": [ + "skills/multi/dingtalk-chat/SKILL.md", + "skills/multi/dingtalk-chat/references/01-messaging.md", + "skills/multi/dingtalk-chat/references/chat/chat-group.md" + ] + }, + { + "id": "chat.reply.quote", + "preferred_tool": "chat.shortcut_messages_reply", + "forbidden_default_tools": [ + "chat.reply_personal_message" + ], + "references": [ + "skills/multi/dingtalk-chat/SKILL.md", + "skills/multi/dingtalk-chat/references/01-messaging.md", + "skills/multi/dingtalk-chat/references/chat/chat-message.md" + ] + }, + { + "id": "chat.conversation.list-top", + "preferred_tool": "chat.shortcut_conversation_list_top", + "allowed_fallbacks": [ + { + "tool": "chat.list_top_conversations", + "reason_code": "raw_response_required" + } + ], + "forbidden_default_tools": [ + "chat.list_top_conversations" + ], + "references": [ + "skills/multi/dingtalk-chat/SKILL.md", + "skills/multi/dingtalk-chat/references/chat/chat-conversation.md" + ] + }, + { + "id": "event.listen.im", + "preferred_tool": "event.listen_im", + "allowed_fallbacks": [ + { + "tool": "event.consume", + "reason_code": "advanced_event_control" + } + ], + "forbidden_default_tools": [ + "event.consume" + ], + "references": [ + "skills/multi/dingtalk-event/SKILL.md", + "skills/multi/dingtalk-event/references/event-im.md" + ] + } + ] +} diff --git a/scripts/policy/schema-compat/main.go b/scripts/policy/schema-compat/main.go index 4fa73ba4..84447246 100644 --- a/scripts/policy/schema-compat/main.go +++ b/scripts/policy/schema-compat/main.go @@ -492,7 +492,7 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri } if oldTool.Constraints != newTool.Constraints && !compatibleHiddenSiblingConstraintExpansion(oldTool, newTool) && - !compatibleConstraintMemberExpansion(oldTool.Constraints, newTool.Constraints) { + !compatibleAdditiveConstraintEvolution(oldTool, newTool) { failures = append(failures, fmt.Sprintf("schema tool %q changed constraints", toolPath)) } if !compatiblePositionals(oldTool.Positionals, newTool.Positionals) { @@ -514,6 +514,86 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri return failures } +// compatibleAdditiveConstraintEvolution accepts constraint evolution that +// cannot invalidate an invocation expressible by the historical public +// parameter contract. Existing groups may only gain members; additions to a +// mutually-exclusive or require-together group must not be historical public +// parameters, because that would reject an invocation expressible by the old +// contract. Adding a member to require-one-of only loosens the group. A newly +// added mutually-exclusive group is safe when it contains at most one +// historical public parameter: aliases and newly added parameters could not +// have appeared together in an old invocation. A new require-together group is +// safe only when it contains no historical public parameter. A new +// require-one-of group always adds a requirement and is therefore incompatible. +func compatibleAdditiveConstraintEvolution(oldTool, newTool toolSchema) bool { + oldGroups, okOld := parseConstraintGroups(oldTool.Constraints) + newGroups, okNew := parseConstraintGroups(newTool.Constraints) + if !okOld || !okNew { + return false + } + for _, key := range []string{"mutually_exclusive", "require_one_of", "require_together"} { + used := make([]bool, len(newGroups[key])) + for _, oldGroup := range oldGroups[key] { + oldSet := stringSet(oldGroup) + if len(oldSet) == 0 { + return false + } + matched := false + for index, newGroup := range newGroups[key] { + newSet := stringSet(newGroup) + if used[index] || !stringSetContainsAll(newSet, oldSet) { + continue + } + if key == "mutually_exclusive" || key == "require_together" { + safe := true + for member := range newSet { + if oldSet[member] { + continue + } + if _, historical := oldTool.Parameters[member]; historical { + safe = false + break + } + } + if !safe { + continue + } + } + used[index] = true + matched = true + break + } + if !matched { + return false + } + } + for index, newGroup := range newGroups[key] { + if used[index] { + continue + } + historicalMembers := 0 + for member := range stringSet(newGroup) { + if _, existed := oldTool.Parameters[member]; existed { + historicalMembers++ + } + } + switch key { + case "mutually_exclusive": + if historicalMembers > 1 { + return false + } + case "require_together": + if historicalMembers > 0 { + return false + } + default: // require_one_of + return false + } + } + } + return true +} + // compatibleHiddenSiblingConstraintExpansion allows declare≡execute repairs: // Schema may start projecting full constraint groups that include unpublished // (hidden) execute-side siblings when the previous contract collapsed the sole @@ -574,23 +654,6 @@ func compatibleHiddenSiblingConstraintExpansion(oldTool, newTool toolSchema) boo return true } -// compatibleConstraintMemberExpansion allows declare≡execute / alias-surface -// repairs that only add members to existing constraint groups. Removing a -// member, dropping a group, or adding a new group remains incompatible. -func compatibleConstraintMemberExpansion(oldConstraints, newConstraints string) bool { - oldGroups, okOld := parseConstraintGroups(oldConstraints) - newGroups, okNew := parseConstraintGroups(newConstraints) - if !okOld || !okNew { - return false - } - for _, key := range []string{"mutually_exclusive", "require_one_of", "require_together"} { - if !constraintGroupsAreMemberExpansions(oldGroups[key], newGroups[key]) { - return false - } - } - return true -} - func parseConstraintGroups(raw string) (map[string][][]string, bool) { raw = strings.TrimSpace(raw) if raw == "" { @@ -611,36 +674,6 @@ func parseConstraintGroups(raw string) (map[string][][]string, bool) { }, true } -func constraintGroupsAreMemberExpansions(oldGroups, newGroups [][]string) bool { - if len(oldGroups) != len(newGroups) { - return false - } - used := make([]bool, len(newGroups)) - for _, oldGroup := range oldGroups { - oldSet := stringSet(oldGroup) - if len(oldSet) == 0 { - return false - } - matched := false - for index, newGroup := range newGroups { - if used[index] { - continue - } - newSet := stringSet(newGroup) - if !stringSetContainsAll(newSet, oldSet) { - continue - } - used[index] = true - matched = true - break - } - if !matched { - return false - } - } - return true -} - func stringSetContainsAll(superset, subset map[string]bool) bool { for value := range subset { if !superset[value] { diff --git a/scripts/policy/schema-compat/main_test.go b/scripts/policy/schema-compat/main_test.go index ca166db0..4cdbfc18 100644 --- a/scripts/policy/schema-compat/main_test.go +++ b/scripts/policy/schema-compat/main_test.go @@ -492,10 +492,10 @@ func TestCrossPlatformCoverageSchemaCompatMCPRetirementAndConstraintExpansion(t parameter.InterfaceType = "" }) mutateTool(¤t, func(tool *toolSchema) { - tool.Constraints = `{"require_one_of":[["title","format","legacy-format"]],"mutually_exclusive":[["title","legacy-title"]]}` + tool.Constraints = `{"require_one_of":[["title","format","legacy-format"]],"mutually_exclusive":[["title","format","legacy-title"]]}` }) - // Baseline has require_one_of only; expanding with a new mutually_exclusive - // group must remain incompatible. + // Baseline has require_one_of only; a new mutually-exclusive group that + // restricts two historical public parameters must remain incompatible. if failures := checkCompatibility(baseline, current); len(failures) == 0 { t.Fatal("adding a new constraint group must fail compatibility") } @@ -532,36 +532,9 @@ func TestCrossPlatformCoverageSchemaCompatMCPRetirementAndConstraintExpansion(t t.Fatalf("hidden-sibling constraint expansion should pass: %v", failures) } - if compatibleConstraintMemberExpansion("{", `{"require_one_of":[["a"]]}`) { - t.Fatal("invalid old constraints must not expand") - } - if compatibleConstraintMemberExpansion(`{"require_one_of":[["a"]]}`, "{") { - t.Fatal("invalid new constraints must not expand") - } if groups, ok := parseConstraintGroups(""); !ok || len(groups["require_one_of"]) != 0 { t.Fatalf("empty constraints parse = %#v ok=%v", groups, ok) } - if constraintGroupsAreMemberExpansions([][]string{{}}, [][]string{{"a"}}) { - t.Fatal("empty old group must not expand") - } - if constraintGroupsAreMemberExpansions( - [][]string{{"a"}, {"b"}}, - [][]string{{"a", "b"}}, - ) { - t.Fatal("group-count reduction must not expand") - } - if constraintGroupsAreMemberExpansions( - [][]string{{"a"}, {"c"}}, - [][]string{{"a", "b"}, {"x", "y"}}, - ) { - t.Fatal("unmatched old group must not expand") - } - if !constraintGroupsAreMemberExpansions( - [][]string{{"a"}, {"b"}}, - [][]string{{"a", "b", "extra"}, {"b", "other"}}, - ) { - t.Fatal("used-group skip should still allow alternate match") - } if !stringSetContainsAll(map[string]bool{"a": true, "b": true}, map[string]bool{"a": true}) || stringSetContainsAll(map[string]bool{"a": true}, map[string]bool{"a": true, "b": true}) { t.Fatal("stringSetContainsAll classification is incorrect") @@ -622,3 +595,87 @@ func TestCrossPlatformCoverageSchemaCompatMCPRetirementAndConstraintExpansion(t t.Fatalf("non-empty interface_type change should fail: %v", failures) } } + +func TestCrossPlatformCoverageSchemaCompatAdditiveConstraintEvolution(t *testing.T) { + oldTool := toolSchema{ + Parameters: map[string]parameterSchema{ + "target": {}, + "limit": {}, + }, + Constraints: `{"require_one_of":[["target","legacy-target"]]}`, + } + compatible := oldTool + compatible.Constraints = `{"require_one_of":[["target","legacy-target","new-target"]],"mutually_exclusive":[["target","new-target"],["new-a","new-b"]],"require_together":[["new-a","new-b"]]}` + if !compatibleAdditiveConstraintEvolution(oldTool, compatible) { + t.Fatal("member expansion and additive alias-only groups must remain compatible") + } + + twoHistorical := compatible + twoHistorical.Constraints = `{"require_one_of":[["target","legacy-target","new-target"]],"mutually_exclusive":[["target","limit","new-target"]]}` + if compatibleAdditiveConstraintEvolution(oldTool, twoHistorical) { + t.Fatal("new mutex group restricting two historical parameters must fail") + } + newRequirement := compatible + newRequirement.Constraints = `{"require_one_of":[["target","legacy-target","new-target"],["new-a","new-b"]]}` + if compatibleAdditiveConstraintEvolution(oldTool, newRequirement) { + t.Fatal("new require_one_of group must fail") + } + requireHistoricalTogether := compatible + requireHistoricalTogether.Constraints = `{"require_one_of":[["target","legacy-target","new-target"]],"require_together":[["limit","new-limit"]]}` + if compatibleAdditiveConstraintEvolution(oldTool, requireHistoricalTogether) { + t.Fatal("new require_together group containing a historical parameter must fail") + } + removedOldGroup := compatible + removedOldGroup.Constraints = `{"mutually_exclusive":[["new-a","new-b"]]}` + if compatibleAdditiveConstraintEvolution(oldTool, removedOldGroup) { + t.Fatal("removing a historical group must fail") + } + invalid := compatible + invalid.Constraints = "{" + if compatibleAdditiveConstraintEvolution(oldTool, invalid) { + t.Fatal("invalid constraints must fail closed") + } + emptyHistoricalGroup := oldTool + emptyHistoricalGroup.Constraints = `{"require_one_of":[[]]}` + if compatibleAdditiveConstraintEvolution(emptyHistoricalGroup, compatible) { + t.Fatal("empty historical constraint group must fail closed") + } + historicalMutex := oldTool + historicalMutex.Constraints = `{"mutually_exclusive":[["target","legacy-target"]]}` + historicalMutexExpanded := oldTool + historicalMutexExpanded.Constraints = `{"mutually_exclusive":[["target","legacy-target","limit"]]}` + if compatibleAdditiveConstraintEvolution(historicalMutex, historicalMutexExpanded) { + t.Fatal("adding a historical parameter to an existing mutex group must fail") + } + historicalTogether := oldTool + historicalTogether.Constraints = `{"require_together":[["target","legacy-target"]]}` + historicalTogetherExpanded := oldTool + historicalTogetherExpanded.Constraints = `{"require_together":[["target","legacy-target","limit"]]}` + if compatibleAdditiveConstraintEvolution(historicalTogether, historicalTogetherExpanded) { + t.Fatal("adding a historical parameter to an existing require-together group must fail") + } + historicalOneOf := oldTool + historicalOneOf.Constraints = `{"require_one_of":[["target","legacy-target","limit"]]}` + if !compatibleAdditiveConstraintEvolution(oldTool, historicalOneOf) { + t.Fatal("adding a historical parameter to require-one-of only loosens the contract") + } + gateBaseline := baselineContract() + mutateTool(&gateBaseline, func(tool *toolSchema) { + tool.Parameters["folder"] = parameterSchema{Type: `"string"`} + tool.Constraints = `{"mutually_exclusive":[["title","format"]]}` + }) + gateCurrent := cloneContract(gateBaseline) + mutateTool(&gateCurrent, func(tool *toolSchema) { + tool.Constraints = `{"mutually_exclusive":[["title","format","folder"]]}` + }) + if failures := checkCompatibility(gateBaseline, gateCurrent); len(failures) == 0 { + t.Fatal("compatibility gate must reject an existing mutex group gaining a historical parameter") + } + multipleHistoricalGroups := oldTool + multipleHistoricalGroups.Constraints = `{"require_one_of":[["target"],["limit"]]}` + multipleExpandedGroups := oldTool + multipleExpandedGroups.Constraints = `{"require_one_of":[["target","limit"],["limit","new-limit"]]}` + if !compatibleAdditiveConstraintEvolution(multipleHistoricalGroups, multipleExpandedGroups) { + t.Fatal("each historical group must match a distinct expanded group") + } +} diff --git a/scripts/run_chat_shortcut_live_audit.py b/scripts/run_chat_shortcut_live_audit.py index 3047dca5..27cd9972 100755 --- a/scripts/run_chat_shortcut_live_audit.py +++ b/scripts/run_chat_shortcut_live_audit.py @@ -306,6 +306,23 @@ def reaction_message_count(value: Any, *, upper: bool) -> int: return count +def primary_projection_for_message_metrics(value: Any) -> Any: + """Select one canonical projected collection for message-level metrics. + + Some stable Shortcut payloads publish compatibility aliases such as + ``messages`` and ``items`` with the same rows. Walking the entire envelope + would count reactions and thread IDs once per alias and report a false + projection mismatch even though the lower and upper message sets agree. + """ + if not isinstance(value, dict): + return value + for key in ("messages", "replies", "results", "items"): + items = value.get(key) + if isinstance(items, list): + return items + return value + + def thread_message_count(value: Any, *, upper: bool) -> int: keys = {"threadId"} if upper else { "openConvThreadId", @@ -393,9 +410,10 @@ def summarize_capture(capture: Capture) -> dict[str, Any]: else 0 ) exact_passthrough = bool(capture.raw_calls and upper == raw) - upper_reactions = reaction_message_count(upper, upper=True) + upper_message_projection = primary_projection_for_message_metrics(upper) + upper_reactions = reaction_message_count(upper_message_projection, upper=True) lower_reactions = reaction_message_count(raw, upper=False) - upper_threads = thread_message_count(upper, upper=True) + upper_threads = thread_message_count(upper_message_projection, upper=True) lower_threads = thread_message_count(raw, upper=False) upper_pagination = pagination_meta(upper) lower_pagination = pagination_meta(pagination_raw) diff --git a/scripts/run_chat_shortcut_live_audit_test.py b/scripts/run_chat_shortcut_live_audit_test.py new file mode 100644 index 00000000..e56a9b44 --- /dev/null +++ b/scripts/run_chat_shortcut_live_audit_test.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Deterministic regression checks for the live Chat audit summarizer.""" + +from __future__ import annotations + +from run_chat_shortcut_live_audit import ( + primary_projection_for_message_metrics, + reaction_message_count, + thread_message_count, +) + + +def check(name: str, condition: bool) -> None: + if not condition: + raise AssertionError(f"FAIL: {name}") + print(f" ok: {name}") + + +def test_compatibility_aliases_are_counted_once() -> None: + rows = [{"threadId": "thread-1", "reactions": [{"type": "LIKE"}]}] + payload = {"messages": rows, "items": rows, "count": 1} + projection = primary_projection_for_message_metrics(payload) + + check("messages is the canonical projection", projection is rows) + check("reaction-bearing message counted once", reaction_message_count(projection, upper=True) == 1) + check("thread-bearing message counted once", thread_message_count(projection, upper=True) == 1) + + +def test_projection_fallback_order() -> None: + replies = [{"threadId": "thread-2"}] + items = [{"threadId": "thread-3"}] + check( + "replies precedes items", + primary_projection_for_message_metrics({"replies": replies, "items": items}) is replies, + ) + check("items remains a supported fallback", primary_projection_for_message_metrics({"items": items}) is items) + + +def test_non_envelope_passthrough() -> None: + rows = [{"threadId": "thread-4"}] + check("bare list passes through", primary_projection_for_message_metrics(rows) is rows) + + +def main() -> None: + tests = [ + test_compatibility_aliases_are_counted_once, + test_projection_fallback_order, + test_non_envelope_passthrough, + ] + for test in tests: + print(f"{test.__name__}:") + test() + print(f"\nAll {len(tests)} test groups passed.") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_chat_shortcut_live_write_audit.py b/scripts/run_chat_shortcut_live_write_audit.py index 87731fb9..33f9ddf6 100644 --- a/scripts/run_chat_shortcut_live_write_audit.py +++ b/scripts/run_chat_shortcut_live_write_audit.py @@ -474,9 +474,22 @@ def run_live(binary: Path, timeout: int) -> list[dict[str, Any]]: ) role_id = "" + # The platform only permits the @all and red-envelope sub-switches + # while the conversation-wide notification-off switch is enabled. + # Keep that prerequisite active across both cases, then restore it. + conversation_mute_captures = [ + run_capture( + binary, + "+conversation-mute", + ["--conversation-id", group_id, "--yes"], + timeout, + ) + ] + # Red-envelope must be exercised first. Toggling the @all sub-switch + # can make the service clear the prerequisite state for the next call. shortcut_case( - "+conversation-mute", - "update_notification_off", + "+conversation-mute-red-envelope", + "update_red_env_notification_off", [ ["--conversation-id", group_id], ["--conversation-id", group_id, "--off"], @@ -490,13 +503,20 @@ def run_live(binary: Path, timeout: int) -> list[dict[str, Any]]: ["--conversation-id", group_id, "--off"], ], ) - shortcut_case( - "+conversation-mute-red-envelope", - "update_red_env_notification_off", - [ - ["--conversation-id", group_id], - ["--conversation-id", group_id, "--off"], - ], + conversation_mute_captures.append( + run_capture( + binary, + "+conversation-mute", + ["--conversation-id", group_id, "--off", "--yes"], + timeout, + ) + ) + results.append( + summarize_action( + "+conversation-mute", + "update_notification_off", + conversation_mute_captures, + ) ) shortcut_case( "+conversation-set-top", diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index 8177ad11..b38a27e2 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -21,14 +21,14 @@ cli_version: ">=1.0.15" - 危险操作必须先向用户确认,用户同意后才加 `--yes` 执行 - 单次批量操作不超过 30 条记录 - 所有命令必须**严格遵循**对应产品参考文档里面规定的参数格式(如:如果有参数值,则参数和参数值之间至少用一个空格隔开) -- **脚本优先**:[scripts/](./scripts/) 下的 `python scripts/.py` 已封装翻页/轮询/批量逻辑,遇到对应场景(如 AI 表格批量导入导出、AI 应用创建轮询、文档创建后写内容、钉盘目录树等)**优先调用脚本**而非手写多步命令。脚本均支持 `--dry-run` 预览、`--format json` 输出,失败时回退到手动步骤 -- **实时个人消息事件例外**:用户要监听消息、订阅事件、自动回复消息或事件驱动 Agent 时,必须走 `dws event consume ... --flatten` 长连接,不要写脚本轮询消息历史 +- **脚本只用于明确覆盖的复合任务**:[scripts/](./scripts/) 下的脚本可封装 AI 表格批量导入导出、AI 应用创建轮询、文档创建后写内容、钉盘目录树等流程;当公开 `+` Shortcut 已提供目标唯一解析、分页/部分失败 ledger 和确认语义时,优先 Shortcut。Chat 历史导出与机器人广播已完全下沉 Runtime,不再发布兼容脚本 +- **实时个人消息事件例外**:用户要监听消息、订阅事件、自动回复消息或事件驱动 Agent 时,默认走 `dws event +listen-im ...`;只有明确需要多个原始 EventKey、Filter DSL、subscribe_id 或原始 envelope 时才用 `dws event consume ... --flatten`,不要写脚本轮询消息历史 ## Shortcut 与原子命令的使用原则 `shortcut` 是对常用操作的高层封装,适合优先承担用户意图;产品参考文档和本 skill 负责判断意图、风险、跨产品流程和复杂参数,CLI 帮助负责声明当前版本真正可调用的命令。 -- 先按产品参考、意图表和 recipe 路由。存在精确覆盖场景的专用脚本/recipe 时继续遵循“脚本优先”;否则用户意图可由可见 shortcut 满足时,优先使用 `dws + ... --format json`,不要手写等价的多步原子命令。 +- 先按产品参考、意图表和 recipe 路由。用户意图可由可见 Shortcut 满足时,优先使用 `dws + ... --format json`,不要手写等价的多步原子命令。只有脚本明确补足 Shortcut 未覆盖的复合交付物且其安全/完整性契约仍适用时才选择脚本。 - 公开内建 shortcut 同时进入 Runtime Schema。用 `dws schema --cli-path " +" --format json` 读取 Agent 选择、参数、跨参数约束、risk/confirmation 与接口语义;`dws shortcut list --service --format json` 只作为轻量批量发现入口。 - 真正组装参数前用叶子帮助 `dws + --help` 核对当前 Cobra 接受的 flags。父级 `dws --help` 只能发现子命令,不能替代叶子参数帮助。 - shortcut catalog 中 `confirmation=user_required` 时,必须先获得用户确认,确认后才加 `--yes`;`not_required` 不额外确认。 @@ -120,9 +120,9 @@ cli_version: ">=1.0.15" 用户提到"在线电子表格/钉钉表格/axls/工作表/单元格读写/合并单元格/筛选视图/导出 xlsx" → `sheet` 用户提到"待办/TODO/任务提醒/循环待办" → `todo` 用户提到"创建知识库/知识库列表/搜索知识库空间/wiki/团队空间/知识库成员管理/我的文档个人空间" → `wiki` -用户提到"监听有人@我/监听单聊或群消息/监听所有单聊或群消息/监听某人发送的消息/监听消息已读/监听消息撤回/监听消息贴表情或表情回应/监听群成员加入/监听群成员退出/监听群改名或群解散/订阅个人 IM 事件/实时接收钉钉事件/个人事件流/event consume user_im_message_*/监听并自动回复消息/驱动 Agent 处理消息" → `event` +用户提到"监听有人@我/监听单聊或群消息/监听所有单聊或群消息/监听某人发送的消息/监听消息已读/监听消息撤回/监听消息贴表情或表情回应/订阅个人 IM 事件/实时接收钉钉事件/监听并自动回复消息/驱动 Agent 处理消息" → `event +listen-im`;群成员加入/退出、群改名/解散或明确原始 EventKey/Filter DSL → `event consume` -事件监听中,同一目标、同一过滤条件的多个兼容事件优先生成一个 `dws event consume [event_key...] --flatten`;不同用户、不同群或不同过滤条件拆成多个 consume 进程。 +普通消息、reaction、已读、撤回监听优先由一个 `dws event +listen-im` 进程表达目标;不同用户、不同群或不同过滤条件拆成独立进程。只有高级事件控制才生成 `dws event consume [event_key...] --flatten`。 关键区分: aitable(数据表格) vs todo(待办任务) 关键区分: report(钉钉日志/日报周报) vs todo(待办任务) @@ -292,7 +292,7 @@ Schema 与 Help 冲突是**契约漂移**,不得静默猜测或把两边字段 `source` 表示最终命令 identity 的来源,不表示运行时 backing;helper/local/MCP 实现机制读取 `interface_mode`、`availability` 和 provenance,不要假定 `dev.*` 必然是 `source=mcp:`,也不要假定本地命令必然是 `source=cobra`。 ## 错误处理 -1. 遇到错误,加 `--verbose` 重试一次 +1. 先读取 JSON 错误的 `retryable`、`retry_after_seconds`、`next_retry_at`、`hint` 和 `actions`;只有明确 `retryable=true` 时才按服务端节奏做一次有界重试。缺少重试语义时加 `--verbose` 收集诊断后停止 2. 若 stderr 出现 `RECOVERY_EVENT_ID=`,优先按 [recovery-guide.md](./references/recovery-guide.md) 执行 recovery 闭环 3. 仍然失败,报告完整错误信息给用户,禁止自行尝试替代方案 4. 认证失败时,参考 [global-reference.md](./references/global-reference.md) 中的认证章节处理 diff --git a/skills/mono/references/best_practices/01-messaging.md b/skills/mono/references/best_practices/01-messaging.md index fb95e817..cc3ee61f 100644 --- a/skills/mono/references/best_practices/01-messaging.md +++ b/skills/mono/references/best_practices/01-messaging.md @@ -4,10 +4,10 @@ | Recipe | 行动指南(固定路线) | |--------|-------------------| -| query-group-chat | **优先**:`chat_export_messages.py`(开源版未引入;可手动用 `dws chat message list` 翻页后写入文件)(自动搜群+翻页+导出)
备选:1. `chat search --query "<群名>"` → 取 `openConversationId`
2. `chat message list --group --time ""` → 取消息列表
3. **翻页**:`hasMore=true` 时取本页最后 `createTime` 作为下次 `--time`,重复至 `hasMore=false`
4. `--forward=false` 拉给定时间**之前**的消息
5. 合并全部消息后总结 | -| query-private-chat | **优先**:`chat_history_with_user.py`(开源版未引入;可手动用 `dws chat search` + `dws chat message list` 组合)(自动搜人+翻页+导出)
备选:1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`
2. `chat message list --user --time ""` → 取消息列表
3. **翻页**:同 query-group-chat
4. 合并全部消息后总结 | -| escalate-ding | 三级升级:
1. `ding message send --robot-code --type app --users --content "<内容>"`(必填项见 [ding.md](../products/ding.md))
2. `chat message send --group --text "<内容>"` 群里提醒(可选 `--title` / `@` 见 [chat.md](../products/chat.md))
3. `todo task create --title "<标题>" --executors --priority 40` 建紧急待办
前置:`aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`;`chat search --query "<群名>"` → 取 `openConversationId` | -| send-by-bot | **多群批量优先**:`bot_broadcast.py`(开源版未引入;可手动用 `dws chat message send-by-bot` 多次调用)
单群:1. `chat bot search` → 取 `robotCode`
2. `chat search --query "<群名>"` → 取 `openConversationId`
3. `chat message send-by-bot --robot-code --group --title "<标题>" --text "<内容>"` | -| forward-message | 1. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message list --group --time "<起始时间>"` 拉源消息
2. `contact user search --query "<姓名>"` → 取 `openDingTalkId`(推荐);或 `chat search --query "<群名>"` → 取目标 `openConversationId`
3. `chat message send --open-dingtalk-id --text "<内容>"`(推荐)或 `--group --text "<内容>"` 发送。仅当无法获取 openDingTalkId 时才用 `--user `(备选) | +| query-group-chat | `dws chat +chat-messages --group "<群名>" --time "" --page-all --output ./exports/messages.json --format json`;只接受唯一解析结果,并检查 `complete/hasMore/failures`。 | +| query-private-chat | 先解析唯一用户 ID,再执行 `dws chat +chat-messages --user "" --time "" --page-all --format json`;零命中/多候选停止消歧。 | +| escalate-ding | 三级升级:
1. `ding message send --robot-code --type app --users --content "<内容>"`(必填项见 [ding.md](../products/ding.md))
2. `dws chat +messages-send --as user --group --text "<内容>" --format json` 群里提醒;Runtime 会在缺少确认时停止,必须先完成收件人、内容与影响确认,再以相同参数追加 `--yes`
3. `todo task create --title "<标题>" --executors --priority 40` 建紧急待办
所有稳定 ID 来自同一 profile 的真实返回,不选搜索第一项。 | +| send-by-bot | 单群用 `--group`;多群用 `dws chat +messages-send --as bot --robot-code --groups --title "<标题>" --text "<内容>" --format json`。Runtime 要求先确认再追加 `--yes`,并去重、返回 `im.batch-write.v1` 逐目标 ledger;未知投递状态时停止重发。 | +| forward-message | 1. `dws chat +chat-messages --group "<源群名>" --format json` 取得唯一源会话与真实 `openMessageId`
2. 从真实返回或唯一解析取得目标 `openConversationId`
3. `dws chat +messages-forward --src-conversation-id --msg-id --dest-conversation-id --format json`;Runtime 会先停止并要求确认,确认后以相同参数追加 `--yes` 执行并保留幂等键。若用户只是要转述内容而非转发原消息,改走 `+dm`/`+send-to-group`。 | | search-common-group | `chat search-common --nicks "<昵称1>,<昵称2>" --limit 20 --cursor 0`(`--match-mode AND`=全在/`OR`=任一在,翻页:`hasMore=true` 时用 `nextCursor`)
用户说"我和XX的共同群" → nicks 包含"我"时,需先 `contact user get-self` 取自己昵称再拼接 | | focus-messages | **零参数一行命令**:`chat message list-focused --limit 50`(拉特别关注人发的消息聚合)
触发 query:`"我特别关注的人最近发了什么消息"`、`"关注的人最近聊了啥"`、`"星标联系人最近的动态"`
**强消歧**:query 含动词【发/说/聊/讲】或名词【消息/聊天/动态】 → **必须**走本命令,**不要**先去拉 `contact relation list-my-followings`;仅当用户终点是"人员列表"(如"我关注了谁")才走 `relation list-my-followings`(详见 [contact.md](../products/contact.md#意图判断) 易混淆硬规则)
翻页:`hasMore=true` 时用 `nextCursor` 作为下次 `--cursor`
按人精控(可选):先 `contact relation list-my-followings` 取 `openDingTalkId`,再 `chat message list-by-sender --sender-open-dingtalk-id --start --end ` | diff --git a/skills/mono/references/products/chat.md b/skills/mono/references/products/chat.md index 94488a33..7fc9952e 100644 --- a/skills/mono/references/products/chat.md +++ b/skills/mono/references/products/chat.md @@ -8,15 +8,16 @@ | 意图 | 首选 | |---|---| -| 以 current-user / bot / webhook 身份发消息 | `dws chat +messages-send --as ...` | -| 拉取单个群聊或单聊的消息 | `dws chat +chat-messages ...` | +| 以 current-user / bot / webhook 身份发消息 | `dws chat +messages-send --as ...`;Bot 多群用 `--groups/--groups-file` | +| 拉取单个群聊或单聊的消息 | `dws chat +chat-messages ...`;全量加 `--page-all`,导出加 `--output` | | 按关键词、发送者、@对象、会话、类型或时间组合搜索 | `dws chat +search-msg ...` | | 查询 @我的消息 | `dws chat +at-me ...` | | 根据消息 ID 批量取详情与 reaction | `dws chat +messages-mget ...` | | 读取已知 thread/topic 的全部回复 | `dws chat +thread-replies ...` | | 下载单个 mediaId/fileId | `dws chat +messages-resource-download ...` | -- `+messages-send` 只暴露下层真实支持的身份能力:user 支持文本/Markdown、已有 mediaId 图片、本地文件和幂等键;bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。 +- `+messages-send` 只暴露下层真实支持的身份能力:user 支持文本/Markdown、已有 mediaId 图片、本地文件和幂等键;bot 支持群聊、最多 100 个稳定群 ID 的逐项 ledger 或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。Bot/Webhook 不支持富媒体。 +- `+chat-messages --page-all` 连续读取 typed `nextPage.time`,按消息 ID 去重并受 `--page-limit/--max-results` 约束;`--output` 将同一完整性 ledger 原子写入工作目录内 JSON。 - `+messages-send` 会自动规范化并补齐 @ 占位符。user 使用 `<@id>` / `<@all>`;bot/webhook 使用 `@id` / `@手机号` / `@all`。声明 `--at-*` / `--at-all` 即可,不要为统一 Shortcut 手工拼 `@10`。 - `+search-msg --page-all` 连续翻页并默认按消息 ID 批量富化;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger。 - `+at-me`、`+chat-messages`、`+messages-mget`、`+search-msg`、`+thread-replies` 可用 `--download-resources` 下载资源。引用、回复、合并转发中的资源使用结果 `resourceRefs` 自带的子消息 `messageId`;仅当子消息缺会话 ID 时继承父消息 `openConversationId`。 @@ -150,8 +151,7 @@ Usage: dws chat group upgrade-to-external [flags] Example: dws chat group upgrade-to-external --group --dry-run - dws chat group upgrade-to-external --group --yes - dws chat group upgrade-to-external --group --extension '{"source":"dws"}' --yes + dws chat group upgrade-to-external --group --extension '{"source":"dws"}' --dry-run Flags: --group string 待升级普通群的 openConversationId (必填) --extension string 预留扩展字段 JSON 对象;对象值必须是字符串 (可选) @@ -1647,7 +1647,7 @@ Flags: #### 关闭/开启 @所有人消息提醒 — 关闭或开启会话中 @所有人的消息通知 -> ⚠️ 当前不可用:该命令当前调用常失败(服务端返回 1002 系统繁忙),多为服务端侧限制。命令本身参数合法,但调用不会生效。 +> 前置条件:先为会话开启总免打扰(`dws chat mute --conversation-id `),否则平台返回 `NotificationOffNotEnabled`。 ``` Usage: dws chat mute-at-all [flags] @@ -1663,6 +1663,7 @@ Flags: 注意: - 默认行为是关闭 @所有人通知,传 --off 则恢复接收通知 + - 该子开关依赖总免打扰;恢复 @所有人通知后,再修改红包子开关前应重新开启总免打扰 - 支持单聊和群聊,openConversationId 可通过 chat search(群聊)或 chat conversation-info(单聊)获取 ``` @@ -1670,7 +1671,7 @@ Flags: #### 关闭/开启红包消息提醒 — 关闭或开启会话中的红包消息通知 -> ⚠️ 当前不可用:该命令当前调用常失败(服务端返回 1002 系统繁忙),多为服务端侧限制。命令本身参数合法,但调用不会生效。 +> 前置条件:先为会话开启总免打扰(`dws chat mute --conversation-id `),否则平台返回 `NotificationOffNotEnabled`。 ``` Usage: dws chat mute-red-envelope [flags] @@ -1686,6 +1687,7 @@ Flags: 注意: - 默认行为是关闭红包通知,传 --off 则恢复接收通知 + - 若刚恢复了 @所有人通知,应先重新开启总免打扰,再修改红包通知 - 支持单聊和群聊,openConversationId 可通过 chat search(群聊)或 chat conversation-info(单聊)获取 ``` @@ -2019,8 +2021,8 @@ Flags: - `chat category list-by-conv` / `chat category batch-info` — 查询会话所属分组 / 批量查询分组信息 - `chat mute` — 开启/关闭会话消息免打扰(默认开启,--off 关闭) - `chat hide` — 在会话列表中隐藏会话(支持单聊/群聊,收到新消息时重新出现) -- `chat mute-at-all` — 关闭/开启 @所有人消息提醒(默认关闭,--off 恢复) -- `chat mute-red-envelope` — 关闭/开启红包消息提醒(默认关闭,--off 恢复) +- `chat mute-at-all` — 关闭/开启 @所有人消息提醒(默认关闭,--off 恢复;需先开启总免打扰) +- `chat mute-red-envelope` — 关闭/开启红包消息提醒(默认关闭,--off 恢复;需先开启总免打扰) - `chat mark-unread` / `chat mark-read` — 标记会话未读 / 标记指定消息及之前的消息已读 - `chat clear-red-point` / `chat clear-all-red-point` — 清除单个会话红点 / 一键清除所有会话红点(全部已读) - `chat list-all-conversations` — 分页拉取当前用户全部会话列表(单聊+群聊,与 list-top-conversations 的区别是不限置顶) @@ -2307,8 +2309,8 @@ Flags: - `chat category list` 无需参数;`category list-conversations` 需传 --category-id(通过 category list 获取) - `chat mute` 默认开启免打扰,传 --off 关闭;--conversation-id / --id / --chat 三个别名均可用于传入会话 ID - `chat hide` 隐藏会话,需传 --conversation-id(openConversationId,支持单聊/群聊),隐藏后不显示在列表中,收到新消息时重新出现 -- `chat mute-at-all` 关闭/开启 @所有人消息提醒,需传 --conversation-id(openConversationId),默认关闭通知,传 --off 恢复接收 -- `chat mute-red-envelope` 关闭/开启红包消息提醒,需传 --conversation-id(openConversationId),默认关闭通知,传 --off 恢复接收 +- `chat mute-at-all` 关闭/开启 @所有人消息提醒,需传 --conversation-id(openConversationId),默认关闭通知,传 --off 恢复接收;调用前需先开启总免打扰 +- `chat mute-red-envelope` 关闭/开启红包消息提醒,需传 --conversation-id(openConversationId),默认关闭通知,传 --off 恢复接收;调用前需先开启总免打扰 - `chat message reply` 引用回复消息(**单聊/群聊均可**),需传 --conversation-id(openConversationId,单聊与群聊使用同一字段)、--ref-msg-id(被引用消息 openMessageId)、--ref-sender(被引用消息发送者 openDingTalkId)、--text(回复内容);目前回复类型仅支持 text - `chat message forward` 转发单条消息(**源/目标会话均支持单聊/群聊**,常见组合:群→群、群→单、单→群、单→单),需传 --src-conversation-id(源会话 openConversationId)、--msg-id(源消息 openMessageId)、--dest-conversation-id(目标会话 openConversationId) - `chat set-top` 设置/取消会话置顶(**单聊/群聊均可**),需传 --conversation-id(openConversationId,单聊与群聊使用同一字段),默认置顶,传 --off 取消 @@ -2320,12 +2322,11 @@ Flags: - `chat group-mute-member` 指定群成员禁言,需传 --group、--user/--users(userId,逗号分隔)、--mute-time(毫秒,仅禁言时必填,支持 300000/3600000/86400000/604800000/2592000000),传 --off 解除禁言;CLI 会自动把 userId 解析成 openDingTalkId 再调用,直接传 userId 即可;禁言群主会被服务端拒绝 - `chat group set-admin` 设置/取消群管理员,需传 --group(openConversationId)、--user/--users(userId,逗号分隔),默认设为管理员,传 --off 取消 -## 自动化脚本 +## Runtime 替代旧 Chat 脚本 -| 脚本 | 场景 | 用法 | -|------|------|------| -| [chat_export_messages.py](../../scripts/chat_export_messages.py) | 导出群聊消息到 JSON 文件 | `python chat_export_messages.py --query "项目冲刺" --time "2026-03-10 00:00:00"` | -| [chat_history_with_user.py](../../scripts/chat_history_with_user.py) | 查询与某人的单聊聊天记录 | `python chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"` | +- 群聊/单聊全量导出:`dws chat +chat-messages --page-all --output <相对.json>`;目标可用稳定 ID 或唯一自然查询。 +- Bot 多群广播:`dws chat +messages-send --as bot --groups ...` 或 `--groups-file ...`;输出 `im.batch-write.v1` 逐项 ledger。 +- 旧 Chat 导出与广播脚本已停止发布,不能在执行计划中引用。 ## 相关产品 diff --git a/skills/mono/references/products/event.md b/skills/mono/references/products/event.md index e31d3d95..a289bd12 100644 --- a/skills/mono/references/products/event.md +++ b/skills/mono/references/products/event.md @@ -1,11 +1,11 @@ # dws event — 个人 IM 事件 -通过个人 Stream 长连接监听当前用户的钉钉消息接收、全量消息、已读、撤回、表情回应和群生命周期事件,NDJSON 输出到 stdout,用于驱动事件触发的 Agent。实时监听、自动回复、订阅事件都必须使用 `dws event consume`,不要写脚本轮询消息历史。 +通过个人 Stream 长连接监听当前用户的钉钉消息接收、已读、撤回、表情回应和群生命周期事件,NDJSON 输出到 stdout,用于驱动事件触发的 Agent。普通 IM 监听默认使用 `dws event +listen-im`;群生命周期、显式 EventKey、Filter DSL、subscribe_id 复用或原始 envelope 才使用 `dws event consume`。不要写脚本轮询消息历史。 ## 运行方式 -- bus 后台进程持有对钉钉的个人 Stream 长连;consume 从 bus 读事件、按 NDJSON 打到 stdout。consume 只读,不发消息(回复用 `dws chat message send`)。 -- 没有 bus 时 consume 自动拉起;通常只跑 consume。 +- bus 后台进程持有对钉钉的个人 Stream 长连;`+listen-im` 把高层意图编译为 EventKey,再复用 consume 的订阅、ready、NDJSON、取消、回滚和清理生命周期。监听本身不发消息;回复使用 `dws chat +messages-send`。 +- 没有 bus 时监听命令自动拉起;普通任务只跑 `+listen-im`。 - 一个组织一个 bus,互不干扰、可同时跑;同组织内多个 consume 共享一个 bus。 - 非默认组织加全局 `--profile `;漏传会退回默认 profile 而失败。 @@ -13,8 +13,9 @@ | Command | Purpose | |---|---| +| `dws event +listen-im --kind ... [flags]` | 普通 message/reaction/read/recall 监听;支持自然姓名/群名唯一解析 | | `dws event schema --flatten` | 查看 Agent 使用的顶层业务字段 schema | -| `dws event consume [event_key...] --flatten [flags]` | 阻塞消费一个或多个兼容事件,事件写到 stdout,用 `-f ndjson` | +| `dws event consume [event_key...] --flatten [flags]` | 高级入口:阻塞消费显式 EventKey,事件写到 stdout,用 `-f ndjson` | | `dws event status --event ` | 查看个人订阅、bus、本地 consume | | `dws event stop --dry-run` / `--yes` | 先预览,再确认取消订阅并停止对应本地消费 | | `dws event stop --all --dry-run` / `--yes` | 先预览,再确认清理当前身份下全部个人订阅 | @@ -48,27 +49,22 @@ | 用户说 | 下一步 | |---|---| -| "监听有人 @ 我的消息" | `event consume`,事件码 `user_im_message_receive_at`,参数 `--flatten -f ndjson` | -| "监听我和 userId test-user-001 的单聊消息" | `event consume`,事件码 `user_im_message_receive_o2o`,参数 `--user test-user-001 --flatten -f ndjson` | -| "监听我和 openDingtalkId abc 的单聊消息" | `event consume`,事件码 `user_im_message_receive_o2o`,参数 `--open-dingtalk-id abc --flatten -f ndjson` | -| "监听 XX 群消息" | 先 `dws chat search --query "XX" --format json`,确认后 consume group | -| "监听 userId test-user-001 发给我的消息" | `event consume`,事件码 `user_im_message_receive_user`,参数 `--user test-user-001 --flatten -f ndjson` | -| "监听 openDingtalkId abc 发给我的消息" | `event consume`,事件码 `user_im_message_receive_user`,参数 `--open-dingtalk-id abc --flatten -f ndjson` | -| "监听我的所有单聊消息" | `event consume`,事件码 `user_im_message_receive_o2o_all`,参数 `--flatten -f ndjson` | -| "监听我所在的所有群消息" | `event consume`,事件码 `user_im_message_receive_group_all`,参数 `--flatten -f ndjson` | -| "监听我发给 userId test-user-001 的消息是否已读" | `event consume`,事件码 `user_im_message_read_o2o`,参数 `--user test-user-001 --flatten -f ndjson` | -| "监听 XX 群消息已读" | 先解析群 ID,再 consume `user_im_message_read_group --group ` | -| "监听我和 userId test-user-001 的消息撤回" | `event consume`,事件码 `user_im_message_recall_o2o`,参数 `--user test-user-001 --flatten -f ndjson` | -| "监听 XX 群消息撤回" | 先解析群 ID,再 consume `user_im_message_recall_group --group ` | -| "监听我和 userId test-user-001 的消息贴表情" | `event consume`,事件码 `user_im_message_reaction_o2o`,参数 `--user test-user-001 --flatten -f ndjson` | -| "监听 XX 群消息表情回应" | 先解析群 ID,再 consume `user_im_message_reaction_group --group ` | +| "监听有人 @ 我的消息" | `dws event +listen-im --kind at-me -f ndjson` | +| "监听我和 userId test-user-001 的单聊消息" | `dws event +listen-im --kind sender --user test-user-001 -f ndjson` | +| "监听我和 openDingtalkId abc 的单聊消息" | `dws event +listen-im --kind sender --open-dingtalk-id abc -f ndjson` | +| "监听 XX 群消息" | `dws event +listen-im --kind group --chat-query "XX" -f ndjson`;多候选停止消歧 | +| "监听某人发给我的消息" | `dws event +listen-im --kind sender --user-query "<姓名>" -f ndjson` | +| "监听我的所有单聊消息" | `dws event +listen-im --kind all-direct -f ndjson` | +| "监听我所在的所有群消息" | `dws event +listen-im --kind all-group -f ndjson` | +| "监听某人的消息已读/撤回/reaction" | `dws event +listen-im --kind sender --user-query "<姓名>" --events read,recall,reaction -f ndjson` | +| "监听 XX 群消息已读/撤回/reaction" | `dws event +listen-im --kind group --chat-query "XX" --events read,recall,reaction -f ndjson` | | "监听 XX 群改名" | 先解析群 ID,再 consume `user_im_group_updated --group ` | | "监听有人加入 XX 群" | 先解析群 ID,再 consume `user_im_group_member_added --group ` | | "监听有人退出 XX 群" | 先解析群 ID,再 consume `user_im_group_member_exited --group ` | | "监听 XX 群解散" | 先解析群 ID,再 consume `user_im_group_disbanded --group `;破坏性自测只能用测试群 | -| "监听并自动回复某人的单聊消息" | 先解析对端 userId,再启动 o2o consume;不要写轮询脚本 | -| "同时监听同一人的单聊、已读和撤回" | 一个 consume 放入 3 个 event key,共享同一个 `--user` | -| "同时监听同一群的消息、改名和解散" | 一个 consume 放入 3 个 event key,共享同一个 `--group` | +| "监听并自动回复某人的单聊消息" | `+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` | | "看个人事件订阅状态" | `dws event status --event ` | | "停止这个个人事件订阅" | `dws event stop --dry-run`,确认后改用 `--yes` | @@ -79,15 +75,26 @@ ## Call flow -1. 从用户意图选择事件码;人名或群名先解析成必填 ID。 +1. 普通 IM 意图选择 `+listen-im` 的 `--kind` 与 `--events`;人名/群名直接用 `--user-query`/`--chat-query` 唯一解析。高级事件控制才手选 EventKey。 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` 猜测。 +3. 普通任务启动 `dws event +listen-im ... -f ndjson`;高级任务启动 `dws event consume [event_key...] ... --flatten -f ndjson`。等待真实 ready marker;多事件记录每条 subscription,再等待整体 ready。不要用 `sleep` 猜测。 4. stdout 每行是一个扁平事件 JSON;消息、动作及群成员加入/退出事件读取顶层业务字段。群标题变更和群解散只读取公共字段和 `payload` 中实际存在的字段。 5. 需要确认监听状态时运行 `dws event status --event `,查看 `Subscriptions` 和 `Consumers`。 6. 任务完成后优雅结束 consume;本次新建的订阅会自动取消。复用已有订阅或需要从外部主动取消时,先运行 `dws event stop --dry-run`,向用户确认后再以 `--yes` 执行;自测可在 consume 加 `--max-events` 或 `--duration` 自动退出。 ## Commands +普通 IM 监听: + +```bash +dws event +listen-im --kind at-me -f ndjson +dws event +listen-im --kind sender --user-query "张三" --events message,reaction -f ndjson +dws event +listen-im --kind group --chat-query "项目冲刺" --events message,read,recall -f ndjson +dws event +listen-im --kind all-direct --max-events 10 -f ndjson +``` + +以下 EventKey 与 consume 命令是高级控制面: + ```bash dws event schema user_im_message_receive_at --flatten dws event schema user_im_message_receive_o2o --flatten diff --git a/skills/mono/scripts/bot_broadcast.py b/skills/mono/scripts/bot_broadcast.py deleted file mode 100644 index 5317e222..00000000 --- a/skills/mono/scripts/bot_broadcast.py +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env python3 -""" -用机器人向多个群批量发送相同消息(如日报提醒) - -用法: - python bot_broadcast.py \ - --robot-code \ - --chats "conv_id1,conv_id2,conv_id3" \ - --title "日报提醒" \ - --text "请大家今天下班前提交日报" - - python bot_broadcast.py \ - --robot-code \ - --chats-file groups.txt \ - --title "周会通知" \ - --text "明天下午3点周会" - - python bot_broadcast.py --dry-run ... -""" - -import sys -import json -import subprocess -import argparse -from pathlib import Path -from typing import List, Any, Optional - - -def run_dws( - args: List[str], dry_run: bool = False, -) -> Optional[Any]: - cmd = ['dws'] + args - if dry_run: - print(f"[dry-run] {' '.join(cmd)}") - return {'dry_run': True} - try: - result = subprocess.run( - cmd, capture_output=True, text=True, timeout=60 - ) - if result.returncode != 0: - print(f" ✗ 错误:{result.stderr.strip()}") - return None - return json.loads(result.stdout) - except (subprocess.TimeoutExpired, json.JSONDecodeError, - FileNotFoundError) as e: - print(f" ✗ 错误:{e}") - return None - - -def main(): - parser = argparse.ArgumentParser( - description='向多个群批量发送机器人消息' - ) - parser.add_argument( - '--robot-code', required=True, help='机器人 Code' - ) - parser.add_argument('--chats', default='', help='会话 ID 列表') - parser.add_argument( - '--chats-file', default='', - help='会话 ID 文件 (每行一个)', - ) - parser.add_argument('--title', required=True, help='消息标题') - parser.add_argument( - '--text', required=True, help='消息内容 Markdown' - ) - parser.add_argument('--dry-run', action='store_true') - args = parser.parse_args() - - chat_ids: List[str] = [] - if args.chats: - chat_ids = [c.strip() for c in args.chats.split(',') - if c.strip()] - elif args.chats_file: - p = Path(args.chats_file) - if not p.exists(): - print(f"错误:文件不存在: {p}") - sys.exit(1) - chat_ids = [line.strip() for line in - p.read_text(encoding='utf-8').splitlines() - if line.strip() and not line.startswith('#')] - if not chat_ids: - print('错误:需要 --chats 或 --chats-file') - sys.exit(1) - - print(f"📢 批量发送消息到 {len(chat_ids)} 个群") - print(f" 标题: {args.title}") - print(f" 机器人: {args.robot_code}") - print('=' * 50) - - success, fail = 0, 0 - for i, chat_id in enumerate(chat_ids, 1): - result = run_dws([ - 'chat', 'message', 'send-by-bot', - '--robot-code', args.robot_code, - '--group', chat_id, - '--title', args.title, - '--text', args.text, - '--format', 'json', - ], dry_run=args.dry_run) - if result: - print(f" ✓ [{i}/{len(chat_ids)}] {chat_id}") - success += 1 - else: - print(f" ✗ [{i}/{len(chat_ids)}] {chat_id}") - fail += 1 - - print(f"\n完成: 成功 {success}, 失败 {fail}") - sys.exit(0 if fail == 0 else 1) - - -if __name__ == '__main__': - main() diff --git a/skills/mono/scripts/chat_export_messages.py b/skills/mono/scripts/chat_export_messages.py deleted file mode 100644 index 2b77d85e..00000000 --- a/skills/mono/scripts/chat_export_messages.py +++ /dev/null @@ -1,194 +0,0 @@ -#!/usr/bin/env python3 -""" -导出群聊消息到 JSON 文件(从指定时间点拉取) - -用法: - python chat_export_messages.py \ - --group \ - --time "2026-03-10 00:00:00" \ - --output messages.json - - python chat_export_messages.py \ - --query "项目冲刺" \ - --time "2026-03-10 00:00:00" \ - --no-forward --limit 100 -""" - -import sys -import json -import subprocess -import argparse -from typing import List, Any, Optional - - -def run_dws( - args: List[str], dry_run: bool = False, -) -> Optional[Any]: - cmd = ['dws'] + args - if dry_run: - print(f"[dry-run] {' '.join(cmd)}") - return None - try: - result = subprocess.run( - cmd, capture_output=True, text=True, timeout=120 - ) - if result.returncode != 0: - print(f"错误:{result.stderr.strip()}", file=sys.stderr) - return None - return json.loads(result.stdout) - except (subprocess.TimeoutExpired, json.JSONDecodeError, - FileNotFoundError) as e: - print(f"错误:{e}", file=sys.stderr) - return None - - -def search_group( - query: str, dry_run: bool = False, -) -> Optional[str]: - data = run_dws([ - 'chat', 'search', - '--query', query, '--format', 'json', - ], dry_run=dry_run) - if dry_run: - return '' - if not data: - return None - if isinstance(data, list): - groups = data - elif isinstance(data, dict): - inner = data.get('result', data) - if isinstance(inner, dict): - groups = inner.get('items', inner.get('groups', [])) - elif isinstance(inner, list): - groups = inner - else: - groups = [] - else: - groups = [] - if not groups: - print(f"未找到群聊: {query}") - return None - g = groups[0] - name = g.get('title') or g.get('name', '未知') - conv_id = g.get('openConversationId') or g.get('id') - print(f" 找到群聊: {name} ({conv_id})") - return conv_id - - -def main(): - parser = argparse.ArgumentParser( - description='导出群聊消息到 JSON' - ) - parser.add_argument('--group', help='群聊 openconversation_id') - parser.add_argument('--query', help='按群名搜索') - parser.add_argument( - '--time', required=True, - help='起始时间 yyyy-MM-dd HH:mm:ss', - ) - parser.add_argument( - '--no-forward', action='store_true', - help='拉给定时间之前的消息 (默认拉给定时间之后)', - ) - parser.add_argument( - '--limit', type=int, default=0, - help='返回条数 (不传则不限制)', - ) - parser.add_argument('--output', default='', help='输出文件') - parser.add_argument('--dry-run', action='store_true') - args = parser.parse_args() - - conv_id = args.group - if not conv_id: - if not args.query: - print('错误:需要 --group 或 --query 参数') - sys.exit(1) - print(f'🔍 搜索群聊: {args.query}') - conv_id = search_group(args.query, args.dry_run) - if not conv_id and not args.dry_run: - sys.exit(1) - - print(f'📥 拉取消息 (起始: {args.time})...') - all_messages: List[Any] = [] - current_time = args.time - page = 0 - max_pages = 50 - remaining = args.limit if args.limit > 0 else float('inf') - - while page < max_pages and remaining > 0: - cmd_args = [ - 'chat', 'message', 'list', - '--group', conv_id or '', - '--time', current_time, - '--format', 'json', - ] - if args.no_forward: - cmd_args.append('--forward=false') - page_limit = min(int(remaining), 200) if args.limit > 0 else 0 - if page_limit > 0: - cmd_args.extend(['--limit', str(page_limit)]) - data = run_dws(cmd_args, dry_run=args.dry_run) - - if args.dry_run: - print('[dry-run] 翻页循环: hasMore → 继续用边界 createTime 作为 --time') - return - - if not data: - break - - # 兼容两种结构: 顶层 messages / {result: {messages, hasMore}} - if isinstance(data, list): - page_msgs = data - has_more = False - else: - container = data - inner = data.get('result') - if isinstance(inner, dict): - container = inner - page_msgs = container.get('messages') - if page_msgs is None and isinstance(inner, list): - page_msgs = inner - if not isinstance(page_msgs, list): - page_msgs = [] - has_more = bool(container.get('hasMore', False)) - - if not page_msgs: - break - - all_messages.extend(page_msgs) - remaining -= len(page_msgs) - page += 1 - - if not has_more: - break - - last_msg = page_msgs[-1] - boundary_time = (last_msg.get('createTime') - or last_msg.get('createAt') - or last_msg.get('time', '')) - if not boundary_time or boundary_time == current_time: - break - current_time = boundary_time - print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...") - - if not all_messages: - print('未拉取到消息') - return - - if args.output: - with open(args.output, 'w', encoding='utf-8') as f: - json.dump(all_messages, f, ensure_ascii=False, indent=2) - print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}") - else: - for m in all_messages: - # 实际字段: sender/createTime/content (兼容旧字段名) - sender = (m.get('sender') or m.get('senderNick') - or m.get('senderOpenDingTalkId', '未知')) - text = m.get('content') or m.get('text', '') - time_str = (m.get('createTime') or m.get('createAt') - or m.get('time', '')) - print(f" [{time_str}] {sender}: {text[:80]}") - print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)") - - -if __name__ == '__main__': - main() diff --git a/skills/mono/scripts/chat_history_with_user.py b/skills/mono/scripts/chat_history_with_user.py deleted file mode 100644 index b007c7be..00000000 --- a/skills/mono/scripts/chat_history_with_user.py +++ /dev/null @@ -1,195 +0,0 @@ -#!/usr/bin/env python3 -""" -查询与某人的单聊聊天记录 - -用法: - python chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00" - python chat_history_with_user.py --user --time "2026-03-10 00:00:00" --limit 50 - python chat_history_with_user.py --name "张三" --time "2026-03-01 00:00:00" --output history.json - -工作流: - 1. 通过 --name 搜索通讯录,获取 userId(或直接传 --user) - 2. 调用 chat message list --user 拉取单聊消息 - 3. 输出到终端或导出为 JSON 文件 -""" - -import sys -import json -import subprocess -import argparse -from typing import List, Any, Optional - - -def run_dws( - args: List[str], dry_run: bool = False, -) -> Optional[Any]: - """执行 dws 命令并解析 JSON 输出""" - cmd = ['dws'] + args - if dry_run: - print(f"[dry-run] {' '.join(cmd)}") - return None - try: - result = subprocess.run( - cmd, capture_output=True, text=True, timeout=120 - ) - if result.returncode != 0: - print(f"错误:{result.stderr.strip()}", file=sys.stderr) - return None - return json.loads(result.stdout) - except (subprocess.TimeoutExpired, json.JSONDecodeError, - FileNotFoundError) as e: - print(f"错误:{e}", file=sys.stderr) - return None - - -def search_user( - name: str, dry_run: bool = False, -) -> Optional[str]: - """按关键词搜索用户,返回第一个匹配的 userId""" - data = run_dws([ - 'contact', 'user', 'search', - '--query', name, '--format', 'json', - ], dry_run=dry_run) - if dry_run: - return '' - if not data: - return None - # 解析返回结构 - users = data - if isinstance(data, dict): - inner = data.get('result', data) - if isinstance(inner, dict): - users = (inner.get('users', []) - or inner.get('list', [])) - elif isinstance(inner, list): - users = inner - else: - users = [] - if not users or not isinstance(users, list): - print(f"未找到用户: {name}") - return None - u = users[0] - user_name = u.get('name') or u.get('nick', '未知') - user_id = u.get('userId') or u.get('userid', '') - print(f" 找到用户: {user_name} ({user_id})") - return user_id - - -def main(): - parser = argparse.ArgumentParser( - description='查询与某人的单聊聊天记录' - ) - group = parser.add_mutually_exclusive_group(required=True) - group.add_argument('--name', help='按姓名搜索用户') - group.add_argument('--user', help='直接指定 userId') - parser.add_argument( - '--time', required=True, - help='起始时间 yyyy-MM-dd HH:mm:ss', - ) - parser.add_argument( - '--no-forward', action='store_true', - help='拉给定时间之前的消息 (默认拉给定时间之后)', - ) - parser.add_argument( - '--limit', type=int, default=0, - help='返回条数 (不传则不限制)', - ) - parser.add_argument('--output', default='', help='导出到 JSON 文件') - parser.add_argument('--dry-run', action='store_true') - args = parser.parse_args() - - # 1. 获取 userId - user_id = args.user - if not user_id: - print(f'🔍 搜索用户: {args.name}') - user_id = search_user(args.name, args.dry_run) - if not user_id and not args.dry_run: - sys.exit(1) - - # 2. 拉取单聊消息(自动翻页) - print(f'📥 拉取与 {user_id} 的聊天记录 (起始: {args.time})...') - all_messages: List[Any] = [] - current_time = args.time - page = 0 - max_pages = 50 - remaining = args.limit if args.limit > 0 else float('inf') - - while page < max_pages and remaining > 0: - cmd_args = [ - 'chat', 'message', 'list', - '--user', user_id or '', - '--time', current_time, - '--format', 'json', - ] - if args.no_forward: - cmd_args.append('--forward=false') - page_limit = min(int(remaining), 200) if args.limit > 0 else 0 - if page_limit > 0: - cmd_args.extend(['--limit', str(page_limit)]) - data = run_dws(cmd_args, dry_run=args.dry_run) - - if args.dry_run: - print('[dry-run] 翻页循环: hasMore → 继续用边界 createTime 作为 --time') - return - - if not data: - break - - # 兼容两种结构: 顶层 messages / {result: {messages, hasMore}} - if isinstance(data, list): - page_msgs = data - has_more = False - else: - container = data - inner = data.get('result') - if isinstance(inner, dict): - container = inner - page_msgs = container.get('messages') - if page_msgs is None and isinstance(inner, list): - page_msgs = inner - if not isinstance(page_msgs, list): - page_msgs = [] - has_more = bool(container.get('hasMore', False)) - - if not page_msgs: - break - - all_messages.extend(page_msgs) - remaining -= len(page_msgs) - page += 1 - - if not has_more: - break - - last_msg = page_msgs[-1] - boundary_time = (last_msg.get('createTime') - or last_msg.get('createAt') - or last_msg.get('time', '')) - if not boundary_time or boundary_time == current_time: - break - current_time = boundary_time - print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...") - - if not all_messages: - print('未拉取到消息') - return - - # 3. 输出结果 - if args.output: - with open(args.output, 'w', encoding='utf-8') as f: - json.dump(all_messages, f, ensure_ascii=False, indent=2) - print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}") - else: - for m in all_messages: - # 实际字段: sender/createTime/content (兼容旧字段名) - sender = (m.get('sender') or m.get('senderNick') - or m.get('senderOpenDingTalkId', '未知')) - text = m.get('content') or m.get('text', '') - time_str = (m.get('createTime') or m.get('createAt') - or m.get('time', '')) - print(f" [{time_str}] {sender}: {text[:80]}") - print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)") - - -if __name__ == '__main__': - main() diff --git a/skills/multi/dingtalk-chat/SKILL.md b/skills/multi/dingtalk-chat/SKILL.md index f0d7f8ad..d28df63b 100644 --- a/skills/multi/dingtalk-chat/SKILL.md +++ b/skills/multi/dingtalk-chat/SKILL.md @@ -11,11 +11,19 @@ metadata: # 钉钉群聊 / 消息 Skill -## 前置条件 — 执行操作前必读 + +## 最小 DWS 执行契约 -> **CRITICAL — 执行任何 `dws` 操作前,MUST 先用 Read 工具完整读取 [`dws-shared`](../dws-shared/SKILL.md)。**该轻量文件包含全局执行契约、安全底线及 shared references 的按需加载导航;不要预加载其全部 references。 - -> 命令参考:[chat.md](references/chat.md);表情:[chat-emoji-list.md](references/chat-emoji-list.md);剧本:[01-messaging.md](references/01-messaging.md)。 +- 只通过 `dws` CLI 操作钉钉;结构化读取使用 `--format json`,按真实返回判断结果。 +- 已知命令直接执行。只有 leaf 参数或安全语义不确定时读取精确 Schema,只有 Cobra flag 不确定时读取精确 leaf Help;不要加载产品级 Catalog 代替选路。 +- 不猜命令、flag、字段、ID、账号或时间。后续 ID 必须来自真实返回;零命中、多候选或类型不明时停止并消歧。 +- 解析目标、读取上下文和最终执行必须使用同一 profile;不得跨组织复用 userId、openDingTalkId 或 openConversationId。多账号组织只使用明确的 `isOrgCurrent=true` 默认账号;没有默认账号时要求用户指定,禁止选择第一项、最近登录或最近使用账号。 +- 不输出或记录 token、refresh token、appSecret、webhook token 等凭据;宿主已注入认证时不要索要凭据。 +- 写操作必须符合用户明确意图。是否需要确认以最终 Runtime gate 和 Schema 为准;需要确认时先说明对象、动作与影响,再追加 `--yes`。 +- 写后按任务结果契约验证;不能仅凭退出码宣称成功。部分结果、未知投递状态和失败项必须如实保留。 +- 时间戳面向用户展示时转换为带时区的可读时间;默认使用当前会话时区,必要时同时保留原值。 +- 遇到认证、权限、profile、confirmation 或未知错误时,只加载 `dws-shared` 中对应 reference;不要连续猜测替代命令。 + ## Shortcut 发现(按需) @@ -25,112 +33,84 @@ metadata: 仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service chat --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。 -## IM Shortcut 优先路由 +## Golden Route -- 统一发送优先用 `chat +messages-send`:通过 `--as user|bot|webhook` 选择身份,并只传该身份真实支持的目标、内容和凭据。仅在需要原子命令的特定返回结构或兼容参数时,才降级到 `chat message send` / `send-by-bot` / `send-by-webhook`。 -- 消息查询按意图选择:指定会话用 `+chat-messages`,跨维度过滤/全量翻页用 `+search-msg`,@我用 `+at-me`,已知消息 ID 批量富化用 `+messages-mget`,已知 thread/topic ID 读取回复用 `+thread-replies`。 -- 查询结果中的 `resourceRefs` 是可继续执行的资源上下文。引用、回复或合并转发中的子消息必须使用该子消息返回的 `messageId`;子消息缺会话 ID 时才继承父消息的 `openConversationId`。 -- 上述五个查询 Shortcut 与 `+messages-resource-download` 都沿用安全本地下载的 `read/not_required` 契约,不应添加 `--yes` 或触发交互确认。下载只允许工作目录内相对路径、默认不覆盖并原子落盘;需要覆盖时必须由用户显式传 `--overwrite`。 -- `+messages-send` 会按身份规范化 @ 占位符:user 使用 `<@id>` / `<@all>`,bot/webhook 使用 `@id` / `@手机号` / `@all`;只需声明 `--at-*` / `--at-all`,缺失占位符会自动补齐。 -- `+messages-send --as user --user ` 会通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId,所有内容类型与 `--dry-run` 都使用同一只读解析链路;已有 openDingTalkId 时直接传 `--open-dingtalk-id`。 -- 流式卡片优先用 `+messages-send-card`:群聊传 `--group`,单聊 userId 传 `--receiver` 并由 CLI 通过通讯录关键词搜索做 userId 精确匹配,已有 openDingTalkId 则必须显式传 `--receiver-open-dingtalk-id`;三者严格三选一,禁止根据首字母猜身份类型。 +按用户任务选择最小充分入口。公开层按意图分流;Resolver、发送执行、消息投影和错误契约在 Runtime 内复用,不把所有能力塞进一个万能命令。 -## 意图表 +| 用户意图 | 唯一推荐入口 | 关键边界 | +|---|---|---| +| 按姓名发简单文本或 Markdown | `dws chat +dm --to <姓名> --text <内容>` | CLI 解析唯一用户;多候选时停止,不先手工查 ID | +| 按群名或 ID 发简单文本或 Markdown | `dws chat +send-to-group --group <群名或ID> --text <内容>` | 稳定 ID 直接使用;群名多候选时停止 | +| 文件、Bot、Webhook、复杂 @ 或高级发送 | `dws chat +messages-send` | Bot 多群用 `--groups/--groups-file` 并检查逐项 ledger | +| 读取指定群聊或单聊 | `dws chat +chat-messages` | 全量加 `--page-all`,检查完整性 ledger | +| 跨会话多维搜索 | `dws chat +search-msg` | 内容用 `--query`;检查 `complete` | +| 查看指定群成员(用户/机器人) | `dws chat +chat-members-list --group <群名或ID>` | 唯一解析并全量读取 | +| 获取群邀请链接 | `dws chat +chat-invite-url --group <群名或ID>` | 多候选时停止 | +| 查看群机器人 | `dws chat +chat-bots --group <群名或ID>` | 返回稳定 `bots[]` | +| 修改群名称 | `dws chat group rename --id --name <新名称>` | 只知群名时先用 `+chat-search --query <群名>` 唯一解析 ID;不猜 `+chat-rename` | +| 查看指定群内 @我的消息 | `dws chat +at-me --group <群名>` | 空结果仍返回数组 | +| 查看全部会话 | `dws chat +conversation-list --page-all` | 检查 `complete` / `failures` | +| 读取并下载消息资源 | 查询命令加 `--download-resources` | 不另起手工下载循环;下载失败项保留在结果中 | +| 查看置顶会话 | `dws chat +conversation-list-top` | 会话 Top 与消息 Pin、消息 Top、Favorite 不同 | +| 监听 IM 事件 | 切换 `dingtalk-event` | 由事件 Skill 选择确定的 EventKey 和生命周期 | -| 用户说 | 命令 | -|--------|------| -| "发消息给张三" | `dws chat +messages-send --as user --open-dingtalk-id --text "<内容>"` | -| "发到XX群" | `dws chat +chat-search --query "<群名>"` → `dws chat +messages-send --as user --chat-id --text "<内容>"` | -| "建群" / "拉人进群" | `dws chat group create` / `dws chat group members add` | -| "改群名" / "踢人" | `dws chat group rename` / `dws chat group members remove --yes`(踢人不可逆,先确认目标) | -| "@我消息" | `dws chat +at-me` | -| "查群聊记录" | `dws chat +chat-messages --group ` | -| "收藏/取消收藏这条消息" | `dws chat +flag-create` / `dws chat +flag-cancel` | -| "查看我收藏的消息" | `dws chat +flag-list` | -| "用机器人发消息" | `dws chat +messages-send --as bot --robot-code --chat-id --text "<内容>"` | -| "Webhook 推一条" | `dws chat +messages-send --as webhook --webhook-token --text "<内容>"` | -| "发送流式卡片" | `dws chat +messages-send-card --group --content "<内容>"`;单聊 userId 改用 `--receiver`,openDingTalkId 改用 `--receiver-open-dingtalk-id` | -| "撤回消息" | `dws chat message recall --conversation-id --msg-id ` | -| "标记未读 / 清除红点 / 全部已读" | `dws chat mark-unread` / `dws chat clear-red-point` / `dws chat clear-all-red-point` | -| "置顶某条消息 / 取消消息置顶" | `dws chat message set-top-msg` / `dws chat message unset-top-msg` | -| "我加入的所有群 / 全部群列表" | `dws chat group list-all` | +以下次级入口在意图明确时直接使用,不需要先加载完整 Catalog: -## 标准 SOP(必遵流程) +| 用户意图 | 入口 | +|---|---| +| 已知消息 ID 批量读取详情 | `dws chat +messages-mget` | +| 已知资源引用单独下载 | `dws chat +messages-resource-download` | +| 引用回复 | `dws chat +messages-reply`;成功结果保留新消息/会话/投递与原消息来源上下文 | +| 撤回当前用户消息 | `dws chat +messages-recall --msg-id `;可省略会话 ID,由 CLI 只读补齐;兼容单值 `--message-ids` | +| 已知 thread/topic ID 读取回复 | `dws chat +thread-replies` | +| 按成员 ID 或姓名创建群聊 | `dws chat +chat-create`;成员/群主均可自然解析,任一歧义都会在创建前整体停止 | +| 跨全部会话查看 @我的消息 | `dws chat +at-me` | -> 命中以下意图**必须**按对应 SOP 顺序执行;**禁止**跳步、替换命令、编造 ID。每条命令必须带 `--format json`,执行后必须按"解析"步取真实字段(`openDingTalkId` / `openConversationId` / `openMessageId`)。 +### 发送入口边界 -### SOP-1 发消息(send-message) +- `+dm`:姓名目标的简单文本/Markdown,参数空间最小。 +- `+send-to-group`:群名或稳定 ID 目标的简单文本/Markdown,避免暴露无关身份矩阵。 +- `+messages-send`:文件、Bot、Webhook、复杂 @ 或幂等控制。user 已知 ID 可直接传,也可用 `--user-query` / `--chat-query` 运行同一只读解析链;Bot 多群使用 `--groups/--groups-file`,返回 `im.batch-write.v1`;bot/webhook 只使用下层真实支持的文本/Markdown 能力。 +- 文件直接传 `+messages-send --file <相对路径>`;不要先独立上传并提取 mediaId。 +- Webhook 使用 `+messages-send --as webhook --webhook-token `;不要退回原子 Webhook 命令。 +- 流式卡片不是普通消息内容,使用 `+messages-send-card`;当前只支持 streaming text create/update,不支持 Card JSON 或 callback。 -**触发**:发消息/单聊/通知某人/发到群里。 +## 关键结果语义 -1. **解析收件人(必须)**:人名 → 先 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 取 `openDingTalkId`(优先)或 `userId`;群名 → 先 `dws chat search --query "<群名>" --format json` 取 `openConversationId`。 -2. **执行(必须)**:单聊 `dws chat message send --open-dingtalk-id --text "<内容>" --format json`(只有拿不到 `openDingTalkId` 时才用 `--user `);群聊 `dws chat message send --group --text "<内容>" --format json`。 -3. **验证(必须)**:发送接口成功时可能只返回 `result.openTaskId`,它可用于确认提交成功,但**不是撤回所需消息 ID**。需要撤回时必须按 SOP-7 用带 `--time` 的 `message list` 回查,取得真实 `openMessageId`;返回非 `success` 必须如实报错,不要谎报已发。 +- `openTaskId` 是发送任务 ID,不是回复或撤回所需的消息 ID;消息 ID 必须来自真实查询结果。 +- 消息查询默认保留稳定 ID、会话/thread、发送者、文本、时间、reaction、引用、转发和 `resourceRefs`;`--no-reactions` 可关闭 reaction。 +- 查询结果必须检查 `complete`、`hasMore`、`failures` 和资源下载 ledger;partial result 不得表述为完整成功。 +- 子消息使用自己的 `messageId`;仅缺会话 ID 时继承父消息的 `conversationId`。 +- 下载只允许工作目录内安全相对路径,默认不覆盖并原子落盘;覆盖必须由用户显式传 `--overwrite`。读取和下载不需要 `--yes`。 +- Favorite、消息 Pin、消息 Top、会话 Top 是不同对象层级,不能互换。 -**禁止**:把人名/群名直接当 ID 传入、跳过 `aisearch person`/`chat search` 解析、跳过 `--format json`、未发送成功就答复"已发送"。 +## 按需加载 -### SOP-2 建群(create-group) +只在任务命中时读取一个精确 reference: -**触发**:建群/拉人进群/新建讨论组。 +| 场景 | Reference | +|---|---| +| 复杂发送、跨会话转发、共同群或组合流程 | [01-messaging.md](references/01-messaging.md) | +| 引用、转发、卡片、reaction、Pin/Top/Favorite | [chat-message.md](references/chat/chat-message.md) | +| 建群、成员、管理员、群公告、群设置 | [chat-group.md](references/chat/chat-group.md) | +| Bot 搜索、入群、群发和撤回 | [chat-bot.md](references/chat/chat-bot.md) | +| 会话置顶、分类、红点、免打扰和隐藏 | [chat-conversation.md](references/chat/chat-conversation.md) | +| 低频意图之间仍需消歧 | [intent-guide.md](references/intent-guide.md) | +| 表情名称与 ID | [chat-emoji-list.md](references/chat-emoji-list.md) | +| 稳定结果、身份矩阵与能力边界 | [contracts.md](references/contracts.md) | +| 流式卡片创建 | [card/create.md](references/card/create.md) | +| 流式卡片更新 | [card/update.md](references/card/update.md) | +| 卡片 callback 是否可用 | [card/callback.md](references/card/callback.md) | +| 卡片公开 Schema 边界 | [card/schema.md](references/card/schema.md) | +| 只有上述 reference 仍无法定位的原子能力 | [chat.md](references/chat.md) 的对应章节 | -1. **解析成员(必须)**:对每个成员 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 取 `userId`,多人英文逗号拼接。 -2. **执行(必须)**:`dws chat group create --name "<群名>" --users --format json`;外部群加 `--type EXTERNAL`,话题圈加 `--thread`。 -3. **验证(必须)**:从返回取 `openConversationId`,可用 `dws chat search --query "<群名>" --format json` 复核。 +不要预加载这些 reference。完整 Shortcut Catalog 只在根路由和精确 reference 都无法定位低频能力时使用。 -**禁止**:跳过成员 userId 解析直接传姓名、编造 `openConversationId`。 +## 错误最短路径 -### SOP-3 Webhook 推送(send-by-webhook) - -**触发**:用机器人群 webhook 推一条消息。 - -1. **执行(必须)**:`dws chat message send-by-webhook --token --title "<标题>" --text "<内容>" --format json`。 -2. **@ 人(必须)**:需要 @ 时,`--text` 中**必须**先包含对应 `@userId` / `@手机号` / `@10`,再配合 `--at-users` / `--at-mobiles` / `--at-all`;否则 @ 不生效。 - -**禁止**:只传 `--at-users` 而 `--text` 里不含 `@<标识>`。 - -### SOP-4 共同群查询(search-common-group) - -**触发**:"我和 XX 的共同群"。 - -1. **取昵称(必须)**:先 `dws contact user get-self --format json` 取自己昵称;对方昵称从历史/上下文取,拿不到必须先问用户。 -2. **执行(必须)**:`dws chat search-common --nicks "<昵称1>,<昵称2>" --limit 20 --cursor 0 --format json`;`hasMore=true` 时**必须**用 `nextCursor` 翻页,不要停在第一页。 - -**禁止**:跳过昵称解析、忽略 `hasMore` 不翻页。 - -### SOP-5 红点 / 未读管理(manage-red-point) - -**触发**:标记未读/清除红点/全部已读。 - -1. **执行(必须)**:标记某会话未读 `dws chat mark-unread --conversation-id --format json`;清除某会话红点 `dws chat clear-red-point --conversation-id --format json`;全部已读 `dws chat clear-all-red-point --format json`。 -2. **取会话 ID(必须)**:`openConversationId` 拿不准时先 `dws chat group list-all --format json` 或 `dws chat search --query "<群名>" --format json`,**禁止**编造。 - -**禁止**:未确认会话就批量"全部已读"(破坏性,必须先与用户确认)。 - -### SOP-6 特别关注消息(focus-messages) - -**触发**:"特别关注的人最近发了什么/聊了什么"。 - -1. **执行(必须)**:`dws chat message list-focused --limit 50 --format json`,直接基于返回答复。 -2. **边界(必须)**:只有用户终点是"我关注了谁"这种**人员列表**时,才切 `dingtalk-contact` 关系查询。 - -**禁止**:用普通 `message list` 冒充 focused、把人员列表需求硬塞进 chat。 - -### SOP-7 拉取 / 撤回消息(list-or-recall-message) - -**触发**:查某个群或单聊的聊天记录、撤回某条消息。 - -1. **定位会话(必须)**:群名先 `dws chat search --query "<群名>" --format json` 取 `openConversationId`;单聊对象先解析真实 `userId` 或 `openDingTalkId`。 -2. **拉取消息(必须)**:`dws chat message list --group --time "" --direction older --format json`;单聊将 `--group` 换成 `--user` 或 `--open-dingtalk-id`。`--time` 是必填参数,必须来自用户时间范围或明确收敛后的边界。 -3. **撤回(必须)**:仅在用户明确要求撤回时,从消息列表 `result.messages[].openMessageId` 取真实 ID,执行 `dws chat message recall --conversation-id --msg-id --format json`。 - -**禁止**:省略 `--time`、用发送返回的 `clientMsgId` 代替 `openMessageId`、只传 `--client-msg-id`、编造会话或消息 ID。 - -## 跨产品协作 - -- 收件人是人名 → 先用 `dingtalk-contact` 或 `dingtalk-aisearch` 拿 `openDingTalkId` / `userId` -- 要发图片/文件 → 先 `dt_media_upload` 上传 → `python scripts/extract_media_id.py ""` 提取 mediaId → 再用 `--media-id` -- 紧急升级(应用内/短信/电话)→ 切到 `dingtalk-misc`(`references/ding.md`) -- 发邮件 → 切到 `dingtalk-mail` -## 局部意图与短流程 - -- [局部意图消歧](references/intent-guide.md);[短流程](references/lite-recipes.md)。 +1. resolution 返回零命中或多候选:停止写操作,展示候选并让用户消歧;禁止默认第一项。 +2. `unknown command` / `unknown flag`:读取精确 leaf Help,修正后最多重试一次。 +3. 参数约束或 confirmation 不清楚:读取精确 leaf Schema,以 Runtime gate 为准。 +4. 认证、权限、profile 或 confirmation 错误:读取 `dws-shared` 的对应 reference;正常 IM 不读取完整 shared Skill。 +5. `backend_dependency_unavailable`:保持原参数,对只读命令最多重试一次;不要改 flag、猜认证命令或切换同义原子命令,持续失败时保留 Trace ID。 +6. 其他错误:保留真实错误和已完成/失败项;不要连续尝试同义原子命令。 diff --git a/skills/multi/dingtalk-chat/references/01-messaging.md b/skills/multi/dingtalk-chat/references/01-messaging.md index 1f043cd6..b301ee04 100644 --- a/skills/multi/dingtalk-chat/references/01-messaging.md +++ b/skills/multi/dingtalk-chat/references/01-messaging.md @@ -1,13 +1,91 @@ -# 消息沟通 +# 消息任务级流程 -> lite recipe(`send-message`、`create-group`、`send-by-webhook`、`search-common-group`、`focus-messages`)见 [lite-recipes.md](./lite-recipes.md)。 +只在单个 Golden Route 不能完成任务、需要跨步骤传递真实结果时读取本文件。简单姓名/群名文本发送、单会话读取和跨会话搜索直接按根 Skill 执行。 -| Recipe | 行动指南(固定路线) | -|--------|-------------------| -| query-group-chat | **优先**:`python scripts/chat_export_messages.py --query "<群名>" --time "" [--no-forward] [--limit N] [--output messages.json]`(自动搜群+翻页+导出)
备选:1. `chat search --query "<群名>"` → 取 `openConversationId`
2. `chat message list --group --time ""` → 取消息列表
3. **翻页**:`hasMore=true` 时取本页最后 `createTime` 作为下次 `--time`,重复至 `hasMore=false`
4. `--forward=false` 拉给定时间**之前**的消息
5. 合并全部消息后总结 | -| query-private-chat | **优先**:`python scripts/chat_history_with_user.py --name "<姓名>" --time "" [--no-forward] [--limit N] [--output messages.json]`(自动搜人+翻页+导出)
备选:1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`
2. `chat message list --user --time ""` → 取消息列表
3. **翻页**:同 query-group-chat
4. 合并全部消息后总结 | -| escalate-ding | 三级升级:
1. `ding message send --robot-code --type app --users --content "<内容>"`(必填项见 `dingtalk-misc` 的 `references/ding.md`)
2. `chat message send --group --text "<内容>"` 群里提醒(可选 `--title` / `@` 见 [chat.md](./chat.md))
3. `todo task create --title "<标题>" --executors --priority 40` 建紧急待办
前置:`aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`;`chat search --query "<群名>"` → 取 `openConversationId` | -| send-by-bot | **多群批量优先**:`python scripts/bot_broadcast.py --robot-code --chats , --title "<标题>" --text "<内容>"`
单群:1. `chat bot search` → 取 `robotCode`
2. `chat search --query "<群名>"` → 取 `openConversationId`
3. `chat message send-by-bot --robot-code --group --title "<标题>" --text "<内容>"` | -| forward-message | 1. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message list --group --time "<起始时间>"` 拉源消息
2. `aisearch person --keyword "<姓名>" --dimension name` → 取 `openDingTalkId`(推荐);或 `chat search --query "<群名>"` → 取目标 `openConversationId`
3. `chat message send --open-dingtalk-id --text "<内容>"`(推荐)或 `--group --text "<内容>"` 发送。仅当无法获取 openDingTalkId 时才用 `--user `(备选) | -| search-common-group | `chat search-common --nicks "<昵称1>,<昵称2>" --limit 20 --cursor 0`(`--match-mode AND`=全在/`OR`=任一在,翻页:`hasMore=true` 时用 `nextCursor`)
用户说"我和XX的共同群" → nicks 包含"我"时,需先 `contact user get-self` 取自己昵称再拼接 | -| focus-messages | **零参数一行命令**:`chat message list-focused --limit 50`(拉特别关注人发的消息聚合)
触发 query:`"我特别关注的人最近发了什么消息"`、`"关注的人最近聊了啥"`、`"星标联系人最近的动态"`
**强消歧**:query 含动词【发/说/聊/讲】或名词【消息/聊天/动态】 → **必须**走本命令,**不要**先去拉 `contact relation list-my-followings`;仅当用户终点是"人员列表"(如"我关注了谁")才走 `relation list-my-followings`(详见 `dingtalk-contact` 易混淆硬规则)
翻页:`hasMore=true` 时用 `nextCursor` 作为下次 `--cursor`
按人精控(可选):先 `contact relation list-my-followings` 取 `openDingTalkId`,再 `chat message list-by-sender --sender-open-dingtalk-id --start --end ` | +## 选择路线 + +1. 先选择任务语义最窄的 Shortcut。 +2. 只有 Shortcut 暂不接受自然目标或目标类型时,才用一个只读 leaf/Shortcut 解析 ID。 +3. 解析全部完成并消歧后再写入;不要边解析边产生部分副作用。 +4. 后续步骤只使用真实返回字段,不从名称、URL 或上下文猜 ID。 + +## 群聊消息 + +读取指定群聊或单聊默认使用 `dws chat +chat-messages`;它负责目标唯一解析、稳定消息投影和完整性 ledger。 + +已知群 ID 时直接读取: + +```bash +dws chat +chat-messages --group --format json +``` + +要求全量或导出时直接使用 Runtime 能力: + +```bash +dws chat +chat-messages --group \ + --page-all --page-limit 50 \ + --output ./exports/messages.json \ + --format json +``` + +必须检查 `complete/hasMore/nextPage/stopReason/failures`;达到页数或结果上限不是来源完整。 + +只有群名时,读取历史直接用 `+chat-messages --group <群名>`,普通文本发送直接用 `+send-to-group`。其它尚不接受群名的高级动作才先用 `+chat-search --query <群名>`;只有唯一候选才把 `openConversationId` 传给下一步。查询结果需要资源时在读取命令上加 `--download-resources`,不要让 Agent 手工遍历资源引用。按姓名读取单聊时先解析唯一用户 ID,再传给 `+chat-messages --user`。 + +## 发送消息 + +- 姓名 + 简单文本:`dws chat +dm`。 +- 群名 + 简单文本:`dws chat +send-to-group`。 +- 已知 ID、文件、Bot、Webhook、复杂 @ 或幂等:`dws chat +messages-send`。 +- 姓名 + 文件/高级控制:`+messages-send --as user --user-query <姓名> --file <相对路径>`。 +- 群名 + 文件/高级控制:`+messages-send --as user --chat-query <群名> --file <相对路径>`。 +- Bot 多群文本/Markdown:`+messages-send --as bot --robot-code --groups `; + Runtime 去重并返回 `im.batch-write.v1` 逐目标 ledger,最多 100 个稳定群 ID。 + +`--user-query` 和 `--chat-query` 会在 CLI 内运行真实只读解析;零命中或多候选时在上传或发送前停止。Bot/Webhook 不接受这两个自然目标参数。 + +文件直接交给 `+messages-send --file`。不要恢复“独立上传 → 提取 mediaId → 发送”的旧默认链路。 + +## 创建群聊 + +基础建群默认使用 `dws chat +chat-create`;它同时接受 `--users` 稳定 ID 和 `--member-query` 姓名/花名。群主默认当前用户,也可用 `--owner-open-dingtalk-id` 或 `--owner-query` 明确指定。自然身份解析、候选消歧、稳定 ID 去重和创建前预检都由 CLI 完成: + +```text +传入全部姓名 +→ 对零命中和多候选统一消歧 +→ 按稳定 ID 去重 +→ 全部成功后执行一次 +chat-create +``` + +任一成员或群主未唯一解析时不会创建群;显式群主会加入初始成员且不再读取当前用户,省略群主时才以当前用户兜底。`--dry-run` 也走同一解析链。不要用群名预搜索伪装幂等,因为业务上允许同名群。 + +## 机器人消息 + +已知 `robotCode` 时使用 `+messages-send --as bot`;单群用 `--group`,多群用 `--groups` 或工作目录内安全的 `--groups-file`。未知机器人、机器人入群或撤回读取 [chat-bot.md](chat/chat-bot.md)。Bot 不继承 user 的文件/图片能力;只使用 leaf Schema 明确发布的文本/Markdown 能力。 + +## 引用与转发 + +- 引用回复:`dws chat +messages-reply`;优先继续使用结果中的 `messageId`、`conversationId`、`deliveryStatus` 和 `referencedMessage`,未知投递状态不得写成成功送达。 +- 单条转发:`+messages-forward`。 +- 合并转发:`+messages-combine-forward`。 +- 话题转发:`+messages-forward-topic`。 + +先用 `+chat-messages`、`+search-msg` 或 `+messages-mget` 取得真实 `messageId` 和 conversation/thread 上下文。引用或合并消息中的子消息优先使用自己的 `messageId`,不要拿父消息 ID 代替。 + +## 上下文传递表 + +| 上一步 | 真实返回 | 下一步用途 | +|---|---|---| +| `+chat-search` | `openConversationId` | 高级发送、读取、群管理 | +| `dingtalk-contact` 唯一用户解析 | `userId` / `openDingTalkId` | 单聊、建群、@、按发送者搜索 | +| `+messages-send` | `openTaskId` / 投递结果 | 查询投递状态;不是回复/撤回消息 ID | +| `+chat-messages` / `+search-msg` / `+messages-mget` | `messageId`、conversation/thread、`resourceRefs` | 回复、转发、撤回、资源下载 | +| `+chat-create` | `openConversationId` | 新群后续消息与群管理 | +| 分页查询 | `hasMore` / `nextCursor` / `complete` | 继续翻页和完整性判断 | + +## 完成判断 + +- 写操作检查任务级结果或可查询状态,不只看退出码。 +- 读取检查 `complete`、`hasMore` 和 `failures`。 +- 下载检查每项 ledger;单项失败不抹掉已取得消息。 +- 投递状态未知时报告 unknown 并保留幂等键,不自动换目标重发。 diff --git a/skills/multi/dingtalk-chat/references/card/callback.md b/skills/multi/dingtalk-chat/references/card/callback.md new file mode 100644 index 00000000..4cc819f5 --- /dev/null +++ b/skills/multi/dingtalk-chat/references/card/callback.md @@ -0,0 +1,10 @@ +# 卡片回调边界 + +当前 DWS lower interface 没有卡片按钮/action callback 的订阅、验签或回复能力, +`callback_supported=false`。因此: + +- 不生成 callback URL、签名密钥或虚构的监听命令; +- 不把 `dws event consume` 当作卡片 callback 的替代; +- 用户必须使用按钮交互时,停止并说明当前不支持,等待平台接口和 Runtime 正式发布。 + +卡片的 create/update 能力不代表 callback 可用。 diff --git a/skills/multi/dingtalk-chat/references/card/create.md b/skills/multi/dingtalk-chat/references/card/create.md new file mode 100644 index 00000000..41a8ed33 --- /dev/null +++ b/skills/multi/dingtalk-chat/references/card/create.md @@ -0,0 +1,15 @@ +# 创建流式卡片 + +使用 `dws chat +messages-send-card`。群目标传 `--group`;单聊 userId 传 `--receiver`, +Runtime 会唯一解析为 openDingTalkId;已有 openDingTalkId 时传 +`--receiver-open-dingtalk-id`。三种目标严格三选一。 + +- 只传目标:创建卡片并从真实结果取得 `bizId`,供后续更新。 +- 同时传 `--content`:Runtime 串行执行 create → 从返回提取 `bizId` → update;默认 + `--flow-status 3`。 +- `--dry-run` 仍执行只读 userId 解析,只输出两步计划,不执行写入。 + +创建成功但自动更新失败时,错误会保留真实 `bizId`。不要重复创建;使用该 `bizId` 继续 +`+messages-update-card` 或人工处理。 + +当前内容仅为 streaming text,不接受 Lark Card JSON、组件树或按钮 callback。 diff --git a/skills/multi/dingtalk-chat/references/card/schema.md b/skills/multi/dingtalk-chat/references/card/schema.md new file mode 100644 index 00000000..a7b1cae7 --- /dev/null +++ b/skills/multi/dingtalk-chat/references/card/schema.md @@ -0,0 +1,14 @@ +# 流式卡片 Schema + +DWS 当前公开的是 `im.streaming-card.v1` 工作流契约,不是任意组件 Schema: + +- target:group、direct user、direct openDingTalkId; +- content:streaming text; +- lifecycle:create 可选串联 update,后续按 `bizId` update; +- flowStatus:1–5; +- callback:不支持。 + +参数、required 和 confirmation 读取 +`dws schema --cli-path "chat +messages-send-card" -f json` 或 +`dws schema --cli-path "chat +messages-update-card" -f json`。不要把 Lark card JSON 字段翻译成 +未发布的 DWS flags。 diff --git a/skills/multi/dingtalk-chat/references/card/update.md b/skills/multi/dingtalk-chat/references/card/update.md new file mode 100644 index 00000000..567161f5 --- /dev/null +++ b/skills/multi/dingtalk-chat/references/card/update.md @@ -0,0 +1,9 @@ +# 更新流式卡片 + +使用 `dws chat +messages-update-card --biz-id --content <文本> --flow-status <1..5>`。 + +状态为:1 processing、2 typing、3 completed、4 executing、5 error。Runtime 拒绝范围外的 +状态;正常完成的最后一次更新应为 3。`bizId` 必须来自真实创建结果,不能用消息 ID 代替。 + +更新是写操作,confirmation 以精确 leaf Schema 与 Runtime gate 为准。失败后保留原 +`bizId` 和状态,不创建新卡片来掩盖更新失败。 diff --git a/skills/multi/dingtalk-chat/references/chat-emoji-list.md b/skills/multi/dingtalk-chat/references/chat-emoji-list.md index 338710fd..4b86ee72 100644 --- a/skills/multi/dingtalk-chat/references/chat-emoji-list.md +++ b/skills/multi/dingtalk-chat/references/chat-emoji-list.md @@ -2,6 +2,13 @@ > 本文件列出钉钉支持的所有用户可见默认表情(showType=1),共 199 个。 > +> **来源与维护合同(reviewed snapshot)**:表格来自钉钉默认表情 catalog 的 showType=1 +> 人工复核快照;当前 lower interface 没有可靠的“列出默认 emoji”命令,因此本文件不是从 +> Runtime 或旧 Catalog 反向生成。更新时必须取得新的官方/真实客户端 catalog,核对 +> emotionId、中文 name、en_US、showType 和总数,说明来源与复核日期,再整体替换表格;禁止 +> 根据用户输入或单次 reaction 响应增补。生成策略为 **non-generated reviewed reference**, +> Schema/Skill 生成器不得机械重写它。最后复核:2026-08-03。 +> > 使用规则: > - 用户描述的表情命中下表中的 `name` → 使用 `chat message add-emoji --emoji ` 贴 emoji 回应 > - 用户描述的表情未命中下表 → 先 `chat message create-text-emotion` 创建文字表情获取 emotionId,再 `chat message add-text-emotion` 贴文字表情回应 diff --git a/skills/multi/dingtalk-chat/references/chat.md b/skills/multi/dingtalk-chat/references/chat.md index 09cc372f..58b56ad9 100644 --- a/skills/multi/dingtalk-chat/references/chat.md +++ b/skills/multi/dingtalk-chat/references/chat.md @@ -1,363 +1,135 @@ -# 会话与群聊 (chat) 命令参考 +# Chat 低频原子能力索引 -> **渐进式文档**:本文件为路由层(索引 + 意图判断 + 全局约束),详细参数、示例和踩坑说明在 [chat/](./chat/) 目录下按场景加载。 +> 返回入口:[DingTalk Chat Skill](../SKILL.md) -> 命令别名:`dws im` 等价于 `dws chat`。 +本文件只用于根 Skill 和精确 task reference 都未覆盖的低频底层能力。普通发送、读取、搜索、 +建群、引用回复和查看置顶会话必须回到根 Skill 的 Golden Route,不在这里重新选路。 -## Shortcut 优先路由 +## 使用边界 -常见 Agent 意图优先使用公开 `+` Shortcut;原子命令保留给需要特定原始返回结构、兼容参数或 Shortcut 未覆盖字段的场景。执行前用 `dws schema --cli-path "chat +" --format json` 读取最终参数、约束和默认确认语义。 +1. 先确认任务确实需要 Shortcut 未发布的底层字段、原始响应或运维控制; +2. 读取精确原子 leaf Schema/Help,不加载产品级 Catalog 猜参数; +3. 自然目标仍必须唯一解析,禁止选择搜索结果第一项; +4. 原子写 leaf 的 confirmation 若与对应 Golden Shortcut 不一致,停止并报告交付漂移; +5. 后续 ID 只使用当前 profile 的真实返回,不跨组织复用; +6. 完成后保留原始结果、partial failure 和可继续编排的稳定 ID。 -| 意图 | 首选 | +## 高频任务返回表 + +| 用户终点 | 返回入口 | |---|---| -| 以 current-user / bot / webhook 身份发消息 | `dws chat +messages-send --as ...` | -| 拉取单个群聊或单聊的消息 | `dws chat +chat-messages ...` | -| 按关键词、发送者、@对象、会话、类型或时间组合搜索 | `dws chat +search-msg ...` | -| 查询 @我的消息 | `dws chat +at-me ...` | -| 根据消息 ID 批量取详情与 reaction | `dws chat +messages-mget ...` | -| 读取已知 thread/topic 的全部回复 | `dws chat +thread-replies ...` | -| 下载单个 mediaId/fileId | `dws chat +messages-resource-download ...` | +| 姓名/群名简单发送、文件、Bot、Webhook、复杂 @ | 根 Skill Golden Route | +| 单会话消息、跨会话搜索、资源下载 | [消息任务级流程](01-messaging.md) | +| 引用、转发、卡片、reaction、Pin/Top/Favorite | [chat-message](chat/chat-message.md) | +| 基础建群、成员、公告、管理员和群设置 | [chat-group](chat/chat-group.md) | +| Bot 搜索、进群和撤回 | [chat-bot](chat/chat-bot.md) | +| 会话置顶、状态和分组 | [chat-conversation](chat/chat-conversation.md) | +| 相邻低频意图仍需消歧 | [intent-guide](intent-guide.md) | -- `+messages-send` 只暴露下层真实支持的身份能力,并自动规范化、补齐对应身份的 @ 占位符。 -- `+search-msg --page-all` 连续翻页并默认按消息 ID 批量富化;续页或富化失败会保留已取得结果并返回逐项失败 ledger。 -- 五个查询 Shortcut 的 `--download-resources` 沿用安全本地下载的 `read/not_required` 契约,不应添加 `--yes` 或触发交互确认。引用、回复、合并转发中的资源使用 `resourceRefs` 自带的子消息 `messageId`;仅当子消息缺会话 ID 时继承父消息 `openConversationId`。 -- `+messages-resource-download` 同样无需交互确认,但只允许工作目录内相对路径、默认拒绝覆盖并原子落盘;需要覆盖时必须由用户显式传 `--overwrite`。 -- 下载器只接受经审查的钉钉与公网 OSS HTTPS 地址并逐跳校验重定向;跨主机时不会转发下层提供的请求头。 +## 消息底层能力 -## 适用范围与安全硬约束 +| 原子命令 | 仅用于 | +|---|---| +| `chat message send` | `+messages-send` 尚未发布的位置、名片等真实底层消息类型 | +| `chat message list` | 需要原始响应或显式手工 continuation;普通浏览使用 `+chat-messages` | +| `chat message list-all` | 指定时间范围的原始全会话分页接口 | +| `chat message list-by-sender` | 需要原始按发送者响应;普通组合搜索使用 `+search-msg` | +| `chat message list-mentions` / `list-focused` | 精确的 @我或特别关注原始列表 | +| `chat message search` / `search-advanced` | `+search-msg` 未发布的底层过滤字段或原始响应 | +| `chat message query-send-status` | 使用真实 `openTaskId` 查询用户消息投递任务 | +| `chat message recall` / `edit` | 撤回或编辑已知消息 | +| `chat message read-status` | 查询已知消息的已读/未读状态 | +| `chat message reply` | `+messages-reply` 未发布的底层引用字段,且安全门禁已对齐 | +| `chat message forward` / `combine-forward` / `forward-topic` | Shortcut 未覆盖的精确转发字段 | +| `chat message list-topic-replies` | 已知 `openConvThreadId` 的原始话题回复列表 | +| `chat message download-media` | Shortcut 无法消费的已知底层 mediaId/fileId 引用 | -`chat` 覆盖钉钉会话、群聊、群成员、会话消息、机器人消息、Webhook、会话状态和群身份管理。 +消息对象管理: -**发消息前参数审查(必须执行)**: +| 原子命令 | 对象 | +|---|---| +| `message set-pin-msg` / `unset-pin-msg` / `list-pin-msg` | 消息 Pin | +| `message set-top-msg` / `unset-top-msg` | 会话内消息 Top | +| `message add-favorite` / `remove-favorite` / `list-favorites` | 当前用户 Favorite | +| `message add-emoji` / `remove-emoji` | 默认 emoji reaction | +| `message create-text-emotion` / `add-text-emotion` / `update-text-emotion` / `remove-text-emotion` | 文字表情 | +| `message list-emotion-replies` | 批量 reaction/文字回应 | -- 发消息类命令包括 `chat message send`、`send-by-bot`、`send-by-webhook`、`send-card`、`reply`、`forward`。 -- 执行前必须逐项核对接收对象、群/人、消息内容、@ 对象、消息类型、附件路径,确认全部来自用户原始需求。 -- 用户没说发给谁、群名/人名可能匹配多个对象、消息文本由 Agent 组织、是否 @ 某人不明确时,必须先确认,严禁自行补全。 -- 重试发送建议复用同一个 `--uuid`,避免重复投递;不传 `--uuid` 时每次调用都视为新消息。 +Favorite、消息 Pin、消息 Top 与会话 Top 是四种对象,不能互换。 -**文件、图片、音频、视频发送硬规则**: +## 群与成员底层能力 -- 新场景发送本地文件、音频、视频统一用 `dws chat message send --msg-type file|audio|video --file-path <本地路径>`,CLI 内部完成上传和发送;`audio` / `video` 底层按 `file` 链路处理。 -- 不要先走 `dt_media_upload`、`extract_media_id.py`、`drive upload`;`--msg-type image --media-id` 只保留给已有 mediaId 的旧链路。 +| 原子命令 | 用途 | +|---|---| +| `chat search` / `search-common` | 群管理前解析唯一群、查询共同群 | +| `chat group get-by-group-id` | 数字群号转 `openConversationId` | +| `chat group create` | `+chat-create` 尚未发布的真实底层创建字段;`--thread` 和显式群主已由 Shortcut 覆盖 | +| `chat group members` / `members list-by-ids` | 群成员分页和精确详情 | +| `chat group members add` / `remove` | 添加/移除已知成员 ID | +| `chat group members add-bot` / `remove-bot` / `group bots` | 机器人进群、移除和列表 | +| `chat group rename` / `update-icon` | 群名和群头像 | +| `chat group transfer-owner` / `set-admin` | 群主和管理员 | +| `chat group upgrade-to-external` | 普通群升级外部群;不可逆 | +| `chat group invite-url` / `share-invite` | 群邀请链接及分享 | +| `chat group update-settings` / `user-settings query|set` | 管理员群开关或当前用户群偏好 | +| `chat group update-nick` / `update-alias` | 当前用户群昵称和群备注 | +| `chat group set-history` | 新成员历史消息可见范围 | +| `chat group-mute` / `group-mute-member` | 全员或指定成员禁言 | +| `chat group notice create|edit|get|list` | 群公告 | +| `chat group list-my-groups` / `list-all` | 当前用户相关群列表 | +| `chat group list-join-validations` / `audit-join-validation` | 入群审批 | +| `chat group-role *` | 群身份定义与成员分配 | -**Markdown 换行硬规则**: +退出、解散群、踢人、转让群主、升级外部群、禁言、管理员和公告写入都属于高影响操作; +必须以最终 Runtime gate/Schema 为准确认对象与影响。 -- `--text` 必须使用真实换行符 `U+000A`,不能传字面量 `\n`。 -- Markdown 单个换行通常不换行,需要空行 `\n\n`、行尾两个空格 + 换行,或 `
`。 -- 机器人 `message send-by-bot` 的 Markdown 文本优先用空行分隔段落;如果在模型内部用转义形式组织文本,必须写 `\n\n`,不要只写 `\n`。 +## Bot 与 Webhook 底层能力 -## 查询命令帮助 +| 原子命令 | 用途 | +|---|---| +| `chat bot search` | 搜索当前用户创建的机器人并取得 `robotCode` | +| `chat bot find` | 搜索可用机器人并取得机器人 `openDingTalkId` | +| `chat message send-by-bot` | `+messages-send --as bot` 未发布的真实底层字段 | +| `chat message recall-by-bot` | 使用 `processQueryKey` 撤回机器人消息 | +| `chat message send-by-webhook` | `+messages-send --as webhook` 未发布的真实底层字段 | -参数名、必填项或命令是否存在不确定时,先查 `--help`,不要猜。 +新发送流程统一使用 `+messages-send`。不得因看见 bot/webhook 原子命令就绕开统一身份能力矩阵。 -```bash -dws chat --help -dws chat message --help -dws chat message send --help -dws chat group --help -dws chat bot --help -``` +## 会话状态与分组 -## Reference 索引 +| 原子命令 | 用途 | +|---|---| +| `chat conversation-info` | 已知稳定用户/群 ID 的会话详情 | +| `chat list-all-conversations` | 全部会话原始分页列表 | +| `chat list-top-conversations` | 需要原始响应时的置顶会话 fallback;普通查看使用 `+conversation-list-top` | +| `chat set-top` | 设置/取消整个会话置顶 | +| `chat mute` / `hide` / `mute-at-all` / `mute-red-envelope` | 会话通知与可见状态 | +| `chat mark-unread` / `mark-read` | 会话未读或消息已读状态 | +| `chat clear-red-point` / `clear-all-red-point` | 清除会话红点 | +| `chat clear-messages` | 清空当前用户视角的会话记录 | +| `chat category *` | 自定义/智能会话分组 | -| Reference | 何时读取 | 主要命令 | -|-----------|----------|----------| -| [chat-message](./chat/chat-message.md) | 发消息、查消息、撤回、搜索、已读、话题、Pin/Top/Favorite、表情、卡片、文件、音频、视频、位置、名片、文本翻译 | `message *`、`file upload`、`download-media`、`text translate` | -| [chat-group](./chat/chat-group.md) | 建群、搜群、成员、机器人进群、群设置、群主、入群审批、群身份、群公告、群邀请分享 | `group *`、`group-role *`、`search`、`search-common` | -| [chat-bot](./chat/chat-bot.md) | 查机器人、机器人发消息/撤回、Webhook、机器人单聊、机器人进群 | `bot *`、`message send-by-bot`、`recall-by-bot`、`send-by-webhook` | -| [chat-conversation](./chat/chat-conversation.md) | 会话列表、置顶、免打扰、隐藏、红点、已读未读、清空、会话分组、智能分组 | `conversation-info`、`category *`、`mute`、`set-top`、`mark-read` | -| [chat-workflows](./chat/chat-workflows.md) | 高频复合流程、上下文传递表、openDingTalkId 获取方式、自动化脚本 | 工作流与字段传递 | +消息 Top 使用 `message set-top-msg`,整个会话 Top 使用 `chat set-top`,查看置顶会话使用 +`+conversation-list-top`。 -## ID 获取速查 +## 稳定 ID 传递 -| 需要的 ID | 首选获取方式 | 说明 | -|-----------|--------------|------| -| 群聊 `openConversationId` | `dws chat search --query "群名" --format json` | 群聊发消息、拉消息、成员管理、会话状态都用它 | -| 数字群号对应的 `openConversationId` | `dws chat group get-by-group-id --group-id <数字群号>` | 用户只给数字群号时先转换 | -| 单聊 `openConversationId` | `dws chat conversation-info --user ` 或 `--open-dingtalk-id ` | 单聊转发、会话置顶、清红点等状态命令需要 | -| `userId` | `dws aisearch person --keyword "姓名" --dimension name --format json` | 内部人员、机器人单聊 users、按发送者 userId 搜索 | -| `openDingTalkId` | `dws aisearch person --keyword "姓名" --dimension name --format json` | 三方/跨组织、openDingTalkId 单聊、@ openDingTalkId | -| `robotCode` | `dws chat bot search --name "机器人名" --format json` | 我创建的机器人,发机器人消息和撤回使用 | -| 机器人 `openDingTalkId` | `dws chat bot find --query "机器人名" --format json` | 给机器人发单聊必须用 find,search 不返回该字段 | -| 群成员昵称 | `dws chat group members --id --format json` | 返回中包含成员在群里的昵称/展示名;不要只查 contact 个人名 | -| 消息 `openMessageId` / `openMsgId` | `dws chat message list ... --format json` | 撤回、已读状态、回复、转发、表情回应 | -| 话题 `openConvThreadId` | `dws chat message list ... --format json` | 拉话题回复或往话题内回复;禁止自行拼接 | -| 卡片 `bizId` | `dws chat message send-card ... --format json` | `update-card --biz-id` | +| 来源 | 只可用于 | +|---|---| +| 唯一群解析 / `+chat-create` | 当前 profile 下的 `openConversationId` | +| 唯一人员解析 | 当前 profile 下的 `userId` / `openDingTalkId` | +| `+messages-send` | `openTaskId` 查询投递状态;它不是消息 ID | +| `+chat-messages` / `+search-msg` / `+messages-mget` | 回复、转发、撤回、资源操作使用的真实消息/会话/thread ID | +| `chat bot search` | `robotCode`;不能当机器人 `openDingTalkId` | +| `chat message send-by-bot` | `processQueryKey`,仅用于机器人撤回 | -## 易混淆路由 +显式稳定 ID 当前不携带可验证的 profile provenance;调用方必须保证来源,不得宣称所有 +跨 profile 误用都会在本地写入前被拦截。 -| 易混淆说法 | 正确路由 | 不要这样做 | -|------------|----------|------------| -| “置顶会话” | `chat set-top` 或 `chat list-top-conversations` | 不要用 `message set-top-msg` | -| “置顶某条消息” | `chat message set-top-msg` | 不要用 `chat set-top` | -| “机器人发消息” | `chat message send-by-bot` | 不要用当前用户身份 `message send` 代发 | -| “给机器人发单聊” | `chat bot find` 取 openDingTalkId → `message send --open-dingtalk-id` | 不要用 `bot search`,它没有 openDingTalkId | -| “发图片/文件/音频/视频” | `message send --msg-type file|audio|video --file-path`;已有图片 mediaId 才用 `image` | 不要先 `drive upload` 或 `dt_media_upload` | -| “特别关注的人最近发了什么” | `message list-focused` | 不要先查 `contact relation list-my-followings` | -| “某人发给我的消息” | `message list-by-sender` | 不要只查单聊,结果应覆盖群聊+单聊 | -| “搜索消息” | 首选 `message search-advanced` | 不要在可组合条件下退回简单 `message search` | -| “消息转待办” | 先用 `chat message list/search-advanced` 取消息内容,再调用 `dws todo task create` | 不要在 chat 内寻找“转待办”命令 | -| “消息转日程” | 先用 `chat message list/search-advanced` 取消息内容,再调用 `dws calendar event create` | 不要在 chat 内寻找“转日程”命令 | -| “共同群” | `chat search-common --nicks` | 不要分别搜索群再手工求交 | -| “拉取群文件 / 群文件列表” | `chat conversation-info --group ` 取 `spaceId` → `dws drive list --space-id ` | 不要用 `chat file upload`,它只负责上传 | -| “清空会话聊天记录” | `chat clear-messages` | 不要理解为删除群消息;它只影响当前用户视角 | +## 故障处理 -## 发送前检查清单 - -执行任何发送类命令前,至少检查: - -1. 接收对象明确:群聊必须有 `openConversationId`,单聊必须有 `userId` 或 `openDingTalkId`。 -2. 身份明确:用户身份发送用 `message send`,机器人身份发送用 `send-by-bot`,Webhook 用 `send-by-webhook`。 -3. 内容明确:消息正文、标题、附件、卡片内容都能从用户原始需求中找到依据。 -4. @ 对象明确:`--at-all`、`--at-user-ids`、`--at-open-dingtalk-ids` 不可自行追加。 -5. 文件路径明确:本地文件、音频、视频必须传 `--msg-type file|audio|video --file-path`,并确认路径是用户提供或当前任务生成的目标文件。 -6. 重试策略明确:失败重试时复用同一个 `--uuid`,避免重复投递。 - -## 高频一跳命令 - -这些命令覆盖多数日常问题;复杂参数再跳到对应子文档。 - -| 场景 | 一跳命令 | -|------|----------| -| 找群 | `dws chat search --query "群名" --format json` | -| 建群 | `dws chat group create --name "群名" --users userId1,userId2 --format json` | -| 看群成员 | `dws chat group members --id --format json` | -| 看群内昵称 | `dws chat group members --id --format json` | -| 发群文字 | `dws chat message send --group --text "内容" --uuid --format json` | -| 发单聊文字 | `dws chat message send --user --text "内容" --uuid --format json` | -| 发本地文件 | `dws chat message send --group --msg-type file --file-path ./file.pdf --format json` | -| 发音频/视频 | `dws chat message send --group --msg-type audio --file-path ./voice.mp3 --format json` / `--msg-type video --file-path ./demo.mp4` | -| 发位置/名片 | `dws chat message send --group --msg-type location ...` / `--msg-type profile --contact-id ` | -| 拉群消息 | `dws chat message list --group --time "2026-03-10 00:00:00" --direction older --format json` | -| 搜消息 | `dws chat message search-advanced --query "关键词" --start --end --format json` | -| 查 @ 我的消息 | `dws chat message search-advanced --at-me --start --end --format json` | -| 查未读会话 | `dws chat message list-unread-conversations --count 20 --format json` | -| 撤回自己消息 | `dws chat message recall --conversation-id --msg-id --format json` | -| 查我的机器人 | `dws chat bot search --name "机器人名" --format json` | -| 机器人发群消息 | `dws chat message send-by-bot --robot-code --group --title "标题" --text "## 标题\n\n正文" --format json` | -| Webhook 告警 | `dws chat message send-by-webhook --token --title "告警" --text "内容" --format json` | -| 查单聊会话 ID | `dws chat conversation-info --user --format json` | -| 查群钉盘空间 | `dws chat conversation-info --group --format json` | -| 发群公告 | `dws chat group notice create --group --content "公告内容" --format json` | -| 分享群邀请 | `dws chat group share-invite --source --target --format json` | -| 置顶会话 | `dws chat set-top --conversation-id --format json` | -| 清红点 | `dws chat clear-red-point --conversation-id --format json` | -| 会话分组 | `dws chat category list --format json` | -| 智能会话分组 | `dws chat category create-smart --name "重点群" --keywords "项目,重点" --format json` | -| 文本翻译 | `dws chat text translate --query "你好世界" --to en_US --format json` | - -## 命令索引表 - -### 消息与文件 - -| 命令 | 用途 | 必填参数 | 详见 | -|------|------|----------|------| -| `message send` | 当前用户发群聊/单聊文本、Markdown、图片、文件、音频、视频、位置、名片 | `--group` / `--user` / `--open-dingtalk-id` 三选一;文本用 `--text`,本地文件/音视频用 `--msg-type file|audio|video --file-path` | [chat-message](chat/chat-message.md#发送消息) | -| `message list` | 拉取群聊或单聊消息 | `--time` + `--group` / `--user` / `--open-dingtalk-id` 三选一 | [chat-message](chat/chat-message.md#拉取消息) | -| `message list-all` | 指定时间范围内拉取当前用户全部会话消息 | `--start` `--end` `--limit` `--cursor` | [chat-message](chat/chat-message.md#拉取消息) | -| `message list-by-sender` | 跨单聊和群聊查指定发送者消息 | `--sender-user-id` / `--sender-open-dingtalk-id` 二选一 | [chat-message](chat/chat-message.md#拉取消息) | -| `message list-mentions` | 查 @ 我的消息 | 可选 `--group`、`--start`、`--end` | [chat-message](chat/chat-message.md#拉取消息) | -| `message list-focused` | 查特别关注人消息 | 无 | [chat-message](chat/chat-message.md#拉取消息) | -| `message list-unread-conversations` | 获取未读会话列表 | 可选 `--count` | [chat-conversation](chat/chat-conversation.md#会话列表与红点) | -| `message search-advanced` | 多维度搜索消息,首选;支持消息类型、会话类型、机器人消息过滤 | 至少一个搜索条件 | [chat-message](chat/chat-message.md#搜索消息) | -| `message search` | 简单关键词搜索消息 | `--query` | [chat-message](chat/chat-message.md#搜索消息) | -| `message read-status` | 查询消息已读/未读状态 | `--group` `--message-id` | [chat-message](chat/chat-message.md#消息状态与撤回) | -| `message query-send-status` | 查询发送任务状态 | `--open-task-id` | [chat-message](chat/chat-message.md#消息状态与撤回) | -| `message recall` | 撤回当前用户消息;群主/管理员可撤回群内他人消息 | `--conversation-id` `--msg-id` | [chat-message](chat/chat-message.md#消息状态与撤回) | -| `message edit` | 编辑已发送消息内容 | `--conversation-id` `--msg-id`,`--text` / `--content` 二选一 | [chat-message](chat/chat-message.md#消息状态与撤回) | -| `message reply` | 引用回复消息 | `--conversation-id` `--ref-msg-id` `--ref-sender` `--text` | [chat-message](chat/chat-message.md#回复与转发) | -| `message forward` | 转发单条消息 | `--src-conversation-id` `--msg-id` `--dest-conversation-id` | [chat-message](chat/chat-message.md#回复与转发) | -| `message combine-forward` | 合并转发多条消息 | `--src-conversation-id` `--msg-ids` `--dest-conversation-id` | [chat-message](chat/chat-message.md#回复与转发) | -| `message list-topic-replies` | 拉取话题回复 | `--group` `--topic-id` | [chat-message](chat/chat-message.md#话题与卡片) | -| `message forward-topic` | 转发话题消息 | 源消息、源会话、话题 ID、目标会话 | [chat-message](chat/chat-message.md#话题与卡片) | -| `message send-card` / `update-card` | 创建并流式更新卡片 | `send-card` 目标二选一;`update-card` 需 `--biz-id` | [chat-message](chat/chat-message.md#话题与卡片) | -| `message add-emoji` / `remove-emoji` | 添加/移除默认表情回应 | `--conversation-id` `--msg-id` `--emoji` | [chat-message](chat/chat-message.md#表情回应) | -| `message add-text-emotion` / `update-text-emotion` / `remove-text-emotion` | 添加/更新/移除文字表情 | update 需 `--old-emotion-id` `--emotion-id`,其余用 `--emotion-*` | [chat-message](chat/chat-message.md#表情回应) | -| `message create-text-emotion` | 创建文字表情模板 | `--emotion-name` `--text` | [chat-message](chat/chat-message.md#表情回应) | -| `message list-emotion-replies` | 批量拉取消息表情回复和文字回复 | `--msg-ids` | [chat-message](chat/chat-message.md#表情回应) | -| `file upload` | 上传本地或 URL 文件到会话文件空间 | 目标三选一 + `--file` / `--url` 二选一 | [chat-message](chat/chat-message.md#文件与媒体) | -| `message download-media` | 下载消息中的图片/视频/语音等资源 | `--type` `--resource-id` `--message-id` `--open-conversation-id` `--output` | [chat-message](chat/chat-message.md#文件与媒体) | -| `text translate` | 翻译文本内容 | `--query` `--to` | [chat-message](chat/chat-message.md#文本工具) | - -### 群聊与群身份 - -| 命令 | 用途 | 必填参数 | 详见 | -|------|------|----------|------| -| `search` | 按关键词搜索群聊 | `--query` | [chat-group](chat/chat-group.md#搜索与基础信息) | -| `search-common` | 查询指定人共同群 | `--nicks` | [chat-group](chat/chat-group.md#搜索与基础信息) | -| `group create` | 创建内部群/外部群/普通群/话题圈 | `--name` `--users` | [chat-group](chat/chat-group.md#群创建与基础操作) | -| `group members` | 查看群成员 | `--id` | [chat-group](chat/chat-group.md#成员与机器人) | -| `group members add/remove` | 添加/移除群成员 | `--id` `--users` | [chat-group](chat/chat-group.md#成员与机器人) | -| `group members add-bot/remove-bot` | 添加/移除群机器人 | `--id` + 机器人标识 | [chat-group](chat/chat-group.md#成员与机器人) | -| `group members list-by-ids` | 批量查群成员详情 | `--id` `--users` | [chat-group](chat/chat-group.md#成员与机器人) | -| `group rename` | 修改群名称 | `--id` `--name` | [chat-group](chat/chat-group.md#群创建与基础操作) | -| `group get-by-group-id` | 数字群号转 openConversationId | `--group-id` | [chat-group](chat/chat-group.md#搜索与基础信息) | -| `group transfer-owner` | 转让群主 | `--group` + `--user` / `--new-owner` | [chat-group](chat/chat-group.md#群设置与权限) | -| `group upgrade-to-external` | 仅将 NORMAL_GROUP 普通群升级为外部群 | `--group` `--yes`,可选 `--extension` | [chat-group](chat/chat-group.md#群设置与权限) | -| `group invite-url` | 获取群邀请链接 | `--group` | [chat-group](chat/chat-group.md#群设置与权限) | -| `group share-invite` | 将群邀请链接分享到另一个会话或单聊用户 | `--source` + `--target` / `--receiver` 二选一 | [chat-group](chat/chat-group.md#群设置与权限) | -| `group notice *` | 群公告创建、修改、详情、列表 | `--group`,视子命令补充公告参数 | [chat-group](chat/chat-group.md#群公告) | -| `group quit` / `dismiss` | 退出/解散群聊 | `--group` | [chat-group](chat/chat-group.md#群创建与基础操作) | -| `group update-icon` | 更新群头像 | `--group` `--icon-media-id` | [chat-group](chat/chat-group.md#群设置与权限) | -| `group update-settings` | 更新管理员级别的群功能开关 | `--group` `--setting-key` `--status` | [chat-group](chat/chat-group.md#群设置与权限) | -| `group user-settings query/set` | 批量查询/更新当前用户自己的群会话设置 | `query --groups` / `set --items` | [chat-group](chat/chat-group.md#群设置与权限) | -| `group update-nick` / `update-alias` | 设置或清除群昵称 / 设置群备注 | `update-nick` 需 `--group`,`--nick` 可选;`update-alias` 需备注 | [chat-group](chat/chat-group.md#群设置与权限) | -| `group set-history` | 设置新成员可见历史消息范围 | `--group` `--option` | [chat-group](chat/chat-group.md#群设置与权限) | -| `group get-mute-config` | 查询群用户禁言配置 | `--group` | [chat-group](chat/chat-group.md#群设置与权限) | -| `group list-my-groups` / `list-all` | 拉取我管理的群 / 我加入的所有群 | 可选分页参数 | [chat-group](chat/chat-group.md#群列表与入群审批) | -| `group list-join-validations` | 拉取入群验证记录 | 可选分页参数 | [chat-group](chat/chat-group.md#群列表与入群审批) | -| `group audit-join-validation` | 审批入群验证 | `--group` `--record-id` `--applicant` `--inviter` `--status` | [chat-group](chat/chat-group.md#群列表与入群审批) | -| `group-mute` / `group-mute-member` | 全员禁言 / 指定成员禁言 | `--group`,成员禁言还需用户与时长 | [chat-group](chat/chat-group.md#群设置与权限) | -| `group set-admin` | 设置/取消群管理员 | `--group` `--user` / `--users` | [chat-group](chat/chat-group.md#群设置与权限) | -| `group-role *` | 群身份列表、增删改、设置成员身份 | `--group` + role/user 参数 | [chat-group](chat/chat-group.md#群身份) | - -### 机器人与 Webhook - -| 命令 | 用途 | 必填参数 | 详见 | -|------|------|----------|------| -| `bot search` | 搜索我创建的机器人 | 可选 `--name` | [chat-bot](./chat/chat-bot.md#机器人搜索) | -| `bot find` | 搜索全部可用机器人,返回 openDingTalkId | `--query` | [chat-bot](./chat/chat-bot.md#机器人搜索) | -| `message send-by-bot` | 机器人发群聊或单聊消息 | `--robot-code` `--title` `--text` + 目标二选一 | [chat-bot](./chat/chat-bot.md#机器人发送与撤回) | -| `message recall-by-bot` | 撤回机器人消息 | `--robot-code` `--keys` | [chat-bot](./chat/chat-bot.md#机器人发送与撤回) | -| `message send-by-webhook` | 自定义机器人 Webhook 发群消息 | `--token` `--title` `--text` | [chat-bot](./chat/chat-bot.md#webhook) | -| `group members add-bot/remove-bot` | 机器人加入/移出群 | `--id` + 机器人标识 | [chat-bot](./chat/chat-bot.md#机器人进群) | -| `group bots` | 查看群内所有机器人 | `--group` | [chat-bot](./chat/chat-bot.md#机器人进群) | - -### 会话状态与分组 - -| 命令 | 用途 | 必填参数 | 详见 | -|------|------|----------|------| -| `conversation-info` | 获取群聊或单聊会话基础信息 | `--group` / `--user` / `--open-dingtalk-id` 三选一 | [chat-conversation](./chat/chat-conversation.md#会话基础信息) | -| `list-all-conversations` | 分页获取全部会话 | 可选分页参数 | [chat-conversation](./chat/chat-conversation.md#会话列表与红点) | -| `list-top-conversations` | 拉取置顶会话列表 | 可选分页参数 | [chat-conversation](./chat/chat-conversation.md#会话列表与红点) | -| `set-top` | 设置/取消会话置顶 | `--conversation-id` | [chat-conversation](./chat/chat-conversation.md#会话置顶与通知) | -| `mute` | 开启/关闭会话免打扰 | `--conversation-id` | [chat-conversation](./chat/chat-conversation.md#会话置顶与通知) | -| `hide` | 隐藏会话 | `--conversation-id` | [chat-conversation](./chat/chat-conversation.md#会话置顶与通知) | -| `mute-at-all` / `mute-red-envelope` | 关闭/恢复 @所有人或红包通知 | `--conversation-id` | [chat-conversation](./chat/chat-conversation.md#会话置顶与通知) | -| `mark-unread` / `mark-read` | 标记会话未读 / 标记消息已读 | 会话 ID;`mark-read` 还需消息 ID | [chat-conversation](./chat/chat-conversation.md#已读未读与清理) | -| `clear-red-point` / `clear-all-red-point` | 清除单会话/全部红点 | 单会话需 `--conversation-id` | [chat-conversation](./chat/chat-conversation.md#会话列表与红点) | -| `clear-messages` | 清空当前用户视角会话消息 | `--conversation-id` | [chat-conversation](./chat/chat-conversation.md#已读未读与清理) | -| `category list-by-conv` | 拉取指定会话所属的用户自定义会话分组 | `--group` | [chat-conversation](./chat/chat-conversation.md#会话分组) | -| `category batch-info` | 批量拉取用户自定义会话分组信息 | `--category-ids` | [chat-conversation](./chat/chat-conversation.md#会话分组) | -| `category *` | 自定义会话分组和智能分组管理 | 视子命令而定 | [chat-conversation](./chat/chat-conversation.md#会话分组) | - -## 意图判断 - -### 消息 - -| 用户说 | 路由 | -|--------|------| -| “发群消息 / 发到某群” | `chat message send --group`,先用 `chat search` 找 `openConversationId` | -| “发单聊 / 发给某人” | `chat message send --user` 或 `--open-dingtalk-id`,人员信息见 `dingtalk-contact` | -| “发图片 / 发文件 / 发截图 / 发音频 / 发视频” | `chat message send --msg-type file|audio|video --file-path <本地路径>`;`audio/video` 底层按 `file` 发送 | -| “发位置 / 分享地址” | `chat message send --msg-type location`,需经用户确认经纬度和地址名 | -| “发联系人名片 / 分享联系人” | `chat message send --msg-type profile --contact-id ` | -| “机器人发消息 / 机器人群发” | `chat message send-by-bot`,不要用用户身份代发 | -| “撤回我发的消息” | `chat message recall` | -| “撤回机器人发的消息” | `chat message recall-by-bot` | -| “查某群聊天记录” | `chat message list --group` | -| “查和某人的单聊记录” | `chat message list --user` 或 `--open-dingtalk-id` | -| “某人发给我的消息 / 指定发送者消息” | `chat message list-by-sender`,跨单聊和群聊 | -| “我今天/最近所有消息” | `chat message list-all --start --end ` | -| “@我的消息” | `chat message list-mentions` 或 `search-advanced --at-me` | -| “特别关注的人最近发了什么” | `chat message list-focused`,不要先查关注人员列表 | -| “搜索消息里的关键词 / 多维度搜索” | 首选 `chat message search-advanced` | -| “只搜文件消息 / 只搜群聊 / 只看机器人消息” | `chat message search-advanced --message-type file --search-conv-type group_chat --only-robot-messages` 按需组合 | -| “翻译这段文字” | `chat text translate --query <文本> --to <语言代码>` | -| “把这条消息转待办 / 消息里提到的事项建待办” | 先取消息内容和相关人员,再按 `dingtalk-todo` 调 `dws todo task create`;标题、执行人、截止时间必须来自消息或用户确认 | -| “把这条消息转日程 / 消息里约的会建日程” | 先取消息内容、时间、地点、参会人,再按 `dingtalk-calendar` 调 `dws calendar event create`;时间不明确必须先确认 | -| “话题回复 / 往话题里回复” | 先 `message list` 获取 `openConvThreadId`,再 `list-topic-replies` 或 `message send --group ` | -| “消息已读未读 / 谁看了消息” | `chat message read-status` | -| “置顶某条消息 / 取消消息置顶” | `chat message set-top-msg` / `unset-top-msg` | - -### 群与成员 - -| 用户说 | 路由 | -|--------|------| -| “建群 / 创建群聊” | `chat group create` | -| “建外部群 / 建普通群 / 建话题圈” | `chat group create --type EXTERNAL` / `--type NORMAL` / `--thread` | -| “找群 / 搜索群” | `chat search --query` | -| “群成员 / 群里有谁” | `chat group members` | -| “群成员在群里的昵称 / 群昵称” | `chat group members --id `,返回中包含群内昵称/展示名 | -| “拉人进群 / 踢人” | `chat group members add` / `remove` | -| “批量查群成员详情” | `chat group members list-by-ids` | -| “改群名 / 群头像 / 群备注 / 群昵称” | `group rename` / `update-icon` / `update-alias` / `update-nick` | -| “转让群主 / 设置管理员 / 禁言” | `group transfer-owner` / `group set-admin` / `group-mute*` | -| “分享群链接 / 群邀请发给某人或某群” | `chat group share-invite` | -| “发群公告 / 改群公告 / 查群公告” | `chat group notice create/edit/get/list` | -| “入群审批 / 入群验证记录” | `group list-join-validations` → `group audit-join-validation` | -| “群身份 / 群标签 / 给成员设置身份” | `group-role *` | -| “我和某人的共同群” | `chat search-common --nicks` | - -### 机器人 - -| 用户说 | 路由 | -|--------|------| -| “查看我的机器人 / 我创建的机器人” | `chat bot search` | -| “找机器人 / 搜索全部可用机器人 / 给机器人发单聊” | `chat bot find`,需要 `openDingTalkId` | -| “加机器人到群 / 从群移除机器人 / 群里有哪些机器人” | `group members add-bot` / `remove-bot` / `group bots` | -| “Webhook 发告警” | `chat message send-by-webhook` | - -### 会话状态 - -| 用户说 | 路由 | -|--------|------| -| “未读会话列表” | `chat message list-unread-conversations` | -| “置顶会话 / 查看置顶会话” | `chat list-top-conversations` | -| “把会话置顶 / 取消会话置顶” | `chat set-top` / `chat set-top --off` | -| “免打扰 / 关闭免打扰” | `chat mute` / `chat mute --off` | -| “隐藏会话” | `chat hide` | -| “清红点 / 全部已读” | `chat clear-red-point` / `chat clear-all-red-point` | -| “标记未读 / 标记已读” | `chat mark-unread` / `chat mark-read` | -| “清空聊天记录” | `chat clear-messages` | -| “会话分组” | `chat category *` | -| “智能分组 / 按关键词或成员自动分组” | `chat category create-smart` | -| “群文件 / 拉取群文件列表” | `chat conversation-info --group ` 取 `spaceId`,再按 `dingtalk-drive` 调 `dws drive list --space-id ` | - -## 核心工作流索引 - -| 场景 | 入口 | -|------|------| -| 搜索群并拉消息 | [chat-workflows §群聊消息](./chat/chat-workflows.md#群聊消息) | -| 个人身份发送群聊/单聊/文件 | [chat-workflows §发送消息](./chat/chat-workflows.md#发送消息) | -| 机器人发消息后撤回 | [chat-workflows §机器人消息](./chat/chat-workflows.md#机器人消息) | -| 机器人不在群内时先邀请再发送 | [chat-workflows §机器人消息](./chat/chat-workflows.md#机器人消息) | -| 给机器人发单聊 | [chat-workflows §机器人消息](./chat/chat-workflows.md#机器人消息) | -| 发送图片 / 文件 / 音频 / 视频 | [chat-workflows §发送消息](./chat/chat-workflows.md#发送消息) | -| 流式卡片创建与更新 | [chat-message §话题与卡片](./chat/chat-message.md#话题与卡片) | -| 群公告发布与修改 | [chat-group §群公告](./chat/chat-group.md#群公告) | -| 消息转待办 | 先用 [chat-message](./chat/chat-message.md#拉取消息) 取消息内容,再读取 `dingtalk-todo` 创建待办 | -| 消息转日程 | 先用 [chat-message](./chat/chat-message.md#拉取消息) 取消息内容,再读取 `dingtalk-calendar` 创建日程 | -| 拉取群文件 | `conversation-info` 取 `spaceId`,再读取 `dingtalk-drive` 查询钉盘文件 | - -## 上下文传递摘要 - -| 操作 | 从返回中提取 | 用于 | -|------|-------------|------| -| `chat search` / `chat group create` / `chat group get-by-group-id` | `openConversationId` | `--group`、`--conversation-id`、群成员、消息发送/拉取 | -| `chat conversation-info` | 单聊 `openConversationId` | 单聊消息搜索、转发、会话状态操作 | -| `chat conversation-info --group` | `spaceId` | `drive list/info/download --space-id`,用于拉取群文件 | -| `chat group members` | 群成员昵称/展示名、`userId`、`openDingTalkId` | 识别群内发言人、@ 人、待办执行人、日程参会人 | -| `aisearch person` | `userId`、`openDingTalkId` | 单聊、@ 人、机器人单聊、按发送者搜索 | -| `chat bot search` | `robotCode` | `send-by-bot`、`recall-by-bot` | -| `chat bot find` | `openDingTalkId` | 给机器人发单聊 | -| `chat message send-by-bot` | `processQueryKey` | `recall-by-bot --keys` | -| `chat message send` | `openTaskId` | `query-send-status` | -| `chat message list` | `openMessageId`、`openMsgId`、`openConvThreadId` | 撤回、已读状态、表情、回复、转发、话题回复 | -| `chat message list/search-advanced` | 消息正文、发送人、时间 | 创建 `todo task` 或 `calendar event` 前的输入材料 | -| `chat message search(-advanced)` / `list-all` | `nextCursor` | 下一页 `--cursor` | -| `chat group-role list` | `openRoleId` | `group-role update/remove/set-user/remove-user` | -| `chat message send-card` | `bizId` | `message update-card --biz-id` | - -完整字段传递表见 [chat-workflows](./chat/chat-workflows.md#上下文传递表)。 - -## 相关产品 - -- [contact](../../dingtalk-contact/references/contact.md) — 搜索人员,获取 `userId` / `openDingTalkId`。 -- [todo](../../dingtalk-todo/references/todo.md) — 消息转待办时使用;从 chat 消息内容提取标题、执行人、截止时间后调用 `dws todo task create`。 -- [calendar](../../dingtalk-calendar/references/calendar.md) — 消息转日程时使用;从 chat 消息内容提取标题、开始/结束时间、地点、参会人后调用 `dws calendar event create`。 -- [drive](../../dingtalk-drive/references/drive.md) — 云盘/群文件管理;拉群文件先用 `chat conversation-info` 取 `spaceId`,再用 `dws drive list --space-id `;chat 纯文件/音视频发送优先用 `chat message send --msg-type file|audio|video --file-path`。 -- [aisearch](../../dingtalk-aisearch/references/aisearch.md) — 可用于人员、行为、历史信息搜索,但消息发送与会话管理仍走 `chat`。 -- [ding](../../dingtalk-misc/references/ding.md) — DING 通知与升级提醒,不等价于群聊消息。 - -文本翻译(`chat text translate`)详见 [chat-message](./chat/chat-message.md#文本工具)。 +- `unknown command` / `unknown flag`:读取精确 leaf Help,最多修正一次; +- confirmation 或参数约束不清:读取精确 leaf Schema,以最终 Runtime gate 为准; +- 自然目标零命中/多候选:停止并展示候选,不选择第一项; +- 权限、认证或 profile:按 `dws-shared` 对应 reference 分流; +- partial result:保留已完成项、失败 ledger、continuation 和真实错误,不换同义原子命令重试。 diff --git a/skills/multi/dingtalk-chat/references/chat/chat-bot.md b/skills/multi/dingtalk-chat/references/chat/chat-bot.md index 5ddeb2f1..fc6165c7 100644 --- a/skills/multi/dingtalk-chat/references/chat/chat-bot.md +++ b/skills/multi/dingtalk-chat/references/chat/chat-bot.md @@ -6,13 +6,22 @@ 用于搜索机器人、机器人发送/撤回消息、Webhook 告警、机器人加入/移出群,以及给机器人发单聊消息。 +Bot/Webhook 默认统一使用 `dws chat +messages-send`,通过 +`--as bot|webhook` 选择身份;原子发送命令只保留 Shortcut 未发布字段的底层 fallback。 + ## 必读约束 -- 用户明确要求“用机器人/机器人身份/robot”发送时,必须用 `chat message send-by-bot`,严禁改用 `chat message send`。 +- 用户明确要求“用机器人/机器人身份/robot”发送时,使用 + `dws chat +messages-send --as bot --robot-code `;不得改成当前用户身份。 - `chat bot search` 只返回我创建的机器人,没有 `openDingTalkId`;给机器人发单聊必须用 `chat bot find`。 - 机器人发群消息前需确认机器人已在群中;报“机器人不存在”时先 `group members add-bot`。 - `send-by-bot --text` 支持 Markdown;需要稳定换行时用空行分隔段落。若以转义形式组织文本,写 `\n\n`,不要只写 `\n`。 - `recall-by-bot` 使用 `processQueryKey`,不是 `openMessageId`。 +- Bot 多群文本/Markdown 直接使用 `+messages-send --groups ` 或 + `--groups-file <工作目录内相对文件>`;最多 100 个稳定 ID,Runtime 去重并返回 + `im.batch-write.v1` 逐目标 ledger。 +- Bot/Webhook 没有与 current-user 等价的文件、图片、音视频发送接口;不得把富媒体转成文本 + 或换身份静默发送。 ## 命令明细 @@ -33,7 +42,20 @@ dws chat bot find --query "日报" --limit 20 --cursor ### 机器人发送与撤回 -#### `dws chat message send-by-bot` +多群正式入口: + +```bash +dws chat +messages-send --as bot --robot-code \ + --groups , \ + --markdown "## 通知\n\n请提交周报" --format json +``` + +读取 `requestedCount/succeededCount/failedCount/results/failures`;unknown 或失败目标不自动重发。 + +#### `dws chat message send-by-bot`(底层 fallback) + +普通文本/Markdown、群聊/批量单聊和 @ 已由 `+messages-send --as bot` 覆盖。只有 Shortcut +缺失真实必需字段且精确 leaf Schema 允许时才使用以下原子命令。 ```bash # 群聊 @@ -74,6 +96,14 @@ dws chat message recall-by-bot --robot-code --keys key1,key2 ### Webhook +默认使用: + +```bash +dws chat +messages-send --as webhook --webhook-token --title "告警" --text "CPU 超 90% @10" --at-all +``` + +以下是底层 fallback,不作为默认选路: + ```bash dws chat message send-by-webhook --token --title "告警" --text "CPU 超 90% @10" --at-all dws chat message send-by-webhook --token --title "test" --text "hi @118785" --at-users 118785 @@ -91,11 +121,11 @@ dws chat message send-by-webhook --token --title "test" --text " |------|------|----------| | `group members add-bot` | 将自定义机器人加入群 | `--id` `--robot-code` | | `group members remove-bot` | 从群移除机器人 | `--id` `--bot-id` | -| `group bots` | 查看群内机器人列表 | `--group` | +| `+chat-bots` | 查看群内机器人列表 | `--group <群名或openConversationId>`;自然群名内部唯一解析 | ```bash dws chat group members add-bot --id --robot-code -dws chat group bots --group +dws chat +chat-bots --group "项目群" dws chat group members remove-bot --id --bot-id ``` @@ -105,7 +135,7 @@ dws chat group members remove-bot --id --bot-id ```bash dws chat bot search --name "日报" --format json -dws chat message send-by-bot --robot-code --group --title "日报" --text "## 今日完成\n\n- 事项 A\n\n- 事项 B" --format json +dws chat +messages-send --as bot --robot-code --group --title "日报" --markdown "## 今日完成\n\n- 事项 A\n\n- 事项 B" --format json dws chat message recall-by-bot --robot-code --group --keys --format json ``` @@ -114,7 +144,7 @@ dws chat message recall-by-bot --robot-code --group --robot-code --format json -dws chat message send-by-bot --robot-code --group --title "通知" --text "内容" --format json +dws chat +messages-send --as bot --robot-code --group --title "通知" --text "内容" --format json ``` ### 给机器人发单聊 @@ -128,7 +158,7 @@ dws chat message send --open-dingtalk-id --text "你好" --form ```bash dws aisearch person --keyword "张三" --dimension name --format json -dws chat message send-by-bot --robot-code --group --at-user-ids userId1 --title "提醒" --text "@userId1 请查收" --format json +dws chat +messages-send --as bot --robot-code --group --at-user-ids userId1 --title "提醒" --text "@userId1 请查收" --format json ``` ## 常见错误与回退 @@ -137,3 +167,4 @@ dws chat message send-by-bot --robot-code --group 查看置顶会话默认使用 `dws chat +conversation-list-top`; + 原子 `list-top-conversations` 只在需要原始响应时作为 fallback。 +- 取得目标会话后读取消息默认使用 `dws chat +chat-messages`; + 不要回到原子 `message list` 作为普通路线。 + ## 必读约束 -- 会话状态类命令通常需要 `openConversationId`。群聊可由 `chat search` 获取,单聊可由 `chat conversation-info --user/--open-dingtalk-id` 获取。 +- 会话状态类命令通常需要 `openConversationId`。群聊只用 `+chat-search --query` 获取唯一候选,单聊可由 `chat conversation-info --user/--open-dingtalk-id` 获取。 - `set-top` 是会话置顶;`message set-top-msg` 是会话内消息置顶,二者不能混用。 - `clear-messages` 只清空当前用户视角的消息,不影响其他成员。 - 智能分组规则中的成员使用 openDingTalkId;如果用户只给姓名,先用 `aisearch person --dimension name` 获取。 @@ -29,8 +34,8 @@ dws chat conversation-info --open-dingtalk-id --format json | 命令 | 用途 | 参数 | |------|------|------| -| `list-all-conversations` | 分页获取当前用户全部会话 | 可选 `--limit` `--cursor` `--exclude-muted` | -| `list-top-conversations` | 获取置顶会话列表 | 可选 `--limit` `--cursor` `--exclude-muted` | +| `+conversation-list` | 获取当前用户会话 | 要求“全部”时加 `--page-all`;检查 `complete` / `failures` | +| `+conversation-list-top` | 获取置顶会话列表 | 可选 `--limit` `--cursor` `--exclude-muted`;使用稳定 `conversations[]` | | `message list-unread-conversations` | 获取未读会话列表 | 可选 `--count` `--exclude-muted` | | `clear-red-point` | 清除指定会话红点 | `--conversation-id`,别名 `--id` / `--chat` | | `clear-all-red-point` | 清除所有会话红点,一键全部已读 | 无参数 | @@ -44,8 +49,11 @@ dws chat conversation-info --open-dingtalk-id --format json | `set-top` | 设置/取消会话置顶 | `--conversation-id`;默认置顶,`--off` 取消 | | `mute` | 开启/关闭会话免打扰 | `--conversation-id`;默认开启,`--off` 关闭 | | `hide` | 隐藏会话 | `--conversation-id` | -| `mute-at-all` | 关闭/恢复 @所有人通知 | `--conversation-id`;默认关闭,`--off` 恢复 | -| `mute-red-envelope` | 关闭/恢复红包通知 | `--conversation-id`;默认关闭,`--off` 恢复 | +| `mute-at-all` | 关闭/恢复 @所有人通知 | `--conversation-id`;默认关闭,`--off` 恢复;必须先开启会话总免打扰 | +| `mute-red-envelope` | 关闭/恢复红包通知 | `--conversation-id`;默认关闭,`--off` 恢复;必须先开启会话总免打扰 | + +若连续操作两个子开关,优先操作红包通知;恢复 @所有人通知后,平台可能清除子开关所需的 +总免打扰状态,此时要先重新开启总免打扰,再操作红包通知。 ```bash dws chat set-top --conversation-id @@ -108,8 +116,8 @@ dws chat set-top --conversation-id --format json ### 查看置顶会话并拉消息 ```bash -dws chat list-top-conversations --limit 100 --format json -dws chat message list --group --time "2026-03-10 00:00:00" --direction older --format json +dws chat +conversation-list-top --limit 100 --format json +dws chat +chat-messages --group --time "2026-03-10 00:00:00" --direction older --format json ``` ### 会话分组 @@ -134,7 +142,7 @@ dws chat category create-smart --name "重点群" --keywords "重点" --members ## 常见错误与回退 - 用户说“置顶消息”:用 `message set-top-msg`,不是 `chat set-top`。 -- 用户说“置顶会话”:用 `chat set-top` 或 `list-top-conversations`。 +- 用户说“置顶会话”:设置/取消用 `chat set-top`,查看列表用 `+conversation-list-top`。 - 单聊没有会话 ID:先 `conversation-info --user` 或 `--open-dingtalk-id`。 - 清空聊天记录前必须确认目标会话;该操作只影响当前用户视角。 - 智能分组没有匹配条件:至少确认分组名称;关键词和成员规则不明确时先向用户确认,不要自行猜成员。 diff --git a/skills/multi/dingtalk-chat/references/chat/chat-group.md b/skills/multi/dingtalk-chat/references/chat/chat-group.md index d9d7ed55..05cf48a7 100644 --- a/skills/multi/dingtalk-chat/references/chat/chat-group.md +++ b/skills/multi/dingtalk-chat/references/chat/chat-group.md @@ -6,10 +6,15 @@ 用于群搜索、建群、成员增删、机器人进群、群设置、群主转让、群邀请分享、群公告、入群审批、群身份和群禁言。 +基础建群默认使用 `dws chat +chat-create`:已知成员 ID 传 +`--users`,姓名/花名传 `--member-query`;群主默认当前用户,也可传 +`--owner-open-dingtalk-id` 或 `--owner-query`。全部自然身份唯一解析并去重后才执行一次创建。 + ## 必读约束 - 群聊目标统一使用 `openConversationId`。只有数字群号时,先用 `chat group get-by-group-id` 转换。 -- `chat search` 在 `chat` 根下,不在 `chat group` 下。 +- 群搜索唯一推荐 `dws chat +chat-search --query <群名>`;`chat search`、`chat group search`、 + `+chat-group-search` 和 `+search-group` 仅是兼容拼法,不应被写成并列默认路线。 - 群成员操作中,`--users` 常为逗号分隔列表;具体要求以命令 `--help` 为准。 - 解散群、踢人、转让群主、禁言、管理员设置都是高影响操作,执行前必须确认目标群和用户。 - 发布或修改群公告会触达群成员;公告正文是 Markdown,定时公告 `--run-at` 建议带时区。 @@ -20,16 +25,20 @@ | 命令 | 用途 | 示例与要点 | |------|------|------------| -| `chat search` | 按关键词搜索群聊 | `dws chat search --query "项目冲刺" --limit 20 --cursor 0`;可选 `--exclude-muted` | +| `+chat-search` | 按关键词搜索群聊 | `dws chat +chat-search --query "项目冲刺" --limit 20 --cursor 0`;多候选必须消歧 | | `chat search-common` | 搜索共同群 | `dws chat search-common --nicks "风雷,山乔" --match-mode AND --limit 20 --cursor 0` | | `chat group get-by-group-id` | 数字群号转 openConversationId | `dws chat group get-by-group-id --group-id 12345678` | -| `chat group bots` | 查看群内所有机器人 | `dws chat group bots --group ` | +| `+chat-bots` | 查看群内所有机器人 | `dws chat +chat-bots --group <群名或openConversationId>`;内部唯一解析自然群名 | `search-common` 中 `--match-mode AND` 表示所有人都在群里,`OR` 表示任一人在群里。 ### 群创建与基础操作 -#### `dws chat group create` +#### `dws chat group create`(底层 fallback) + +只有需要 `+chat-create` 尚未发布的底层字段时才评估原子创建命令,并先读取精确 leaf +Schema。`+chat-create` 已支持 `--thread` 和显式群主;普通内部/外部群创建不得回流到手工 +`aisearch → group create` 链路。 ```bash dws chat group create --name "Q1 项目冲刺群" --users userId1,userId2,userId3 @@ -50,7 +59,7 @@ dws chat group create --name "话题圈" --users userId1,userId2 --thread | 命令 | 用途 | 必填参数 | |------|------|----------| -| `group rename` | 更新群名称 | `--id` `--name` | +| `group rename` | 更新群名称 | `--id` `--name`;只知群名时先用 `+chat-search --query <群名>` 唯一解析 ID,不猜 `+chat-rename` | | `group quit` | 当前用户退出群聊 | `--group` | | `group dismiss` | 解散群聊,不可逆 | `--group` | @@ -58,7 +67,7 @@ dws chat group create --name "话题圈" --users userId1,userId2 --thread | 命令 | 用途 | 示例 | |------|------|------| -| `group members` | 分页查看群成员 | `dws chat group members --id --cursor 0` | +| `+chat-members-list` | 全量查看群成员并分桶用户/机器人 | `--group <群名或openConversationId>`;显式 ID 也可用 `--conversation-id`,检查 buckets/complete/failures | | `group members add` | 添加群成员 | `dws chat group members add --id --users userId1,userId2` | | `group members remove` | 移除群成员 | `dws chat group members remove --id --users userId1,userId2` | | `group members list-by-ids` | 按 openDingTalkId 批量查成员详情 | `dws chat group members list-by-ids --id --users openDingTalkId1,openDingTalkId2` | @@ -73,7 +82,7 @@ dws chat group create --name "话题圈" --users userId1,userId2 --thread |------|------|----------| | `group transfer-owner` | 转让群主 | `--group` + `--user`(userId) 或 `--new-owner`(openDingTalkId) | | `group upgrade-to-external` | 将普通群升级为外部群(不可逆,需先确认) | `--group` `--yes`;可选 `--extension`(拓展字段) | -| `group invite-url` | 获取群邀请链接 | `--group`,可选 `--expires-seconds`,0 表示永久 | +| `+chat-invite-url` | 获取群邀请链接 | `--group <群名或openConversationId>`;可选 `--expires-seconds` | | `group share-invite` | 将指定群的邀请链接分享到另一个会话或单聊用户 | `--source` + `--target` / `--receiver` 二选一 | | `group update-icon` | 更新群头像 | `--group` `--icon-media-id` | | `group update-settings` | 更新管理员级别的群功能开关 | `--group` `--setting-key` `--status` | @@ -179,15 +188,14 @@ dws chat group audit-join-validation --group --record-id 12 ### 搜索群并发消息 ```bash -dws chat search --query "项目冲刺" --format json -dws chat message send --group --text "请大家看一下最新进展" --format json +dws chat +send-to-group --group "项目冲刺" --text "请大家看一下最新进展" --format json ``` ### 建群并拉人 ```bash -dws aisearch person --keyword "张三" --dimension name --format json -dws chat group create --name "Q1 项目冲刺群" --users userId1,userId2 --format json +dws chat +chat-create --name "Q1 项目冲刺群" --member-query "张三,李四" --format json +dws chat +chat-create --name "合作群" --member-query "张三,李四" --owner-query "王五" --format json dws chat group members add --id --users userId3,userId4 --format json ``` @@ -208,7 +216,7 @@ dws chat group-mute-member --group --users userId3 --mute-t ## 常见错误与回退 - 只有数字群号:先 `group get-by-group-id`,不要直接当 `--group`。 -- 找不到群:先扩大 `chat search --query`,不要臆测 openConversationId。 +- 找不到群:使用 `+chat-search --query` 扩大关键词;零命中或多候选时停止,不臆测 openConversationId。 - 入群审批缺参数:从 `group list-join-validations` 提取 `record-id`、`applicant`、`inviter`。 - 机器人进群失败:确认当前用户有群管理权限。 - 分享群邀请目标不明确:`--target` 和 `--receiver` 只能二选一,先确认是发到群/会话还是发给个人。 diff --git a/skills/multi/dingtalk-chat/references/chat/chat-message.md b/skills/multi/dingtalk-chat/references/chat/chat-message.md index 7e412ef6..2bcf550f 100644 --- a/skills/multi/dingtalk-chat/references/chat/chat-message.md +++ b/skills/multi/dingtalk-chat/references/chat/chat-message.md @@ -6,21 +6,42 @@ 用于当前用户发消息、拉消息、搜索消息、撤回、已读状态、文件/图片/音频/视频/位置/名片发送、话题回复、转发、Pin/Top、表情回应、文本翻译和流式卡片。 +## 默认入口 + +| 用户终点 | 默认入口 | +|---|---| +| 按姓名发送简单文本/Markdown | `dws chat +dm` | +| 按群名发送简单文本/Markdown | `dws chat +send-to-group` | +| 文件、Bot、Webhook、复杂 @、已知 ID 或幂等发送 | `dws chat +messages-send` | +| 读取一个指定群聊或单聊 | `dws chat +chat-messages` | +| 跨会话、多条件或全量翻页搜索 | `dws chat +search-msg` | +| 引用回复已有消息 | `dws chat +messages-reply` | +| 查看指定群内 @我的消息 | `dws chat +at-me --group <群名>` | +| 撤回当前用户消息 | `dws chat +messages-recall --msg-id ` | + +以下原子命令只用于 Shortcut 未暴露的底层字段、原始响应或精确分页控制。不得把它们重新写成 +上述高频任务的默认路径;写入原子命令若与 Golden Shortcut 的 confirmation 不一致,停止并 +报告交付漂移,不以文档确认代替 Runtime gate。 + ## 必读约束 - 发消息前必须核对接收对象、消息内容、@ 对象、附件路径和消息类型;不明确时先问用户。 - `--group`、`--user`、`--open-dingtalk-id` 通常互斥,群聊用 `--group`,单聊用 `--user` 或 `--open-dingtalk-id`。 -- 发送本地文件、音频、视频统一用 `chat message send --msg-type file|audio|video --file-path `;`audio` / `video` 底层按 `file` 链路发送;旧图片链路 `--msg-type image --media-id` 仅在已有 mediaId 时使用。 +- 发送本地文件、音频、视频默认用 `dws chat +messages-send --as user --file <相对路径>`; + `audio` / `video` 的具体类型以 leaf Schema 为准。只有 Shortcut 未暴露的位置、名片等底层 + 类型才进入本文件的原子 fallback。 - 发送位置消息前必须确认纬度、经度、地址名称;地图缩略图需先通过旧媒体上传链路拿到 mediaId。 - 分享联系人名片前必须确认联系人 `openDingTalkId`,不要把 userId 直接当 `--contact-id`。 - 消息内容按 Markdown 渲染,换行必须是真实换行符;需要换行效果时用空行、行尾两个空格或 `
`。 - 建议发送时带 `--uuid`,失败重试复用同一个值。 +- Bot/Webhook 只支持文本/Markdown;Bot 多群使用 `+messages-send --groups/--groups-file` 的逐项 + ledger。不要把 user 文件/图片能力外推到 Bot。 -## 命令明细 +## 原子 fallback 命令明细 -### 发送消息 +### 发送消息的底层 fallback -#### `dws chat message send` +#### `dws chat message send`(非默认入口) 以当前用户身份发送群聊或单聊消息。 @@ -65,9 +86,16 @@ dws chat message send --group --msg-type profile --contact- | `--contact-id` | 联系人名片 openDingTalkId | | `--uuid` | 幂等 UUID,24h 内相同值不重复投递 | -话题回复:`chat message list` 返回的 `openConvThreadId` 可直接作为 `--group` 值发送到话题内,禁止自行拼接。 +当前没有经过验证的 Thread writer。`openConvThreadId` 只用于 `+thread-replies` 读取或 +`+messages-forward-topic` 转发;不要把它作为普通 `--group` 猜测写入。引用回复使用 +`+messages-reply`,但这不等于 Thread 内新增回复。 -### 拉取消息 +### 拉取消息的底层 fallback + +默认使用 `dws chat +chat-messages`。群聊的 `--group` 可传群名或 openConversationId; +也可用 `--chat-query` 显式按群名解析、用 `--conversation-id` 显式传稳定 ID。全量读取加 `--page-all`,必要时用 `--page-limit`、 +`--max-results` 控制边界,用 `--output <相对.json>` 原子导出。只有需要原始响应或显式手工 +continuation 时才使用下表;原子 `message list` 不代表自动全量分页。 | 命令 | 用途 | 示例与要点 | |------|------|------------| @@ -87,9 +115,11 @@ dws chat message send --group --msg-type profile --contact- - `hasMore=true` 时,用结果中的边界 `createTime` 作为下次 `--time`。 - 返回 `openConvThreadId` 表示话题消息,完整内容需再拉 `list-topic-replies`。 -### 搜索消息 +### 搜索消息的底层 fallback -优先使用 `message search-advanced`,它是 `message search` 的严格超集。 +默认使用 `dws chat +search-msg`;搜索内容使用 canonical `--query`,群名和发送者姓名分别使用 +`--chat-query` / `--sender-query`。需要 Shortcut 未暴露的原始过滤字段或响应时,才评估 +`message search-advanced`。它是原子 `message search` 的严格超集,但不是 Agent 高频默认入口。 ```bash dws chat message search-advanced --query "周报" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" @@ -125,11 +155,11 @@ dws chat message search --query "codereview" --group --star | 命令 | 用途 | 必填参数 | |------|------|----------| | `message query-send-status` | 查询当前用户发消息任务状态 | `--open-task-id`,来自 `message send` 返回 | -| `message recall` | 撤回当前用户消息;群主/管理员可撤回群内他人消息 | `--conversation-id` `--msg-id` | +| `+messages-recall` | 撤回当前用户消息 | `--msg-id`;可选 `--conversation-id`,省略时 CLI 从消息详情补齐;兼容单值 `--message-ids` | | `message edit` | 编辑已发送消息内容 | `--conversation-id` `--msg-id`,并在 `--text` / `--content` 中二选一;可选 `--title` `--at-all` `--at-open-dingtalk-ids` | | `message read-status` | 查消息已读/未读状态 | `--group` `--message-id`;可选目标用户 | -`recall` 与 `recall-by-bot` 不同:前者通过 IM 接口撤回用户消息,需要 `openConversationId + openMessageId`,支持撤回自己发出的消息,也支持群主/管理员撤回群内他人消息;后者撤回机器人消息,需要 `robot-code + processQueryKey`。 +`+messages-recall` 与 `recall-by-bot` 不同:前者使用 `openMessageId`,缺少会话 ID 时先只读查询消息详情;后者撤回机器人消息,需要 `robot-code + processQueryKey`。不要把 `processQueryKey` 当 `openMessageId`。 编辑消息使用 `message edit`。推荐传 `--text`,CLI 会生成 markdown content JSON:`{"title":"标题","text":"正文"}`;可选 `--title`,不传时会从正文自动生成标题。高级场景可直接传 `--content`,此时必须是完整 markdown content JSON,且不能同时传 `--text`。 @@ -143,7 +173,10 @@ dws chat message edit --group --msg-id --te dws chat message edit --group --msg-id --content '{"title":"标题","text":"更新后的内容"}' ``` -### 回复与转发 +### 回复与转发的底层 fallback + +引用回复默认使用 `dws chat +messages-reply`。以下原子 reply 只保留底层字段 fallback;转发 +仍按各自精确 Shortcut/leaf Schema 选择。 | 命令 | 用途 | 必填参数 | |------|------|----------| @@ -159,16 +192,17 @@ dws chat message edit --group --msg-id --co 1. `dws chat message list --group --time ...` 获取话题主消息。 2. 如果返回 `openConvThreadId`,执行 `dws chat message list-topic-replies --group --topic-id `。 -流式卡片必须 `send-card` 与 `update-card` 搭配: +流式卡片优先使用公开 Shortcut;创建可选在同一次调用中写入内容: ```bash -dws chat message send-card --group -dws chat message send-card --receiver -dws chat message update-card --biz-id --content "更新的卡片内容" --flow-status 2 -dws chat message update-card --biz-id --content "最终内容" --flow-status 3 +dws chat +messages-send-card --group --content "开始处理" --flow-status 1 +dws chat +messages-update-card --biz-id --content "更新的卡片内容" --flow-status 2 +dws chat +messages-update-card --biz-id --content "最终内容" --flow-status 3 ``` -`flow-status`:1=处理中,2=输入中,3=完成,4=执行中,5=错误。最后一次必须设为 3,否则卡片会停留在“生成中”。 +`flow-status`:1=处理中,2=输入中,3=完成,4=执行中,5=错误,Runtime 拒绝范围外值。 +当前只支持 streaming text;不支持 Card JSON 组件或 action callback。精确边界见 +[card references](../card/schema.md)。 ### Pin / Top / Favorite @@ -230,16 +264,20 @@ dws chat message download-media --type mediaId --resource-id --message `resource-id` 来自消息内容中的 mediaId,`message-id` 来自 `openMessageId`,会话 ID 来自 `chat search` 或 `conversation-info`。 +公开 `+messages-resource-download` 使用工作目录内安全相对路径、默认不覆盖、整文件临时落盘后 +原子发布。当前没有 Range/断点续传;失败时保留 ledger 或错误,显式重试整个文件,不拼接残片。 + ## 常见工作流 ### 群聊发文字与文件 ```bash -dws chat search --query "项目冲刺" --format json -dws chat message send --group --text "请大家本周五前提交周报" --uuid --format json -dws chat message send --group --msg-type file --file-path ./report.pdf --format json -dws chat message send --group --msg-type audio --file-path ./voice.mp3 --format json -dws chat message send --group --msg-type video --file-path ./demo.mp4 --format json +dws chat +send-to-group --group "项目冲刺" --text "请大家本周五前提交周报" --format json +dws chat +messages-send --as user --chat-query "项目冲刺" --file ./report.pdf --uuid --format json +dws chat +messages-send --as user --chat-query "项目冲刺" --msg-type audio --file ./voice.mp3 --uuid --format json +dws chat +messages-send --as user --chat-query "项目冲刺" --msg-type video --file ./demo.mp4 --uuid --format json + +# 仅 Shortcut 尚未覆盖的位置/名片底层类型才使用原子 fallback dws chat message send --group --msg-type location --latitude <纬度> --longitude <经度> --location-name <地址名称> --map-thumbnail-url "@mediaId" --format json dws chat message send --group --msg-type profile --contact-id --format json ``` @@ -247,8 +285,8 @@ dws chat message send --group --msg-type profile --contact- ### 查消息并撤回 ```bash -dws chat message list --group --time "2026-03-10 00:00:00" --direction older --format json -dws chat message recall --conversation-id --msg-id --format json +dws chat +chat-messages --group --direction older --format json +dws chat +messages-recall --msg-id --format json ``` ### 多维度搜索 @@ -260,7 +298,7 @@ dws chat message search-advanced --message-type file --search-conv-type group_ch ## 常见错误与回退 -- 发送目标不唯一:先确认群/人;群用 `chat search`,单聊用 `aisearch person` + `conversation-info`。 +- 发送目标不唯一:保留 resolver 返回的候选并让用户消歧;不要退回手工搜索后选择第一项。 - `unknown flag`:立即执行对应命令 `--help`,不要猜参数。 - 文件/音视频发送失败:确认本地路径可读;新链路使用 `--msg-type file|audio|video --file-path`。 - 位置消息参数不完整:先确认经纬度、地址名称和缩略图 mediaId。 diff --git a/skills/multi/dingtalk-chat/references/chat/chat-workflows.md b/skills/multi/dingtalk-chat/references/chat/chat-workflows.md deleted file mode 100644 index 889c5e13..00000000 --- a/skills/multi/dingtalk-chat/references/chat/chat-workflows.md +++ /dev/null @@ -1,179 +0,0 @@ -# chat-workflows:高频流程与上下文传递 - -> 返回入口:[chat.md](../chat.md) - -## 适用场景 - -用于复合任务编排、字段传递、openDingTalkId 获取、自动化脚本和跨产品衔接。 - -## 必读约束 - -- 已知上游字段时直接使用,不要重复搜索。 -- 群聊 `openConversationId` 通常来自 `chat search`;单聊 `openConversationId` 来自 `conversation-info`。 -- `userId` 与 `openDingTalkId` 不是同一种标识,按命令要求传递。 - -## openDingTalkId 获取方式 - -| 场景 | 命令 | -|------|------| -| 搜人员 openDingTalkId | `dws aisearch person --keyword "姓名" --dimension name --format json` | -| 给机器人发单聊 | `dws chat bot find --query "机器人名" --format json`,不要用 `bot search` | -| 已有 userId 需要单聊会话 ID | `dws chat conversation-info --user --format json` | -| 已有 openDingTalkId 需要单聊会话 ID | `dws chat conversation-info --open-dingtalk-id --format json` | - -## 高频工作流 - -### 群聊消息 - -```bash -# 1. 搜索群,提取 openConversationId -dws chat search --query "项目冲刺" --format json - -# 2. 拉取群消息 -dws chat message list --group --time "2025-03-01 00:00:00" --direction older --format json - -# 3. 拉未读会话 -dws chat message list-unread-conversations --count 20 --format json -``` - -### 发送消息 - -```bash -# 个人身份发群消息 -dws chat message send --group --title "周报提醒" --text "请大家本周五前提交周报" --uuid --format json - -# 个人身份发单聊 -dws chat message send --user --text "你好" --uuid --format json -dws chat message send --open-dingtalk-id --text "你好" --uuid --format json - -# 发送图片/文件/音频/视频 -dws chat message send --group --msg-type file --file-path ./screenshot.png --format json -dws chat message send --open-dingtalk-id --msg-type file --file-path ./report.pdf --format json -dws chat message send --group --msg-type audio --file-path ./voice.mp3 --format json -dws chat message send --group --msg-type video --file-path ./demo.mp4 --format json - -# 文件后补文字说明 -dws chat message send --open-dingtalk-id --msg-type file --file-path ./screenshot.png --format json -dws chat message send --open-dingtalk-id --text "这是本周数据汇总" --format json -``` - -### 机器人消息 - -```bash -# 查我的机器人,提取 robotCode -dws chat bot search --name "日报" --format json - -# 机器人发群消息,提取 processQueryKey;Markdown 稳定换行用 \n\n,不要只用 \n -dws chat message send-by-bot --robot-code --group --title "日报" --text "## 今日完成\n\n- 事项 A\n\n- 事项 B" --format json - -# 撤回机器人消息 -dws chat message recall-by-bot --robot-code --group --keys --format json - -# 机器人不在群内时先邀请 -dws chat group members add-bot --id --robot-code --format json -dws chat message send-by-bot --robot-code --group --title "通知" --text "内容" --format json - -# 给机器人发单聊,必须用 find 拿 openDingTalkId -dws chat bot find --query "玉澜" --format json -dws chat message send --open-dingtalk-id --text "你好" --format json -``` - -### 机器人 @ 指定人 - -```bash -dws aisearch person --keyword "张三" --dimension name --format json - -dws chat message send-by-bot --robot-code --group \ - --at-user-ids userId1,userId2 \ - --title "提醒" --text "@userId1 @userId2 请查收本周报告" --format json - -dws chat message send-by-bot --robot-code --group \ - --at-open-dingtalk-ids openDingtalkId1,openDingtalkId2 \ - --title "提醒" --text "@openDingtalkId1 @openDingtalkId2 请查收本周报告" --format json - -dws chat message send-by-bot --robot-code --group \ - --at-all --title "通知" --text "请所有人注意" --format json -``` - -### Webhook 告警 - -```bash -dws chat message send-by-webhook --token --title "告警" --text "CPU 超 90% @10" --at-all --format json -``` - -### 话题完整读取与回复 - -```bash -dws chat message list --group --time "2026-03-10 00:00:00" --direction older --format json -dws chat message list-topic-replies --group --topic-id --format json -dws chat message send --group --text "回复话题内容" --format json -``` - -### 群公告与邀请分享 - -```bash -# 分享 A 群邀请链接到 B 群 -dws chat group share-invite --source --target --format json - -# 发布群公告,Markdown 段落换行用空行 -dws chat group notice create --group --content "# 重要通知\n\n请大家查收" --send-ding --format json - -# 修改公告前先列表查询 notice-id/dataId -dws chat group notice list --group --format json -dws chat group notice edit --group --notice-id --content "完整的新公告内容" --format json -``` - -### 智能分组与文本翻译 - -```bash -dws aisearch person --keyword "张三" --dimension name --format json -dws chat category create-smart --name "重点群" --keywords "重点,项目" --members openDingTalkId1 --format json -dws chat text translate --query "你好世界" --to en_US --format json -``` - -## 上下文传递表 - -| 操作 | 从返回中提取 | 用于 | -|------|-------------|------| -| `chat search` | `openConversationId` | `message send/list`、`group members`、`set-top`、`group-mute` | -| `chat group create` | `openConversationId` | 新群后续发消息、成员管理、群设置 | -| `chat group get-by-group-id` | `openConversationId` | 将数字群号转为可用会话 ID | -| `chat conversation-info` | `openConversationId` | 单聊消息搜索、转发、会话状态操作 | -| `aisearch person` | `userId` | `message send --user`、`send-by-bot --users`、`--at-user-ids`、`list-by-sender --sender-user-id` | -| `aisearch person` | `openDingTalkId` | `message send --open-dingtalk-id`、`--at-open-dingtalk-ids`、`send-by-bot --open-dingtalk-ids` | -| `aisearch person` | `userId` | 可替代人员搜索结果,继续用于 userId 参数 | -| `chat bot search` | `robotCode` | `send-by-bot`、`recall-by-bot` | -| `chat bot find` | `openDingTalkId` | 给机器人发单聊 | -| `chat group bots` | `openBotId` | `group members remove-bot --bot-id` | -| `chat message send-by-bot` | `processQueryKey` | `recall-by-bot --keys` | -| `chat message send` | `openTaskId` | `query-send-status --open-task-id` | -| `chat message list` | `openMessageId` | `message recall --msg-id`、`reply --ref-msg-id`、`forward --msg-id` | -| `chat message list` | `openMsgId` | `read-status --message-id`、表情回应命令 | -| `chat message list` | `openConvThreadId` | `list-topic-replies --topic-id`、话题回复 `message send --group` | -| `chat message search` | `nextCursor` | 下次 `message search --cursor` | -| `chat message search-advanced` | `nextCursor` | 下次 `search-advanced --cursor` | -| `chat message list-all` | `nextCursor` | 下次 `list-all --cursor` | -| `chat search-common` | `openConversationId` | 后续消息发送/拉取 | -| `chat group notice list/get/create` | `dataId` / `notice-id` | `group notice get/edit --notice-id` | -| `chat group-role list` | `openRoleId` | `group-role update/remove/set-user/remove-user --role-id` | -| `chat message create-text-emotion` | `emotionId` | `add-text-emotion --emotion-id` | -| `chat message list/search-advanced` | `openMessageId` / `openMsgId` | `list-emotion-replies --msg-ids` | -| `chat category list` | `categoryId` | `category list-conversations/add-conv/remove-conv --category-id(s)` | -| `chat category create-smart` | 智能分组 ID/规则结果 | 后续查看或调整智能分组 | -| `chat message send-card` | `bizId` | `update-card --biz-id` | - -## 自动化脚本 - -| 脚本 | 场景 | 用法 | -|------|------|------| -| [chat_export_messages.py](../../scripts/chat_export_messages.py) | 导出群聊消息到 JSON 文件 | `python chat_export_messages.py --query "项目冲刺" --time "2026-03-10 00:00:00"` | -| [chat_history_with_user.py](../../scripts/chat_history_with_user.py) | 查询与某人的单聊聊天记录 | `python chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"` | -| [extract_media_id.py](../../scripts/extract_media_id.py) | 旧链路:从 dt_media_upload URL 提取 mediaId | 新场景不要用,文件/音视频直接 `--msg-type file|audio|video --file-path` | - -## 常见错误与回退 - -- 字段已由上游返回:直接传递,不要再次搜索。 -- `chat bot search` 没有 openDingTalkId:改用 `chat bot find`。 -- 群号是纯数字:先 `chat group get-by-group-id`。 -- 翻页不要自造 cursor:使用响应中的 `nextCursor` 原值。 -- 文件/音视频发送不要走旧上传链路:优先 `message send --msg-type file|audio|video --file-path`;`audio/video` 底层按 `file` 发送。 diff --git a/skills/multi/dingtalk-chat/references/contracts.md b/skills/multi/dingtalk-chat/references/contracts.md new file mode 100644 index 00000000..59d47f27 --- /dev/null +++ b/skills/multi/dingtalk-chat/references/contracts.md @@ -0,0 +1,62 @@ +# IM 稳定契约与能力边界 + +本页只记录需要跨命令复用的 Runtime 契约。下面四个 marker 区块由 +`check-multi-im-skill-chain.sh` 对照 Go typed descriptor 逐字校验;修改能力时先改 Runtime、 +测试与 descriptor,再同步本页。命令参数仍以精确 leaf Schema 为准。 + +## 消息结果 `im.message-list.v1` + +`+chat-messages`、`+search-msg`、`+messages-mget` 及相关消息读取使用同一兼容版本。 +字段可能为空,但不能换名猜测;全量任务必须结合完整性 ledger 判断。 + + +- `version`: `im.message-list.v1` +- `message_fields`: `messageId`, `conversationId`, `threadId`, `sender`, `senderId`, `senderType`, `messageType`, `text`, `createTime`, `updateTime`, `reactions`, `quotedMessage`, `forwarded`, `resourceRefs` +- `envelope_fields`: `contractVersion`, `messages`, `count`, `pagesFetched`, `paginationKnown`, `complete`, `hasMore`, `nextPage`, `stopReason`, `truncatedByPageLimit`, `truncatedByResultLimit`, `failedCount`, `failures`, `partial`, `resourceDownloads` + + +当 `complete=false` 时不能称为全量成功。`nextPage` 只能来自真实 lower boundary; +`failedCount/failures`、`partial` 和两个 truncated 字段必须原样保留。 + +## `+messages-send` 身份矩阵 + + +| identity | targets | content types | natural targets | mention targets | idempotency keys | batch ledger | +|---|---|---|---|---|---:|---:| +| `user` | `group`
`direct-user`
`direct-open-dingtalk-id` | `text`
`markdown`
`image-media-id`
`file`
`audio-as-file`
`video-as-file` | `chat-query`
`user-query` | `open-dingtalk-id`
`all` | `true` | `false` | +| `bot` | `group`
`groups`
`direct-users`
`direct-open-dingtalk-ids` | `text`
`markdown` | — | `user-id`
`open-dingtalk-id`
`all` | `false` | `true` | +| `webhook` | `token-owned-group` | `text`
`markdown` | — | `user-id`
`mobile`
`all` | `false` | `false` | + + +Bot 多群用 `--groups` 或 `--groups-file`,Runtime 去重后输出 +`im.batch-write.v1` 逐目标 ledger。Bot/Webhook 不支持的内容类型会在写前失败,不能降级为 +另一身份或偷偷改成纯文本。 + +## 流式卡片 + + +- `version`: `im.streaming-card.v1` +- `targets`: `group`, `direct-user`, `direct-open-dingtalk-id` +- `content_types`: `streaming-text` +- `flow_statuses`: `1=processing`, `2=typing`, `3=completed`, `4=executing`, `5=error` +- `callback_supported`: `false` + + +发送目标与状态范围由 Runtime 校验。当前不是 Lark Card JSON 编译器,也不消费按钮 callback; +具体创建和更新流程见 `card/` 下的精确 reference。 + +## 正向能力与负向边界 + + +| capability | supported | current route / boundary | +|---|---:|---| +| `thread-write` | `false` | quote reply with +messages-reply; thread reading with +thread-replies | +| `bot-rich-media` | `false` | bot text/markdown, or current-user file/image send | +| `card-action-callback` | `false` | streaming text card create/update only | +| `resource-resume` | `false` | atomic whole-file download with explicit retry | +| `group-member-full-pagination` | `true` | +chat-members-list or +group-members | +| `group-owner-selection` | `true` | +chat-create owner flags | + + +`supported=false` 是执行门禁,不是待猜测字段。只有 lower interface、Runtime、测试、Schema 和 +此页同时升级后,才能改变对外承诺。 diff --git a/skills/multi/dingtalk-chat/references/intent-guide.md b/skills/multi/dingtalk-chat/references/intent-guide.md index 7fa79ebf..6f64a323 100644 --- a/skills/multi/dingtalk-chat/references/intent-guide.md +++ b/skills/multi/dingtalk-chat/references/intent-guide.md @@ -1,88 +1,51 @@ -# chat 局部意图消歧 +# Chat 低频意图消歧 -本文件从单 Skill `intent-guide.md` 拆分而来,仅保留与本产品相关的跨产品消歧规则。 +只在根 Skill 的 Golden Route 无法区分相邻能力时读取。命令 flags 和安全语义以精确 leaf Schema/Runtime 为准。 -| 用户说... | 真实意图 | 应该用 | 不要用 | 理由 | -|---|---|---|---|---| -| "通知群里的人都来开会" | 个人身份群发 | `chat message send` | `chat message send-by-bot` | 以个人身份向群发消息 | -| "让机器人每天推送日报" | 机器人定时推送 | `chat message send-by-bot` | `chat message send` | 需要机器人身份定期发送 | -| "CPU 超过 90% 自动告警" | Webhook 告警 | `chat message send-by-webhook` | `chat message send-by-bot` | 系统告警场景,需自定义 Webhook | -| "拉取一下上周项目群的聊天记录" | 拉取会话消息 | `chat message list` | — | 拉取指定群聊的消息列表 | -| "看看张三发给我的消息" | 按发送者查询消息 | `chat message list-by-sender` | `chat message list --user` | 用户未明确说"单聊"时优先用 list-by-sender(跨单聊/群聊) | -| "拉取和张三的单聊记录" | 拉取单聊消息 | `chat message list --user` | `chat message list-by-sender` | 用户明确说"单聊"时用 list --user | -| "谁@了我/查看提及我的消息" | 查询@我的消息 | `chat message list-mentions` | `chat message list-all` | 都是跨会话时间范围查询,但 list-mentions 只返回@我的消息 | -| "查看我今天的所有消息" | 全量会话消息 | `chat message list-all` | `chat message list` | 用户未指定具体会话时用 list-all(跨所有会话),指定了具体群或人时用 list | -| "搜一下消息里的changefree链接" | 消息搜索 | `chat message search-advanced`(首选) | `chat search` | 推荐首选 search-advanced,它是 search 的严格超集(keyword 可选、支持多群、可叠加发送者/at 维度) | -| "按发送者搜索/指定多个群搜索/多维度搜消息" | 多维度搜索消息 | `chat message search-advanced`(首选) | `chat message search` | 推荐首选,支持关键词、发送者、@我、多个会话等维度组合 | -| "消息发没发成功/查询消息发送状态" | 查询消息发送状态 | `chat message query-send-status` | — | 需要 send 返回的 openTaskId | -| "撤回我发的消息/撤回消息/群主撤回消息/管理员撤回他人消息" | 撤回消息 | `chat message recall` | `chat message recall-by-bot` | recall 撤回个人消息或群主/管理员撤回他人消息,recall-by-bot 撤回机器人消息 | -| "未读消息会话/未读会话列表/我的未读会话" | 未读会话列表 | `chat message list-unread-conversations` | `chat message read-status` | list-unread-conversations 查哪些会话有未读;read-status 查具体消息的已读状态 | -| "谁看了这条消息/消息已读未读/查读状态" | 查询消息已读状态 | `chat message read-status` | `chat message list-unread-conversations` | read-status 查具体消息的已读人员;list-unread-conversations 查未读会话列表 | -| "查看消息的表情回复/拉取消息回复/消息的文字回复" | 批量拉取消息表情回复和文字回复 | `chat message list-emotion-replies` | — | 根据消息 ID 批量查询表情回复和文字回复 | -| "我和风雷的共同群/我们都在哪些群" | 搜索共同群 | `chat search-common` | `chat search` | search-common 按人员搜共同群,chat search 按群名搜索 | -| "查看会话分组/自定义分组" | 获取会话分组 | `chat category list` | — | 获取用户自定义的会话分组列表 | -| "某个分组下有哪些会话" | 分组下会话列表 | `chat category list-conversations` | — | 需先通过 category list 获取分组 ID | -| "创建会话分组/新建分组" | 创建自定义分组 | `chat category create` | — | 需指定 --title 分组名称 | -| "删除会话分组/移除分组" | 删除自定义分组 | `chat category delete` | — | 需指定 --category-id | -| "重命名分组/修改分组名称" | 更新分组名称 | `chat category rename` | — | 需指定 --category-id 和 --title | -| "把会话加到分组/将群移入分组" | 会话加入分组 | `chat category add-conv` | `chat category remove-conv` | add-conv 加入;remove-conv 移出 | -| "把会话从分组移出/从分组中删除" | 会话移出分组 | `chat category remove-conv` | `chat category add-conv` | remove-conv 移出;add-conv 加入 | -| "创建智能分组/新建智能会话分组/按关键词分组" | 创建智能会话分组 | `chat category create-smart` | `chat category create` | create-smart 按关键词/成员智能匹配;create 手动创建普通分组 | -| "分享群链接/把群分享给某人/群链接发到某群" | 分享群聊链接到会话 | `chat group share-invite` | `chat group invite-url` | share-invite 直接分享到目标会话/用户;invite-url 只获取链接不发送 | -| "根据群号查群信息/群号转openConversationId" | 群号查群聊信息 | `chat group get-by-group-id` | `chat search` | 已知数字群号时直接查;用户发消息只给了群号时,先用此工具将群号转为 openConversationId | -| "我创建的群/我管理的群/我是群主的群/我当管理员的群" | 拉取我创建/管理的群 | `chat group list-my-groups` | `chat search` | list-my-groups 按角色(群主/管理员)过滤当前用户的群;chat search 按关键词搜全部群 | -| "引用消息回复/回复那条消息" | 引用回复消息 | `chat message reply` | `chat message send` | reply 引用指定消息回复;send 普通发消息不引用 | -| "转发消息/把消息转到另一个群" | 转发单条消息 | `chat message forward` | `chat message send` | forward 转发已有消息到目标会话;send 是发新消息 | -| "置顶会话/取消置顶" | 设置/取消会话置顶 | `chat set-top` | `chat list-top-conversations` | set-top 设置或取消置顶;list-top-conversations 查看置顶列表 | -| "全员禁言/群禁言" | 全员禁言或解除 | `chat group-mute` | `chat group-mute-member` | group-mute 全员禁言或解除;group-mute-member 指定成员禁言 | -| "禁言某人/指定成员禁言" | 指定成员禁言 | `chat group-mute-member` | `chat group-mute` | group-mute-member 指定成员禁言/解禁;group-mute 全员禁言 | -| "查看群禁言配置/群里谁被禁言/禁言名单" | 查询群用户禁言配置 | `chat group get-mute-config` | `chat group-mute-member` | get-mute-config 是只读查询,返回黑白名单及时间配置;group-mute-member 会修改禁言状态 | -| "设管理员/取消管理员" | 设置群管理员 | `chat group set-admin` | `chat group invite` | set-admin 设置或取消管理员角色;invite 是邀请入群 | -| "退群/退出群聊/离开群" | 退出群聊 | `chat group quit` | `chat group members remove` | quit 是当前用户自己退群;members remove 是管理员踢别人 | -| "改群头像/更新群头像" | 更新群头像 | `chat group update-icon` | `chat group rename` | update-icon 改群头像;rename 改群名称 | -| "改群设置/群设置开关/群权限/入群许可/禁止私聊" | 更新管理员级别群功能开关 | `chat group update-settings` | `chat group set-admin` | update-settings 改管理员级别的群功能开关;set-admin 是管理员角色操作 | -| "改群昵称/设置群昵称/我在群里的名字" | 设置个人群昵称 | `chat group update-nick` | `chat group rename` | update-nick 改自己的群昵称;rename 改群名称 | -| "群备注/给群加备注/修改群备注" | 设置群备注 | `chat group update-alias` | `chat group rename` | update-alias 设置仅自己可见的备注;rename 改群名称全员可见 | -| "我的群置顶/取消我的群置顶/置顶这个群" | 设置当前用户群会话置顶 | `chat group user-settings set` | `chat group update-settings` | user-settings set 是个人会话置顶;update-settings 是管理员级别的群功能开关 | -| "我的群免打扰/关闭我的群免打扰/这个群别提醒我" | 设置当前用户群免打扰 | `chat group user-settings set` | `chat group update-settings` | user-settings set 是个人通知偏好;update-settings 是管理员级别的群功能开关 | -| "隐藏会话/隐藏群聊/隐藏对话" | 隐藏会话 | `chat hide` | `chat mute` | hide 隐藏会话不显示;mute 是免打扰但仍显示 | -| "关闭@所有人通知/屏蔽@all/不接收@所有人" | 关闭 @所有人提醒 | `chat mute-at-all` | `chat mute` | mute-at-all 仅屏蔽 @所有人;mute 是整个会话免打扰 | -| "关闭红包通知/屏蔽红包/不接收红包提醒" | 关闭红包提醒 | `chat mute-red-envelope` | `chat mute` | mute-red-envelope 仅屏蔽红包;mute 是整个会话免打扰 | -| "解散群/解散群聊" | 解散群聊 | `chat group dismiss` | `chat group quit` | dismiss 是群主解散整个群(不可逆);quit 是当前用户自己退群 | -| "新成员看历史/历史消息可见范围" | 设置新成员可见历史消息 | `chat group set-history` | `chat group update-settings` | set-history 控制新成员入群后可见历史消息范围;update-settings 是管理员级别的其他群功能开关 | -| "群里有哪些机器人/查看群机器人/列出群机器人" | 查看群内机器人列表 | `chat group bots` | `chat group members` | bots 只列机器人;members 列普通群成员 | -| "从群里移除机器人/踢机器人" | 移除群内机器人 | `chat group members remove-bot` | `chat group members add-bot` | remove-bot 通过 openBotId 移除;add-bot 通过 robotCode 添加 | -| "批量查群成员详情/按ID查群成员/查看指定成员信息" | 批量查询群成员详情 | `chat group members list-by-ids` | `chat group members` | list-by-ids 根据 openDingTalkId 列表查询指定成员详情;members 分页查询全部成员列表 | -| "标记未读/设为未读/会话标未读" | 标记会话为未读 | `chat mark-unread` | `chat clear-red-point` | mark-unread 标记为未读;clear-red-point 清除红点(相反操作) | -| "清除红点/已读/取消未读" | 清除会话红点 | `chat clear-red-point` | `chat mark-unread` | clear-red-point 清除单个会话红点;mark-unread 标记未读(相反操作) | -| "全部已读/一键清除所有未读/红点清零" | 清除所有会话红点 | `chat clear-all-red-point` | `chat clear-red-point` | clear-all-red-point 清除所有会话红点;clear-red-point 只清单个会话 | -| "所有会话/全部会话列表/我的会话" | 分页获取全部会话 | `chat list-all-conversations` | `chat list-top-conversations` | list-all-conversations 返回所有会话;list-top-conversations 仅返回置顶会话 | -| "清空聊天记录/删除会话消息/清空消息" | 清空会话聊天记录 | `chat clear-messages` | `chat clear-red-point` | clear-messages 清空聊天记录;clear-red-point 仅清除未读红点 | -| "标记已读/消息已读/读了" | 标记消息已读 | `chat mark-read` | `chat clear-red-point` | mark-read 标记指定消息及之前的消息为已读;clear-red-point 仅清除红点不标记消息 | -| "我加入的所有群/拉取全部群列表/我的群" | 分页拉取所有群 | `chat group list-all` | `chat group list-my-groups` | list-all 返回用户加入的所有群;list-my-groups 仅返回用户作为群主/管理员的群 | -| "入群申请记录/谁申请入群/群申请列表/查看入群审批" | 拉取入群验证记录 | `chat group list-join-validations` | `chat group list-all` | list-join-validations 拉取入群验证/审批记录;list-all 拉取群列表 | -| "通过入群申请/拒绝入群/审批入群/同意加群" | 审批入群验证 | `chat group audit-join-validation` | `chat group list-join-validations` | audit-join-validation 执行审批操作;list-join-validations 仅查询记录 | -| "发群公告/在群里发公告/群公告置顶" | 发布群公告 | `chat group notice create` | `blackboard create` | notice create 是群维度公告(发到指定群聊);blackboard 是企业公告(面向全员,不可撤回) | -| "改群公告/修改群公告/更新群公告" | 修改群公告 | `chat group notice edit` | `chat group notice create` | edit 整体替换已有公告内容(需 dataId);create 发布新公告 | -| "查群公告/看群公告/群公告列表/群里有什么公告" | 查看群公告列表 | `chat group notice list` | `blackboard list` | notice list 查指定群的公告;blackboard list 查企业公告 | -| "群公告详情/公告已读人数/谁读了公告" | 查看群公告详情 | `chat group notice get` | `chat group notice list` | get 返回单条公告详情(含已读人数、点赞/评论数);list 分页拉公告列表 | -| "搜索机器人/找机器人/查机器人/帮我找XXX机器人" | 搜索全部可用机器人 | `chat bot find` | `chat bot search` | find 返回全部可用机器人(含他人/官方),额外返回 openDingTalkId(可用于给机器人发单聊消息);search 仅返回我创建的 | -| "给机器人发单聊/给机器人发消息/跟机器人聊天" | 给机器人发单聊消息 | `chat bot find` → `chat message send --open-dingtalk-id` | `chat bot search` | 必须先用 find 拿 openDingTalkId(search 没有此字段),再用 send --open-dingtalk-id 发单聊 | -| "我创建的机器人/我的机器人/我自己的机器人/查看我的机器人" | 搜索我创建的机器人 | `chat bot search` | `chat bot find` | search 仅返回当前用户自己创建的机器人(返回 robotCode + robotName,无 openDingTalkId);find 返回全部可用机器人 | -| "合并转发/批量转发/合并转发多条消息" | 合并转发多条消息 | `chat message combine-forward` | `chat message forward` | combine-forward 合并多条为一条转发;forward 转发单条消息 | -| "转发话题/转发话题消息/话题转发到另一个群" | 转发话题消息 | `chat message forward-topic` | `chat message forward` | forward-topic 专用于转发话题消息(需要话题ID);forward 转发普通单条消息 | -| "发卡片消息/推送流式卡片" | 创建并推送流式卡片 | `chat message send-card` | `chat message send` | send-card 发流式卡片;send 发普通文本/Markdown 消息 | -| "更新卡片/流式更新卡片" | 流式更新卡片内容 | `chat message update-card` | `chat message send-card` | update-card 更新已有卡片;send-card 创建新卡片 | -| "钉住消息/Pin消息/置顶消息到会话" | 钉住消息 | `chat message set-pin-msg` | `chat set-top` | set-pin-msg 钉住单条消息(Pin);set-top 置顶整个会话 | -| "取消钉住/Unpin消息" | 取消钉住消息 | `chat message unset-pin-msg` | `chat message set-pin-msg` | unset-pin-msg 取消钉住;set-pin-msg 设置钉住 | -| "查看钉住的消息/Pin列表/钉住消息列表" | 拉取钉住消息列表 | `chat message list-pin-msg` | `chat message list` | list-pin-msg 只返回被钉住的消息;list 拉取全部消息 | -| "置顶某条消息/把这条消息置顶" | 置顶消息 | `chat message set-top-msg` | `chat set-top` | set-top-msg 置顶会话内某条消息;set-top 置顶整个会话在列表中 | -| "取消置顶消息/取消消息置顶" | 取消置顶消息 | `chat message unset-top-msg` | `chat message set-top-msg` | unset-top-msg 取消置顶;set-top-msg 设置置顶 | -| "DING消息/查DING/DING历史" | 查询 DING 消息列表 | `ding message list` | `chat message list` | ding 是独立顶层命令;ding message list 查 DING 消息;chat message list 查普通聊天消息 | -| "DING接收状态/谁收到了DING" | DING 接收状态 | `ding message receiver-status` | `chat message read-status` | ding 是独立顶层命令;receiver-status 查 DING 接收;chat message read-status 查普通消息已读 | -| "发DING/DING通知" | 发送 DING 消息 | `ding message send` | `chat message send` | DING 是钉钉的强提醒(应用内/短信/电话),独立顶层命令;普通群消息用 chat | -| "撤回DING" | 撤回 DING 消息 | `ding message recall` | `chat message recall` | DING 撤回独立命令;chat recall 是撤回普通聊天消息 | -| "搜一下智能化方案/最近 OKR 相关邮件/最近发版相关消息" | 搜企业知识内容 | `aisearch enterprise` | `doc search` / `mail search` / `chat message search` | 跨文档、消息、日程、听记、邮件等企业内容语义检索走 enterprise;具体 `queries/types/time-range` 抽槽见 `aisearch.md` | -| "我发给某人的消息/邮件/文档/今天我干了什么" | 搜行为记录 | `aisearch behavior` | `chat` / `mail` / `doc` / `report` | 关注“谁对什么做过什么”,走 behavior;具体 `behavior-type/direction/chat-scope` 抽槽见 `aisearch.md` | -| "把这段文字翻译成英文/translate this" | 通用文本翻译 | `chat text translate` | `doc` / `aisearch` | 纯文本翻译,不是文档编辑或语义搜索 | -| "帮我把这个文档翻译成日文" | 文档内容翻译 | 先 `doc get` 再 `chat text translate` | `chat text translate` 直接传文件 | translate 仅支持纯文本,需先提取文档内容 | +## 消息选择 + +| 用户终点 | 选择 | 不要混用 | +|---|---|---| +| 给姓名发简单文本 | `+dm` | 先查人再原子发送 | +| 给群名发简单文本 | `+send-to-group` | 先搜群再原子发送 | +| 文件、Bot、Webhook、复杂 @、幂等 | `+messages-send` | 为不同身份各走一套原子入口 | +| 浏览指定会话 | `+chat-messages` | 跨会话搜索命令 | +| 跨会话或多维过滤 | `+search-msg` | 单会话浏览命令 | +| 已知消息 IDs 取详情 | `+messages-mget` | 重新搜索关键词 | +| @我的消息 | `+at-me` | 全量消息后本地猜测 @ | +| 已知 thread/topic ID 的回复 | `+thread-replies` | 普通消息列表 | +| 引用回复一条消息 | `+messages-reply` | 普通发送 | +| 单条/合并/话题转发 | `+messages-forward` / `+messages-combine-forward` / `+messages-forward-topic` | 复制正文重新发送 | +| 流式卡片创建或更新 | `+messages-send-card` / `+messages-update-card` | 普通 text/Markdown 发送 | + +## 对象层级 + +| 用户终点 | 对象 | Reference | +|---|---|---| +| 收藏或取消收藏 | 当前用户的 Favorite | [chat-message.md](chat/chat-message.md) | +| Pin/Unpin 一条消息 | 消息 Pin | [chat-message.md](chat/chat-message.md) | +| 置顶/取消置顶一条消息 | 消息 Top | [chat-message.md](chat/chat-message.md) | +| 置顶/取消置顶整个会话 | 会话 Top | [chat-conversation.md](chat/chat-conversation.md) | +| 查看置顶会话 | 会话列表 | `+conversation-list-top` | +| 标记消息已读 | 消息读取状态 | [chat-message.md](chat/chat-message.md) | +| 清红点、标记会话未读 | 会话状态 | [chat-conversation.md](chat/chat-conversation.md) | + +Favorite、消息 Pin、消息 Top 和会话 Top 不能互换,即使用户都说“收藏/钉住/置顶”。 + +## 群与机器人 + +| 用户终点 | 选择 | +|---|---| +| 已有成员 IDs 创建群 | `+chat-create` | +| 加人、踢人、管理员、群公告、群设置 | [chat-group.md](chat/chat-group.md) | +| 找可用机器人并取得单聊 ID | `chat bot find`,不是只查自己创建机器人的 `bot search` | +| 已知 robotCode 发送 | `+messages-send --as bot` | +| 机器人入群、移除、批量群发或撤回 | [chat-bot.md](chat/chat-bot.md) | + +## 跨产品边界 + +- 紧急 DING、短信或电话:切 `dingtalk-misc`,不要当普通 Chat 消息。 +- 邮件:切 `dingtalk-mail`。 +- 只查询人员资料或把姓名解析成 ID:切 `dingtalk-contact`;若终点只是简单发消息,直接留在 Chat 使用 `+dm`。 +- 企业知识跨文档、消息、邮件搜索:切 `dingtalk-aisearch`;明确只搜聊天消息时使用 `+search-msg`。 +- 文档翻译先由文档产品读取正文;`chat text translate` 只处理纯文本。 diff --git a/skills/multi/dingtalk-chat/references/lite-recipes.md b/skills/multi/dingtalk-chat/references/lite-recipes.md deleted file mode 100644 index 09f1c2ae..00000000 --- a/skills/multi/dingtalk-chat/references/lite-recipes.md +++ /dev/null @@ -1,7 +0,0 @@ -# chat Lite Recipe - -本文件从单 Skill `lite-recipes.md` 拆分而来,仅保留与本产品相关的轻量流程。 - -## #1 消息沟通 - -所有消息沟通相关的命令详情、参数说明、意图路由和复合工作流,请查阅 [chat.md](./chat.md)。 diff --git a/skills/multi/dingtalk-chat/scripts/bot_broadcast.py b/skills/multi/dingtalk-chat/scripts/bot_broadcast.py deleted file mode 100644 index 5317e222..00000000 --- a/skills/multi/dingtalk-chat/scripts/bot_broadcast.py +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env python3 -""" -用机器人向多个群批量发送相同消息(如日报提醒) - -用法: - python bot_broadcast.py \ - --robot-code \ - --chats "conv_id1,conv_id2,conv_id3" \ - --title "日报提醒" \ - --text "请大家今天下班前提交日报" - - python bot_broadcast.py \ - --robot-code \ - --chats-file groups.txt \ - --title "周会通知" \ - --text "明天下午3点周会" - - python bot_broadcast.py --dry-run ... -""" - -import sys -import json -import subprocess -import argparse -from pathlib import Path -from typing import List, Any, Optional - - -def run_dws( - args: List[str], dry_run: bool = False, -) -> Optional[Any]: - cmd = ['dws'] + args - if dry_run: - print(f"[dry-run] {' '.join(cmd)}") - return {'dry_run': True} - try: - result = subprocess.run( - cmd, capture_output=True, text=True, timeout=60 - ) - if result.returncode != 0: - print(f" ✗ 错误:{result.stderr.strip()}") - return None - return json.loads(result.stdout) - except (subprocess.TimeoutExpired, json.JSONDecodeError, - FileNotFoundError) as e: - print(f" ✗ 错误:{e}") - return None - - -def main(): - parser = argparse.ArgumentParser( - description='向多个群批量发送机器人消息' - ) - parser.add_argument( - '--robot-code', required=True, help='机器人 Code' - ) - parser.add_argument('--chats', default='', help='会话 ID 列表') - parser.add_argument( - '--chats-file', default='', - help='会话 ID 文件 (每行一个)', - ) - parser.add_argument('--title', required=True, help='消息标题') - parser.add_argument( - '--text', required=True, help='消息内容 Markdown' - ) - parser.add_argument('--dry-run', action='store_true') - args = parser.parse_args() - - chat_ids: List[str] = [] - if args.chats: - chat_ids = [c.strip() for c in args.chats.split(',') - if c.strip()] - elif args.chats_file: - p = Path(args.chats_file) - if not p.exists(): - print(f"错误:文件不存在: {p}") - sys.exit(1) - chat_ids = [line.strip() for line in - p.read_text(encoding='utf-8').splitlines() - if line.strip() and not line.startswith('#')] - if not chat_ids: - print('错误:需要 --chats 或 --chats-file') - sys.exit(1) - - print(f"📢 批量发送消息到 {len(chat_ids)} 个群") - print(f" 标题: {args.title}") - print(f" 机器人: {args.robot_code}") - print('=' * 50) - - success, fail = 0, 0 - for i, chat_id in enumerate(chat_ids, 1): - result = run_dws([ - 'chat', 'message', 'send-by-bot', - '--robot-code', args.robot_code, - '--group', chat_id, - '--title', args.title, - '--text', args.text, - '--format', 'json', - ], dry_run=args.dry_run) - if result: - print(f" ✓ [{i}/{len(chat_ids)}] {chat_id}") - success += 1 - else: - print(f" ✗ [{i}/{len(chat_ids)}] {chat_id}") - fail += 1 - - print(f"\n完成: 成功 {success}, 失败 {fail}") - sys.exit(0 if fail == 0 else 1) - - -if __name__ == '__main__': - main() diff --git a/skills/multi/dingtalk-chat/scripts/chat_export_messages.py b/skills/multi/dingtalk-chat/scripts/chat_export_messages.py deleted file mode 100644 index da7e50c4..00000000 --- a/skills/multi/dingtalk-chat/scripts/chat_export_messages.py +++ /dev/null @@ -1,230 +0,0 @@ -#!/usr/bin/env python3 -""" -导出群聊消息到 JSON 文件(从指定时间点拉取) - -用法: - python chat_export_messages.py \ - --group \ - --time "2026-03-10 00:00:00" \ - --output messages.json - - python chat_export_messages.py \ - --query "项目冲刺" \ - --time "2026-03-10 00:00:00" \ - --no-forward --limit 100 -""" - -import sys -import json -import datetime -import subprocess -import argparse -from typing import List, Any, Optional - - -def timestamp_to_datetime(ts_ms: int) -> str: - """将毫秒时间戳转换为 yyyy-MM-dd HH:mm:ss 格式""" - dt = datetime.datetime.fromtimestamp( - ts_ms / 1000, tz=datetime.timezone(datetime.timedelta(hours=8)) - ) - return dt.strftime("%Y-%m-%d %H:%M:%S") - - -def run_dws( - args: List[str], dry_run: bool = False, -) -> Optional[Any]: - cmd = ['dws'] + args - if dry_run: - print(f"[dry-run] {' '.join(cmd)}") - return None - try: - result = subprocess.run( - cmd, capture_output=True, text=True, timeout=120 - ) - if result.returncode != 0: - print(f"错误:{result.stderr.strip()}", file=sys.stderr) - return None - return json.loads(result.stdout) - except (subprocess.TimeoutExpired, json.JSONDecodeError, - FileNotFoundError) as e: - print(f"错误:{e}", file=sys.stderr) - return None - - -def search_group( - query: str, dry_run: bool = False, -) -> Optional[str]: - data = run_dws([ - 'chat', 'search', - '--query', query, '--format', 'json', - ], dry_run=dry_run) - if dry_run: - return '' - if not data: - return None - if isinstance(data, dict) and data.get('success') is False: - print(f"搜索失败: {data.get('errorMsg', '未知错误')}", file=sys.stderr) - return None - if isinstance(data, list): - groups = data - elif isinstance(data, dict): - inner = data.get('result', data) - if isinstance(inner, dict): - groups = ( - inner.get('value') - or inner.get('groups') - or inner.get('items') - or [] - ) - elif isinstance(inner, list): - groups = inner - else: - groups = [] - else: - groups = [] - if not groups: - print(f"未找到群聊: {query}") - return None - g = groups[0] - name = g.get('title') or g.get('name', '未知') - conv_id = g.get('openConversationId') or g.get('id') - print(f" 找到群聊: {name} ({conv_id})") - return conv_id - - -def main(): - parser = argparse.ArgumentParser( - description='导出群聊消息到 JSON' - ) - parser.add_argument('--group', help='群聊 openconversation_id') - parser.add_argument('--query', help='按群名搜索') - parser.add_argument( - '--time', required=True, - help='起始时间 yyyy-MM-dd HH:mm:ss', - ) - parser.add_argument( - '--no-forward', action='store_true', - help='拉给定时间之前的消息 (默认拉给定时间之后)', - ) - parser.add_argument( - '--limit', type=int, default=0, - help='返回条数 (不传则不限制)', - ) - parser.add_argument('--output', default='', help='输出文件') - parser.add_argument('--dry-run', action='store_true') - args = parser.parse_args() - - conv_id = args.group - if not conv_id: - if not args.query: - print('错误:需要 --group 或 --query 参数') - sys.exit(1) - print(f'🔍 搜索群聊: {args.query}') - conv_id = search_group(args.query, args.dry_run) - if not conv_id and not args.dry_run: - sys.exit(1) - - print(f'📥 拉取消息 (起始: {args.time})...') - all_messages: List[Any] = [] - current_time = args.time - page = 0 - max_pages = 50 - remaining = args.limit if args.limit > 0 else float('inf') - - while page < max_pages and remaining > 0: - cmd_args = [ - 'chat', 'message', 'list', - '--group', conv_id or '', - '--time', current_time, - '--format', 'json', - ] - if args.no_forward: - cmd_args.append('--forward=false') - page_limit = min(int(remaining), 200) if args.limit > 0 else 0 - if page_limit > 0: - cmd_args.extend(['--limit', str(page_limit)]) - data = run_dws(cmd_args, dry_run=args.dry_run) - - if args.dry_run: - print('[dry-run] 翻页循环: hasMore → 继续用边界 createTime 作为 --time') - return - - if not data: - break - - if isinstance(data, dict) and data.get('success') is False: - print(f"拉取失败: {data.get('errorMsg', '未知错误')}", file=sys.stderr) - break - - if isinstance(data, list): - page_msgs = data - has_more = False - next_cursor = None - elif isinstance(data, dict): - inner = data.get('result', data) - if isinstance(inner, dict): - page_msgs = inner.get('messages', []) - has_more = inner.get('hasMore', False) - next_cursor = inner.get('nextCursor') - elif isinstance(inner, list): - page_msgs = inner - has_more = False - next_cursor = None - else: - page_msgs = [] - has_more = False - next_cursor = None - else: - page_msgs = [] - has_more = False - next_cursor = None - - if not page_msgs: - break - - all_messages.extend(page_msgs) - remaining -= len(page_msgs) - page += 1 - - if not has_more: - break - - # 翻页:优先用 nextCursor(毫秒精度,转为时间字符串),降级用最后消息的 createTime - if next_cursor: - current_time = timestamp_to_datetime(next_cursor) - else: - last_msg = page_msgs[-1] - boundary_time = ( - last_msg.get('createTime') - or last_msg.get('createAt') - or last_msg.get('time', '') - ) - if not boundary_time or boundary_time == current_time: - break - current_time = boundary_time - print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...") - - if not all_messages: - print('未拉取到消息') - return - - if args.output: - with open(args.output, 'w', encoding='utf-8') as f: - json.dump(all_messages, f, ensure_ascii=False, indent=2) - print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}") - else: - for m in all_messages: - sender = ( - m.get('sender') or m.get('senderNick') or '未知' - ) - text = m.get('content') or m.get('text', '') - time_str = ( - m.get('createTime') or m.get('createAt') - or m.get('time', '') - ) - print(f" [{time_str}] {sender}: {text[:80]}") - print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)") - - -if __name__ == '__main__': - main() diff --git a/skills/multi/dingtalk-chat/scripts/chat_history_with_user.py b/skills/multi/dingtalk-chat/scripts/chat_history_with_user.py deleted file mode 100644 index 8ef9c398..00000000 --- a/skills/multi/dingtalk-chat/scripts/chat_history_with_user.py +++ /dev/null @@ -1,223 +0,0 @@ -#!/usr/bin/env python3 -""" -查询与某人的单聊聊天记录 - -用法: - python chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00" - python chat_history_with_user.py --user --time "2026-03-10 00:00:00" --limit 50 - python chat_history_with_user.py --name "张三" --time "2026-03-01 00:00:00" --output history.json - -工作流: - 1. 通过 --name 搜索通讯录,获取 userId(或直接传 --user) - 2. 调用 chat message list --user 拉取单聊消息 - 3. 输出到终端或导出为 JSON 文件 -""" - -import sys -import json -import datetime -import subprocess -import argparse -from typing import List, Any, Optional - - -def timestamp_to_datetime(ts_ms: int) -> str: - """将毫秒时间戳转换为 yyyy-MM-dd HH:mm:ss 格式""" - dt = datetime.datetime.fromtimestamp( - ts_ms / 1000, tz=datetime.timezone(datetime.timedelta(hours=8)) - ) - return dt.strftime("%Y-%m-%d %H:%M:%S") - - -def run_dws( - args: List[str], dry_run: bool = False, -) -> Optional[Any]: - """执行 dws 命令并解析 JSON 输出""" - cmd = ['dws'] + args - if dry_run: - print(f"[dry-run] {' '.join(cmd)}") - return None - try: - result = subprocess.run( - cmd, capture_output=True, text=True, timeout=120 - ) - if result.returncode != 0: - print(f"错误:{result.stderr.strip()}", file=sys.stderr) - return None - return json.loads(result.stdout) - except (subprocess.TimeoutExpired, json.JSONDecodeError, - FileNotFoundError) as e: - print(f"错误:{e}", file=sys.stderr) - return None - - -def search_user( - name: str, dry_run: bool = False, -) -> Optional[str]: - """按关键词搜索用户,返回第一个匹配的 userId""" - data = run_dws([ - 'contact', 'user', 'search', - '--query', name, '--format', 'json', - ], dry_run=dry_run) - if dry_run: - return '' - if not data: - return None - # 解析返回结构 - users = data - if isinstance(data, dict): - inner = data.get('result', data) - if isinstance(inner, dict): - users = (inner.get('users', []) - or inner.get('list', [])) - elif isinstance(inner, list): - users = inner - else: - users = [] - if not users or not isinstance(users, list): - print(f"未找到用户: {name}") - return None - u = users[0] - user_name = u.get('name') or u.get('nick', '未知') - user_id = u.get('userId') or u.get('userid', '') - print(f" 找到用户: {user_name} ({user_id})") - return user_id - - -def main(): - parser = argparse.ArgumentParser( - description='查询与某人的单聊聊天记录' - ) - group = parser.add_mutually_exclusive_group(required=True) - group.add_argument('--name', help='按姓名搜索用户') - group.add_argument('--user', help='直接指定 userId') - parser.add_argument( - '--time', required=True, - help='起始时间 yyyy-MM-dd HH:mm:ss', - ) - parser.add_argument( - '--no-forward', action='store_true', - help='拉给定时间之前的消息 (默认拉给定时间之后)', - ) - parser.add_argument( - '--limit', type=int, default=0, - help='返回条数 (不传则不限制)', - ) - parser.add_argument('--output', default='', help='导出到 JSON 文件') - parser.add_argument('--dry-run', action='store_true') - args = parser.parse_args() - - # 1. 获取 userId - user_id = args.user - if not user_id: - print(f'🔍 搜索用户: {args.name}') - user_id = search_user(args.name, args.dry_run) - if not user_id and not args.dry_run: - sys.exit(1) - - # 2. 拉取单聊消息(自动翻页) - print(f'📥 拉取与 {user_id} 的聊天记录 (起始: {args.time})...') - all_messages: List[Any] = [] - current_time = args.time - page = 0 - max_pages = 50 - remaining = args.limit if args.limit > 0 else float('inf') - - while page < max_pages and remaining > 0: - cmd_args = [ - 'chat', 'message', 'list', - '--user', user_id or '', - '--time', current_time, - '--format', 'json', - ] - if args.no_forward: - cmd_args.extend(['--forward', 'false']) - page_limit = min(int(remaining), 200) if args.limit > 0 else 0 - if page_limit > 0: - cmd_args.extend(['--limit', str(page_limit)]) - data = run_dws(cmd_args, dry_run=args.dry_run) - - if args.dry_run: - print('[dry-run] 翻页循环: hasMore → 继续用边界 createTime 作为 --time') - return - - if not data: - break - - if isinstance(data, dict) and data.get('success') is False: - print(f"拉取失败: {data.get('errorMsg', '未知错误')}", file=sys.stderr) - break - - if isinstance(data, list): - page_msgs = data - has_more = False - next_cursor = None - elif isinstance(data, dict): - inner = data.get('result', data) - if isinstance(inner, dict): - page_msgs = inner.get('messages', []) - has_more = inner.get('hasMore', False) - next_cursor = inner.get('nextCursor') - elif isinstance(inner, list): - page_msgs = inner - has_more = False - next_cursor = None - else: - page_msgs = [] - has_more = False - next_cursor = None - else: - page_msgs = [] - has_more = False - next_cursor = None - - if not page_msgs: - break - - all_messages.extend(page_msgs) - remaining -= len(page_msgs) - page += 1 - - if not has_more: - break - - # 翻页:优先用 nextCursor(毫秒精度,转为时间字符串),降级用最后消息的 createTime - if next_cursor: - current_time = timestamp_to_datetime(next_cursor) - else: - last_msg = page_msgs[-1] - boundary_time = ( - last_msg.get('createTime') - or last_msg.get('createAt') - or last_msg.get('time', '') - ) - if not boundary_time or boundary_time == current_time: - break - current_time = boundary_time - print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...") - - if not all_messages: - print('未拉取到消息') - return - - # 3. 输出结果 - if args.output: - with open(args.output, 'w', encoding='utf-8') as f: - json.dump(all_messages, f, ensure_ascii=False, indent=2) - print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}") - else: - for m in all_messages: - sender = ( - m.get('sender') or m.get('senderNick') or '未知' - ) - text = m.get('content') or m.get('text', '') - time_str = ( - m.get('createTime') or m.get('createAt') - or m.get('time', '') - ) - print(f" [{time_str}] {sender}: {text[:80]}") - print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)") - - -if __name__ == '__main__': - main() diff --git a/skills/multi/dingtalk-event/SKILL.md b/skills/multi/dingtalk-event/SKILL.md index d621f82c..c4866dda 100644 --- a/skills/multi/dingtalk-event/SKILL.md +++ b/skills/multi/dingtalk-event/SKILL.md @@ -1,254 +1,108 @@ --- name: dingtalk-event -description: 钉钉个人 IM 事件长连接监听、订阅与消费,覆盖消息接收、全部单聊/群消息、指定发送人、已读、撤回、表情回应和群生命周期,输出 NDJSON 到 stdout。Use when 用户提到 监听个人消息事件、监听所有单聊或群消息、被@消息、监听单聊或群消息、监听某人发送的消息、监听消息已读、监听消息撤回、监听消息贴表情或表情回应、监听群成员加入、监听群成员退出、监听群改名或群解散、实时接收钉钉事件、用事件驱动 Agent。命令前缀:dws event。 +description: 钉钉个人 IM 事件长连接监听、订阅与消费,覆盖 @我、指定发送人、指定群、全部单聊/群消息,以及消息已读、撤回、表情回应和群生命周期。Use when 用户提到实时监听消息、被@消息、监听某人或某群、监听已读/撤回/表情回应、群成员加入、群成员退出、群状态变化、事件驱动 Agent。命令前缀:dws event。 --- # 钉钉个人 IM 事件 -只使用 `dws event consume` 建立个人消息事件长连接。用户要求实时监听、订阅、自动回复或驱动 Agent 时,不要写轮询脚本,不要用消息历史查询模拟事件。 +实时监听必须使用事件长连接,不写轮询脚本,不用历史消息查询模拟事件。高频 IM 意图优先交给 `dws event +listen-im`;它在 CLI 内解析自然目标、选择 EventKey,并复用现有订阅与 bus 生命周期。 -## 运行方式 +消息、reaction、已读和撤回的默认监听入口是 `dws event +listen-im`; +只有群生命周期、Filter DSL、原始 envelope 或底层订阅控制才使用 +`event consume` fallback。 -- bus 后台进程持有对钉钉的个人 Stream 长连;consume 从 bus 读事件、按 NDJSON 打到 stdout。consume 只读,不发消息(回复用 `dws chat message send`)。 -- 没有 bus 时 consume 自动拉起;通常只跑 consume。 -- 一个组织一个 bus,可同时跑;同组织内多个 consume 共享一个 bus。 -- 非默认组织加全局 `--profile `;漏传会退回默认 profile 而失败。 +## Golden Route -## Core commands - -| Command | Purpose | +| 用户意图 | 唯一推荐入口 | |---|---| -| `dws event list` | 查看当前个人事件目录;不要把它当能力菜单主动展示 | -| `dws event schema --flatten` | 查看 Agent 使用的顶层业务字段 schema,默认 JSON | -| `dws event consume [event_key...] --flatten [flags]` | 阻塞消费一个或多个兼容事件;事件写到 stdout,推荐 `-f ndjson` | -| `dws event status --event ` | 查看个人订阅、personal bus 和本地 consume | -| `dws event stop --dry-run` / `--yes` | 先预览,再确认取消个人订阅并停止对应本地消费 | -| `dws event stop --all --dry-run` / `--yes` | 先预览,再确认清理当前身份下本地记录的全部个人订阅 | +| 监听 @我的消息 | `dws event +listen-im --kind at-me` | +| 监听某人发来的消息 | `dws event +listen-im --kind sender --user-query <姓名>` | +| 监听指定群消息 | `dws event +listen-im --kind group --chat-query <群名>` | +| 同一人/群的消息、表情、已读或撤回 | `dws event +listen-im --kind --events message,reaction,read,recall ...` | +| 监听全部单聊或全部群消息 | `dws event +listen-im --kind `;只有用户明确要求“全部”时使用 | +| 群改名、成员进退、群解散 | 读取 [EventKey 索引](references/event-im-keys.md),使用精确 `event consume` EventKey | +| 已知 EventKey 或需要底层订阅控制 | `dws event consume`;参数与约束以 leaf Schema 为准 | +| 查看状态 / 停止 | `dws event status` / `dws event stop --dry-run`,确认后再 `--yes` | -区分两个 schema:`dws event schema ` 查事件输出字段;`dws schema "event consume"` 查 consume 命令入参(统一内嵌 ToolSpec,含 parameters + 位置参数)。`source` 是 reviewed command identity 的 provenance;`event list/schema` 是 `interface_mode=local`,`event consume/status/stop` 因同时编排远端订阅控制面与本地 bus 而是 `interface_mode=composite`,不要把 identity 与实现机制混为一谈。 +默认 `--events message`。可选事件为 `message`、`reaction`、`read`、`recall`: -## Event catalog +- `at-me`、`all-direct`、`all-group` 只支持 `message`,且不接受目标。 +- `sender` 必须且只能传 `--user`、`--open-dingtalk-id` 或 `--user-query` 之一。 +- `group` 必须且只能传 `--chat-id` 或 `--chat-query` 之一。 +- `--query` 只用于纯 `message` 监听;混入 reaction/read/recall 时不得使用。 -| 事件码 | 场景 | 必填参数 | -|---|---|---| -| `user_im_message_receive_at` | 当前用户被 @ 的消息 | 无 | -| `user_im_message_receive_o2o` | 当前用户与指定用户的单聊消息 | `--user` 或 `--open-dingtalk-id` | -| `user_im_message_receive_group` | 当前用户所在指定群聊/会话的消息 | `--group` | -| `user_im_message_receive_user` | 当前用户收到的指定用户发送的消息(单聊和群聊) | `--user` 或 `--open-dingtalk-id` | -| `user_im_message_receive_o2o_all` | 当前用户收到的所有单聊消息 | 无 | -| `user_im_message_receive_group_all` | 当前用户收到的所有群聊消息 | 无 | -| `user_im_message_read_o2o` | 指定单聊中当前用户发送的消息被已读 | `--user` 或 `--open-dingtalk-id` | -| `user_im_message_read_group` | 指定群聊中当前用户发送的消息被已读 | `--group` | -| `user_im_message_recall_o2o` | 指定单聊中的消息被撤回 | `--user` 或 `--open-dingtalk-id` | -| `user_im_message_recall_group` | 指定群聊中的消息被撤回 | `--group` | -| `user_im_message_reaction_o2o` | 指定单聊中的消息收到表情回应 | `--user` 或 `--open-dingtalk-id` | -| `user_im_message_reaction_group` | 指定群聊中的消息收到表情回应 | `--group` | -| `user_im_group_updated` | 指定群聊的标题发生变更 | `--group` | -| `user_im_group_member_added` | 指定群聊有成员加入 | `--group` | -| `user_im_group_member_exited` | 指定群聊有成员退出 | `--group` | -| `user_im_group_disbanded` | 指定群聊被解散 | `--group` | +自然姓名和群名由 CLI 内部唯一解析:零命中或多候选返回结构化失败,在创建任何订阅前停止。`--dry-run` 走同一解析链。解析、监听、状态和停止必须使用同一个 `--profile`,不得跨组织搬运 ID。 -只承认上表 16 个事件码。其它身份模式、应用凭证模式、非个人 IM 事件不在本 skill 范围内。 +### 兼容 EventKey 索引 -## Command rules +`+listen-im` 覆盖高频路径;只有需要精确底层控制时才直接使用以下 16 个 EventKey: -- 默认身份就是当前用户,不要额外加身份切换 flag。 -- 使用当前用户 OAuth 登录态;未登录或 token 失效时,引导用户执行 `dws auth login`。 -- 不主动运行 `dws event list` 作为能力菜单;按用户意图直接选择上表事件。 -- 缺少必填 ID 时先解析或追问,不要猜测 ID。 -- 用户只给单聊对端人名时,先运行 `dws aisearch person --keyword "" --dimension name --format json` 解析 userId;多候选必须让用户确认。 -- 企业内部 userId 使用 `--user`;用户明确提供 openDingtalkId,或目标是外部联系人、机器人、跨组织身份时,使用 `--open-dingtalk-id`。 -- `--user` 与 `--open-dingtalk-id` 严格二选一。不要把 openDingtalkId 填入 `--user`,不要自动猜测或转换身份类型;缺少外部目标的 openDingtalkId 时先追问。 -- “监听我和某人的单聊”使用 `user_im_message_receive_o2o`;“监听某人发给我的消息/监听某人发送的消息”使用 `user_im_message_receive_user`,后者覆盖该发送人的单聊和群聊消息。 -- 只有用户明确要求“所有单聊消息”或“所有群消息”时才使用 `user_im_message_receive_o2o_all` / `user_im_message_receive_group_all`;指定人或指定群仍使用范围更小的事件。 -- 用户只给群名时,先运行 `dws chat search --query "" --format json` 解析 openConversationId;多候选必须让用户确认。 -- “监听群改名/群标题变更”使用 `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`。 -- 正常 Agent 消费统一显式使用 `--flatten -f ndjson`。抓一条样本可用 `--flatten --max-events 1 -f json`。`--format` 只控制 JSON 序列化,`--flatten` 才控制数据结构。 -- 同一目标、同一过滤条件的兼容事件优先放在一个 `consume` 命令中:用户类事件共享一个 `--user` 或 `--open-dingtalk-id`,群类事件共享一个 `--group`,无目标事件可加入任一类组合。 -- 用户类与群类事件不能放进同一命令;不同用户、不同群或不同过滤条件必须启动多个 consume 进程。多事件命令不使用 `--subscribe-id`、`--rule`、`--event-types`、`--filter`、`--foreground`、`--force` 或 `--debug-raw-events`。 -- 多事件共享 `--query` / `--filter-json` 时,所选事件必须全部是消息接收事件;已读、撤回、表情回应或群生命周期事件混入后不能使用消息过滤参数。 -- 监听非默认组织时带 `--profile `;漏传会退回默认 profile 而失败。 -- 自己发的消息不作为事件回来(`isSelfLoop` 过滤):边监听边 `dws chat message send` 回复不成环;测试投递用别人 / 机器人发(自发会看到 0 事件)。 -- `--debug-raw-events` 只用于联调确认服务端推送是否到达本地连接;正常任务不要使用。它和 `--flatten` 互斥,`-f raw` 也不能与 `--flatten` 同时使用。 -- 排查:consume 报 bus 启动失败 → 报错已带真实原因,先查 `dws --profile auth status`(非默认组织带对 `--profile`);本地日志见 `~/.dws/events//personal_stream//bus.log`(`hash` 见 `dws event status` 的 Workdir);有残留先用 `dws event stop --all --dry-run` 预览,确认后加 `--yes` 清理。看着"挂住"无输出多是误加了 `--foreground`(那是跑 bus、不打印事件),去掉即可。 - -## 订阅创建失败与重试预算 - -以下约束适用于上表全部 16 个事件以及多事件命令中的每一项,只治理 `[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 的订阅。 -- 同一逻辑订阅由当前 profile / 身份、event key、rule type、目标和过滤条件共同确定。`subscribe_id`、`trace_id` 以及重新启动进程都只是诊断或执行信息,不会生成新的逻辑操作;Agent/host 必须自行延续原编排预算。 -- Agent/host 收到 `retryable=false`:`max_additional_attempts=0`,立即停止,不得自动重跑。 -- Agent/host 收到 `retryable=true`:`max_additional_attempts=2`,初次失败后最多再尝试 2 次;错误若给出 `retry_after_seconds` 或 `next_retry_at`,不得提前重试。 -- 未返回 `retryable`(即 `retryable=unknown`):Agent/host 使用 `max_additional_attempts=1`,最多补偿尝试 1 次;仍无法确认时停止并上报错误与 trace。 -- `in_flight` 表示同一逻辑订阅已有请求执行中;`cooldown` 或 `terminal_hold` 表示当前被退避或终态保护。遇到这些状态不得递归调用 `event consume`、并行启动相同订阅或通过新 subId / trace 绕过;等待原请求或保护时间结束,同时由 Agent/host 继续维护自己的编排次数。 -- 多事件命令必须作为同一次原始操作治理。不得把失败事件拆成新的单事件命令、调整顺序或反复重启来重置预算;启动中任一项失败时,由 CLI 回滚本次已经创建的订阅。 - -### 本地保护状态运维 - -- open 版默认状态文件为 `~/.dws/events/open/personal_stream//personal_subscription_attempts.json`。设置 `DWS_CONFIG_DIR` 后配置根目录随之变化;其它 edition 使用对应 edition 目录,不固定为 `open`。 -- identity 目录权限为 `0700`;`personal_subscription_attempts.json` 与 `personal_subscription_attempts.lock` 权限均为 `0600`。 -- 连续 24h 没有失败后失败计数重置;`terminal_hold` 持续 1h。正常处理优先等待错误中的 `next_retry_at`,不要把删状态文件当成常规重试手段。 -- 紧急恢复时,先确认该 identity 没有正在创建订阅的进程;只删除 `personal_subscription_attempts.json`,不要删除 lock 文件。删除 JSON 会清空该 identity 的全部保护记录,不只影响一个事件。 - -## Call flow - -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` 中实际存在的字段。 -5. 需要确认监听状态时运行 `dws event status --event `,查看 `Subscriptions` 和 `Consumers`。 -6. 任务完成后优雅结束 consume;本次新建的订阅会自动取消。复用已有订阅或需要从外部主动取消时,先用 `dws event stop --dry-run` 预览,向用户确认后再加 `--yes`;临时测试可用 `--max-events` 或 `--duration` 自动退出。 - -## Subprocess contract - -- `event consume` 阻塞式长连接。stdout 只出事件;stderr 只出状态 / debug / 错误。 -- 就绪:单事件使用 `[event] ready event_key= bus_pid= subscribe_id=`;多事件在全部逻辑 consumer 就绪后使用 `[event] ready event_count= bus_pid=`,其前每个事件各有一条 `[event] subscription ...`。父进程等对应 ready 行再处理 stdout;不要 `--quiet`。 -- 退出:末行 `[event] exited — received N event(s) in Xs (reason: limit|timeout|signal|bus_shutdown)`;受控退出码 0,失败非 0 无 exited 行。 -- stdin 关闭 = 停机:仅当 stdin 是管道且未设 `--max-events/--duration` 时生效;交互终端和 `< /dev/null` 不触发。用管道 stdin 又要常驻就喂 `< <(tail -f /dev/null)`。 -- 正常事件处理持续读取 stdout 管道,不要改写为 `--output-dir` watcher。 -- 无界监听需外部进程管理;有界自测用 `--max-events N` 或 `--duration 10m`。 -- 订阅清理:本次新建的订阅任意干净退出即自动退订;`--subscribe-id` 复用的保留,`--ephemeral` 强制退订。优雅停用 SIGTERM、关 stdin,或外部先用 `dws event stop --dry-run` 预览、确认后加 `--yes`。不要 `kill -9`(跳过退订、泄漏服务端订阅)。 -- 批量清理先用 `dws event stop --all --dry-run` 预览,确认后加 `--yes`。 -- 一个 consume 可监听多个兼容事件,并为每个事件建立独立订阅和逻辑 consumer;它们共享本机 bus、远程连接、输出和生命周期,仍按 `event_type + subscribe_id` 隔离。`dws event stop ` 只移除对应事件,最后一个被移除后进程退出。 - -## Examples - -```bash -# 当前用户被 @ 的消息 -dws event consume user_im_message_receive_at --flatten -f ndjson - -# 当前用户与指定用户的单聊消息 -dws event consume user_im_message_receive_o2o \ - --user test-user-001 \ - --flatten \ - -f ndjson - -# 使用 openDingtalkId 监听外部联系人、机器人或跨组织身份的单聊消息 -dws event consume user_im_message_receive_o2o \ - --open-dingtalk-id open-user-1 \ - --flatten \ - -f ndjson - -# 指定群聊/会话消息 -dws event consume user_im_message_receive_group \ - --group cidxxxxxxxx \ - --flatten \ - -f ndjson - -# 指定发送人的消息(单聊和群聊) -dws event consume user_im_message_receive_user \ - --user test-user-001 \ - --flatten \ - -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 - -# 使用 openDingtalkId 监听指定发送人的消息 -dws event consume user_im_message_receive_user \ - --open-dingtalk-id open-user-1 \ - --flatten \ - -f ndjson - -# 指定单聊消息已读 -dws event consume user_im_message_read_o2o \ - --user test-user-001 \ - --flatten \ - -f ndjson - -# 指定群聊消息撤回 -dws event consume user_im_message_recall_group \ - --group cidxxxxxxxx \ - --flatten \ - -f ndjson - -# 指定单聊消息收到表情回应 -dws event consume user_im_message_reaction_o2o \ - --user test-user-001 \ - --flatten \ - -f ndjson - -# 指定群标题变更 -dws event consume user_im_group_updated \ - --group cidxxxxxxxx \ - --flatten \ - -f ndjson - -# 指定群有成员加入 -dws event consume user_im_group_member_added \ - --group cidxxxxxxxx \ - --flatten \ - -f ndjson - -# 指定群有成员退出 -dws event consume user_im_group_member_exited \ - --group cidxxxxxxxx \ - --flatten \ - -f ndjson - -# 指定群解散 -dws event consume user_im_group_disbanded \ - --group cidxxxxxxxx \ - --flatten \ - -f ndjson - -# 同一用户的单聊消息、已读和撤回(一个进程) -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 - -# 同一群的消息和生命周期事件(一个进程) -dws event consume \ - user_im_message_receive_group \ - user_im_group_updated \ - user_im_group_disbanded \ - --group cidxxxxxxxx \ - --flatten \ - -f ndjson - -# 有界自测 -dws event consume user_im_message_receive_at \ - --duration 10m \ - --flatten \ - -f ndjson - -# 抓一条样本 -dws event consume user_im_message_receive_o2o \ - --user test-user-001 \ - --max-events 1 \ - --flatten \ - -f json +```text +user_im_message_receive_at +user_im_message_receive_o2o user_im_message_receive_user +user_im_message_receive_group 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 ``` -所有 `*_o2o` 命令和 `user_im_message_receive_user` 都可将 `--user ` 替换为 `--open-dingtalk-id `,但两个参数不能同时使用。 +用户类事件传 `--user` 或 `--open-dingtalk-id`,群类事件传 `--group`。群生命周期输出可含 `operator_open_dingtalk_id` 和 `members`;成员项使用 `open_dingtalk_id`。精确组合、兼容性和 Filter 规则见 reference。 -## 输出处理 +## 公开层与内部统一边界 -- `dws event schema --flatten` 是 Agent 写解析逻辑的依据。 -- `--flatten` 模式的顶层 `jq_root_path` 为 `.`;不传时为兼容存量脚本的 transport envelope,业务 payload 在 `.data | fromjson`。 -- `schema.properties` 是业务字段列表,例如 `content`、`sender`、`conversation_id`、`message_id`、`event_time`。 -- Agent 命令已显式传 `--flatten`,消息接收、已读、撤回和表情回应事件直接读取顶层业务字段;不要对该模式再生成 `fromjson` 或内部 transport 路径。 -- 引用回复读取可选的 `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` 排查。 -- 群自动回复使用事件顶层 `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`。 -- 图片、文件等媒体消息的 `content` 可能是可读描述;合并转发媒体的下载定位信息位于对应 `forward_messages[].content`。需要实际媒体文件时调用 `dws chat message download-media`。 +`+listen-im` 是意图编译层,不是第二套事件系统。它只负责: -## Topic index +```text +kind + events + target +→ typed resolver +→ 确定 EventKey 集合 +→ 一次 event consume 生命周期 +``` -| Topic | Reference | Coverage | +订阅创建/复用、单 bus、多 consumer、ready marker、扁平 NDJSON、超时/取消、部分失败回滚和退出清理全部复用现有 Runtime。低频 EventKey、群生命周期、Filter DSL、原始 envelope、复用 subscribe_id 等仍由 `event consume` 承担。 + +## 运行与结果契约 + +- 正常消费固定使用当前用户 OAuth 身份、`--flatten` 和 NDJSON;stdout 只输出事件,stderr 输出订阅、ready、退出和错误状态。 +- 单事件 ready:`[event] ready event_key= bus_pid= subscribe_id=`。 +- 多事件先逐条输出 subscription,全部就绪后输出 `[event] ready event_count= bus_pid=`。必须等待 ready,不用 `sleep` 猜测。 +- 有界任务使用 `--max-events N` 或 `--duration 10m`;无界任务需要宿主管理进程并持续读取 stdout。 +- 干净退出会取消本次新建的订阅;使用 SIGTERM、关闭符合条件的管道 stdin,或 Runtime 的 bounded exit。不要 `kill -9`。 +- 当前用户自己发送的消息会被 self-loop 过滤;自测事件应由另一用户或机器人发送。 +- 事件只负责监听;需要回复时按 [输出与 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`。 + +## 安全与失败处理 + +- `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 尝试次数。 +- 重试必须遵守 `retry_after_seconds` / `next_retry_at`。遇到 `in_flight`、`cooldown`、`terminal_hold` 不并发或递归重启同一逻辑订阅,也不换 `subscribe_id` / `trace_id` 绕过保护。 +- 认证、profile、订阅保护状态和 bus 排障按失败类型读取 [订阅运维](references/event-im-operations.md),不要在正常路径预加载完整运维手册。 + +### 本地订阅保护契约 + +- open 版状态路径为 `~/.dws/events/open/personal_stream//personal_subscription_attempts.json`;设置 `DWS_CONFIG_DIR` 后根目录随之变化。 +- identity 目录权限为 `0700`;`personal_subscription_attempts.json` 与 `personal_subscription_attempts.lock` 权限为 `0600`。 +- 连续 `24h` 无失败后重置计数;`terminal_hold` 持续 `1h`。优先等待 `next_retry_at`,不要把删状态当常规重试。 +- 仅在确认该 identity 没有订阅创建进程的紧急恢复场景,只删除 `personal_subscription_attempts.json`,不要删除 lock 文件。该操作会清空该 identity 的全部保护记录,而非单个事件。 + +## 何时查询 Schema + +- 已知 Golden Route 时直接执行,不先跑 `event list`。 +- 只有解析业务字段时才用 `dws event schema --flatten`。 +- 只有参数或安全不确定时才用 `dws schema --cli-path "event +listen-im"` 或对应 leaf。 +- `event schema` 描述事件 payload;顶层 `dws schema` 描述 CLI 命令,两者不要混用。 + +## Reference + +| Topic | Reference | 何时读取 | |---|---|---| -| IM | [references/event-im.md](references/event-im.md) | 十六类个人 IM 事件命令、参数、生命周期、输出解析、自测和排障 | +| 任务索引 | [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) | 订阅控制或失败恢复 | diff --git a/skills/multi/dingtalk-event/references/event-im-keys.md b/skills/multi/dingtalk-event/references/event-im-keys.md new file mode 100644 index 00000000..d82a0de1 --- /dev/null +++ b/skills/multi/dingtalk-event/references/event-im-keys.md @@ -0,0 +1,54 @@ +# IM EventKey 与底层消费 + +高频消息、reaction、已读和撤回优先 `dws event +listen-im`。本页只用于群生命周期、 +显式 EventKey 或高级底层控制。 + +## 16 个 EventKey + +| EventKey | 范围 | 目标参数 | +|---|---|---| +| `user_im_message_receive_at` | 被 @ 消息 | 无 | +| `user_im_message_receive_o2o` | 指定单聊消息 | `--user` 或 `--open-dingtalk-id` | +| `user_im_message_receive_group` | 指定群消息 | `--group` | +| `user_im_message_receive_user` | 指定发送人在单聊/群聊中的消息 | `--user` 或 `--open-dingtalk-id` | +| `user_im_message_receive_o2o_all` | 全部单聊消息 | 无 | +| `user_im_message_receive_group_all` | 全部群消息 | 无 | +| `user_im_message_read_o2o` | 指定单聊已读 | user 二选一 | +| `user_im_message_read_group` | 指定群已读 | `--group` | +| `user_im_message_recall_o2o` | 指定单聊撤回 | user 二选一 | +| `user_im_message_recall_group` | 指定群撤回 | `--group` | +| `user_im_message_reaction_o2o` | 指定单聊 reaction | user 二选一 | +| `user_im_message_reaction_group` | 指定群 reaction | `--group` | +| `user_im_group_updated` | 群标题变化 | `--group` | +| `user_im_group_member_added` | 成员加入 | `--group` | +| `user_im_group_member_exited` | 成员退出 | `--group` | +| `user_im_group_disbanded` | 群解散 | `--group` | + +`--user` 只接收 userId;明确 openDingTalkId 时用对应 flag。群目标必须是 +openConversationId。不要把两种身份混传或选择搜索第一项。 + +## 精确模板 + +```bash +dws event consume user_im_group_member_added \ + --group --flatten -f ndjson + +dws event consume user_im_message_receive_user \ + --open-dingtalk-id --flatten -f ndjson + +dws event consume user_im_message_receive_group \ + user_im_message_reaction_group \ + user_im_message_recall_group \ + --group --flatten -f ndjson +``` + +多事件只能共享同一 target/filter。用户类与群类不能混在一个命令中;不同人、群或过滤条件 +启动不同 consume。只有全部 EventKey 都是接收消息时才能共享 `--query/--filter-json`。 +多事件不支持 `--subscribe-id`、`--rule`、`--event-types`、`--filter`、`--foreground`、 +`--force` 或 `--debug-raw-events`。 + +## 意图消歧 + +- “我和某人的单聊” → `receive_o2o`;“某人发给我的消息” → `receive_user`。 +- 执行撤回/添加 reaction 走 `dws chat`;监听它们才走 Event。 +- 群改名/成员进退/解散使用四个生命周期 EventKey;解散自测只能用已确认可销毁测试群。 diff --git a/skills/multi/dingtalk-event/references/event-im-lifecycle.md b/skills/multi/dingtalk-event/references/event-im-lifecycle.md new file mode 100644 index 00000000..b4676106 --- /dev/null +++ b/skills/multi/dingtalk-event/references/event-im-lifecycle.md @@ -0,0 +1,35 @@ +# IM consume 生命周期 + +## Ready 门禁 + +单事件只有出现以下 stderr 后才读取 stdout: + +```text +[event] ready event_key= bus_pid= subscribe_id= +``` + +多事件会先逐条输出 subscription,全部 IPC consumer 就绪后才输出: + +```text +[event] subscription event_key= subscribe_id= +[event] subscription event_key= subscribe_id= +[event] ready event_count=2 bus_pid= +``` + +不能把 subscription 行当成整体 ready,也不要用 `sleep` 猜建联。任一订阅或 IPC 建联失败, +Runtime 会回滚本次已创建订阅。 + +## 有界与长期任务 + +- 有界消费使用 `--max-events N` 或 `--duration 10m`。 +- 长期任务由宿主管理子进程并持续读取 stdout/stderr,避免管道背压。 +- `--output-dir`/`--route` 是明确落盘任务,不要用文件 watcher 代替 stdout 事件循环。 +- stdout 的 NDJSON 每行独立解析;单行失败不能吞掉后续事件。 + +## 干净退出 + +本次新建的订阅会在 SIGTERM、Ctrl+C、符合条件的 stdin EOF、duration 或 max-events 退出时 +自动取消。复用 `--subscribe-id` 的订阅默认保留,除非显式 `--ephemeral`。 + +多事件中停止一个 subscribe_id 只移除对应 consumer;其余继续,最后一个移除后进程退出。 +禁止 `kill -9`,它会跳过清理。外部停止流程见 [event-im-operations.md](event-im-operations.md)。 diff --git a/skills/multi/dingtalk-event/references/event-im-operations.md b/skills/multi/dingtalk-event/references/event-im-operations.md new file mode 100644 index 00000000..0ef93866 --- /dev/null +++ b/skills/multi/dingtalk-event/references/event-im-operations.md @@ -0,0 +1,52 @@ +# IM 订阅过滤、状态与排障 + +## Filter + +优先用订阅规则缩小范围:单聊/发送人用 user 身份,群用 `--group`。接收消息事件需要额外 +正文过滤时才用 `--query` 或 `--filter-json`;已读、撤回、reaction 和群生命周期不使用 +消息内容过滤。业务别名包括 `content`、`sender`、`conversation_id`、 +`sender_open_dingtalk_id`。 + +## Status 与 stop + +```bash +dws event status --event +dws event stop --dry-run +dws event stop --yes +dws event stop --all --dry-run +dws event stop --all --yes +``` + +`status` 同时看服务端 Subscriptions 与本地 Consumers;用 PID、EventKey、subscribe_id 和 +received/dropped 判断 consumer。裸 `event stop` 不取消任何订阅。stop 是有影响操作,先预览, +按 Runtime gate 确认后执行。 + +## 创建失败与 `0/2/1` 编排预算 + +这些约束只治理 ready 之前的创建;ready 后断线由长连接重连: + +- `retryable=false`:额外尝试 0 次。 +- `retryable=true`:最多额外 2 次,并遵守 `retry_after_seconds/next_retry_at`。 +- 未给 retryable:最多额外 1 次,然后停止并保留 trace。 + +这是 Agent/host 预算,不是 CLI 跨进程计数器。一个逻辑订阅由 profile/身份、EventKey、 +rule、目标和 filter 确定;换 subscribe_id、trace 或进程不能重置预算。遇到 `in_flight`、 +`cooldown`、`terminal_hold` 不并发、递归或拆分多事件绕过保护。 + +## 本地保护状态 + +open 版路径为 +`~/.dws/events/open/personal_stream//personal_subscription_attempts.json`; +`DWS_CONFIG_DIR` 会改变根目录。identity 目录为 `0700`,JSON 与 lock 为 `0600`。 +连续 24h 无失败后重置,terminal hold 为 1h。 + +紧急恢复前确认没有创建进程,只删除 attempts JSON,不删除 lock;这会清空该 identity 的 +全部保护记录,不是常规重试方式。 + +## 最短排障 + +- 无输出:先确认正确 ready marker,再看 subscribe_id 和 received/dropped。 +- 目标错误:o2o/user 检查身份 flag,group 检查 openConversationId。 +- 判断服务端是否推到连接:临时单事件 `--debug --debug-raw-events`;它不能与 `--flatten` + 并用,排查后立即移除。 +- 长期运行:交给宿主进程管理,不写历史消息轮询脚本。 diff --git a/skills/multi/dingtalk-event/references/event-im-output.md b/skills/multi/dingtalk-event/references/event-im-output.md new file mode 100644 index 00000000..06d6014d --- /dev/null +++ b/skills/multi/dingtalk-event/references/event-im-output.md @@ -0,0 +1,39 @@ +# IM 事件输出与 Chat 交接 + +Agent 默认使用 `--flatten -f ndjson`,顶层直接读取字段,不再 `fromjson`。消息接收常见字段: + +| 字段 | 语义 | +|---|---| +| `type` / `event_id` / `timestamp` | 事件类型、去重 ID、时间 | +| `subscribe_id` | 个人订阅与本地输出隔离键 | +| `message_id` / `conversation_id` | 稳定消息与会话 ID | +| `sender` / `sender_open_dingtalk_id` | 展示名与稳定发送人开放 ID | +| `content` / `create_time` / `event_time` | 正文及时间 | +| `quoted_message` | 可选引用原消息 | +| `forward_messages` | 可选合并转发子消息数组 | + +引用和转发子消息字段为 `message_id`、`conversation_id`、`sender`、 +`sender_open_dingtalk_id`、`content`、`create_time`。按结构识别转发,不匹配本地化摘要。 + +动作事件通用字段之外:已读提供 `reader_open_dingtalk_id/read_time`,撤回提供 +`recaller_open_dingtalk_id/recall_time`,reaction 提供 +`operator_open_dingtalk_id/reaction_name/reaction_text/operation_type/operation_time`。 + +成员加入/退出提供 `conversation_id`、`operator_open_dingtalk_id`、`members[]` 和 +`event_time`;成员稳定 ID 是 `members[].open_dingtalk_id`。群标题变化和群解散仅保守承诺 +基础路由字段及 `payload`,不得猜未由真实样本确认的键。 + +## 事件驱动回复的精确 ID 映射 + + +| event field | exact chat target | +|---|---| +| `conversation_id` | `dws chat +messages-send --as user --group ` | +| `sender_open_dingtalk_id` | `dws chat +messages-send --as user --open-dingtalk-id ` | + + +在上述命令后追加真实 `--text`/`--markdown` 和必要 confirmation。禁止使用 `sender` 再做 +`--user-query`,也禁止把单聊发送人 ID 当成群 ID。交接 marker 由 policy 逐字验证。 + +媒体事件正文可能只是描述。优先按真实消息 ID/会话 ID 使用 Chat 消息读取命令加 +`--download-resources`;只有精确 lower fallback 才用 `chat message download-media`。 diff --git a/skills/multi/dingtalk-event/references/event-im.md b/skills/multi/dingtalk-event/references/event-im.md index cfa3ffce..df27a814 100644 --- a/skills/multi/dingtalk-event/references/event-im.md +++ b/skills/multi/dingtalk-event/references/event-im.md @@ -1,399 +1,58 @@ -# IM 个人事件 +# IM 事件任务路由 -先读上层 [SKILL.md](../SKILL.md) 的命令规则、调用流和子进程契约。本参考覆盖当前公开的 IM 个人事件:消息接收、全量消息、已读、撤回、表情回应和群生命周期。 +本页是任务索引,不再混放 16 个 EventKey、输出字段和运维细节。先用上层 +[SKILL.md](../SKILL.md) 的 Golden Route;只加载当前任务对应的一份子 reference。 -实时监听、自动回复、订阅事件都必须使用 `dws event consume` 长连接,不要写轮询脚本。 +消息、reaction、已读和撤回默认使用 `dws event +listen-im` 长连接; +群生命周期、显式 EventKey、Filter DSL、原始 envelope 或底层订阅控制才使用 +`event consume`。不要写轮询脚本。 -## Prerequisite +## 选择哪一页 -个人事件使用当前用户 OAuth 登录态。未登录或 token 失效时,先执行: - -```bash -dws auth login -``` - -查看事件 schema: - -```bash -dws event schema user_im_message_receive_at --flatten -dws event schema user_im_message_receive_o2o --flatten -dws event schema user_im_message_receive_group --flatten -dws event schema user_im_message_receive_user --flatten -dws event schema user_im_message_receive_o2o_all --flatten -dws event schema user_im_message_receive_group_all --flatten -dws event schema user_im_message_read_o2o --flatten -dws event schema user_im_message_read_group --flatten -dws event schema user_im_message_recall_o2o --flatten -dws event schema user_im_message_recall_group --flatten -dws event schema user_im_message_reaction_o2o --flatten -dws event schema user_im_message_reaction_group --flatten -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 -``` - -schema 默认 JSON。Agent 使用 `--flatten` schema,业务字段在 `schema.properties`,`jq_root_path` 为 `.`。不传 `--flatten` 时查看兼容 transport envelope,其 `jq_root_path` 为 `.data | fromjson`。 - -## Event catalog - -| 事件码 | 规则 | 用途 | 必填参数 | -|---|---|---|---| -| `user_im_message_receive_at` | `at` | 当前用户被 @ 的消息 | 无 | -| `user_im_message_receive_o2o` | `singleChat` | 当前用户与指定用户的单聊消息 | `--user` 或 `--open-dingtalk-id` | -| `user_im_message_receive_group` | `group` | 当前用户所在指定群聊/会话的消息 | `--group` | -| `user_im_message_receive_user` | `sender` | 当前用户收到的指定用户发送的消息(单聊和群聊) | `--user` 或 `--open-dingtalk-id` | -| `user_im_message_receive_o2o_all` | `all` | 当前用户收到的所有单聊消息 | 无 | -| `user_im_message_receive_group_all` | `all` | 当前用户收到的所有群聊消息 | 无 | -| `user_im_message_read_o2o` | `singleChat` | 指定单聊中当前用户发送的消息被已读 | `--user` 或 `--open-dingtalk-id` | -| `user_im_message_read_group` | `group` | 指定群聊中当前用户发送的消息被已读 | `--group` | -| `user_im_message_recall_o2o` | `singleChat` | 指定单聊中的消息被撤回 | `--user` 或 `--open-dingtalk-id` | -| `user_im_message_recall_group` | `group` | 指定群聊中的消息被撤回 | `--group` | -| `user_im_message_reaction_o2o` | `singleChat` | 指定单聊中的消息收到表情回应 | `--user` 或 `--open-dingtalk-id` | -| `user_im_message_reaction_group` | `group` | 指定群聊中的消息收到表情回应 | `--group` | -| `user_im_group_updated` | `group` | 指定群聊的标题发生变更 | `--group` | -| `user_im_group_member_added` | `group` | 指定群聊有成员加入 | `--group` | -| `user_im_group_member_exited` | `group` | 指定群聊有成员退出 | `--group` | -| `user_im_group_disbanded` | `group` | 指定群聊被解散 | `--group` | - -默认身份就是当前用户。不要额外加身份切换 flag,不要使用应用凭证模式,不要使用本表以外的事件码。 - -## ID resolution - -- 人名 → `dws aisearch person --keyword "" --dimension name --format json`,确认后取 `userId`。 -- 企业内部 userId → `--user`;明确给出 openDingtalkId,或目标是外部联系人、机器人、跨组织身份 → `--open-dingtalk-id`。 -- 两个身份参数严格二选一,不得把 openDingtalkId 放进 `--user`,也不要自动猜测或转换;缺少外部目标的 openDingtalkId 时先追问。 -- “我和某人的单聊”选择 `user_im_message_receive_o2o`;“某人发给我的消息/某人发送的消息”选择 `user_im_message_receive_user`。 -- 只有明确要求“所有单聊消息”或“所有群消息”时才选择对应 `*_all` 事件;指定人或指定群继续选择范围更小的事件。 -- 群名 → `dws chat search --query "" --format json`,确认后取 `openConversationId`。 -- 多候选 → 展示候选并让用户确认。 -- 仍缺必填 ID → 先追问,不要编造。 -- “撤回消息”表示执行操作时走 `dws chat`;“监听/订阅消息撤回”才走本事件能力。 -- “贴标签”表示给消息贴表情时,对应 `reaction` 表情回应事件。 -- “群改名/群标题变更”对应 `user_im_group_updated`;“有人进群”对应 `user_im_group_member_added`;“有人退群”对应 `user_im_group_member_exited`;“群解散”对应 `user_im_group_disbanded`。群解散自测必须使用测试群,并在执行解散动作前再次确认破坏性影响。 - -## Consume commands - -```bash -# 被 @ 消息 -dws event consume user_im_message_receive_at --flatten -f ndjson - -# 指定单聊消息 -dws event consume user_im_message_receive_o2o \ - --user test-user-001 \ - --flatten \ - -f ndjson - -# 通过 openDingtalkId 指定单聊对端 -dws event consume user_im_message_receive_o2o \ - --open-dingtalk-id open-user-1 \ - --flatten \ - -f ndjson - -# 指定群消息 -dws event consume user_im_message_receive_group \ - --group cidxxxxxxxx \ - --flatten \ - -f ndjson - -# 指定发送人的消息(单聊和群聊) -dws event consume user_im_message_receive_user \ - --user test-user-001 \ - --flatten \ - -f ndjson - -# 通过 openDingtalkId 指定发送人 -dws event consume user_im_message_receive_user \ - --open-dingtalk-id open-user-1 \ - --flatten \ - -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 consume user_im_message_read_o2o \ - --user test-user-001 \ - --flatten \ - -f ndjson - -# 指定群聊已读事件 -dws event consume user_im_message_read_group \ - --group cidxxxxxxxx \ - --flatten \ - -f ndjson - -# 指定单聊撤回事件 -dws event consume user_im_message_recall_o2o \ - --user test-user-001 \ - --flatten \ - -f ndjson - -# 指定群聊撤回事件 -dws event consume user_im_message_recall_group \ - --group cidxxxxxxxx \ - --flatten \ - -f ndjson - -# 指定单聊表情回应事件 -dws event consume user_im_message_reaction_o2o \ - --user test-user-001 \ - --flatten \ - -f ndjson - -# 指定群聊表情回应事件 -dws event consume user_im_message_reaction_group \ - --group cidxxxxxxxx \ - --flatten \ - -f ndjson - -# 指定群标题变更 -dws event consume user_im_group_updated \ - --group cidxxxxxxxx \ - --flatten \ - -f ndjson - -# 指定群有成员加入 -dws event consume user_im_group_member_added \ - --group cidxxxxxxxx \ - --flatten \ - -f ndjson - -# 指定群有成员退出 -dws event consume user_im_group_member_exited \ - --group cidxxxxxxxx \ - --flatten \ - -f ndjson - -# 指定群解散 -dws event consume user_im_group_disbanded \ - --group cidxxxxxxxx \ - --flatten \ - -f ndjson -``` - -## Multi-event consume - -同一目标、同一过滤条件需要监听多个事件时,把多个 event key 作为位置参数放在同一个命令中: - -```bash -# 同一用户的消息接收、已读和撤回 -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 - -# 同一群的消息和群生命周期 -dws event consume \ - user_im_message_receive_group \ - user_im_group_updated \ - user_im_group_disbanded \ - --group cidxxxxxxxx \ - --flatten \ - -f ndjson -``` - -- 用户类事件共享一个 `--user` 或 `--open-dingtalk-id`;群类事件共享一个 `--group`;无目标事件可加入用户类、群类或仅由无目标事件组成的组合。 -- 用户类与群类不能混在一个命令中。不同用户、不同群或不同过滤条件要启动多个 consume 进程。 -- `--query` / `--filter-json` 只有在全部事件都是消息接收事件时才能共享使用。动作事件和群生命周期事件不能混用消息过滤。 -- `--duration`、`--max-events`、输出格式、route 和 output-dir 对整个命令生效;多事件不支持 `--subscribe-id`、`--rule`、`--event-types`、`--filter`、`--foreground`、`--force`、`--debug-raw-events`。 -- 每个事件仍有独立服务端订阅和 `subscribe_id`。用 `dws event stop --yes` 可只移除一个监听,其余继续运行;移除最后一个后前台进程退出。 - -多事件启动时,stderr 先逐条输出订阅信息,全部 IPC consumer 就绪后才输出整体 ready: - -```text -[event] subscription event_key= subscribe_id= -[event] subscription event_key= subscribe_id= -[event] ready event_count=2 bus_pid= -``` - -只有整体 ready 出现后才开始处理 stdout。启动中任一订阅或 IPC 建联失败时,本次已创建的订阅会统一回滚。 - -## Self-test triggers - -| 事件码 | 自测参数 | 触发方式 | -|---|---|---| -| `user_im_message_receive_at` | `--flatten --duration 10m -f ndjson` | 让任意可触达用户在群里 @ 当前登录用户 | -| `user_im_message_receive_o2o` | `--user ` 或 `--open-dingtalk-id `,加 `--flatten --duration 10m -f ndjson` | 让对端用户给当前登录用户发送单聊消息 | -| `user_im_message_receive_group` | `--group --flatten --duration 10m -f ndjson` | 让任意用户在该群发送消息 | -| `user_im_message_receive_user` | `--user ` 或 `--open-dingtalk-id `,加 `--flatten --duration 10m -f ndjson` | 让指定用户分别在单聊或共同群聊中发送消息 | -| `user_im_message_receive_o2o_all` | `--flatten --duration 10m -f ndjson` | 让任意其他用户给当前登录用户发送单聊消息 | -| `user_im_message_receive_group_all` | `--flatten --duration 10m -f ndjson` | 让任意其他用户在当前登录用户所在的任意群发送消息 | -| `user_im_message_read_o2o` | `--user ` 或 `--open-dingtalk-id `,加 `--flatten --duration 10m -f ndjson` | 当前用户给对端发送单聊消息,再让对端打开并阅读 | -| `user_im_message_read_group` | `--group --flatten --duration 10m -f ndjson` | 当前用户在群内发送消息,再让群成员打开并阅读 | -| `user_im_message_recall_o2o` | `--user ` 或 `--open-dingtalk-id `,加 `--flatten --duration 10m -f ndjson` | 在指定单聊中发送并撤回一条消息 | -| `user_im_message_recall_group` | `--group --flatten --duration 10m -f ndjson` | 在指定群聊中发送并撤回一条消息 | -| `user_im_message_reaction_o2o` | `--user ` 或 `--open-dingtalk-id `,加 `--flatten --duration 10m -f ndjson` | 在指定单聊中给消息添加表情回应 | -| `user_im_message_reaction_group` | `--group --flatten --duration 10m -f ndjson` | 在指定群聊中给消息添加表情回应 | -| `user_im_group_updated` | `--group --flatten --duration 10m -f ndjson` | 修改测试群的群标题 | -| `user_im_group_member_added` | `--group --flatten --duration 10m -f ndjson` | 邀请一名测试成员加入测试群 | -| `user_im_group_member_exited` | `--group --flatten --duration 10m -f ndjson` | 让一名测试成员主动退出测试群 | -| `user_im_group_disbanded` | `--group --flatten --duration 10m -f ndjson` | 确认是可销毁测试群后再解散;该操作不可逆 | - -单事件以 `[event] ready event_key= bus_pid= subscribe_id=` 为就绪标志;多事件以 `[event] ready event_count= bus_pid=` 为整体就绪标志。父进程等对应 ready 行后再处理 stdout。stdout 每行是一个扁平事件 JSON。 - -## Runtime flags - -| 参数 | 用途 | +| 任务 | Reference | |---|---| -| `--flatten` | 将 `ndjson/json/pretty` 的默认 transport envelope(或原 compact processor)投影为 Agent 可直接读取的顶层业务字段;不能与 `-f raw` 或 `--debug-raw-events` 同时使用 | -| `-f ndjson` | 控制序列化为一行一个 JSON;不改变数据结构 | -| `-f json` | 人工查看单条或少量样本;必须配合 `--max-events` 或 `--duration` | -| `--max-events ` | 收到 N 条后退出 | -| `--duration ` | 到时退出,例如 `30s`、`10m` | -| `--output-dir ` | 每个事件写入一个文件 | -| `--route '=dir:'` | 按事件类型路由到目录 | -| `--subscribe-id ` | 单事件模式复用已有个人订阅;多事件不支持 | -| `--ephemeral` | 即使复用已有订阅,也在 consume 退出时取消订阅 | -| `--query ` | 按消息正文关键词过滤,逗号分隔 | -| `--filter-json ` | 使用个人事件 Filter DSL 过滤 | -| `--debug-raw-events` | 单事件联调用:绕过本地过滤,输出当前 personal stream 实际收到的可解析事件 | +| 选择 16 个 EventKey、区分 user/group/all 规则、组合底层 consume | [event-im-keys.md](event-im-keys.md) | +| 等待 ready、多事件回滚、bounded consume、退出清理 | [event-im-lifecycle.md](event-im-lifecycle.md) | +| 扁平字段、引用/转发、动作与群成员事件、事件驱动回复 | [event-im-output.md](event-im-output.md) | +| Filter、status/stop、重试预算、本地保护与排障 | [event-im-operations.md](event-im-operations.md) | -正常 Agent 消费不要使用 `--debug-raw-events`。它会输出当前连接收到的所有可解析事件,只用于判断服务端是否推到了本机连接,并且不能与 `--flatten` 同时使用。 +## 共同硬规则 -## Output parsing +- 个人事件使用当前用户 OAuth;解析、consume、status、stop 必须是同一 `--profile`。 +- `+listen-im --user-query/--chat-query` 在 CLI 内唯一解析;零命中、多候选或分页不完整时, + 在创建订阅前停止。底层 fallback 只传真实稳定 ID。 +- 默认 `--flatten -f ndjson`;stdout 只处理事件,stderr 等待明确 ready marker。 +- 指定目标优先于 `*_all`;只有用户明确说“全部单聊/全部群消息”才订阅全量事件。 +- 当前用户自己发送的消息会被 self-loop 过滤;自测由另一用户或机器人触发。 +- 事件只负责监听。回复必须使用事件里的真实 `conversation_id` 或 + `sender_open_dingtalk_id`,禁止把展示名重新做自然查询。 -Agent 使用 `--flatten -f ndjson`,stdout 每行是一个扁平业务事件对象。消息接收事件常见顶层字段: +## 跨页契约索引 -| 字段 | 说明 | -|---|---| -| `type` | 个人事件码 | -| `event_id` | 事件 ID,可用于去重 | -| `timestamp` | 事件发生时间戳 | -| `subscribe_id` | 个人订阅 ID,也是本地输出隔离键 | -| `content` | 消息正文 | -| `sender` | 发送人展示名 | -| `conversation_id` | 开放会话 ID | -| `message_id` | 开放消息 ID | -| `sender_open_dingtalk_id` | 发送人的开放钉钉 ID | -| `create_time` | 消息创建时间 | -| `event_time` | 消息事件时间戳 | -| `quoted_message` | 可选;引用回复所引用的原消息对象 | -| `forward_messages` | 可选;合并转发包含的原消息数组 | +以下索引保留跨页必须一致的机器可检验契约;解释、示例和操作步骤仍按上表按需加载: -`quoted_message` 和 `forward_messages[]` 的内部字段为 `message_id`、`conversation_id`、`sender`、`sender_open_dingtalk_id`、`content`、`create_time`。服务端未提供内部发送人时,`sender` 可能为空或为 `null` 字符串。合并转发必须按 `forward_messages` 判断和解析,不要匹配可能随语言变化的外层聊天记录摘要。 +- 16 个事件都使用同一 `--profile`。就绪以 `[event] ready` 为准;全量事件是 + `user_im_message_receive_o2o_all` / `user_im_message_receive_group_all`,群生命周期是 + `user_im_group_updated` / `user_im_group_member_added` / + `user_im_group_member_exited` / `user_im_group_disbanded`。 +- 扁平动作字段包括 `reader_open_dingtalk_id`、`recaller_open_dingtalk_id`、 + `operator_open_dingtalk_id`、`reaction_name`、`operation_type` 和 `members`;媒体 lower + fallback 是 `dws chat message download-media`,外部身份参数是 `--open-dingtalk-id`。 +- 创建失败遵循 Agent/host 的 `0/2/1` 预算:`retryable=false` 对应 + `max_additional_attempts=0`,`retryable=true` 对应 `max_additional_attempts=2`, + `retryable=unknown` 对应 `max_additional_attempts=1`,并遵守 `retry_after_seconds` / + `next_retry_at`。这不是 CLI 持久化硬总次数上限;进程内不会自动重试,也不持久化或计算跨调用的 + Agent/host 尝试次数。`subscribe_id` / `trace_id` 不重置预算,`in_flight` / `cooldown` / + `terminal_hold` 不得被并发绕过。 +- open 版保护文件是 + `~/.dws/events/open/personal_stream//personal_subscription_attempts.json`; + `DWS_CONFIG_DIR` 可改变根目录。目录权限 `0700`,`personal_subscription_attempts.json` 与 + `personal_subscription_attempts.lock` 权限 `0600`;连续 `24h` 无失败后重置, + `terminal_hold` 为 `1h`。紧急恢复只删除 `personal_subscription_attempts.json`, + 不要删除 lock 文件;这会清空该 identity 的全部保护记录。 -在 `--flatten` 模式下直接按顶层字段解析,不要再使用 `fromjson` 或内部 payload 路径。不传 `--flatten` 时保持兼容 transport envelope,字段为 `type/event_type/data/headers`,业务 payload 需从 `.data | fromjson` 读取。图片、文件等媒体消息的 `content` 可能是可读描述;合并转发媒体的下载定位信息位于对应 `forward_messages[].content`。需要实际媒体文件时调用 `dws chat message download-media`。 +## Schema 边界 -所有动作事件都包含顶层 `type`、`event_id`、`timestamp`、`subscribe_id`、`message_id`、`conversation_id`、`sender`、`sender_open_dingtalk_id` 和 `event_time`。各类动作的专有字段如下: - -| 事件类型 | 顶层业务字段 | -|---|---| -| 已读 | `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` | - -正常输出不会暴露 `payload`、`uid`、`corpid`、`clientId`、`filterSubId`、`bizid` 等内部字段。需要检查原始协议时才使用 `-f raw` 或 `--debug-raw-events`。 - -群成员加入/退出事件使用稳定的顶层字段: - -| 字段 | 说明 | -|---|---| -| `conversation_id` | 发生成员变更的群会话 ID | -| `operator` | 执行操作的人;系统操作或成员自行退出时可能为空 | -| `operator_open_dingtalk_id` | 执行操作人的开放 ID;可能为空 | -| `members` | 本次加入或退出的成员数组,支持多人 | -| `members[].nick` | 成员展示名 | -| `members[].open_dingtalk_id` | 成员开放 ID | -| `event_time` | 群成员变更事件时间戳 | - -群标题变更和群解散仍采用保守输出:顶层只承诺 `type`、`event_id`、`timestamp`、`subscribe_id` 和 `payload`。`payload` 会保留服务端推送的未知业务字段,并移除已知的顶层身份/路由字段;不要猜测尚未由真实样本确认的键。 - -## Event-driven replies - -- 群消息自动回复:读取顶层 `conversation_id`,再调用 `dws chat message send --group "" --text "" --format json`。 -- 单聊自动回复:读取顶层 `sender_open_dingtalk_id`,再调用 `dws chat message send --open-dingtalk-id "" --text "" --format json`。 -- 正常处理持续读取 consume 的 stdout 管道。不要用 `sleep` 猜测建联,不要改写成 `--output-dir` watcher。 - -## Filtering - -优先用订阅规则参数缩小服务端推送范围: - -- 单聊用 `--user`。 -- 群消息用 `--group`。 -- 全量单聊/群聊事件没有范围参数,只在用户明确要求“所有”时使用。 - -收消息事件需要额外文本过滤时再用 `--query` 或 `--filter-json`: - -```bash -dws event consume user_im_message_receive_group \ - --group cidxxxxxxxx \ - --query "报警,故障" \ - --flatten \ - -f ndjson -``` - -`--filter-json` 使用 `content`、`sender`、`conversation_id`、`sender_open_dingtalk_id` 等业务别名表达意图。 - -动作事件只使用 `--user` 或 `--group` 限定订阅范围。`--query` 和消息内容 `--filter-json` 面向接收消息事件(包括两个 `*_all` 事件),不用于已读、撤回、表情回应或群生命周期事件。 - -## Status and stop - -```bash -dws event status --event user_im_message_receive_at -dws event status --event user_im_message_receive_o2o -dws event status --event user_im_message_receive_group -dws event status --event user_im_message_receive_user -dws event status --event user_im_message_receive_o2o_all -dws event status --event user_im_message_receive_group_all -dws event status --event user_im_message_read_o2o -dws event status --event user_im_message_recall_group -dws event status --event user_im_message_reaction_o2o -dws event status --event user_im_group_updated -dws event status --event user_im_group_member_added -dws event status --event user_im_group_member_exited -dws event status --event user_im_group_disbanded -``` - -`status` 同时展示服务端 `Subscriptions` 和本地 `Consumers`。`Consumers` 表里的 PID、事件码、`subscribe_id`、received/dropped 计数用于确认当前前台 consume 是否还挂在 personal bus 上。同一个多事件前台进程会显示多行 consumer,PID 相同但 event key 和 `subscribe_id` 不同。 - -停止指定订阅: - -```bash -dws event stop --dry-run -dws event stop --yes -``` - -清理当前身份下本地记录的全部个人订阅: - -```bash -dws event stop --all --dry-run -dws event stop --all --yes -``` - -裸 `dws event stop` 不会取消订阅。本次 consume 新建的订阅会在 SIGTERM、Ctrl+C、stdin EOF、duration 或 max-events 等干净退出时自动取消;通过 `--subscribe-id` 复用的订阅默认保留。多事件进程中,停止一个 `subscribe_id` 只移除对应逻辑 consumer,其余事件继续监听;最后一个被移除时进程退出。需要从外部取消时,使用事件输出或 `status` 里的 `subscribe_id`,先执行 `dws event stop --dry-run`,确认预览后再加 `--yes`。不要使用 `kill -9`,它会跳过清理。 - -## 订阅创建失败与重试预算 - -以下约束适用于上表全部 16 个事件以及多事件命令中的每一项,只治理 `[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 的订阅。 -- 同一逻辑订阅由当前 profile / 身份、event key、rule type、目标和过滤条件共同确定。`subscribe_id`、`trace_id` 以及重新启动进程都只是诊断或执行信息,不会生成新的逻辑操作;Agent/host 必须自行延续原编排预算。 -- Agent/host 收到 `retryable=false`:`max_additional_attempts=0`,立即停止,不得自动重跑。 -- Agent/host 收到 `retryable=true`:`max_additional_attempts=2`,初次失败后最多再尝试 2 次;错误若给出 `retry_after_seconds` 或 `next_retry_at`,不得提前重试。 -- 未返回 `retryable`(即 `retryable=unknown`):Agent/host 使用 `max_additional_attempts=1`,最多补偿尝试 1 次;仍无法确认时停止并上报错误与 trace。 -- `in_flight` 表示同一逻辑订阅已有请求执行中;`cooldown` 或 `terminal_hold` 表示当前被退避或终态保护。遇到这些状态不得递归调用 `event consume`、并行启动相同订阅或通过新 subId / trace 绕过;等待原请求或保护时间结束,同时由 Agent/host 继续维护自己的编排次数。 -- 多事件命令必须作为同一次原始操作治理。不得把失败事件拆成新的单事件命令、调整顺序或反复重启来重置预算;启动中任一项失败时,由 CLI 回滚本次已经创建的订阅。 - -### 本地保护状态运维 - -- open 版默认状态文件为 `~/.dws/events/open/personal_stream//personal_subscription_attempts.json`。设置 `DWS_CONFIG_DIR` 后配置根目录随之变化;其它 edition 使用对应 edition 目录,不固定为 `open`。 -- identity 目录权限为 `0700`;`personal_subscription_attempts.json` 与 `personal_subscription_attempts.lock` 权限均为 `0600`。 -- 连续 24h 没有失败后失败计数重置;`terminal_hold` 持续 1h。正常处理优先等待错误中的 `next_retry_at`,不要把删状态文件当成常规重试手段。 -- 紧急恢复时,先确认该 identity 没有正在创建订阅的进程;只删除 `personal_subscription_attempts.json`,不要删除 lock 文件。删除 JSON 会清空该 identity 的全部保护记录,不只影响一个事件。 - -## Troubleshooting - -- 没有输出:单事件确认 stderr 已出现 `ready event_key=...`;多事件确认已出现 `ready event_count=...`,不要把某条 `subscription` 行误认为整体就绪。 -- 参数缺失:所有 o2o 事件必须有对端 ID,所有 group 事件必须有 openConversationId。 -- 收到非预期消息:检查 stdout 的 `subscribe_id` 是否等于当前命令创建/复用的订阅 ID。 -- 需要判断服务端是否推到当前连接:临时加 `--debug --debug-raw-events`,排查后去掉。 -- 需要长期运行:交给外部进程管理;不要把消息历史查询写成轮询脚本。 -- 安装或更新 skill 后,已打开的 Agent 旧会话需要新开会话或重新加载 skills。 +- 业务 payload:`dws event schema --flatten`。 +- CLI 参数/安全:`dws schema --cli-path "event +listen-im" -f json` 或精确 consume leaf。 +- `--flatten` 的 `jq_root_path` 为 `.`;兼容 transport envelope 才使用 `.data | fromjson`。 diff --git a/skills/multi/dws-shared/SKILL.md b/skills/multi/dws-shared/SKILL.md index 7358231a..d7604cf0 100644 --- a/skills/multi/dws-shared/SKILL.md +++ b/skills/multi/dws-shared/SKILL.md @@ -11,33 +11,23 @@ metadata: # DWS 共享执行契约 -把本文件作为所有 DWS 操作的轻量公共前置条件。明确单产品请求直接使用对应 -`dingtalk-*` skill;只有泛称 DWS、跨产品流程、URL 预检或意图不清时,才把本 -skill 作为入口进行二级路由。 +本文件只在泛称 DWS、跨产品流程、URL 预检或意图不清时作为入口。明确单产品请求直接使用对应 `dingtalk-*` skill;已经内嵌最小执行契约的产品根 Skill 不需要先完整读取本文件。 -## 所有 DWS 操作必须遵守 + +## 最小 DWS 执行契约 -- 只通过 `dws` CLI 操作钉钉产品;不要改用 curl、私有 HTTP API 或浏览器绕过。 -- 所有结构化机器读取命令使用 `--format json`,按真实返回字段判断结果。 -- 返回中的秒/毫秒时间戳必须转换为带时区的可读日期时间后再面向用户展示;默认使用 - 当前会话时区(本环境为 `Asia/Shanghai`),必要时同时保留原始值,禁止只把裸时间戳 - 交给 Agent 或用户判断具体日期。 -- 不要猜测命令、flag、字段名、UUID、nodeId、userId、邮箱、手机号或 URL。 -- 用户已明确产品内容意图时,意图优先于 URL 形态。`.md` 等本地格式文件按普通 - 文件走 `drive`:先 `dws drive download` 下载到本地处理,再用 `dws drive upload` - 回传。 -- 命令或参数不确定时,先运行最接近层级的 `dws --help`;禁止把自然语言 - 同义词直接拼成命令。 -- 后续步骤使用的 ID 必须来自前一步真实返回;多人同名、多目标或类型不明时先消歧。 -- 多账号场景只传组织时,使用该组织唯一 `isOrgCurrent=true` 的默认账号;多账号组织 - 没有默认账号时先让用户指定账号,禁止选择第一项、最近登录或最近使用账号。账号 - 选择与跨组织规则详见 `../dingtalk-profile/SKILL.md`。 -- 不输出、转述或记录 token、refresh token、appSecret 等凭据。宿主已注入认证时 - 不要求用户重新提供凭据。 -- 写入操作必须符合用户明确意图。删除、审批决策及其他不可逆/高影响操作,先展示 - 对象、动作和影响,获得明确同意后才追加 `--yes`;不得静默确认。 -- 创建、更新、发送等写操作后,按产品 skill 的要求回读或查询状态,不得仅凭退出码 - 宣称成功。 +- 只通过 `dws` CLI 操作钉钉;结构化读取使用 `--format json`,按真实返回判断结果。 +- 已知命令直接执行。只有 leaf 参数或安全语义不确定时读取精确 Schema,只有 Cobra flag 不确定时读取精确 leaf Help;不要加载产品级 Catalog 代替选路。 +- 不猜命令、flag、字段、ID、账号或时间。后续 ID 必须来自真实返回;零命中、多候选或类型不明时停止并消歧。 +- 解析目标、读取上下文和最终执行必须使用同一 profile;不得跨组织复用 userId、openDingTalkId 或 openConversationId。多账号组织只使用明确的 `isOrgCurrent=true` 默认账号;没有默认账号时要求用户指定,禁止选择第一项、最近登录或最近使用账号。 +- 不输出或记录 token、refresh token、appSecret、webhook token 等凭据;宿主已注入认证时不要索要凭据。 +- 写操作必须符合用户明确意图。是否需要确认以最终 Runtime gate 和 Schema 为准;需要确认时先说明对象、动作与影响,再追加 `--yes`。 +- 写后按任务结果契约验证;不能仅凭退出码宣称成功。部分结果、未知投递状态和失败项必须如实保留。 +- 时间戳面向用户展示时转换为带时区的可读时间;默认使用当前会话时区,必要时同时保留原值。 +- 遇到认证、权限、profile、confirmation 或未知错误时,只加载 `dws-shared` 中对应 reference;不要连续猜测替代命令。 + + +产品或跨产品规则在最小契约之上增量加载。用户已明确产品内容意图时,意图优先于 URL 形态;多账号选择与跨组织规则读取 `../dingtalk-profile/SKILL.md`。本地文件、产品边界和跨产品传递规则只在对应任务中加载,避免把全局手册放入每个单产品请求。 ## 渐进加载 @@ -82,8 +72,9 @@ skill 作为入口进行二级路由。 ## 错误最短路径 -1. `unknown command` / `unknown flag`:运行对应层级 `--help`,修正后最多重试一次。 +1. `unknown command` / `unknown flag`:运行对应层级 `--help`,按公开 flag 修正后最多重试一次。 2. 认证或权限错误:读取 `global-reference.md` 与 `error-codes.md` 对应章节。 -3. 其他错误:加 `--verbose` 重试一次;仍失败则停止并报告真实错误,不连续尝试替代 - 命令。 +3. 其他错误:优先读取 JSON 错误中的 `retryable`、`retry_after_seconds`、 + `next_retry_at`、`hint` 和 `actions`。只有明确 `retryable=true` 时才按服务端节奏重试; + 缺少重试语义时用 `--verbose` 获取诊断并停止,不连续尝试替代命令。 4. 明确不支持的能力:说明边界,不通过其他接口绕过。 diff --git a/skills/multi/dws-shared/references/error-codes.md b/skills/multi/dws-shared/references/error-codes.md index 97f60f74..a5861663 100644 --- a/skills/multi/dws-shared/references/error-codes.md +++ b/skills/multi/dws-shared/references/error-codes.md @@ -1,25 +1,61 @@ -# 错误码说明 -全产品错误参考 + 调试流程。Agent 遇到错误时查阅此文档。 +# 错误与恢复说明 + +只查当前错误对应章节。先读取结构化字段,再决定修正、重试、请求用户介入或停止。 ## 错误返回格式 ```json -{"success": false, "code": "InvalidParameter", "message": "baseId is required"} -{"success": false, "code": "AUTH_TOKEN_EXPIRED", "message": "Token验证失败"} -{"success": false, "code": "PermissionDenied", "message": "无权限访问该资源"} +{ + "error": { + "code": 3, + "category": "validation", + "message": "missing required flag(s): --base-id", + "reason": "...", + "retryable": false, + "hint": "...", + "actions": ["..."] + } +} ``` +错误写到 stderr。`code` 是 CLI 退出码,`category` 是稳定大类:`api`、`auth`、 +`validation`、`discovery`、`internal`。其他字段按错误类型出现,不保证每次都有。 + ## 错误分类与 Agent 行为 ### 可自行修复 -- 参数缺失 / 格式错误 / ID 无效 → 检查参数后修正重试 + +- `category=validation` 且 `available_flags`/`hint` 给出明确修正:核对 leaf Help,修正 + 一次;不要猜 flag。 +- 自然目标返回 `details.candidates`:零命中或多候选都停止并消歧,禁止选择第一项。 +- `reason=confirmation_required`:核对最终 Schema 和用户意图;用户确认后才追加 + `--yes`,不能换成确认更弱的底层命令。 ### 需用户介入 -- 权限不足 / 资源不存在 / 配额超限 → 报告完整错误信息给用户,不要自行尝试替代方案 -## 通用错误 -- 请求超时 — 网络慢或服务端响应慢 → `--timeout 60` 重试 -- 网络连接失败 — 无法连接 MCP Server → 用最简命令验证: `dws contact user get-self --format json` +- 权限不足、资源不存在、配额/权益不足:报告 `server_error_code`、`trace_id`、`hint` + 和 `action_url`(如有),不自行改身份或尝试替代接口。 +- profile 不存在或同组织多账号无默认:让用户指定 `corpId:userId`,不要选最近账号。 +- 未知投递状态:停止重发,先查询状态;没有状态查询能力时如实报告未知。 + +## 重试规则 + +- `retryable=true`:遵守 `retry_after_seconds` 或 `next_retry_at`;运行时可能已经完成 + HTTP 重试,调用方只做一次有界重试并保留相同幂等键。 +- `retryable=false`:不要重试。 +- 未出现 `retryable`:不要从 `category`、HTTP 文案或“看起来像临时错误”推断可重试; + 使用 `--verbose` 收集诊断后停止。 +- 超时不会因为盲目增大 `--timeout` 自动变安全。只有任务确实允许更长等待且操作状态可判定时, + 才显式调整超时。 + +## 认证与权限 + +- 精确的 access-token 拒绝信号会由 Runtime 自动刷新并最多重放一次;不要再包一层“重试两次”。 +- `reason=auth_refresh_failed` 或 `auth status` 返回 `authenticated=false`:保留原错误,运行 + `dws auth status --profile --format json`;按返回 `hint` 恢复,必要时请用户登录。 +- HTTP/RPC 403 与普通权限不足不会触发 token 刷新;不要通过切 profile、bot 或 webhook + 身份绕过。 +- AppKey/AppSecret 缺失只用于应用凭据配置问题;不要向普通业务用户索要凭据。 --- @@ -64,10 +100,18 @@ ## chat 高频错误 -- 参数互斥报错 — `--group` 与 `--user` / `--users` 同时传入 → 群聊用 `--group`,单聊用 `--user`/`--users`,二者互斥 -- 群不存在 — openconversation_id 不正确 → `chat search --query "群名"` 获取正确 ID -- 机器人无法添加到群 — 当前用户非群管理员 → 报告给用户,需群管理员操作 -- `send` 消息 text 参数缺失 — text 是位置参数,不是 flag → text 直接跟在 flags 后: `send --group "内容"` +- 群聊读取优先 `chat +chat-messages --group <群名或ID>`;单聊先解析唯一用户 ID 再读取。 + 自然群名多候选时读取 `details.candidates` 并让用户消歧,不取第一项。 +- 普通发送优先 `chat +dm`、`chat +send-to-group` 或 `chat +messages-send`;发送类 Shortcut + 的最终 Schema 要求确认时,必须先确认再加 `--yes`。 +- `--group`、`--user`、`--open-dingtalk-id` 等目标参数互斥:按 leaf Help 只传一类目标。 +- `+messages-send` 的 `--text`/`--markdown`/`--media-id`/`--file` 互斥;身份、目标、 + 凭据和幂等参数还受 user/bot/webhook 能力矩阵约束。 +- `ok=false`、`partial=true`、非空 `failures` 或分页仍有 continuation 都不是完整成功; + 必须保留 ledger,不能只返回已成功部分。 +- 机器人不在群或当前用户无管理权限:报告真实失败,需要群管理员处理;不改用当前用户身份发送。 +- 原子 `chat message send` 只用于 Shortcut 未覆盖的底层消息类型/原始字段。其正文可用 + `--text`,不要把某个历史位置参数写法当成所有发送入口的规则。 --- diff --git a/skills/multi/dws-shared/references/global-reference.md b/skills/multi/dws-shared/references/global-reference.md index 4ad3b94e..1bea0512 100644 --- a/skills/multi/dws-shared/references/global-reference.md +++ b/skills/multi/dws-shared/references/global-reference.md @@ -1,89 +1,122 @@ -# 全局参考 +# 全局运行时参考 -## 认证 +只在认证、profile、公开全局 flag 或输出格式不确定时读取本文件。命令与参数事实以当前 +分支的 leaf Schema 和 Cobra Help 为准。 -认证由系统自动管理,无需手动登录。 +## 认证与 profile + +日常业务命令会使用本地 OAuth 登录态,并在明确的 access-token 拒绝信号下自动刷新、 +最多重放当前调用一次。不要从错误文本猜测 token 状态,也不要自行循环重试。 ```bash -# 查看当前认证状态 -dws auth status +# 查看当前 profile 的登录态;读取 authenticated/token_valid 等真实字段 +dws auth status --format json -# 退出登录 -dws auth logout +# 列出全部账号及 isCurrent/isOrgCurrent +dws profile list --format json + +# 本机浏览器登录;无头环境使用 --device +dws auth login --recommend --format json +dws auth login --device --recommend --format json + +# 只指定本次组织/账号,不持久切换默认 profile +dws auth status --profile : --format json ``` -系统自动管理 token 刷新,日常使用无需干预。 +- 同一条 IM 链路的目标解析、读取与写入必须使用同一 `--profile`。 +- `profile list` 本身不刷新 token;`auth status --profile ...` 只检查/刷新选中的 token + slot,不修改 `currentProfile`。 +- `auth logout` 默认退出全部账号;传 `--profile` 才缩小范围。`auth reset` 会清除本地 + token,只有确认登录态需要重建时才使用。 +- 宿主已注入认证时不要向用户索要 token、refresh token、AppSecret 或 webhook token。 -| Token | 有效期 | 说明 | -|-------|--------|------| -| Access Token | 2 小时 | 调用 API 的凭证,过期自动刷新 | -| Refresh Token | 30 天 | 换新 Access Token,使用后轮转 | +### 可迁移认证包 -30 天内使用一次即自动续期。 - -### 认证失败处理 -- 命令返回 `AUTH_TOKEN_EXPIRED` / `USER_TOKEN_ILLEGAL` / "Token验证失败" → 请重新执行上一条命令(最多重试两次) - -### Headless 环境 (CI/CD) +只有用户明确要求迁移登录态时才使用: ```bash -# 桌面: 导出凭证 -dws auth export > credentials.json - -# 服务器: 设置环境变量 -export DINGTALK_CREDENTIALS_FILE=~/.config/dingtalk/credentials.json +dws auth export --output dws-auth.tar.gz +dws auth import --input dws-auth.tar.gz +dws auth status --format json ``` -refresh_token 单设备独占,远程刷新后源设备凭证失效。 +认证包包含敏感凭据和解密材料;不得打印其内容,迁移完成后按用户的安全策略删除。 +平台/密钥后端限制以 `dws auth export --help` 与命令返回为准,不自行绕过。 -## 全局标志 +## 公开全局 flag -| 标志 | 短名 | 说明 | 默认 | -|------|:---:|------|------| -| `--format` | `-f` | 输出格式: json / table / raw | table | -| `--verbose` | `-v` | 详细日志 | false | -| `--debug` | | 调试日志 | false | -| `--yes` | `-y` | 跳过确认提示 | false | -| `--dry-run` | | 预览操作不执行 | false | -| `--timeout` | | HTTP 超时 (秒) | 30 | -| `--token` | | API Token (覆盖配置) | 无 | -| `--mock` | | Mock 数据 (开发用) | false | +| flag | 语义 | 默认/约束 | +|---|---|---| +| `--format`, `-f` | `json\|table\|raw\|pretty\|ndjson\|csv` | `json` | +| `--fields` | 逗号分隔的输出字段筛选 | 空 | +| `--jq` | jq 表达式过滤输出 | 空 | +| `--profile` | 一次性指定组织或账号;支持 CSV 多选的命令由运行时决定 | 当前 profile | +| `--timeout` | HTTP 请求超时秒数 | `30` | +| `--dry-run` | 请求预览 | 只有 leaf 明确支持时才代表可执行预览 | +| `--yes`, `-y` | 通过 Runtime confirmation gate | 仅在用户确认且最终 Schema 要求时追加 | +| `--verbose`, `-v` | 增加诊断信息 | `false` | +| `--debug` | 输出内部调试诊断 | `false`;不得泄露凭据 | +| `--mock` | 开发调试 Mock | `false`;不得当作业务成功 | +| `--client-id` / `--client-secret` | OAuth 应用凭据覆盖 | 必须成对、仅在明确配置任务使用 | -## 输出格式 +公开 Help 不提供通用业务 `--token` flag。Webhook token 等凭据只传给声明该 leaf +参数的命令;不要把某个 leaf 的 `--token` 推广成全局认证方式。 -### --format json (机器可读, Agent 必须使用) +## 输出契约 + +### 成功输出 + +`--format json` 直接序列化命令的真实 payload;不存在适用于所有产品的固定 +`{"success":true,"body":...}` 包装。读取 leaf 声明的字段,并保留以下完整性信号: + +- 分页:`hasMore`、`nextCursor`、`nextPageToken` 等当前响应字段; +- 批量/编排:`ok`、`partial`、`failures`、`results` 等当前 Shortcut ledger; +- 写入:返回的稳定资源 ID、投递状态或逐项失败,不用退出码代替结果验证。 + +`table`/`pretty` 面向人工阅读,`raw` 保留原始文本,`ndjson`/`csv` 面向列表流转。 +Agent 默认使用 JSON;只有确定列表形状与下游需求时才切换格式。 + +### 错误输出 + +JSON 模式下,失败写到 stderr,稳定外层结构为: ```json -{"success": true, "body": {...}} +{ + "error": { + "code": 3, + "category": "validation", + "message": "...", + "reason": "...", + "retryable": false, + "hint": "...", + "actions": ["..."] + } +} ``` -### --format table (人类可读, 默认) +除 `code/category/message` 外的字段按错误类型出现。优先消费 `reason`、`retryable`、 +`retry_after_seconds`、`next_retry_at`、`details`、`available_flags`、`trace_id`、 +`server_error_code`、`hint` 和 `actions`;不要依赖自由文本子串做自动修复。 -``` -已创建 AI 表格 "项目管理" (UUID: abc123) +## 相关环境变量 -下一步: - dws aitable base get --base-id abc123 -``` +| 变量 | 用途 | +|---|---| +| `DWS_CONFIG_DIR` | 覆盖默认 `~/.dws` 配置目录 | +| `DWS_CLIENT_ID` + `DWS_CLIENT_SECRET` | 成对覆盖 OAuth 应用凭据;只设置一个时不会作为完整凭据对使用 | +| `DWS_CHANNEL` | 受控分发渠道;仅在明确渠道任务中按 [channel-login.md](channel-login.md) 使用 | -## 环境变量 - -| 变量 | 说明 | -|------|------| -| `DINGTALK_TOKEN` | API Token (最高优先级) | -| `DINGTALK_CREDENTIALS_FILE` | 导出的凭证文件路径 | -| `DINGTALK_MCP_URL` | MCP Server URL (覆盖内置) | - -凭证优先级: `--token` > `DINGTALK_TOKEN` > `DINGTALK_CREDENTIALS_FILE` > OAuth token.json +不要把内部调试/兼容环境变量写进普通业务流程,也不要把凭据持久写入 shell profile。 ## 命令自省 -不确定某个命令的参数时,用 `--help` 查看: - ```bash -dws calendar event create --help -dws chat message send --help -dws aitable record query --help +# 决定 Agent 选路、参数映射与确认语义 +dws schema --cli-path "chat +messages-send" --format json + +# 核对当前二进制真实接受的 flag +dws chat +messages-send --help ``` -`--help` 会显示所有 flag、类型、默认值和必填标记,比凭记忆猜参数更可靠。 +已知 leaf 直接执行。只有 selection、安全或参数映射不确定时查精确 Schema;只有 flag +拼写不确定时查精确 Help。不要为一个 leaf 加载产品级全量 Catalog。 diff --git a/skills/multi/dws-shared/references/runtime-contract.md b/skills/multi/dws-shared/references/runtime-contract.md new file mode 100644 index 00000000..28759c4f --- /dev/null +++ b/skills/multi/dws-shared/references/runtime-contract.md @@ -0,0 +1,11 @@ +## 最小 DWS 执行契约 + +- 只通过 `dws` CLI 操作钉钉;结构化读取使用 `--format json`,按真实返回判断结果。 +- 已知命令直接执行。只有 leaf 参数或安全语义不确定时读取精确 Schema,只有 Cobra flag 不确定时读取精确 leaf Help;不要加载产品级 Catalog 代替选路。 +- 不猜命令、flag、字段、ID、账号或时间。后续 ID 必须来自真实返回;零命中、多候选或类型不明时停止并消歧。 +- 解析目标、读取上下文和最终执行必须使用同一 profile;不得跨组织复用 userId、openDingTalkId 或 openConversationId。多账号组织只使用明确的 `isOrgCurrent=true` 默认账号;没有默认账号时要求用户指定,禁止选择第一项、最近登录或最近使用账号。 +- 不输出或记录 token、refresh token、appSecret、webhook token 等凭据;宿主已注入认证时不要索要凭据。 +- 写操作必须符合用户明确意图。是否需要确认以最终 Runtime gate 和 Schema 为准;需要确认时先说明对象、动作与影响,再追加 `--yes`。 +- 写后按任务结果契约验证;不能仅凭退出码宣称成功。部分结果、未知投递状态和失败项必须如实保留。 +- 时间戳面向用户展示时转换为带时区的可读时间;默认使用当前会话时区,必要时同时保留原值。 +- 遇到认证、权限、profile、confirmation 或未知错误时,只加载 `dws-shared` 中对应 reference;不要连续猜测替代命令。 diff --git a/test/mock_mcp/mock_mcp_smoke_test.go b/test/mock_mcp/mock_mcp_smoke_test.go index 0a9afda8..58e5127b 100644 --- a/test/mock_mcp/mock_mcp_smoke_test.go +++ b/test/mock_mcp/mock_mcp_smoke_test.go @@ -3,8 +3,17 @@ package mock_mcp_test import ( "bytes" "context" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" "encoding/json" + "encoding/pem" "fmt" + "io" + "math/big" + "net" "net/http" "net/http/httptest" "os" @@ -18,9 +27,13 @@ import ( "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app" + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" ) -const mockMCPSmokeHelperEnv = "DWS_MOCK_MCP_SMOKE_HELPER" +const ( + mockMCPSmokeHelperEnv = "DWS_MOCK_MCP_SMOKE_HELPER" + mockMCPSmokeCAEnv = "DWS_MOCK_MCP_SMOKE_CA_FILE" +) type recordedToolCall struct { path string @@ -39,6 +52,20 @@ func TestCLIHelperProcess(t *testing.T) { if os.Getenv(mockMCPSmokeHelperEnv) != "1" { return } + if caFile := strings.TrimSpace(os.Getenv(mockMCPSmokeCAEnv)); caFile != "" { + certificatePEM, err := os.ReadFile(caFile) + if err != nil { + fmt.Fprintf(os.Stderr, "Mock MCP smoke helper: read CA: %v\n", err) + os.Exit(2) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(certificatePEM) { + fmt.Fprintln(os.Stderr, "Mock MCP smoke helper: invalid CA file") + os.Exit(2) + } + _ = os.Setenv("GODEBUG", "x509usefallbackroots=1") + x509.SetFallbackRoots(pool) + } marker := -1 for i, arg := range os.Args { @@ -163,6 +190,519 @@ func TestMockMCPSmoke_CLIRoutesSerializedArgumentsAndPrintsJSON(t *testing.T) { } } +func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) { + var requestsMu sync.Mutex + var requests []recordedToolCall + var uploadedBody []byte + download := newTrustedDownloadFixture(t, []byte("downloaded-resource-body")) + authConfigDir := t.TempDir() + authKeychainDir := filepath.Join(t.TempDir(), "keychain") + t.Setenv("DWS_CONFIG_DIR", authConfigDir) + t.Setenv("DWS_KEYCHAIN_DIR", authKeychainDir) + t.Setenv("DWS_DISABLE_KEYCHAIN", "1") + if err := authpkg.SaveTokenData(authConfigDir, &authpkg.TokenData{ + AccessToken: "ci-smoke-token", + RefreshToken: "ci-refresh-token", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(2 * time.Hour), + ClientID: "ci-client", + CorpID: "ci-corp", + UserID: "ci-user", + }); err != nil { + t.Fatalf("save isolated event auth: %v", err) + } + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut && r.URL.Path == "/upload/conversation" { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + requestsMu.Lock() + uploadedBody = append([]byte(nil), body...) + requestsMu.Unlock() + w.WriteHeader(http.StatusOK) + return + } + var envelope struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + } `json:"params"` + } + decodeErr := json.NewDecoder(r.Body).Decode(&envelope) + call := recordedToolCall{ + path: r.URL.Path, + method: r.Method, + authorization: r.Header.Get("Authorization"), + jsonrpc: envelope.JSONRPC, + tool: envelope.Params.Name, + arguments: envelope.Params.Arguments, + err: decodeErr, + } + requestsMu.Lock() + requests = append(requests, call) + requestsMu.Unlock() + + response := multiIMMockResponse( + envelope.Params.Name, + envelope.Params.Arguments, + server.URL, + download.URL, + ) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": envelope.ID, + "result": map[string]any{ + "content": []map[string]any{{"type": "text", "text": response}}, + }, + }) + })) + defer server.Close() + + env := isolatedCLIEnv(t, map[string]string{ + "DINGTALK_CONTACT_MCP_URL": server.URL + "/mcp/contact", + "DINGTALK_CHAT_MCP_URL": server.URL + "/mcp/chat", + "DINGTALK_IM_MCP_URL": server.URL + "/mcp/im", + "HTTPS_PROXY": download.ProxyURL, + "https_proxy": download.ProxyURL, + "SSL_CERT_FILE": download.CAFile, + mockMCPSmokeCAEnv: download.CAFile, + "DWS_CONFIG_DIR": authConfigDir, + "DWS_KEYCHAIN_DIR": authKeychainDir, + }) + reset := func() { + requestsMu.Lock() + requests = nil + uploadedBody = nil + requestsMu.Unlock() + } + snapshot := func() []recordedToolCall { + requestsMu.Lock() + defer requestsMu.Unlock() + return append([]recordedToolCall(nil), requests...) + } + + t.Run("natural user advanced send resolves then writes once", func(t *testing.T) { + reset() + stdout, stderr, err := runCLI(t, env, + "--token", "ci-smoke-token", "--format", "json", + "chat", "+messages-send", "--as", "user", + "--user-query", "张三", "--text", "你好", "--yes", + ) + if err != nil { + t.Fatalf("natural send failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr) + } + calls := snapshot() + if len(calls) != 2 || calls[0].tool != "search_contact_by_key_word" || calls[1].tool != "send_personal_message" { + t.Fatalf("calls = %#v, want resolve + one send", calls) + } + if calls[1].arguments["receiverOpenDingTalkId"] != "D-user-1" { + t.Fatalf("send target = %#v", calls[1].arguments) + } + assertMultiIMCallsAreLocalAndAuthorized(t, calls) + }) + + t.Run("natural group file send uploads commits and writes once", func(t *testing.T) { + reset() + workdir := t.TempDir() + fileBody := []byte("multi-im-file-upload") + if err := os.WriteFile(filepath.Join(workdir, "report.txt"), fileBody, 0o600); err != nil { + t.Fatal(err) + } + stdout, stderr, err := runCLIInDir(t, env, workdir, + "--token", "ci-smoke-token", "--format", "json", + "chat", "+messages-send", "--as", "user", + "--chat-query", "项目群", "--file", "./report.txt", + "--idempotency-key", "file-key", "--yes", + ) + if err != nil { + t.Fatalf("file send failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr) + } + calls := snapshot() + wantTools := []string{ + "search_groups", + "init_conversation_file_upload", + "commit_conversation_file_upload", + "send_personal_message", + } + if got := recordedToolNames(calls); !reflect.DeepEqual(got, wantTools) { + t.Fatalf("file send tools = %#v, want %#v", got, wantTools) + } + requestsMu.Lock() + gotUpload := append([]byte(nil), uploadedBody...) + requestsMu.Unlock() + if !bytes.Equal(gotUpload, fileBody) { + t.Fatalf("uploaded body = %q, want %q", gotUpload, fileBody) + } + var payload map[string]any + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatalf("file send output is not JSON: %v\n%s", err, stdout) + } + if payload["ok"] != true || payload["effectiveMessageType"] != "file" { + t.Fatalf("file send result = %#v", payload) + } + }) + + t.Run("ambiguous user has zero write side effects", func(t *testing.T) { + reset() + _, _, err := runCLI(t, env, + "--token", "ci-smoke-token", "--format", "json", + "chat", "+messages-send", "--as", "user", + "--user-query", "同名用户", "--text", "不能发", "--yes", + ) + if err == nil { + t.Fatal("ambiguous user unexpectedly succeeded") + } + calls := snapshot() + if len(calls) != 1 || calls[0].tool != "search_contact_by_key_word" { + t.Fatalf("ambiguous calls = %#v, want resolver only", calls) + } + }) + + t.Run("natural group resource read downloads atomically", func(t *testing.T) { + reset() + workdir := t.TempDir() + stdout, stderr, err := runCLIInDir(t, env, workdir, + "--token", "ci-smoke-token", "--format", "json", + "chat", "+chat-messages", "--chat-query", "资源群", + "--download-resources", "--output-dir", "./downloads", + ) + if err != nil { + t.Fatalf("resource read failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr) + } + calls := snapshot() + wantTools := []string{"search_groups", "list_conversation_message_v2", "get_resource_download_url"} + if got := recordedToolNames(calls); !reflect.DeepEqual(got, wantTools) { + t.Fatalf("resource tools = %#v, want %#v", got, wantTools) + } + downloaded, err := os.ReadFile(filepath.Join(workdir, "downloads", "artifact.bin")) + if err != nil { + t.Fatalf("read downloaded resource: %v\nstdout=%s\nstderr=%s", err, stdout, stderr) + } + if string(downloaded) != "downloaded-resource-body" { + t.Fatalf("downloaded resource = %q", downloaded) + } + var payload map[string]any + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatalf("resource output is not JSON: %v\n%s", err, stdout) + } + ledger, _ := payload["resourceDownloads"].(map[string]any) + if payload["complete"] != true || ledger["downloadedCount"] != float64(1) || ledger["failedCount"] != float64(0) { + t.Fatalf("resource completion = %#v", payload) + } + }) + + t.Run("search later page failure preserves results and marks incomplete", func(t *testing.T) { + reset() + stdout, stderr, err := runCLI(t, env, + "--token", "ci-smoke-token", "--format", "json", + "chat", "+search-msg", "--query", "分页失败", + "--page-all", "--no-enrich", + ) + if err != nil { + t.Fatalf("partial search failed as a command: %v\nstdout=%s\nstderr=%s", err, stdout, stderr) + } + calls := snapshot() + if got := recordedToolNames(calls); !reflect.DeepEqual(got, []string{"search_messages", "search_messages"}) { + t.Fatalf("search tools = %#v", got) + } + var payload map[string]any + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatalf("search output is not JSON: %v\n%s", err, stdout) + } + if payload["complete"] != false || payload["count"] != float64(1) || + payload["pagesFetched"] != float64(1) || payload["failedCount"] != float64(1) { + t.Fatalf("partial search contract = %#v", payload) + } + failures, _ := payload["failures"].([]any) + if len(failures) != 1 || failures[0].(map[string]any)["stage"] != "search-page" { + t.Fatalf("partial search failures = %#v", failures) + } + }) + + t.Run("event listen im process dry-run resolves and creates no subscription", func(t *testing.T) { + reset() + stdout, stderr, err := runCLI(t, env, + "--token", "ci-smoke-token", "--client-id", "ci-client", + "event", "+listen-im", "--kind", "group", + "--events", "message,reaction", "--chat-query", "项目群", + "--dry-run", + ) + if err != nil { + t.Fatalf("event dry-run failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr) + } + calls := snapshot() + if got := recordedToolNames(calls); !reflect.DeepEqual(got, []string{"search_groups"}) { + t.Fatalf("event dry-run tools = %#v", got) + } + if !strings.Contains(stderr, "subscription[0]") || !strings.Contains(stderr, "subscription[1]") || + !strings.Contains(stderr, "user_im_message_receive_group") || + !strings.Contains(stderr, "user_im_message_reaction_group") { + t.Fatalf("event dry-run plan missing:\nstdout=%s\nstderr=%s", stdout, stderr) + } + }) + + t.Run("natural group read returns complete shared message contract", func(t *testing.T) { + reset() + stdout, stderr, err := runCLI(t, env, + "--token", "ci-smoke-token", "--format", "json", + "chat", "+chat-messages", "--chat-query", "项目群", + ) + if err != nil { + t.Fatalf("natural read failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr) + } + calls := snapshot() + if len(calls) != 2 || calls[0].tool != "search_groups" || calls[1].tool != "list_conversation_message_v2" { + t.Fatalf("read calls = %#v", calls) + } + var payload map[string]any + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatalf("read output is not JSON: %v\n%s", err, stdout) + } + if payload["contractVersion"] != "im.message-list.v1" || payload["complete"] != true || payload["failedCount"] != float64(0) { + t.Fatalf("message contract = %#v", payload) + } + messages, _ := payload["messages"].([]any) + if len(messages) != 1 { + t.Fatalf("messages = %#v", payload["messages"]) + } + message, _ := messages[0].(map[string]any) + if message["messageId"] != "msg-1" || message["senderId"] != "D-sender" || message["senderType"] != "user" { + t.Fatalf("projected message = %#v", message) + } + }) + + t.Run("natural member create dry-run resolves but never creates", func(t *testing.T) { + reset() + stdout, stderr, err := runCLI(t, env, + "--token", "ci-smoke-token", "--format", "json", + "chat", "+chat-create", "--name", "新群", + "--member-query", "张三", "--dry-run", "--yes", + ) + if err != nil { + t.Fatalf("chat-create dry-run failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr) + } + calls := snapshot() + if len(calls) != 2 || calls[0].tool != "search_contact_by_key_word" || calls[1].tool != "get_current_user_profile" { + t.Fatalf("dry-run calls = %#v, want resolver/profile and no create", calls) + } + for _, call := range calls { + if call.tool == "create_group_conversation" { + t.Fatalf("dry-run created a group: %#v", calls) + } + } + }) + + t.Run("reply returns continuation context", func(t *testing.T) { + reset() + stdout, stderr, err := runCLI(t, env, + "--token", "ci-smoke-token", "--format", "json", + "chat", "+messages-reply", + "--conversation-id", "cid-1", "--message-id", "msg-1", + "--ref-sender", "D-sender", "--text", "收到", + "--idempotency-key", "reply-key", "--yes", + ) + if err != nil { + t.Fatalf("reply failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr) + } + calls := snapshot() + if len(calls) != 1 || calls[0].tool != "send_personal_message" { + t.Fatalf("reply calls = %#v", calls) + } + var payload map[string]any + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatalf("reply output is not JSON: %v\n%s", err, stdout) + } + if payload["contractVersion"] != "im.message-reply.v1" || payload["messageId"] != "sent-msg-1" || + payload["conversationId"] != "cid-1" || payload["idempotencyKey"] != "reply-key" { + t.Fatalf("reply continuation = %#v", payload) + } + }) +} + +func multiIMMockResponse(tool string, arguments map[string]any, mcpBaseURL, resourceURL string) string { + switch tool { + case "search_contact_by_key_word": + if arguments["keyword"] == "同名用户" { + return `{"result":[{"name":"同名用户","userId":"u1","openDingTalkId":"D1"},{"name":"同名用户","userId":"u2","openDingTalkId":"D2"}]}` + } + return `{"result":[{"name":"张三","userId":"user-1","openDingTalkId":"D-user-1"}]}` + case "search_groups": + if arguments["keyword"] == "资源群" { + return `{"result":[{"title":"资源群","openConversationId":"cid-resources"}]}` + } + return `{"result":[{"title":"项目群","openConversationId":"cid-1"}]}` + case "list_conversation_message_v2": + if arguments["openconversation_id"] == "cid-resources" { + return `{"result":{"hasMore":false,"messages":[{"openMessageId":"msg-resource","openConversationId":"cid-resources","sender":{"name":"张三","openDingTalkId":"D-sender","senderType":"user"},"msgType":"file","content":"{\"mediaId\":\"@media-1\"}","createTime":"2026-08-03 10:00:00"}]}}` + } + return `{"result":{"hasMore":false,"messages":[{"openMessageId":"msg-1","openConversationId":"cid-1","sender":{"name":"张三","openDingTalkId":"D-sender","senderType":"user"},"msgType":"text","content":"进度正常","createTime":"2026-08-03 10:00:00"}]}}` + case "get_resource_download_url": + body, _ := json.Marshal(map[string]any{"result": map[string]any{ + "resourceUrl": resourceURL, + "fileName": "artifact.bin", + }}) + return string(body) + case "init_conversation_file_upload": + body, _ := json.Marshal(map[string]any{ + "resourceUrl": mcpBaseURL + "/upload/conversation", + "uploadKey": "upload-key-1", + }) + return string(body) + case "commit_conversation_file_upload": + return `{"result":{"dentryId":101,"spaceId":202}}` + case "search_messages": + if arguments["keyword"] == "分页失败" && fmt.Sprint(arguments["cursor"]) == "c2" { + return `{"success":false,"code":"MOCK_PAGE_FAILURE","message":"simulated second page failure"}` + } + return `{"result":{"messages":[{"openMessageId":"search-msg-1","openConversationId":"cid-1","senderOpenDingTalkId":"D-sender","content":"first page"}],"hasMore":true,"nextCursor":"c2"}}` + case "get_current_user_profile": + return `{"result":[{"orgEmployeeModel":{"userId":"self-user"}}]}` + case "create_group_conversation": + return `{"result":{"openCid":"created-cid"}}` + case "send_personal_message": + return `{"result":{"openMessageId":"sent-msg-1","sendStatus":"accepted"}}` + default: + return `{"success":true}` + } +} + +func recordedToolNames(calls []recordedToolCall) []string { + names := make([]string, 0, len(calls)) + for _, call := range calls { + names = append(names, call.tool) + } + return names +} + +type trustedDownloadFixture struct { + URL string + ProxyURL string + CAFile string +} + +func newTrustedDownloadFixture(t *testing.T, body []byte) trustedDownloadFixture { + t.Helper() + caKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + now := time.Now() + caTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "DWS Multi IM Test CA"}, + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + } + caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) + if err != nil { + t.Fatal(err) + } + caCertificate, err := x509.ParseCertificate(caDER) + if err != nil { + t.Fatal(err) + } + leafKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + leafTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "download.dingtalk.com"}, + DNSNames: []string{"download.dingtalk.com"}, + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, caCertificate, &leafKey.PublicKey, caKey) + if err != nil { + t.Fatal(err) + } + leafPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER}) + leafPEM = append(leafPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})...) + privateKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(leafKey)}) + certificate, err := tls.X509KeyPair(leafPEM, privateKeyPEM) + if err != nil { + t.Fatal(err) + } + + downloadServer := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(body) + })) + downloadServer.TLS = &tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12} + downloadServer.StartTLS() + t.Cleanup(downloadServer.Close) + + proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodConnect { + http.Error(w, "CONNECT required", http.StatusMethodNotAllowed) + return + } + upstream, err := net.DialTimeout("tcp", downloadServer.Listener.Addr().String(), 2*time.Second) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + hijacker, ok := w.(http.Hijacker) + if !ok { + _ = upstream.Close() + http.Error(w, "hijacking unsupported", http.StatusInternalServerError) + return + } + client, buffered, err := hijacker.Hijack() + if err != nil { + _ = upstream.Close() + return + } + _, _ = fmt.Fprint(buffered, "HTTP/1.1 200 Connection Established\r\n\r\n") + _ = buffered.Flush() + go func() { + _, _ = io.Copy(upstream, client) + _ = upstream.Close() + }() + _, _ = io.Copy(client, upstream) + _ = client.Close() + })) + t.Cleanup(proxyServer.Close) + + caFile := filepath.Join(t.TempDir(), "download-ca.pem") + caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}) + if err := os.WriteFile(caFile, caPEM, 0o600); err != nil { + t.Fatal(err) + } + return trustedDownloadFixture{ + URL: "https://download.dingtalk.com/artifact.bin", + ProxyURL: proxyServer.URL, + CAFile: caFile, + } +} + +func assertMultiIMCallsAreLocalAndAuthorized(t *testing.T, calls []recordedToolCall) { + t.Helper() + for _, call := range calls { + if call.err != nil { + t.Fatal(call.err) + } + if call.method != http.MethodPost || call.jsonrpc != "2.0" || call.authorization != "Bearer ci-smoke-token" { + t.Fatalf("invalid local JSON-RPC call = %#v", call) + } + if !strings.HasPrefix(call.path, "/mcp/") { + t.Fatalf("unexpected MCP path = %q", call.path) + } + } +} + func TestMockMCPSmoke_ChatDownloadMediaJSONCLI(t *testing.T) { type mediaRequest struct { method string @@ -388,6 +928,10 @@ func isolatedCLIEnv(t *testing.T, extra map[string]string) []string { } func runCLI(t *testing.T, env []string, args ...string) (string, string, error) { + return runCLIInDir(t, env, "", args...) +} + +func runCLIInDir(t *testing.T, env []string, dir string, args ...string) (string, string, error) { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -395,6 +939,7 @@ func runCLI(t *testing.T, env []string, args ...string) (string, string, error) processArgs := append([]string{"-test.run=^TestCLIHelperProcess$", "--"}, args...) cmd := exec.CommandContext(ctx, os.Args[0], processArgs...) cmd.Env = env + cmd.Dir = dir var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr