Compare commits

...
Author SHA1 Message Date
Dennis e2c15fe9c8 fix(schema): reject breaking constraint expansion 2026-08-05 14:56:18 +08:00
Dennis 9fdf0d2cb3 fix(im): preserve incomplete read failures 2026-08-05 14:27:53 +08:00
Dennis b6325a4f8a fix(im): harden user resolution and broadcast lookup 2026-08-05 13:57:27 +08:00
Dennis 2588c711a7 Merge origin/main into codex/multi-im-optimization 2026-08-05 11:24:58 +08:00
john 000bc13450 Merge pull request #830 from typefield/agent/cmdcore-phase2
feat(corecmd): unify leaf/shortcut command framework onto a shared typed base
2026-08-05 10:03:21 +08:00
Dennis 01782cd9d7 test(profilectx): cover profile selector 2026-08-05 09:35:57 +08:00
玉澜andCursor ba56b7ff78 fix(test): use testseam.Protect in InitDepsForTest coverage
Policy bans manual t.Cleanup deps restores; Protect is the required seam form.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 02:03:51 +08:00
玉澜andCursor c5c97e60f3 test(coverage): cover InitDepsForTest and StaticServers visibility
Platform coverage only selects TestCrossPlatformCoverage*; name the new
declaration-only visibility regression accordingly and exercise the ForTest
deps restore helper.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 01:49:37 +08:00
玉澜andCursor 6780177356 fix(test): assert non-dry-run version preflight via CallTool
Outside --dry-run, list_doc_versions uses the normal CallTool channel;
CallReadTool is reserved for the dry-run read path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 01:30:17 +08:00
玉澜andCursor 5fa40b8ada fix: keep Schema source visibility without clobbering deps
Declaration-only roots skip injectStaticServers; derive product visibility
from StaticServers directly so reverse completeness is not weakened, and
restore helpers deps via pointer snapshot instead of InitDeps(nil).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 01:22:45 +08:00
玉澜andCursor 8a7d4a7027 fix: nil-safe doc deprecation wrappers for declaration-only Schema probes
Homology Execute probes use NewSchemaSourceRootCommand (no InitDeps); skip
deps.Out when unset and InitDeps a throwaway caller on the probe path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 00:18:46 +08:00
玉澜andCursor 894e550950 fix: keep schema source root from clobbering runtime deps/endpoints
Schema assembly must mount the reviewed command tree without InitDeps or
SetDynamicServers, so a live process keeps its ToolCaller and plugin
endpoints. Also restore doc version revert dry-run short-circuit so
--dry-run skips remote version preflight.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 23:42:05 +08:00
Dennis ad4ed41559 Merge origin/main into codex/multi-im-optimization 2026-08-04 22:23:31 +08:00
玉澜andCursor bb205c0f5c ci: align macOS auth filter with main
Drop Windows-only DPAPI export/import names from the darwin -run filter;
those tests skip on macOS and already run under the Windows job.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 22:14:48 +08:00
Dennis 86f9054d5b test(chat): cover batch output failures 2026-08-04 22:13:20 +08:00
玉澜andCursor 82c6b631bf Merge origin/main into agent/cmdcore-phase2
Resolve macOS CI auth/keychain job conflicts by keeping main's focused
6m keychain/auth split and unsigned-darwin self-heal filter, while retaining
this branch's portable DPAPI export/import diagnostics coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 22:11:33 +08:00
github-actions[bot] 306c30ecad Merge pull request #857 from DingTalk-Real-AI/codex/fix-macos-auth-test-scope
ci: avoid duplicate internal/app race suite on macOS
2026-08-04 22:07:20 +08:00
玉澜andCursor f793d980b5 fix(test): align leaf BuildArgs expectations with required-after-transform
TestLeafArgsOmitsEmptyAndNonPositive called BuildArgs with unset required
CSV flags; after rejecting empty required transforms that path correctly
errors. Satisfy required flags in the omit-empty case and cover the new
RequiredError / scalar-transform branches for the macOS coverage gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 21:54:02 +08:00
玉澜andCursor 706dbb349c fix(corecmd): reject required flags that transform to empty lists
Separator-only inputs like --event-codes ',' passed pre-transform Required
checks, then BuildArgs omitted the key and ConfirmFirst write paths could
still call MCP. Enforce non-empty transform results for Required flags and
add CrossPlatformCoverage regression coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 21:38:40 +08:00
玉澜andCursor 45d0d1cd72 test(ci): reject restoring combined macOS app race shard
Keep the darwin workflow contract from re-accepting the merged
keychain+auth+app invocation that main briefly required.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 21:06:33 +08:00
玉澜andCursor e9a2360d36 fix(ci): align darwin workflow contract with auth/keychain split
Keep the changelog gate asserting the focused macOS auth/keychain job
instead of main's combined keychain/auth/app race command.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 21:05:17 +08:00
玉澜andCursor 9531dad9c6 Merge origin/main into agent/cmdcore-phase2
Port OA approval form-schema / forecast-process / create-instance from
main via DeclareLeafMetadata and mapping-ledger exclusions; keep retired
schema pin paths deleted and preserve the CI auth/keychain split.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 20:52:53 +08:00
chichuan 20d27f6db9 Merge latest main into codex/fix-macos-auth-test-scope
Resolve the macOS race budget conflict in favour of the focused scope.

c1f96241 on main extended the whole-package macOS race step from 10m to
12m and pinned that budget in the workflow contract. This branch removes
the whole-package run instead: ./internal/app is already covered by the
Ubuntu "race: app" shard, and macOS only needs the natively-gated tests.
With the focused scope the job drops from 10m47s to ~3m, so the 12m
budget is no longer needed and the two step timeouts (6m + 5m) fit inside
the 15m job budget with headroom.

The contract assertion c1f96241 added is superseded rather than dropped:
pinning both focused commands locks the per-step timeouts, and the
existing checks still block a whole-package regression and require
./internal/app to appear exactly once.
2026-08-04 20:14:58 +08:00
github-actions[bot] ca2b8adcb2 Merge pull request #853 from DingTalk-Real-AI/codex/sync-wukong-oa-approval
feat(oa): add approval form workflow commands
2026-08-04 20:05:58 +08:00
Dennis c4d5139a50 fix(chat): fail batch sends on delivery errors 2026-08-04 19:43:22 +08:00
Dennis 867f20abec test(chat): cover unsafe group files on Windows 2026-08-04 19:22:58 +08:00
Dennis 7c07b29de5 ci: retain failed Windows coverage profiles 2026-08-04 19:06:50 +08:00
Dennis 6ee0df8a9c test(chat): cover Windows drive-relative paths 2026-08-04 18:53:24 +08:00
Dennis 33ceab6000 test(chat): keep platform replace alias coverage-neutral 2026-08-04 18:36:39 +08:00
Dennis 26b06fe0ff fix(chat): replace exports atomically on Windows 2026-08-04 18:18:08 +08:00
chichuan a3c7009f9b Merge latest main into codex/fix-macos-auth-test-scope 2026-08-04 18:17:33 +08:00
chichuan 2d3f820f91 ci: keep the darwin-gated upgrade self-heal test reachable on macOS
Narrowing the macOS internal/app -run pattern orphaned
TestValidateNewBinary_RecoversFromUnsignedDarwin: it is the only
runtime.GOOS != "darwin" gated test in the package, the Ubuntu race shard
skips it on Linux, and the platform coverage gate only runs
^(TestAllShortcuts|TestCrossPlatformCoverage). No CI job selected it any
more, so it could never run or fail again.

- Add the self-heal test back to the macOS -run pattern.
- Add the (CrossPlatformCoverage)? group the Windows pattern already has,
  which also recovers TestCrossPlatformCoverageAuthMigrateKeychainRemainingBranches.
- Attribute vacuous runs: the two skip paths now name the branch that went
  unverified, and DWS_REQUIRE_AMFI_SELF_HEAL=1 escalates such a run to a
  hard failure on a host that does enforce amfid. GitHub's hosted macOS
  runners do not reproduce the amfid kill, so this test has been silently
  skipping there all along.
- Restore step-timeout headroom: 10m + 5m exactly equalled the 15m job
  budget, leaving none for setup. The keychain/auth step drops to 6m
  (measured 2m43s).
- Add a contract test that couples the macOS -run pattern to the set of
  darwin-gated tests in internal/app, so the next narrowing fails loudly
  instead of silently orphaning one.
