Compare commits

...
Author SHA1 Message Date
栩朝 7a95659133 fix(chat): harden openDingTalkId target routing 2026-08-13 19:38:25 +08:00
chichuan 29c00341fa Merge pull request #997 from DingTalk-Real-AI/codex/fix-sealed-stable-compat
fix(ci): preserve delivered stable compatibility baseline
2026-08-13 19:26:10 +08:00
chichuan 14a2175434 fix(ci): preserve delivered stable compatibility baseline 2026-08-13 19:04:57 +08:00
chichuan 1b06d0105a Merge pull request #995 from DingTalk-Real-AI/codex/changelog-v1.0.58
docs: seal changelog for v1.0.58
2026-08-13 17:53:40 +08:00
chichuan 18fad57bbe docs: seal changelog for v1.0.58 2026-08-13 17:44:56 +08:00
github-actions[bot] c3ef04988b chore: update beta formula for v1.0.58-beta.6 [skip ci] 2026-08-13 09:10:23 +00:00
john 76d54d6df6 Merge pull request #993 from DingTalk-Real-AI/codex/changelog-v1.0.58-beta.6
docs: seal v1.0.58-beta.6 changelog
2026-08-13 16:33:34 +08:00
chichuan a36189d31e docs: seal v1.0.58-beta.6 changelog 2026-08-13 16:19:04 +08:00
chichuan d52d16dba4 Merge pull request #987 from DingTalk-Real-AI/codex/fix-release-seal-ci-path
ci: fast-path release seal fragment archival
2026-08-13 16:15:00 +08:00
chichuan d77fa91c69 Merge remote-tracking branch 'origin/main' into codex/fix-release-seal-ci-path 2026-08-13 15:37:08 +08:00
chichuan 9ea527a7c4 ci: reject truncated release seal file lists 2026-08-13 15:25:11 +08:00
chichuan f78f1b83e7 Merge pull request #991 from typefield/agent/fix-release-validator
fix: align package verifier with Agent skill roots
2026-08-13 15:24:10 +08:00
玉澜 3a6fa9a00c Merge remote-tracking branch 'origin/agent/fix-release-validator' into agent/fix-release-validator 2026-08-13 15:04:12 +08:00
玉澜 dc43d0d6d4 Merge remote-tracking branch 'upstream/main' into agent/fix-release-validator 2026-08-13 15:02:03 +08:00
chichuan bb69ed76df Merge branch 'main' into agent/fix-release-validator 2026-08-13 15:01:15 +08:00
chichuan a26b16b30e test: scope release seal env assertions 2026-08-13 14:58:44 +08:00
玉澜 e0c9b4910d fix: align package verifier with Agent skill roots 2026-08-13 14:57:51 +08:00
chichuan a1dc997004 Merge branch 'main' into codex/fix-release-seal-ci-path 2026-08-13 14:41:29 +08:00
chichuan b525497da8 fix: pass release seal classification to policy 2026-08-13 14:31:50 +08:00
chichuan ea9e31a59f Merge pull request #986 from DingTalk-Real-AI/codex/changelog-v1.0.58-beta.5
release: seal v1.0.58-beta.5 changelog
2026-08-13 13:45:14 +08:00
chichuan e58b85ea45 test: cover release seal CI fast path 2026-08-13 13:44:23 +08:00
chichuan e3fef0b6d4 ci: fast-path release seal fragment archival 2026-08-13 13:34:43 +08:00
chichuan c7236a1844 release: seal v1.0.58-beta.5 changelog 2026-08-13 13:18:21 +08:00
39 changed files with 2171 additions and 277 deletions
@@ -0,0 +1,5 @@
---
category: Changed
---
- **Chat identity routing** — validates explicit `openDingTalkId` inputs and improves name, `userId`, and `openDingTalkId` routing for message shortcuts.
+93 -12
View File
@@ -24,6 +24,7 @@ jobs:
pull-requests: read
outputs:
changelog_only: ${{ steps.classify.outputs.changelog_only }}
release_seal_only: ${{ steps.classify.outputs.release_seal_only }}
changelog_changed: ${{ steps.classify.outputs.changelog_changed }}
docs_only: ${{ steps.classify.outputs.docs_only }}
full_suite: ${{ steps.classify.outputs.full_suite }}
@@ -39,6 +40,7 @@ jobs:
with:
script: |
let changelogOnly = false;
let releaseSealOnly = false;
let changelogChanged = false;
let docsOnly = false;
let fullSuite = context.eventName === 'push';
@@ -162,6 +164,43 @@ jobs:
filename === 'go.mod' ||
filename === 'go.sum'
);
const isExactReleaseSeal = (candidates) => {
const changelog = candidates.filter(
({ filename, status, previous_filename }) =>
filename === 'CHANGELOG.md' &&
status === 'modified' &&
!previous_filename
);
if (changelog.length !== 1 || candidates.length < 2) {
return false;
}
let version = '';
return candidates.every((file) => {
if (file.filename === 'CHANGELOG.md') {
return file.status === 'modified' && !file.previous_filename;
}
if (
file.status !== 'renamed' ||
typeof file.filename !== 'string' ||
typeof file.previous_filename !== 'string' ||
file.additions !== 0 ||
file.deletions !== 0
) {
return false;
}
const target = file.filename.match(
/^\.changes\/released\/([0-9]+\.[0-9]+\.[0-9]+(?:-beta\.[1-9][0-9]*)?)\/([a-z0-9][a-z0-9._-]*\.md)$/
);
if (!target || file.previous_filename !== `.changes/${target[2]}`) {
return false;
}
if (version && version !== target[1]) {
return false;
}
version = target[1];
return true;
});
};
const classifyFiles = (complete) => {
const paths = files.flatMap(({ filename, previous_filename }) =>
[filename, previous_filename].filter(
@@ -248,19 +287,26 @@ jobs:
);
}
changelogOnly =
const exactChangelogDiff =
files.length === 1 &&
files[0].filename === 'CHANGELOG.md' &&
files[0].status === 'modified' &&
!files[0].previous_filename;
releaseSealOnly = isExactReleaseSeal(files);
changelogOnly = exactChangelogDiff || releaseSealOnly;
changelogChanged = files.some(
({ filename, previous_filename }) =>
filename === 'CHANGELOG.md' ||
previous_filename === 'CHANGELOG.md'
);
classifyFiles(true);
if (releaseSealOnly) {
fullSuite = false;
}
fastPathTrust = changelogOnly
? 'exact pull-request revision and synthetic merge policy'
? releaseSealOnly
? 'exact release-seal fragment archival and synthetic merge policy'
: 'exact CHANGELOG-only revision and synthetic merge policy'
: docsOnly
? 'documentation-only focused admission'
: fullSuite
@@ -295,7 +341,8 @@ jobs:
per_page: 100,
});
files = Array.isArray(comparison.files) ? comparison.files : [];
classifyFiles(files.length < 300);
const pushFilesComplete = files.length < 300;
classifyFiles(pushFilesComplete);
const linearFromValidatedTip =
comparison.status === 'ahead' &&
comparison.merge_base_commit?.sha === expectedBefore &&
@@ -307,8 +354,10 @@ jobs:
files[0].filename === 'CHANGELOG.md' &&
files[0].status === 'modified' &&
!files[0].previous_filename;
const exactReleaseSealDiff =
pushFilesComplete && isExactReleaseSeal(files);
if (linearFromValidatedTip && exactChangelogDiff) {
if (linearFromValidatedTip && (exactChangelogDiff || exactReleaseSealDiff)) {
const requiredContexts = [
'Lint',
'Test',
@@ -359,9 +408,15 @@ jobs:
if (missing.length === 0 && nonSuccess.length === 0) {
changelogOnly = true;
releaseSealOnly = exactReleaseSealDiff;
changelogChanged = true;
if (releaseSealOnly) {
fullSuite = false;
}
fastPathTrust =
`exact CHANGELOG-only successor of validated ${expectedBefore}`;
releaseSealOnly
? `exact release-seal successor of validated ${expectedBefore}`
: `exact CHANGELOG-only successor of validated ${expectedBefore}`;
} else {
fastPathTrust =
'predecessor Code Admission is not fully successful; ' +
@@ -376,6 +431,7 @@ jobs:
}
core.setOutput('changelog_only', String(changelogOnly));
core.setOutput('release_seal_only', String(releaseSealOnly));
core.setOutput('changelog_changed', String(changelogChanged));
core.setOutput('docs_only', String(docsOnly));
core.setOutput('full_suite', String(fullSuite));
@@ -387,7 +443,8 @@ jobs:
await core.summary
.addHeading('Code Admission scope')
.addRaw(`- Event: \`${context.eventName}\`\n`)
.addRaw(`- Exact modified CHANGELOG only: \`${changelogOnly}\`\n`)
.addRaw(`- Metadata-only fast path: \`${changelogOnly}\`\n`)
.addRaw(`- Release-seal fragments only: \`${releaseSealOnly}\`\n`)
.addRaw(`- CHANGELOG touched: \`${changelogChanged}\`\n`)
.addRaw(`- Documentation-only: \`${docsOnly}\`\n`)
.addRaw(`- Full suite: \`${fullSuite}\`\n`)
@@ -402,7 +459,14 @@ jobs:
- name: Record CHANGELOG-only fast path
if: steps.classify.outputs.changelog_only == 'true'
run: echo "Lint is satisfied by the trusted CHANGELOG-only Policy path." >> "$GITHUB_STEP_SUMMARY"
env:
RELEASE_SEAL_ONLY: ${{ steps.classify.outputs.release_seal_only }}
run: |
if [ "$RELEASE_SEAL_ONLY" = true ]; then
echo "Lint is satisfied by the trusted release-seal fragment Policy path." >> "$GITHUB_STEP_SUMMARY"
else
echo "Lint is satisfied by the trusted CHANGELOG-only Policy path." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Record documentation-only fast path
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only == 'true'
@@ -1256,6 +1320,7 @@ jobs:
env:
CLASSIFIED_CHANGELOG_CHANGED: ${{ needs.lint.outputs.changelog_changed }}
CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }}
RELEASE_SEAL_ONLY: ${{ needs.lint.outputs.release_seal_only }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -eu
@@ -1282,7 +1347,7 @@ jobs:
fi
mode=--content-only
if [ "$CHANGELOG_ONLY" = true ]; then
if [ "$CHANGELOG_ONLY" = true ] && [ "$RELEASE_SEAL_ONLY" != true ]; then
mode=--fast-path
fi
./scripts/policy/check-changelog-pr.sh \
@@ -1294,25 +1359,41 @@ jobs:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: ./scripts/policy/check-release-fragments.sh "$PR_BASE_SHA" HEAD
- name: Validate trusted main CHANGELOG-only push
- name: Validate trusted main metadata-only push
if: github.event_name == 'push' && needs.lint.outputs.changelog_only == 'true'
env:
PUSH_BEFORE_SHA: ${{ github.event.before }}
PUSH_AFTER_SHA: ${{ github.event.after }}
RELEASE_SEAL_ONLY: ${{ needs.lint.outputs.release_seal_only }}
run: |
set -eu
test "$(git rev-parse HEAD)" = "$PUSH_AFTER_SHA" || {
echo "checked-out push revision does not match event after SHA" >&2
exit 1
}
mode=--fast-path
if [ "$RELEASE_SEAL_ONLY" = true ]; then
mode=--content-only
fi
./scripts/policy/check-changelog-pr.sh \
--fast-path "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA"
"$mode" "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA"
if [ "$RELEASE_SEAL_ONLY" = true ]; then
./scripts/policy/check-release-fragments.sh \
"$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA"
fi
- name: Record CHANGELOG-only fast path
if: needs.lint.outputs.changelog_only == 'true'
env:
RELEASE_SEAL_ONLY: ${{ needs.lint.outputs.release_seal_only }}
run: |
echo "Only the trusted base-equivalent CHANGELOG validator ran; executable sources are unchanged." \
>> "$GITHUB_STEP_SUMMARY"
if [ "$RELEASE_SEAL_ONLY" = true ]; then
echo "Only the trusted release-seal and fragment validators ran; executable sources are unchanged." \
>> "$GITHUB_STEP_SUMMARY"
else
echo "Only the trusted base-equivalent CHANGELOG validator ran; executable sources are unchanged." \
>> "$GITHUB_STEP_SUMMARY"
fi
- name: Validate scoped policy
if: ${{ needs.lint.outputs.changelog_only != 'true' && (needs.lint.outputs.docs_only == 'true' || (needs.lint.outputs.full_suite != 'true' && needs.lint.outputs.interface_sensitive != 'true')) }}
+34
View File
@@ -2645,6 +2645,40 @@ jobs:
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
# The sealed candidate tag is intentionally visible while its GitHub
# authority is checked above. Compatibility must instead discover the
# previous delivered stable tag, so hide only this verified candidate
# from this isolated runner's local tag namespace.
- name: Prepare delivered-stable compatibility ref view
if: ${{ matrix.check == 'compatibility' }}
env:
RELEASE_VERSION: ${{ needs.release-contract.outputs.release_version }}
RELEASE_COMMIT: ${{ needs.release-contract.outputs.release_commit }}
RELEASE_TAG_OBJECT: ${{ needs.release-contract.outputs.release_tag_object }}
PREVIOUS_STABLE: ${{ needs.release-contract.outputs.previous_stable }}
PREVIOUS_STABLE_COMMIT: ${{ needs.release-contract.outputs.previous_stable_commit }}
run: |
set -eu
test -n "$RELEASE_VERSION"
test -n "$RELEASE_COMMIT"
test -n "$RELEASE_TAG_OBJECT"
test -n "$PREVIOUS_STABLE"
test -n "$PREVIOUS_STABLE_COMMIT"
test "$RELEASE_VERSION" != "$PREVIOUS_STABLE"
test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT"
test "$(git rev-parse --verify "refs/tags/${RELEASE_VERSION}")" = "$RELEASE_TAG_OBJECT"
test "$(git rev-parse --verify "refs/tags/${RELEASE_VERSION}^{commit}")" = "$RELEASE_COMMIT"
test "$(git rev-parse --verify "${PREVIOUS_STABLE}^{commit}")" = "$PREVIOUS_STABLE_COMMIT"
git update-ref -d "refs/tags/${RELEASE_VERSION}" "$RELEASE_TAG_OBJECT"
if git show-ref --verify --quiet "refs/tags/${RELEASE_VERSION}"; then
echo "sealed candidate tag is still visible to compatibility baseline discovery" >&2
exit 2
fi
test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT"
test "$(git rev-parse --verify "${PREVIOUS_STABLE}^{commit}")" = "$PREVIOUS_STABLE_COMMIT"
- name: Set up Go
uses: actions/setup-go@v5
with:
+58
View File
@@ -6,6 +6,64 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.58] - 2026-08-13
This release promotes the sealed `v1.0.58-beta.6` contents to stable.
### Changed
- **Expanded collaborative workflows** — adds full AI Table, Sheet, Minutes,
approval-event, Drive-comment, document export, and CSV workflow support,
including safer validation, explicit confirmation for writes, and
machine-readable completion receipts.
- **More capable Chat operations** — adds robot image/file messages, toolbar
management, streaming-card mentions, automatic pagination controls, and
clearer post-send ID, Markdown-image, paging, and result-shape guidance.
- **Reliable Agent and CLI contracts** — expands Agent-visible Chat and
Minutes commands, aligns bundled skills, improves schema/result envelopes,
and hardens parameter, pagination, runtime-token, and write-result
verification so ambiguous or incomplete operations fail closed.
- **Multi-skill install and upgrade** — makes the multi-skill layout the
default for fresh installs and upgrades while preserving an explicit legacy
mono option.
- **Safer release delivery** — strengthens release-equivalent compatibility,
sealing, package verification, and evaluation-dispatch checks for more
reliable cross-platform releases.
## [1.0.58-beta.6] - 2026-08-13
### Fixed
- **npm package verification for multi-skill installs** (#991) — aligns the
release verifier with the installer’s concrete Agent skill-root selection,
preventing valid multi-skill package layouts from failing release delivery.
### Changed
- **Release-seal CI classification** (#987) — recognizes the reviewed
CHANGELOG-and-fragment archival shape while retaining release-contract and
lifecycle validation, reducing unrelated CI work for release-seal PRs.
## [1.0.58-beta.5] - 2026-08-13
### Added
- **Agent version and extended context passthrough** (Aone 85384225) — adds
validated `DWS_AGENT_VER` and sensitive JSON `DWS_AGENT_EXT` metadata to
ordinary non-plugin MCP requests without forwarding it to A2A, OAuth,
Discovery, or third-party plugins.
- **Drive file comments** (#961) — adds `dws drive comment list` and `dws drive comment create` for comments on ordinary preview files.
- **Chat automatic pagination controls** (#970) — adds bounded `--max-items` and cancellable `--page-delay` support to the core IM list shortcuts, with safe continuation metadata and truncation reporting.
### Changed
- **Chat message send help** - Clarifies Markdown image syntax for inline mixed text and images.
- **Doc/drive/wiki routing descriptions** — clarifies the document-space container-vs-content boundary across the doc, drive, and wiki skill descriptions for more predictable first-round Agent selection, without changing CLI behavior.
## [1.0.58-beta.4] - 2026-08-12
### Added
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.58-beta.4"
version "1.0.58-beta.6"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-darwin-arm64.tar.gz"
sha256 "5c2ac92e35b1f1dba80234af8b0c9505b2883f4a37c1e73892b8a1c3087b7702"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-darwin-arm64.tar.gz"
sha256 "8f55497b84113f81b318e087c723016a02eded1cb5784cbd0811fe527d5852ca"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-darwin-amd64.tar.gz"
sha256 "93ef787770105fe1f0d27585adcac7b740aa6c37ff490275c4113814541ae095"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-darwin-amd64.tar.gz"
sha256 "b1762f1640310fb4100634fe54d9baace46384bb270c59148fe306275554d491"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-linux-arm64.tar.gz"
sha256 "011ce16a73d8fd24275e34c3122d3d0832c60cde2480f496018eb654059b5c05"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-linux-arm64.tar.gz"
sha256 "55393310ef0e1f24ea2c0dc22f00c0eb3b343edc2deb18d0db7838cda65af25d"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-linux-amd64.tar.gz"
sha256 "847b17ff8a8d80dce38f0013eb35c77c102be16c9f98b955a632b983cd5ec104"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-linux-amd64.tar.gz"
sha256 "3830f77d09b4da4aa39f0c08d772ff3fa1ffb837eb15bb417f97edbccb073b7d"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-skills.zip"
sha256 "f5e0c72cc92cb7e8886409319cf68bbbfc7740e969bd39a389b74af4befdbc66"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-skills.zip"
sha256 "f304a883a4f9e938b26a44692cd5a8d3d8704ba70ee7f33ba7c288434da72b6f"
end
def install
+4 -4
View File
@@ -322,7 +322,7 @@ func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
caller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasE2E(t, caller,
"chat", "message", "send",
"--to-user", "D-recipient",
"--to-user", appFixtureCurrentDOpenID,
"--text", "hello alias",
"--uuid", "alias-e2e",
)
@@ -336,7 +336,7 @@ func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
t.Fatalf("chat calls = %#v", caller.calls)
}
payload := caller.calls[0].args
if payload["receiverOpenDingTalkId"] != "D-recipient" || payload["uuid"] != "alias-e2e" || payload["msgType"] != "markdown" {
if payload["receiverOpenDingTalkId"] != appFixtureCurrentDOpenID || payload["uuid"] != "alias-e2e" || payload["msgType"] != "markdown" {
t.Fatalf("chat payload identity fields = %#v", payload)
}
content, _ := payload["content"].(string)
@@ -627,11 +627,11 @@ func TestCrossPlatformCoverageSelectedParamAliasesProduceCanonicalEquivalentDryR
tool: "send_personal_message",
canonicalArgs: []string{
"--dry-run", "chat", "message", "send",
"--user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run",
"--user", appFixtureCurrentDOpenID, "--text", "hello dry-run", "--uuid", "alias-dry-run",
},
aliasArgs: []string{
"--dry-run", "chat", "message", "send",
"--to-user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run",
"--to-user", appFixtureCurrentDOpenID, "--text", "hello dry-run", "--uuid", "alias-dry-run",
},
wantCorrections: 1,
wantArgKeys: []string{"clawType", "content", "msgType", "receiverOpenDingTalkId", "uuid"},
@@ -12,6 +12,11 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
const (
appFixtureCurrentDOpenID = "DAAAAAAAAAAAiE"
appFixtureCurrentDOpenID2 = "DAQEBAQEBAQEiE"
)
// paramAliasCompleteCommands is deliberately keyed by the exact reviewed
// fixture command path. Every argv is a complete, business-valid invocation:
// required companion flags are present, time and enum values are valid, and
@@ -32,12 +37,12 @@ var paramAliasCompleteCommands = map[string][]string{
"chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"},
"chat +chat-add-bot": {"chat", "+chat-add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
"chat +chat-audit-join": {"chat", "+chat-audit-join", "--group", "fixture-conversation", "--record-id", "7", "--applicant", "user-1", "--inviter", "user-2", "--status", "AuditApprove", "--yes"},
"chat +chat-members-get": {"chat", "+chat-members-get", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
"chat +chat-members-get": {"chat", "+chat-members-get", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID + "," + appFixtureCurrentDOpenID2},
"chat +chat-members-list": {"chat", "+chat-members-list", "--conversation-id", "fixture-conversation", "--member-types", "user,bot"},
"chat +chat-mute-member": {"chat", "+chat-mute-member", "--group", "fixture-conversation", "--users", "D-user-1,D-user-2", "--mute-time", "3600000", "--yes"},
"chat +chat-mute-member": {"chat", "+chat-mute-member", "--group", "fixture-conversation", "--users", appFixtureCurrentDOpenID + "," + appFixtureCurrentDOpenID2, "--mute-time", "3600000", "--yes"},
"chat +chat-remove-bot": {"chat", "+chat-remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
"chat +chat-role-remove-user": {"chat", "+chat-role-remove-user", "--group", "fixture-conversation", "--user", "D-user-1", "--role-ids", "role-1", "--yes"},
"chat +chat-transfer-owner": {"chat", "+chat-transfer-owner", "--group", "fixture-conversation", "--new-owner", "D-user-1", "--yes"},
"chat +chat-role-remove-user": {"chat", "+chat-role-remove-user", "--group", "fixture-conversation", "--user", appFixtureCurrentDOpenID, "--role-ids", "role-1", "--yes"},
"chat +chat-transfer-owner": {"chat", "+chat-transfer-owner", "--group", "fixture-conversation", "--new-owner", appFixtureCurrentDOpenID, "--yes"},
"chat +chat-update": {"chat", "+chat-update", "--group", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
"chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"},
"chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
@@ -55,7 +60,7 @@ var paramAliasCompleteCommands = map[string][]string{
"chat +messages-list": {"chat", "+messages-list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"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-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
"chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--text", "hello fixture", "--yes"},
"chat +messages-resource-download": {"chat", "+messages-resource-download", "--resource-id", "resource-1", "--message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--output", "downloads/fixture.bin"},
"chat +messages-set-pin": {"chat", "+messages-set-pin", "--open-conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
"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"},
@@ -68,10 +73,10 @@ var paramAliasCompleteCommands = map[string][]string{
"chat category create-smart": {"chat", "category", "create-smart", "--name", "Fixture Smart Category", "--keywords", "fixture,priority", "--yes"},
"chat category rename": {"chat", "category", "rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"},
"chat group members": {"chat", "group", "members", "--id", "fixture-conversation"},
"chat group members add": {"chat", "group", "members", "add", "--id", "fixture-conversation", "--users", "D-user-1"},
"chat group members add": {"chat", "group", "members", "add", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID},
"chat group members add-bot": {"chat", "group", "members", "add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
"chat group members list-by-ids": {"chat", "group", "members", "list-by-ids", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
"chat group members remove": {"chat", "group", "members", "remove", "--id", "fixture-conversation", "--users", "D-user-1", "--yes"},
"chat group members list-by-ids": {"chat", "group", "members", "list-by-ids", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID + "," + appFixtureCurrentDOpenID2},
"chat group members remove": {"chat", "group", "members", "remove", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID, "--yes"},
"chat group members remove-bot": {"chat", "group", "members", "remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
"chat group rename": {"chat", "group", "rename", "--id", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
"chat group set-admin": {"chat", "group", "set-admin", "--group", "fixture-conversation", "--user", "user-1", "--yes"},
@@ -86,9 +91,9 @@ var paramAliasCompleteCommands = map[string][]string{
"chat message list-by-ids": {"chat", "message", "list-by-ids", "--msg-ids", "message-1,message-2"},
"chat message list-unread-conversations": {"chat", "message", "list-unread-conversations", "--count", "7", "--exclude-muted"},
"chat message recall": {"chat", "message", "recall", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
"chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
"chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--text", "hello fixture", "--yes"},
"chat message search-advanced": {"chat", "message", "search-advanced", "--conversation-ids", "fixture-conversation", "--query", "fixture"},
"chat message send": {"chat", "message", "send", "--user", "D-recipient", "--text", "hello fixture", "--uuid", "param-alias-equivalence", "--yes"},
"chat message send": {"chat", "message", "send", "--user", appFixtureCurrentDOpenID, "--text", "hello fixture", "--uuid", "param-alias-equivalence", "--yes"},
"chat message send-by-bot": {"chat", "message", "send-by-bot", "--robot-code", "robot-1", "--group", "fixture-conversation", "--title", "Fixture Alert", "--text", "@user-1 @user-2 fixture", "--at-user-ids", "user-1,user-2", "--yes"},
"chat message send-by-webhook": {"chat", "message", "send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
"contact +dept-members": {"contact", "+dept-members", "--dept", "Fixture Dept"},
@@ -160,7 +165,7 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{
"user": {"chat", "message", "list", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
},
"chat message list-by-sender": {
"sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", "D-sender", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
"sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", appFixtureCurrentDOpenID, "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
},
"chat message send": {
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--uuid", "param-alias-equivalence-group", "--yes"},
+25 -5
View File
@@ -491,6 +491,11 @@ func chatMessageListBySenderArgs(cmd *cobra.Command) (map[string]any, error) {
if senderUserID == "" && senderOpenDingTalkID == "" {
return nil, fmt.Errorf("--sender-user-id or --sender-open-dingtalk-id is required")
}
if senderOpenDingTalkID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--sender-open-dingtalk-id", senderOpenDingTalkID); err != nil {
return nil, err
}
}
startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start"))
if err != nil {
return nil, err
@@ -993,8 +998,7 @@ func isNumericUserID(value string) bool {
}
func isOpenDingTalkID(value string) bool {
value = strings.TrimSpace(value)
return len(value) > 0 && (value[0] == 'D' || value[0] == 'd')
return targetresolver.LooksLikeCurrentDOpenDingTalkID(value)
}
// webhookErrcodeFailure 解析自定义机器人 webhook 的响应,判定是否发送失败。
@@ -2636,6 +2640,11 @@ func newChatCommand() *cobra.Command {
if specified == 0 {
return fmt.Errorf("--group, --user or --open-dingtalk-id is required")
}
if openDingTalkID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
return err
}
}
if userID != "" && isOpenDingTalkID(userID) {
openDingTalkID = userID
userID = ""
@@ -2723,6 +2732,11 @@ func newChatCommand() *cobra.Command {
if userID == "" && openDingTalkID == "" {
return fmt.Errorf("--user or --open-dingtalk-id is required")
}
if openDingTalkID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
return err
}
}
if userID != "" && isOpenDingTalkID(userID) {
openDingTalkID = userID
userID = ""
@@ -2852,6 +2866,11 @@ func newChatCommand() *cobra.Command {
if specified == 0 {
return fmt.Errorf("--group, --user or --open-dingtalk-id is required")
}
if openDingTalkID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
return err
}
}
if userID != "" && isOpenDingTalkID(userID) {
openDingTalkID = userID
userID = ""
@@ -4667,9 +4686,10 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
userID := rawUserID
openDingTalkID := rawOpenDingTalkID
if openDingTalkID != "" && !isOpenDingTalkID(openDingTalkID) {
userID = openDingTalkID
openDingTalkID = ""
if openDingTalkID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
return err
}
}
if userID != "" && isOpenDingTalkID(userID) {
openDingTalkID = userID
+25 -4
View File
@@ -14,6 +14,11 @@ import (
"github.com/spf13/cobra"
)
const (
helperCurrentDOpenID = "DAAAAAAAAAAAiE"
helperCurrentDOpenID2 = "DAQEBAQEBAQEiE"
)
func newChatFlagTestCommand() *cobra.Command {
cmd := &cobra.Command{Use: "chat"}
cmd.Flags().String("forward", "", "")
@@ -37,6 +42,22 @@ func newChatFlagTestCommand() *cobra.Command {
return cmd
}
func TestNativeChatIDSplitUsesCurrentDFormat(t *testing.T) {
userIDs, openIDs := splitChatIDValues([]string{
helperCurrentDOpenID,
"D-prefix-fixture-user",
"d-prefix-fixture-user",
"D-invalid",
"fixture-user-id",
})
if len(openIDs) != 1 || openIDs[0] != helperCurrentDOpenID {
t.Fatalf("open IDs=%#v", openIDs)
}
if got := strings.Join(userIDs, ","); got != "D-prefix-fixture-user,d-prefix-fixture-user,D-invalid,fixture-user-id" {
t.Fatalf("user IDs=%#v", userIDs)
}
}
func TestCrossPlatformCoverageChatDirectionAndScalarCoverage(t *testing.T) {
search := &cobra.Command{Use: "search"}
search.Flags().String("nicks", "", "")
@@ -151,18 +172,18 @@ func TestCrossPlatformCoverageChatContactMappingCoverage(t *testing.T) {
}
func TestCrossPlatformCoverageResolveOpenDingTalkIDsCoverage(t *testing.T) {
if id, err := resolveOpenDingTalkID(context.Background(), "D-direct"); err != nil || id != "D-direct" {
if id, err := resolveOpenDingTalkID(context.Background(), helperCurrentDOpenID); err != nil || id != helperCurrentDOpenID {
t.Fatalf("direct ID = %q, %v", id, err)
}
if _, err := resolveOpenDingTalkID(context.Background(), ""); err == nil {
t.Fatal("empty ID unexpectedly resolved")
}
if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{" D1 ", ""}); err != nil || ids[0] != "D1" {
if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{" " + helperCurrentDOpenID + " ", ""}); err != nil || ids[0] != helperCurrentDOpenID {
t.Fatalf("direct IDs = %#v, %v", ids, err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"D1"}]}`}}}
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"` + helperCurrentDOpenID2 + `"}]}`}}}
installScriptedCaller(t, caller)
if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{"u1", "u1"}); err != nil || ids[1] != "D1" {
if ids, err := resolveOpenDingTalkIDs(context.Background(), []string{"u1", "u1"}); err != nil || ids[1] != helperCurrentDOpenID2 {
t.Fatalf("resolved IDs = %#v, %v", ids, err)
}
caller.steps = []scriptedToolStep{{text: `{}`}, {text: `{}`}}
+4 -4
View File
@@ -178,7 +178,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) {
caller := &chatFilePathCaller{}
commandArgs := []string{
"message", "send",
"--open-dingtalk-id=D-target",
"--open-dingtalk-id=" + helperCurrentDOpenID,
"--msg-type=file",
"--file-path=" + filePath,
}
@@ -203,7 +203,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) {
server: "im",
tool: "init_conversation_file_upload",
args: map[string]any{
"openDingTalkId": "D-target",
"openDingTalkId": helperCurrentDOpenID,
"fileName": "report.pdf",
"fileSize": int64(len(payload)),
"md5": fileMD5,
@@ -216,7 +216,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) {
server: "im",
tool: "commit_conversation_file_upload",
args: map[string]any{
"openDingTalkId": "D-target",
"openDingTalkId": helperCurrentDOpenID,
"uploadKey": "upload-key",
"fileName": "report.pdf",
"fileSize": int64(len(payload)),
@@ -231,7 +231,7 @@ func TestChatMessageSendFilePathUsesOpenDingTalkIDTarget(t *testing.T) {
if send.server != "chat" || send.tool != "send_personal_message" || send.args["msgType"] != "file" {
t.Fatalf("send direct target call = %#v", send)
}
if send.args["receiverOpenDingTalkId"] != "D-target" {
if send.args["receiverOpenDingTalkId"] != helperCurrentDOpenID {
t.Fatalf("send direct target = %#v", send.args)
}
if _, ok := send.args["openDingTalkId"]; ok {
+26 -26
View File
@@ -786,11 +786,11 @@ func TestChatSendAndReplyDefaultToAgentProductForIMClawType(t *testing.T) {
}{
{
name: "send",
args: []string{"message", "send", "--open-dingtalk-id", "D1", "--text", "hello"},
args: []string{"message", "send", "--open-dingtalk-id", helperCurrentDOpenID, "--text", "hello"},
},
{
name: "reply",
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", "D1", "--text", "hello"},
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID, "--text", "hello"},
},
}
@@ -819,11 +819,11 @@ func TestChatSendAndReplyDisableAITagWithEmptyClawType(t *testing.T) {
}{
{
name: "send",
args: []string{"message", "send", "--open-dingtalk-id", "D1", "--text", "hello", "--ai-tag=false"},
args: []string{"message", "send", "--open-dingtalk-id", helperCurrentDOpenID, "--text", "hello", "--ai-tag=false"},
},
{
name: "reply",
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", "D1", "--text", "hello", "--ai-tag=false"},
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID, "--text", "hello", "--ai-tag=false"},
},
}
@@ -857,25 +857,25 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
name: "send",
args: []string{
"message", "send", "--group", "cid",
"--text", "收到 @D-target 和 <@D-second>",
"--at-open-dingtalk-ids", "D-target,D-second",
"--text", "收到 @" + helperCurrentDOpenID + " 和 <@" + helperCurrentDOpenID2 + ">",
"--at-open-dingtalk-ids", helperCurrentDOpenID + "," + helperCurrentDOpenID2,
"--at-all",
},
contentField: "text",
wantContent: "<@all> 收到 <@D-target> 和 <@D-second>",
wantContent: "<@all> 收到 <@" + helperCurrentDOpenID + "> 和 <@" + helperCurrentDOpenID2 + ">",
wantAtAll: true,
wantOpenIDs: []string{"D-target", "D-second"},
wantOpenIDs: []string{helperCurrentDOpenID, helperCurrentDOpenID2},
},
{
name: "send keeps missing member placeholders unchanged",
args: []string{
"message", "send", "--group", "cid",
"--text", "DWS 发消息自测",
"--at-open-dingtalk-ids", "D-target",
"--at-open-dingtalk-ids", helperCurrentDOpenID,
},
contentField: "text",
wantContent: "DWS 发消息自测",
wantOpenIDs: []string{"D-target"},
wantOpenIDs: []string{helperCurrentDOpenID},
},
{
name: "reply",
@@ -883,15 +883,15 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--text", "收到 @D-target 和 <@D-second>",
"--at-open-dingtalk-ids", "D-target,D-second",
"--ref-sender", helperCurrentDOpenID,
"--text", "收到 @" + helperCurrentDOpenID + " 和 <@" + helperCurrentDOpenID2 + ">",
"--at-open-dingtalk-ids", helperCurrentDOpenID + "," + helperCurrentDOpenID2,
"--at-all",
},
contentField: "content",
wantContent: "<@all> 收到 <@D-target> 和 <@D-second>",
wantContent: "<@all> 收到 <@" + helperCurrentDOpenID + "> 和 <@" + helperCurrentDOpenID2 + ">",
wantAtAll: true,
wantOpenIDs: []string{"D-target", "D-second"},
wantOpenIDs: []string{helperCurrentDOpenID, helperCurrentDOpenID2},
},
{
name: "reply adds missing member placeholders",
@@ -899,13 +899,13 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--text", "DWS 回复艾特前津(非主用)自测",
"--at-open-dingtalk-ids", "D-target,D-second,D-target",
"--ref-sender", helperCurrentDOpenID,
"--text", "DWS synthetic reply mention test",
"--at-open-dingtalk-ids", helperCurrentDOpenID + "," + helperCurrentDOpenID2 + "," + helperCurrentDOpenID,
},
contentField: "content",
wantContent: "<@D-target> <@D-second> DWS 回复艾特前津(非主用)自测",
wantOpenIDs: []string{"D-target", "D-second", "D-target"},
wantContent: "<@" + helperCurrentDOpenID + "> <@" + helperCurrentDOpenID2 + "> DWS synthetic reply mention test",
wantOpenIDs: []string{helperCurrentDOpenID, helperCurrentDOpenID2, helperCurrentDOpenID},
},
{
name: "reply adds missing member placeholders after at-all",
@@ -913,15 +913,15 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--ref-sender", helperCurrentDOpenID,
"--text", "请大家确认",
"--at-open-dingtalk-ids", "D-target",
"--at-open-dingtalk-ids", helperCurrentDOpenID,
"--at-all",
},
contentField: "content",
wantContent: "<@all> <@D-target> 请大家确认",
wantContent: "<@all> <@" + helperCurrentDOpenID + "> 请大家确认",
wantAtAll: true,
wantOpenIDs: []string{"D-target"},
wantOpenIDs: []string{helperCurrentDOpenID},
},
{
name: "reply at-all preserves alliance word",
@@ -929,7 +929,7 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--ref-sender", helperCurrentDOpenID,
"--text", "联系 @alliance",
"--at-all",
},
@@ -943,7 +943,7 @@ func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T)
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--ref-sender", helperCurrentDOpenID,
"--text", "联系 @alliance",
},
contentField: "content",
+17 -4
View File
@@ -24,13 +24,14 @@ import (
"fmt"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
)
// isOpenID reports whether value looks like an openDingTalkId (starts with D/d),
// mirroring helpers.isOpenDingTalkID.
// isOpenID classifies mixed userId/openDingTalkId inputs using the canonical
// current-D wire format shared with helpers and smart shortcuts.
func isOpenID(v string) bool {
v = strings.TrimSpace(v)
return len(v) > 0 && (v[0] == 'D' || v[0] == 'd')
return targetresolver.LooksLikeCurrentDOpenDingTalkID(v)
}
// splitIDs partitions a mixed list of userId / openDingTalkId values, mirroring
@@ -50,6 +51,18 @@ func splitIDs(vals []string) (userIDs, openIDs []string) {
return userIDs, openIDs
}
func validateExplicitOpenIDs(flagName string, values []string) error {
for _, value := range values {
if strings.TrimSpace(value) == "" {
continue
}
if err := targetresolver.ValidateExplicitOpenDingTalkID(flagName, value); err != nil {
return err
}
}
return nil
}
// toInt64Slice converts string values to []int64, mirroring helpers.parseCSVInt64.
func toInt64Slice(vals []string) ([]int64, error) {
out := make([]int64, 0, len(vals))
@@ -25,6 +25,7 @@ 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"
)
// ConversationInfo gets conversation info (get_conversation_info, chat server).
@@ -71,6 +72,9 @@ var ConversationInfo = shortcut.Shortcut{
params["openConversationId"] = rt.Str("group")
}
if rt.Str("open-dingtalk-id") != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", rt.Str("open-dingtalk-id")); err != nil {
return err
}
params["openDingTalkId"] = rt.Str("open-dingtalk-id")
}
if len(params) == 0 {
+3
View File
@@ -409,6 +409,9 @@ var ChatMembersGet = shortcut.Shortcut{
},
Tips: []string{`dws chat +chat-members-get --id <openConversationId> --users odid1,odid2`},
Execute: func(rt *shortcut.RuntimeContext) error {
if err := validateExplicitOpenIDs("--users", rt.StrSlice("users")); err != nil {
return err
}
return rt.CallMCP("list_group_member_by_ids", map[string]any{
"openConversationId": rt.Str("id"),
"cid": rt.Str("id"),
+18
View File
@@ -29,6 +29,7 @@ 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 directMessagesHardPageLimit = 500
@@ -55,6 +56,9 @@ var MessagesSendByBot = shortcut.Shortcut{
},
Tips: []string{`dws chat +messages-send-by-bot --robot-code <robotCode> --group <openConversationId> --title "日报" --text "## 今日完成"`},
Execute: func(rt *shortcut.RuntimeContext) error {
if err := validateExplicitOpenIDs("--at-open-dingtalk-ids", rt.StrSlice("at-open-dingtalk-ids")); err != nil {
return err
}
params := map[string]any{
"robotCode": rt.Str("robot-code"),
"openConversationId": rt.Str("group"),
@@ -93,6 +97,9 @@ var MessagesBatchSendByBot = shortcut.Shortcut{
},
Tips: []string{`dws chat +messages-batch-send-by-bot --robot-code <robotCode> --users userId1,userId2 --title "提醒" --text "请提交周报"`},
Execute: func(rt *shortcut.RuntimeContext) error {
if err := validateExplicitOpenIDs("--open-dingtalk-ids", rt.StrSlice("open-dingtalk-ids")); err != nil {
return err
}
params := map[string]any{
"robotCode": rt.Str("robot-code"),
"title": rt.Str("title"),
@@ -493,6 +500,9 @@ func executeMessagesListDirect(rt *shortcut.RuntimeContext) error {
}
switch {
case rt.Str("open-dingtalk-id") != "":
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", rt.Str("open-dingtalk-id")); err != nil {
return err
}
params["openDingTalkId"] = rt.Str("open-dingtalk-id")
case rt.Str("user") != "":
params["userId"] = rt.Str("user")
@@ -1515,6 +1525,14 @@ var MessagesSendCard = shortcut.Shortcut{
`dws chat +messages-send-card --group <openConversationId> --at-open-dingtalk-ids <openDingTalkId> --content "任务已完成"`,
},
Validate: func(rt *shortcut.RuntimeContext) error {
if receiverOpenID := rt.Str("receiver-open-dingtalk-id"); receiverOpenID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--receiver-open-dingtalk-id", receiverOpenID); err != nil {
return err
}
}
if err := validateExplicitOpenIDs("--at-open-dingtalk-ids", rt.StrSlice("at-open-dingtalk-ids")); err != nil {
return err
}
if status := rt.Int("flow-status"); !validCardFlowStatus(status) {
return fmt.Errorf("--flow-status 必须在 1-5 之间")
}
@@ -26,6 +26,11 @@ import (
"github.com/spf13/cobra"
)
const (
fixtureCurrentDOpenID = "DAAAAAAAAAAAiE"
fixtureCurrentDOpenID2 = "DAQEBAQEBAQEiE"
)
type platformCoverageCaller struct {
product string
tool string
@@ -53,7 +58,7 @@ func (f *muteMemberResolutionCaller) CallTool(_ context.Context, product, tool s
case "contact/get_user_info_by_user_ids":
text = `{"result":[{"orgEmployeeModel":{"orgUserId":"user-2","orgUserName":"测试成员"}}]}`
case "chat/get_group_members":
text = `{"result":{"hasMore":false,"list":[{"memberEmpName":"测试成员","openDingtalkId":"D-open-2"}]}}`
text = `{"result":{"hasMore":false,"list":[{"memberEmpName":"测试成员","openDingtalkId":"` + fixtureCurrentDOpenID2 + `"}]}}`
case "im/set_group_member_mute_list":
text = `{"success":true}`
default:
@@ -235,8 +240,10 @@ func TestCrossPlatformCoverageChatIDHelpers(t *testing.T) {
value string
want bool
}{
{value: "DingTalk-open-id", want: true},
{value: " dingtalk-open-id ", want: true},
{value: "DAAAAAAAAAAAiE", want: true},
{value: " DAAAAAAAAAAAiE ", want: true},
{value: "D-prefix-fixture-user", want: false},
{value: "d-prefix-fixture-user", want: false},
{value: "user-id", want: false},
{value: " ", want: false},
}
@@ -248,11 +255,11 @@ func TestCrossPlatformCoverageChatIDHelpers(t *testing.T) {
})
t.Run("split mixed IDs", func(t *testing.T) {
userIDs, openIDs := splitIDs([]string{" user-1 ", "", "D-open-1", "d-open-2", "user-2"})
if want := []string{"user-1", "user-2"}; !reflect.DeepEqual(userIDs, want) {
userIDs, openIDs := splitIDs([]string{" user-1 ", "", "DAAAAAAAAAAAiE", "d-open-2", "D-prefix-fixture-user", "user-2"})
if want := []string{"user-1", "d-open-2", "D-prefix-fixture-user", "user-2"}; !reflect.DeepEqual(userIDs, want) {
t.Fatalf("user IDs = %#v, want %#v", userIDs, want)
}
if want := []string{"D-open-1", "d-open-2"}; !reflect.DeepEqual(openIDs, want) {
if want := []string{"DAAAAAAAAAAAiE"}; !reflect.DeepEqual(openIDs, want) {
t.Fatalf("open IDs = %#v, want %#v", openIDs, want)
}
})
@@ -281,7 +288,7 @@ func TestCrossPlatformCoverageChatMuteMemberResolvesUserIDToOpenDingTalkID(t *te
root.SetArgs([]string{
"chat", "+chat-mute-member",
"--group", "cid-1",
"--users", "user-2,D-open-2",
"--users", "user-2," + fixtureCurrentDOpenID2,
"--mute-time", "300000",
"--yes",
})
@@ -298,7 +305,7 @@ func TestCrossPlatformCoverageChatMuteMemberResolvesUserIDToOpenDingTalkID(t *te
if _, ok := final.args["uids"]; ok {
t.Fatalf("known-broken uids argument leaked into final call: %#v", final.args)
}
if got, want := final.args["openDingTalkIds"], []string{"D-open-2"}; !reflect.DeepEqual(got, want) {
if got, want := final.args["openDingTalkIds"], []string{fixtureCurrentDOpenID2}; !reflect.DeepEqual(got, want) {
t.Fatalf("openDingTalkIds = %#v, want %#v", got, want)
}
}
@@ -225,7 +225,7 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
{
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"},
args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--ref-sender", fixtureCurrentDOpenID, "--text", "收到", "--yes"},
wantError: "fixture lower call failed",
},
}
@@ -259,7 +259,7 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
}}
helpers.InitDeps(external)
root = newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+chat-create", "--name", "群", "--owner-open-dingtalk-id", "D-owner", "--member-query", "外部联系人", "--yes"})
root.SetArgs([]string{"chat", "+chat-create", "--name", "群", "--owner-open-dingtalk-id", fixtureCurrentDOpenID, "--member-query", "外部联系人", "--yes"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
@@ -400,17 +400,17 @@ func TestCrossPlatformCoverageUnifiedSendValidationMatrix(t *testing.T) {
cases := [][]string{
{"--identity", "user", "--group", "cid", "--users", "u1", "--text", "x"},
{"--identity", "user", "--text", "x"},
{"--identity", "user", "--group", "cid", "--open-dingtalk-id", "D1", "--text", "x"},
{"--identity", "user", "--group", "cid", "--open-dingtalk-id", fixtureCurrentDOpenID, "--text", "x"},
{"--identity", "user", "--group", "cid", "--at-user-ids", "u1", "--text", "x"},
{"--identity", "user", "--open-dingtalk-id", "D1", "--at-all", "--text", "x"},
{"--identity", "user", "--open-dingtalk-id", fixtureCurrentDOpenID, "--at-all", "--text", "x"},
{"--identity", "bot", "--group", "cid", "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--users", "u1", "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--open-dingtalk-id", "D1", "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--open-dingtalk-id", fixtureCurrentDOpenID, "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--at-mobiles", "13800000000", "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--users", "u1", "--at-user-ids", "u2", "--text", "x"},
{"--identity", "webhook", "--text", "x"},
{"--identity", "webhook", "--webhook-token", "token", "--group", "cid", "--text", "x"},
{"--identity", "webhook", "--webhook-token", "token", "--at-open-dingtalk-ids", "D1", "--text", "x"},
{"--identity", "webhook", "--webhook-token", "token", "--at-open-dingtalk-ids", fixtureCurrentDOpenID, "--text", "x"},
{"--identity", "webhook", "--webhook-token", "token", "--uuid", "key", "--text", "x"},
}
for _, tail := range cases {
@@ -437,23 +437,23 @@ func TestCrossPlatformCoverageUnifiedSendOptionalArgumentsAndErrors(t *testing.T
}{
{
name: "user group mentions",
args: []string{"--identity", "user", "--group", "cid", "--text", "x", "--at-open-dingtalk-ids", "D1,D2", "--at-all"},
want: map[string]any{"atOpenDingTalkIds": []string{"D1", "D2"}, "atAll": true},
args: []string{"--identity", "user", "--group", "cid", "--text", "x", "--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2, "--at-all"},
want: map[string]any{"atOpenDingTalkIds": []string{fixtureCurrentDOpenID, fixtureCurrentDOpenID2}, "atAll": true},
},
{
name: "user direct",
args: []string{"--identity", "user", "--open-dingtalk-id", "D1", "--text", "x"},
want: map[string]any{"receiverOpenDingTalkId": "D1"},
args: []string{"--identity", "user", "--open-dingtalk-id", fixtureCurrentDOpenID, "--text", "x"},
want: map[string]any{"receiverOpenDingTalkId": fixtureCurrentDOpenID},
},
{
name: "bot group mentions",
args: []string{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--text", "x", "--at-user-ids", "u1", "--at-open-dingtalk-ids", "D1"},
want: map[string]any{"atUserIds": []string{"u1"}, "atOpendingtalkIds": []string{"D1"}},
args: []string{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--text", "x", "--at-user-ids", "u1", "--at-open-dingtalk-ids", fixtureCurrentDOpenID},
want: map[string]any{"atUserIds": []string{"u1"}, "atOpendingtalkIds": []string{fixtureCurrentDOpenID}},
},
{
name: "bot direct targets",
args: []string{"--identity", "bot", "--robot-code", "r", "--users", "u1", "--open-dingtalk-ids", "D1", "--text", "x", "--at-all"},
want: map[string]any{"userIds": []string{"u1"}, "openDingtalkIds": []string{"D1"}, "isAtAll": "true"},
args: []string{"--identity", "bot", "--robot-code", "r", "--users", "u1", "--open-dingtalk-ids", fixtureCurrentDOpenID, "--text", "x", "--at-all"},
want: map[string]any{"userIds": []string{"u1"}, "openDingtalkIds": []string{fixtureCurrentDOpenID}, "isAtAll": "true"},
},
{
name: "webhook mentions",
@@ -580,7 +580,7 @@ func TestCrossPlatformCoverageUnifiedSendGroupFileAndBatchBoundaries(t *testing.
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",
"--at-open-dingtalk-ids", fixtureCurrentDOpenID, "--at-all", "--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
@@ -590,7 +590,7 @@ func TestCrossPlatformCoverageUnifiedSendGroupFileAndBatchBoundaries(t *testing.
}
for _, call := range fake.calls {
if !reflect.DeepEqual(call.args["atUserIds"], []string{"u1"}) ||
!reflect.DeepEqual(call.args["atOpendingtalkIds"], []string{"D1"}) ||
!reflect.DeepEqual(call.args["atOpendingtalkIds"], []string{fixtureCurrentDOpenID}) ||
call.args["isAtAll"] != "true" {
t.Fatalf("batch mention args = %#v", call.args)
}
+49 -20
View File
@@ -417,8 +417,8 @@ func TestCrossPlatformCoverageChatCreateExplicitOwnerSkipsCurrentProfileAndDedup
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",
"chat", "+chat-create", "--name", "测试群", "--users", fixtureCurrentDOpenID + ",user-1",
"--owner-open-dingtalk-id", fixtureCurrentDOpenID, "--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
@@ -427,24 +427,24 @@ func TestCrossPlatformCoverageChatCreateExplicitOwnerSkipsCurrentProfileAndDedup
t.Fatalf("explicit owner calls = %#v", fake.calls)
}
create := fake.calls[0].args
if create["ownerOpenDingTalkId"] != "D-owner" {
if create["ownerOpenDingTalkId"] != fixtureCurrentDOpenID {
t.Fatalf("ownerOpenDingTalkId = %#v", create["ownerOpenDingTalkId"])
}
if got, want := create["groupMembers"], []string{"D-owner", "user-1"}; !reflect.DeepEqual(got, want) {
if got, want := create["groupMembers"], []string{fixtureCurrentDOpenID, "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"}]}`,
"contact/search_contact_by_key_word": `{"result":[{"name":"测试用户甲","userId":"owner-user","openDingTalkId":"` + fixtureCurrentDOpenID + `"}]}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetOut(&bytes.Buffer{})
root.SetArgs([]string{
"chat", "+chat-create", "--name", "测试群", "--users", "user-1",
"--owner-query", "张三", "--yes",
"--owner-query", "测试用户甲", "--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
@@ -454,10 +454,10 @@ func TestCrossPlatformCoverageChatCreateOwnerQueryResolvesBeforeSingleCreate(t *
t.Fatalf("owner query calls = %#v", fake.calls)
}
create := fake.calls[1].args
if create["ownerOpenDingTalkId"] != "D-owner" {
if create["ownerOpenDingTalkId"] != fixtureCurrentDOpenID {
t.Fatalf("ownerOpenDingTalkId = %#v", create["ownerOpenDingTalkId"])
}
if got, want := create["groupMembers"], []string{"D-owner", "user-1"}; !reflect.DeepEqual(got, want) {
if got, want := create["groupMembers"], []string{fixtureCurrentDOpenID, "user-1"}; !reflect.DeepEqual(got, want) {
t.Fatalf("groupMembers = %#v, want %#v", got, want)
}
}
@@ -601,7 +601,7 @@ func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileDryRunAndFailures(
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send",
"--open-dingtalk-id", "D-target",
"--open-dingtalk-id", fixtureCurrentDOpenID,
"--file", "./fixture.bin",
"--dry-run",
"--yes",
@@ -773,7 +773,7 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--at-open-dingtalk-ids", "D-one,D-two,D-one",
"--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2 + "," + fixtureCurrentDOpenID,
"--at-all",
"--content", "完成",
"--flow-status", "3",
@@ -786,7 +786,7 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
fake.calls[1].tool != "update_streaming_card" {
t.Fatalf("card calls = %#v", fake.calls)
}
if got, want := fake.calls[0].args["atOpenDingTalkIds"], []string{"D-one", "D-two"}; !reflect.DeepEqual(got, want) {
if got, want := fake.calls[0].args["atOpenDingTalkIds"], []string{fixtureCurrentDOpenID, fixtureCurrentDOpenID2}; !reflect.DeepEqual(got, want) {
t.Fatalf("card create atOpenDingTalkIds = %#v, want %#v", got, want)
}
if fake.calls[0].args["atAll"] != true {
@@ -842,7 +842,7 @@ func TestCrossPlatformCoverageMessagesSendCardRejectsMissingRequestedAtTag(t *te
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--at-open-dingtalk-ids", "D-mentioned",
"--at-open-dingtalk-ids", fixtureCurrentDOpenID,
"--content", "正文",
"--yes",
})
@@ -891,7 +891,7 @@ func TestCrossPlatformCoverageMessagesSendCardUsesExplicitOpenReceiver(t *testin
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send-card",
"--receiver-open-dingtalk-id", "D-direct",
"--receiver-open-dingtalk-id", fixtureCurrentDOpenID2,
"--yes",
})
if err := root.Execute(); err != nil {
@@ -902,7 +902,7 @@ func TestCrossPlatformCoverageMessagesSendCardUsesExplicitOpenReceiver(t *testin
fake.calls[0].tool != "create_and_send_card" {
t.Fatalf("card open receiver calls = %#v", fake.calls)
}
if got := fake.calls[0].args["receiverOpenDingTalkId"]; got != "D-direct" {
if got := fake.calls[0].args["receiverOpenDingTalkId"]; got != fixtureCurrentDOpenID2 {
t.Fatalf("receiverOpenDingTalkId = %#v, want D-direct", got)
}
}
@@ -969,7 +969,7 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--at-open-dingtalk-ids", "D-mentioned",
"--at-open-dingtalk-ids", fixtureCurrentDOpenID,
"--at-all",
"--content", "处理中",
"--dry-run",
@@ -990,7 +990,7 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
createArguments, _ := create["arguments"].(map[string]any)
update, _ := actions[1].(map[string]any)
updateArguments, _ := update["arguments"].(map[string]any)
if !reflect.DeepEqual(createArguments["atOpenDingTalkIds"], []any{"D-mentioned"}) || createArguments["atAll"] != true {
if !reflect.DeepEqual(createArguments["atOpenDingTalkIds"], []any{fixtureCurrentDOpenID}) || createArguments["atAll"] != true {
t.Fatalf("card create dry-run mentions = %#v", createArguments)
}
if _, exists := updateArguments["atOpenDingTalkIds"]; exists {
@@ -1090,10 +1090,10 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
for _, args := range [][]string{
{"--group", "cid", "--content", "x", "--flow-status", "6"},
{"--group", "cid", "--flow-status", "2"},
{"--group", "cid", "--receiver-open-dingtalk-id", "D-direct"},
{"--receiver", "user-id", "--receiver-open-dingtalk-id", "D-direct"},
{"--receiver", "user-id", "--at-open-dingtalk-ids", "D-mentioned"},
{"--receiver-open-dingtalk-id", "D-direct", "--at-all"},
{"--group", "cid", "--receiver-open-dingtalk-id", fixtureCurrentDOpenID2},
{"--receiver", "user-id", "--receiver-open-dingtalk-id", fixtureCurrentDOpenID2},
{"--receiver", "user-id", "--at-open-dingtalk-ids", fixtureCurrentDOpenID},
{"--receiver-open-dingtalk-id", fixtureCurrentDOpenID2, "--at-all"},
} {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
@@ -1104,6 +1104,35 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
}
}
func TestCrossPlatformCoverageExplicitOpenIDValidationEdges(t *testing.T) {
if err := validateExplicitOpenIDs("--open-dingtalk-ids", []string{" ", fixtureCurrentDOpenID}); err != nil {
t.Fatalf("blank and valid IDs: %v", err)
}
if err := validateExplicitOpenIDs("--open-dingtalk-ids", []string{fixtureCurrentDOpenID, "not-an-open-id"}); err == nil {
t.Fatal("invalid explicit open ID unexpectedly accepted")
}
for _, args := range [][]string{
{"chat", "+messages-send", "--open-dingtalk-id", "not-an-open-id", "--text", "x", "--yes"},
{"chat", "+messages-send", "--open-dingtalk-ids", "not-an-open-id", "--text", "x", "--yes"},
{"chat", "+messages-send", "--group", "cid", "--at-open-dingtalk-ids", "not-an-open-id", "--text", "x", "--yes"},
{"chat", "+messages-send-card", "--receiver-open-dingtalk-id", "not-an-open-id", "--yes"},
{"chat", "+messages-send-card", "--group", "cid", "--at-open-dingtalk-ids", "not-an-open-id", "--yes"},
{"chat", "+conversation-info", "--open-dingtalk-id", "not-an-open-id"},
{"chat", "+chat-members-get", "--id", "cid", "--users", "not-an-open-id"},
{"chat", "+messages-send-by-bot", "--robot-code", "robot", "--group", "cid", "--title", "title", "--text", "text", "--at-open-dingtalk-ids", "not-an-open-id", "--yes"},
{"chat", "+messages-batch-send-by-bot", "--robot-code", "robot", "--title", "title", "--text", "text", "--open-dingtalk-ids", "not-an-open-id", "--yes"},
{"chat", "+messages-list-direct", "--open-dingtalk-id", "not-an-open-id", "--time", "2026-01-01 00:00:00"},
{"chat", "+chat-create", "--name", "fixture", "--users", "user-1", "--owner-open-dingtalk-id", "not-an-open-id", "--yes"},
} {
root := newPlatformCoverageRoot()
root.SetArgs(args)
if err := root.Execute(); err == nil {
t.Fatalf("invalid explicit open ID unexpectedly accepted: %v", args)
}
}
}
func TestCrossPlatformCoverageMessagesUpdateCardVerifiesSuccess(t *testing.T) {
t.Run("agent shortcut owns confirmation boundary", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
+7 -2
View File
@@ -52,6 +52,11 @@ var ChatCreate = shortcut.Shortcut{
`dws chat +chat-create --name "合作群" --member-query "张三,李四" --type EXTERNAL`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
if ownerOpenID := rt.Str("owner-open-dingtalk-id"); ownerOpenID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--owner-open-dingtalk-id", ownerOpenID); err != nil {
return err
}
}
resolvedMembers, err := targetresolver.ResolveUsers(
rt,
rt.StrSlice("member-query"),
@@ -339,7 +344,7 @@ func findOpenDingTalkID(value any) string {
switch typed := value.(type) {
case map[string]any:
for _, key := range []string{"senderOpenDingTalkId", "senderOpenDingtalkId", "openDingTalkId", "openDingtalkId"} {
if candidate := strings.TrimSpace(fmt.Sprint(typed[key])); isOpenID(candidate) {
if candidate := strings.TrimSpace(fmt.Sprint(typed[key])); candidate != "" && candidate != "<nil>" {
return candidate
}
}
@@ -374,7 +379,7 @@ func findMessageSenderOpenDingTalkID(message map[string]any) string {
"senderOpenDingtalkId",
"senderOpenId",
} {
if candidate := strings.TrimSpace(fmt.Sprint(message[key])); isOpenID(candidate) {
if candidate := strings.TrimSpace(fmt.Sprint(message[key])); candidate != "" && candidate != "<nil>" {
return candidate
}
}
@@ -289,12 +289,12 @@ func TestCrossPlatformCoverageMessagesSendRoutesIdentitySpecificTransports(t *te
}{
{
name: "user",
args: []string{"chat", "+messages-send", "--as", "user", "--chat-id", "cid", "--markdown", "hello @D1", "--at-open-dingtalk-ids", "D1", "--at-all", "--idempotency-key", "u1", "--yes"},
args: []string{"chat", "+messages-send", "--as", "user", "--chat-id", "cid", "--markdown", "hello @" + fixtureCurrentDOpenID, "--at-open-dingtalk-ids", fixtureCurrentDOpenID, "--at-all", "--idempotency-key", "u1", "--yes"},
product: "chat",
tool: "send_personal_message",
want: map[string]any{"openConversationId": "cid", "msgType": "markdown", "uuid": "u1"},
bodyKey: "content",
body: "<@all> hello <@D1>",
body: "<@all> hello <@" + fixtureCurrentDOpenID + ">",
},
{
name: "bot",
@@ -471,7 +471,7 @@ func TestCrossPlatformCoverageMessagesReplyPublishesPlainTextBoundary(t *testing
"chat", "+messages-reply",
"--conversation-id", "cid",
"--message-id", "msg",
"--ref-sender", "D-sender",
"--ref-sender", fixtureCurrentDOpenID,
"--text", "收到",
"--idempotency-key", "reply-uuid",
"--yes",
@@ -494,7 +494,7 @@ func TestCrossPlatformCoverageMessagesReplyPublishesPlainTextBoundary(t *testing
t.Fatal(err)
}
if content["referenceOpenMessageId"] != "msg" ||
content["srcMsgSendOpenDingTalkId"] != "D-sender" ||
content["srcMsgSendOpenDingTalkId"] != fixtureCurrentDOpenID ||
content["replyMsgType"] != "text" ||
content["content"] != "收到" {
t.Fatalf("reply content = %#v", content)
@@ -525,7 +525,7 @@ func TestCrossPlatformCoverageMessagesReplyDryRunStopsBeforeWrite(t *testing.T)
"chat", "+messages-reply",
"--conversation-id", "cid",
"--message-id", "msg",
"--ref-sender", "D-sender",
"--ref-sender", fixtureCurrentDOpenID,
"--text", "收到",
"--dry-run",
"--yes",
@@ -56,7 +56,7 @@ func TestCrossPlatformCoverageDirectMessagesPageAllUsesMillisecondCursor(t *test
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-list-direct", "--open-dingtalk-id", "D-user",
"chat", "+messages-list-direct", "--open-dingtalk-id", fixtureCurrentDOpenID,
"--time", "2026-08-05 16:49:00", "--forward=false", "--limit", "1",
"--page-all", "--page-limit", "5",
})
+11
View File
@@ -142,6 +142,17 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
atOpenIDs := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids"))
atUserIDs := uniqueShortcutStrings(rt.StrSlice("at-user-ids"))
atMobiles := uniqueShortcutStrings(rt.StrSlice("at-mobiles"))
if openID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openID); err != nil {
return err
}
}
if err := validateExplicitOpenIDs("--open-dingtalk-ids", openIDs); err != nil {
return err
}
if err := validateExplicitOpenIDs("--at-open-dingtalk-ids", atOpenIDs); err != nil {
return err
}
contentType, err := messagesSendContentType(rt)
if err != nil {
return err
+228 -42
View File
@@ -57,7 +57,7 @@ var ChatMessages = shortcut.Shortcut{
Command: "+chat-messages",
Product: "chat",
Description: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出",
Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 senderId 筛选同一次读取结果;" +
Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;--sender 是可选过滤条件,可传姓名、userId 或 openDingTalkId,唯一解析后按 senderId 筛选同一次读取结果;不传时原样读取会话且不查询发送者身份。sender 仅是展示名,身份只比较稳定 senderId;" +
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" +
"全量读取用 --page-all,并由 --page-limit/--max-items 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
@@ -83,7 +83,7 @@ var ChatMessages = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出",
UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 senderId 筛选同一次读取结果;" +
UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;--sender 是可选过滤条件,可传姓名、userId 或 openDingTalkId,唯一解析后按 senderId 筛选同一次读取结果;不传时原样读取会话且不查询发送者身份。sender 仅是展示名,身份只比较稳定 senderId;" +
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" +
"全量读取用 --page-all,并由 --page-limit/--max-items 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
@@ -104,7 +104,8 @@ var ChatMessages = shortcut.Shortcut{
{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: "sender-query", Type: shortcut.FlagStringSlice, Desc: "按姓名唯一解析发送者并筛选同一次会话读取结果(可选,可重复或逗号分隔)"},
{Name: "sender", Type: shortcut.FlagStringSlice, Desc: "单个或多个发送者姓名、userId 或 openDingTalkId;自动唯一解析并筛选同一次会话读取结果"},
{Name: "sender-query", Type: shortcut.FlagStringSlice, 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: "start", Type: shortcut.FlagString, Desc: "范围开始时间(可选、包含),支持 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD"},
{Name: "start-time", Type: shortcut.FlagString, Desc: "--start 的 lark-cli 对齐别名(可选、包含)"},
@@ -133,6 +134,7 @@ var ChatMessages = shortcut.Shortcut{
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "start", "start-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "end", "end-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "order", "sort"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"sender", "sender-query"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "start", "start-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "end", "end-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "order", "sort"}},
@@ -223,105 +225,267 @@ type chatMessagesRequest struct {
}
type chatMessagesSenderFilter struct {
requested bool
applied bool
stableIDs map[string]bool
resolutions []targetresolver.UserResolution
failure map[string]any
requested bool
applied bool
inputs []string
inputMode string
stableIDs map[string]bool
hasUserIDs bool
hasOpenIDs bool
resolutions []targetresolver.UserResolution
resolutionFailure map[string]any
resolutionErr error
scopeErr error
}
// resolveOptionalChatMessagesSenderFilter is deliberately local to
// +chat-messages. Sender resolution is an optional post-read filter here: an
// unresolved name must not suppress the command's primary conversation-list
// result. +search-msg keeps its stricter semantics because sender identity is
// part of the lower search request there.
// resolveOptionalChatMessagesSenderFilter accepts the same mixed user target
// as +search-msg while retaining --sender-query as a compatibility entry. It
// resolves only explicitly requested senders; it never enumerates every member
// or tries to infer a person from message display names.
func resolveOptionalChatMessagesSenderFilter(rt *shortcut.RuntimeContext) chatMessagesSenderFilter {
queries := rt.StrSlice("sender-query")
direct := uniqueChatMessageTargets(rt.StrSlice("sender"))
queries := uniqueChatMessageTargets(rt.StrSlice("sender-query"))
inputs := direct
inputMode := "sender"
if len(inputs) == 0 {
inputs = queries
inputMode = "sender-query"
}
filter := chatMessagesSenderFilter{
requested: len(queries) > 0,
requested: len(inputs) > 0,
inputs: inputs,
inputMode: inputMode,
stableIDs: map[string]bool{},
}
if !filter.requested {
return filter
}
resolutions, err := targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny)
var resolutions []targetresolver.UserResolution
var err error
if len(direct) > 0 {
for _, value := range direct {
var resolved targetresolver.UserResolution
resolved, err = targetresolver.ResolveSenderTarget(rt, value, targetresolver.IdentityAny)
if err != nil {
break
}
resolutions = append(resolutions, resolved)
}
} else {
resolutions, err = targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny)
}
if err != nil {
failure := map[string]any{
"stage": "sender_resolution",
"queries": queries,
"error": err.Error(),
"stage": "sender_resolution",
"inputs": inputs,
"mode": inputMode,
"error": err.Error(),
}
var typed *apperrors.Error
if errors.As(err, &typed) {
if typed.Reason != "" {
failure["reason"] = typed.Reason
}
if typed.Hint != "" {
failure["hint"] = typed.Hint
}
if len(typed.Details) > 0 {
failure["details"] = typed.Details
}
}
filter.failure = failure
filter.resolutionFailure = failure
filter.resolutionErr = err
return filter
}
filter.resolutions = resolutions
for _, resolution := range resolutions {
for _, identity := range []string{
resolution.Selected.UserID,
resolution.Selected.OpenDingTalkID,
} {
if identity = strings.TrimSpace(identity); identity != "" {
filter.stableIDs[identity] = true
}
}
}
// ResolveUsers(IdentityAny) only returns users extracted with at least one
// stable userId/openDingTalkId and fails when no resolvable user remains.
filter.hasUserIDs, filter.hasOpenIDs = addChatMessagesResolvedSenderIDs(filter.stableIDs, resolutions)
filter.applied = true
return filter
}
// applyOptionalChatMessagesSenderFilter preserves the unfiltered message list
// when optional resolution fails. Successful resolution filters both the
func uniqueChatMessageTargets(values []string) []string {
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func addChatMessagesResolvedSenderIDs(ids map[string]bool, resolutions []targetresolver.UserResolution) (bool, bool) {
hasUserIDs := false
hasOpenIDs := false
for _, resolution := range resolutions {
if identity := strings.TrimSpace(resolution.Selected.UserID); identity != "" {
ids[identity] = true
hasUserIDs = true
}
if identity := strings.TrimSpace(resolution.Selected.OpenDingTalkID); identity != "" {
ids[identity] = true
hasOpenIDs = true
}
}
return hasUserIDs, hasOpenIDs
}
// applyOptionalChatMessagesSenderFilter fails closed when an explicitly
// requested sender cannot be resolved. Successful resolution filters both the
// public projection and raw rows so exports and resource downloads cannot
// accidentally include messages outside the requested sender set.
func applyOptionalChatMessagesSenderFilter(
rt *shortcut.RuntimeContext,
payload map[string]any,
rawItems []map[string]any,
filter chatMessagesSenderFilter,
filter *chatMessagesSenderFilter,
) []map[string]any {
if !filter.requested || payload == nil {
if filter == nil || !filter.requested || payload == nil {
return rawItems
}
if !filter.applied {
failures, _ := payload["failures"].([]map[string]any)
priorFailures := len(failures)
failures = append(failures, filter.failure)
failure := filter.resolutionFailure
if failure == nil {
failure = map[string]any{
"stage": "sender_resolution",
"inputs": filter.inputs,
"mode": filter.inputMode,
"error": "无法把发送者目标唯一解析为稳定身份",
}
}
failures = append(failures, failure)
payload["failures"] = failures
payload["failedCount"] = len(failures)
payload["complete"] = false
payload["partial"] = len(rawItems) > 0
payload["partial"] = false
payload["messages"] = []map[string]any{}
payload["count"] = 0
payload["senderFilter"] = map[string]any{
"requested": true,
"inputs": filter.inputs,
"mode": filter.inputMode,
"applied": false,
"status": "resolution_failed",
"suppressedCount": len(rawItems),
}
payload["identityResult"] = map[string]any{
"status": "resolution_failed",
"nameComparisonAllowed": false,
"stableIdField": "senderId",
"hint": "当前发送者尚未解析为稳定身份,不能根据 sender 展示名判断该人员是否发过消息。请先完成人员解析。",
}
if priorFailures == 0 {
payload["stopReason"] = "sender_resolution_failed"
}
return rawItems
return nil
}
filtered := make([]map[string]any, 0, len(rawItems))
unverifiableMessageIDs := make([]string, 0)
for _, item := range rawItems {
identity := strings.TrimSpace(fmt.Sprint(chatmsg.SenderID(item)))
if identity != "" && identity != "<nil>" && filter.stableIDs[identity] {
filtered = append(filtered, item)
if identity == "" || identity == "<nil>" {
messageID := chatmsg.StableMessageID(item)
if messageID == "" {
messageID = "<unknown>"
}
unverifiableMessageIDs = append(unverifiableMessageIDs, messageID)
continue
}
if filter.stableIDs[identity] {
filtered = append(filtered, item)
continue
}
if targetresolver.LooksLikeCurrentDOpenDingTalkID(identity) {
if !filter.hasOpenIDs {
unverifiableMessageIDs = append(unverifiableMessageIDs, chatMessagesMessageID(item))
}
} else if !filter.hasUserIDs {
unverifiableMessageIDs = append(unverifiableMessageIDs, chatMessagesMessageID(item))
}
}
if len(unverifiableMessageIDs) > 0 {
filter.scopeErr = apperrors.NewAPI(
"部分消息缺少 senderId,无法证明发送者过滤范围;已阻止完整成功与导出",
apperrors.WithReason("chat_messages_sender_scope_unverified"),
apperrors.WithRetryable(false),
apperrors.WithHint("请保留 trace_id 并检查消息读取服务是否返回稳定 senderId"),
apperrors.WithDetails(map[string]any{
"requestedSenders": filter.inputs,
"unverifiableMessageIds": uniqueChatMessageTargets(unverifiableMessageIDs),
}),
)
failures, _ := payload["failures"].([]map[string]any)
failures = append(failures, map[string]any{
"stage": "sender_scope_verification",
"error": filter.scopeErr.Error(),
"unverifiableMessageIds": uniqueChatMessageTargets(unverifiableMessageIDs),
})
payload["failures"] = failures
payload["failedCount"] = len(failures)
payload["complete"] = false
payload["partial"] = len(filtered) > 0
}
payload["messages"] = projectChatMessages(filtered, !rt.Bool("no-reactions"))
payload["count"] = len(filtered)
payload["resolvedFilters"] = map[string]any{"senders": filter.resolutions}
filterStatus := "applied"
if filter.scopeErr != nil {
filterStatus = "scope_unverified"
}
payload["senderFilter"] = map[string]any{
"requested": true,
"inputs": filter.inputs,
"mode": filter.inputMode,
"applied": true,
"status": filterStatus,
}
identityResult := map[string]any{
"status": "evaluated",
"nameComparisonAllowed": false,
"negativeConclusionAllowed": true,
"stableIdField": "senderId",
}
if filter.scopeErr != nil {
identityResult["status"] = "scope_unverified"
identityResult["negativeConclusionAllowed"] = false
identityResult["hint"] = "部分消息缺少稳定 senderId,当前结果不能用于断言该人员没有发过消息。"
}
payload["identityResult"] = identityResult
return filtered
}
func chatMessagesMessageID(item map[string]any) string {
messageID := chatmsg.StableMessageID(item)
if messageID == "" {
return "<unknown>"
}
return messageID
}
func attachUnfilteredSenderIdentitySemantics(payload map[string]any, filter chatMessagesSenderFilter) {
if payload == nil || filter.requested {
return
}
payload["senderFilter"] = map[string]any{
"requested": false,
"applied": false,
"status": "not_requested",
}
payload["identityResult"] = map[string]any{
"status": "requires_query_check",
"negativeConclusionAllowed": false,
"hint": "若原始任务包含自然人发送者条件,请先将目标人解析为唯一 userId/openDingTalkId,再与 messages[].senderId 比较;禁止直接比较 messages[].sender 展示名。",
}
}
func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesRequest, error) {
groupID := strings.TrimSpace(rt.StrFirst("conversation-id", "id", "open-conversation-id"))
userID := rt.Str("user")
@@ -331,6 +495,11 @@ func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesReques
"--open-dingtalk-id 收到的是群 openConversationId;群聊请改用 --group(兼容别名 --chat)",
)
}
if openID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openID); err != nil {
return chatMessagesRequest{}, err
}
}
if groupID == "" && (rt.Str("group") != "" || rt.Str("chat-query") != "") {
resolved, err := targetresolver.ResolveChatTarget(rt, rt.Str("group"), rt.Str("chat-query"))
if err != nil {
@@ -408,7 +577,8 @@ func executeChatMessages(rt *shortcut.RuntimeContext) error {
return err
}
senderFilter := resolveOptionalChatMessagesSenderFilter(rt)
rawItems = applyOptionalChatMessagesSenderFilter(rt, payload, rawItems, senderFilter)
rawItems = applyOptionalChatMessagesSenderFilter(rt, payload, rawItems, &senderFilter)
attachUnfilteredSenderIdentitySemantics(payload, senderFilter)
if err != nil {
// Full-page collection returns its failure ledger together with a
// non-zero error. Publish that ledger for diagnosis, but stop before
@@ -420,6 +590,22 @@ func executeChatMessages(rt *shortcut.RuntimeContext) error {
}
return err
}
if senderFilter.requested && !senderFilter.applied {
if payload != nil {
if outputErr := rt.Output(payload); outputErr != nil {
return outputErr
}
}
return senderFilter.resolutionErr
}
if senderFilter.scopeErr != nil {
if payload != nil {
if outputErr := rt.Output(payload); outputErr != nil {
return outputErr
}
}
return senderFilter.scopeErr
}
if rt.Bool("download-resources") {
chatshortcut.AttachMessageResourceDownloads(
payload,
+144 -1
View File
@@ -20,6 +20,7 @@ import (
stderrors "errors"
"math"
"os"
"reflect"
"testing"
"time"
@@ -546,7 +547,7 @@ func TestCrossPlatformCoverageChatMessagesAdditionalValidationAndHelpers(t *test
filter := resolveOptionalChatMessagesSenderFilter(chatMessagesRuntimeForTest(t, map[string]string{
"sender-query": "测试用户甲",
}))
if filter.applied || filter.failure == nil {
if filter.applied || filter.resolutionFailure == nil {
t.Fatalf("identity-free sender resolution = %#v", filter)
}
@@ -577,6 +578,148 @@ func TestCrossPlatformCoverageChatMessagesAdditionalValidationAndHelpers(t *test
}
}
func TestChatMessagesSenderScopeRejectsUnmappedIdentityFamily(t *testing.T) {
filter := chatMessagesSenderFilter{
requested: true,
applied: true,
inputs: []string{"DAAAAAAAAAAAiE"},
inputMode: "sender",
stableIDs: map[string]bool{"DAAAAAAAAAAAiE": true},
hasOpenIDs: true,
}
payload := map[string]any{
"complete": true,
"failures": []map[string]any{},
}
filtered := applyOptionalChatMessagesSenderFilter(
chatMessagesRuntimeForTest(t, nil),
payload,
[]map[string]any{{"openMessageId": "m-user-family", "senderUserId": "user-1"}},
&filter,
)
if len(filtered) != 0 || filter.scopeErr == nil || payload["complete"] != false {
t.Fatalf("filtered=%#v filter=%#v payload=%#v", filtered, filter, payload)
}
}
func TestCrossPlatformCoverageChatMessagesSenderFilterFailureEdges(t *testing.T) {
t.Run("direct resolution stops after first error", func(t *testing.T) {
fake := &platformCoverageCaller{failTool: "contact/search_contact_by_key_word"}
helpers.InitDeps(fake)
filter := resolveOptionalChatMessagesSenderFilter(chatMessagesRuntimeForTest(t, map[string]string{
"sender": testCurrentDOpenID + ",fixture-name,ignored-name",
}))
if filter.applied || filter.resolutionErr == nil || len(fake.calls) != 1 {
t.Fatalf("filter=%#v calls=%#v", filter, fake.calls)
}
})
t.Run("dedupes blanks and repeated values", func(t *testing.T) {
got := uniqueChatMessageTargets([]string{"", " ", "one", "one", " two "})
if want := []string{"one", "two"}; !reflect.DeepEqual(got, want) {
t.Fatalf("targets=%#v want=%#v", got, want)
}
})
t.Run("synthesizes failure when none was supplied", func(t *testing.T) {
payload := map[string]any{"failures": []map[string]any{{"stage": "existing"}}}
filter := chatMessagesSenderFilter{requested: true, inputs: []string{"fixture"}, inputMode: "sender"}
if got := applyOptionalChatMessagesSenderFilter(
chatMessagesRuntimeForTest(t, nil), payload, []map[string]any{{"openMessageId": "hidden"}}, &filter,
); got != nil {
t.Fatalf("filtered=%#v", got)
}
if _, exists := payload["stopReason"]; exists {
t.Fatalf("existing failure should retain stop reason ownership: %#v", payload)
}
})
t.Run("rejects missing and unmapped identity families", func(t *testing.T) {
payload := map[string]any{"complete": true, "failures": []map[string]any{}}
filter := chatMessagesSenderFilter{
requested: true,
applied: true,
inputs: []string{"fixture"},
inputMode: "sender",
stableIDs: map[string]bool{"wanted-user": true},
hasUserIDs: true,
}
filtered := applyOptionalChatMessagesSenderFilter(
chatMessagesRuntimeForTest(t, nil),
payload,
[]map[string]any{
{"content": "missing sender and id"},
{"openMessageId": "wanted", "senderUserId": "wanted-user"},
{"openMessageId": "open-family", "senderOpenDingTalkId": testCurrentDOpenID},
},
&filter,
)
if len(filtered) != 1 || filter.scopeErr == nil || chatMessagesMessageID(map[string]any{}) != "<unknown>" || chatMessagesMessageID(filtered[0]) != "wanted" {
t.Fatalf("filtered=%#v filter=%#v payload=%#v", filtered, filter, payload)
}
})
t.Run("rejects user identity when only open ids were resolved", func(t *testing.T) {
payload := map[string]any{"complete": true, "failures": []map[string]any{}}
filter := chatMessagesSenderFilter{
requested: true,
applied: true,
inputs: []string{testCurrentDOpenID},
inputMode: "sender",
stableIDs: map[string]bool{testCurrentDOpenID: true},
hasOpenIDs: true,
}
applyOptionalChatMessagesSenderFilter(
chatMessagesRuntimeForTest(t, nil), payload,
[]map[string]any{{"senderUserId": "other-user"}}, &filter,
)
if filter.scopeErr == nil {
t.Fatalf("filter=%#v", filter)
}
})
if _, err := resolveChatMessagesRequest(chatMessagesRuntimeForTest(t, map[string]string{
"open-dingtalk-id": "not-an-open-id",
})); err == nil {
t.Fatal("invalid explicit open ID unexpectedly accepted")
}
}
func TestCrossPlatformCoverageChatMessagesSenderFailureOutputEdges(t *testing.T) {
for _, tc := range []struct {
name string
sender string
}{
{name: "resolution", sender: "fixture-name"},
{name: "scope", sender: testCurrentDOpenID},
} {
t.Run(tc.name+" returns domain error", func(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":false,"messages":[{"openMessageId":"m-without-sender"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--sender", tc.sender})
if err := root.Execute(); err == nil {
t.Fatal("sender failure unexpectedly succeeded")
}
})
t.Run(tc.name+" prioritizes output error", func(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":false,"messages":[{"openMessageId":"m-without-sender"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetOut(chatMessagesFailWriter{})
root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--sender", tc.sender})
if err := root.Execute(); err == nil || err.Error() != "fixture output failure" {
t.Fatalf("error=%v", err)
}
})
}
}
func TestCrossPlatformCoverageChatMessagesAdditionalCollectionEdges(t *testing.T) {
runtimeWith := func(t *testing.T, caller *chatMessagesPagingCaller, values map[string]string) *shortcut.RuntimeContext {
t.Helper()
@@ -105,7 +105,7 @@ func TestCrossPlatformCoverageChatMessagesOpenIDRoute(t *testing.T) {
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+chat-messages", "--open-dingtalk-id", "D-user", "--limit", "1", "--yes"})
root.SetArgs([]string{"chat", "+chat-messages", "--open-dingtalk-id", testCurrentDOpenID, "--limit", "1", "--yes"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
@@ -22,7 +22,7 @@ func TestCrossPlatformCoverageNaturalSearchAndSenderFlagsStayPublicOptional(t *t
want []string
}{
{name: "search natural adapters", flags: SearchMsg.Flags, want: []string{"chat-query", "sender-query"}},
{name: "chat messages natural adapters", flags: ChatMessages.Flags, want: []string{"chat-query", "sender-query"}},
{name: "chat messages natural adapters", flags: ChatMessages.Flags, want: []string{"chat-query", "sender", "sender-query"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
@@ -160,7 +160,7 @@ func TestCrossPlatformCoverageChatMessagesOptionallyFiltersResolvedSenderByEithe
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+chat-messages", "--group", "cid-fixture-chat-0001",
"--sender-query", "测试用户甲", "--page-all",
"--sender", "测试用户甲", "--page-all",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
@@ -201,7 +201,64 @@ func TestCrossPlatformCoverageChatMessagesOptionallyFiltersResolvedSenderByEithe
}
}
func TestCrossPlatformCoverageChatMessagesSenderResolutionFailureKeepsUnfilteredMessages(t *testing.T) {
func TestChatMessagesFormatValidSenderSkipsDirectoryAndFiltersStableID(t *testing.T) {
fake := &platformCoverageCaller{
chatMessagesResult: `{"result":{"hasMore":false,"messages":[
{"openMessageId":"wanted","sender":"目标展示名","senderOpenDingTalkId":"DAAAAAAAAAAAiE","content":"保留"},
{"openMessageId":"other","sender":"其他人","senderOpenDingTalkId":"DAQEBAQEBAQEiE","content":"过滤"}
]}}`,
}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+chat-messages", "--group", "cid-fixture-chat-0001",
"--sender", "DAAAAAAAAAAAiE", "--page-all",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "list_conversation_message_v2" {
t.Fatalf("format-valid sender unexpectedly preflighted: %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(1) || payload["complete"] != true {
t.Fatalf("payload=%#v", payload)
}
}
func TestChatMessagesWithoutSenderDoesNotResolveEveryMessageIdentity(t *testing.T) {
fake := &platformCoverageCaller{chatMessagesResult: `{"result":{"hasMore":false,"messages":[
{"openMessageId":"m1","sender":"群昵称一","senderOpenDingTalkId":"D1","content":"一"},
{"openMessageId":"m2","sender":"群昵称二","senderOpenDingTalkId":"D2","content":"二"}
]}}`}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-messages", "--group", "cid-fixture-chat-0001", "--page-all"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "list_conversation_message_v2" {
t.Fatalf("calls=%#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
identity := payload["identityResult"].(map[string]any)
if payload["count"] != float64(2) || identity["status"] != "requires_query_check" ||
identity["negativeConclusionAllowed"] != false {
t.Fatalf("payload=%#v", payload)
}
}
func TestCrossPlatformCoverageChatMessagesSenderResolutionFailureStopsWithoutUnfilteredMessages(t *testing.T) {
fake := &platformCoverageCaller{
contactSearchResult: `{"result":[]}`,
chatMessagesResult: `{"result":{"hasMore":false,"messages":[
@@ -216,22 +273,26 @@ func TestCrossPlatformCoverageChatMessagesSenderResolutionFailureKeepsUnfiltered
"chat", "+chat-messages", "--group", "cid-fixture-chat-0001",
"--sender-query", "不存在的人", "--page-all",
})
if err := root.Execute(); err != nil {
t.Fatalf("optional sender failure stopped message read: %v", err)
if err := root.Execute(); err == nil {
t.Fatal("sender resolution failure unexpectedly succeeded")
}
if len(fake.calls) != 2 || fake.calls[0].tool != "list_conversation_message_v2" ||
fake.calls[1].tool != "search_contact_by_key_word" {
t.Fatalf("calls = %#v, want message read followed by non-blocking failed resolve", fake.calls)
t.Fatalf("calls = %#v, want message read followed by failed resolve", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("decode output: %v\n%s", err, output.String())
}
if payload["count"] != float64(1) || payload["complete"] != false ||
payload["partial"] != true || payload["failedCount"] != float64(1) ||
if payload["count"] != float64(0) || payload["complete"] != false ||
payload["partial"] != false || payload["failedCount"] != float64(1) ||
payload["stopReason"] != "sender_resolution_failed" {
t.Fatalf("unfiltered fallback payload = %#v", payload)
t.Fatalf("fail-closed payload = %#v", payload)
}
messages, ok := payload["messages"].([]any)
if !ok || len(messages) != 0 {
t.Fatalf("failed resolution leaked unfiltered messages: %#v", payload["messages"])
}
if _, exists := payload["resolvedFilters"]; exists {
t.Fatalf("failed resolution unexpectedly published resolvedFilters: %#v", payload)
+234 -30
View File
@@ -35,7 +35,7 @@ import (
// search hits through list_messages_by_ids in chunks of 50. A later-page or
// enrichment failure never turns a partial result into a false success: the
// output carries an explicit failure ledger and complete=false.
const searchMsgIntent = "当你要按关键词、发送者、@对象、消息类型、机器人来源或会话范围组合搜索 IM 消息时使用;可搜索单个、多个或全部会话,会话与发送者过滤使用稳定 ID。默认查询近 7 天,也可指定精确起止时间和输出顺序。" +
const searchMsgIntent = "当你要按关键词、发送者、@对象、消息类型、机器人来源或会话范围组合搜索 IM 消息时使用;可搜索单个、多个或全部会话。--group/--groups 接受群名或 openConversationId,--sender/--senders 接受姓名、userId 或 openDingTalkId,并统一唯一解析为稳定 ID。默认查询近 7 天,也可指定精确起止时间和输出顺序。" +
"显式指定会话时会先验证 CID,再执行有界全局扫描并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID。" +
"--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。" +
"--download-resources 使用安全本地路径、默认不覆盖和原子落盘。"
@@ -70,8 +70,8 @@ var SearchMsg = shortcut.Shortcut{
UseWhen: []string{searchMsgIntent},
AvoidWhen: []string{"只想查看或导出一个指定会话的消息记录、且没有发送者、关键词、@对象或消息类型等主要筛选条件时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"},
Examples: []string{
"dws chat +search-msg --group \"项目群\" --sender \"测试用户甲\" --page-all",
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all",
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all --jq '.messages[] | {messageId, text}'",
},
},
},
@@ -80,15 +80,15 @@ var SearchMsg = shortcut.Shortcut{
{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: "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: "按群名唯一解析会话过滤条件(可选,可重复或逗号分隔)"},
{Name: "senders", Type: shortcut.FlagStringSlice, Desc: "发送者 userId/openDingTalkId 列表"},
{Name: "sender", Type: shortcut.FlagStringSlice, Desc: "--senders 的 lark-cli 对齐别名"},
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "按姓名唯一解析发送者过滤条件(可选,可重复或逗号分隔)"},
{Name: "groups", Type: shortcut.FlagStringSlice, Desc: "多个群名或 openConversationId;可混合输入并逐项唯一解析"},
{Name: "chat-id", Type: shortcut.FlagStringSlice, Desc: "--groups 的 lark-cli 对齐别名;只接受 openConversationId"},
{Name: "chat-query", Type: shortcut.FlagStringSlice, Desc: "显式按群名唯一解析的兼容入口(可选,可重复或逗号分隔)"},
{Name: "senders", Type: shortcut.FlagStringSlice, Desc: "多个发送者姓名、userId 或 openDingTalkId;可混合输入并逐项唯一解析"},
{Name: "sender", Type: shortcut.FlagStringSlice, Desc: "单个或多个发送者姓名、userId 或 openDingTalkId;--senders 的兼容别名"},
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "显式按姓名唯一解析的兼容入口(可选,可重复或逗号分隔)"},
{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 列表"},
@@ -140,6 +140,7 @@ var SearchMsg = shortcut.Shortcut{
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"cursor", "page-token"}},
}, chatshortcut.MessageResourceDownloadConstraints()...),
Tips: []string{
`dws chat +search-msg --group "项目群" --sender "测试用户甲" --page-all`,
`dws chat +search-msg --group <openConversationId> --query "changefree"`,
`dws chat +search-msg --senders <openDingTalkId> --at-me --days 3 --page-all`,
`dws chat +search-msg --group <openConversationId> --query "changefree" --jq '.messages[] | {messageId, text}'`,
@@ -270,6 +271,13 @@ var SearchMsg = shortcut.Shortcut{
}
messages = validatedMessages
}
if len(resolvedFilters.Senders) > 0 {
var unverifiableMessageIDs []string
messages, unverifiableMessageIDs = filterSearchSenderScope(messages, resolvedFilters.Senders)
if len(unverifiableMessageIDs) > 0 {
return searchSenderScopeUnverifiedError(resolvedFilters.Senders, unverifiableMessageIDs)
}
}
order := strings.ToLower(strings.TrimSpace(rt.StrFirst("order", "sort")))
if order == "" {
@@ -293,9 +301,19 @@ var SearchMsg = shortcut.Shortcut{
"failedCount": len(failures),
"failures": failures,
"queryRange": searchMessageQueryRange(params, order),
"timeCoverage": searchMessageTimeCoverage(rt),
"conclusionGuard": searchMessageConclusionGuard(rt, complete, len(results)),
}
if len(resolvedFilters.Chats) > 0 || len(resolvedFilters.Senders) > 0 {
payload["resolvedFilters"] = resolvedFilters
}
if len(resolvedFilters.Senders) > 0 {
payload["resolvedFilters"] = resolvedFilters
payload["senderScope"] = map[string]any{
"targetsResolved": true,
"filterApplied": true,
"filterMode": "server_and_client",
"resultsWithinScope": true,
}
}
if scopedSearch {
payload["scope"] = searchScopePayload(requestedConversationIDs, paginationKnown && !hasMore)
@@ -313,6 +331,39 @@ var SearchMsg = shortcut.Shortcut{
},
}
func searchMessageTimeCoverage(rt *shortcut.RuntimeContext) map[string]any {
source := "implicit_default"
if rt.Changed("start") || rt.Changed("start-time") || rt.Changed("end") || rt.Changed("end-time") {
source = "explicit_range"
} else if rt.Changed("days") {
source = "explicit_days"
}
coverage := map[string]any{
"source": source,
"allHistory": false,
}
if source != "explicit_range" {
coverage["days"] = rt.Int("days")
}
return coverage
}
func searchMessageConclusionGuard(rt *shortcut.RuntimeContext, complete bool, resultCount int) map[string]any {
coverage := searchMessageTimeCoverage(rt)
guard := map[string]any{
"absenceWithinQueryRangeAllowed": complete,
"absenceAcrossAllHistoryAllowed": false,
"allHistoryCountAllowed": false,
"requiredActionForAllHistory": "widen_time_range_or_reuse_complete_message_ledger",
}
if coverage["source"] == "implicit_default" && resultCount == 0 {
guard["requiredAction"] = "widen_time_range_or_reuse_complete_message_ledger"
guard["hint"] = "当前只证明默认 7 天时间窗内没有命中,不能据此判断全部历史没有。若已有完整会话消息,请解析稳定身份后复用现有消息;否则扩大时间范围。"
}
return guard
}
func validateSearchMsgWithResources(rt *shortcut.RuntimeContext) error {
if err := validateSearchMsg(rt); err != nil {
return err
@@ -350,6 +401,39 @@ func validateSearchMsg(rt *shortcut.RuntimeContext) error {
if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > 40 {
return apperrors.NewValidation("--page-limit 必须在 1-40 之间")
}
if err := validateSearchMsgStrictConversationAliases(rt); err != nil {
return err
}
return nil
}
// The conventional --group/--groups flags are intentionally dual-form. Only
// the explicit ID aliases remain strict so scripts that select them retain a
// deterministic contract.
func validateSearchMsgStrictConversationAliases(rt *shortcut.RuntimeContext) error {
values := append([]string{}, rt.StrSlice("chat-id")...)
if value := rt.StrFirst("conversation-id", "id"); value != "" {
values = append(values, value)
}
for _, value := range uniqueSearchStrings(values) {
if strings.HasPrefix(strings.ToLower(value), "cid") {
continue
}
return apperrors.NewValidation(
fmt.Sprintf("显式会话 ID 参数收到非 openConversationId 值 %q;已停止消息搜索", value),
apperrors.WithReason("target_type_mismatch"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("request_validation"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
apperrors.WithHint(fmt.Sprintf("--chat-id/--conversation-id/--id 只接受 cid 开头的 openConversationId;群名请使用 --group %q 或 --groups %q。", value, value)),
apperrors.WithDetails(map[string]any{
"flags": []string{"chat-id", "conversation-id", "id"},
"expectedType": "openConversationId",
"providedValue": value,
}),
)
}
return nil
}
@@ -358,6 +442,7 @@ func validateSearchMsg(rt *shortcut.RuntimeContext) error {
// the directory name, so consumers must join the selected stable identity to
// each projected message's senderId instead of comparing those two names.
type searchResolvedFilters struct {
Chats []targetresolver.ChatResolution `json:"chats,omitempty"`
Senders []targetresolver.UserResolution `json:"senders,omitempty"`
}
@@ -367,41 +452,61 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, searchResolve
if value := rt.StrFirst("query", "keyword", "text", "text-query"); value != "" {
params["keyword"] = value
}
conversationIDs := append([]string{}, rt.StrSlice("groups")...)
conversationIDs = append(conversationIDs, rt.StrSlice("chat-id")...)
if value := rt.StrFirst("group", "conversation-id", "id"); value != "" {
conversationIDs := append([]string{}, rt.StrSlice("chat-id")...)
if value := rt.StrFirst("conversation-id", "id"); value != "" {
conversationIDs = append(conversationIDs, value)
}
groupTargets := append([]string{}, rt.StrSlice("groups")...)
if value := rt.Str("group"); value != "" {
groupTargets = append(groupTargets, value)
}
for _, target := range uniqueSearchStrings(groupTargets) {
if strings.HasPrefix(strings.ToLower(target), "cid") {
conversationIDs = append(conversationIDs, target)
continue
}
resolved, err := targetresolver.ResolveChatTarget(rt, target, "")
if err != nil {
return nil, searchResolvedFilters{}, err
}
resolvedFilters.Chats = append(resolvedFilters.Chats, resolved)
conversationIDs = append(conversationIDs, resolved.Selected.OpenConversationID)
}
if queries := rt.StrSlice("chat-query"); len(queries) > 0 {
for _, query := range queries {
resolved, err := targetresolver.ResolveChatTarget(rt, "", query)
if err != nil {
return nil, searchResolvedFilters{}, err
}
resolvedFilters.Chats = append(resolvedFilters.Chats, resolved)
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")...)
senderTargets := append([]string{}, rt.StrSlice("senders")...)
senderTargets = append(senderTargets, rt.StrSlice("sender")...)
for _, target := range uniqueSearchStrings(senderTargets) {
resolved, err := targetresolver.ResolveSenderTarget(rt, target, targetresolver.IdentityAny)
if err != nil {
return nil, searchResolvedFilters{}, err
}
resolvedFilters.Senders = append(resolvedFilters.Senders, resolved)
}
if queries := rt.StrSlice("sender-query"); len(queries) > 0 {
resolvedUsers, err := targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny)
if err != nil {
return nil, searchResolvedFilters{}, err
}
resolvedFilters.Senders = append(resolvedFilters.Senders, resolvedUsers...)
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")
appendResolvedSearchActorIDs(params, resolvedFilters.Senders, "senderUserIds", "senderOpenDingTakIds")
atUsers, err := resolveSearchStableActorTargets(rt, rt.StrSlice("at-ids"))
if err != nil {
return nil, searchResolvedFilters{}, err
}
appendResolvedSearchActorIDs(params, atUsers, "atUserIds", "atOpenDingTakIds")
if rt.Bool("at-me") || rt.Bool("is-at-me") {
params["atMe"] = true
}
@@ -439,15 +544,41 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, searchResolve
return params, resolvedFilters, nil
}
func appendSearchActorIDs(params map[string]any, values []string, userKey, openIDKey string) {
var userIDs, openIDs []string
func resolveSearchStableActorTargets(
rt *shortcut.RuntimeContext,
values []string,
) ([]targetresolver.UserResolution, error) {
resolvedUsers := make([]targetresolver.UserResolution, 0, len(values))
for _, value := range uniqueSearchStrings(values) {
if len(value) > 0 && (value[0] == 'D' || value[0] == 'd') {
openIDs = append(openIDs, value)
} else {
userIDs = append(userIDs, value)
resolved, err := targetresolver.ResolveStableUserTarget(rt, value, targetresolver.IdentityAny)
if err != nil {
return nil, err
}
resolvedUsers = append(resolvedUsers, resolved)
}
return resolvedUsers, nil
}
// appendResolvedSearchActorIDs prefers openDingTalkId whenever directory
// resolution supplies it because the current message-search backend reliably
// applies the open-ID field but may ignore senderUserIds.
func appendResolvedSearchActorIDs(
params map[string]any,
resolutions []targetresolver.UserResolution,
userKey, openIDKey string,
) {
var userIDs, openIDs []string
for _, resolved := range resolutions {
openID := strings.TrimSpace(resolved.Selected.OpenDingTalkID)
userID := strings.TrimSpace(resolved.Selected.UserID)
if openID != "" {
openIDs = append(openIDs, openID)
} else if userID != "" {
userIDs = append(userIDs, userID)
}
}
userIDs = uniqueSearchStrings(userIDs)
openIDs = uniqueSearchStrings(openIDs)
if len(userIDs) > 0 {
params[userKey] = userIDs
}
@@ -527,6 +658,79 @@ func searchScopePayload(conversationIDs []string, sourceComplete bool) map[strin
}
}
func filterSearchSenderScope(
messages []map[string]any,
resolutions []targetresolver.UserResolution,
) ([]map[string]any, []string) {
allowed := map[string]bool{}
hasUserIDs := false
hasOpenIDs := false
for _, resolution := range resolutions {
if value := strings.TrimSpace(resolution.Selected.UserID); value != "" {
allowed[value] = true
hasUserIDs = true
}
if value := strings.TrimSpace(resolution.Selected.OpenDingTalkID); value != "" {
allowed[value] = true
hasOpenIDs = true
}
}
filtered := make([]map[string]any, 0, len(messages))
unverifiable := make([]string, 0)
for _, message := range messages {
senderID := strings.TrimSpace(fmt.Sprint(chatmsg.SenderID(message)))
if senderID == "" || senderID == "<nil>" {
messageID := strings.TrimSpace(fmt.Sprint(searchMsgMessageID(message)))
if messageID == "" || messageID == "<nil>" {
messageID = "<unknown>"
}
unverifiable = append(unverifiable, messageID)
continue
}
if allowed[senderID] {
filtered = append(filtered, message)
continue
}
if targetresolver.LooksLikeCurrentDOpenDingTalkID(senderID) {
if !hasOpenIDs {
unverifiable = append(unverifiable, searchScopeMessageID(message))
}
} else if !hasUserIDs {
unverifiable = append(unverifiable, searchScopeMessageID(message))
}
}
return filtered, uniqueSearchStrings(unverifiable)
}
func searchScopeMessageID(message map[string]any) string {
messageID := strings.TrimSpace(fmt.Sprint(searchMsgMessageID(message)))
if messageID == "" || messageID == "<nil>" {
return "<unknown>"
}
return messageID
}
func searchSenderScopeUnverifiedError(
resolutions []targetresolver.UserResolution,
messageIDs []string,
) error {
queries := make([]string, 0, len(resolutions))
for _, resolution := range resolutions {
queries = append(queries, resolution.Query)
}
return apperrors.NewAPI(
"搜索结果缺少 senderId,无法证明发送者过滤范围;已停止输出",
apperrors.WithReason("search_sender_scope_unverified"),
apperrors.WithDetails(map[string]any{
"requestedSenders": uniqueSearchStrings(queries),
"unverifiableMessageIds": messageIDs,
}),
apperrors.WithRetryable(false),
apperrors.WithHint("请保留 trace_id 并检查 IM 搜索服务是否返回稳定 senderId"),
)
}
func enrichSearchMessages(rt *shortcut.RuntimeContext, messages []map[string]any) ([]map[string]any, int, []map[string]any) {
detailsByID := map[string]map[string]any{}
failures := make([]map[string]any, 0)
@@ -15,25 +15,35 @@ 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/targetresolver"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type searchMsgExecutionCaller struct {
calls []platformCoverageCall
failSecondPage bool
failEnrichment bool
omitPagination bool
omitMgetItem bool
failPreflight bool
preflightError error
searchResponse string
wrongMgetScope bool
missingMgetCID bool
firstResponse string
mgetResponse string
numericZeroEnd bool
calls []platformCoverageCall
failSecondPage bool
failEnrichment bool
omitPagination bool
omitMgetItem bool
failPreflight bool
preflightError error
searchResponse string
wrongMgetScope bool
missingMgetCID bool
firstResponse string
mgetResponse string
numericZeroEnd bool
contactResponse string
groupResponse string
failContactKeyword string
failGroupKeyword string
}
const (
testCurrentDOpenID = "DAAAAAAAAAAAiE"
testCurrentDOpenID2 = "DAQEBAQEBAQEiE"
)
func (f *searchMsgExecutionCaller) 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 == "chat" && tool == "get_conversation_info" {
@@ -45,10 +55,28 @@ func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool str
}
return searchMsgToolResult(`{"result":{"openConversationId":"` + args["openConversationId"].(string) + `"}}`), nil
}
if product == "contact" && tool == "search_contact_by_key_word" {
if f.failContactKeyword != "" && args["keyword"] == f.failContactKeyword {
return nil, errors.New("fixture contact failure")
}
if f.contactResponse != "" {
return searchMsgToolResult(f.contactResponse), nil
}
keyword := args["keyword"].(string)
return searchMsgToolResult(`{"result":[{"userId":"user-` + keyword + `","openDingTalkId":"` + testCurrentDOpenID + `","name":"` + keyword + `"}],"hasMore":false}`), nil
}
if product != "im" {
return nil, errors.New("unexpected product")
}
switch tool {
case "search_groups":
if f.failGroupKeyword != "" && args["keyword"] == f.failGroupKeyword {
return nil, errors.New("fixture group failure")
}
if f.groupResponse != "" {
return searchMsgToolResult(f.groupResponse), nil
}
return searchMsgToolResult(`{"result":[{"openConversationId":"cid-resolved-group","title":"` + args["keyword"].(string) + `"}],"hasMore":false}`), nil
case "search_messages":
if f.searchResponse != "" {
return searchMsgToolResult(f.searchResponse), nil
@@ -63,12 +91,12 @@ func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool str
if f.failSecondPage {
return nil, errors.New("second page unavailable")
}
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m2","openConversationId":"cid-2","content":"sparse-2"}],"hasMore":false}}`), nil
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m2","openConversationId":"cid-2","senderOpenDingTalkId":"` + testCurrentDOpenID + `","content":"sparse-2"}],"hasMore":false}}`), nil
}
if f.firstResponse != "" {
return searchMsgToolResult(f.firstResponse), nil
}
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-1","content":"sparse-1"}],"hasMore":true,"nextCursor":"c2"}}`), nil
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-1","senderOpenDingTalkId":"` + testCurrentDOpenID + `","content":"sparse-1"}],"hasMore":true,"nextCursor":"c2"}}`), nil
case "list_messages_by_ids":
if f.failEnrichment {
return nil, errors.New("mget unavailable")
@@ -129,13 +157,175 @@ func executeSearchMsgResult(caller *searchMsgExecutionCaller, args ...string) (m
return payload, nil
}
func TestSearchMsgMixedSenderClassifiesFormatWithoutIDPreflight(t *testing.T) {
tests := []string{"D-prefix-fixture-user", "d-prefix-fixture-user", "测试用户甲", "fixture-user-id"}
for _, target := range tests {
t.Run(target, func(t *testing.T) {
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
payload := executeSearchMsg(t, caller, "--sender", target, "--no-enrich")
if payload["complete"] != true || len(caller.calls) != 2 {
t.Fatalf("payload=%#v calls=%#v", payload, caller.calls)
}
if caller.calls[0].product != "contact" || caller.calls[0].tool != "search_contact_by_key_word" ||
caller.calls[1].tool != "search_messages" {
t.Fatalf("calls=%#v", caller.calls)
}
if got, want := caller.calls[1].args["senderOpenDingTakIds"], []string{testCurrentDOpenID}; !reflect.DeepEqual(got, want) {
t.Fatalf("senderOpenDingTakIds=%#v want=%#v", got, want)
}
})
}
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
executeSearchMsg(t, caller, "--sender", testCurrentDOpenID, "--no-enrich")
if len(caller.calls) != 1 || caller.calls[0].tool != "search_messages" {
t.Fatalf("format-valid ID unexpectedly preflighted: %#v", caller.calls)
}
}
func TestSearchMsgSenderScopeFiltersBackendOverReturn(t *testing.T) {
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[
{"openMessageId":"wanted","senderOpenDingTalkId":"DAAAAAAAAAAAiE","content":"keep"},
{"openMessageId":"other","senderOpenDingTalkId":"DAQEBAQEBAQEiE","content":"drop"}
],"hasMore":false}}`}
payload := executeSearchMsg(t, caller, "--sender", testCurrentDOpenID, "--no-enrich")
if payload["count"] != float64(1) {
t.Fatalf("payload=%#v", payload)
}
messages := payload["messages"].([]any)
if messages[0].(map[string]any)["messageId"] != "wanted" {
t.Fatalf("messages=%#v", messages)
}
}
func TestCrossPlatformCoverageSearchMsgSenderScopeRejectsUnmappedIdentityFamily(t *testing.T) {
filtered, unverifiable := filterSearchSenderScope(
[]map[string]any{{"openMessageId": "m-user-family", "senderUserId": "user-1"}},
[]targetresolver.UserResolution{{
Selected: targetresolver.User{OpenDingTalkID: testCurrentDOpenID},
}},
)
if len(filtered) != 0 || !reflect.DeepEqual(unverifiable, []string{"m-user-family"}) {
t.Fatalf("filtered=%#v unverifiable=%#v", filtered, unverifiable)
}
}
func TestCrossPlatformCoverageSearchMsgResolutionFailureEdges(t *testing.T) {
for _, tc := range []struct {
name string
caller *searchMsgExecutionCaller
args []string
}{
{
name: "group target",
caller: &searchMsgExecutionCaller{failGroupKeyword: "missing-group"},
args: []string{"--group", "missing-group", "--no-enrich"},
},
{
name: "sender target",
caller: &searchMsgExecutionCaller{failContactKeyword: "missing-sender"},
args: []string{"--sender", "missing-sender", "--no-enrich"},
},
{
name: "sender query",
caller: &searchMsgExecutionCaller{failContactKeyword: "missing-query"},
args: []string{"--sender-query", "missing-query", "--no-enrich"},
},
{
name: "at stable id",
caller: &searchMsgExecutionCaller{failContactKeyword: "missing-at"},
args: []string{"--at-ids", "missing-at", "--no-enrich"},
},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := executeSearchMsgResult(tc.caller, tc.args...); err == nil {
t.Fatal("resolution failure unexpectedly succeeded")
}
})
}
}
func TestCrossPlatformCoverageSearchMsgSenderScopeFailureEdges(t *testing.T) {
t.Run("execute rejects senderless backend rows", func(t *testing.T) {
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[{"content":"missing identity"}],"hasMore":false}}`}
_, err := executeSearchMsgResult(caller, "--sender", testCurrentDOpenID, "--no-enrich")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "search_sender_scope_unverified" {
t.Fatalf("error=%#v", err)
}
})
resolutions := []targetresolver.UserResolution{{
Query: "fixture-user",
Selected: targetresolver.User{UserID: "wanted-user"},
}}
filtered, unverifiable := filterSearchSenderScope(
[]map[string]any{
{"content": "missing identity"},
{"openMessageId": "wanted", "senderUserId": "wanted-user"},
{"openMessageId": "open-family", "senderOpenDingTalkId": testCurrentDOpenID},
},
resolutions,
)
if len(filtered) != 1 || !reflect.DeepEqual(unverifiable, []string{"<unknown>", "open-family"}) {
t.Fatalf("filtered=%#v unverifiable=%#v", filtered, unverifiable)
}
if got := searchScopeMessageID(map[string]any{}); got != "<unknown>" {
t.Fatalf("missing message ID=%q", got)
}
if got := searchScopeMessageID(map[string]any{"openMessageId": "message-1"}); got != "message-1" {
t.Fatalf("message ID=%q", got)
}
err := searchSenderScopeUnverifiedError(
append(resolutions, resolutions...),
[]string{"message-1"},
)
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "search_sender_scope_unverified" {
t.Fatalf("error=%#v", err)
}
}
func TestCrossPlatformCoverageSearchMsgExplicitDaysMetadata(t *testing.T) {
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
payload := executeSearchMsg(t, caller, "--query", "fixture", "--days", "3", "--no-enrich")
coverage := payload["timeCoverage"].(map[string]any)
if coverage["source"] != "explicit_days" || coverage["days"] != float64(3) {
t.Fatalf("coverage=%#v", coverage)
}
}
func TestCrossPlatformCoverageSearchMsgRejectsNonConversationIDAlias(t *testing.T) {
caller := &searchMsgExecutionCaller{}
_, err := executeSearchMsgResult(caller, "--query", "fixture", "--chat-id", "group-name", "--no-enrich")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "target_type_mismatch" {
t.Fatalf("error=%#v", err)
}
}
func TestCrossPlatformCoverageSearchMsgGroupNameAndDefaultWindowMetadata(t *testing.T) {
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
payload := executeSearchMsg(t, caller, "--group", "项目讨论群", "--no-enrich")
if len(caller.calls) != 3 || caller.calls[0].tool != "search_groups" ||
caller.calls[1].tool != "get_conversation_info" || caller.calls[2].tool != "search_messages" {
t.Fatalf("calls=%#v", caller.calls)
}
coverage := payload["timeCoverage"].(map[string]any)
guard := payload["conclusionGuard"].(map[string]any)
if coverage["source"] != "implicit_default" || coverage["days"] != float64(7) ||
guard["absenceAcrossAllHistoryAllowed"] != false {
t.Fatalf("coverage=%#v guard=%#v", coverage, guard)
}
}
func TestCrossPlatformCoverageSearchMsgPagesAndEnrichesWithAdvancedFilters(t *testing.T) {
caller := &searchMsgExecutionCaller{}
payload := executeSearchMsg(t, caller,
"--query", "周报",
"--chat-id", "cid-1,cid-2",
"--sender", "42,Dsender",
"--at-ids", "43,Dat",
"--sender", testCurrentDOpenID,
"--at-ids", testCurrentDOpenID2,
"--is-at-me",
"--message-type", "text",
"--only-robot",
@@ -160,10 +350,8 @@ func TestCrossPlatformCoverageSearchMsgPagesAndEnrichesWithAdvancedFilters(t *te
t.Fatalf("first call = %#v", first)
}
for key, want := range map[string]any{
"senderUserIds": []string{"42"},
"senderOpenDingTakIds": []string{"Dsender"},
"atUserIds": []string{"43"},
"atOpenDingTakIds": []string{"Dat"},
"senderOpenDingTakIds": []string{testCurrentDOpenID},
"atOpenDingTakIds": []string{testCurrentDOpenID2},
"messageType": "text",
"onlyRobotMessages": true,
"searchConvType": "group",
@@ -9,6 +9,7 @@
package targetresolver
import (
"encoding/base64"
stderrors "errors"
"fmt"
"strings"
@@ -105,6 +106,274 @@ func ResolveUser(rt Reader, query string, requirement IdentityRequirement) (User
return resolveUserCandidates(ExtractUsers(data), query, requirement)
}
// ResolveUserTarget accepts the mixed user target used by public convenience
// flags: a natural name, userId, or current-version openDingTalkId. The local
// format check is deliberately a classifier rather than an existence check.
// Format-valid IDs go directly to the downstream business API; all other
// values use the directory resolver, which can match names and exact userIds.
func ResolveUserTarget(rt Reader, value string, requirement IdentityRequirement) (UserResolution, error) {
value = strings.TrimSpace(value)
if value == "" {
return UserResolution{}, apperrors.NewValidation(
"必须指定用户姓名、userId 或 openDingTalkId",
apperrors.WithReason("missing_target"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("request_validation"),
apperrors.WithExecutionStarted(false),
)
}
if !LooksLikeCurrentDOpenDingTalkID(value) {
return ResolveUser(rt, value, requirement)
}
if requirement == IdentityUserID {
return UserResolution{}, apperrors.NewValidation(
fmt.Sprintf("用户目标参数类型不匹配:%q 符合当前版本 openDingTalkId 格式,但下游只接受 userId", value),
apperrors.WithReason("target_type_mismatch"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
)
}
return UserResolution{
Status: StatusResolved,
EntityType: "user",
Query: value,
MatchType: "stable_id",
Selected: User{OpenDingTalkID: value},
Profile: profilectx.Get(),
}, nil
}
// ResolveSenderTarget resolves the mixed identity accepted specifically by
// sender convenience flags. Unlike the shared name resolver, it may select a
// unique candidate whose stable userId/openDingTalkId exactly equals the
// supplied value. Natural-name candidates retain the shared fail-closed
// ambiguity behavior.
func ResolveSenderTarget(rt Reader, value string, requirement IdentityRequirement) (UserResolution, error) {
value = strings.TrimSpace(value)
if value == "" || LooksLikeCurrentDOpenDingTalkID(value) {
return ResolveUserTarget(rt, value, requirement)
}
data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{
"keyword": value,
})
if err != nil {
return UserResolution{}, err
}
users := filterUsersByIdentity(dedupeUsers(ExtractUsers(data)), requirement)
stableMatches := make([]User, 0, 1)
for _, user := range users {
if strings.TrimSpace(user.UserID) == value ||
strings.TrimSpace(user.OpenDingTalkID) == value {
stableMatches = append(stableMatches, user)
}
}
if len(stableMatches) == 1 {
return UserResolution{
Status: StatusResolved,
EntityType: "user",
Query: value,
MatchType: "stable_id",
Selected: stableMatches[0],
Profile: profilectx.Get(),
}, nil
}
if len(stableMatches) > 1 {
return UserResolution{}, newResolutionError(StatusAmbiguous, "user", value, stableMatches)
}
if cause := incompleteUserSearchCause(data, "通讯录搜索"); cause != "" {
return UserResolution{}, newIncompleteUserResolutionError(value, users, cause)
}
return resolveUserCandidates(users, value, requirement)
}
// ResolveStableUserTarget accepts only an exact userId or current-version
// openDingTalkId. It is intended for flags whose contract explicitly excludes
// natural names; no unique-name fallback is allowed.
func ResolveStableUserTarget(rt Reader, value string, requirement IdentityRequirement) (UserResolution, error) {
value = strings.TrimSpace(value)
if value == "" {
return UserResolution{}, apperrors.NewValidation(
"必须指定 userId 或 openDingTalkId",
apperrors.WithReason("missing_target"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("request_validation"),
apperrors.WithExecutionStarted(false),
)
}
if LooksLikeCurrentDOpenDingTalkID(value) {
if requirement == IdentityUserID {
return UserResolution{}, apperrors.NewValidation(
fmt.Sprintf("用户目标参数类型不匹配:%q 是 openDingTalkId,但当前参数只接受 userId", value),
apperrors.WithReason("target_type_mismatch"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
)
}
return UserResolution{
Status: StatusResolved,
EntityType: "user",
Query: value,
MatchType: "stable_id",
Selected: User{OpenDingTalkID: value},
Profile: profilectx.Get(),
}, nil
}
data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{
"keyword": value,
})
if err != nil {
return UserResolution{}, err
}
users := filterUsersByIdentity(dedupeUsers(ExtractUsers(data)), requirement)
stableMatches := make([]User, 0, 1)
for _, user := range users {
if strings.TrimSpace(user.UserID) == value ||
strings.TrimSpace(user.OpenDingTalkID) == value {
stableMatches = append(stableMatches, user)
}
}
if len(stableMatches) == 1 {
return UserResolution{
Status: StatusResolved,
EntityType: "user",
Query: value,
MatchType: "stable_id",
Selected: stableMatches[0],
Profile: profilectx.Get(),
}, nil
}
if len(stableMatches) > 1 {
return UserResolution{}, newResolutionError(StatusAmbiguous, "user", value, stableMatches)
}
if cause := incompleteUserSearchCause(data, "通讯录搜索"); cause != "" {
return UserResolution{}, newIncompleteUserResolutionError(value, users, cause)
}
return UserResolution{}, apperrors.NewValidation(
fmt.Sprintf("%q 未精确匹配到 userId 或 openDingTalkId", value),
apperrors.WithReason("stable_user_identity_required"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
apperrors.WithHint("请先解析目标人员,并传入通讯录结果中原样返回的 userId 或 openDingTalkId"),
apperrors.WithDetails(map[string]any{
"providedValue": value,
"expectedTypes": []string{"userId", "openDingTalkId"},
}),
)
}
// LooksLikeCurrentDOpenDingTalkID reports whether value has the canonical
// wire format emitted by the current D-version encoder. It does not decrypt
// the value and therefore does not prove that the identity exists or is
// accessible to the current profile.
func LooksLikeCurrentDOpenDingTalkID(value string) bool {
value = strings.TrimSpace(value)
if len(value) < 2 || value[0] != 'D' {
return false
}
encoded, ok := unescapeCurrentDOpenDingTalkID(value[1:])
if !ok {
return false
}
ciphertext, err := base64.StdEncoding.Strict().DecodeString(encoded)
if err != nil || len(ciphertext) == 0 || len(ciphertext)%8 != 0 {
return false
}
// Require the canonical Base64 and escape spelling. This rejects alternate
// but decodable strings and keeps the classifier aligned with the encoder.
canonical := escapeCurrentDOpenDingTalkID(base64.StdEncoding.EncodeToString(ciphertext))
return canonical == value[1:]
}
func unescapeCurrentDOpenDingTalkID(value string) (string, bool) {
var decoded strings.Builder
decoded.Grow(len(value))
for i := 0; i < len(value); i++ {
current := value[i]
if current != 'i' {
if !isASCIIAlphaNumeric(current) {
return "", false
}
decoded.WriteByte(current)
continue
}
if i+1 >= len(value) {
return "", false
}
i++
switch value[i] {
case 'i':
decoded.WriteByte('i')
case 'P':
decoded.WriteByte('+')
case 'S':
decoded.WriteByte('/')
case 'E':
decoded.WriteByte('=')
default:
return "", false
}
}
return decoded.String(), true
}
func escapeCurrentDOpenDingTalkID(value string) string {
var encoded strings.Builder
encoded.Grow(len(value))
for i := 0; i < len(value); i++ {
switch value[i] {
case 'i':
encoded.WriteString("ii")
case '+':
encoded.WriteString("iP")
case '/':
encoded.WriteString("iS")
case '=':
encoded.WriteString("iE")
default:
encoded.WriteByte(value[i])
}
}
return encoded.String()
}
func isASCIIAlphaNumeric(value byte) bool {
return value >= '0' && value <= '9' ||
value >= 'A' && value <= 'Z' ||
value >= 'a' && value <= 'z'
}
// ValidateExplicitOpenDingTalkID applies the current-version format contract
// to an explicitly typed ID flag. Unlike mixed targets, an invalid explicit ID
// must fail locally instead of falling back to a name or userId lookup.
func ValidateExplicitOpenDingTalkID(flagName, value string) error {
if value == strings.TrimSpace(value) && LooksLikeCurrentDOpenDingTalkID(value) {
return nil
}
return apperrors.NewValidation(
fmt.Sprintf("%s 收到的值不符合当前 D 版本 openDingTalkId 格式", flagName),
apperrors.WithReason("target_type_mismatch"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("request_validation"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
apperrors.WithHint("请传通讯录或消息结果中原样返回的 openDingTalkId;姓名或 userId 请使用对应的混合目标参数"),
apperrors.WithDetails(map[string]any{
"flag": flagName,
"expectedType": "current_d_openDingTalkId",
}),
)
}
// 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.
@@ -686,3 +686,278 @@ func TestCrossPlatformCoverageResolverCompletionBranches(t *testing.T) {
}
})
}
func TestCrossPlatformCoverageLooksLikeCurrentDOpenDingTalkID(t *testing.T) {
tests := []struct {
name string
value string
want bool
}{
{name: "canonical current D ciphertext", value: "DAAAAAAAAAAAiE", want: true},
{name: "synthetic current D ciphertext with escaped plus", value: "DiPiPiPiPiPiPiPiPiPiP8iE", want: true},
{name: "synthetic current D ciphertext with escaped literal i", value: "DYmJiiYmJiiYmIiE", want: true},
{name: "surrounding whitespace", value: " DAAAAAAAAAAAiE ", want: true},
{name: "empty", value: "", want: false},
{name: "prefix only", value: "D", want: false},
{name: "lowercase prefix", value: "dAAAAAAAAAAAiE", want: false},
{name: "natural uppercase D fixture", value: "D-prefix-fixture-user", want: false},
{name: "natural lowercase d fixture", value: "d-prefix-fixture-user", want: false},
{name: "invalid escape", value: "DAAAAAiDAAAAAiE", want: false},
{name: "non alphanumeric body", value: "DAAAAA/AAAAAiE", want: false},
{name: "invalid base64", value: "Dabc", want: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := LooksLikeCurrentDOpenDingTalkID(tc.value); got != tc.want {
t.Fatalf("LooksLikeCurrentDOpenDingTalkID(%q) = %v, want %v", tc.value, got, tc.want)
}
})
}
}
func TestResolveUserTargetUsesFormatClassificationWithoutIDPreflight(t *testing.T) {
const openID = "DAAAAAAAAAAAiE"
calls := 0
resolved, err := ResolveUserTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
calls++
return nil, stderrors.New("unexpected remote call")
}), openID, IdentityAny)
if err != nil {
t.Fatal(err)
}
if calls != 0 || resolved.MatchType != "stable_id" || resolved.Selected.OpenDingTalkID != openID {
t.Fatalf("calls=%d resolved=%#v", calls, resolved)
}
}
func TestCrossPlatformCoverageResolveSenderTargetScopesStableIDPreferenceToSenderInputs(t *testing.T) {
calls := 0
reader := resolverReaderFunc(func(product, tool string, params map[string]any) (map[string]any, error) {
calls++
if product != "contact" || tool != "search_contact_by_key_word" {
t.Fatalf("unexpected tool %s/%s", product, tool)
}
query := fmt.Sprint(params["keyword"])
switch query {
case "D-prefix-fixture-user":
return map[string]any{"result": []any{map[string]any{
"userId": "fixture-user-d-upper", "openDingTalkId": "D-directory-value", "name": "D-prefix-fixture-user",
}}}, nil
case "d-prefix-fixture-user":
return map[string]any{"result": []any{map[string]any{
"userId": "fixture-user-d-lower", "openDingTalkId": "D-directory-value-2", "name": "d-prefix-fixture-user",
}}}, nil
case "fixture-user-id":
return map[string]any{"result": []any{
map[string]any{"userId": "other", "name": "相似候选"},
map[string]any{"userId": "fixture-user-id", "openDingTalkId": "D-directory-value-3", "name": "测试目标用户"},
}}, nil
default:
t.Fatalf("unexpected query %q", query)
return nil, nil
}
})
for _, query := range []string{"D-prefix-fixture-user", "d-prefix-fixture-user"} {
_, err := ResolveSenderTarget(reader, query, IdentityAny)
if err != nil {
t.Fatalf("%s: %v", query, err)
}
}
_, err := ResolveUser(reader, "fixture-user-id", IdentityAny)
var ambiguous *apperrors.Error
if !stderrors.As(err, &ambiguous) || ambiguous.Reason != "resolution_ambiguous" {
t.Fatalf("shared name resolver error = %#v, want resolution_ambiguous", err)
}
resolved, err := ResolveSenderTarget(reader, "fixture-user-id", IdentityAny)
if err != nil {
t.Fatal(err)
}
if resolved.MatchType != "stable_id" || resolved.Selected.UserID != "fixture-user-id" {
t.Fatalf("exact sender userId resolution = %#v", resolved)
}
if calls != 4 {
t.Fatalf("calls=%d", calls)
}
}
func TestValidateExplicitOpenDingTalkIDNeverFallsBack(t *testing.T) {
if err := ValidateExplicitOpenDingTalkID("--open-dingtalk-id", "DAAAAAAAAAAAiE"); err != nil {
t.Fatalf("valid format: %v", err)
}
err := ValidateExplicitOpenDingTalkID("--open-dingtalk-id", "D-prefix-fixture-user")
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Reason != "target_type_mismatch" {
t.Fatalf("error=%#v", err)
}
err = ValidateExplicitOpenDingTalkID("--open-dingtalk-id", " DAAAAAAAAAAAiE ")
if !stderrors.As(err, &typed) || typed.Reason != "target_type_mismatch" {
t.Fatalf("whitespace-wrapped explicit ID error=%#v", err)
}
}
func TestCrossPlatformCoverageResolveStableUserTargetRejectsNaturalNameButAcceptsExactUserID(t *testing.T) {
reader := resolverReaderFunc(func(product, tool string, params map[string]any) (map[string]any, error) {
if product != "contact" || tool != "search_contact_by_key_word" {
t.Fatalf("unexpected tool %s/%s", product, tool)
}
return map[string]any{"result": []any{
map[string]any{"userId": "fixture-user-id", "openDingTalkId": "DAAAAAAAAAAAiE", "name": "测试用户甲"},
}}, nil
})
resolved, err := ResolveStableUserTarget(reader, "fixture-user-id", IdentityAny)
if err != nil {
t.Fatal(err)
}
if resolved.MatchType != "stable_id" || resolved.Selected.UserID != "fixture-user-id" {
t.Fatalf("exact userId resolution = %#v", resolved)
}
_, err = ResolveStableUserTarget(reader, "测试用户甲", IdentityAny)
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Reason != "stable_user_identity_required" {
t.Fatalf("natural name error = %#v", err)
}
}
func TestResolveStableUserTargetRoutesCurrentDWithoutDirectoryLookup(t *testing.T) {
const openID = "DAAAAAAAAAAAiE"
calls := 0
resolved, err := ResolveStableUserTarget(resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
calls++
return nil, stderrors.New("unexpected remote call")
}), openID, IdentityAny)
if err != nil {
t.Fatal(err)
}
if calls != 0 || resolved.Selected.OpenDingTalkID != openID {
t.Fatalf("calls=%d resolved=%#v", calls, resolved)
}
}
func TestStableIdentityExactMatchWinsEvenWhenDirectoryPageIsIncomplete(t *testing.T) {
reader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
return map[string]any{
"result": []any{
map[string]any{"userId": "fixture-user-id", "openDingTalkId": "DAAAAAAAAAAAiE", "name": "测试目标用户"},
map[string]any{"userId": "other", "name": "其他候选"},
},
"hasMore": true,
}, nil
})
for _, resolve := range []struct {
name string
fn func(Reader, string, IdentityRequirement) (UserResolution, error)
}{
{name: "sender", fn: ResolveSenderTarget},
{name: "stable-only", fn: ResolveStableUserTarget},
} {
t.Run(resolve.name, func(t *testing.T) {
resolved, err := resolve.fn(reader, "fixture-user-id", IdentityAny)
if err != nil {
t.Fatal(err)
}
if resolved.MatchType != "stable_id" || resolved.Selected.UserID != "fixture-user-id" {
t.Fatalf("resolved=%#v", resolved)
}
})
}
}
func TestCrossPlatformCoverageMixedIdentityResolutionFailureEdges(t *testing.T) {
unexpectedReader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
return nil, stderrors.New("directory unavailable")
})
for _, tc := range []struct {
name string
resolve func(Reader, string, IdentityRequirement) (UserResolution, error)
value string
requirement IdentityRequirement
wantReason string
}{
{name: "mixed empty", resolve: ResolveUserTarget, wantReason: "missing_target"},
{name: "mixed open id requires user id", resolve: ResolveUserTarget, value: "DAAAAAAAAAAAiE", requirement: IdentityUserID, wantReason: "target_type_mismatch"},
{name: "stable empty", resolve: ResolveStableUserTarget, wantReason: "missing_target"},
{name: "stable open id requires user id", resolve: ResolveStableUserTarget, value: "DAAAAAAAAAAAiE", requirement: IdentityUserID, wantReason: "target_type_mismatch"},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := tc.resolve(unexpectedReader, tc.value, tc.requirement)
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Reason != tc.wantReason {
t.Fatalf("error=%#v, want reason %q", err, tc.wantReason)
}
})
}
if _, err := ResolveUserTarget(unexpectedReader, "ordinary-user", IdentityAny); err == nil || err.Error() != "directory unavailable" {
t.Fatalf("mixed directory error=%v", err)
}
if _, err := ResolveSenderTarget(unexpectedReader, "ordinary-user", IdentityAny); err == nil || err.Error() != "directory unavailable" {
t.Fatalf("sender directory error=%v", err)
}
if _, err := ResolveStableUserTarget(unexpectedReader, "ordinary-user", IdentityAny); err == nil || err.Error() != "directory unavailable" {
t.Fatalf("stable directory error=%v", err)
}
}
func TestCrossPlatformCoverageStableIdentityAmbiguousAndIncompleteEdges(t *testing.T) {
duplicateReader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
return map[string]any{"result": []any{
map[string]any{"userId": "duplicate", "openDingTalkId": "D-one", "name": "甲"},
map[string]any{"userId": "other", "openDingTalkId": "duplicate", "name": "乙"},
}}, nil
})
for _, resolve := range []struct {
name string
fn func(Reader, string, IdentityRequirement) (UserResolution, error)
}{
{name: "sender", fn: ResolveSenderTarget},
{name: "stable", fn: ResolveStableUserTarget},
} {
t.Run(resolve.name+" ambiguous", func(t *testing.T) {
_, err := resolve.fn(duplicateReader, "duplicate", IdentityAny)
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Reason != "resolution_ambiguous" {
t.Fatalf("error=%#v", err)
}
})
}
incompleteReader := resolverReaderFunc(func(string, string, map[string]any) (map[string]any, error) {
return map[string]any{
"result": []any{map[string]any{"userId": "other", "name": "其他"}},
"hasMore": true,
}, nil
})
for _, resolve := range []struct {
name string
fn func(Reader, string, IdentityRequirement) (UserResolution, error)
}{
{name: "sender", fn: ResolveSenderTarget},
{name: "stable", fn: ResolveStableUserTarget},
} {
t.Run(resolve.name+" incomplete", func(t *testing.T) {
_, err := resolve.fn(incompleteReader, "missing", IdentityAny)
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Reason != "resolution_incomplete" {
t.Fatalf("error=%#v", err)
}
})
}
}
func TestCrossPlatformCoverageCurrentDCodecEdges(t *testing.T) {
if _, ok := unescapeCurrentDOpenDingTalkID("abcdi"); ok {
t.Fatal("dangling escape unexpectedly accepted")
}
if decoded, ok := unescapeCurrentDOpenDingTalkID("iS"); !ok || decoded != "/" {
t.Fatalf("slash escape decoded=%q ok=%v", decoded, ok)
}
if got := escapeCurrentDOpenDingTalkID("i+/="); got != "iiiPiSiE" {
t.Fatalf("escaped=%q", got)
}
}
+57 -28
View File
@@ -54,7 +54,7 @@ while [ "$#" -gt 0 ]; do
done
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-package-verify-XXXXXX")"
HOME_AGENT_PARENTS="
HOME_SPECIFIC_AGENT_PARENTS="
.claude
.cursor
.qoder
@@ -72,8 +72,7 @@ HOME_AGENT_PARENTS="
.openclaw
.hermes
"
HOME_SKILL_BASES="
.agents/skills
HOME_SPECIFIC_SKILL_BASES="
.claude/skills
.cursor/skills
.qoder/skills
@@ -102,43 +101,55 @@ cleanup() {
}
trap cleanup EXIT INT TERM
seed_agent_homes() {
seed_specific_agent_homes() {
home_root="$1"
for parent in $HOME_AGENT_PARENTS; do
for parent in $HOME_SPECIFIC_AGENT_PARENTS; do
mkdir -p "$home_root/$parent"
done
}
verify_skill_base() {
home_root="$1"
base="$2"
need_file "$home_root/$base/dingtalk-shared/SKILL.md"
need_file "$home_root/$base/dingtalk-misc/SKILL.md"
[ ! -e "$home_root/$base/dws" ] || err "unexpected mono Skill layout found in $home_root/$base/dws"
}
verify_skill_targets() {
home_root="$1"
for base in $HOME_SKILL_BASES; do
need_file "$home_root/$base/dingtalk-shared/SKILL.md"
need_file "$home_root/$base/dingtalk-misc/SKILL.md"
[ ! -e "$home_root/$base/dws" ] || err "unexpected mono Skill layout found in $home_root/$base/dws"
found_specific=0
for base in $HOME_SPECIFIC_SKILL_BASES; do
parent="${base%/skills}"
if [ -d "$home_root/$parent" ]; then
verify_skill_base "$home_root" "$base"
found_specific=1
fi
done
if [ "$found_specific" -eq 0 ]; then
verify_skill_base "$home_root" ".agents/skills"
return
fi
for name in dingtalk-shared dingtalk-misc dws; do
[ ! -e "$home_root/.agents/skills/$name" ] || \
err "unexpected generic Skill copy found in $home_root/.agents/skills/$name"
done
}
verify_npm() {
need_cmd npm
need_cmd node
need_cmd tar
need_cmd unzip
need_file "$NPM_STAGE_DIR/package.json"
npm_home="$TMP_ROOT/npm-home"
npm_prefix="$TMP_ROOT/npm-prefix"
npm_cache="$TMP_ROOT/npm-cache"
verify_npm_install() {
tarball_path="$1"
scenario="$2"
npm_home="$TMP_ROOT/npm-home-$scenario"
npm_prefix="$TMP_ROOT/npm-prefix-$scenario"
npm_cache="$TMP_ROOT/npm-cache-$scenario"
mkdir -p "$npm_home" "$npm_prefix" "$npm_cache"
seed_agent_homes "$npm_home"
say "==> verifying npm package install"
tarball_name="$(
cd "$NPM_STAGE_DIR"
HOME="$npm_home" npm_config_cache="$npm_cache" npm pack --silent
)"
tarball_path="$NPM_STAGE_DIR/$tarball_name"
need_file "$tarball_path"
if [ "$scenario" = "specific-agent-roots" ]; then
seed_specific_agent_homes "$npm_home"
fi
say "==> verifying npm package install ($scenario)"
HOME="$npm_home" npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \
npm install -g "$tarball_path" >/dev/null
@@ -158,6 +169,24 @@ verify_npm() {
HOME="$npm_home" npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \
npm uninstall -g dingtalk-workspace-cli >/dev/null
}
verify_npm() {
need_cmd npm
need_cmd node
need_cmd tar
need_cmd unzip
need_file "$NPM_STAGE_DIR/package.json"
tarball_name="$(
cd "$NPM_STAGE_DIR"
HOME="$TMP_ROOT/npm-pack-home" npm_config_cache="$TMP_ROOT/npm-pack-cache" npm pack --silent
)"
tarball_path="$NPM_STAGE_DIR/$tarball_name"
need_file "$tarball_path"
verify_npm_install "$tarball_path" "specific-agent-roots"
verify_npm_install "$tarball_path" "generic-fallback"
rm -f "$tarball_path"
}
+10 -9
View File
@@ -33,6 +33,7 @@ import (
const (
mockMCPSmokeHelperEnv = "DWS_MOCK_MCP_SMOKE_HELPER"
mockMCPSmokeCAEnv = "DWS_MOCK_MCP_SMOKE_CA_FILE"
mockCurrentDOpenID = "DAAAAAAAAAAAiE"
)
type recordedToolCall struct {
@@ -293,7 +294,7 @@ func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) {
stdout, stderr, err := runCLI(t, env,
"--token", "ci-smoke-token", "--format", "json",
"chat", "+messages-send", "--as", "user",
"--user-query", "张三", "--text", "你好", "--yes",
"--user-query", "测试用户甲", "--text", "你好", "--yes",
)
if err != nil {
t.Fatalf("natural send failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
@@ -302,7 +303,7 @@ func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) {
if len(calls) != 2 || calls[0].tool != "search_contact_by_key_word" || calls[1].tool != "send_personal_message" {
t.Fatalf("calls = %#v, want resolve + one send", calls)
}
if calls[1].arguments["receiverOpenDingTalkId"] != "D-user-1" {
if calls[1].arguments["receiverOpenDingTalkId"] != mockCurrentDOpenID {
t.Fatalf("send target = %#v", calls[1].arguments)
}
assertMultiIMCallsAreLocalAndAuthorized(t, calls)
@@ -473,7 +474,7 @@ func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) {
t.Fatalf("messages = %#v", payload["messages"])
}
message, _ := messages[0].(map[string]any)
if message["messageId"] != "msg-1" || message["senderId"] != "D-sender" || message["senderType"] != "user" {
if message["messageId"] != "msg-1" || message["senderId"] != mockCurrentDOpenID || message["senderType"] != "user" {
t.Fatalf("projected message = %#v", message)
}
})
@@ -483,7 +484,7 @@ func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) {
stdout, stderr, err := runCLI(t, env,
"--token", "ci-smoke-token", "--format", "json",
"chat", "+chat-create", "--name", "新群",
"--member-query", "张三", "--dry-run", "--yes",
"--member-query", "测试用户甲", "--dry-run", "--yes",
)
if err != nil {
t.Fatalf("chat-create dry-run failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
@@ -505,7 +506,7 @@ func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) {
"--token", "ci-smoke-token", "--format", "json",
"chat", "+messages-reply",
"--conversation-id", "cid-1", "--message-id", "msg-1",
"--ref-sender", "D-sender", "--text", "收到",
"--ref-sender", mockCurrentDOpenID, "--text", "收到",
"--idempotency-key", "reply-key", "--yes",
)
if err != nil {
@@ -532,7 +533,7 @@ func multiIMMockResponse(tool string, arguments map[string]any, mcpBaseURL, reso
if arguments["keyword"] == "同名用户" {
return `{"result":[{"name":"同名用户","userId":"u1","openDingTalkId":"D1"},{"name":"同名用户","userId":"u2","openDingTalkId":"D2"}]}`
}
return `{"result":[{"name":"张三","userId":"user-1","openDingTalkId":"D-user-1"}]}`
return `{"result":[{"name":"测试用户甲","userId":"user-1","openDingTalkId":"` + mockCurrentDOpenID + `"}]}`
case "search_groups":
if arguments["keyword"] == "资源群" {
return `{"result":[{"title":"资源群","openConversationId":"cid-resources"}]}`
@@ -540,9 +541,9 @@ func multiIMMockResponse(tool string, arguments map[string]any, mcpBaseURL, reso
return `{"result":[{"title":"项目群","openConversationId":"cid-1"}]}`
case "list_conversation_message_v2":
if arguments["openconversation_id"] == "cid-resources" {
return `{"result":{"hasMore":false,"messages":[{"openMessageId":"msg-resource","openConversationId":"cid-resources","sender":{"name":"张三","openDingTalkId":"D-sender","senderType":"user"},"msgType":"file","content":"{\"mediaId\":\"@media-1\"}","createTime":"2026-08-03 10:00:00"}]}}`
return `{"result":{"hasMore":false,"messages":[{"openMessageId":"msg-resource","openConversationId":"cid-resources","sender":{"name":"测试用户甲","openDingTalkId":"` + mockCurrentDOpenID + `","senderType":"user"},"msgType":"file","content":"{\"mediaId\":\"@media-1\"}","createTime":"2026-08-03 10:00:00"}]}}`
}
return `{"result":{"hasMore":false,"messages":[{"openMessageId":"msg-1","openConversationId":"cid-1","sender":{"name":"张三","openDingTalkId":"D-sender","senderType":"user"},"msgType":"text","content":"进度正常","createTime":"2026-08-03 10:00:00"}]}}`
return `{"result":{"hasMore":false,"messages":[{"openMessageId":"msg-1","openConversationId":"cid-1","sender":{"name":"测试用户甲","openDingTalkId":"` + mockCurrentDOpenID + `","senderType":"user"},"msgType":"text","content":"进度正常","createTime":"2026-08-03 10:00:00"}]}}`
case "get_resource_download_url":
body, _ := json.Marshal(map[string]any{"result": map[string]any{
"resourceUrl": resourceURL,
@@ -561,7 +562,7 @@ func multiIMMockResponse(tool string, arguments map[string]any, mcpBaseURL, reso
if arguments["keyword"] == "分页失败" && fmt.Sprint(arguments["cursor"]) == "c2" {
return `{"success":false,"code":"MOCK_PAGE_FAILURE","message":"simulated second page failure"}`
}
return `{"result":{"messages":[{"openMessageId":"search-msg-1","openConversationId":"cid-1","senderOpenDingTalkId":"D-sender","content":"first page"}],"hasMore":true,"nextCursor":"c2"}}`
return `{"result":{"messages":[{"openMessageId":"search-msg-1","openConversationId":"cid-1","senderOpenDingTalkId":"` + mockCurrentDOpenID + `","content":"first page"}],"hasMore":true,"nextCursor":"c2"}}`
case "get_current_user_profile":
return `{"result":[{"orgEmployeeModel":{"userId":"self-user"}}]}`
case "create_group_conversation":
+40 -2
View File
@@ -1019,12 +1019,47 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) {
}
admission := readWorkflow(".github/workflows/ci.yml")
policyStart := strings.Index(admission, "\n policy:\n")
policyEnd := strings.Index(admission, "\n interface-integrity:\n")
if policyStart < 0 || policyEnd <= policyStart {
t.Fatal("Code Admission workflow missing Policy job boundaries")
}
policyJob := admission[policyStart:policyEnd]
requirePolicyEnv := func(step, nextStep string) {
t.Helper()
start := strings.Index(policyJob, " - name: "+step+"\n")
end := strings.Index(policyJob[start+1:], " - name: "+nextStep+"\n")
if start < 0 || end < 0 {
t.Fatalf("Policy job missing %q step boundary", step)
}
block := policyJob[start : start+1+end]
if !strings.Contains(block, "RELEASE_SEAL_ONLY: ${{ needs.lint.outputs.release_seal_only }}") {
t.Errorf("Policy %q step must receive release-seal classification", step)
}
}
requirePolicyEnv("Validate changed CHANGELOG content", "Validate release fragment lifecycle")
requirePolicyEnv("Validate trusted main metadata-only push", "Record CHANGELOG-only fast path")
pushStart := strings.Index(admission, "} else if (context.eventName === 'push') {")
pushEnd := strings.Index(admission, "\n core.setOutput('changelog_only'")
if pushStart < 0 || pushEnd <= pushStart {
t.Fatal("Code Admission workflow missing push classification boundaries")
}
pushClassification := admission[pushStart:pushEnd]
if strings.Contains(pushClassification, "const exactReleaseSealDiff = isExactReleaseSeal(files)") {
t.Error("push release-seal fast path must not trust an unguarded compare file list")
}
for _, want := range []string{
"name: CI",
"files.length === 1",
"files[0].filename === 'CHANGELOG.md'",
"files[0].status === 'modified'",
"!files[0].previous_filename",
"const isExactReleaseSeal",
"file.status !== 'renamed'",
"file.additions !== 0",
"file.deletions !== 0",
"releaseSealOnly = isExactReleaseSeal(files)",
"release_seal_only: ${{ steps.classify.outputs.release_seal_only }}",
"pre-classification",
"post-classification",
"before.changed_files !== files.length",
@@ -1048,7 +1083,8 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) {
"mode=--fast-path",
`"$mode" "$PR_BASE_SHA" HEAD`,
`test "$(git rev-parse HEAD)" = "$PUSH_AFTER_SHA"`,
`--fast-path "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA"`,
`"$mode" "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA"`,
`./scripts/policy/check-release-fragments.sh \`,
"needs.lint.outputs.platform_sensitive == 'true'",
`COVERAGE_TARGET: "100"`,
`COVERAGE_ENFORCE_OVERALL: "false"`,
@@ -1060,7 +1096,9 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) {
`package_output="$(./scripts/ci/test-packages.sh list`,
"docs_only: ${{ steps.classify.outputs.docs_only }}",
"full_suite: ${{ steps.classify.outputs.full_suite }}",
"classifyFiles(files.length < 300)",
"const pushFilesComplete = files.length < 300",
"classifyFiles(pushFilesComplete)",
"pushFilesComplete && isExactReleaseSeal(files)",
"platformSensitive =",
"files.some(isNativeGoChange)",
"paths.some(isEditionSensitive)",
+187 -1
View File
@@ -70,10 +70,12 @@ func TestPackageManagerVersionVerificationReadsRawBinary(t *testing.T) {
t.Fatal("package-manager verifier still requires the version marker to occupy a strings(1) line")
}
for _, want := range []string{
"HOME_SKILL_BASES=",
"HOME_SPECIFIC_SKILL_BASES=",
`$base/dingtalk-shared/SKILL.md`,
`$base/dingtalk-misc/SKILL.md`,
"unexpected mono Skill layout",
`verify_npm_install "$tarball_path" "specific-agent-roots"`,
`verify_npm_install "$tarball_path" "generic-fallback"`,
} {
if !strings.Contains(script, want) {
t.Errorf("package-manager verifier is missing multi-layout contract %q", want)
@@ -84,6 +86,60 @@ func TestPackageManagerVersionVerificationReadsRawBinary(t *testing.T) {
}
}
func TestPackageManagerVerifierCoversSpecificAndFallbackSkillRoots(t *testing.T) {
t.Parallel()
postGoreleaserPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "post-goreleaser.sh"))
if err != nil {
t.Fatalf("Abs(post-goreleaser.sh) error = %v", err)
}
verifierPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "verify-package-managers.sh"))
if err != nil {
t.Fatalf("Abs(verify-package-managers.sh) error = %v", err)
}
distDir := filepath.Join(t.TempDir(), "dist")
targets := []string{
"dws-darwin-amd64.tar.gz",
"dws-darwin-arm64.tar.gz",
"dws-linux-amd64.tar.gz",
"dws-linux-arm64.tar.gz",
}
hostArchive := "dws-" + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz"
if runtime.GOOS == "windows" {
hostArchive = "dws-" + runtime.GOOS + "-" + runtime.GOARCH + ".zip"
}
foundHost := false
for _, target := range targets {
if target == hostArchive {
foundHost = true
break
}
}
if !foundHost {
targets = append(targets, hostArchive)
}
seedDistArtifacts(t, distDir, targets)
packageCmd := exec.Command("sh", postGoreleaserPath)
packageCmd.Env = postGoreleaserEnv(t, distDir, "v0.0.0-test", "https://downloads.example.com/dws/releases/v0.0.0-test")
if output, err := packageCmd.CombinedOutput(); err != nil {
t.Fatalf("post-goreleaser.sh error = %v\noutput:\n%s", err, output)
}
verifyCmd := exec.Command("sh", verifierPath, "--npm-only")
verifyCmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+distDir)
output, err := verifyCmd.CombinedOutput()
if err != nil {
t.Fatalf("verify-package-managers.sh error = %v\noutput:\n%s", err, output)
}
for _, scenario := range []string{"specific-agent-roots", "generic-fallback"} {
if !strings.Contains(string(output), "verifying npm package install ("+scenario+")") {
t.Errorf("verifier output is missing %s scenario:\n%s", scenario, output)
}
}
}
func seedDistArchive(t *testing.T, path string) {
t.Helper()
file, err := os.Create(path)
@@ -1166,6 +1222,136 @@ func TestReleaseWorkflowParallelizesSealedValidationWithoutWeakeningPublication(
}
}
func TestReleaseWorkflowHidesOnlyVerifiedSealedTagFromCompatibilityBaseline(t *testing.T) {
t.Parallel()
workflow := readReleaseWorkflow(t)
validation := releaseWorkflowSection(t, workflow, " release-validation:\n", "\n release-plan:\n")
preparedView := releaseWorkflowSection(
t,
validation,
" - name: Prepare delivered-stable compatibility ref view\n",
"\n - name: Set up Go\n",
)
script := releaseWorkflowRunScript(
t,
validation,
"Prepare delivered-stable compatibility ref view",
"Set up Go",
)
if !strings.Contains(preparedView, "if: ${{ matrix.check == 'compatibility' }}") {
t.Fatal("delivered-stable ref view must run only for the compatibility matrix leg")
}
for _, required := range []string{
"RELEASE_VERSION: ${{ needs.release-contract.outputs.release_version }}",
"RELEASE_COMMIT: ${{ needs.release-contract.outputs.release_commit }}",
"RELEASE_TAG_OBJECT: ${{ needs.release-contract.outputs.release_tag_object }}",
"PREVIOUS_STABLE: ${{ needs.release-contract.outputs.previous_stable }}",
"PREVIOUS_STABLE_COMMIT: ${{ needs.release-contract.outputs.previous_stable_commit }}",
} {
if !strings.Contains(preparedView, required) {
t.Errorf("delivered-stable compatibility view is missing %q", required)
}
}
for _, required := range []string{
`test "$RELEASE_VERSION" != "$PREVIOUS_STABLE"`,
`test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT"`,
`test "$(git rev-parse --verify "refs/tags/${RELEASE_VERSION}")" = "$RELEASE_TAG_OBJECT"`,
`test "$(git rev-parse --verify "refs/tags/${RELEASE_VERSION}^{commit}")" = "$RELEASE_COMMIT"`,
`test "$(git rev-parse --verify "${PREVIOUS_STABLE}^{commit}")" = "$PREVIOUS_STABLE_COMMIT"`,
`git update-ref -d "refs/tags/${RELEASE_VERSION}" "$RELEASE_TAG_OBJECT"`,
`git show-ref --verify --quiet "refs/tags/${RELEASE_VERSION}"`,
} {
if !strings.Contains(script, required) {
t.Errorf("delivered-stable compatibility view is missing %q", required)
}
}
verifiedTag := strings.Index(validation, "verify-github-tag-authority.sh")
deleteLocalTag := strings.Index(validation, "git update-ref -d")
compatibility := strings.Index(validation, "check-command-compatibility.sh")
if verifiedTag == -1 || deleteLocalTag == -1 || compatibility == -1 ||
verifiedTag > deleteLocalTag || deleteLocalTag > compatibility {
t.Fatal("release tag authority verification, local candidate removal, and compatibility checking must stay ordered")
}
for _, forbidden := range []string{"git push --delete", "github.rest.git.deleteRef"} {
if strings.Contains(workflow, forbidden) {
t.Errorf("release workflow must not delete a remote ref: found %q", forbidden)
}
}
repo, err := os.MkdirTemp(".", ".release-compatibility-test-")
if err != nil {
t.Fatalf("MkdirTemp(release compatibility test repository) error = %v", err)
}
t.Cleanup(func() {
if err := os.RemoveAll(repo); err != nil {
t.Errorf("RemoveAll(%s) error = %v", repo, err)
}
})
mustRun(t, repo, "git", "init", "-b", "main")
mustRun(t, repo, "git", "config", "user.name", "Release Compatibility Test")
mustRun(t, repo, "git", "config", "user.email", "release-compatibility@example.com")
mustWriteFile(t, filepath.Join(repo, "tracked"), []byte("stable\n"), 0o644)
mustRun(t, repo, "git", "add", "tracked")
mustRun(t, repo, "git", "commit", "-m", "stable release")
previousStableCommit := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD"))
mustRun(t, repo, "git", "tag", "-a", "v1.0.57", "-m", "v1.0.57")
mustWriteFile(t, filepath.Join(repo, "tracked"), []byte("sealed\n"), 0o644)
mustRun(t, repo, "git", "commit", "-am", "sealed release")
releaseCommit := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD"))
mustRun(t, repo, "git", "tag", "-a", "v1.0.58", "-m", "v1.0.58")
releaseTagObject := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "refs/tags/v1.0.58"))
run := func(tagObject string) ([]byte, error) {
cmd := exec.Command("sh", "-c", script)
cmd.Dir = repo
cmd.Env = make([]string, 0, len(os.Environ())+5)
for _, entry := range os.Environ() {
if strings.HasPrefix(entry, "RELEASE_VERSION=") ||
strings.HasPrefix(entry, "RELEASE_COMMIT=") ||
strings.HasPrefix(entry, "RELEASE_TAG_OBJECT=") ||
strings.HasPrefix(entry, "PREVIOUS_STABLE=") ||
strings.HasPrefix(entry, "PREVIOUS_STABLE_COMMIT=") {
continue
}
cmd.Env = append(cmd.Env, entry)
}
cmd.Env = append(cmd.Env,
"RELEASE_VERSION=v1.0.58",
"RELEASE_COMMIT="+releaseCommit,
"RELEASE_TAG_OBJECT="+tagObject,
"PREVIOUS_STABLE=v1.0.57",
"PREVIOUS_STABLE_COMMIT="+previousStableCommit,
)
return cmd.CombinedOutput()
}
if output, err := run(releaseTagObject); err != nil {
t.Fatalf("prepared compatibility ref view failed: %v\noutput:\n%s", err, output)
}
candidateTag := exec.Command("git", "rev-parse", "--verify", "refs/tags/v1.0.58")
candidateTag.Dir = repo
if _, err := candidateTag.CombinedOutput(); err == nil {
t.Fatal("prepared compatibility ref view left the sealed candidate tag visible")
}
mustRun(t, repo, "git", "cat-file", "-e", releaseTagObject+"^{tag}")
if got := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "refs/tags/v1.0.57^{commit}")); got != previousStableCommit {
t.Fatalf("previous stable tag commit = %s, want %s", got, previousStableCommit)
}
if got := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD")); got != releaseCommit {
t.Fatalf("HEAD after prepared compatibility ref view = %s, want %s", got, releaseCommit)
}
mustRun(t, repo, "git", "update-ref", "refs/tags/v1.0.58", releaseTagObject)
if output, err := run(strings.Repeat("0", 40)); err == nil {
t.Fatalf("prepared compatibility ref view unexpectedly accepted a mismatched tag object\noutput:\n%s", output)
}
if got := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "refs/tags/v1.0.58")); got != releaseTagObject {
t.Fatalf("mismatched tag object removed or changed the local sealed tag: got %s, want %s", got, releaseTagObject)
}
}
func TestReleaseFingerprintRefPatternsMatchAllAllocatedTags(t *testing.T) {
t.Parallel()
repo := t.TempDir()