2026-08-04 17:59:59 +08:00
chichuan f5a1b64d7a test(oa): include approval cases in native coverage 2026-08-04 17:36:22 +08:00
chichuan c1f96241af ci: extend macOS race test budget 2026-08-04 17:23:04 +08:00
玉澜andCursor eb63b3933e test(schema): restore interface metadata fallback coverage for overall gate
Cover applyInterfaceMetadataFallback success/audit paths and summary edges
that were lost when interface_metadata_test.go was retired, closing the
aggregate overall non-regression gap (~91.22% → above merge-base).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 17:19:51 +08:00
Dennis 7a5582f4f9 fix(ci): make multi IM coverage platform-safe 2026-08-04 17:05:59 +08:00
chichuan eb571e6e73 fix(oa): remove unreachable mode checks 2026-08-04 16:42:57 +08:00
Dennis f050fbdebc Merge latest main into codex/multi-im-optimization 2026-08-04 16:17:01 +08:00
chichuan 4d5a47ac93 Merge latest main into codex/sync-wukong-oa-approval 2026-08-04 16:16:21 +08:00
Dennis e27dc9fe53 fix(schema): close chat runtime contract review 2026-08-04 16:09:54 +08:00
chichuan 6108f51c9d fix(oa): align approval mode schema contracts 2026-08-04 16:08:51 +08:00
Dennis 85587b9b62 fix(schema): preserve published chat constraints 2026-08-04 15:31:10 +08:00
Dennis 10d93f310e fix(schema): restore chat compatibility gates 2026-08-04 15:18:04 +08:00
Dennis 99478c0060 feat(schema): publish complete chat shortcut catalog 2026-08-04 14:24:49 +08:00
chichuan 31e3f6bcbc Merge remote-tracking branch 'origin/main' into codex/sync-wukong-oa-approval
# Conflicts:
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog/catalog.json
2026-08-04 11:41:38 +08:00
chichuan 678f108adf docs(oa): complete approval workflow references 2026-08-04 11:39:55 +08:00
Dennis ee943d9b3f Merge remote-tracking branch 'origin/main' into codex/multi-im-optimization
# Conflicts:
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog/catalog.json
#	internal/cli/schema_hints/runtime-surface-completeness.json
#	test/mock_mcp/mock_mcp_smoke_test.go
2026-08-04 11:25:36 +08:00
Dennis a3c85a01a8 chore(im): sanitize pull request fixtures 2026-08-04 11:19:38 +08:00
Dennis 81f67c8d7b fix(im): classify failures and normalize chat targets 2026-08-04 11:06:35 +08:00
Raph a240ad2b81 ci: focus macOS auth race tests 2026-08-03 23:09:58 +08:00
chichuan f39a3f5417 Merge branch 'main' into codex/sync-wukong-oa-approval 2026-08-03 21:18:39 +08:00
Dennis 30314311e4 fix(im): harden live audit contracts 2026-08-03 20:39:27 +08:00
Dennis 2a8c6c87cb feat(im): harden multi IM golden routes 2026-08-03 19:50:10 +08:00
chichuan e806e761ad test(oa): cover approval request branches 2026-08-03 19:48:26 +08:00
chichuan a6696fe9e9 fix(schema): map OA request wrappers 2026-08-03 19:26:22 +08:00
chichuan 06af21c7a1 fix(oa): complete approval instance options 2026-08-03 19:09:51 +08:00
chichuan eba2b692ec feat(oa): add approval form workflow commands 2026-08-03 19:02:39 +08:00
Dennis 017258e5b4 feat(im): optimize Multi IM golden routes
Unify natural target resolution and message contracts, add deterministic IM event listening, streamline cold-start skills, and cover the flows with schema gates and end-to-end tests.
2026-08-03 14:54:19 +08:00
154 changed files with 14499 additions and 3748 deletions
+5 -7
View File
@@ -702,14 +702,11 @@ jobs:
with:
go-version-file: go.mod
# Full ./internal/app (including schema --all assembly) already runs in the
# race:app shard. Keep this job focused on native auth/keychain paths so
# heavy Schema completeness tests cannot exhaust the 10m budget.
- name: Test macOS auth and Keychain paths with Race Detection
run: go test -v -race -count=1 -timeout=10m ./internal/keychain ./internal/auth
- name: Test macOS auth and Keychain packages with Race Detection
run: go test -v -race -count=1 -timeout=6m ./internal/keychain ./internal/auth
- name: Test macOS auth migration and portable auth diagnostics
run: go test -v -race -count=1 -timeout=5m ./internal/app -run '^Test(CrossPlatformCoverage)?Auth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch|ExportRejectsWindowsDPAPIBackend|ImportRejectsWindowsDPAPIBackend)'
- name: Test macOS auth migration, Keychain diagnostics, and upgrade self-heal with Race Detection
run: go test -v -race -count=1 -timeout=5m ./internal/app -run '^(TestValidateNewBinary_RecoversFromUnsignedDarwin|Test(CrossPlatformCoverage)?Auth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch))'
test-windows:
name: Test (Windows)
@@ -820,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
+6 -1
View File
@@ -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=<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
+6 -3
View File
@@ -40,7 +40,7 @@ Every command inherits these flags (documented here once, not repeated per comma
- [`dws doc` — DingTalk Doc](#dws-doc) · 21 commands
- [`dws drive` — DingTalk Drive](#dws-drive) · 6 commands
- [`dws minutes` — AI Minutes](#dws-minutes) · 19 commands
- [`dws oa` — OA Approval](#dws-oa) · 9 commands
- [`dws oa` — OA Approval](#dws-oa) · 12 commands
- [`dws report` — Reports](#dws-report) · 7 commands
- [`dws todo` — Todo Tasks](#dws-todo) · 6 commands
@@ -277,14 +277,17 @@ _AI meeting notes: listing, summary, todos, transcription, recording control, mi
## `dws oa` — OA Approval
_OA approval workflows: list, approve, reject, revoke, records._
_OA approval workflows: inspect forms, forecast routes, create instances, approve, reject, revoke, and audit records._
**9 commands**
**12 commands**
| Command | Description | When to use |
|---|---|---|
| `dws oa approval approve` | Approve a pending approval process instance (task) as the current user. | When the agent acts on a pending approval the user has delegated it to handle. |
| `dws oa approval create-instance` | Create a real approval process instance from validated form values or a complete request payload. | After the agent has inspected the form Schema, forecast the route, resolved any selectable approvers, and obtained explicit user confirmation. |
| `dws oa approval detail` | Retrieve full details of an approval process instance, including form fields, attachments, and state. | When the agent needs to read the content of an approval ticket before deciding on it or summarizing it. |
| `dws oa approval form-schema` | Retrieve the form Schema for an approval template by processCode. | Before collecting or validating values for a new approval instance. |
| `dws oa approval forecast-process` | Forecast the approval route for a template and its proposed form values. | Before creating an instance, especially when the route contains user-selectable approver or notifier nodes. |
| `dws oa approval list-forms` | List approval process templates (forms) the current user is allowed to initiate. | When the agent needs to pick the right approval form before submitting a new request. |
| `dws oa approval list-initiated` | List approval process instances the current user has initiated. | When the agent reviews the status of approvals the user submitted. |
| `dws oa approval list-pending` | List approval process instances currently awaiting action from the current user. | When the agent surfaces "needs your approval" items in the user's inbox. |
+1
View File
@@ -99,6 +99,7 @@ func newEventCommand() *cobra.Command {
RunE: func(c *cobra.Command, _ []string) error { return c.Help() },
}
cmd.AddCommand(
newEventListenIMCommand(),
newEventConsumeCommand(),
newEventListCommand(),
newEventSchemaCommand(),
+295
View File
@@ -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 <openConversationId> --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
}
+360
View File
@@ -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)
}
})
}
+3 -3
View File
@@ -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)
+13 -3
View File
@@ -27,9 +27,11 @@ import (
"github.com/spf13/cobra"
)
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller, loadUserShortcuts bool) []*cobra.Command {
injectStaticServers()
helpers.InitDeps(caller)
// mountLegacyPublicCommands builds the product + shortcut command tree without
// mutating process-global MCP deps or dynamic server endpoints. Used by the
// Schema source root (declaration-only) path so assembly cannot clobber a live
// runtime's InitDeps caller or plugin endpoints.
func mountLegacyPublicCommands(runner executor.Runner, loadUserShortcuts bool) []*cobra.Command {
commands := helpers.NewPublicCommands(runner)
// Load user-defined shortcuts (~/.dws/shortcuts/*.yaml) BEFORE compiling the
// command tree, so distilled high-frequency operations mount alongside the
@@ -51,6 +53,14 @@ func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller,
return mergeTopLevelCommands(commands)
}
// newLegacyPublicCommands is the executable CLI path: inject static MCP
// endpoints, InitDeps, then mount the public command tree.
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller, loadUserShortcuts bool) []*cobra.Command {
injectStaticServers()
helpers.InitDeps(caller)
return mountLegacyPublicCommands(runner, loadUserShortcuts)
}
func injectStaticServers() {
hooks := edition.Get()
var servers []edition.ServerInfo
@@ -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"},
@@ -119,6 +121,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"},
@@ -129,6 +132,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"},
@@ -169,7 +173,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)
@@ -220,6 +224,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)
}
@@ -252,7 +257,7 @@ func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads
}
}
func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
activeAliases := 0
for _, test := range paramAliasNewIMCases {
test := test
@@ -292,6 +297,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)
}
@@ -302,6 +308,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 {
+14 -5
View File
@@ -374,19 +374,21 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command {
if len(ctx) > 0 && ctx[0] != nil {
rootCtx = ctx[0]
}
return newRootCommandWithEngine(rootCtx, nil, true)
return newRootCommandWithEngine(rootCtx, nil, true, false)
}
// NewSchemaSourceRootCommand constructs the distribution-owned command tree
// used as the Schema assembly source root (RegisterSchemaSourceRoot →
// ResolveSchemaBuild) and by command-surface policy. Installed plugins and
// user-defined shortcuts must not change the reviewed Schema surface.
// declarationOnly skips injectStaticServers / helpers.InitDeps so Schema
// assembly cannot clobber a live process's ToolCaller or plugin endpoints.
func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command {
var rootCtx context.Context
if len(ctx) > 0 && ctx[0] != nil {
rootCtx = ctx[0]
}
return newRootCommandWithEngine(rootCtx, nil, false)
return newRootCommandWithEngine(rootCtx, nil, false, true)
}
// NewRootCommandWithEngine constructs the root CLI command with an
@@ -394,10 +396,10 @@ func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command {
// no pipeline processing is applied.
func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command {
registerSchemaRuntimeDelivery()
return newRootCommandWithEngine(rootCtx, engine, true)
return newRootCommandWithEngine(rootCtx, engine, true, false)
}
func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, loadRuntimeExtensions bool) *cobra.Command {
func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, loadRuntimeExtensions bool, declarationOnly bool) *cobra.Command {
if rootCtx == nil {
rootCtx = context.Background()
}
@@ -488,7 +490,14 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
}
root.AddCommand(utilityCommands...)
root.AddCommand(newLegacyPublicCommands(runner, patCaller, loadRuntimeExtensions)...)
if declarationOnly {
// Schema / surface assembly: mount the reviewed tree only. Do not
// injectStaticServers or InitDeps — those mutate process globals and
// would clobber a live runtime's caller and plugin endpoints.
root.AddCommand(mountLegacyPublicCommands(runner, loadRuntimeExtensions)...)
} else {
root.AddCommand(newLegacyPublicCommands(runner, patCaller, loadRuntimeExtensions)...)
}
// PAT authorization commands (open-source core)
pat.RegisterCommands(root, patCaller)
+17 -3
View File
@@ -163,9 +163,16 @@ func commandShort(cmd *cobra.Command) string {
// resolveVisibleProducts returns the set of top-level product IDs that should
// be treated as visible. It unions the edition's VisibleProducts hook (when
// set) with DirectRuntimeProductIDs(), so dynamically-registered products —
// including plugins loaded via AppendDynamicServer — are never silently hidden
// by a static VisibleProducts list.
// set), StaticServers product IDs, and DirectRuntimeProductIDs(), so
// dynamically-registered products — including plugins loaded via
// AppendDynamicServer — are never silently hidden by a static VisibleProducts
// list.
//
// StaticServers are consulted directly (without injectStaticServers) so the
// declaration-only Schema source root can keep reviewed products visible
// without mutating the process-global dynamic endpoint registry.
// SupplementServers stay out of this set: they are helper-only endpoints and
// must not synthesize top-level product visibility.
func resolveVisibleProducts() map[string]bool {
allowed := map[string]bool{}
if fn := edition.Get().VisibleProducts; fn != nil {
@@ -173,6 +180,13 @@ func resolveVisibleProducts() map[string]bool {
allowed[p] = true
}
}
if fn := edition.Get().StaticServers; fn != nil {
for _, server := range fn() {
if id := strings.TrimSpace(server.ID); id != "" {
allowed[id] = true
}
}
}
for id := range DirectRuntimeProductIDs() {
allowed[id] = true
}
+41 -14
View File
@@ -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
@@ -0,0 +1,76 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"strings"
"testing"
)
func TestOAApprovalDualModeConstraintsReachEmbeddedSchema(t *testing.T) {
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
tests := []struct {
canonical string
optional []string
requireTogether []string
mutuallyExclusive []string
}{
{
canonical: "oa.forecast_process",
optional: []string{"request", "process-code", "dept-id", "form-values"},
requireTogether: []string{"process-code", "dept-id", "form-values"},
mutuallyExclusive: []string{"process-code", "dept-id", "form-values"},
},
{
canonical: "oa.start_process_instance",
optional: []string{"request", "process-code", "dept-id", "form-values", "originator-user-id", "approvers", "approvers-action-type", "cc-list", "cc-position"},
requireTogether: []string{"process-code", "form-values"},
mutuallyExclusive: []string{"process-code", "dept-id", "form-values", "originator-user-id", "approvers", "approvers-action-type", "cc-list", "cc-position"},
},
}
for _, test := range tests {
t.Run(test.canonical, func(t *testing.T) {
tool := tools[test.canonical]
parameters := schemaContractMap(tool["parameters"])
for _, name := range test.optional {
if got := parameters[name]["required"]; got != false {
t.Errorf("--%s required = %#v, want false for dual-mode command", name, got)
}
}
assertSchemaContractConstraintGroup(t, tool, "require_one_of", []string{"request", "process-code"})
assertSchemaContractConstraintGroup(t, tool, "require_together", test.requireTogether)
for _, name := range test.mutuallyExclusive {
assertSchemaContractConstraintGroup(t, tool, "mutually_exclusive", []string{"request", name})
}
constraints, _ := tool["constraints"].(map[string]any)
groups, _ := constraints["mutually_exclusive"].([]any)
if len(groups) != len(test.mutuallyExclusive) {
t.Errorf("mutually_exclusive group count = %d, want %d: %#v", len(groups), len(test.mutuallyExclusive), groups)
}
for _, rawGroup := range groups {
group, _ := rawGroup.([]any)
if len(group) != 2 {
t.Errorf("mutually_exclusive contains an over-broad group: %#v", group)
}
}
hasRequestOnlyExample := false
for _, example := range schemaContractStringSlice(tool["examples"]) {
if strings.Contains(example, " --request ") && !strings.Contains(example, " --process-code ") && !strings.Contains(example, " --form-values ") {
hasRequestOnlyExample = true
}
}
if !hasRequestOnlyExample {
t.Errorf("examples do not contain a request-only invocation: %#v", tool["examples"])
}
})
}
create := tools["oa.start_process_instance"]
if got := schemaContractString(create["confirmation"]); got != "user_required" {
t.Errorf("create-instance confirmation = %q, want user_required", got)
}
}
@@ -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)
}
}
@@ -0,0 +1,163 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"context"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
// schemaSourceRootMarkerCaller is a recognizable ToolCaller used to detect
// InitDeps clobbering when NewSchemaSourceRootCommand builds the tree.
type schemaSourceRootMarkerCaller struct{}
func (schemaSourceRootMarkerCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return &edition.ToolResult{}, nil
}
func (schemaSourceRootMarkerCaller) Format() string { return "json" }
func (schemaSourceRootMarkerCaller) DryRun() bool { return false }
func (schemaSourceRootMarkerCaller) Fields() string { return "" }
func (schemaSourceRootMarkerCaller) JQ() string { return "" }
// TestSchemaSourceRootPreservesRuntimeDepsAndPluginEndpoints ensures Schema
// assembly (NewSchemaSourceRootCommand / ResolveMeta delivery) does not call
// helpers.InitDeps or SetDynamicServers, which would wipe a live process's
// caller and plugin endpoints.
func TestSchemaSourceRootPreservesRuntimeDepsAndPluginEndpoints(t *testing.T) {
const (
pluginID = "schema-source-root-plugin-marker"
pluginEndpoint = "https://schema-source-root-plugin.example/mcp"
)
dynamicMu.RLock()
previousEndpoints := dynamicEndpoints
previousProducts := dynamicProducts
previousAliases := dynamicAliases
previousToolEndpoints := dynamicToolEndpoints
dynamicMu.RUnlock()
t.Cleanup(func() {
dynamicMu.Lock()
dynamicEndpoints = previousEndpoints
dynamicProducts = previousProducts
dynamicAliases = previousAliases
dynamicToolEndpoints = previousToolEndpoints
dynamicMu.Unlock()
})
marker := schemaSourceRootMarkerCaller{}
helpers.InitDepsForTest(t, marker)
SetDynamicServers(nil)
AppendDynamicServer(mcptypes.ServerDescriptor{
Key: pluginID,
Endpoint: pluginEndpoint,
CLI: mcptypes.CLIOverlay{
ID: pluginID,
Command: pluginID,
},
})
root := NewSchemaSourceRootCommand()
if root == nil {
t.Fatal("NewSchemaSourceRootCommand returned nil")
}
if got := helpers.GetCaller(); got != marker {
t.Fatalf("helpers.GetCaller() after NewSchemaSourceRootCommand = %T (%p), want marker caller preserved", got, got)
}
gotEndpoint, ok := directRuntimeEndpoint(pluginID, "")
if !ok || gotEndpoint != pluginEndpoint {
t.Fatalf("plugin endpoint after NewSchemaSourceRootCommand = %q ok=%v, want %q preserved", gotEndpoint, ok, pluginEndpoint)
}
resolved, err := cli.ResolveSchemaBuild(root)
if err != nil {
t.Fatalf("ResolveSchemaBuild after declaration-only root: %v", err)
}
if resolved.CommandCount() == 0 {
t.Fatal("ResolveSchemaBuild command count is 0")
}
// Delivery path also builds NewSchemaSourceRootCommand via the factory.
registerSchemaRuntimeDelivery()
meta, ok := cli.ResolveMeta("dev app delete")
if !ok || meta.Identity.Canonical == "" {
t.Fatalf("ResolveMeta after registerSchemaRuntimeDelivery = %#v ok=%v", meta, ok)
}
if got := helpers.GetCaller(); got != marker {
t.Fatalf("helpers.GetCaller() after ResolveMeta delivery = %T, want marker caller preserved", got)
}
gotEndpoint, ok = directRuntimeEndpoint(pluginID, "")
if !ok || gotEndpoint != pluginEndpoint {
t.Fatalf("plugin endpoint after ResolveMeta delivery = %q ok=%v, want %q preserved", gotEndpoint, ok, pluginEndpoint)
}
}
// TestCrossPlatformCoverageSchemaSourceRootStaticServerVisibilityWithoutInject
// proves the declaration-only Schema source root does not need
// injectStaticServers to keep StaticServers products visible. VisibleProducts
// may be unset; StaticServers alone must still prevent
// hideNonDirectRuntimeCommands from marking those products Hidden — without
// mutating dynamic endpoints.
func TestCrossPlatformCoverageSchemaSourceRootStaticServerVisibilityWithoutInject(t *testing.T) {
previous := edition.Get()
t.Cleanup(func() { edition.Override(previous) })
dynamicMu.RLock()
previousEndpoints := dynamicEndpoints
previousProducts := dynamicProducts
previousAliases := dynamicAliases
previousToolEndpoints := dynamicToolEndpoints
dynamicMu.RUnlock()
t.Cleanup(func() {
dynamicMu.Lock()
dynamicEndpoints = previousEndpoints
dynamicProducts = previousProducts
dynamicAliases = previousAliases
dynamicToolEndpoints = previousToolEndpoints
dynamicMu.Unlock()
})
edition.Override(&edition.Hooks{
Name: "schema-source-root-static-visibility",
StaticServers: func() []edition.ServerInfo {
return []edition.ServerInfo{
{ID: "", Name: "ignored-empty", Endpoint: "https://schema-source-root-static.example/empty"},
{ID: " ", Name: "ignored-blank", Endpoint: "https://schema-source-root-static.example/blank"},
{ID: "doc", Name: "Doc", Endpoint: "https://schema-source-root-static.example/doc"},
}
},
// VisibleProducts intentionally nil: declaration-only visibility must
// still derive from StaticServers without SetDynamicServers.
})
SetDynamicServers(nil)
root := NewSchemaSourceRootCommand()
var doc *cobra.Command
for _, cmd := range root.Commands() {
if cmd.Name() == "doc" {
doc = cmd
break
}
}
if doc == nil {
t.Fatal("doc command missing from Schema source root")
}
if doc.Hidden {
t.Fatal("declaration-only Schema source root hid doc despite StaticServers")
}
// Endpoint resolution may still read StaticServers directly; the invariant
// is that declaration-only construction must not mutate the dynamic registry.
dynamicMu.RLock()
_, injected := dynamicProducts["doc"]
dynamicMu.RUnlock()
if injected {
t.Fatal("declaration-only Schema source root must not inject StaticServers into dynamicProducts")
}
}
+105
View File
@@ -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
}
@@ -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)
}
}
+19 -3
View File
@@ -543,6 +543,13 @@ func TestIsLikelyAMFIKill(t *testing.T) {
// validateNewBinary recovers via repairDarwinBinary (ad-hoc codesign) and
// successfully re-executes the binary. We use go itself as a stand-in for the
// new dws binary — it's a real signed Mach-O we can strip and re-sign.
//
// GitHub's hosted macOS runners do not reproduce the amfid kill, so in CI this
// test reports a skip that names the unverified path rather than implying the
// self-heal was exercised. Set DWS_REQUIRE_AMFI_SELF_HEAL=1 on a host that does
// enforce amfid to turn such a vacuous run into a hard failure.
const requireAMFISelfHealEnv = "DWS_REQUIRE_AMFI_SELF_HEAL"
func TestValidateNewBinary_RecoversFromUnsignedDarwin(t *testing.T) {
if runtime.GOOS != "darwin" {
@@ -573,15 +580,24 @@ func TestValidateNewBinary_RecoversFromUnsignedDarwin(t *testing.T) {
t.Fatalf("strip signature: %v\n%s", err, out)
}
// Sanity: confirm direct exec is killed.
// Sanity: confirm direct exec is killed. When it is not, repairDarwinBinary
// never runs and the rest of this test proves nothing — say so.
if _, err := tryExecVersion(bin); err == nil {
t.Skip("unsigned binary executed without amfid kill — likely Intel Mac or SIP disabled")
const unverified = "amfid did not kill the unsigned binary, so repairDarwinBinary was NOT exercised"
if os.Getenv(requireAMFISelfHealEnv) == "1" {
t.Fatalf("%s (%s=1)", unverified, requireAMFISelfHealEnv)
}
t.Skipf("%s — host does not enforce amfid (Intel Mac, SIP disabled, or hosted runner)", unverified)
}
// validateNewBinary should self-heal and succeed.
if err := validateNewBinary(bin, "dev"); err != nil {
if strings.Contains(err.Error(), "signal: killed") {
t.Skipf("host security policy still rejects the ad-hoc signed test binary: %v", err)
const unverified = "host security policy still rejects the ad-hoc signed binary, so the self-heal outcome was NOT verified"
if os.Getenv(requireAMFISelfHealEnv) == "1" {
t.Fatalf("%s (%s=1): %v", unverified, requireAMFISelfHealEnv, err)
}
t.Skipf("%s: %v", unverified, err)
}
t.Fatalf("validateNewBinary did not recover: %v", err)
}
+3 -12
View File
@@ -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.
@@ -4,6 +4,7 @@
package homology
import (
"context"
"errors"
"fmt"
"io"
@@ -19,8 +20,22 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
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"
)
// homologyProbeCaller is a throwaway ToolCaller for Execute-based confirmation
// probes. Schema source roots stay declaration-only (no InitDeps); the probe
// path initializes deps locally so leaf RunE wrappers do not nil-deref.
type homologyProbeCaller struct{}
func (homologyProbeCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return &edition.ToolResult{}, nil
}
func (homologyProbeCaller) Format() string { return "json" }
func (homologyProbeCaller) DryRun() bool { return false }
func (homologyProbeCaller) Fields() string { return "" }
func (homologyProbeCaller) JQ() string { return "" }
// TestUserRequiredSafetyHomologyWithRuntimeGate proves Catalog Safety and the
// executable confirmation gate share one source for every live user_required leaf:
//
@@ -29,6 +44,12 @@ import (
// Sheet protect marker, or framework NewCommand/Shortcut RunE (verified by
// closed-stdin Execute → confirmation_required / 用户取消了操作 without --yes)
func TestUserRequiredSafetyHomologyWithRuntimeGate(t *testing.T) {
// Declaration-only Schema roots intentionally skip InitDeps. Probes Execute
// live leaves (including deprecated doc wrappers), so install a local
// throwaway caller without touching SetDynamicServers. Restore the prior
// deps pointer (including nil) — InitDeps(previousCaller) cannot.
helpers.InitDepsForTest(t, homologyProbeCaller{})
root := app.NewSchemaSourceRootCommand()
if root.PersistentFlags().Lookup("yes") == nil {
root.PersistentFlags().Bool("yes", false, "")
+12
View File
@@ -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{
+5 -1
View File
@@ -57,6 +57,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."},
@@ -255,7 +257,9 @@
{"command": "chat +unread-chats", "emitted": "limit", "expect": "count", "via": "override:scoped"},
{"command": "chat +unread-chats", "emitted": "size", "expect": "count", "via": "override:scoped"},
{"command": "chat category rename", "emitted": "name", "expect": "title", "via": "override:scoped"},
{"command": "chat message list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"}
{"command": "chat message list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"},
{"command": "chat +chat-messages", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"},
{"command": "chat +search-msg", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"}
]
}
}
+6
View File
@@ -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") {
-58
View File
@@ -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",
},
},
}
@@ -511,6 +511,17 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"minutes.record_resume --session-id": "Reviewed unpinned adapter: minutes.record_resume has no singular pinned interface_ref; --session-id is a CLI wrapper input and does not publish a direct interface property.",
"minutes.record_start --session-id": "Reviewed unpinned adapter: minutes.record_start has no singular pinned interface_ref; --session-id is a CLI wrapper input and does not publish a direct interface property.",
"minutes.record_stop --session-id": "Reviewed unpinned adapter: minutes.record_stop has no singular pinned interface_ref; --session-id is a CLI wrapper input and does not publish a direct interface property.",
"oa.forecast_process --dept-id": "Conditional request wrapper: --dept-id is encoded inside ProcessForecastPopRequest together with the other simple-mode flags, so it has no independent top-level MCP property.",
"oa.forecast_process --form-values": "Conditional request wrapper: --form-values is transformed into formComponentValues inside ProcessForecastPopRequest, so it has no independent top-level MCP property.",
"oa.forecast_process --process-code": "Conditional request wrapper: --process-code is encoded inside ProcessForecastPopRequest together with the other simple-mode flags, so it has no independent top-level MCP property.",
"oa.start_process_instance --approvers": "Conditional request wrapper: --approvers is transformed into an approvers array inside ProcessInstanceCreationPopRequest, so it has no independent top-level MCP property.",
"oa.start_process_instance --approvers-action-type": "Conditional request wrapper: --approvers-action-type only configures the generated approvers array inside ProcessInstanceCreationPopRequest, so it has no independent top-level MCP property.",
"oa.start_process_instance --cc-list": "Conditional request wrapper: --cc-list is encoded inside ProcessInstanceCreationPopRequest only when supplied, so it has no independent top-level MCP property.",
"oa.start_process_instance --cc-position": "Conditional request wrapper: --cc-position only configures the generated ccList inside ProcessInstanceCreationPopRequest, so it has no independent top-level MCP property.",
"oa.start_process_instance --dept-id": "Conditional request wrapper: --dept-id is encoded inside ProcessInstanceCreationPopRequest together with the other simple-mode flags, so it has no independent top-level MCP property.",
"oa.start_process_instance --form-values": "Conditional request wrapper: --form-values is transformed into formComponentValues inside ProcessInstanceCreationPopRequest, so it has no independent top-level MCP property.",
"oa.start_process_instance --originator-user-id": "Conditional request wrapper: --originator-user-id is encoded inside ProcessInstanceCreationPopRequest only when supplied, so it has no independent top-level MCP property.",
"oa.start_process_instance --process-code": "Conditional request wrapper: --process-code is encoded inside ProcessInstanceCreationPopRequest together with the other simple-mode flags, so it has no independent top-level MCP property.",
"pat.batch_grant --domain": "aggregate/two-stage wrapper: --domain is merged with all product/domain flags into productCodes for pat.batch_plan; only the plan's selectedScopes are later sent to pat.batch_grant.scopes",
"pat.batch_grant --domains": "aggregate/two-stage wrapper: --domains is merged with all product/domain flags into productCodes for pat.batch_plan; only the plan's selectedScopes are later sent to pat.batch_grant.scopes",
"pat.batch_grant --product": "aggregate/two-stage wrapper: --product is merged with all product/domain flags into productCodes for pat.batch_plan; only the plan's selectedScopes are later sent to pat.batch_grant.scopes",
+45 -11
View File
@@ -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)
}
}
@@ -885,7 +890,21 @@ func BuildArgs(cmd *cobra.Command, flags []FlagSpec) (map[string]any, error) {
if err != nil {
return nil, err
}
if value == nil {
// Required is checked on the pre-transform string. A transform that
// collapses separator-only input ("," / ";") to nil/empty must still
// fail required flags locally — otherwise BuildArgs would omit the
// key and ConfirmFirst write paths could reach the backend.
if value == nil || emptyTransformResult(value) {
if flag.Required {
message := strings.TrimSpace(flag.RequiredHint)
if message == "" {
message = strings.TrimSpace(flag.RequiredError)
}
if message == "" {
message = fmt.Sprintf("必填参数 --%s 不能为空", flag.Name)
}
return nil, apperrors.NewValidation(message)
}
continue
}
toolArgs[bind] = value
@@ -896,6 +915,21 @@ func BuildArgs(cmd *cobra.Command, flags []FlagSpec) (map[string]any, error) {
return toolArgs, nil
}
// emptyTransformResult reports whether a Transform produced an empty payload
// that must not satisfy a Required flag (nil is handled by the caller).
func emptyTransformResult(value any) bool {
switch v := value.(type) {
case string:
return strings.TrimSpace(v) == ""
case []string:
return len(v) == 0
case []any:
return len(v) == 0
default:
return false
}
}
// EffectiveValue reads the value by "explicit main flag → alias → env →
// registration default" order (string form, integers uniformly formatted);
// Trim TrimSpace's the result.
+73 -4
View File
@@ -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 {
@@ -458,6 +463,70 @@ func TestCrossPlatformCoverageBuildArgsTransformAndErrors(t *testing.T) {
t.Fatalf("nil transform should skip key: %#v", args)
}
// Required + transform-to-empty must fail (separator-only lists)
requiredEmpty := []FlagSpec{{
Name: "codes", Usage: "C", Required: true, RequiredHint: "--codes 为必填",
Transform: func(string) (any, error) { return nil, nil },
}}
cmd = newTestCommand()
RegisterFlags(cmd, requiredEmpty)
_ = cmd.Flags().Set("codes", ",")
if _, err := BuildArgs(cmd, requiredEmpty); err == nil || !strings.Contains(err.Error(), "--codes 为必填") {
t.Fatalf("required empty transform err = %v", err)
}
requiredEmptySlice := []FlagSpec{{
Name: "codes", Usage: "C", Required: true,
Transform: func(string) (any, error) { return []string{}, nil },
}}
cmd = newTestCommand()
RegisterFlags(cmd, requiredEmptySlice)
_ = cmd.Flags().Set("codes", ";")
if _, err := BuildArgs(cmd, requiredEmptySlice); err == nil || !strings.Contains(err.Error(), "必填参数 --codes 不能为空") {
t.Fatalf("required empty-slice transform err = %v", err)
}
requiredEmptyString := []FlagSpec{{
Name: "codes", Usage: "C", Required: true,
Transform: func(string) (any, error) { return " ", nil },
}}
cmd = newTestCommand()
RegisterFlags(cmd, requiredEmptyString)
_ = cmd.Flags().Set("codes", ",")
if _, err := BuildArgs(cmd, requiredEmptyString); err == nil || !strings.Contains(err.Error(), "必填参数 --codes 不能为空") {
t.Fatalf("required empty-string transform err = %v", err)
}
requiredEmptyAnySlice := []FlagSpec{{
Name: "codes", Usage: "C", Required: true,
Transform: func(string) (any, error) { return []any{}, nil },
}}
cmd = newTestCommand()
RegisterFlags(cmd, requiredEmptyAnySlice)
_ = cmd.Flags().Set("codes", ",")
if _, err := BuildArgs(cmd, requiredEmptyAnySlice); err == nil || !strings.Contains(err.Error(), "必填参数 --codes 不能为空") {
t.Fatalf("required empty []any transform err = %v", err)
}
requiredErrorHint := []FlagSpec{{
Name: "codes", Usage: "C", Required: true, RequiredError: "codes missing after transform",
Transform: func(string) (any, error) { return nil, nil },
}}
cmd = newTestCommand()
RegisterFlags(cmd, requiredErrorHint)
_ = cmd.Flags().Set("codes", ",")
if _, err := BuildArgs(cmd, requiredErrorHint); err == nil || !strings.Contains(err.Error(), "codes missing after transform") {
t.Fatalf("required RequiredError transform err = %v", err)
}
// Non-empty non-list transform result is kept (covers emptyTransformResult default).
keepScalar := []FlagSpec{{
Name: "n", Usage: "N", Required: true, Bind: "count",
Transform: func(string) (any, error) { return 3, nil },
}}
cmd = newTestCommand()
RegisterFlags(cmd, keepScalar)
_ = cmd.Flags().Set("n", "x")
args, err = BuildArgs(cmd, keepScalar)
if err != nil || args["count"] != 3 {
t.Fatalf("scalar transform args = %#v err = %v", args, err)
}
// Transform error propagates
boom := errors.New("transform boom")
bad := []FlagSpec{{Name: "y", Usage: "Y", Transform: func(string) (any, error) { return nil, boom }}}
+66
View File
@@ -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)))
}
+38 -10
View File
@@ -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
@@ -69,4 +69,103 @@ func TestCrossPlatformCoverageInterfaceSummaryRemainingEdges(t *testing.T) {
if interfaceIdentifierOnly("含中文") {
t.Fatal("non-ASCII summary classified as identifier-only")
}
if !interfaceIdentifierOnly("search_open_platform-docs.v1") {
t.Fatal("identifier-only summary must be detected")
}
if got := summarizeInterfaceDescription("search_open_platform_docs", 40); got != "" {
t.Fatalf("identifier description = %q, want empty", got)
}
if got := summarizeInterfaceDescription("**查询日历。** 第二句不保留。", 40); got != "查询日历。" {
t.Fatalf("sentence summary = %q", got)
}
if got := summarizeInterfaceDescription("这是一个没有句号而且明显超过允许长度的接口描述文本", 12); got != "这是一个没有句号而..." {
t.Fatalf("truncated summary = %q", got)
}
if got := summarizeInterfaceDescription("First half, then a very long continuation without a terminal mark", 20); !strings.HasSuffix(got, "...") {
t.Fatalf("punctuation-cut summary = %q", got)
}
if got := interfaceSummarySource(interfaceMetadataFile{Source: " mcp-tools ", SourceRevision: "abcdef1234567890"}); got != "mcp-tools@abcdef123456" {
t.Fatalf("interfaceSummarySource = %q", got)
}
if got := interfaceSummarySource(interfaceMetadataFile{}); got != "mcp-interface" {
t.Fatalf("default interfaceSummarySource = %q", got)
}
}
func TestOverallCoverageGapInterfaceMetadataFallbackSuccessPath(t *testing.T) {
if err := applyInterfaceMetadataFallback(&File{}, nil, Options{}, &Stats{}, sourceTracker{}); err != nil {
t.Fatalf("empty InterfaceMetadataPath error = %v", err)
}
if hasSurfaceAgentSummary(File{Tools: map[string]ToolMetadata{
"blank": {AgentSummary: "", agentSummaryPresent: false},
}}, nil, "blank") {
t.Fatal("absent agent summary must not match")
}
if got := summarizeInterfaceDescription("first line\n\nsecond paragraph ignored", 80); got != "first line" {
t.Fatalf("paragraph break summary = %q", got)
}
display := filepath.ToSlash("interface.json")
body := `{
"version": 1,
"source": "mcp-tools-list+cli-registry",
"source_revision": "abcdef1234567890",
"source_hash": "sha256:interface",
"tools": {
"calendar.get": {
"description": "读取指定日历。",
"interface_ref": {"product_id": "calendar", "rpc_name": "get"}
},
"calendar.list": {
"description": "列出当前用户可访问的日历。后续句子不应进入 summary。"
},
"calendar.raw": {"description": "raw_tool_name"},
"outside.tool": {"description": "不在公开命令面"}
}
}`
files := map[string]sourceFile{display: {display: display, data: []byte(body)}}
out := &File{Tools: map[string]ToolMetadata{
"calendar.get": {AgentSummary: "已有摘要", agentSummaryPresent: true},
}}
stats := &Stats{}
if err := applyInterfaceMetadataFallback(out, files, Options{
InterfaceMetadataPath: "interface.json",
ToolPaths: map[string]string{
"calendar.get": "calendar get",
"calendar.list": "calendar list",
"calendar.raw": "calendar raw",
},
}, stats, sourceTracker{}); err != nil {
t.Fatal(err)
}
audit := stats.InterfaceMetadata
if audit == nil || audit.SourceTools != 4 || audit.SurfaceTools != 3 ||
audit.EligibleSummaries != 2 || audit.AppliedSummaries != 1 ||
audit.PreservedSummaries != 1 {
t.Fatalf("interface audit = %#v", audit)
}
if len(audit.RejectedTools) != 1 || audit.RejectedTools[0] != "calendar.raw" ||
len(audit.OutsideSurface) != 1 || audit.OutsideSurface[0] != "outside.tool" {
t.Fatalf("interface audit paths = %#v", audit)
}
list := out.Tools["calendar.list"]
if list.AgentSummary != "列出当前用户可访问的日历。" {
t.Fatalf("applied MCP summary = %q", list.AgentSummary)
}
if list.AgentSummarySource != "mcp-tools-list+cli-registry@abcdef123456" {
t.Fatalf("applied summary source = %q", list.AgentSummarySource)
}
if list.Reviewed == nil || *list.Reviewed {
t.Fatalf("applied reviewed = %#v, want false", list.Reviewed)
}
if list.InterfaceRef != nil {
t.Fatalf("list tool unexpectedly gained interface_ref: %#v", list.InterfaceRef)
}
get := out.Tools["calendar.get"]
if get.InterfaceRef == nil || get.InterfaceRef.ProductID != "calendar" || get.InterfaceRef.RPCName != "get" {
t.Fatalf("preserved tool interface_ref = %#v", get.InterfaceRef)
}
if get.AgentSummary != "已有摘要" {
t.Fatalf("preserved summary overwritten: %#v", get)
}
}
+6
View File
@@ -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")
@@ -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")
}
}
+129 -48
View File
@@ -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 <openConversationId> --yes
Example: ` dws chat group dismiss --group <openConversationId>
# 查询群 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 <openConversationId>")
}
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 <openConversationId> --yes
dws chat group upgrade-to-external --group <openConversationId> --extension '{"source":"dws"}' --yes
Example: ` dws chat group upgrade-to-external --group <openConversationId> --dry-run
dws chat group upgrade-to-external --group <openConversationId> --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 <GROUP_OPEN_CONVERSATION_ID>"))
return root
}
@@ -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"},
+37 -28
View File
@@ -98,13 +98,8 @@ func TestApprovalRevokeDryRunSkipsConfirmationAndEmitsPreview(t *testing.T) {
}
}
func TestDocVersionRevertDryRunPreflightsVersionAndEmitsPreview(t *testing.T) {
caller := &contractDefectCaller{
dryRun: true,
responses: map[string]string{
"doc/list_doc_versions": `{"versions":[{"version":7}]}`,
},
}
func TestDocVersionRevertDryRunSkipsRemotePreflightAndEmitsPreview(t *testing.T) {
caller := &contractDefectCaller{dryRun: true}
output, err := executeContractDefectCommand(t, caller, newDocCommand,
"version", "revert", "--node", "node-dry-run", "--version", "7", "--dry-run")
if err != nil {
@@ -113,10 +108,8 @@ func TestDocVersionRevertDryRunPreflightsVersionAndEmitsPreview(t *testing.T) {
if len(caller.calls) != 0 {
t.Fatalf("dry-run mutation calls = %#v, want none", caller.calls)
}
if len(caller.readCalls) != 1 ||
caller.readCalls[0].productID != "doc" ||
caller.readCalls[0].toolName != "list_doc_versions" {
t.Fatalf("dry-run read calls = %#v, want doc/list_doc_versions", caller.readCalls)
if len(caller.readCalls) != 0 {
t.Fatalf("dry-run read calls = %#v, want none (no remote version preflight)", caller.readCalls)
}
if !strings.Contains(output, `"tool": "revert_doc_version"`) ||
!strings.Contains(output, `"version": 7`) {
@@ -124,30 +117,46 @@ func TestDocVersionRevertDryRunPreflightsVersionAndEmitsPreview(t *testing.T) {
}
}
func TestDocVersionRevertDryRunRejectsMissingVersionBeforePreview(t *testing.T) {
caller := &contractDefectCaller{
dryRun: true,
responses: map[string]string{
"doc/list_doc_versions": `{"versions":[{"version":7}]}`,
},
}
func TestDocVersionRevertDryRunDoesNotRejectMissingVersionRemotely(t *testing.T) {
caller := &contractDefectCaller{dryRun: true}
output, err := executeContractDefectCommand(t, caller, newDocCommand,
"version", "revert", "--node", "node-dry-run", "--version", "999", "--dry-run")
if err == nil || !strings.Contains(err.Error(), "文档版本 999 不存在") {
t.Fatalf("missing version error = %v, want explicit rejection", err)
if err != nil {
t.Fatalf("doc version revert dry-run returned error: %v", err)
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) || appErr.Reason != "version_not_found" {
t.Fatalf("missing version error = %#v, want typed version_not_found", err)
}
if len(caller.readCalls) != 1 {
t.Fatalf("dry-run read calls = %#v, want one version lookup", caller.readCalls)
if len(caller.readCalls) != 0 {
t.Fatalf("dry-run read calls = %#v, want none (no remote version preflight)", caller.readCalls)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run mutation calls = %#v, want none", caller.calls)
}
if strings.Contains(output, `"tool": "revert_doc_version"`) {
t.Fatalf("missing version emitted a misleading mutation preview: %q", output)
if !strings.Contains(output, `"tool": "revert_doc_version"`) ||
!strings.Contains(output, `"version": 999`) {
t.Fatalf("dry-run output = %q, want preview without remote missing-version rejection", output)
}
}
func TestDocVersionRevertNonDryRunPreflightsVersion(t *testing.T) {
caller := &contractDefectCaller{
responses: map[string]string{
"doc/list_doc_versions": `{"versions":[{"version":7}]}`,
"doc/revert_doc_version": `{}`,
},
}
output, err := executeContractDefectCommand(t, caller, newDocCommand,
"version", "revert", "--node", "node-live", "--version", "7", "--yes")
if err != nil {
t.Fatalf("doc version revert returned error: %v", err)
}
// Outside --dry-run, list_doc_versions rides the normal CallTool channel
// (CallReadTool is dry-run-only). Expect preflight then mutation.
if len(caller.calls) != 2 ||
caller.calls[0].productID != "doc" || caller.calls[0].toolName != "list_doc_versions" ||
caller.calls[1].productID != "doc" || caller.calls[1].toolName != "revert_doc_version" {
t.Fatalf("non-dry-run calls = %#v, want list_doc_versions then revert_doc_version", caller.calls)
}
if strings.Contains(output, `"dry_run": true`) {
t.Fatalf("non-dry-run output unexpectedly previewed: %q", output)
}
}
+3 -1
View File
@@ -2232,7 +2232,9 @@ func splitDevAppList(raw string) []string {
}
// transformDevAppListParam splits a comma/semicolon list for LeafFlag.Transform.
// Empty input returns nil so the key is omitted from toolArgs.
// Empty input (including separator-only) returns nil so optional flags omit the
// key from toolArgs. Required flags that collapse to empty are rejected by
// corecmd.BuildArgs after transform.
func transformDevAppListParam(raw string) (any, error) {
values := splitDevAppList(raw)
if len(values) == 0 {
+27
View File
@@ -709,6 +709,33 @@ func TestDevAppEventSubscribeRequiresEventCodes(t *testing.T) {
}
}
func TestCrossPlatformCoverageDevAppEventSubscribeRejectsSeparatorOnlyEventCodes(t *testing.T) {
for _, tc := range []struct {
name string
args []string
}{
{"subscribe_comma", []string{"dev", "app", "event", "subscribe", "--unified-app-id", "u-1", "--event-codes", ",", "--yes"}},
{"subscribe_semicolon", []string{"dev", "app", "event", "subscribe", "--unified-app-id", "u-1", "--event-codes", ";", "--yes"}},
{"unsubscribe_comma", []string{"dev", "app", "event", "unsubscribe", "--unified-app-id", "u-1", "--event-codes", ",", "--yes"}},
} {
t.Run(tc.name, func(t *testing.T) {
runner := &captureRunner{}
root := newDevAppTestRoot(runner)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(tc.args)
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), "--event-codes 为必填") {
t.Fatalf("Execute() error = %v, want --event-codes 为必填", err)
}
if runner.last.Tool != "" {
t.Fatalf("runner should not be called, got tool %q", runner.last.Tool)
}
})
}
}
func TestDevAppWebappCommandsBuildParams(t *testing.T) {
cases := []struct {
name string
+29 -17
View File
@@ -3911,20 +3911,22 @@ CLI 内部自动完成全部流程:
return fmt.Errorf("flag --version is required")
}
version, _ := cmd.Flags().GetInt("version")
exists, err := docVersionExists(cmd.Context(), nodeID, version)
if err != nil {
return err
}
if !exists {
return apperrors.NewValidation(
fmt.Sprintf("文档版本 %d 不存在,已停止回滚", version),
apperrors.WithReason("version_not_found"),
apperrors.WithHint(fmt.Sprintf(
"请先执行 dws doc version list --node %s --format json 获取可回滚版本",
nodeID,
)),
apperrors.WithActions("查询可用文档版本", "选择存在的版本号后重新预览"),
)
if !commandDryRun(cmd) {
exists, err := docVersionExists(cmd.Context(), nodeID, version)
if err != nil {
return err
}
if !exists {
return apperrors.NewValidation(
fmt.Sprintf("文档版本 %d 不存在,已停止回滚", version),
apperrors.WithReason("version_not_found"),
apperrors.WithHint(fmt.Sprintf(
"请先执行 dws doc version list --node %s --format json 获取可回滚版本",
nodeID,
)),
apperrors.WithActions("查询可用文档版本", "选择存在的版本号后重新预览"),
)
}
}
return callMCPToolOnServer("doc", "revert_doc_version", map[string]any{
"nodeId": nodeID,
@@ -4230,6 +4232,16 @@ CLI 内部自动完成全部流程:
return root
}
// printDocDeprecationWarning emits a deprecation warning when shared deps are
// initialized. Schema declaration-only roots skip InitDeps; homology and other
// Execute probes must remain nil-safe on that path.
func printDocDeprecationWarning(msg string) {
if deps == nil || deps.Out == nil {
return
}
deps.Out.PrintWarning(msg)
}
// wrapDocDeprecated wraps a doc command's RunE to print a deprecation warning
// directing users to the corresponding drive command. The original command
// continues to function normally during the transition period.
@@ -4237,7 +4249,7 @@ func wrapDocDeprecated(cmd *cobra.Command, driveSubCmd string) {
originalRunE := cmd.RunE
cmd.RunE = func(c *cobra.Command, args []string) error {
if strings.HasPrefix(c.CommandPath(), "dws doc ") {
deps.Out.PrintWarning(fmt.Sprintf(
printDocDeprecationWarning(fmt.Sprintf(
"⚠️ 'dws doc %s' is deprecated, use 'dws drive %s' instead.",
c.CommandPath()[8:], // strip "dws doc " prefix
driveSubCmd,
@@ -4253,7 +4265,7 @@ func wrapDocDeprecatedToWiki(cmd *cobra.Command, wikiSubCmd string) {
originalRunE := cmd.RunE
cmd.RunE = func(c *cobra.Command, args []string) error {
if strings.HasPrefix(c.CommandPath(), "dws doc ") {
deps.Out.PrintWarning(fmt.Sprintf(
printDocDeprecationWarning(fmt.Sprintf(
"⚠️ 'dws doc %s' is deprecated, use 'dws %s' instead.",
c.CommandPath()[8:],
wikiSubCmd,
@@ -4269,7 +4281,7 @@ func wrapDocDeprecatedToTarget(cmd *cobra.Command, targetCmd string) {
originalRunE := cmd.RunE
cmd.RunE = func(c *cobra.Command, args []string) error {
if strings.HasPrefix(c.CommandPath(), "dws doc ") {
deps.Out.PrintWarning(fmt.Sprintf(
printDocDeprecationWarning(fmt.Sprintf(
"⚠️ 'dws doc %s' is deprecated, use 'dws %s' instead.",
c.CommandPath()[8:],
targetCmd,
@@ -173,6 +173,24 @@ func TestCrossPlatformCoverageDocDeprecationWrappersCoverage(t *testing.T) {
_ = cmd.RunE(cmd, nil)
}
}
// Declaration-only Schema roots leave deps nil; wrappers must not panic.
deps = nil
for _, wrap := range []func(*cobra.Command){
func(cmd *cobra.Command) { wrapDocDeprecated(cmd, "drive target") },
func(cmd *cobra.Command) { wrapDocDeprecatedToWiki(cmd, "wiki target") },
func(cmd *cobra.Command) { wrapDocDeprecatedToTarget(cmd, "target") },
} {
cmd := &cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }}
wrap(cmd)
root := &cobra.Command{Use: "dws"}
doc := &cobra.Command{Use: "doc"}
root.AddCommand(doc)
doc.AddCommand(cmd)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("nil-deps deprecation wrapper: %v", err)
}
}
}
func TestCrossPlatformCoverageRunDocUploadDownloadAndMediaCoverage(t *testing.T) {
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// Test helpers for package-level deps. Production code must not call these;
// the ForTest suffix is the boundary.
// InitDepsForTest installs caller for the test duration and restores the prior
// deps pointer (including a prior nil) via testseam. Prefer this over
// InitDeps + InitDeps(previousCaller): restoring through InitDeps(nil) leaves a
// non-nil Deps with a nil Caller, which is not the original unset state.
func InitDepsForTest(t *testing.T, caller edition.ToolCaller) {
t.Helper()
testseam.Protect(t, &deps)
InitDeps(caller)
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"context"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type fortestCoverageCaller struct{}
func (fortestCoverageCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return &edition.ToolResult{}, nil
}
func (fortestCoverageCaller) Format() string { return "json" }
func (fortestCoverageCaller) DryRun() bool { return false }
func (fortestCoverageCaller) Fields() string { return "" }
func (fortestCoverageCaller) JQ() string { return "" }
// TestCrossPlatformCoverageInitDepsForTestRestoresPriorDeps covers the
// ForTest deps restore helper used by declaration-only Schema probes.
func TestCrossPlatformCoverageInitDepsForTestRestoresPriorDeps(t *testing.T) {
testseam.Protect(t, &deps)
deps = nil
marker := fortestCoverageCaller{}
InitDepsForTest(t, marker)
if got := GetCaller(); got != marker {
t.Fatalf("GetCaller() during InitDepsForTest = %T, want marker", got)
}
if deps == nil {
t.Fatal("InitDepsForTest left deps nil before test cleanup")
}
}
+1
View File
@@ -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,
+3
View File
@@ -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_",
} {
+11 -4
View File
@@ -229,6 +229,15 @@ func TestLeafArgs(t *testing.T) {
func TestLeafArgsOmitsEmptyAndNonPositive(t *testing.T) {
cmd := NewLeafCommand(leafTestSpec())
// Satisfy Required flags first: BuildArgs now rejects Required transforms that
// collapse to empty (unset CSV), matching runtime behavior after ValidateRequired.
t.Setenv("DWS_LEAF_TEST_TOKEN", "tok")
if err := cmd.Flags().Set("users", "u1"); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("content", "hello"); err != nil {
t.Fatal(err)
}
args, err := corecmd.BuildArgs(cmd, leafTestSpec().Flags)
if err != nil {
t.Fatalf("leafArgs() error = %v", err)
@@ -239,10 +248,8 @@ func TestLeafArgsOmitsEmptyAndNonPositive(t *testing.T) {
if _, present := args["cursor"]; present {
t.Fatalf("cursor present = %v, want omitted when zero", args["cursor"])
}
// 未配置 OmitEmpty 的 flag 即使为空也入参(复现手写语义;Required 校验在
// leafArgs 之前执行,保证真实路径不会发出空值)。
if v, present := args["accessToken"]; !present || v != "" {
t.Fatalf("accessToken = %v/%v, want present-but-empty without OmitEmpty", v, present)
if v, present := args["accessToken"]; !present || v != "tok" {
t.Fatalf("accessToken = %v/%v, want env tok", v, present)
}
// 未设置 OmitEmpty 的字符串即使为空也入参(复现手写 remindType 恒入参语义)。
if v, present := args["remindType"]; !present || v != "app" {
+267 -1
View File
@@ -1,21 +1,55 @@
package helpers
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
func decodeOARequest(raw string) (map[string]any, error) {
dec := json.NewDecoder(bytes.NewBufferString(raw))
dec.UseNumber()
var request map[string]any
if err := dec.Decode(&request); err != nil || request == nil {
if err != nil {
return nil, err
}
return nil, fmt.Errorf("JSON 请求不能为 null")
}
if err := dec.Decode(new(any)); !errors.Is(err, io.EOF) {
return nil, fmt.Errorf("JSON 请求包含多余内容")
}
return request, nil
}
func oaFormValues(raw string) ([]map[string]string, error) {
var values map[string]string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil, err
}
result := make([]map[string]string, 0, len(values))
for name, value := range values {
result = append(result, map[string]string{"name": name, "value": value})
}
return result, nil
}
// ──────────────────────────────────────────────────────────
// dws oa — OA 审批
// MCP tools(tools/list): list_pending_approvals, get_processInstance_detail,
// approve_processInstance, reject_processInstance, revoke_processInstance,
// get_processInstance_records, list_initiated_instances, list_pending_tasks,
// list_user_visible_process, append_task, search_form, oa_ding_user, revert_task,
// get_inst_revert_activities
// get_inst_revert_activities, get_process_schema, forecast_process,
// start_process_instance
// ──────────────────────────────────────────────────────────
func newOaCommand() *cobra.Command {
@@ -1017,6 +1051,195 @@ func newOaCommand() *cobra.Command {
},
}
approvalFormSchemaCmd := &cobra.Command{
Use: "form-schema", Short: "查询审批模板的表单 Schema",
Example: "dws oa approval form-schema --process-code <processCode>",
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "process-code"); err != nil {
return err
}
return callMCPTool("get_process_schema", map[string]any{"processCode": mustGetFlag(cmd, "process-code")})
},
}
DeclareLeafMetadata(approvalFormSchemaCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "get_process_schema",
CanonicalPath: "oa.get_process_schema",
CLIPath: "oa approval form-schema",
PrimaryCLIPath: "oa approval form-schema",
},
Description: "查询审批模板的表单 Schema",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "get_process_schema"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询审批模板的表单 Schema",
UseWhen: []string{"已从 list-forms 或 search-forms 获得 processCode,需要读取字段、选项和必填规则后再填写审批时"},
AvoidWhen: []string{"只需列出可用模板时使用 list-forms;不要把返回的 Schema 当作可直接提交的实例请求"},
Examples: []string{"dws oa approval form-schema --process-code <processCode>"},
},
Parameters: []contract.ParamDecl{
{Name: "process-code", Property: "processCode"},
},
},
})
approvalForecastCmd := &cobra.Command{
Use: "forecast-process", Short: "根据表单值预测审批流程与自选节点",
Example: "dws oa approval forecast-process --process-code <processCode> --dept-id -1 --form-values '{\"金额\":\"100\"}'",
RunE: func(cmd *cobra.Command, args []string) error {
if raw, _ := cmd.Flags().GetString("request"); raw != "" {
request, err := decodeOARequest(raw)
if err != nil {
return fmt.Errorf("--request JSON 解析失败: %w", err)
}
return callMCPTool("forecast_process", map[string]any{"ProcessForecastPopRequest": request})
}
if err := validateRequiredFlags(cmd, "process-code", "dept-id", "form-values"); err != nil {
return err
}
deptID, err := strconv.ParseInt(mustGetFlag(cmd, "dept-id"), 10, 64)
if err != nil {
return fmt.Errorf("--dept-id 必须为整数: %w", err)
}
values, err := oaFormValues(mustGetFlag(cmd, "form-values"))
if err != nil {
return fmt.Errorf("--form-values JSON 解析失败: %w", err)
}
return callMCPTool("forecast_process", map[string]any{"ProcessForecastPopRequest": map[string]any{"processCode": mustGetFlag(cmd, "process-code"), "deptId": deptID, "formComponentValues": [][]map[string]string{values}}})
},
}
DeclareLeafMetadata(approvalForecastCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "forecast_process",
CanonicalPath: "oa.forecast_process",
CLIPath: "oa approval forecast-process",
PrimaryCLIPath: "oa approval forecast-process",
},
Description: "根据表单值预测审批流程与自选节点",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "forecast_process"},
},
Selection: contract.SelectionSpec{
AgentSummary: "预测审批流程与自选审批节点",
UseWhen: []string{"已知道 processCode 且已根据表单 Schema 组装字段,需要在发起前确认审批路径或自选节点时"},
AvoidWhen: []string{"需要真正创建审批单时改用 create-instance;未获得字段定义时先用 form-schema"},
Examples: []string{
"dws oa approval forecast-process --process-code <processCode> --dept-id -1 --form-values '{\"金额\":\"100\"}'",
"dws oa approval forecast-process --request '{\"processCode\":\"PROC-xxx\",\"deptId\":-1,\"formComponentValues\":[[{\"name\":\"金额\",\"value\":\"100\"}]]}'",
},
},
// Simple-mode flags are mapping exclusions (encoded inside ProcessForecastPopRequest).
Parameters: []contract.ParamDecl{
{Name: "request", Property: "ProcessForecastPopRequest", InterfaceType: "object"},
},
},
})
approvalCreateCmd := &cobra.Command{
Use: "create-instance", Short: "发起审批实例(需要 --yes 确认)",
Example: "dws oa approval create-instance --process-code <processCode> --form-values '{\"事由\":\"测试\"}' --yes",
RunE: func(cmd *cobra.Command, args []string) error {
if !commandDryRun(cmd) {
yes, _ := cmd.Flags().GetBool("yes")
if !yes {
return fmt.Errorf("发起审批实例会创建真实业务数据;请先核对参数,然后添加 --yes 确认执行")
}
}
var request map[string]any
if raw, _ := cmd.Flags().GetString("request"); raw != "" {
var err error
request, err = decodeOARequest(raw)
if err != nil {
return fmt.Errorf("--request JSON 解析失败: %w", err)
}
} else {
if err := validateRequiredFlags(cmd, "process-code", "form-values"); err != nil {
return err
}
values, err := oaFormValues(mustGetFlag(cmd, "form-values"))
if err != nil {
return fmt.Errorf("--form-values JSON 解析失败: %w", err)
}
request = map[string]any{"processCode": mustGetFlag(cmd, "process-code"), "formComponentValues": values}
if dept, _ := cmd.Flags().GetString("dept-id"); dept != "" {
value, err := strconv.ParseInt(dept, 10, 64)
if err != nil {
return fmt.Errorf("--dept-id 必须为整数: %w", err)
}
request["deptId"] = value
}
if userID, _ := cmd.Flags().GetString("originator-user-id"); userID != "" {
request["originatorUserId"] = userID
}
if rawApprovers, _ := cmd.Flags().GetString("approvers"); rawApprovers != "" {
action, _ := cmd.Flags().GetString("approvers-action-type")
if action != "AND" && action != "OR" && action != "NONE" {
return fmt.Errorf("--approvers-action-type 必须为 AND、OR 或 NONE")
}
request["approvers"] = []map[string]any{{"actionType": action, "userIds": strings.Split(rawApprovers, ",")}}
}
if rawCC, _ := cmd.Flags().GetString("cc-list"); rawCC != "" {
position, _ := cmd.Flags().GetString("cc-position")
if position != "START" && position != "FINISH" && position != "START_FINISH" {
return fmt.Errorf("--cc-position 必须为 START、FINISH 或 START_FINISH")
}
request["ccList"] = strings.Split(rawCC, ",")
request["ccPosition"] = position
}
}
return callMCPTool("start_process_instance", map[string]any{"ProcessInstanceCreationPopRequest": request})
},
}
DeclareLeafMetadata(approvalCreateCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "start_process_instance",
CanonicalPath: "oa.start_process_instance",
CLIPath: "oa approval create-instance",
PrimaryCLIPath: "oa approval create-instance",
},
Description: "发起审批实例(需要 --yes 确认)",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "start_process_instance"},
},
Selection: contract.SelectionSpec{
AgentSummary: "发起新的审批实例",
UseWhen: []string{"用户确认要发起审批,且已查询表单 Schema、核对字段及审批路径后使用"},
AvoidWhen: []string{"只需预测流程时使用 forecast-process;用户尚未确认或字段未按 Schema 核对时不要发起"},
Examples: []string{
"dws oa approval create-instance --process-code <processCode> --form-values '{\"事由\":\"测试\"}'",
"dws oa approval create-instance --request '{\"processCode\":\"PROC-xxx\",\"deptId\":-1,\"formComponentValues\":[{\"name\":\"事由\",\"value\":\"测试\"}],\"targetSelectActioners\":[{\"actionerKey\":\"manual-node\",\"actionerStaffIds\":[\"user-id\"]}]}'",
},
},
// Simple-mode flags are mapping exclusions (encoded inside ProcessInstanceCreationPopRequest).
Parameters: []contract.ParamDecl{
{Name: "request", Property: "ProcessInstanceCreationPopRequest", InterfaceType: "object"},
},
},
})
approvalListPendingCmd.Flags().String("start", "", "开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00) (必填)")
approvalListPendingCmd.Flags().String("end", "", "结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00) (必填)")
approvalListPendingCmd.Flags().String("page", "", "分页页码 (可选)")
@@ -1087,6 +1310,46 @@ func newOaCommand() *cobra.Command {
approvalRevertTaskCmd.Flags().String("target-activity-id", "", "退回到的节点 ID(退回发起人固定传 sid-startevent)(必填)")
approvalRevertTaskCmd.Flags().String("action", "", "退回方式:REVERT_FOR_APPROVAL(退回到审批人)/ REVERT_FOR_RESUBMIT(退回到发起人)(必填)")
approvalRevertTaskCmd.Flags().String("remark", "", "退回说明 (可选)")
approvalFormSchemaCmd.Flags().String("process-code", "", "审批模板 processCode (必填)")
approvalForecastCmd.Flags().String("process-code", "", "审批模板 processCode(简单模式使用;与 --request 互斥)")
approvalForecastCmd.Flags().String("dept-id", "", "发起人部门 ID(简单模式使用;与 --request 互斥)")
approvalForecastCmd.Flags().String("form-values", "", "表单值 JSON(简单模式使用;与 --request 互斥)")
approvalForecastCmd.Flags().String("request", "", "完整请求 JSON(高级模式;与简单模式参数互斥)")
approvalForecastCmd.MarkFlagsOneRequired("request", "process-code")
approvalForecastCmd.MarkFlagsRequiredTogether("process-code", "dept-id", "form-values")
forecastMutuallyExclusive := make([][]string, 0, 3)
for _, name := range []string{"process-code", "dept-id", "form-values"} {
approvalForecastCmd.MarkFlagsMutuallyExclusive("request", name)
forecastMutuallyExclusive = append(forecastMutuallyExclusive, []string{"request", name})
}
cli.AnnotateRuntimeConstraints(approvalForecastCmd, cli.RuntimeSchemaConstraints{
MutuallyExclusive: forecastMutuallyExclusive,
RequireOneOf: [][]string{{"request", "process-code"}},
RequireTogether: [][]string{{"process-code", "dept-id", "form-values"}},
})
approvalCreateCmd.Flags().String("process-code", "", "审批模板 processCode(简单模式使用;与 --request 互斥)")
approvalCreateCmd.Flags().String("dept-id", "-1", "发起人部门 ID")
approvalCreateCmd.Flags().String("form-values", "", "表单值 JSON(简单模式使用;与 --request 互斥)")
approvalCreateCmd.Flags().String("request", "", "完整请求 JSON(高级模式;与简单模式参数互斥)")
approvalCreateCmd.Flags().String("originator-user-id", "", "审批发起人 userId")
approvalCreateCmd.Flags().String("approvers", "", "审批人 userId 列表,多个用逗号分隔")
approvalCreateCmd.Flags().String("approvers-action-type", "OR", "审批类型:AND、OR 或 NONE")
approvalCreateCmd.Flags().String("cc-list", "", "抄送人 userId 列表,多个用逗号分隔")
approvalCreateCmd.Flags().String("cc-position", "START", "抄送时点:START、FINISH 或 START_FINISH")
approvalCreateCmd.MarkFlagsOneRequired("request", "process-code")
approvalCreateCmd.MarkFlagsRequiredTogether("process-code", "form-values")
createSimpleFlags := []string{"process-code", "dept-id", "form-values", "originator-user-id", "approvers", "approvers-action-type", "cc-list", "cc-position"}
createMutuallyExclusive := make([][]string, 0, len(createSimpleFlags))
for _, name := range createSimpleFlags {
approvalCreateCmd.MarkFlagsMutuallyExclusive("request", name)
createMutuallyExclusive = append(createMutuallyExclusive, []string{"request", name})
}
cli.AnnotateRuntimeConstraints(approvalCreateCmd, cli.RuntimeSchemaConstraints{
MutuallyExclusive: createMutuallyExclusive,
RequireOneOf: [][]string{{"request", "process-code"}},
RequireTogether: [][]string{{"process-code", "form-values"}},
})
approvalCmd.AddCommand(
approvalListPendingCmd,
@@ -1109,6 +1372,9 @@ func newOaCommand() *cobra.Command {
approvalAppendTaskCmd,
approvalRevertActivitiesCmd,
approvalRevertTaskCmd,
approvalFormSchemaCmd,
approvalForecastCmd,
approvalCreateCmd,
)
root.AddCommand(approvalCmd)
+169 -1
View File
@@ -1,6 +1,34 @@
package helpers
import "testing"
import (
"io"
"os"
"strings"
"testing"
)
func executeOACommand(t *testing.T, caller *scriptedToolCaller, args ...string) error {
t.Helper()
previous := deps
previousArgs := os.Args
os.Args = []string{"dws", "oa"}
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
t.Cleanup(func() {
deps = previous
os.Args = previousArgs
})
cmd := newOaCommand()
cmd.PersistentFlags().Bool("yes", false, "跳过确认")
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
return cmd.Execute()
}
func TestCrossPlatformCoverageOARemainingTimeAndRevertBranches(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
@@ -31,3 +59,143 @@ func TestCrossPlatformCoverageOARemainingTimeAndRevertBranches(t *testing.T) {
t.Fatalf("revert task: %v", err)
}
}
func TestCrossPlatformCoverageOAApprovalCreateInstanceMapsInternalSimpleOptions(t *testing.T) {
caller := &scriptedToolCaller{}
err := executeOACommand(t, caller,
"approval", "create-instance",
"--process-code", "PROC",
"--form-values", `{"事由":"测试"}`,
"--originator-user-id", "originator",
"--approvers", "approver-1,approver-2",
"--approvers-action-type", "AND",
"--cc-list", "cc-1,cc-2",
"--cc-position", "FINISH",
"--yes",
)
if err != nil {
t.Fatalf("create instance: %v", err)
}
if caller.server != "oa" || caller.tool != "start_process_instance" {
t.Fatalf("called %s/%s, want oa/start_process_instance", caller.server, caller.tool)
}
request, ok := caller.args["ProcessInstanceCreationPopRequest"].(map[string]any)
if !ok {
t.Fatalf("request payload = %#v", caller.args)
}
if got := request["originatorUserId"]; got != "originator" {
t.Fatalf("originatorUserId = %#v", got)
}
approvers, ok := request["approvers"].([]map[string]any)
if !ok || len(approvers) != 1 || approvers[0]["actionType"] != "AND" {
t.Fatalf("approvers = %#v", request["approvers"])
}
if got := approvers[0]["userIds"]; len(got.([]string)) != 2 || got.([]string)[0] != "approver-1" || got.([]string)[1] != "approver-2" {
t.Fatalf("approver userIds = %#v", got)
}
if got := request["ccList"]; len(got.([]string)) != 2 || got.([]string)[0] != "cc-1" || got.([]string)[1] != "cc-2" {
t.Fatalf("ccList = %#v", got)
}
if got := request["ccPosition"]; got != "FINISH" {
t.Fatalf("ccPosition = %#v", got)
}
}
func TestCrossPlatformCoverageOAApprovalCreateInstanceRejectsMixedRequestModes(t *testing.T) {
caller := &scriptedToolCaller{}
err := executeOACommand(t, caller,
"approval", "create-instance",
"--request", `{"processCode":"PROC"}`,
"--process-code", "PROC",
"--yes",
)
if err == nil {
t.Fatal("mixed request modes returned nil")
}
if caller.calls != 0 {
t.Fatalf("unexpected MCP call count: %d", caller.calls)
}
}
func TestCrossPlatformCoverageOAApprovalCreateInstanceRequiresExplicitYes(t *testing.T) {
caller := &scriptedToolCaller{}
err := executeOACommand(t, caller,
"approval", "create-instance",
"--request", `{"processCode":"PROC"}`,
)
if err == nil || !strings.Contains(err.Error(), "--yes") {
t.Fatalf("create instance without --yes error = %v, want explicit --yes requirement", err)
}
if caller.calls != 0 {
t.Fatalf("create instance without --yes made %d MCP calls", caller.calls)
}
}
func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *testing.T) {
validCases := []struct {
name string
args []string
tool string
}{
{
name: "form schema",
args: []string{"approval", "form-schema", "--process-code", "PROC"},
tool: "get_process_schema",
},
{
name: "forecast simple mode",
args: []string{"approval", "forecast-process", "--process-code", "PROC", "--dept-id", "-1", "--form-values", `{"金额":"100"}`},
tool: "forecast_process",
},
{
name: "forecast request mode",
args: []string{"approval", "forecast-process", "--request", `{"processCode":"PROC"}`},
tool: "forecast_process",
},
{
name: "create request mode",
args: []string{"approval", "create-instance", "--request", `{"processCode":"PROC"}`, "--yes"},
tool: "start_process_instance",
},
}
for _, tc := range validCases {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
if err := executeOACommand(t, caller, tc.args...); err != nil {
t.Fatalf("execute %v: %v", tc.args, err)
}
if caller.tool != tc.tool || caller.calls != 1 {
t.Fatalf("called tool=%q calls=%d, want %q once", caller.tool, caller.calls, tc.tool)
}
})
}
invalidCases := [][]string{
{"approval", "form-schema"},
{"approval", "forecast-process"},
{"approval", "forecast-process", "--request", `{"processCode":"PROC"}`, "--process-code", "PROC"},
{"approval", "forecast-process", "--request", "{"},
{"approval", "forecast-process", "--request", "null"},
{"approval", "forecast-process", "--request", "{} {}"},
{"approval", "forecast-process", "--process-code", "PROC", "--dept-id", "bad", "--form-values", `{"金额":"100"}`},
{"approval", "forecast-process", "--process-code", "PROC", "--dept-id", "-1", "--form-values", "["},
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`},
{"approval", "create-instance", "--yes"},
{"approval", "create-instance", "--request", "{", "--yes"},
{"approval", "create-instance", "--request", "null", "--yes"},
{"approval", "create-instance", "--request", "{} {}", "--yes"},
{"approval", "create-instance", "--process-code", "PROC", "--form-values", "[", "--yes"},
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--dept-id", "bad", "--yes"},
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--approvers", "u", "--approvers-action-type", "bad", "--yes"},
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--cc-list", "u", "--cc-position", "bad", "--yes"},
}
for _, args := range invalidCases {
caller := &scriptedToolCaller{}
if err := executeOACommand(t, caller, args...); err == nil {
t.Fatalf("invalid args %v returned nil", args)
}
if caller.calls != 0 {
t.Fatalf("invalid args %v made %d MCP calls", args, caller.calls)
}
}
}
@@ -24,10 +24,16 @@ type scriptedToolCaller struct {
format string
dry bool
calls int
server string
tool string
args map[string]any
}
func (c *scriptedToolCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.calls++
c.server = serverID
c.tool = toolName
c.args = args
if len(c.steps) == 0 {
return &edition.ToolResult{}, nil
}
@@ -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",
+30
View File
@@ -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
}
+20
View File
@@ -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)
}
}
+47 -1
View File
@@ -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,
+69 -2
View File
@@ -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.
+41 -17
View File
@@ -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
}
+2 -2
View File
@@ -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,
)
)...)
}
+140 -30
View File
@@ -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 != "<nil>" {
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,
)
)...)
}
@@ -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)
}
}
+61 -19
View File
@@ -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 <openConversationId>"},
},
},
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 <openConversationId>`},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"group", "chat-query", "group-query"}},
},
Tips: []string{
`dws chat +chat-invite-url --group <openConversationId>`,
`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 <openConversationId>"},
},
},
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 <openConversationId>`,
`dws chat +chat-bots --group "项目群"`,
},
Tips: []string{`dws chat +chat-bots --group <openConversationId>`},
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,
)
)...)
}
+149 -67
View File
@@ -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 <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 <openConversationId> --msg-id <openMessageId>`},
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 == "<nil>" {
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 <openConversationId> --content \"任务已完成\"",
@@ -1221,7 +1292,7 @@ var MessagesSendCard = shortcut.Shortcut{
`dws chat +messages-send-card --group <openConversationId> --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 <bizId> --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 <bizId> --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,
)
)...)
}
@@ -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{
@@ -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
@@ -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
+11
View File
@@ -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
@@ -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
+256 -2
View File
@@ -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 {
@@ -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
}
+76
View File
@@ -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...)
}
+146 -32
View File
@@ -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 <openConversationId> --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,
)
)...)
}
+228 -22
View File
@@ -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"}]}}`,
}
+120
View File
@@ -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
}
+31 -19
View File
@@ -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)...)
}
@@ -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)
}
}
@@ -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")
+160
View File
@@ -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,
},
}
}
@@ -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)
}
}
+194 -45
View File
@@ -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 <openConversationId> --markdown \"## 周报\" --idempotency-key <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 <openConversationId> --markdown "## 周报" --idempotency-key <key>`,
`dws chat +messages-send --as user --user <userId> --msg-type file --file ./report.pdf --idempotency-key <key>`,
`dws chat +messages-send --as bot --robot-code <robotCode> --users userId1,userId2 --text "请提交周报"`,
`dws chat +messages-send --as bot --robot-code <robotCode> --groups <openConversationId1>,<openConversationId2> --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)...)
}
@@ -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)
}
}
+203 -2
View File
@@ -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}
+83 -15
View File
@@ -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)}
+73 -13
View File
@@ -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.
@@ -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
+24 -17
View File
@@ -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)
+377 -126
View File
@@ -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 <openconversation_id> --time "2025-03-01 00:00:00"
// dws chat +chat-messages --user <userId> --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 <openConversationId> --direction older",
"dws chat +chat-messages --open-dingtalk-id <openDingTalkId> --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 <openconversation_id> --time "2025-03-01 00:00:00"`,
`dws chat +chat-messages --user <userId> --time "2025-03-01 00:00:00" --limit 50`,
`dws chat +chat-messages --group <openconversation_id> --direction older`,
`dws chat +chat-messages --user <userId> --time "2025-03-01 00:00:00" --page-all --page-limit 50`,
`dws chat +chat-messages --group <openconversation_id> --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 == "<nil>" {
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 == "<nil>" || 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() {
+248 -2
View File
@@ -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)
}
}
@@ -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
@@ -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)
}
}
+17 -20
View File
@@ -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,
)
},
}
+27 -5
View File
@@ -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",
})
+215 -85
View File
@@ -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 <openConversationId> 指定群。",
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 <openConversationId> --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 <openConversationId> --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 != "<nil>" && seenIDs[stableID] {
continue
}
if stableID != "" && stableID != "<nil>" {
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 == "<nil>" || 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) {
@@ -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),
)
}
@@ -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)
}
}
+41 -85
View File
@@ -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
}
+66 -51
View File
@@ -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 <openDingTalkId> --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 != "<nil>" {
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
+36 -101
View File
@@ -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 <openConversationId>`.
//
@@ -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 <openConversationId> 指定群。",
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() {
+24 -4
View File
@@ -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 <openconversationId> --thread-id <threadId>`,
`dws chat +thread-replies --group <openconversationId> --thread-id <threadId> --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.
+10 -1
View File
@@ -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.
@@ -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 == "<nil>" {
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
}
@@ -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, "", "<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)
}
})
}
+12
View File
@@ -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
+51 -2
View File
@@ -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 = "<!-- VISIBLE_SHORTCUTS_OVERVIEW_START -->"
MONO_END = "<!-- VISIBLE_SHORTCUTS_OVERVIEW_END -->"
PRODUCT_START = "<!-- VISIBLE_SHORTCUTS_START -->"
PRODUCT_END = "<!-- VISIBLE_SHORTCUTS_END -->"
RUNTIME_CONTRACT_START = "<!-- DWS_RUNTIME_CONTRACT_START -->"
RUNTIME_CONTRACT_END = "<!-- DWS_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)
+16
View File
@@ -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"

Some files were not shown because too many files have changed in this diff Show More