Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68a1c7a49b | ||
|
|
9a6e60c643 | ||
|
|
99a0952505 | ||
|
|
f30aaeba91 | ||
|
|
43798de088 | ||
|
|
c4d8987f6c | ||
|
|
657d2c25e3 |
@@ -1,6 +0,0 @@
|
||||
paths:
|
||||
.github/workflows/release.yml:
|
||||
ignore:
|
||||
# GitHub Actions added concurrency.queue in 2026. actionlint v1.7.12's
|
||||
# bundled workflow schema has not caught up with the platform syntax.
|
||||
- 'unexpected key "queue" for "concurrency" section'
|
||||
+28
-160
@@ -7,7 +7,8 @@ on:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
@@ -58,17 +59,7 @@ jobs:
|
||||
run: make build
|
||||
|
||||
- name: Test with Race Detection
|
||||
run: |
|
||||
go test -v -race -count=1 -timeout=5m \
|
||||
./cmd/... \
|
||||
./internal/... \
|
||||
./pkg/... \
|
||||
./test/unit/...
|
||||
go test -v -count=1 -timeout=5m \
|
||||
./test/cli/...
|
||||
|
||||
- name: Test engineering scripts
|
||||
run: go test -v -count=1 -timeout=5m ./test/scripts/...
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/...
|
||||
|
||||
- name: Test release scripts
|
||||
run: go test -v -count=1 -timeout=5m ./test/scripts
|
||||
@@ -145,6 +136,30 @@ jobs:
|
||||
coverage.txt
|
||||
coverage.html
|
||||
|
||||
- name: Update coverage badge
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
COVERAGE=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | sed 's/%//')
|
||||
echo "Coverage: ${COVERAGE}%"
|
||||
if (( $(echo "$COVERAGE >= 80" | bc -l) )); then
|
||||
COLOR="brightgreen"
|
||||
elif (( $(echo "$COVERAGE >= 60" | bc -l) )); then
|
||||
COLOR="yellow"
|
||||
else
|
||||
COLOR="red"
|
||||
fi
|
||||
mkdir -p .github/badges
|
||||
curl -s "https://img.shields.io/badge/coverage-${COVERAGE}%25-${COLOR}" > .github/badges/coverage.svg
|
||||
|
||||
- name: Commit badge
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add .github/badges/coverage.svg || true
|
||||
git diff --staged --quiet || git commit -m "chore: update coverage badge [skip ci]"
|
||||
git push || true
|
||||
|
||||
policy:
|
||||
name: Policy Check
|
||||
runs-on: ubuntu-latest
|
||||
@@ -183,158 +198,11 @@ jobs:
|
||||
- name: Run edition contract tests
|
||||
run: go test -v -count=1 ./pkg/editiontest/...
|
||||
|
||||
code-check:
|
||||
name: Code Check
|
||||
needs: [lint, test, test-darwin, test-windows, coverage, policy, edition-tests]
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Verify code checks
|
||||
env:
|
||||
LINT_RESULT: ${{ needs.lint.result }}
|
||||
TEST_RESULT: ${{ needs.test.result }}
|
||||
TEST_DARWIN_RESULT: ${{ needs.test-darwin.result }}
|
||||
TEST_WINDOWS_RESULT: ${{ needs.test-windows.result }}
|
||||
COVERAGE_RESULT: ${{ needs.coverage.result }}
|
||||
POLICY_RESULT: ${{ needs.policy.result }}
|
||||
EDITION_TESTS_RESULT: ${{ needs.edition-tests.result }}
|
||||
run: |
|
||||
set -eu
|
||||
failed=0
|
||||
for check in \
|
||||
"Lint:$LINT_RESULT" \
|
||||
"Test:$TEST_RESULT" \
|
||||
"Test (macOS auth/keychain):$TEST_DARWIN_RESULT" \
|
||||
"Test (Windows):$TEST_WINDOWS_RESULT" \
|
||||
"Coverage:$COVERAGE_RESULT" \
|
||||
"Policy Check:$POLICY_RESULT" \
|
||||
"Edition Contract Tests:$EDITION_TESTS_RESULT"
|
||||
do
|
||||
name="${check%%:*}"
|
||||
result="${check#*:}"
|
||||
printf '%s: %s\n' "$name" "$result"
|
||||
if [ "$result" != "success" ]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
test "$failed" -eq 0
|
||||
|
||||
command-compatibility:
|
||||
name: Command Compatibility
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch official release tags
|
||||
run: |
|
||||
git fetch --force --no-tags \
|
||||
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
|
||||
'+refs/tags/v*:refs/tags/v*'
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Check command compatibility
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -eu
|
||||
base_ref="$PUSH_BEFORE_SHA"
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
base_ref="$PR_BASE_SHA"
|
||||
fi
|
||||
stable_ref="$(git tag --merged HEAD --list 'v*' --sort=-version:refname | awk '/^v[0-9]+\.[0-9]+\.[0-9]+$/ { print; exit }')"
|
||||
test -n "$base_ref"
|
||||
test -n "$stable_ref"
|
||||
stable_commit="$(git rev-parse "$stable_ref^{commit}")"
|
||||
./scripts/release/verify-delivered-stable.sh "$stable_ref" "$stable_commit"
|
||||
./scripts/policy/check-command-compatibility.sh \
|
||||
--base-ref "$base_ref" \
|
||||
--stable-ref "$stable_ref"
|
||||
|
||||
cli-smoke:
|
||||
name: CLI Smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run CLI smoke tests
|
||||
run: go test -count=1 -timeout=5m ./test/smoke/...
|
||||
|
||||
mock-mcp-smoke:
|
||||
name: Mock MCP Smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run Mock MCP smoke tests
|
||||
run: go test -count=1 -timeout=5m ./test/mock_mcp/...
|
||||
|
||||
ci-gate:
|
||||
name: CI Gate
|
||||
needs: [code-check, command-compatibility, cli-smoke, mock-mcp-smoke]
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Verify required checks
|
||||
env:
|
||||
CODE_CHECK_RESULT: ${{ needs.code-check.result }}
|
||||
COMMAND_COMPATIBILITY_RESULT: ${{ needs.command-compatibility.result }}
|
||||
CLI_SMOKE_RESULT: ${{ needs.cli-smoke.result }}
|
||||
MOCK_MCP_SMOKE_RESULT: ${{ needs.mock-mcp-smoke.result }}
|
||||
run: |
|
||||
set -eu
|
||||
failed=0
|
||||
for check in \
|
||||
"Code Check:$CODE_CHECK_RESULT" \
|
||||
"Command Compatibility:$COMMAND_COMPATIBILITY_RESULT" \
|
||||
"CLI Smoke:$CLI_SMOKE_RESULT" \
|
||||
"Mock MCP Smoke:$MOCK_MCP_SMOKE_RESULT"
|
||||
do
|
||||
name="${check%%:*}"
|
||||
result="${check#*:}"
|
||||
printf '%s: %s\n' "$name" "$result"
|
||||
if [ "$result" != "success" ]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
test "$failed" -eq 0
|
||||
|
||||
notify-downstream:
|
||||
name: Notify Wukong Overlay
|
||||
needs: [ci-gate]
|
||||
needs: [test, policy, edition-tests]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Trigger downstream CI
|
||||
run: |
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# 把本仓库 main 代码自动镜像到 Gitee,供国内用户访问 raw 脚本入口。
|
||||
# Release tag 与附件只由 release.yml 的受控 publication queue 发布。
|
||||
# 把本仓库代码自动镜像到 Gitee,供国内用户访问(raw 脚本入口 + tags)。
|
||||
# 用 HTTPS + 令牌直接 git push(无需 SSH key),复用已配置的 secret:
|
||||
# GITEE_TOKEN —— Gitee 私人令牌(勾 projects)
|
||||
# GITEE_USER —— 令牌所属 Gitee 用户名(用于 https 推送鉴权)
|
||||
@@ -11,6 +10,8 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
schedule:
|
||||
- cron: '0 18 * * *'
|
||||
workflow_dispatch:
|
||||
@@ -22,14 +23,13 @@ concurrency:
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.ref_name == github.event.repository.default_branch && github.repository_owner == 'DingTalk-Real-AI' }}
|
||||
# GitHub Actions 不允许在 job-level if 直接引用 secrets,故先用 env 暴露再在 step 守卫。
|
||||
env:
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
steps:
|
||||
- name: Checkout main history
|
||||
- name: Checkout (full history + tags)
|
||||
if: env.GITEE_TOKEN != ''
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -41,7 +41,15 @@ jobs:
|
||||
set -eu
|
||||
REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git"
|
||||
|
||||
git fetch --force origin 'refs/heads/main:refs/remotes/origin/main'
|
||||
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
|
||||
git fetch --force --tags origin "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
|
||||
git push --force "$REMOTE" "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
|
||||
echo "✅ 已镜像 tag ${GITHUB_REF_NAME} 到 Gitee ${GITEE_REPO}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 取到 main 与所有 tag(落到 origin/* 与本地 tags,避免推当前分支引用冲突)
|
||||
git fetch --force --tags origin 'refs/heads/main:refs/remotes/origin/main'
|
||||
|
||||
# Gitee 专属分支:在 origin/main 之上叠加一个 README 本地化 commit。
|
||||
# GitHub 那份 README 不变;只有推往 Gitee 的副本被改写。
|
||||
@@ -73,6 +81,7 @@ jobs:
|
||||
git add README.md README_zh.md 2>/dev/null || true
|
||||
git commit -m "docs(gitee): localize install commands + coverage badge for Gitee mirror" || true
|
||||
|
||||
# main 镜像对齐;release tag 由 release.yml 单独校验后创建,禁止在这里 force。
|
||||
# 镜像对齐(force:Gitee 始终跟随 GitHub + Gitee 专属 README 本地化)
|
||||
git push --force "$REMOTE" 'gitee-main:refs/heads/main'
|
||||
echo "✅ 已镜像 main(含 Gitee README 本地化)到 Gitee ${GITEE_REPO}"
|
||||
git push --force --tags "$REMOTE"
|
||||
echo "✅ 已镜像 main(+Gitee README 本地化) + tags 到 Gitee ${GITEE_REPO}"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
name: Publish npm release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release tag to publish to npm (e.g. v1.0.48)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish-npm:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download GitHub release assets
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p dist
|
||||
gh release download "${{ inputs.version }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--dir dist \
|
||||
--pattern 'dws-*' \
|
||||
--pattern 'checksums.txt' \
|
||||
--clobber
|
||||
ls -la dist
|
||||
|
||||
- name: Stage npm package
|
||||
run: |
|
||||
set -eu
|
||||
version="${{ inputs.version }}"
|
||||
semver="${version#v}"
|
||||
pkg_root="dist/npm/dingtalk-workspace-cli"
|
||||
rm -rf "$pkg_root"
|
||||
mkdir -p "$pkg_root/assets" "$pkg_root/bin"
|
||||
cp build/npm/install.js "$pkg_root/install.js"
|
||||
cp build/npm/bin/dws.js "$pkg_root/bin/dws.js"
|
||||
cp build/npm/README.md "$pkg_root/README.md"
|
||||
sed "s|__VERSION__|${semver}|g" build/npm/package.json.tmpl > "$pkg_root/package.json"
|
||||
cp dist/dws-* "$pkg_root/assets/"
|
||||
cp dist/checksums.txt "$pkg_root/assets/"
|
||||
test -f "$pkg_root/assets/dws-skills.zip"
|
||||
cat "$pkg_root/package.json"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Publish stable to npm
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(inputs.version, '-') }}
|
||||
working-directory: dist/npm/dingtalk-workspace-cli
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish prerelease to npm beta
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(inputs.version, '-') }}
|
||||
working-directory: dist/npm/dingtalk-workspace-cli
|
||||
run: npm publish --access public --tag beta
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
+102
-786
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
name: Sync release to Gitee
|
||||
|
||||
# Manually mirror a published GitHub release's assets to the matching Gitee
|
||||
# release. Use this to repair a release whose Gitee mirror is incomplete (e.g.
|
||||
# the Release job timed out mid-upload). It runs ONLY the idempotent Gitee sync
|
||||
# step — it does not run GoReleaser and does not touch the GitHub release, so
|
||||
# there is no release outage. The sync script skips assets already on Gitee, so
|
||||
# this only uploads what is missing.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release tag to mirror to Gitee (e.g. v1.0.42)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync-gitee:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download GitHub release assets
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p dist
|
||||
gh release download "${{ inputs.version }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--dir dist \
|
||||
--pattern 'dws-*' \
|
||||
--pattern 'checksums.txt' \
|
||||
--clobber
|
||||
ls -la dist
|
||||
|
||||
- name: Mirror release to Gitee (China)
|
||||
# Idempotent: uploads only assets not already present on the Gitee release.
|
||||
run: ./scripts/release/sync-to-gitee.sh
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
+7
-2
@@ -1,14 +1,19 @@
|
||||
# GoReleaser configuration for dws
|
||||
# Docs: https://goreleaser.com
|
||||
#
|
||||
# To release, use scripts/release/release.sh. It seals main, validates the
|
||||
# CHANGELOG and packages, then pushes the annotated tag for CI/CD to publish.
|
||||
# To release:
|
||||
# git tag -a v0.1.0 -m "Release v0.1.0"
|
||||
# git push origin v0.1.0
|
||||
#
|
||||
# To test locally (no publish):
|
||||
# goreleaser release --snapshot --clean
|
||||
|
||||
version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
|
||||
builds:
|
||||
- main: ./cmd
|
||||
binary: dws
|
||||
|
||||
+6
-4
@@ -6,18 +6,20 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- **`event consume` AI-subprocess contract** — aligns personal event streaming with the contract an orchestrator can drive without guessing: a fixed stderr ready line `[event] ready event_key=<key> bus_pid=<pid>` (block on it, don't `sleep`); a final `[event] exited — received N event(s) in Xs (reason: limit|timeout|signal|bus_shutdown)` line with exit code 0 on controlled exit and non-zero (no `exited` line) on failure; stdin-EOF as a graceful shutdown signal, armed only for a parent-controlled pipe stdin on an unbounded run (an interactive TTY and `< /dev/null` never trigger it), with a self-explaining diagnostic when it fires; and ownership-based subscription cleanup — a subscription this run created is unsubscribed on any clean exit while a `--subscribe-id`-reused one is left intact (`--ephemeral` still forces cleanup), so `kill -9` is the only way to leak a server-side subscription. Skill docs (mono + `dingtalk-event`) document the contract; design notes in `docs/event-subprocess-contract.md`.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws schema` for registered local commands** — `dws schema "event consume"` (or `event.consume`) now returns a machine-readable input schema synthesized from the command's cobra flags, in the same flat shape helper subtrees emit: `{description, path, source, parameters{<flag>:{type, required, description, default?}}}` plus an `arguments` array for positional inputs, with `source: "cobra"` distinguishing flag-synthesized schema from MCP-fetched (`mcp:<server>`). Intermediate nodes (`dws schema event`) list their subcommands. The mechanism is a reusable registry (`cobraSchemaRoots`); `event` is the first consumer and more command trees can opt in without further wiring. Inherited global flags and hidden internal flags are excluded so the schema describes just that command.
|
||||
- **Safe macOS Keychain → file-DEK migration** — `dws auth migrate-keychain --to file-dek` preflights every legacy/profile auth entry before rewriting, ignores unrelated application secrets, supports side-effect-free `--dry-run`, requires explicit `--yes`, and lets sandboxed and normal processes share an existing login without exposing tokens.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dev connect` forwards complex message payloads and recoverable attachments** — messages such as forwarded chat records (`chatRecord`) are no longer discarded when they do not match the CLI's built-in text, image, or file extractors; their message type and raw JSON payload are passed to every Stream-bridge backend. Attachment discovery is based on locator capabilities (`downloadCode`, `mediaId`, `fileId`, or `dentryId` + `spaceId`) rather than a message-type allowlist, so new and unknown outer, rich-text, and nested record types retain their original bytes. Nested pictures, audio, video, and files are downloaded completely, including entries degraded by DingTalk to locator-free `unknownMsgType`: the connector acknowledges the Stream callback first, then resolves their original `mediaId` / `fileId` through the message APIs. Original bytes use each backend's native attachment path where available (OpenCode file parts, Gemini inline/Files API, Codex local images, Qoder attachments) or a narrowly allowed read-only local path for CLI/custom agents. For OpenCode, large videos are converted to a full-duration 12-frame storyboard before the model call, avoiding in-process base64 expansion and out-of-memory failures while preserving the fully downloaded original for the duration of the turn.
|
||||
- **Cross-platform auth regression coverage** — dedicated macOS CI now runs the Darwin-only auth/keychain regression suite with race detection, Windows CI builds and tests the native DPAPI path, and recovery guidance prefers safe migration or per-profile cleanup over destructive global reset.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Guarded prerelease and stable automation** — adds the guided `dws-release` entry for one-command CHANGELOG preparation, validation-only and annotated-tag publication flows; promotes only an explicitly validated beta; verifies command-tree compatibility and all six packaged binaries; and serializes immutable GitHub Release, npm channel, OSS, and optional Gitee delivery with fail-closed recovery checks.
|
||||
|
||||
## [1.0.51] - 2026-07-10
|
||||
|
||||
This release promotes the sealed `v1.0.51-beta.1` contents to stable. It syncs the hardcoded Wukong command surface, prevents `dev connect` conversations from blocking on messages received mid-turn, and makes local credential failures diagnosable without mutating key material.
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ git diff --check
|
||||
|
||||
1. Keep implementation and tests in sync.
|
||||
2. Run `./scripts/dev/ci-local.sh`.
|
||||
3. Run `./scripts/policy/check-command-surface.sh --strict` when command paths/flags change. CI also runs `./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>` against both the target branch and latest stable release.
|
||||
3. Run `./scripts/policy/check-command-surface.sh --strict` when command paths/flags change.
|
||||
4. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may change.
|
||||
5. Run `./scripts/release/verify-package-managers.sh` when packaging or installer surfaces change (run `make package` first).
|
||||
6. Update docs and `CHANGELOG.md` for behavior/interface changes.
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
GO ?= go
|
||||
REMOTE ?=
|
||||
PUBLISH ?= 0
|
||||
YES ?= 0
|
||||
|
||||
.PHONY: all help build rebuild test lint fmt policy edition-test package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
.PHONY: all help build rebuild test lint fmt policy edition-test package release publish-homebrew-formula setup-hooks
|
||||
|
||||
all: setup-hooks fmt lint build test rebuild
|
||||
|
||||
@@ -14,11 +11,8 @@ help:
|
||||
@printf " make lint - Run formatting checks and golangci-lint when available\n"
|
||||
@printf " make fmt - Format Go source files\n"
|
||||
@printf " make policy - Run open-source asset and command-surface checks\n"
|
||||
@printf " make package - Build all release artifacts locally\n"
|
||||
@printf " make changelog-pre VERSION=vX.Y.Z-beta.N - Prepare prerelease notes\n"
|
||||
@printf " make changelog-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N - Prepare stable notes\n"
|
||||
@printf " make release-pre VERSION=vX.Y.Z-beta.N [PUBLISH=1] - Validate or publish prerelease\n"
|
||||
@printf " make release-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N [PUBLISH=1] - Validate or publish stable\n"
|
||||
@printf " make package - Build all release artifacts locally (goreleaser snapshot)\n"
|
||||
@printf " make release - Build and publish a release via goreleaser\n"
|
||||
@printf " make publish-homebrew-formula - Push dist/homebrew/dingtalk-workspace-cli.rb to a tap repo\n"
|
||||
|
||||
build:
|
||||
@@ -44,8 +38,8 @@ edition-test:
|
||||
$(GO) test -v -count=1 ./pkg/editiontest/...
|
||||
|
||||
package:
|
||||
@version="$(if $(VERSION),$(VERSION),v0.0.0-SNAPSHOT)"; VERSION="$${version#v}" ./scripts/dev/build-all.sh
|
||||
@version="$(if $(VERSION),$(VERSION),v0.0.0-SNAPSHOT)"; DWS_PACKAGE_VERSION="$$version" ./scripts/release/post-goreleaser.sh
|
||||
@./scripts/dev/build-all.sh
|
||||
@./scripts/release/post-goreleaser.sh
|
||||
|
||||
publish-homebrew-formula:
|
||||
@./scripts/release/publish-homebrew-formula.sh
|
||||
@@ -53,32 +47,6 @@ publish-homebrew-formula:
|
||||
setup-hooks:
|
||||
@git config core.hooksPath scripts/hooks 2>/dev/null || true
|
||||
|
||||
changelog-pre:
|
||||
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3-beta.1\n' >&2; exit 2)
|
||||
@./scripts/release/prepare-changelog.sh prerelease "$(VERSION)"
|
||||
|
||||
changelog-stable:
|
||||
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3\n' >&2; exit 2)
|
||||
@test -n "$(FROM_BETA)" || (printf 'FROM_BETA is required, e.g. v1.2.3-beta.2\n' >&2; exit 2)
|
||||
@./scripts/release/prepare-changelog.sh stable "$(VERSION)" --from-beta "$(FROM_BETA)"
|
||||
|
||||
release-pre:
|
||||
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3-beta.1\n' >&2; exit 2)
|
||||
@test -n "$(REMOTE)" || (printf 'REMOTE is required, e.g. origin\n' >&2; exit 2)
|
||||
@args=""; \
|
||||
if [ "$(PUBLISH)" = "1" ]; then args="$$args --publish"; fi; \
|
||||
if [ "$(YES)" = "1" ]; then args="$$args --yes"; fi; \
|
||||
./scripts/release/release.sh prerelease "$(VERSION)" --remote "$(REMOTE)" $$args
|
||||
|
||||
release-stable:
|
||||
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3\n' >&2; exit 2)
|
||||
@test -n "$(FROM_BETA)" || (printf 'FROM_BETA is required, e.g. v1.2.3-beta.2\n' >&2; exit 2)
|
||||
@test -n "$(REMOTE)" || (printf 'REMOTE is required, e.g. origin\n' >&2; exit 2)
|
||||
@args=""; \
|
||||
if [ "$(PUBLISH)" = "1" ]; then args="$$args --publish"; fi; \
|
||||
if [ "$(YES)" = "1" ]; then args="$$args --yes"; fi; \
|
||||
./scripts/release/release.sh stable "$(VERSION)" --from-beta "$(FROM_BETA)" --remote "$(REMOTE)" $$args
|
||||
|
||||
release:
|
||||
@printf 'Use make release-pre or make release-stable; direct goreleaser publishing is disabled.\n' >&2
|
||||
@exit 2
|
||||
goreleaser release --clean
|
||||
@./scripts/release/post-goreleaser.sh
|
||||
|
||||
+16
-19
@@ -2,7 +2,6 @@ class __CLASS_NAME__ < Formula
|
||||
desc "DingTalk Workspace CLI"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
url "__ARCHIVE_URL__"
|
||||
version "__VERSION__"
|
||||
sha256 "__ARCHIVE_SHA256__"
|
||||
license "Apache-2.0"
|
||||
__KEG_ONLY_LINE__
|
||||
@@ -40,25 +39,23 @@ __KEG_ONLY_LINE__
|
||||
entries = Dir["#{skill_root}/*"]
|
||||
return if entries.empty?
|
||||
|
||||
skill_home_override = "__SKILL_HOME_OVERRIDE__"
|
||||
skill_home = skill_home_override.empty? ? Dir.home : skill_home_override
|
||||
targets = [
|
||||
Pathname.new(File.join(skill_home, ".agents/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".claude/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".cursor/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".qoder/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".qoderwork/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".gemini/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".codex/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".github/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".windsurf/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".augment/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".cline/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".amp/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".kiro/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".trae/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".openclaw/skills/dws")),
|
||||
Pathname.new(File.join(skill_home, ".hermes/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".agents/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".claude/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".cursor/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".qoder/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".qoderwork/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".gemini/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".codex/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".github/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".windsurf/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".augment/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".cline/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".amp/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".kiro/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".trae/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".openclaw/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".hermes/skills/dws")),
|
||||
]
|
||||
|
||||
targets.each_with_index do |dest, index|
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// interface-snapshot is an internal CI helper. It is intentionally a separate
|
||||
// binary so it can be copied into a temporary worktree and compiled against an
|
||||
// older revision's real Cobra root.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
|
||||
func run(args []string, stdout, stderr io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
printUsage(stderr)
|
||||
return 2
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "generate":
|
||||
if err := runGenerate(args[1:], stdout, stderr); err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 2
|
||||
}
|
||||
return 0
|
||||
case "compare":
|
||||
compatible, err := runCompare(args[1:], stdout, stderr)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 2
|
||||
}
|
||||
if !compatible {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintf(stderr, "unknown command %q\n", args[0])
|
||||
printUsage(stderr)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
func runGenerate(args []string, stdout, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet("generate", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
output := flags.String("output", "-", "snapshot output path, or - for stdout")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return fmt.Errorf("generate accepts no positional arguments")
|
||||
}
|
||||
|
||||
home, err := os.MkdirTemp("", "dws-interface-snapshot-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create isolated home: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(home)
|
||||
|
||||
environment := map[string]string{
|
||||
"DWS_CONFIG_DIR": home,
|
||||
"DWS_LANG": "en",
|
||||
"HOME": home,
|
||||
"NO_COLOR": "1",
|
||||
"USERPROFILE": home,
|
||||
}
|
||||
type previousEnv struct {
|
||||
value string
|
||||
set bool
|
||||
}
|
||||
previous := make(map[string]previousEnv, len(environment))
|
||||
for key, value := range environment {
|
||||
oldValue, wasSet := os.LookupEnv(key)
|
||||
previous[key] = previousEnv{value: oldValue, set: wasSet}
|
||||
if err := os.Setenv(key, value); err != nil {
|
||||
return fmt.Errorf("set %s: %w", key, err)
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
for key, old := range previous {
|
||||
if old.set {
|
||||
_ = os.Setenv(key, old.value)
|
||||
} else {
|
||||
_ = os.Unsetenv(key)
|
||||
}
|
||||
}
|
||||
}()
|
||||
previousLang := i18n.Lang()
|
||||
defer i18n.SetLang(previousLang)
|
||||
i18n.SetLang("en")
|
||||
|
||||
snapshot := interfacesnapshot.Capture(app.NewRootCommand())
|
||||
if *output == "-" {
|
||||
return interfacesnapshot.Write(stdout, snapshot)
|
||||
}
|
||||
|
||||
file, err := os.Create(filepath.Clean(*output))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create snapshot %q: %w", *output, err)
|
||||
}
|
||||
writeErr := interfacesnapshot.Write(file, snapshot)
|
||||
closeErr := file.Close()
|
||||
if writeErr != nil {
|
||||
return fmt.Errorf("write snapshot %q: %w", *output, writeErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close snapshot %q: %w", *output, closeErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
flags := flag.NewFlagSet("compare", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
currentPath := flags.String("current", "", "candidate snapshot path")
|
||||
basePath := flags.String("base", "", "target main/development baseline snapshot path")
|
||||
stablePath := flags.String("stable", "", "latest stable GA snapshot path")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return false, fmt.Errorf("compare accepts no positional arguments")
|
||||
}
|
||||
if *currentPath == "" {
|
||||
return false, fmt.Errorf("compare requires --current")
|
||||
}
|
||||
if *basePath == "" && *stablePath == "" {
|
||||
return false, fmt.Errorf("compare requires --base, --stable, or both")
|
||||
}
|
||||
|
||||
current, err := readSnapshot(*currentPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read current snapshot: %w", err)
|
||||
}
|
||||
references := make(map[string]interfacesnapshot.Snapshot, 2)
|
||||
if *basePath != "" {
|
||||
references["main"], err = readSnapshot(*basePath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read main/development baseline snapshot: %w", err)
|
||||
}
|
||||
}
|
||||
if *stablePath != "" {
|
||||
references["stable"], err = readSnapshot(*stablePath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read stable snapshot: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
report := interfacesnapshot.CompareAll(current, references)
|
||||
encoder := json.NewEncoder(stdout)
|
||||
encoder.SetEscapeHTML(false)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(report); err != nil {
|
||||
return false, fmt.Errorf("write comparison report: %w", err)
|
||||
}
|
||||
return report.Compatible, nil
|
||||
}
|
||||
|
||||
func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
|
||||
file, err := os.Open(filepath.Clean(path))
|
||||
if err != nil {
|
||||
return interfacesnapshot.Snapshot{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
return interfacesnapshot.Read(file)
|
||||
}
|
||||
|
||||
func printUsage(w io.Writer) {
|
||||
fmt.Fprintln(w, "usage:")
|
||||
fmt.Fprintln(w, " interface-snapshot generate [--output FILE]")
|
||||
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE]")
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
|
||||
)
|
||||
|
||||
func TestRunGenerateCapturesActualRootOffline(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exitCode := run([]string{"generate"}, &stdout, &stderr); exitCode != 0 {
|
||||
t.Fatalf("run(generate) exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
snapshot, err := interfacesnapshot.Read(bytes.NewReader(stdout.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("decode generated snapshot: %v", err)
|
||||
}
|
||||
|
||||
commands := make(map[string]interfacesnapshot.Command, len(snapshot.Commands))
|
||||
for _, command := range snapshot.Commands {
|
||||
commands[command.Path] = command
|
||||
}
|
||||
for _, path := range []string{"dws", "dws chat", "dws dev app create"} {
|
||||
if _, ok := commands[path]; !ok {
|
||||
t.Errorf("actual root snapshot is missing %q", path)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"dws completion", "dws help"} {
|
||||
if _, ok := commands[path]; ok {
|
||||
t.Errorf("framework-noise path %q leaked into snapshot", path)
|
||||
}
|
||||
}
|
||||
|
||||
create := commands["dws dev app create"]
|
||||
if !hasFlag(create.LocalFlags, "name", "string") {
|
||||
t.Errorf("dev app create local flags do not contain --name string: %#v", create.LocalFlags)
|
||||
}
|
||||
if !hasFlag(create.InheritedFlags, "profile", "string") {
|
||||
t.Errorf("dev app create inherited flags do not contain --profile string: %#v", create.InheritedFlags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCompareUsesBothSnapshotInputsAndExitCode(t *testing.T) {
|
||||
current := commandSnapshot("dws")
|
||||
mergeBase := commandSnapshot("dws")
|
||||
stable := commandSnapshot("dws", "dws legacy")
|
||||
|
||||
dir := t.TempDir()
|
||||
currentPath := writeSnapshot(t, dir, "current.json", current)
|
||||
mergeBasePath := writeSnapshot(t, dir, "base.json", mergeBase)
|
||||
stablePath := writeSnapshot(t, dir, "stable.json", stable)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{
|
||||
"compare",
|
||||
"--current", currentPath,
|
||||
"--base", mergeBasePath,
|
||||
"--stable", stablePath,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 1 {
|
||||
t.Fatalf("run(compare) exit=%d, want 1; stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"reference": "main"`)) ||
|
||||
!bytes.Contains(stdout.Bytes(), []byte(`"reference": "stable"`)) ||
|
||||
!bytes.Contains(stdout.Bytes(), []byte(`"kind": "command_removed"`)) {
|
||||
t.Fatalf("comparison report does not contain both references and the blocking change:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func commandSnapshot(paths ...string) interfacesnapshot.Snapshot {
|
||||
commands := make([]interfacesnapshot.Command, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
commands = append(commands, interfacesnapshot.Command{
|
||||
Path: path,
|
||||
Aliases: []string{},
|
||||
LocalFlags: []interfacesnapshot.Flag{},
|
||||
InheritedFlags: []interfacesnapshot.Flag{},
|
||||
})
|
||||
}
|
||||
return interfacesnapshot.Snapshot{
|
||||
SchemaVersion: interfacesnapshot.SchemaVersion,
|
||||
Rules: interfacesnapshot.Rules{
|
||||
ExcludedCommandSubtrees: []string{},
|
||||
ExcludedFlags: []string{},
|
||||
},
|
||||
Commands: commands,
|
||||
}
|
||||
}
|
||||
|
||||
func writeSnapshot(t *testing.T, dir, name string, snapshot interfacesnapshot.Snapshot) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create %s: %v", path, err)
|
||||
}
|
||||
if err := interfacesnapshot.Write(file, snapshot); err != nil {
|
||||
file.Close()
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("close %s: %v", path, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == name && flag.Type == flagType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
# Event consume — AI subprocess contract
|
||||
|
||||
Aligns `dws event consume` with the "AI subprocess contract" that
|
||||
`lark-cli event consume` exposes, so any orchestrator (Claude Code's
|
||||
Monitor, a bash bridge, systemd, an agent plugin) can drive it with zero
|
||||
ambiguity: know when it is ready, stop it cleanly, and machine-read why it
|
||||
exited.
|
||||
|
||||
Scope of this branch: the four **contract** items below. Reconnect
|
||||
resilience (keeping the stream alive across a transient upstream drop) is
|
||||
tracked separately and intentionally out of scope here.
|
||||
|
||||
## Baseline (already present, no work)
|
||||
|
||||
- `--max-events N` — stop after N events (exit 0).
|
||||
- `--duration D` — wall-clock budget (exit 0). Kept as `--duration`, NOT
|
||||
aliased to `--timeout`: the global `--timeout` is the HTTP request
|
||||
timeout (int seconds) and would collide (different type and meaning).
|
||||
Docs note the lark-cli name difference.
|
||||
- Bus idle-shutdown fires only with **zero** consumers, so a connected
|
||||
consumer is never idle-killed.
|
||||
- SIGINT/SIGTERM already cancel the run context and return cleanly.
|
||||
|
||||
## Improvements
|
||||
|
||||
### 1. Ready marker (standardized)
|
||||
|
||||
On connect, emit a fixed stderr line **before** any stdout event:
|
||||
|
||||
```
|
||||
[event] ready event_key=<key> bus_pid=<pid>
|
||||
```
|
||||
|
||||
Parents block on stderr until this line, then read stdout. Suppressed
|
||||
under `--quiet`. Replaces the ad-hoc `connected bus pid=...` line (which
|
||||
omits `event_key`).
|
||||
|
||||
**Verification**
|
||||
- T1a: stderr contains a line matching `^\[event\] ready event_key=<key>`.
|
||||
- T1b: that line appears before the first stdout event (ordering).
|
||||
- T1c: with `--quiet`, the line is absent.
|
||||
|
||||
### 2. stdin EOF = graceful exit
|
||||
|
||||
`consume` watches stdin; closing stdin is a shutdown signal (wired for AI
|
||||
subprocess callers). To stay resident, feed a never-EOF stdin
|
||||
(`< <(tail -f /dev/null)`) or run bounded (`--max-events` / `--duration`).
|
||||
|
||||
**Verification**
|
||||
- T2a: `printf '' | dws event consume <key>` exits ≤2s, code 0, final
|
||||
line `reason: signal` (stdin-eof classified as signal).
|
||||
- T2b: `dws event consume <key> < <(tail -f /dev/null)` still alive after
|
||||
5s, connection intact.
|
||||
- T2c (unit): a controllable stdin reader hitting EOF makes Run return nil
|
||||
via the cleanup path.
|
||||
|
||||
### 3. Exit reason contract + exit codes
|
||||
|
||||
On exit, final stderr line:
|
||||
|
||||
```
|
||||
[event] exited — received N event(s) in Xs (reason: <limit|timeout|signal|bus_shutdown>)
|
||||
```
|
||||
|
||||
Exit codes: controlled exit (limit/timeout/signal/stdin-eof) = 0; startup
|
||||
or runtime failure (permissions, network, params) = non-zero, with no
|
||||
`exited` line and an `Error:` line instead.
|
||||
|
||||
**Verification**
|
||||
- T3a: `--max-events 1` + 1 event → exit 0, reason=`limit`, N=1.
|
||||
- T3b: `--duration 2s`, no events → exit 0, reason=`timeout`.
|
||||
- T3c: SIGTERM mid-run → exit 0, reason=`signal`.
|
||||
- T3d: bad params / permission failure → exit≠0, no `exited` line, has `Error:`.
|
||||
- Unit tests assert (reason string, exit code) for each path.
|
||||
|
||||
### 4. Cleanup on exit (no `kill -9`)
|
||||
|
||||
Ownership-based, matching lark-cli:
|
||||
- If this run **created** the subscription (no `--subscribe-id`), a clean
|
||||
exit (SIGTERM / SIGINT / stdin-EOF / limit / timeout) **unsubscribes**
|
||||
it server-side and sends Bye.
|
||||
- If `--subscribe-id` was passed (reusing an existing subscription), the
|
||||
subscription is **left intact** — the caller owns its lifecycle.
|
||||
- `--ephemeral` remains as an explicit "always unsubscribe" override.
|
||||
- Help/docs warn: avoid `kill -9` (skips the unsubscribe → leaked
|
||||
server-side subscription: "subscription already exists" on restart,
|
||||
duplicate delivery). Prefer SIGTERM or closing stdin.
|
||||
|
||||
**Verification**
|
||||
- T4a: start consume (self-created subscription), record subscribe_id;
|
||||
SIGTERM; afterwards `dws event status` no longer lists that subscribe_id
|
||||
and the server-side subscription is gone.
|
||||
- T4b: start consume with `--subscribe-id <existing>`; SIGTERM; the
|
||||
subscription is still present (reuse case preserved).
|
||||
- T4c (control): `kill -9` leaves subscribe_id lingering (documented risk;
|
||||
we only guarantee SIGTERM is clean, we do not fix kill -9 itself).
|
||||
|
||||
## Out of scope (next branch)
|
||||
|
||||
**Reconnect resilience** — today `personal source` retries only
|
||||
`retryable` errors (1–30s backoff); a non-retryable error tears the bus
|
||||
down and takes consume with it (the likely cause of the observed silent
|
||||
drop). Making more drops retryable, keeping the bus alive across a
|
||||
reconnect, and emitting `reason: source_lost` only after exhausting the
|
||||
budget — tracked on its own branch, since it needs error-classification
|
||||
judgement and real flaky-network testing, and would otherwise couple clean
|
||||
contract work with resilience work.
|
||||
|
||||
## Test surface
|
||||
|
||||
- Unit: extend `internal/event/consume/*_test.go` with fake bus conn /
|
||||
stdin / stderr sink for T1c, T2c, T3 (all paths), T4 ownership branch.
|
||||
- Integration/e2e: `--foreground` + mock source (or a short real run) for
|
||||
T1a/b, T2a/b, T3a–d, T4a/b/c — assert the stderr contract lines and exit
|
||||
codes.
|
||||
@@ -1,113 +0,0 @@
|
||||
# 发布手册(预发 / 正式)
|
||||
|
||||
发布只走一条链路:本地脚本负责封板、验证并推送 annotated tag;GitHub Actions 负责构建和发布最终产物。不要直接运行 `goreleaser release`,也不要手工补打或移动 tag。
|
||||
|
||||
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 要求 `CI Gate`,操作机已安装并登录 `gh`。本地脚本会在封 tag 前通过 API 检查 immutable releases、当前 SHA 的 `CI Gate` 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置并由操作人确认。
|
||||
|
||||
## 日常只用一个入口
|
||||
|
||||
安装发布 Skill 后直接运行:
|
||||
|
||||
```bash
|
||||
dws-release
|
||||
```
|
||||
|
||||
零参数会进入引导模式。仓库内的等价入口是 `./scripts/release/dws-release.sh`。第一次使用只需配置一次生产发布远端,命令会把远端名及其规范化仓库身份一起保存在当前 Git 仓库中:
|
||||
|
||||
```bash
|
||||
dws-release config --remote origin
|
||||
```
|
||||
|
||||
之后命令按仓库状态自动走到正确步骤:缺少精确 CHANGELOG 章节时只生成模板并停止;补全、提交并合入 `main` 后,再运行同一条命令就会安全快进本地 `main` 并执行完整预检。若同名 remote 后续被改指向其他仓库会直接拒绝。只有显式增加 `--publish` 才会进入 tag 发布,且底层仍要求最终版本确认。
|
||||
|
||||
## 发布模型
|
||||
|
||||
```text
|
||||
main 上的候选代码 + beta CHANGELOG
|
||||
→ vX.Y.Z-beta.N(预发验证)
|
||||
→ 只允许补正式 CHANGELOG,源码不得再变化
|
||||
→ vX.Y.Z(正式发布)
|
||||
```
|
||||
|
||||
正式版必须显式指定本次验证过的 beta。脚本会比较两者:除 `CHANGELOG.md` 外只要有任何文件变化,就拒绝正式发布。这样预发测过的代码、命令树和正式发布的代码是同一份。
|
||||
|
||||
## 预发发布
|
||||
|
||||
运行统一入口:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3-beta.1
|
||||
```
|
||||
|
||||
如果 CHANGELOG 尚不存在,该命令只生成模板并停止。补全内容、删除所有 `TODO`,提交后通过 PR 合入 `main`;然后重新运行完全相同的命令,它会执行完整预检:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3-beta.1
|
||||
```
|
||||
|
||||
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。通过后发布:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3-beta.1 --publish
|
||||
```
|
||||
|
||||
命令会在所有预检完成后要求再次输入完整版本号。统一入口不提供跳过确认的参数。
|
||||
|
||||
## 正式发布
|
||||
|
||||
beta 验证通过后,运行正式版入口:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3 --from-beta v1.2.3-beta.1
|
||||
```
|
||||
|
||||
首次运行只生成正式版 CHANGELOG 并停止。补全内容、删除 `TODO`,提交后通过 PR 合入 `main`;重新运行同一条命令做完整预检,确认后增加 `--publish`:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3 --from-beta v1.2.3-beta.1
|
||||
dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
|
||||
```
|
||||
|
||||
`FROM_BETA` 不会自动推断,并会写入 stable annotated tag 的 `From-Beta` 元数据,CI 会再次读取和验证。
|
||||
|
||||
## CHANGELOG 契约
|
||||
|
||||
每个 tag 必须有唯一、非空且不含 `TODO/TBD` 的精确章节:
|
||||
|
||||
```markdown
|
||||
## [1.2.3-beta.1] - 2026-07-11
|
||||
|
||||
### Changed
|
||||
|
||||
- 本次 beta 验证的用户可见变化。
|
||||
```
|
||||
|
||||
正式版使用 `## [1.2.3] - YYYY-MM-DD`。该章节会直接成为 GitHub Release Notes。
|
||||
|
||||
## CI/CD 保证
|
||||
|
||||
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求先重跑补齐。
|
||||
- tag 必须是 annotated tag;本地脚本在推送前重新确认 HEAD 与远端 `main` 完全一致,CI 允许其后 `main` 前进,但要求封板提交仍位于 `main` 历史中。
|
||||
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
|
||||
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
|
||||
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
|
||||
- stable 发布到 npm `latest`,更新 OSS `latest.txt` 和共享安装脚本;prerelease 发布到 npm `beta`,只更新 OSS `beta.txt`,不会覆盖稳定入口。
|
||||
- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;本地入口仍要求上一条 Release 完成后才能封下一个 tag。
|
||||
- 本地 tag push 失败时会删除本次新建的本地 tag。tag 一旦成功推送,后续发布归 CI 所有,禁止改 tag 指向或复用版本号。
|
||||
|
||||
npm 补发只允许从默认分支触发 Release workflow 的 `repair_npm_version`。它只支持启用 immutable releases 后、由本流水线成功产出的公开 immutable release:目标必须是 `main` 历史中的 annotated tag,并且同 commit 的 `Build immutable GitHub Release` job 已成功。即使后续 npm 分发失败,这个独立的产物封存边界仍可作为补发依据。补发会用目标 commit 的 npm 模板重组包,逐平台核验资产和二进制版本,再发布到隔离的 `backfill` dist-tag,不会回滚 `latest` / `beta`。历史 mutable release 不进入自动补发路径,避免把可被替换的资产带入 npm。
|
||||
|
||||
OSS/Gitee 分发失败时直接重跑该 tag 的 `Publish npm and mirrors` failed job;各步会复用 immutable GitHub 资产并保持 channel 单调。独立 Gitee release workflow 和本地直发脚本已停用,避免绕开 publication queue 或用重新构建的不同字节覆盖镜像。
|
||||
|
||||
OSS 的 `latest.txt` / `beta.txt` 当前是镜像频道元数据;仓库内安装器仍从 GitHub/Gitee 解析版本,不能把 OSS pointer 当成已接入的安装通道。
|
||||
|
||||
Homebrew 当前只属于本机预检/手工公式通道:预检会在当前 macOS 架构真实安装,但 Release workflow 不发布 tap,CI 生成的单主机公式也不应当作 Darwin 双架构正式交付。正式自动交付范围是 GitHub Release、npm、OSS,以及显式开启时的 Gitee fallback;Homebrew 双架构 tap 发布需另立需求。
|
||||
|
||||
## 平台治理前置
|
||||
|
||||
仓库管理员还需要在 GitHub 平台配置两项不可由脚本替代的规则:
|
||||
|
||||
- `main` 必须要求精确的 `CI Gate`;tag workflow 也会通过 Checks API 再确认该封板 SHA 已通过。
|
||||
- 必须启用 immutable releases;它只保护启用后发布的 release,因此应在第一次使用新流水线前配置。为 `v*` 增加 tag ruleset,限制创建权限,并在 release 发布前保护 tag 的短暂窗口。
|
||||
|
||||
immutable releases 或 `CI Gate` 缺失时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
|
||||
@@ -101,7 +101,9 @@ func newEventConsumeCommand() *cobra.Command {
|
||||
raw 仅 SDK 原始 payload,无外层封装
|
||||
compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好)
|
||||
|
||||
默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接。
|
||||
默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加
|
||||
--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用
|
||||
SIGTERM、关 stdin,或 dws event stop <subscribe_id>,不要 kill -9。
|
||||
--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费
|
||||
通常不需要设置。`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
@@ -214,6 +216,11 @@ func newEventConsumeCommand() *cobra.Command {
|
||||
DryRun: dryRun,
|
||||
SpawnExtraArgs: streamOpts.spawnArgs(),
|
||||
}
|
||||
// Arm the stdin-EOF shutdown watcher only for a pipe-style,
|
||||
// unbounded run (see shouldWatchStdinEOF).
|
||||
if shouldWatchStdinEOF(maxEvents, duration) {
|
||||
cfg.Stdin = c.InOrStdin()
|
||||
}
|
||||
|
||||
// Step 5: validation (flag-only rules).
|
||||
if err := consume.ValidateConfig(cfg); err != nil {
|
||||
@@ -260,7 +267,7 @@ func newEventConsumeCommand() *cobra.Command {
|
||||
f.BoolVar(&dryRun, "dry-run", false,
|
||||
"仅打印解析后的配置,不连接 bus / 云端")
|
||||
f.BoolVar(&foreground, "foreground", false,
|
||||
"不 fork daemon,当前进程跑 bus (systemd/k8s/launchd 友好)")
|
||||
"当前进程直接跑 bus 服务、不 fork、不打印事件(给 systemd/k8s 托管用);读事件不要用它")
|
||||
f.StringVar(&personalOpts.SubscribeID, "subscribe-id", "",
|
||||
"个人事件订阅 ID;传入后复用已有订阅")
|
||||
f.StringVar(&personalOpts.Rule, "rule", "",
|
||||
@@ -274,7 +281,10 @@ func newEventConsumeCommand() *cobra.Command {
|
||||
f.DurationVar(&personalOpts.TTL, "ttl", 0,
|
||||
"个人订阅 TTL (Go duration,如 24h;0 表示不过期)")
|
||||
f.BoolVar(&personalOpts.Ephemeral, "ephemeral", false,
|
||||
"consume 退出时自动取消个人订阅")
|
||||
"强制退出时取消个人订阅。默认已按归属清理:本次新建的订阅退出即取消,"+
|
||||
"用 --subscribe-id 复用的订阅保留。优雅停可用 SIGTERM、关闭 stdin,"+
|
||||
"或从外部 dws event stop <subscribe_id>(会一并退订);"+
|
||||
"请勿 kill -9(会跳过退订、泄漏服务端订阅)")
|
||||
f.StringVar(&personalOpts.UserID, "user", "",
|
||||
"个人单聊对端 userId")
|
||||
f.StringVar(&personalOpts.GroupID, "group", "",
|
||||
@@ -457,7 +467,13 @@ func newEventBusCommand() *cobra.Command {
|
||||
readyPipe := busctl.ReadyFDFromEnv()
|
||||
failEarly := func(err error) error {
|
||||
if readyPipe != nil {
|
||||
// 'E' signals failure; the trailing text lets the parent
|
||||
// (busctl.waitReady) surface the real startup error to the
|
||||
// user instead of an opaque "startup failure on ready pipe".
|
||||
_, _ = readyPipe.Write([]byte{'E'})
|
||||
if err != nil {
|
||||
_, _ = io.WriteString(readyPipe, err.Error())
|
||||
}
|
||||
_ = readyPipe.Close()
|
||||
}
|
||||
return err
|
||||
@@ -1093,6 +1109,26 @@ func firstArg(args []string) string {
|
||||
return args[0]
|
||||
}
|
||||
|
||||
// shouldWatchStdinEOF gates the stdin-EOF shutdown watcher (AI-subprocess
|
||||
// contract). It arms only for a parent-controlled, pipe-style stdin on an
|
||||
// unbounded run:
|
||||
// - bounded runs (--max-events / --duration) already have their own
|
||||
// lifecycle, so stdin is irrelevant;
|
||||
// - char devices (an interactive TTY, or /dev/null) are excluded, so a
|
||||
// terminal Ctrl-D and the common `< /dev/null` launch do NOT trigger a
|
||||
// surprise shutdown. Only a pipe / regular file — an stdin a parent
|
||||
// holds and can close to stop us — arms the watcher.
|
||||
func shouldWatchStdinEOF(maxEvents int, duration time.Duration) bool {
|
||||
if maxEvents > 0 || duration > 0 {
|
||||
return false
|
||||
}
|
||||
fi, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return fi.Mode()&os.ModeCharDevice == 0
|
||||
}
|
||||
|
||||
// eventTypesWithDefault picks the catch-all list from registry when the
|
||||
// user did not pass --event-types.
|
||||
func eventTypesWithDefault(types []string) []string {
|
||||
|
||||
@@ -204,6 +204,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
EventKey: opts.EventKey,
|
||||
Format: normalised,
|
||||
OutputDir: opts.Common.OutputDir,
|
||||
Routes: routes,
|
||||
@@ -239,7 +240,14 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
_ = client.DeleteSubscription(context.Background(), sub.SubscribeID)
|
||||
_ = personal.RemoveRunStates(workDir, []string{sub.SubscribeID})
|
||||
}
|
||||
if opts.Ephemeral {
|
||||
// Ownership-based cleanup (AI-subprocess contract, aligned with
|
||||
// lark-cli): a subscription this run CREATED is unsubscribed on exit
|
||||
// (any exit — SIGTERM / stdin-EOF / limit / timeout / error), so nothing
|
||||
// leaks server-side. A subscription REUSED via --subscribe-id is left
|
||||
// intact — the caller owns its lifecycle. --ephemeral forces cleanup
|
||||
// either way.
|
||||
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
|
||||
if opts.Ephemeral || selfCreated {
|
||||
defer cleanup()
|
||||
}
|
||||
|
||||
@@ -251,6 +259,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
EventKey: eventKey,
|
||||
Format: normalised,
|
||||
OutputDir: opts.Common.OutputDir,
|
||||
Routes: routes,
|
||||
@@ -260,6 +269,11 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
Foreground: opts.Common.Foreground,
|
||||
Force: opts.Common.Force,
|
||||
}
|
||||
// Arm the stdin-EOF shutdown watcher only for a pipe-style, unbounded
|
||||
// run (see shouldWatchStdinEOF).
|
||||
if shouldWatchStdinEOF(opts.Common.MaxEvents, opts.Common.Duration) {
|
||||
cfg.Stdin = c.InOrStdin()
|
||||
}
|
||||
applyPersonalConsumeFilters(&cfg, opts, sub.SubscribeID, eventKey)
|
||||
if opts.DebugRawEvents && !opts.Common.Quiet {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "debug raw events enabled: local event filters disabled\nworkdir: %s\nbus_log: %s\n",
|
||||
@@ -756,6 +770,16 @@ func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL stri
|
||||
"--source-kind", string(dwsevent.SourceKindPersonalStream),
|
||||
"--stream-source-id", identity.SourceID,
|
||||
}
|
||||
// Forward the organization so the detached _bus child resolves
|
||||
// credentials for the SAME profile the parent used. Without this the
|
||||
// child falls back to the default profile's token slot and fails to
|
||||
// authenticate the personal stream for a non-default `--profile`
|
||||
// (symptom: "bus child reported startup failure on ready pipe", no
|
||||
// bus.log). --profile accepts a corpId; the root pre-parses it into the
|
||||
// runtime profile before the _bus handler resolves the identity.
|
||||
if cid := strings.TrimSpace(identity.CorpID); cid != "" {
|
||||
args = append(args, "--profile", cid)
|
||||
}
|
||||
if strings.TrimSpace(ticketMode) != "" {
|
||||
args = append(args, "--stream-ticket-mode", ticketMode)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
)
|
||||
|
||||
// A bounded run never arms the stdin-EOF watcher, regardless of stdin
|
||||
// shape: --max-events / --duration are the lifecycle control.
|
||||
func TestShouldWatchStdinEOF_BoundedIsNeverArmed(t *testing.T) {
|
||||
if shouldWatchStdinEOF(1, 0) {
|
||||
t.Error("--max-events set should not arm stdin watcher")
|
||||
}
|
||||
if shouldWatchStdinEOF(0, 5*time.Second) {
|
||||
t.Error("--duration set should not arm stdin watcher")
|
||||
}
|
||||
if shouldWatchStdinEOF(3, 2*time.Second) {
|
||||
t.Error("both bounds set should not arm stdin watcher")
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: the detached _bus child must receive --profile so it resolves
|
||||
// credentials for the same organization as the parent. Missing it made a
|
||||
// non-default `--profile` consume fail with "bus child reported startup
|
||||
// failure on ready pipe" (no bus.log).
|
||||
func TestPersonalBusSpawnArgs_ForwardsProfile(t *testing.T) {
|
||||
args := personalBusSpawnArgs(personal.Identity{
|
||||
CorpID: "dinga626d60c1128d449",
|
||||
SourceID: "open",
|
||||
}, "", "")
|
||||
found := false
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == "--profile" && args[i+1] == "dinga626d60c1128d449" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("spawn args must forward --profile <corpId>; got %v", args)
|
||||
}
|
||||
|
||||
// No CorpID → no --profile appended (avoid an empty flag value).
|
||||
bare := personalBusSpawnArgs(personal.Identity{SourceID: "open"}, "", "")
|
||||
for _, a := range bare {
|
||||
if a == "--profile" {
|
||||
t.Errorf("must not append --profile when CorpID is empty; got %v", bare)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,10 @@ func NewSchemaCommand(_ CatalogLoader, helperTools HelperToolFetcher) *cobra.Com
|
||||
Short: "查看有限的本地 Schema(静态端点模式)",
|
||||
Long: `查看有限的本地 Schema 元数据。
|
||||
|
||||
服务发现和动态 schema 已下线。静态端点模式下,仅支持 helper-only 子树的 schema 查询;普通产品命令和 flag 以当前二进制的 --help 为准。`,
|
||||
服务发现和动态 schema 已下线。静态端点模式下,schema 覆盖两类命令:
|
||||
1. helper-only 子树(如 dev):CONTENT 从其绑定的 MCP 服务实时取,source 为 mcp:<server>;
|
||||
2. 登记的本地命令(如 event):从二进制注册的 cobra flag 合成,source 为 cobra。
|
||||
其余普通产品命令和 flag 仍以当前二进制的 --help 为准。`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -88,7 +91,7 @@ func NewSchemaCommand(_ CatalogLoader, helperTools HelperToolFetcher) *cobra.Com
|
||||
args = []string{cliPath}
|
||||
}
|
||||
|
||||
// Helper-only subtrees support.
|
||||
// Helper-only subtrees: schema CONTENT fetched live from the MCP server.
|
||||
if len(args) > 0 && helperTools != nil {
|
||||
payload, ok, err := renderHelperSchema(cmd.Context(), cmd.Root(), args[0], helperTools)
|
||||
if err != nil {
|
||||
@@ -101,6 +104,19 @@ func NewSchemaCommand(_ CatalogLoader, helperTools HelperToolFetcher) *cobra.Com
|
||||
}
|
||||
}
|
||||
|
||||
// Registered local subtrees (event, …): schema synthesized from cobra flags.
|
||||
if len(args) > 0 {
|
||||
payload, ok, err := renderCobraSchema(cmd.Root(), args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
data, _ := json.MarshalIndent(payload, "", " ")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), string(data))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(cmd.OutOrStdout(), `{"kind":"schema","count":0,"products":[],"note":"static endpoint mode"}`)
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// cobraSchemaRoots are top-level command names whose subtrees answer `dws schema`
|
||||
// by SYNTHESIZING the machine-readable input schema from their cobra flags. This
|
||||
// is the local-command counterpart to helperSchemaRoots: helper subtrees (dev)
|
||||
// fetch CONTENT live from an MCP server, whereas these commands have no MCP
|
||||
// backing so the schema is built from the flags the binary actually registered.
|
||||
//
|
||||
// The output shape is the same flat object helper leaves emit — {description,
|
||||
// path, source, parameters{<flag>:{type,description,required,default?}}} — so an
|
||||
// agent gets one consistent schema format no matter the source; `source` is
|
||||
// "cobra" to mark it synthesized from flags (vs "mcp:<server>"). event is the
|
||||
// first consumer; register more command trees here as they adopt the contract.
|
||||
var cobraSchemaRoots = map[string]bool{"event": true}
|
||||
|
||||
// renderCobraSchema builds the `dws schema` payload for command subtrees listed
|
||||
// in cobraSchemaRoots. Mirrors renderHelperSchema's routing: returns
|
||||
// (payload, true) when the path targets a registered subtree so the caller
|
||||
// skips the static-mode fallback; (nil, false) otherwise.
|
||||
//
|
||||
// A runnable leaf renders the flat parameter object synthesized from its flags
|
||||
// (plus positional arguments parsed from its Use line). A group/root renders the
|
||||
// same browse listing helper groups use.
|
||||
func renderCobraSchema(root *cobra.Command, rawPath string) (map[string]any, bool, error) {
|
||||
if root == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
tokens := splitSchemaPathTokens(rawPath)
|
||||
if len(tokens) == 0 || !cobraSchemaRoots[tokens[0]] {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
target, rest, err := root.Find(tokens)
|
||||
if err != nil || target == nil {
|
||||
target = root
|
||||
rest = tokens[1:]
|
||||
}
|
||||
// Any non-flag leftover token means an unknown subcommand — surface it with
|
||||
// the closest group's children, same as renderHelperSchema.
|
||||
if unknown := firstNonFlag(rest); unknown != "" {
|
||||
return map[string]any{
|
||||
"path": rawPath,
|
||||
"error": "unknown subcommand \"" + unknown + "\" under \"" + helperCommandPath(target) + "\"",
|
||||
"available": helperSubcommands(target),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
if target.Runnable() && !target.HasAvailableSubCommands() {
|
||||
return cobraLeafSchema(target), true, nil
|
||||
}
|
||||
return map[string]any{
|
||||
"path": helperCommandPath(target),
|
||||
"commands": helperSubcommands(target),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
// cobraLeafSchema renders one runnable command as the flat schema object,
|
||||
// synthesizing parameters from its flags and (when present) positional
|
||||
// arguments from its Use line.
|
||||
func cobraLeafSchema(cmd *cobra.Command) map[string]any {
|
||||
out := map[string]any{
|
||||
"description": strings.TrimSpace(cmd.Short),
|
||||
"path": helperCommandPath(cmd),
|
||||
"source": "cobra",
|
||||
"parameters": cobraFlatParameters(cmd),
|
||||
}
|
||||
if args := cobraPositionalArgs(cmd); len(args) > 0 {
|
||||
out["arguments"] = args
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cobraFlatParameters projects a command's LOCAL flags into the flat
|
||||
// per-parameter object. Local (non-inherited) flags are the command-specific
|
||||
// inputs; global persistent flags inherited from the root (--profile, --verbose,
|
||||
// --jq, …) are intentionally excluded so the schema describes THIS command, not
|
||||
// the whole CLI. Hidden internal flags are skipped. Each entry is
|
||||
// {type, description, required, default?} with type mapped to a JSON-type
|
||||
// string, required read from cobra's required-flag annotation, and default only
|
||||
// when the flag has a meaningful (non-zero) default.
|
||||
func cobraFlatParameters(cmd *cobra.Command) map[string]any {
|
||||
params := map[string]any{}
|
||||
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
|
||||
if f.Hidden {
|
||||
return
|
||||
}
|
||||
entry := map[string]any{
|
||||
"type": pflagJSONType(f),
|
||||
"description": strings.TrimSpace(f.Usage),
|
||||
"required": flagIsRequired(f),
|
||||
}
|
||||
if def, ok := meaningfulDefault(f); ok {
|
||||
entry["default"] = def
|
||||
}
|
||||
params[f.Name] = entry
|
||||
})
|
||||
return params
|
||||
}
|
||||
|
||||
// cobraPositionalArgs parses a command's Use line into structured positional
|
||||
// arguments. Cobra has no typed metadata for positionals, so the Use string
|
||||
// ("consume [event_key]", "stop [subscribe_id]") is the source of truth:
|
||||
// tokens after the command name are positional slots. <name> is required,
|
||||
// [name] is optional, a trailing "..." marks it variadic. Returns nil when the
|
||||
// command declares no positionals (e.g. flag-only commands), so the leaf object
|
||||
// simply omits the "arguments" field.
|
||||
func cobraPositionalArgs(cmd *cobra.Command) []map[string]any {
|
||||
fields := strings.Fields(cmd.Use)
|
||||
if len(fields) <= 1 {
|
||||
return nil
|
||||
}
|
||||
out := []map[string]any{}
|
||||
for _, tok := range fields[1:] {
|
||||
variadic := strings.Contains(tok, "...")
|
||||
required := strings.HasPrefix(tok, "<")
|
||||
name := strings.Trim(tok, "[]<>.")
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
arg := map[string]any{
|
||||
"name": name,
|
||||
"required": required,
|
||||
}
|
||||
if variadic {
|
||||
arg["variadic"] = true
|
||||
}
|
||||
out = append(out, arg)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pflagJSONType maps a pflag value type to a JSON-type string. Duration is
|
||||
// expressed as "string" because it is entered as a CLI string ("10m"); slice
|
||||
// and array types collapse to "array"; the numeric families collapse to
|
||||
// "integer"/"number". Unknown types default to "string" so the contract is
|
||||
// always populated.
|
||||
func pflagJSONType(f *pflag.Flag) string {
|
||||
switch f.Value.Type() {
|
||||
case "bool":
|
||||
return "boolean"
|
||||
case "int", "int8", "int16", "int32", "int64",
|
||||
"uint", "uint8", "uint16", "uint32", "uint64", "count":
|
||||
return "integer"
|
||||
case "float32", "float64":
|
||||
return "number"
|
||||
case "stringSlice", "stringArray", "intSlice", "int32Slice", "int64Slice",
|
||||
"uintSlice", "float32Slice", "float64Slice", "boolSlice", "durationSlice":
|
||||
return "array"
|
||||
default:
|
||||
// string, duration, ip, and any custom Value type read as a string.
|
||||
return "string"
|
||||
}
|
||||
}
|
||||
|
||||
// flagIsRequired reports whether cobra.MarkFlagRequired was applied to the flag
|
||||
// (it records the requirement in the flag's annotations under
|
||||
// cobra.BashCompOneRequiredFlag). Event's conditionally-required inputs
|
||||
// (--user / --group depend on the event key) are enforced at runtime, not via
|
||||
// this annotation, so they read as required:false here — the dependency is
|
||||
// documented in the command help, not the flag metadata.
|
||||
func flagIsRequired(f *pflag.Flag) bool {
|
||||
if f.Annotations == nil {
|
||||
return false
|
||||
}
|
||||
vals, ok := f.Annotations[cobra.BashCompOneRequiredFlag]
|
||||
return ok && len(vals) == 1 && vals[0] == "true"
|
||||
}
|
||||
|
||||
// meaningfulDefault returns a flag's default only when it is a real value, not
|
||||
// the zero/unset sentinel ("", "0", "0s", "false", "[]"). A zero default means
|
||||
// "no default — the value comes from you", so omitting it matches how the helper
|
||||
// renderer omits absent MCP defaults and keeps the schema free of noise.
|
||||
func meaningfulDefault(f *pflag.Flag) (string, bool) {
|
||||
def := strings.TrimSpace(f.DefValue)
|
||||
switch def {
|
||||
case "", "0", "0s", "false", "[]", "{}":
|
||||
return "", false
|
||||
default:
|
||||
return def, true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// buildEventTestTree mirrors the shape of the real `dws event` subtree closely
|
||||
// enough to exercise the cobra-flag schema renderer: a group with a runnable
|
||||
// leaf that carries a positional arg, typed flags (string/int/duration/bool),
|
||||
// a required flag, a hidden internal flag, and a defaulted flag.
|
||||
func buildEventTestTree() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
// A global persistent flag inherited by every command — must NOT appear in a
|
||||
// command's synthesized parameters (it describes the CLI, not the command).
|
||||
root.PersistentFlags().String("profile", "", "组织 profile")
|
||||
|
||||
consume := &cobra.Command{
|
||||
Use: "consume [event_key]",
|
||||
Short: "订阅事件流并输出到 stdout",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Run: func(*cobra.Command, []string) {},
|
||||
}
|
||||
f := consume.Flags()
|
||||
f.StringP("format", "f", "ndjson", "输出格式")
|
||||
f.String("user", "", "单聊对端 userId")
|
||||
f.String("group", "", "群 openConversationId")
|
||||
f.Int("max-events", 0, "收到 N 条后退出")
|
||||
f.Duration("duration", 0, "运行时长上限")
|
||||
f.Bool("ephemeral", false, "退出时强制退订")
|
||||
f.String("subscribe-id", "", "复用已有订阅")
|
||||
f.String("client-id", "", "内部:覆盖凭证解析")
|
||||
_ = f.MarkHidden("client-id")
|
||||
// A flag marked required via cobra — must read required:true.
|
||||
f.String("token", "", "必填令牌")
|
||||
_ = consume.MarkFlagRequired("token")
|
||||
|
||||
stop := &cobra.Command{
|
||||
Use: "stop [subscribe_id]",
|
||||
Short: "取消订阅",
|
||||
Run: func(*cobra.Command, []string) {},
|
||||
}
|
||||
stop.Flags().Bool("all", false, "取消全部")
|
||||
|
||||
event := &cobra.Command{Use: "event", Short: "个人消息事件"}
|
||||
// A hidden internal subcommand — must not appear in the browse listing.
|
||||
bus := &cobra.Command{Use: "_bus", Short: "内部 bus", Hidden: true, Run: func(*cobra.Command, []string) {}}
|
||||
event.AddCommand(consume, stop, bus)
|
||||
|
||||
root.AddCommand(event)
|
||||
return root
|
||||
}
|
||||
|
||||
func TestRenderCobraSchema_LeafFlatShape(t *testing.T) {
|
||||
root := buildEventTestTree()
|
||||
|
||||
payload, ok, err := renderCobraSchema(root, "event consume")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected cobra renderer to claim the event path")
|
||||
}
|
||||
|
||||
if payload["description"] != "订阅事件流并输出到 stdout" {
|
||||
t.Fatalf("description = %v", payload["description"])
|
||||
}
|
||||
if payload["path"] != "event consume" {
|
||||
t.Fatalf("path = %v", payload["path"])
|
||||
}
|
||||
if payload["source"] != "cobra" {
|
||||
t.Fatalf("source = %v, want cobra", payload["source"])
|
||||
}
|
||||
|
||||
params, _ := payload["parameters"].(map[string]any)
|
||||
if params == nil {
|
||||
t.Fatalf("no parameters: %#v", payload)
|
||||
}
|
||||
|
||||
// Inherited global flag must be excluded.
|
||||
if _, present := params["profile"]; present {
|
||||
t.Fatal("inherited --profile must not appear in a command's parameters")
|
||||
}
|
||||
// Hidden internal flag must be excluded.
|
||||
if _, present := params["client-id"]; present {
|
||||
t.Fatal("hidden --client-id must not appear")
|
||||
}
|
||||
|
||||
// Type mapping.
|
||||
if got := paramField(t, params, "user", "type"); got != "string" {
|
||||
t.Errorf("user type = %v, want string", got)
|
||||
}
|
||||
if got := paramField(t, params, "max-events", "type"); got != "integer" {
|
||||
t.Errorf("max-events type = %v, want integer", got)
|
||||
}
|
||||
if got := paramField(t, params, "duration", "type"); got != "string" {
|
||||
t.Errorf("duration type = %v, want string (CLI string like 10m)", got)
|
||||
}
|
||||
if got := paramField(t, params, "ephemeral", "type"); got != "boolean" {
|
||||
t.Errorf("ephemeral type = %v, want boolean", got)
|
||||
}
|
||||
|
||||
// Meaningful default is surfaced; zero defaults are omitted.
|
||||
if got := paramField(t, params, "format", "default"); got != "ndjson" {
|
||||
t.Errorf("format default = %v, want ndjson", got)
|
||||
}
|
||||
if _, hasDefault := params["max-events"].(map[string]any)["default"]; hasDefault {
|
||||
t.Error("max-events has a zero default (0) — must be omitted")
|
||||
}
|
||||
if _, hasDefault := params["ephemeral"].(map[string]any)["default"]; hasDefault {
|
||||
t.Error("ephemeral has a zero default (false) — must be omitted")
|
||||
}
|
||||
if _, hasDefault := params["duration"].(map[string]any)["default"]; hasDefault {
|
||||
t.Error("duration has a zero default (0s) — must be omitted")
|
||||
}
|
||||
|
||||
// Required annotation is honored; unmarked flags read required:false.
|
||||
if got := paramField(t, params, "token", "required"); got != true {
|
||||
t.Errorf("token required = %v, want true", got)
|
||||
}
|
||||
if got := paramField(t, params, "user", "required"); got != false {
|
||||
t.Errorf("user required = %v, want false", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCobraSchema_PositionalArguments(t *testing.T) {
|
||||
root := buildEventTestTree()
|
||||
|
||||
payload, _, err := renderCobraSchema(root, "event.consume")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
args, _ := payload["arguments"].([]map[string]any)
|
||||
if len(args) != 1 {
|
||||
t.Fatalf("arguments = %#v, want 1 positional", payload["arguments"])
|
||||
}
|
||||
if args[0]["name"] != "event_key" {
|
||||
t.Errorf("arg name = %v, want event_key", args[0]["name"])
|
||||
}
|
||||
// [event_key] is optional syntax → required:false.
|
||||
if args[0]["required"] != false {
|
||||
t.Errorf("arg required = %v, want false", args[0]["required"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCobraSchema_DotAndSpacePathEquivalent(t *testing.T) {
|
||||
root := buildEventTestTree()
|
||||
dotted, _, _ := renderCobraSchema(root, "event.consume")
|
||||
spaced, _, _ := renderCobraSchema(root, "event consume")
|
||||
if dotted["path"] != spaced["path"] || dotted["path"] != "event consume" {
|
||||
t.Fatalf("dot/space forms diverged: %v vs %v", dotted["path"], spaced["path"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCobraSchema_GroupBrowse(t *testing.T) {
|
||||
root := buildEventTestTree()
|
||||
|
||||
payload, ok, err := renderCobraSchema(root, "event")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected claim")
|
||||
}
|
||||
cmds, _ := payload["commands"].([]map[string]any)
|
||||
// consume + stop; hidden _bus excluded.
|
||||
if len(cmds) != 2 {
|
||||
t.Fatalf("commands = %#v, want 2 (hidden _bus excluded)", cmds)
|
||||
}
|
||||
for _, c := range cmds {
|
||||
if c["cli_path"] == "event _bus" {
|
||||
t.Fatal("hidden _bus must not be listed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCobraSchema_UnknownSubcommand(t *testing.T) {
|
||||
root := buildEventTestTree()
|
||||
payload, ok, err := renderCobraSchema(root, "event nope")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected claim")
|
||||
}
|
||||
if payload["error"] == nil {
|
||||
t.Fatalf("expected unknown-subcommand error, got %#v", payload)
|
||||
}
|
||||
if avail, _ := payload["available"].([]map[string]any); len(avail) == 0 {
|
||||
t.Fatal("expected available subcommands listed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCobraSchema_NonRegisteredPathDeclined(t *testing.T) {
|
||||
root := buildEventTestTree()
|
||||
if _, ok, _ := renderCobraSchema(root, "dev app create"); ok {
|
||||
t.Fatal("non-registered path must not be claimed by the cobra renderer")
|
||||
}
|
||||
if _, ok, _ := renderCobraSchema(root, "ding.message.send"); ok {
|
||||
t.Fatal("non-registered path must not be claimed")
|
||||
}
|
||||
}
|
||||
|
||||
// paramField fetches params[<name>][<field>], failing the test if the param is
|
||||
// absent.
|
||||
func paramField(t *testing.T, params map[string]any, name, field string) any {
|
||||
t.Helper()
|
||||
p, _ := params[name].(map[string]any)
|
||||
if p == nil {
|
||||
t.Fatalf("missing param %q in %#v", name, params)
|
||||
}
|
||||
return p[field]
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -136,11 +137,15 @@ func Spawn(cfg SpawnConfig) (pid int, err error) {
|
||||
return pid, nil
|
||||
}
|
||||
|
||||
// waitReady reads exactly one byte from pr ('R' or 'E') within ReadyTimeout.
|
||||
// pr is closed by the caller on return.
|
||||
// waitReady reads the child's ready signal within ReadyTimeout. The first
|
||||
// byte is 'R' (ready) or 'E' (error); on 'E' the child may write its real
|
||||
// error text after the byte and close the pipe, which we surface so the
|
||||
// caller sees WHY the bus failed to start (instead of an opaque
|
||||
// ErrSpawnFailed). pr is closed by the caller on return.
|
||||
func waitReady(pr *os.File) error {
|
||||
type result struct {
|
||||
b byte
|
||||
msg string
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
@@ -155,6 +160,13 @@ func waitReady(pr *os.File) error {
|
||||
done <- result{err: io.ErrUnexpectedEOF}
|
||||
return
|
||||
}
|
||||
if buf[0] == 'E' {
|
||||
// Failure: read the trailing error text (bounded), which the
|
||||
// child writes right after 'E' before closing.
|
||||
rest, _ := io.ReadAll(io.LimitReader(pr, 4096))
|
||||
done <- result{b: 'E', msg: strings.TrimSpace(string(rest))}
|
||||
return
|
||||
}
|
||||
done <- result{b: buf[0]}
|
||||
}()
|
||||
select {
|
||||
@@ -169,6 +181,9 @@ func waitReady(pr *os.File) error {
|
||||
case 'R':
|
||||
return nil
|
||||
case 'E':
|
||||
if res.msg != "" {
|
||||
return fmt.Errorf("%w: %s", ErrSpawnFailed, res.msg)
|
||||
}
|
||||
return ErrSpawnFailed
|
||||
default:
|
||||
return fmt.Errorf("busctl: unexpected ready byte %q", res.b)
|
||||
|
||||
@@ -15,9 +15,11 @@ package busctl
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -246,3 +248,45 @@ func TestReadyFDFromEnv_LowFDRejected(t *testing.T) {
|
||||
t.Errorf("ReadyFDFromEnv should reject stdio fds, got %v", f)
|
||||
}
|
||||
}
|
||||
|
||||
// waitReady must surface the child's real startup error (written after the
|
||||
// 'E' byte) so consume shows WHY the bus failed, not an opaque message.
|
||||
func TestWaitReady_FailureSurfacesChildError(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses Unix pipe")
|
||||
}
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pr.Close()
|
||||
const reason = "newPersonalStreamSource: token expired for corp dinga626"
|
||||
go func() {
|
||||
_, _ = pw.Write([]byte{'E'})
|
||||
_, _ = io.WriteString(pw, reason)
|
||||
_ = pw.Close()
|
||||
}()
|
||||
err = waitReady(pr)
|
||||
if !errors.Is(err, ErrSpawnFailed) {
|
||||
t.Fatalf("want ErrSpawnFailed, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), reason) {
|
||||
t.Errorf("error must surface the child's real reason; got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A bare 'E' (no trailing text) still reports ErrSpawnFailed.
|
||||
func TestWaitReady_BareFailureByte(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses Unix pipe")
|
||||
}
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pr.Close()
|
||||
go func() { _, _ = pw.Write([]byte{'E'}); _ = pw.Close() }()
|
||||
if err := waitReady(pr); !errors.Is(err, ErrSpawnFailed) {
|
||||
t.Fatalf("want ErrSpawnFailed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,11 @@ type Config struct {
|
||||
// MaxEvents: stop after receiving this many events. 0 = no limit.
|
||||
MaxEvents int
|
||||
|
||||
// EventKey is the single event key being consumed. Used only for the
|
||||
// AI-subprocess contract stderr lines (`[event] ready event_key=...`).
|
||||
// Empty → the key is omitted from the marker.
|
||||
EventKey string
|
||||
|
||||
// Duration: wall-clock budget for the consume run. After this elapses,
|
||||
// Run returns nil (clean exit, exit code 0). Zero = no limit.
|
||||
//
|
||||
@@ -91,6 +96,18 @@ type Config struct {
|
||||
// Routes are pre-parsed --route specs. Empty = no routing.
|
||||
Routes []Route
|
||||
|
||||
// Stdin, when non-nil, is watched for EOF: closing stdin triggers a
|
||||
// graceful shutdown (reason: signal). This wires the AI-subprocess
|
||||
// contract — a parent closes stdin to stop the consumer. The cobra
|
||||
// layer passes os.Stdin; tests inject a controllable reader. nil →
|
||||
// stdin is not watched (backward-compatible default for callers that
|
||||
// do not opt in).
|
||||
//
|
||||
// Note: `< /dev/null` EOFs immediately and exits at once. To stay
|
||||
// resident feed a never-EOF stdin (`< <(tail -f /dev/null)`) or run
|
||||
// bounded (--max-events / --duration).
|
||||
Stdin io.Reader
|
||||
|
||||
// Stdout sink; nil → os.Stdout. Injected for tests.
|
||||
Stdout io.Writer
|
||||
// Stderr sink for status lines (HelloAck info, bye reason); nil → os.Stderr.
|
||||
@@ -132,15 +149,30 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Distinguish the exit cause for the contract's `exited` line:
|
||||
// duration deadline → timeout
|
||||
// parentCtx cancelled → signal (SIGTERM/SIGINT)
|
||||
// runCtx-only cancelled → signal (stdin EOF)
|
||||
parentCtx := ctx
|
||||
|
||||
// --duration: layer a deadline on top of caller-provided ctx. Run
|
||||
// returns nil on deadline (clean exit) rather than surfacing the
|
||||
// context.DeadlineExceeded as an error to the user.
|
||||
var timeoutCtx context.Context
|
||||
if cfg.Duration > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, cfg.Duration)
|
||||
timeoutCtx, cancel = context.WithTimeout(ctx, cfg.Duration)
|
||||
defer cancel()
|
||||
ctx = timeoutCtx
|
||||
}
|
||||
|
||||
// runCtx lets the stdin watcher and the read loop share one cancel
|
||||
// without disturbing the timeout/parent contexts (so we can still tell
|
||||
// stdin-EOF from a real signal from a duration deadline).
|
||||
runCtx, cancelRun := context.WithCancel(ctx)
|
||||
defer cancelRun()
|
||||
ctx = runCtx
|
||||
|
||||
pipeline, err := BuildPipeline(cfg.Format, cfg.OutputDir, cfg.Routes, cfg.Stdout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("consume: build pipeline: %w", err)
|
||||
@@ -184,19 +216,57 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
return fmt.Errorf("consume: unexpected first frame type %q", ack.Type)
|
||||
}
|
||||
if !cfg.Quiet {
|
||||
fmt.Fprintf(cfg.Stderr,
|
||||
"connected bus pid=%d source=%s state=%s idle_timeout=%ds\n",
|
||||
ack.BusPID, ack.StateSource, ack.SourceState, ack.IdleTimeoutSecs)
|
||||
// Contract: a fixed ready line on stderr BEFORE any stdout event.
|
||||
// Parents block on stderr until this appears, then read stdout.
|
||||
if cfg.EventKey != "" {
|
||||
fmt.Fprintf(cfg.Stderr, "[event] ready event_key=%s bus_pid=%d\n", cfg.EventKey, ack.BusPID)
|
||||
} else {
|
||||
fmt.Fprintf(cfg.Stderr, "[event] ready bus_pid=%d\n", ack.BusPID)
|
||||
}
|
||||
// Secondary diagnostic line (source/state/idle); not part of the
|
||||
// ready contract.
|
||||
fmt.Fprintf(cfg.Stderr, "[event] bus source=%s state=%s idle_timeout=%ds\n",
|
||||
ack.StateSource, ack.SourceState, ack.IdleTimeoutSecs)
|
||||
}
|
||||
|
||||
// Watch stdin for EOF → graceful shutdown (AI-subprocess contract).
|
||||
// The cobra layer only sets Stdin when the watcher should arm (a
|
||||
// pipe-style, unbounded run); tests inject it directly.
|
||||
if cfg.Stdin != nil {
|
||||
go watchStdinEOF(runCtx, cfg.Stdin, cfg.Stderr, cancelRun)
|
||||
}
|
||||
|
||||
// Exit-reason contract: on a graceful exit emit a final stderr line
|
||||
// [event] exited — received N event(s) in Xs (reason: <r>)
|
||||
// Error returns leave reason empty → no `exited` line (an `Error:` line
|
||||
// is printed by the cobra layer instead).
|
||||
received := 0
|
||||
start := time.Now()
|
||||
reason := ""
|
||||
defer func() {
|
||||
if !cfg.Quiet && reason != "" {
|
||||
fmt.Fprintf(cfg.Stderr, "[event] exited — received %d event(s) in %s (reason: %s)\n",
|
||||
received, time.Since(start).Round(time.Millisecond), reason)
|
||||
}
|
||||
}()
|
||||
|
||||
// classifyCancel maps a context-cancelled exit to a contract reason.
|
||||
classifyCancel := func() string {
|
||||
if timeoutCtx != nil && errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) && parentCtx.Err() == nil {
|
||||
return "timeout"
|
||||
}
|
||||
return "signal"
|
||||
}
|
||||
|
||||
for {
|
||||
raw, err := r.Read()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil // peer closed cleanly
|
||||
reason = "bus_shutdown" // peer closed cleanly
|
||||
return nil
|
||||
}
|
||||
if isCtxCancelled(ctx) {
|
||||
reason = classifyCancel()
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("consume: read frame: %w", err)
|
||||
@@ -219,6 +289,7 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
Type: transport.FrameTypeBye,
|
||||
Reason: "client_done",
|
||||
})
|
||||
reason = "signal"
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("consume: deliver event: %w", err)
|
||||
@@ -229,14 +300,16 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
Type: transport.FrameTypeBye,
|
||||
Reason: "client_done",
|
||||
})
|
||||
reason = "limit"
|
||||
return nil
|
||||
}
|
||||
case transport.FrameTypeBye:
|
||||
var bye transport.Bye
|
||||
_ = json.Unmarshal(raw, &bye)
|
||||
if !cfg.Quiet {
|
||||
fmt.Fprintf(cfg.Stderr, "bus closing: %s\n", bye.Reason)
|
||||
fmt.Fprintf(cfg.Stderr, "[event] bus closing: %s\n", bye.Reason)
|
||||
}
|
||||
reason = "bus_shutdown"
|
||||
return nil
|
||||
case transport.FrameTypeSourceState:
|
||||
if !cfg.Quiet {
|
||||
@@ -269,3 +342,35 @@ func isCtxCancelled(ctx context.Context) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// watchStdinEOF reads and discards stdin until EOF (or any read error),
|
||||
// then prints a self-explaining diagnostic and calls onEOF to trigger a
|
||||
// graceful shutdown. This implements the AI-subprocess contract: a parent
|
||||
// closes the child's stdin to stop it. It returns early if ctx is
|
||||
// cancelled first (the run ended for another reason), so it does not fire
|
||||
// a spurious shutdown. errOut is io.Discard under --quiet.
|
||||
func watchStdinEOF(ctx context.Context, r io.Reader, errOut io.Writer, onEOF func()) {
|
||||
buf := make([]byte, 512)
|
||||
for {
|
||||
if _, err := r.Read(buf); err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Run already ending; do not attribute this to stdin.
|
||||
default:
|
||||
fmt.Fprintln(errOut, "[event] stdin closed — shutting down. "+
|
||||
"consume treats stdin EOF as an exit signal (wired for AI subprocess callers). "+
|
||||
"To keep running: pass --max-events/--duration for a bounded run, "+
|
||||
"keep stdin open (`< <(tail -f /dev/null)` in a script), "+
|
||||
"or stop via SIGTERM instead of closing stdin.")
|
||||
onEOF()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Discard any data and keep reading until EOF.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,9 +163,9 @@ func TestRun_StdoutNDJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Stderr should have the connected-to-bus banner.
|
||||
if !strings.Contains(stderr.String(), "connected bus pid=") {
|
||||
t.Errorf("stderr missing connected banner:\n%s", stderr.String())
|
||||
// Stderr should carry the standardized ready marker.
|
||||
if !strings.Contains(stderr.String(), "[event] ready ") {
|
||||
t.Errorf("stderr missing ready marker:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,3 +309,156 @@ func TestRun_MultipleConsumersOneBus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- AI subprocess contract tests (event-subprocess-contract.md) ---
|
||||
|
||||
// T1a/T1b: the ready marker carries event_key and precedes the first
|
||||
// stdout event.
|
||||
func TestRun_ReadyMarkerContract(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir, sock, cancel, runDone, trigger := bringUpBus(t, []dwsevent.RawEvent{
|
||||
{EventID: "1", EventType: "im.message.receive_v1", Data: `{}`},
|
||||
})
|
||||
defer func() { cancel(); <-runDone }()
|
||||
go func() { time.Sleep(150 * time.Millisecond); close(trigger) }()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
ctx, cc := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cc()
|
||||
if err := Run(ctx, Config{
|
||||
WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test",
|
||||
Stdout: &stdout, Stderr: &stderr,
|
||||
EventKey: "im.message.receive_v1", MaxEvents: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
// T1a: ready line with event_key.
|
||||
if !strings.Contains(stderr.String(), "[event] ready event_key=im.message.receive_v1") {
|
||||
t.Errorf("missing ready marker with event_key:\n%s", stderr.String())
|
||||
}
|
||||
// T1b: ready is on stderr, the event is on stdout — the ready line is
|
||||
// emitted before Deliver writes the first stdout line. Assert stdout
|
||||
// got exactly the event and stderr got ready before exited.
|
||||
rIdx := strings.Index(stderr.String(), "[event] ready")
|
||||
xIdx := strings.Index(stderr.String(), "[event] exited")
|
||||
if rIdx < 0 || xIdx < 0 || rIdx > xIdx {
|
||||
t.Errorf("ready must precede exited:\n%s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "im.message.receive_v1") {
|
||||
t.Errorf("stdout missing the event:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// T3a: --max-events exit → reason=limit.
|
||||
func TestRun_ExitReasonLimit(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir, sock, cancel, runDone, trigger := bringUpBus(t, []dwsevent.RawEvent{
|
||||
{EventID: "1", EventType: "im.message.receive_v1", Data: `{}`},
|
||||
})
|
||||
defer func() { cancel(); <-runDone }()
|
||||
go func() { time.Sleep(150 * time.Millisecond); close(trigger) }()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
ctx, cc := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cc()
|
||||
if err := Run(ctx, Config{
|
||||
WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test",
|
||||
Stdout: &stdout, Stderr: &stderr, MaxEvents: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "reason: limit") {
|
||||
t.Errorf("expected reason: limit:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// T3b: --duration deadline with no events → reason=timeout, exit clean.
|
||||
func TestRun_ExitReasonTimeout(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
// Bring up bus but never trigger emission → consume blocks until the
|
||||
// duration deadline fires.
|
||||
dir, sock, cancel, runDone, _ := bringUpBus(t, nil)
|
||||
defer func() { cancel(); <-runDone }()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
ctx, cc := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cc()
|
||||
if err := Run(ctx, Config{
|
||||
WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test",
|
||||
Stdout: &stdout, Stderr: &stderr, Duration: 300 * time.Millisecond,
|
||||
}); err != nil {
|
||||
t.Fatalf("Run should exit clean on duration: %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "reason: timeout") {
|
||||
t.Errorf("expected reason: timeout:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// T3c: parent ctx cancel (signal) → reason=signal.
|
||||
func TestRun_ExitReasonSignal(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir, sock, cancel, runDone, _ := bringUpBus(t, nil)
|
||||
defer func() { cancel(); <-runDone }()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
ctx, cc := context.WithCancel(context.Background())
|
||||
go func() { time.Sleep(250 * time.Millisecond); cc() }()
|
||||
if err := Run(ctx, Config{
|
||||
WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test",
|
||||
Stdout: &stdout, Stderr: &stderr,
|
||||
}); err != nil {
|
||||
t.Fatalf("Run should exit clean on signal: %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "reason: signal") {
|
||||
t.Errorf("expected reason: signal:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// T2c: closing stdin triggers a graceful shutdown (reason=signal), with no
|
||||
// --max-events / --duration set.
|
||||
func TestRun_StdinEOFShutsDown(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir, sock, cancel, runDone, _ := bringUpBus(t, nil)
|
||||
defer func() { cancel(); <-runDone }()
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
go func() { time.Sleep(250 * time.Millisecond); _ = pw.Close() }() // EOF
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
ctx, cc := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cc()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- Run(ctx, Config{
|
||||
WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test",
|
||||
Stdout: &stdout, Stderr: &stderr, Stdin: pr,
|
||||
})
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("Run should exit clean on stdin EOF: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Run did not exit after stdin EOF")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "reason: signal") {
|
||||
t.Errorf("expected reason: signal on stdin EOF:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// T3d: a startup failure returns non-nil and emits NO `exited` line (the
|
||||
// cobra layer prints an Error: line instead).
|
||||
func TestRun_FailureHasNoExitedLine(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
var stderr bytes.Buffer
|
||||
// Missing WorkDir/IPCEndpoint/ClientID → immediate config error, before
|
||||
// the exit-reason machinery is armed.
|
||||
err := Run(context.Background(), Config{Stderr: &stderr})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing required config")
|
||||
}
|
||||
if strings.Contains(stderr.String(), "[event] exited") {
|
||||
t.Errorf("failure must not print an exited line:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1040,7 +1040,7 @@ func newChatCommand() *cobra.Command {
|
||||
Use: "chat",
|
||||
Aliases: []string{"im"},
|
||||
Short: "群聊 / 消息 / 机器人",
|
||||
Long: `管理钉钉会话与群聊:创建群、搜索群、查看群成员、添加机器人到群、修改群名称、拉取会话消息、发送群消息、机器人消息与 Webhook。`,
|
||||
Long: `管理钉钉会话与群聊:创建群、搜索群、查看群成员、添加机器人到群、修改群名称、拉取/发送/收藏会话消息、机器人消息与 Webhook。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
@@ -1326,7 +1326,12 @@ func newChatCommand() *cobra.Command {
|
||||
|
||||
// ── message 子命令 ────────────────────────────────────────
|
||||
|
||||
chatMessageCmd := &cobra.Command{Use: "message", Short: "会话消息管理", RunE: groupRunE}
|
||||
chatMessageCmd := &cobra.Command{
|
||||
Use: "message",
|
||||
Short: "会话消息管理",
|
||||
Long: `管理会话消息,包括拉取、发送、搜索、转发、钉住、收藏和撤回消息。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
chatMessageListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -4244,6 +4249,80 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
chatMessageListPinCmd.Flags().String("cursor", "", "分页游标(首次不传,翻页时传上次返回的 nextCursor)")
|
||||
chatMessageListPinCmd.Flags().Int("size", 0, "一次拉取的消息数量(默认 20,最大 100)")
|
||||
|
||||
// ── message favorites: 收藏/取消收藏/查询收藏消息 ──────────
|
||||
|
||||
chatMessageAddFavoriteCmd := &cobra.Command{
|
||||
Use: "add-favorite",
|
||||
Short: "收藏指定消息",
|
||||
Long: `收藏指定消息。消息 ID 和会话 ID 可从 chat message list 等消息查询命令的返回结果中获取。
|
||||
|
||||
该操作会修改当前用户的消息收藏状态。`,
|
||||
Example: ` dws chat message add-favorite --open-message-id <openMessageId> --open-conversation-id <openConversationId>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "open-message-id", "open-conversation-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("im", "add_message_favorite", map[string]any{
|
||||
"openMessageId": mustGetFlag(cmd, "open-message-id"),
|
||||
"openConversationId": mustGetFlag(cmd, "open-conversation-id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
chatMessageAddFavoriteCmd.Flags().String("open-message-id", "", "消息 openMessageId (必填)")
|
||||
_ = chatMessageAddFavoriteCmd.MarkFlagRequired("open-message-id")
|
||||
chatMessageAddFavoriteCmd.Flags().String("open-conversation-id", "", "消息所在会话的 openConversationId (必填,支持群聊/单聊)")
|
||||
_ = chatMessageAddFavoriteCmd.MarkFlagRequired("open-conversation-id")
|
||||
|
||||
chatMessageRemoveFavoriteCmd := &cobra.Command{
|
||||
Use: "remove-favorite",
|
||||
Short: "取消收藏指定消息",
|
||||
Long: `取消收藏指定消息。消息 ID 和会话 ID 可从 chat message list 等消息查询命令的返回结果中获取。
|
||||
|
||||
该操作只移除当前用户的收藏标记,不会删除原消息。`,
|
||||
Example: ` dws chat message remove-favorite --open-message-id <openMessageId> --open-conversation-id <openConversationId>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "open-message-id", "open-conversation-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("im", "remove_message_favorite", map[string]any{
|
||||
"openMessageId": mustGetFlag(cmd, "open-message-id"),
|
||||
"openConversationId": mustGetFlag(cmd, "open-conversation-id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
chatMessageRemoveFavoriteCmd.Flags().String("open-message-id", "", "消息 openMessageId (必填)")
|
||||
_ = chatMessageRemoveFavoriteCmd.MarkFlagRequired("open-message-id")
|
||||
chatMessageRemoveFavoriteCmd.Flags().String("open-conversation-id", "", "消息所在会话的 openConversationId (必填,支持群聊/单聊)")
|
||||
_ = chatMessageRemoveFavoriteCmd.MarkFlagRequired("open-conversation-id")
|
||||
|
||||
chatMessageListFavoritesCmd := &cobra.Command{
|
||||
Use: "list-favorites",
|
||||
Short: "查询收藏的消息列表",
|
||||
Long: `查询当前用户收藏的消息列表,支持数字游标分页。
|
||||
|
||||
首次请求可省略分页参数,CLI 会按 Open 服务契约传 cursor=0、size="20"。
|
||||
返回 hasMore=true 时,将 nextCursor 作为下一次的 --cursor。`,
|
||||
Example: ` dws chat message list-favorites
|
||||
dws chat message list-favorites --size 50
|
||||
dws chat message list-favorites --cursor 20 --size 20`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cursor, _ := cmd.Flags().GetInt64("cursor")
|
||||
if cursor < 0 {
|
||||
return apperrors.NewValidation("--cursor must be greater than or equal to 0")
|
||||
}
|
||||
size, _ := cmd.Flags().GetInt("size")
|
||||
if size < 1 || size > 100 {
|
||||
return apperrors.NewValidation("--size must be between 1 and 100")
|
||||
}
|
||||
return callMCPToolOnServer("im", "list_message_favorites", map[string]any{
|
||||
"cursor": cursor,
|
||||
"size": strconv.Itoa(size),
|
||||
})
|
||||
},
|
||||
}
|
||||
chatMessageListFavoritesCmd.Flags().Int64("cursor", 0, "数字分页游标(默认 0;翻页时传上次返回的 nextCursor)")
|
||||
chatMessageListFavoritesCmd.Flags().Int("size", 20, "一次拉取的收藏数量(默认 20,范围 1-100)")
|
||||
|
||||
// ── group list-my-groups: 拉取我创建/管理的群 ──────────────
|
||||
|
||||
chatGroupListMyGroupsCmd := &cobra.Command{
|
||||
@@ -5019,7 +5098,7 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
|
||||
chatGroupMembersCmd.AddCommand(chatGroupMembersRemoveBotCmd, chatGroupMembersListByIdsCmd)
|
||||
chatBotCmd.AddCommand(chatBotFindCmd)
|
||||
chatCategoryCmd.AddCommand(chatCategoryCreateSmartCmd)
|
||||
chatMessageCmd.AddCommand(chatMessageListDirectCmd, chatMessageSearchCommonCmd, chatMessageCombineForwardCmd, chatMessageForwardTopicCmd, chatMessageSetPinCmd, chatMessageUnsetPinCmd, chatMessageListPinCmd, chatMessageSetTopMsgCmd, chatMessageUnsetTopMsgCmd, chatMessageListEmotionRepliesCmd)
|
||||
chatMessageCmd.AddCommand(chatMessageListDirectCmd, chatMessageSearchCommonCmd, chatMessageCombineForwardCmd, chatMessageForwardTopicCmd, chatMessageSetPinCmd, chatMessageUnsetPinCmd, chatMessageListPinCmd, chatMessageAddFavoriteCmd, chatMessageRemoveFavoriteCmd, chatMessageListFavoritesCmd, chatMessageSetTopMsgCmd, chatMessageUnsetTopMsgCmd, chatMessageListEmotionRepliesCmd)
|
||||
|
||||
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd)
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type chatFavoritesCall struct {
|
||||
productID string
|
||||
toolName string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type chatFavoritesCaller struct {
|
||||
calls []chatFavoritesCall
|
||||
}
|
||||
|
||||
func (c *chatFavoritesCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, chatFavoritesCall{productID: productID, toolName: toolName, args: args})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
|
||||
}
|
||||
|
||||
func (*chatFavoritesCaller) Format() string { return "json" }
|
||||
func (*chatFavoritesCaller) DryRun() bool { return false }
|
||||
func (*chatFavoritesCaller) Fields() string { return "" }
|
||||
func (*chatFavoritesCaller) JQ() string { return "" }
|
||||
|
||||
func executeChatFavoritesCommand(t *testing.T, caller *chatFavoritesCaller, args ...string) error {
|
||||
t.Helper()
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
root := newChatCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestChatFavoritesCommandsRegistered(t *testing.T) {
|
||||
root := newChatCommand()
|
||||
tests := []struct {
|
||||
name string
|
||||
flags []string
|
||||
}{
|
||||
{name: "add-favorite", flags: []string{"open-message-id", "open-conversation-id"}},
|
||||
{name: "remove-favorite", flags: []string{"open-message-id", "open-conversation-id"}},
|
||||
{name: "list-favorites", flags: []string{"cursor", "size"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
cmd, remaining, err := root.Find([]string{"message", tt.name})
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("dws chat message %s not registered: cmd=%v remaining=%v err=%v", tt.name, cmd, remaining, err)
|
||||
}
|
||||
for _, flag := range tt.flags {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("chat message %s: missing flag --%s", tt.name, flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatFavoriteMutationMappings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
command string
|
||||
toolName string
|
||||
}{
|
||||
{name: "add", command: "add-favorite", toolName: "add_message_favorite"},
|
||||
{name: "remove", command: "remove-favorite", toolName: "remove_message_favorite"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &chatFavoritesCaller{}
|
||||
err := executeChatFavoritesCommand(t, caller,
|
||||
"message", tt.command,
|
||||
"--open-message-id", "msg-1",
|
||||
"--open-conversation-id", "cid-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat message %s returned error: %v", tt.command, err)
|
||||
}
|
||||
|
||||
want := chatFavoritesCall{
|
||||
productID: "im",
|
||||
toolName: tt.toolName,
|
||||
args: map[string]any{
|
||||
"openMessageId": "msg-1",
|
||||
"openConversationId": "cid-1",
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatFavoriteMutationsRequireBothIDs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "add missing message", args: []string{"message", "add-favorite", "--open-conversation-id", "cid-1"}},
|
||||
{name: "add missing conversation", args: []string{"message", "add-favorite", "--open-message-id", "msg-1"}},
|
||||
{name: "remove missing message", args: []string{"message", "remove-favorite", "--open-conversation-id", "cid-1"}},
|
||||
{name: "remove missing conversation", args: []string{"message", "remove-favorite", "--open-message-id", "msg-1"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &chatFavoritesCaller{}
|
||||
err := executeChatFavoritesCommand(t, caller, tt.args...)
|
||||
if err == nil {
|
||||
t.Fatal("command without all required IDs returned nil error")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatListFavoritesSuppliesOpenDefaults(t *testing.T) {
|
||||
caller := &chatFavoritesCaller{}
|
||||
if err := executeChatFavoritesCommand(t, caller, "message", "list-favorites"); err != nil {
|
||||
t.Fatalf("list-favorites returned error: %v", err)
|
||||
}
|
||||
|
||||
want := chatFavoritesCall{
|
||||
productID: "im",
|
||||
toolName: "list_message_favorites",
|
||||
args: map[string]any{
|
||||
"cursor": int64(0),
|
||||
"size": "20",
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatListFavoritesMapsExplicitPagination(t *testing.T) {
|
||||
caller := &chatFavoritesCaller{}
|
||||
err := executeChatFavoritesCommand(t, caller,
|
||||
"message", "list-favorites", "--cursor", "42", "--size", "50")
|
||||
if err != nil {
|
||||
t.Fatalf("list-favorites returned error: %v", err)
|
||||
}
|
||||
|
||||
want := map[string]any{"cursor": int64(42), "size": "50"}
|
||||
if len(caller.calls) != 1 || caller.calls[0].productID != "im" || caller.calls[0].toolName != "list_message_favorites" || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("calls = %#v, want im/list_message_favorites %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatListFavoritesRejectsInvalidSize(t *testing.T) {
|
||||
for _, size := range []string{"-1", "0", "101"} {
|
||||
t.Run(size, func(t *testing.T) {
|
||||
caller := &chatFavoritesCaller{}
|
||||
err := executeChatFavoritesCommand(t, caller, "message", "list-favorites", "--size", size)
|
||||
if err == nil || !strings.Contains(err.Error(), "--size must be between 1 and 100") {
|
||||
t.Fatalf("error = %v, want size validation error", err)
|
||||
}
|
||||
if got := apperrors.ExitCode(err); got != 3 {
|
||||
t.Fatalf("exit code = %d, want validation code 3", got)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatListFavoritesRejectsNegativeCursor(t *testing.T) {
|
||||
caller := &chatFavoritesCaller{}
|
||||
err := executeChatFavoritesCommand(t, caller, "message", "list-favorites", "--cursor", "-1")
|
||||
if err == nil || !strings.Contains(err.Error(), "--cursor must be greater than or equal to 0") {
|
||||
t.Fatalf("error = %v, want cursor validation error", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
connectVideoStoryboardFrames = 12
|
||||
connectVideoStoryboardWidth = 480
|
||||
connectVideoStoryboardMax = 16 << 20
|
||||
)
|
||||
|
||||
var generateConnectVideoStoryboard = buildConnectVideoStoryboard
|
||||
|
||||
// prepareOpenCodeAttachments adapts media that OpenCode's file-part bridge
|
||||
// cannot safely send to the provider. OpenCode expands video files into the
|
||||
// request in-process (a real 267 MiB merged-forward recording caused Bun to
|
||||
// OOM), and the current bridge does not submit video/* as native multimodal
|
||||
// video even when the selected model supports it. A full-duration, evenly
|
||||
// sampled storyboard gives the visual model the actual sequence without
|
||||
// sacrificing the separately downloaded original file.
|
||||
//
|
||||
// The selected iDEALab model has no audio input modality. DingTalk's message
|
||||
// API normally supplies a speech-recognition transcript in the recovered
|
||||
// prompt, so the opaque audio bytes are omitted from OpenCode's file parts
|
||||
// instead of provoking a binary-file error.
|
||||
func prepareOpenCodeAttachments(ctx context.Context, prompt string, attachments []connectMediaAttachment) (string, []connectMediaAttachment) {
|
||||
prepared := make([]connectMediaAttachment, 0, len(attachments))
|
||||
for _, attachment := range attachments {
|
||||
mediaType := inboundMediaType(attachment.MediaType)
|
||||
switch mediaType {
|
||||
case "video":
|
||||
storyboard, err := generateConnectVideoStoryboard(ctx, attachment.LocalPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] OpenCode 视频故事板生成失败,跳过不安全的原视频 file part: %v\n", err)
|
||||
prompt += "\n(原视频已完整下载,但当前 OpenCode 无法安全提交视频,且关键帧故事板生成失败;请明确告知用户本轮未能读取视频画面。)"
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(strings.TrimSpace(attachment.FileName), filepath.Ext(attachment.FileName))
|
||||
if name == "" {
|
||||
name = "转发视频"
|
||||
}
|
||||
proxy := connectMediaAttachment{
|
||||
LocalPath: storyboard,
|
||||
FileName: name + ".storyboard.jpg",
|
||||
MediaType: "image",
|
||||
}
|
||||
prompt = strings.ReplaceAll(prompt, attachment.LocalPath, storyboard)
|
||||
prompt += "\n(原视频已完整下载;为避免 OpenCode 展开大视频导致内存溢出,已按完整时长均匀抽取 12 帧并生成故事板图片。请按从左到右、从上到下的时间顺序分析画面。)"
|
||||
prepared = append(prepared, proxy)
|
||||
if info, err := os.Stat(attachment.LocalPath); err == nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] OpenCode 视频故事板已生成: 原始=%d 字节 故事板=%s\n", info.Size(), storyboard)
|
||||
}
|
||||
case "audio":
|
||||
prompt = strings.ReplaceAll(prompt, attachment.LocalPath, "[语音原文件已完整下载,当前模型使用钉钉转写]")
|
||||
prompt += "\n(语音原文件已完整下载;当前 OpenCode 模型不接收音频 file part,请优先依据上述钉钉语音转写处理。)"
|
||||
default:
|
||||
prepared = append(prepared, attachment)
|
||||
}
|
||||
}
|
||||
return prompt, prepared
|
||||
}
|
||||
|
||||
func buildConnectVideoStoryboard(ctx context.Context, videoPath string) (string, error) {
|
||||
ffmpegPath, err := exec.LookPath("ffmpeg")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("未安装 ffmpeg")
|
||||
}
|
||||
ffprobePath, err := exec.LookPath("ffprobe")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("未安装 ffprobe")
|
||||
}
|
||||
|
||||
probe := exec.CommandContext(ctx, ffprobePath,
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
videoPath,
|
||||
)
|
||||
rawDuration, err := probe.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ffprobe 读取视频时长失败: %w", err)
|
||||
}
|
||||
duration, err := strconv.ParseFloat(strings.TrimSpace(string(rawDuration)), 64)
|
||||
if err != nil || duration <= 0 {
|
||||
return "", fmt.Errorf("ffprobe 返回无效视频时长 %q", strings.TrimSpace(string(rawDuration)))
|
||||
}
|
||||
interval := duration / connectVideoStoryboardFrames
|
||||
if interval < 0.5 {
|
||||
interval = 0.5
|
||||
}
|
||||
|
||||
dir := filepath.Join(os.TempDir(), "dws-connect-media")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
dest := filepath.Join(dir, uuid.NewString()+".storyboard.jpg")
|
||||
filter := fmt.Sprintf("fps=1/%.6f,scale=%d:-2,tile=4x3:padding=4:margin=4", interval, connectVideoStoryboardWidth)
|
||||
cmd := exec.CommandContext(ctx, ffmpegPath,
|
||||
"-nostdin", "-hide_banner", "-loglevel", "error", "-y",
|
||||
"-i", videoPath,
|
||||
"-vf", filter,
|
||||
"-frames:v", "1",
|
||||
"-q:v", "3",
|
||||
dest,
|
||||
)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
_ = os.Remove(dest)
|
||||
return "", fmt.Errorf("ffmpeg 生成视频故事板失败: %w (%s)", err, truncateRunes(strings.TrimSpace(string(output)), 300))
|
||||
}
|
||||
info, err := os.Stat(dest)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("视频故事板未生成: %w", err)
|
||||
}
|
||||
if info.Size() <= 0 || info.Size() > connectVideoStoryboardMax {
|
||||
_ = os.Remove(dest)
|
||||
return "", fmt.Errorf("视频故事板大小异常: %d 字节", info.Size())
|
||||
}
|
||||
return dest, nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPrepareOpenCodeAttachmentsUsesStoryboardAndTranscript(t *testing.T) {
|
||||
previous := generateConnectVideoStoryboard
|
||||
t.Cleanup(func() { generateConnectVideoStoryboard = previous })
|
||||
storyboard := filepath.Join(t.TempDir(), "storyboard.jpg")
|
||||
if err := os.WriteFile(storyboard, []byte("jpeg"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
generateConnectVideoStoryboard = func(_ context.Context, path string) (string, error) {
|
||||
if path != "/tmp/original.mov" {
|
||||
t.Fatalf("video path = %q", path)
|
||||
}
|
||||
return storyboard, nil
|
||||
}
|
||||
|
||||
prompt, attachments := prepareOpenCodeAttachments(context.Background(),
|
||||
"视频路径 /tmp/original.mov;语音路径 /tmp/original.ogg;转写:你是谁?",
|
||||
[]connectMediaAttachment{
|
||||
{LocalPath: "/tmp/picture.jpg", FileName: "picture.jpg", MediaType: "image"},
|
||||
{LocalPath: "/tmp/original.ogg", FileName: "voice.ogg", MediaType: "audio"},
|
||||
{LocalPath: "/tmp/original.mov", FileName: "demo.mov", MediaType: "video"},
|
||||
{LocalPath: "/tmp/report.md", FileName: "report.md", MediaType: "file"},
|
||||
},
|
||||
)
|
||||
if len(attachments) != 3 {
|
||||
t.Fatalf("attachments = %#v, want image + video storyboard + file (audio omitted)", attachments)
|
||||
}
|
||||
if attachments[0].LocalPath != "/tmp/picture.jpg" || attachments[1].LocalPath != storyboard || attachments[1].MediaType != "image" || attachments[2].LocalPath != "/tmp/report.md" {
|
||||
t.Fatalf("attachments = %#v", attachments)
|
||||
}
|
||||
if strings.Contains(prompt, "/tmp/original.mov") || strings.Contains(prompt, "/tmp/original.ogg") {
|
||||
t.Fatalf("prompt still points OpenCode at unsupported original binary: %q", prompt)
|
||||
}
|
||||
for _, want := range []string{storyboard, "12 帧", "钉钉转写", "你是谁?"} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("prompt missing %q: %q", want, prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareOpenCodeAttachmentsDoesNotSubmitVideoWhenStoryboardFails(t *testing.T) {
|
||||
previous := generateConnectVideoStoryboard
|
||||
t.Cleanup(func() { generateConnectVideoStoryboard = previous })
|
||||
generateConnectVideoStoryboard = func(context.Context, string) (string, error) {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
prompt, attachments := prepareOpenCodeAttachments(context.Background(), "请看视频", []connectMediaAttachment{
|
||||
{LocalPath: "/tmp/original.mov", FileName: "demo.mov", MediaType: "video"},
|
||||
})
|
||||
if len(attachments) != 0 || !strings.Contains(prompt, "未能读取视频画面") {
|
||||
t.Fatalf("prompt=%q attachments=%#v", prompt, attachments)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// chatRecordLookup records which entries DingTalk erased to unknownMsgType in
|
||||
// a Stream callback. The indexes line up with forwardMessages returned by the
|
||||
// user-state message API, which still exposes the original message metadata.
|
||||
type chatRecordLookup struct {
|
||||
MsgID string
|
||||
UnknownIndexes []int
|
||||
}
|
||||
|
||||
type chatRecordToolCall func(context.Context, string, string, map[string]any) (string, error)
|
||||
|
||||
type chatRecordMessage struct {
|
||||
OpenMessageID string `json:"openMessageId"`
|
||||
OpenConversationID string `json:"openConversationId"`
|
||||
CreateTime string `json:"createTime"`
|
||||
Content string `json:"content"`
|
||||
ForwardMessages []chatRecordMessage `json:"forwardMessages"`
|
||||
}
|
||||
|
||||
type chatRecordMessagesEnvelope struct {
|
||||
Result struct {
|
||||
Messages []chatRecordMessage `json:"messages"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
type chatRecordEnrichment struct {
|
||||
Prompt string
|
||||
Files []fileInboundInfo
|
||||
MissingCount int
|
||||
}
|
||||
|
||||
var (
|
||||
chatRecordMediaIDPattern = regexp.MustCompile(`\(?mediaId=([^\s)]+)\)?`)
|
||||
chatRecordFileIDPattern = regexp.MustCompile(`(?i)\bfileId:\s*([^\s]+)`)
|
||||
chatRecordFileNamePattern = regexp.MustCompile(`(?m)^\[文件\]\s*(.*?)(?:\s+fileId:|$)`)
|
||||
chatRecordDownloadHintPattern = regexp.MustCompile(`\s*注意:如需下载使用dws\s+(?:chat message download-media|drive download)命令下载\s*`)
|
||||
)
|
||||
|
||||
func chatRecordEntries(content interface{}) []interface{} {
|
||||
m, ok := content.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if entries, ok := m["chatRecord"].([]interface{}); ok {
|
||||
return entries
|
||||
}
|
||||
if raw, ok := m["chatRecord"].(string); ok {
|
||||
var entries []interface{}
|
||||
if json.Unmarshal([]byte(raw), &entries) == nil {
|
||||
return entries
|
||||
}
|
||||
}
|
||||
if entries, ok := m["contents"].([]interface{}); ok {
|
||||
return entries
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func chatRecordUnknownIndexes(content interface{}) []int {
|
||||
entries := chatRecordEntries(content)
|
||||
indexes := make([]int, 0)
|
||||
for i, entry := range entries {
|
||||
node, ok := entry.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, key := range []string{"msgType", "msgtype", "type"} {
|
||||
if value, ok := node[key].(string); ok && strings.EqualFold(strings.TrimSpace(value), "unknownMsgType") {
|
||||
info := parseTypedFileInbound("file", node)
|
||||
if !info.hasActionable() {
|
||||
indexes = append(indexes, i)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return indexes
|
||||
}
|
||||
|
||||
// recoverChatRecordUnknowns resolves only the entries that were
|
||||
// unknownMsgType in the Stream callback. It deliberately runs after the
|
||||
// callback returns, so user-state MCP lookups cannot delay DingTalk's ACK.
|
||||
func recoverChatRecordUnknowns(ctx context.Context, lookup chatRecordLookup, call chatRecordToolCall) (chatRecordEnrichment, error) {
|
||||
var enrichment chatRecordEnrichment
|
||||
if strings.TrimSpace(lookup.MsgID) == "" || len(lookup.UnknownIndexes) == 0 {
|
||||
return enrichment, nil
|
||||
}
|
||||
|
||||
raw, err := call(ctx, "im", "list_messages_by_ids", map[string]any{
|
||||
"openMsgIds": []string{strings.TrimSpace(lookup.MsgID)},
|
||||
})
|
||||
if err != nil {
|
||||
return enrichment, fmt.Errorf("查询合并转发消息: %w", err)
|
||||
}
|
||||
envelope, err := parseChatRecordMessages(raw)
|
||||
if err != nil {
|
||||
return enrichment, fmt.Errorf("解析合并转发消息: %w", err)
|
||||
}
|
||||
var outer *chatRecordMessage
|
||||
for i := range envelope.Result.Messages {
|
||||
if envelope.Result.Messages[i].OpenMessageID == strings.TrimSpace(lookup.MsgID) {
|
||||
outer = &envelope.Result.Messages[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if outer == nil && len(envelope.Result.Messages) == 1 {
|
||||
outer = &envelope.Result.Messages[0]
|
||||
}
|
||||
if outer == nil {
|
||||
return enrichment, fmt.Errorf("未找到外层消息 %s", strings.TrimSpace(lookup.MsgID))
|
||||
}
|
||||
|
||||
indexes := uniqueValidIndexes(lookup.UnknownIndexes, len(outer.ForwardMessages))
|
||||
if len(indexes) == 0 {
|
||||
return enrichment, fmt.Errorf("unknownMsgType 索引超出转发消息范围")
|
||||
}
|
||||
|
||||
// list_messages_by_ids keeps mediaId for images/audio but can omit fileId
|
||||
// and even stamp forwarded files with the outer conversation ID. Query every
|
||||
// candidate source conversation visible in the same record and match by the
|
||||
// stable inner openMessageId to recover the actual fileId.
|
||||
resolvedByID := make(map[string]chatRecordMessage)
|
||||
needsFileLookup := false
|
||||
for _, index := range indexes {
|
||||
message := outer.ForwardMessages[index]
|
||||
resolvedByID[message.OpenMessageID] = message
|
||||
if looksLikeForwardedFile(message.Content) && chatRecordFileID(message.Content) == "" {
|
||||
needsFileLookup = true
|
||||
}
|
||||
}
|
||||
if needsFileLookup {
|
||||
enrichForwardedFileLocators(ctx, outer.ForwardMessages, resolvedByID, call)
|
||||
}
|
||||
|
||||
lines := []string{"已通过钉钉消息接口补拉到合并转发中原先标记为 unknownMsgType 的内容:"}
|
||||
for _, index := range indexes {
|
||||
message := outer.ForwardMessages[index]
|
||||
if resolved, ok := resolvedByID[message.OpenMessageID]; ok {
|
||||
message = resolved
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%d. %s", index+1, humanChatRecordContent(message.Content)))
|
||||
info, ok := recoveredForwardAttachment(message)
|
||||
if !ok || !info.hasActionable() {
|
||||
if looksLikeForwardedAttachment(message.Content) {
|
||||
enrichment.MissingCount++
|
||||
}
|
||||
continue
|
||||
}
|
||||
enrichment.Files = append(enrichment.Files, info)
|
||||
}
|
||||
enrichment.Prompt = strings.Join(lines, "\n")
|
||||
return enrichment, nil
|
||||
}
|
||||
|
||||
func parseChatRecordMessages(raw string) (chatRecordMessagesEnvelope, error) {
|
||||
var envelope chatRecordMessagesEnvelope
|
||||
if err := json.Unmarshal([]byte(raw), &envelope); err != nil {
|
||||
return envelope, err
|
||||
}
|
||||
return envelope, nil
|
||||
}
|
||||
|
||||
func uniqueValidIndexes(indexes []int, length int) []int {
|
||||
seen := make(map[int]struct{}, len(indexes))
|
||||
out := make([]int, 0, len(indexes))
|
||||
for _, index := range indexes {
|
||||
if index < 0 || index >= length {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[index]; ok {
|
||||
continue
|
||||
}
|
||||
seen[index] = struct{}{}
|
||||
out = append(out, index)
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func enrichForwardedFileLocators(ctx context.Context, all []chatRecordMessage, resolved map[string]chatRecordMessage, call chatRecordToolCall) {
|
||||
candidates := make(map[string]struct{})
|
||||
var earliest time.Time
|
||||
for _, message := range all {
|
||||
if conversationID := strings.TrimSpace(message.OpenConversationID); conversationID != "" {
|
||||
candidates[conversationID] = struct{}{}
|
||||
}
|
||||
if parsed, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(message.CreateTime), time.Local); err == nil && (earliest.IsZero() || parsed.Before(earliest)) {
|
||||
earliest = parsed
|
||||
}
|
||||
}
|
||||
if earliest.IsZero() {
|
||||
return
|
||||
}
|
||||
start := earliest.Add(-time.Minute).Format("2006-01-02 15:04:05")
|
||||
for conversationID := range candidates {
|
||||
raw, err := call(ctx, "chat", "list_conversation_message_v2", map[string]any{
|
||||
"openconversation_id": conversationID,
|
||||
"time": start,
|
||||
"forward": true,
|
||||
"limit": 50,
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
envelope, err := parseChatRecordMessages(raw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, message := range envelope.Result.Messages {
|
||||
current, wanted := resolved[message.OpenMessageID]
|
||||
if !wanted || chatRecordFileID(message.Content) == "" {
|
||||
continue
|
||||
}
|
||||
current.Content = message.Content
|
||||
current.OpenConversationID = message.OpenConversationID
|
||||
if strings.TrimSpace(current.CreateTime) == "" {
|
||||
current.CreateTime = message.CreateTime
|
||||
}
|
||||
resolved[message.OpenMessageID] = current
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func recoveredForwardAttachment(message chatRecordMessage) (fileInboundInfo, bool) {
|
||||
content := strings.TrimSpace(message.Content)
|
||||
info := fileInboundInfo{
|
||||
OpenMessageID: strings.TrimSpace(message.OpenMessageID),
|
||||
OpenConversationID: strings.TrimSpace(message.OpenConversationID),
|
||||
}
|
||||
if mediaID := chatRecordMediaID(content); mediaID != "" {
|
||||
info.MediaID = mediaID
|
||||
switch {
|
||||
case strings.Contains(content, "[图片消息]"):
|
||||
info.MediaType, info.FileName = "image", "转发图片"
|
||||
case strings.Contains(content, "[语音消息]"):
|
||||
info.MediaType, info.FileName = "audio", "转发语音.bin"
|
||||
case strings.Contains(content, "[视频消息]"):
|
||||
info.MediaType, info.FileName = "video", "转发视频.bin"
|
||||
default:
|
||||
info.MediaType, info.FileName = "file", "转发媒体.bin"
|
||||
}
|
||||
return info, true
|
||||
}
|
||||
if !looksLikeForwardedFile(content) {
|
||||
return fileInboundInfo{}, false
|
||||
}
|
||||
info.FileID = chatRecordFileID(content)
|
||||
info.FileName = chatRecordFileName(content)
|
||||
if info.FileName == "" {
|
||||
info.FileName = "转发文件"
|
||||
}
|
||||
info.MediaType = mediaTypeFromFileName(info.FileName)
|
||||
return info, true
|
||||
}
|
||||
|
||||
func chatRecordMediaID(content string) string {
|
||||
match := chatRecordMediaIDPattern.FindStringSubmatch(content)
|
||||
if len(match) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(match[1])
|
||||
}
|
||||
|
||||
func chatRecordFileID(content string) string {
|
||||
match := chatRecordFileIDPattern.FindStringSubmatch(content)
|
||||
if len(match) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(match[1])
|
||||
}
|
||||
|
||||
func chatRecordFileName(content string) string {
|
||||
match := chatRecordFileNamePattern.FindStringSubmatch(content)
|
||||
if len(match) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(match[1])
|
||||
}
|
||||
|
||||
func looksLikeForwardedFile(content string) bool {
|
||||
return strings.Contains(content, "[文件]")
|
||||
}
|
||||
|
||||
func looksLikeForwardedAttachment(content string) bool {
|
||||
return looksLikeForwardedFile(content) || chatRecordMediaID(content) != ""
|
||||
}
|
||||
|
||||
func humanChatRecordContent(content string) string {
|
||||
content = chatRecordDownloadHintPattern.ReplaceAllString(content, "")
|
||||
content = chatRecordMediaIDPattern.ReplaceAllString(content, "")
|
||||
content = chatRecordFileIDPattern.ReplaceAllString(content, "")
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return "[无法提取文字内容]"
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func mediaTypeFromFileName(fileName string) string {
|
||||
switch strings.ToLower(filepath.Ext(strings.TrimSpace(fileName))) {
|
||||
case ".mp4", ".mov", ".m4v", ".avi", ".mkv", ".webm":
|
||||
return "video"
|
||||
case ".mp3", ".m4a", ".aac", ".wav", ".amr", ".ogg", ".flac":
|
||||
return "audio"
|
||||
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".heic":
|
||||
return "image"
|
||||
default:
|
||||
return "file"
|
||||
}
|
||||
}
|
||||
|
||||
func (c *aiCardClient) downloadRecoveredChatRecordFile(ctx context.Context, info fileInboundInfo) (string, error) {
|
||||
return c.downloadRecoveredChatRecordFileWithCall(ctx, info, callMCPToolReturnTextOnServer)
|
||||
}
|
||||
|
||||
func (c *aiCardClient) downloadRecoveredChatRecordFileWithCall(ctx context.Context, info fileInboundInfo, call chatRecordToolCall) (string, error) {
|
||||
var raw string
|
||||
var err error
|
||||
switch {
|
||||
case strings.TrimSpace(info.MediaID) != "":
|
||||
raw, err = call(ctx, "im", "get_resource_download_url", map[string]any{
|
||||
"resourceType": "mediaId",
|
||||
"resourceId": strings.TrimSpace(info.MediaID),
|
||||
"openMessageId": strings.TrimSpace(info.OpenMessageID),
|
||||
"openConversationId": strings.TrimSpace(info.OpenConversationID),
|
||||
})
|
||||
case strings.TrimSpace(info.FileID) != "":
|
||||
raw, err = call(ctx, "drive", "download_file", map[string]any{
|
||||
"fileId": strings.TrimSpace(info.FileID),
|
||||
})
|
||||
default:
|
||||
return "", fmt.Errorf("转发附件缺少 mediaId/fileId")
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resourceURL, headers, err := parseDownloadInfo(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return downloadConnectURLToTemp(ctx, c.httpClient, resourceURL, headers, info.FileName)
|
||||
}
|
||||
|
||||
func downloadConnectURLToTemp(ctx context.Context, client *http.Client, resourceURL string, headers map[string]string, fileName string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
resp, err := connectMediaDownloadClient(client).Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("转发附件下载 HTTP %d", resp.StatusCode)
|
||||
}
|
||||
if resp.ContentLength > mediaMaxDownloadBytes {
|
||||
return "", fmt.Errorf("转发附件过大:%d 字节,最大允许 %d 字节", resp.ContentLength, mediaMaxDownloadBytes)
|
||||
}
|
||||
dir := filepath.Join(os.TempDir(), "dws-connect-media")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ext := filepath.Ext(filepath.Base(strings.TrimSpace(fileName)))
|
||||
if ext == "" {
|
||||
ext = mediaExt(resourceURL, resp.Header.Get("Content-Type"))
|
||||
}
|
||||
dest := filepath.Join(dir, uuid.NewString()+ext)
|
||||
if err := writeCompleteMediaFile(dest, resp.Body); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dest, nil
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChatRecordUnknownIndexesMatchesObservedCallback(t *testing.T) {
|
||||
content := map[string]interface{}{
|
||||
"chatRecord": `[{"msgType":"picture","downloadCode":"pic-live"},{"msgType":"unknownMsgType"},{"msgType":"unknownMsgType"},{"msgType":"text","content":"[合并的聊天记录]"},{"msgType":"unknownMsgType"}]`,
|
||||
}
|
||||
if got, want := chatRecordUnknownIndexes(content), []int{1, 2, 4}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("unknown indexes = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverChatRecordUnknownsRestoresAudioVideoAndFile(t *testing.T) {
|
||||
outer := `{
|
||||
"result": {"messages": [{
|
||||
"openMessageId": "outer-1",
|
||||
"openConversationId": "outer-conv",
|
||||
"createTime": "2026-07-14 16:16:44",
|
||||
"forwardMessages": [
|
||||
{"openMessageId":"pic-1","openConversationId":"source-conv","createTime":"2026-07-14 14:33:15","content":"[图片消息](mediaId=picture-media) 注意:如需下载使用dws chat message download-media命令下载"},
|
||||
{"openMessageId":"audio-1","openConversationId":"source-conv","createTime":"2026-07-14 14:33:19","content":"你是谁?你是谁\n[语音消息](mediaId=audio-media) 注意:如需下载使用dws chat message download-media命令下载"},
|
||||
{"openMessageId":"video-1","openConversationId":"outer-conv","createTime":"2026-07-14 14:33:36","content":"[文件] 录屏.mov"},
|
||||
{"openMessageId":"text-1","openConversationId":"source-conv","createTime":"2026-07-14 14:34:47","content":"普通文本"},
|
||||
{"openMessageId":"file-1","openConversationId":"outer-conv","createTime":"2026-07-14 14:34:57","content":"[文件] 巡检报告(1)(1).md"}
|
||||
]
|
||||
}]}
|
||||
}`
|
||||
sourceConversation := `{
|
||||
"result": {"messages": [
|
||||
{"openMessageId":"video-1","openConversationId":"source-conv","createTime":"2026-07-14 14:33:36","content":"[文件] 录屏.mov fileId: video-file-id 注意:如需下载使用dws drive download命令下载"},
|
||||
{"openMessageId":"file-1","openConversationId":"source-conv","createTime":"2026-07-14 14:34:57","content":"[文件] 巡检报告.md fileId: markdown-file-id 注意:如需下载使用dws drive download命令下载"}
|
||||
]}
|
||||
}`
|
||||
|
||||
var calls []string
|
||||
call := func(_ context.Context, server, tool string, args map[string]any) (string, error) {
|
||||
calls = append(calls, server+"."+tool)
|
||||
switch tool {
|
||||
case "list_messages_by_ids":
|
||||
if server != "im" || !reflect.DeepEqual(args["openMsgIds"], []string{"outer-1"}) {
|
||||
t.Fatalf("list_messages_by_ids route/args = %s %#v", server, args)
|
||||
}
|
||||
return outer, nil
|
||||
case "list_conversation_message_v2":
|
||||
if server != "chat" || args["forward"] != true || args["limit"] != 50 {
|
||||
t.Fatalf("list_conversation_message_v2 route/args = %s %#v", server, args)
|
||||
}
|
||||
if args["openconversation_id"] == "source-conv" {
|
||||
return sourceConversation, nil
|
||||
}
|
||||
return `{"result":{"messages":[]}}`, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unexpected tool %s", tool)
|
||||
}
|
||||
}
|
||||
|
||||
enrichment, err := recoverChatRecordUnknowns(context.Background(), chatRecordLookup{
|
||||
MsgID: "outer-1",
|
||||
UnknownIndexes: []int{1, 2, 4},
|
||||
}, call)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if enrichment.MissingCount != 0 || len(enrichment.Files) != 3 {
|
||||
t.Fatalf("enrichment = %#v, want 3 recovered files and no missing attachment", enrichment)
|
||||
}
|
||||
wants := []fileInboundInfo{
|
||||
{MediaID: "audio-media", OpenMessageID: "audio-1", OpenConversationID: "source-conv", FileName: "转发语音.bin", MediaType: "audio"},
|
||||
{FileID: "video-file-id", OpenMessageID: "video-1", OpenConversationID: "source-conv", FileName: "录屏.mov", MediaType: "video"},
|
||||
{FileID: "markdown-file-id", OpenMessageID: "file-1", OpenConversationID: "source-conv", FileName: "巡检报告.md", MediaType: "file"},
|
||||
}
|
||||
for i, want := range wants {
|
||||
if !reflect.DeepEqual(enrichment.Files[i], want) {
|
||||
t.Fatalf("files[%d] = %#v, want %#v", i, enrichment.Files[i], want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(enrichment.Prompt, "你是谁?你是谁") || !strings.Contains(enrichment.Prompt, "录屏.mov") || !strings.Contains(enrichment.Prompt, "巡检报告.md") {
|
||||
t.Fatalf("prompt did not preserve recovered user content: %q", enrichment.Prompt)
|
||||
}
|
||||
if strings.Contains(enrichment.Prompt, "mediaId") || strings.Contains(enrichment.Prompt, "fileId") || strings.Contains(enrichment.Prompt, "dws drive") {
|
||||
t.Fatalf("prompt leaked transport locators/instructions: %q", enrichment.Prompt)
|
||||
}
|
||||
if len(calls) != 3 || calls[0] != "im.list_messages_by_ids" {
|
||||
t.Fatalf("calls = %v, want outer lookup plus both candidate conversations", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverChatRecordUnknownsKeepsMetadataWhenFileLocatorMissing(t *testing.T) {
|
||||
outer := `{"result":{"messages":[{"openMessageId":"outer-1","forwardMessages":[{"openMessageId":"file-1","openConversationId":"wrong-conv","createTime":"2026-07-14 14:34:57","content":"[文件] report.md"}]}]}}`
|
||||
call := func(_ context.Context, _, tool string, _ map[string]any) (string, error) {
|
||||
if tool == "list_messages_by_ids" {
|
||||
return outer, nil
|
||||
}
|
||||
return `{"result":{"messages":[]}}`, nil
|
||||
}
|
||||
enrichment, err := recoverChatRecordUnknowns(context.Background(), chatRecordLookup{MsgID: "outer-1", UnknownIndexes: []int{0}}, call)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if enrichment.MissingCount != 1 || len(enrichment.Files) != 0 || !strings.Contains(enrichment.Prompt, "report.md") {
|
||||
t.Fatalf("enrichment = %#v, want metadata prompt plus one honestly missing attachment", enrichment)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadRecoveredChatRecordFileRoutesAndPreservesOriginalBytes(t *testing.T) {
|
||||
wantBody := []byte("original attachment bytes")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Signed-Test") != "yes" {
|
||||
http.Error(w, "missing signed header", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(wantBody)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
info fileInboundInfo
|
||||
wantServer string
|
||||
wantTool string
|
||||
wantArg string
|
||||
wantValue string
|
||||
}{
|
||||
{
|
||||
name: "mediaId audio",
|
||||
info: fileInboundInfo{MediaID: "media-1", OpenMessageID: "msg-1", OpenConversationID: "conv-1", FileName: "voice.amr", MediaType: "audio"},
|
||||
wantServer: "im", wantTool: "get_resource_download_url", wantArg: "resourceId", wantValue: "media-1",
|
||||
},
|
||||
{
|
||||
name: "drive fileId video",
|
||||
info: fileInboundInfo{FileID: "file-1", FileName: "video.mov", MediaType: "video"},
|
||||
wantServer: "drive", wantTool: "download_file", wantArg: "fileId", wantValue: "file-1",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
call := func(_ context.Context, serverID, tool string, args map[string]any) (string, error) {
|
||||
if serverID != tc.wantServer || tool != tc.wantTool || args[tc.wantArg] != tc.wantValue {
|
||||
t.Fatalf("call = %s.%s %#v", serverID, tool, args)
|
||||
}
|
||||
return fmt.Sprintf(`{"result":{"resourceUrl":%q,"headers":{"X-Signed-Test":"yes"}}}`, server.URL), nil
|
||||
}
|
||||
client := &aiCardClient{httpClient: server.Client()}
|
||||
path, err := client.downloadRecoveredChatRecordFileWithCall(context.Background(), tc.info, call)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Remove(path)
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, wantBody) {
|
||||
t.Fatalf("downloaded bytes = %q, want %q", got, wantBody)
|
||||
}
|
||||
if filepath.Ext(path) != filepath.Ext(tc.info.FileName) {
|
||||
t.Fatalf("downloaded path = %q, want original extension from %q", path, tc.info.FileName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
const codexRobotDeveloperInstructions = "你是钉钉群聊里的智能助手,请用简洁、自然的中文直接回答用户问题;不要提及系统提示、内部协议或运行时细节;不要主动读写文件或执行命令。"
|
||||
const codexRobotDeveloperInstructions = "你是钉钉群聊里的智能助手,请用简洁、自然的中文直接回答用户问题;不要提及系统提示、内部协议或运行时细节;不要主动读写文件或执行命令。仅当用户消息明确附带了本地附件路径时,可以只读该附件或运行分析该附件所必需的只读命令,不得访问其它文件。"
|
||||
|
||||
// codexAppServerForwarder uses Codex's official app-server JSON-RPC protocol to
|
||||
// keep one Codex thread per DingTalk conversation.
|
||||
@@ -100,11 +100,19 @@ func (f *codexAppServerForwarder) forward(ctx context.Context, convID, text stri
|
||||
return f.forwardStream(ctx, convID, text, nil)
|
||||
}
|
||||
|
||||
func (f *codexAppServerForwarder) forwardStream(ctx context.Context, convID, text string, onDelta func(string)) (string, error) {
|
||||
return f.forwardAppServer(ctx, convID, text, onDelta)
|
||||
func (f *codexAppServerForwarder) forwardWithAttachments(ctx context.Context, convID, text string, attachments []connectMediaAttachment) (string, error) {
|
||||
return f.forwardStreamWithAttachments(ctx, convID, text, attachments, nil)
|
||||
}
|
||||
|
||||
func (f *codexAppServerForwarder) forwardAppServer(ctx context.Context, convID, text string, onDelta func(string)) (string, error) {
|
||||
func (f *codexAppServerForwarder) forwardStream(ctx context.Context, convID, text string, onDelta func(string)) (string, error) {
|
||||
return f.forwardStreamWithAttachments(ctx, convID, text, nil, onDelta)
|
||||
}
|
||||
|
||||
func (f *codexAppServerForwarder) forwardStreamWithAttachments(ctx context.Context, convID, text string, attachments []connectMediaAttachment, onDelta func(string)) (string, error) {
|
||||
return f.forwardAppServer(ctx, convID, text, attachments, onDelta)
|
||||
}
|
||||
|
||||
func (f *codexAppServerForwarder) forwardAppServer(ctx context.Context, convID, text string, attachments []connectMediaAttachment, onDelta func(string)) (string, error) {
|
||||
ctx, cancel := applyTimeout(ctx, f.timeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -151,7 +159,7 @@ func (f *codexAppServerForwarder) forwardAppServer(ctx context.Context, convID,
|
||||
}
|
||||
}
|
||||
|
||||
reply, err := cli.runTurn(ctx, threadID, text, onDelta)
|
||||
reply, err := cli.runTurn(ctx, threadID, text, attachments, onDelta)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -436,13 +444,19 @@ func (c *codexAppServerClient) resumeThread(ctx context.Context, params map[stri
|
||||
return codexThreadIDFromResult(c.waitResponse(ctx, id))
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) runTurn(ctx context.Context, threadID, text string, onDelta func(string)) (string, error) {
|
||||
func (c *codexAppServerClient) runTurn(ctx context.Context, threadID, text string, attachments []connectMediaAttachment, onDelta func(string)) (string, error) {
|
||||
id := c.requestID()
|
||||
input := []map[string]string{{"type": "text", "text": text}}
|
||||
for _, attachment := range attachments {
|
||||
if attachment.MediaType == "image" && strings.TrimSpace(attachment.LocalPath) != "" {
|
||||
input = append(input, map[string]string{"type": "localImage", "path": attachment.LocalPath})
|
||||
}
|
||||
}
|
||||
if err := c.send(map[string]any{
|
||||
"id": id,
|
||||
"method": "turn/start",
|
||||
"params": map[string]any{
|
||||
"input": []map[string]string{{"type": "text", "text": text}},
|
||||
"input": input,
|
||||
"threadId": threadID,
|
||||
},
|
||||
}); err != nil {
|
||||
|
||||
@@ -122,9 +122,13 @@ while IFS= read -r line; do
|
||||
workDir: dir,
|
||||
sessions: newCodexThreadSessions(""),
|
||||
}
|
||||
imagePath := filepath.Join(dir, "forwarded.png")
|
||||
if err := os.WriteFile(imagePath, []byte("png-bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var deltas []string
|
||||
reply, err := fwd.forwardStream(context.Background(), "conv-1", "第一问", func(s string) {
|
||||
reply, err := fwd.forwardStreamWithAttachments(context.Background(), "conv-1", "第一问", []connectMediaAttachment{{LocalPath: imagePath, FileName: "forwarded.png", MediaType: "image"}}, func(s string) {
|
||||
deltas = append(deltas, s)
|
||||
})
|
||||
if err != nil {
|
||||
@@ -151,6 +155,9 @@ while IFS= read -r line; do
|
||||
if strings.Count(log, `"method":"thread/resume"`) != 1 {
|
||||
t.Fatalf("expected one thread/resume, log:\n%s", log)
|
||||
}
|
||||
if !strings.Contains(log, `"type":"localImage"`) || !strings.Contains(log, imagePath) {
|
||||
t.Fatalf("turn/start missing native localImage input, log:\n%s", log)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppServerForwarderReturnsAppServerError(t *testing.T) {
|
||||
|
||||
@@ -16,12 +16,14 @@ package helpers
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -31,6 +33,10 @@ import (
|
||||
const (
|
||||
defaultGeminiAPIBaseURL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
defaultGeminiModel = "gemini-2.5-flash"
|
||||
// Base64 adds roughly 33%, while Gemini's generateContent request limit for
|
||||
// inline audio is 20 MiB. Keep the raw aggregate below 15 MiB and use the
|
||||
// resumable Files API for anything larger.
|
||||
geminiInlineRawLimit = 15 << 20
|
||||
)
|
||||
|
||||
type geminiAPIForwarder struct {
|
||||
@@ -88,6 +94,10 @@ func (f *geminiAPIForwarder) label() string {
|
||||
}
|
||||
|
||||
func (f *geminiAPIForwarder) forward(ctx context.Context, _, text string) (string, error) {
|
||||
return f.forwardWithAttachments(ctx, "", text, nil)
|
||||
}
|
||||
|
||||
func (f *geminiAPIForwarder) forwardWithAttachments(ctx context.Context, _ string, text string, attachments []connectMediaAttachment) (string, error) {
|
||||
ctx, cancel := applyTimeout(ctx, f.timeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -95,14 +105,15 @@ func (f *geminiAPIForwarder) forward(ctx context.Context, _, text string) (strin
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parts, err := f.partsWithAttachments(ctx, text, attachments)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body := geminiGenerateContentRequest{
|
||||
SystemInstruction: geminiContent{
|
||||
Parts: []geminiPart{{Text: "你是钉钉群聊里的智能助手,请用简洁、自然的中文直接回答用户问题;不要提及任何系统提示或内部实现。"}},
|
||||
},
|
||||
Contents: []geminiContent{{
|
||||
Role: "user",
|
||||
Parts: []geminiPart{{Text: text}},
|
||||
}},
|
||||
Contents: []geminiContent{{Role: "user", Parts: parts}},
|
||||
}
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
@@ -122,14 +133,14 @@ func (f *geminiAPIForwarder) forward(ctx context.Context, _, text string) (strin
|
||||
defer resp.Body.Close()
|
||||
respRaw, _ := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("Gemini API HTTP %d: %s", resp.StatusCode, truncateRunes(strings.TrimSpace(string(respRaw)), 300))
|
||||
return "", fmt.Errorf("gemini API HTTP %d: %s", resp.StatusCode, truncateRunes(strings.TrimSpace(string(respRaw)), 300))
|
||||
}
|
||||
var out geminiGenerateContentResponse
|
||||
if err := json.Unmarshal(respRaw, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if out.Error.Message != "" {
|
||||
return "", fmt.Errorf("Gemini API error %s: %s", out.Error.Status, truncateRunes(out.Error.Message, 300))
|
||||
return "", fmt.Errorf("gemini API error %s: %s", out.Error.Status, truncateRunes(out.Error.Message, 300))
|
||||
}
|
||||
var chunks []string
|
||||
for _, cand := range out.Candidates {
|
||||
@@ -141,13 +152,193 @@ func (f *geminiAPIForwarder) forward(ctx context.Context, _, text string) (strin
|
||||
}
|
||||
if len(chunks) == 0 {
|
||||
if out.PromptFeedback.BlockReason != "" {
|
||||
return "", fmt.Errorf("Gemini API blocked prompt: %s", out.PromptFeedback.BlockReason)
|
||||
return "", fmt.Errorf("gemini API blocked prompt: %s", out.PromptFeedback.BlockReason)
|
||||
}
|
||||
return "(Gemini API 无文本输出)", nil
|
||||
}
|
||||
return strings.Join(chunks, "\n\n"), nil
|
||||
}
|
||||
|
||||
func (f *geminiAPIForwarder) partsWithAttachments(ctx context.Context, text string, attachments []connectMediaAttachment) ([]geminiPart, error) {
|
||||
parts := []geminiPart{{Text: text}}
|
||||
var aggregate int64
|
||||
for _, attachment := range attachments {
|
||||
if info, err := os.Stat(attachment.LocalPath); err == nil {
|
||||
aggregate += info.Size()
|
||||
}
|
||||
}
|
||||
for _, attachment := range attachments {
|
||||
path := strings.TrimSpace(attachment.LocalPath)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
mimeType := connectAttachmentMIME(path)
|
||||
if aggregate <= geminiInlineRawLimit {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 Gemini 附件 %s 失败:%w", path, err)
|
||||
}
|
||||
parts = append(parts, geminiPart{InlineData: &geminiInlineData{
|
||||
MIMEType: mimeType,
|
||||
Data: base64.StdEncoding.EncodeToString(raw),
|
||||
}})
|
||||
continue
|
||||
}
|
||||
uploaded, err := f.uploadFile(ctx, path, mimeType, attachment.FileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts = append(parts, geminiPart{FileData: &geminiFileData{
|
||||
MIMEType: uploaded.MIMEType,
|
||||
FileURI: uploaded.URI,
|
||||
}})
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
type geminiUploadedFile struct {
|
||||
Name string `json:"name"`
|
||||
URI string `json:"uri"`
|
||||
MIMEType string `json:"mimeType"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
func (f *geminiAPIForwarder) uploadFile(ctx context.Context, path, mimeType, displayName string) (geminiUploadedFile, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return geminiUploadedFile{}, fmt.Errorf("读取 Gemini 附件信息 %s 失败:%w", path, err)
|
||||
}
|
||||
if strings.TrimSpace(displayName) == "" {
|
||||
displayName = filepath.Base(path)
|
||||
}
|
||||
meta, err := json.Marshal(map[string]any{"file": map[string]any{"display_name": displayName}})
|
||||
if err != nil {
|
||||
return geminiUploadedFile{}, err
|
||||
}
|
||||
startURL, err := f.filesEndpoint(true)
|
||||
if err != nil {
|
||||
return geminiUploadedFile{}, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, startURL, bytes.NewReader(meta))
|
||||
if err != nil {
|
||||
return geminiUploadedFile{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-goog-api-key", f.apiKey)
|
||||
req.Header.Set("X-Goog-Upload-Protocol", "resumable")
|
||||
req.Header.Set("X-Goog-Upload-Command", "start")
|
||||
req.Header.Set("X-Goog-Upload-Header-Content-Length", fmt.Sprint(info.Size()))
|
||||
req.Header.Set("X-Goog-Upload-Header-Content-Type", mimeType)
|
||||
resp, err := f.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return geminiUploadedFile{}, err
|
||||
}
|
||||
startBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return geminiUploadedFile{}, fmt.Errorf("gemini Files API 启动上传 HTTP %d: %s", resp.StatusCode, truncateRunes(strings.TrimSpace(string(startBody)), 300))
|
||||
}
|
||||
uploadURL := strings.TrimSpace(resp.Header.Get("X-Goog-Upload-URL"))
|
||||
if uploadURL == "" {
|
||||
return geminiUploadedFile{}, fmt.Errorf("gemini Files API 未返回 X-Goog-Upload-URL")
|
||||
}
|
||||
fh, err := os.Open(path)
|
||||
if err != nil {
|
||||
return geminiUploadedFile{}, err
|
||||
}
|
||||
defer fh.Close()
|
||||
uploadReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, fh)
|
||||
if err != nil {
|
||||
return geminiUploadedFile{}, err
|
||||
}
|
||||
uploadReq.ContentLength = info.Size()
|
||||
uploadReq.Header.Set("Content-Type", mimeType)
|
||||
uploadReq.Header.Set("X-Goog-Upload-Offset", "0")
|
||||
uploadReq.Header.Set("X-Goog-Upload-Command", "upload, finalize")
|
||||
uploadResp, err := f.httpClient.Do(uploadReq)
|
||||
if err != nil {
|
||||
return geminiUploadedFile{}, err
|
||||
}
|
||||
defer uploadResp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(uploadResp.Body, 4*1024*1024))
|
||||
if uploadResp.StatusCode < 200 || uploadResp.StatusCode >= 300 {
|
||||
return geminiUploadedFile{}, fmt.Errorf("gemini Files API 上传 HTTP %d: %s", uploadResp.StatusCode, truncateRunes(strings.TrimSpace(string(raw)), 300))
|
||||
}
|
||||
var envelope struct {
|
||||
File geminiUploadedFile `json:"file"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
return geminiUploadedFile{}, err
|
||||
}
|
||||
if envelope.File.URI == "" {
|
||||
return geminiUploadedFile{}, fmt.Errorf("gemini Files API 上传结果缺少 file.uri")
|
||||
}
|
||||
if envelope.File.MIMEType == "" {
|
||||
envelope.File.MIMEType = mimeType
|
||||
}
|
||||
return f.waitForUploadedFile(ctx, envelope.File)
|
||||
}
|
||||
|
||||
func (f *geminiAPIForwarder) waitForUploadedFile(ctx context.Context, file geminiUploadedFile) (geminiUploadedFile, error) {
|
||||
for strings.EqualFold(file.State, "PROCESSING") {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return geminiUploadedFile{}, ctx.Err()
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
base, err := f.filesEndpoint(false)
|
||||
if err != nil {
|
||||
return geminiUploadedFile{}, err
|
||||
}
|
||||
name := strings.TrimPrefix(strings.TrimLeft(file.Name, "/"), "files/")
|
||||
statusURL := strings.TrimRight(base, "/") + "/" + name
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
|
||||
if err != nil {
|
||||
return geminiUploadedFile{}, err
|
||||
}
|
||||
req.Header.Set("x-goog-api-key", f.apiKey)
|
||||
resp, err := f.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return geminiUploadedFile{}, err
|
||||
}
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return geminiUploadedFile{}, fmt.Errorf("gemini Files API 查询 HTTP %d: %s", resp.StatusCode, truncateRunes(strings.TrimSpace(string(raw)), 300))
|
||||
}
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return geminiUploadedFile{}, err
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(file.State, "FAILED") {
|
||||
return geminiUploadedFile{}, fmt.Errorf("gemini Files API 处理附件失败: %s", file.Name)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (f *geminiAPIForwarder) filesEndpoint(upload bool) (string, error) {
|
||||
base := strings.TrimRight(strings.TrimSpace(f.baseURL), "/")
|
||||
if base == "" {
|
||||
base = defaultGeminiAPIBaseURL
|
||||
}
|
||||
u, err := url.Parse(base)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return "", fmt.Errorf("GEMINI_API_BASE_URL 无效")
|
||||
}
|
||||
path := strings.TrimRight(u.Path, "/")
|
||||
if upload {
|
||||
if strings.HasSuffix(path, "/v1beta") {
|
||||
path = strings.TrimSuffix(path, "/v1beta") + "/upload/v1beta/files"
|
||||
} else {
|
||||
path += "/upload/v1beta/files"
|
||||
}
|
||||
} else if !strings.HasSuffix(path, "/files") {
|
||||
path += "/files"
|
||||
}
|
||||
u.Path = path
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (f *geminiAPIForwarder) generateContentEndpoint() (string, error) {
|
||||
base := strings.TrimRight(strings.TrimSpace(f.baseURL), "/")
|
||||
if base == "" {
|
||||
@@ -174,7 +365,19 @@ type geminiContent struct {
|
||||
}
|
||||
|
||||
type geminiPart struct {
|
||||
Text string `json:"text"`
|
||||
Text string `json:"text,omitempty"`
|
||||
InlineData *geminiInlineData `json:"inlineData,omitempty"`
|
||||
FileData *geminiFileData `json:"fileData,omitempty"`
|
||||
}
|
||||
|
||||
type geminiInlineData struct {
|
||||
MIMEType string `json:"mimeType"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
type geminiFileData struct {
|
||||
MIMEType string `json:"mimeType"`
|
||||
FileURI string `json:"fileUri"`
|
||||
}
|
||||
|
||||
type geminiGenerateContentResponse struct {
|
||||
|
||||
@@ -15,9 +15,12 @@ package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -44,6 +47,75 @@ func TestGeminiChannelUsesAPIWithoutLocalCLI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiAPIForwarderSendsSmallAttachmentInline(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
path := filepath.Join(t.TempDir(), "voice.mp3")
|
||||
wantBytes := []byte("real-audio-bytes")
|
||||
if err := os.WriteFile(path, wantBytes, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got geminiPart
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req geminiGenerateContentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
if len(req.Contents) == 1 && len(req.Contents[0].Parts) == 2 {
|
||||
got = req.Contents[0].Parts[1]
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"ok"}]}}]}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
f := &geminiAPIForwarder{model: "gemini-test", apiKey: "key", baseURL: ts.URL, httpClient: ts.Client()}
|
||||
_, err := f.forwardWithAttachments(context.Background(), "conv", "转写", []connectMediaAttachment{{LocalPath: path, FileName: "voice.mp3", MediaType: "audio"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.InlineData == nil || got.InlineData.MIMEType != "audio/mpeg" {
|
||||
t.Fatalf("inlineData = %#v", got.InlineData)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(got.InlineData.Data)
|
||||
if err != nil || string(decoded) != string(wantBytes) {
|
||||
t.Fatalf("inline bytes = %q, err=%v", decoded, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiAPIForwarderUploadsLargeAttachment(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
path := filepath.Join(t.TempDir(), "video.mp4")
|
||||
if err := os.WriteFile(path, make([]byte, geminiInlineRawLimit+1), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var started, uploaded, generated bool
|
||||
var ts *httptest.Server
|
||||
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/upload/v1beta/files":
|
||||
started = r.Header.Get("X-Goog-Upload-Command") == "start"
|
||||
w.Header().Set("X-Goog-Upload-URL", ts.URL+"/upload-session")
|
||||
case "/upload-session":
|
||||
uploaded = r.Header.Get("X-Goog-Upload-Command") == "upload, finalize"
|
||||
_, _ = w.Write([]byte(`{"file":{"name":"files/1","uri":"https://files.example/1","mimeType":"video/mp4","state":"ACTIVE"}}`))
|
||||
case "/v1beta/models/gemini-test:generateContent":
|
||||
var req geminiGenerateContentRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
generated = len(req.Contents) == 1 && len(req.Contents[0].Parts) == 2 && req.Contents[0].Parts[1].FileData != nil && req.Contents[0].Parts[1].FileData.FileURI == "https://files.example/1"
|
||||
_, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"ok"}]}}]}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
f := &geminiAPIForwarder{model: "gemini-test", apiKey: "key", baseURL: ts.URL + "/v1beta", httpClient: ts.Client()}
|
||||
_, err := f.forwardWithAttachments(context.Background(), "conv", "分析", []connectMediaAttachment{{LocalPath: path, FileName: "video.mp4", MediaType: "video"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !started || !uploaded || !generated {
|
||||
t.Fatalf("started=%v uploaded=%v generated=%v", started, uploaded, generated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiAPIForwarderForward(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
var gotPath, gotKey, gotText string
|
||||
|
||||
@@ -18,19 +18,52 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// mediaMaxDownloadBytes caps a single inbound image download (screenshots are
|
||||
// well under this; the cap is a hostile-input guard).
|
||||
const mediaMaxDownloadBytes = 20 << 20
|
||||
// mediaMaxDownloadBytes caps a single inbound attachment download. Real merged
|
||||
// forwards can contain high-resolution screen recordings above 250 MiB, so the
|
||||
// connector allows up to 512 MiB but rejects larger payloads explicitly. It
|
||||
// must never silently truncate a file and then tell an agent it has the
|
||||
// original content.
|
||||
const mediaMaxDownloadBytes = 512 << 20
|
||||
|
||||
const mediaDownloadTimeout = 5 * time.Minute
|
||||
|
||||
// connectAttachmentMIME returns the best MIME type available for a downloaded
|
||||
// attachment. DingTalk's download API returns an opaque URL, so the connector
|
||||
// must recover the type from the preserved filename and, when necessary, the
|
||||
// actual bytes before handing it to a multimodal backend.
|
||||
func connectAttachmentMIME(path string) string {
|
||||
if typ := mime.TypeByExtension(strings.ToLower(filepath.Ext(path))); typ != "" {
|
||||
typ = strings.TrimSpace(strings.SplitN(typ, ";", 2)[0])
|
||||
// Generic .bin paths are common for DingTalk voice messages. Sniff their
|
||||
// bytes instead of telling a multimodal backend they are opaque binary.
|
||||
if typ != "application/octet-stream" {
|
||||
return typ
|
||||
}
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
defer f.Close()
|
||||
buf := make([]byte, 512)
|
||||
n, _ := f.Read(buf)
|
||||
if n == 0 {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
return http.DetectContentType(buf[:n])
|
||||
}
|
||||
|
||||
// pictureDownloadCode digs the downloadCode out of a picture callback's
|
||||
// loosely-typed content payload (the stream SDK models Content as
|
||||
@@ -48,6 +81,86 @@ func pictureDownloadCode(content interface{}) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func stringField(m map[string]interface{}, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := m[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// explicitPictureDownloadCode only accepts the picture-specific field. A
|
||||
// generic downloadCode can represent any kind of attachment and must not be
|
||||
// classified as an image merely because it is downloadable.
|
||||
func explicitPictureDownloadCode(content interface{}) string {
|
||||
m, ok := content.(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return stringField(m, "pictureDownloadCode")
|
||||
}
|
||||
|
||||
// richTextPictureDownloadCodes returns the media download codes embedded in a
|
||||
// msgtype="richText" callback, preserving the node order. DingTalk represents
|
||||
// an inline picture as a richText node instead of a top-level picture message:
|
||||
//
|
||||
// {"type":"picture","downloadCode":"...","pictureDownloadCode":"..."}
|
||||
//
|
||||
// The two code fields identify the same picture; pictureDownloadCode handles
|
||||
// their precedence and returns only one code per node.
|
||||
func richTextPictureDownloadCodes(content interface{}) []string {
|
||||
pictures, _ := richTextInboundMedia(content)
|
||||
return pictures
|
||||
}
|
||||
|
||||
// richTextInboundMedia applies the same capability-based discovery to every
|
||||
// inline node. A non-picture node with a locator is preserved as an
|
||||
// attachment instead of being discarded because its type is new or unknown.
|
||||
func richTextInboundMedia(content interface{}) (pictureCodes []string, files []fileInboundInfo) {
|
||||
m, ok := content.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
items, ok := m["richText"].([]interface{})
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
seenPictures := make(map[string]struct{})
|
||||
seenFiles := make(map[string]struct{})
|
||||
for _, item := range items {
|
||||
node, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
typeHint := strings.ToLower(stringField(node, "type", "msgType", "msgtype"))
|
||||
code := ""
|
||||
if explicitPictureDownloadCode(node) != "" {
|
||||
code = pictureDownloadCode(node)
|
||||
}
|
||||
if code == "" && (typeHint == "" || typeHint == "picture" || typeHint == "image") {
|
||||
code = pictureDownloadCode(node)
|
||||
}
|
||||
if code != "" {
|
||||
if _, exists := seenPictures[code]; !exists {
|
||||
seenPictures[code] = struct{}{}
|
||||
pictureCodes = append(pictureCodes, code)
|
||||
}
|
||||
continue
|
||||
}
|
||||
info := parseTypedFileInbound(typeHint, node)
|
||||
if !info.hasActionable() {
|
||||
continue
|
||||
}
|
||||
key := fileInboundKey(info)
|
||||
if _, exists := seenFiles[key]; !exists {
|
||||
seenFiles[key] = struct{}{}
|
||||
files = append(files, info)
|
||||
}
|
||||
}
|
||||
return pictureCodes, files
|
||||
}
|
||||
|
||||
// fileInboundInfo carries everything a msgtype="file" callback might expose.
|
||||
// Client-sent files (a user attaching a file in the DingTalk client) surface
|
||||
// DownloadCode + FileName; API-sent files (`dws chat message send --msg-type
|
||||
@@ -56,16 +169,29 @@ func pictureDownloadCode(content interface{}) string {
|
||||
// recognisable or the connector silently drops legitimate file messages.
|
||||
type fileInboundInfo struct {
|
||||
DownloadCode string
|
||||
FileName string
|
||||
FileType string
|
||||
FilePath string
|
||||
DentryID int64
|
||||
SpaceID int64
|
||||
FileSize int64
|
||||
// MediaID + message/conversation IDs identify media embedded in a
|
||||
// forwarded chat record. DingTalk's Stream callback can erase these into
|
||||
// unknownMsgType, while the user-state message API still preserves them.
|
||||
MediaID string
|
||||
OpenMessageID string
|
||||
OpenConversationID string
|
||||
// FileID is the dentryUuid returned by the user-state conversation API for
|
||||
// forwarded files. It is resolved through drive.download_file.
|
||||
FileID string
|
||||
FileName string
|
||||
FileType string
|
||||
FilePath string
|
||||
MediaType string
|
||||
DentryID int64
|
||||
SpaceID int64
|
||||
FileSize int64
|
||||
}
|
||||
|
||||
func (f fileInboundInfo) hasActionable() bool {
|
||||
return strings.TrimSpace(f.DownloadCode) != "" || (f.DentryID != 0 && f.SpaceID != 0)
|
||||
return strings.TrimSpace(f.DownloadCode) != "" ||
|
||||
(strings.TrimSpace(f.MediaID) != "" && strings.TrimSpace(f.OpenMessageID) != "" && strings.TrimSpace(f.OpenConversationID) != "") ||
|
||||
strings.TrimSpace(f.FileID) != "" ||
|
||||
(f.DentryID != 0 && f.SpaceID != 0)
|
||||
}
|
||||
|
||||
// parseFileInbound reads every relevant field out of a file callback's
|
||||
@@ -79,21 +205,15 @@ func parseFileInbound(content interface{}) fileInboundInfo {
|
||||
if !ok {
|
||||
return info
|
||||
}
|
||||
for _, key := range []string{"downloadCode", "fileDownloadCode"} {
|
||||
if v, ok := m[key].(string); ok && strings.TrimSpace(v) != "" {
|
||||
info.DownloadCode = strings.TrimSpace(v)
|
||||
break
|
||||
}
|
||||
}
|
||||
if v, ok := m["fileName"].(string); ok {
|
||||
info.FileName = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := m["fileType"].(string); ok {
|
||||
info.FileType = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := m["filePath"].(string); ok {
|
||||
info.FilePath = strings.TrimSpace(v)
|
||||
}
|
||||
info.DownloadCode = stringField(m, "downloadCode", "fileDownloadCode")
|
||||
info.MediaID = stringField(m, "mediaId", "mediaID")
|
||||
info.OpenMessageID = stringField(m, "openMessageId", "openMessageID")
|
||||
info.OpenConversationID = stringField(m, "openConversationId", "openConversationID")
|
||||
info.FileID = stringField(m, "fileId", "fileID", "dentryUuid", "dentryUUID")
|
||||
info.FileName = stringField(m, "fileName", "name")
|
||||
info.FileType = stringField(m, "fileType")
|
||||
info.FilePath = stringField(m, "filePath")
|
||||
info.MediaType = stringField(m, "mediaType")
|
||||
info.DentryID = readInt64Field(m, "dentryId", "dentryID")
|
||||
info.SpaceID = readInt64Field(m, "spaceId", "spaceID")
|
||||
info.FileSize = readInt64Field(m, "fileSize", "size")
|
||||
@@ -103,6 +223,213 @@ func parseFileInbound(content interface{}) fileInboundInfo {
|
||||
return info
|
||||
}
|
||||
|
||||
// inboundMediaType normalizes the callback spellings used for downloadable
|
||||
// non-picture media. The returned value is only used to make the agent prompt
|
||||
// precise; download authorization still comes exclusively from downloadCode
|
||||
// or dentryId+spaceId.
|
||||
func inboundMediaType(msgtype string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(msgtype)) {
|
||||
case "image", "picture":
|
||||
return "image"
|
||||
case "audio", "voice":
|
||||
return "audio"
|
||||
case "video":
|
||||
return "video"
|
||||
default:
|
||||
return "file"
|
||||
}
|
||||
}
|
||||
|
||||
func parseTypedFileInbound(msgtype string, content interface{}) fileInboundInfo {
|
||||
info := parseFileInbound(content)
|
||||
if mediaType := strings.TrimSpace(msgtype); mediaType != "" {
|
||||
info.MediaType = inboundMediaType(mediaType)
|
||||
}
|
||||
if info.MediaType == "" || info.MediaType == "file" {
|
||||
info.MediaType = mediaTypeFromFileName(info.FileName)
|
||||
}
|
||||
if info.FileName == "未知文件" {
|
||||
switch info.MediaType {
|
||||
case "audio":
|
||||
info.FileName = "语音消息"
|
||||
case "video":
|
||||
info.FileName = "视频消息"
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func fileInboundKey(info fileInboundInfo) string {
|
||||
switch {
|
||||
case strings.TrimSpace(info.DownloadCode) != "":
|
||||
return "download:" + strings.TrimSpace(info.DownloadCode)
|
||||
case strings.TrimSpace(info.MediaID) != "":
|
||||
return "media:" + strings.TrimSpace(info.MediaID) + ":" + strings.TrimSpace(info.OpenMessageID) + ":" + strings.TrimSpace(info.OpenConversationID)
|
||||
case strings.TrimSpace(info.FileID) != "":
|
||||
return "file:" + strings.TrimSpace(info.FileID)
|
||||
case info.DentryID != 0 && info.SpaceID != 0:
|
||||
return fmt.Sprintf("dentry:%d:%d", info.SpaceID, info.DentryID)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// chatRecordInboundMedia extracts every actionable attachment that DingTalk
|
||||
// preserved in a msgtype=chatRecord callback. The observed callback encodes
|
||||
// the record array as a JSON string under content.chatRecord; accepting an
|
||||
// already-decoded array as well keeps the parser compatible with SDK changes.
|
||||
//
|
||||
// Some forwarded entries arrive as {"msgType":"unknownMsgType"} with no
|
||||
// message id, download code, or storage id. Those entries are counted for
|
||||
// diagnostics but cannot be recovered by the connector because the callback
|
||||
// contains no locator for the original bytes.
|
||||
func chatRecordInboundMedia(content interface{}) (pictureCodes []string, files []fileInboundInfo, unrecoverableCount int) {
|
||||
m, ok := content.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, nil, 0
|
||||
}
|
||||
|
||||
var entries []interface{}
|
||||
switch raw := m["chatRecord"].(type) {
|
||||
case string:
|
||||
if err := json.Unmarshal([]byte(raw), &entries); err != nil {
|
||||
return nil, nil, 0
|
||||
}
|
||||
case []interface{}:
|
||||
entries = raw
|
||||
default:
|
||||
// A few callback variants call the decoded array "contents".
|
||||
if decoded, ok := m["contents"].([]interface{}); ok {
|
||||
entries = decoded
|
||||
}
|
||||
}
|
||||
|
||||
seenPictures := make(map[string]struct{})
|
||||
seenFiles := make(map[string]struct{})
|
||||
for _, entry := range entries {
|
||||
node, ok := entry.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
msgtype := ""
|
||||
for _, key := range []string{"msgType", "msgtype", "type"} {
|
||||
if value, ok := node[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
msgtype = strings.ToLower(strings.TrimSpace(value))
|
||||
break
|
||||
}
|
||||
}
|
||||
// Attachment discovery is capability-based. The nested msgType is a
|
||||
// classification hint only; a future/unknown type with a valid locator
|
||||
// must still reach the backend with its original bytes.
|
||||
pictureCode := ""
|
||||
if explicitPictureDownloadCode(node) != "" {
|
||||
pictureCode = pictureDownloadCode(node)
|
||||
}
|
||||
if pictureCode == "" && (msgtype == "picture" || msgtype == "image") {
|
||||
pictureCode = pictureDownloadCode(node)
|
||||
}
|
||||
if pictureCode != "" {
|
||||
if _, exists := seenPictures[pictureCode]; !exists {
|
||||
seenPictures[pictureCode] = struct{}{}
|
||||
pictureCodes = append(pictureCodes, pictureCode)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
info := parseTypedFileInbound(msgtype, node)
|
||||
if info.hasActionable() {
|
||||
key := fileInboundKey(info)
|
||||
if _, exists := seenFiles[key]; !exists {
|
||||
seenFiles[key] = struct{}{}
|
||||
files = append(files, info)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if msgtype == "unknownmsgtype" {
|
||||
unrecoverableCount++
|
||||
}
|
||||
}
|
||||
return pictureCodes, files, unrecoverableCount
|
||||
}
|
||||
|
||||
func hasChatRecordPayload(content interface{}) bool {
|
||||
m, ok := content.(map[string]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch raw := m["chatRecord"].(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(raw) != ""
|
||||
case []interface{}:
|
||||
return true
|
||||
}
|
||||
_, hasDecodedContents := m["contents"].([]interface{})
|
||||
return hasDecodedContents
|
||||
}
|
||||
|
||||
// callbackInboundMedia discovers downloadable payloads from their locator
|
||||
// fields instead of an allowlist of msgtype values. msgtype is retained only
|
||||
// as a media classification hint, so newly introduced message types are
|
||||
// forwarded immediately without requiring a connector release.
|
||||
func callbackInboundMedia(msgtype string, content interface{}) (pictureCodes []string, files []fileInboundInfo, unrecoverableCount int) {
|
||||
seenPictures := make(map[string]struct{})
|
||||
seenFiles := make(map[string]struct{})
|
||||
addPicture := func(code string) {
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return
|
||||
}
|
||||
if _, exists := seenPictures[code]; exists {
|
||||
return
|
||||
}
|
||||
seenPictures[code] = struct{}{}
|
||||
pictureCodes = append(pictureCodes, code)
|
||||
}
|
||||
addFile := func(info fileInboundInfo) {
|
||||
if !info.hasActionable() {
|
||||
return
|
||||
}
|
||||
key := fileInboundKey(info)
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
if _, picture := seenPictures[info.DownloadCode]; picture {
|
||||
return
|
||||
}
|
||||
if _, exists := seenFiles[key]; exists {
|
||||
return
|
||||
}
|
||||
seenFiles[key] = struct{}{}
|
||||
files = append(files, info)
|
||||
}
|
||||
|
||||
richPictures, richFiles := richTextInboundMedia(content)
|
||||
for _, code := range richPictures {
|
||||
addPicture(code)
|
||||
}
|
||||
for _, info := range richFiles {
|
||||
addFile(info)
|
||||
}
|
||||
pictureCode := ""
|
||||
if explicitPictureDownloadCode(content) != "" {
|
||||
pictureCode = pictureDownloadCode(content)
|
||||
}
|
||||
if pictureCode == "" && (strings.EqualFold(msgtype, "picture") || strings.EqualFold(msgtype, "image")) {
|
||||
pictureCode = pictureDownloadCode(content)
|
||||
}
|
||||
addPicture(pictureCode)
|
||||
addFile(parseTypedFileInbound(msgtype, content))
|
||||
|
||||
nestedPictures, nestedFiles, nestedUnknown := chatRecordInboundMedia(content)
|
||||
for _, code := range nestedPictures {
|
||||
addPicture(code)
|
||||
}
|
||||
for _, info := range nestedFiles {
|
||||
addFile(info)
|
||||
}
|
||||
return pictureCodes, files, nestedUnknown
|
||||
}
|
||||
|
||||
// readInt64Field pulls an int64 out of the loose content map under any of the
|
||||
// provided keys, tolerating JSON string / float64 / int64 / json.Number.
|
||||
func readInt64Field(m map[string]interface{}, keys ...string) int64 {
|
||||
@@ -158,6 +485,19 @@ func summarizeContent(content interface{}) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// rawCallbackPrompt preserves message types whose payload is meaningful but
|
||||
// has no locally recognised text/media shape (for example msgtype=chatRecord).
|
||||
// The connector should not decide that such messages are empty: forwarding
|
||||
// the type and JSON payload lets the backend model interpret new and complex
|
||||
// DingTalk message formats without waiting for a CLI-side parser update.
|
||||
func rawCallbackPrompt(msgtype string, content interface{}) string {
|
||||
b, err := json.Marshal(content)
|
||||
if err != nil {
|
||||
b = []byte(fmt.Sprintf("%v", content))
|
||||
}
|
||||
return fmt.Sprintf("用户发送了一条钉钉消息,msgtype=%q。请解析以下原始消息 JSON,提取有用信息并处理用户意图:\n%s", strings.TrimSpace(msgtype), b)
|
||||
}
|
||||
|
||||
// extractCallbackText pulls the visible text out of a structured-text callback
|
||||
// payload (msgtype=richText / markdown / etc.) for the case where the SDK's
|
||||
// data.Text.Content is empty. This matters because `dws chat message send
|
||||
@@ -172,7 +512,7 @@ func extractCallbackText(content interface{}) string {
|
||||
case map[string]interface{}:
|
||||
// Common shapes: {"text":"..."}, {"title":"...","text":"..."},
|
||||
// {"content":"..."}, richText {"richText":[{"text":"..."}]}.
|
||||
for _, key := range []string{"text", "content", "markdown", "title"} {
|
||||
for _, key := range []string{"text", "content", "markdown", "title", "recognition"} {
|
||||
if s, ok := v[key].(string); ok && strings.TrimSpace(s) != "" {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
@@ -259,6 +599,13 @@ func extractInteractiveCardText(content interface{}) string {
|
||||
// questions are the top Q&A inbound; without this the connector silently
|
||||
// drops every picture message.
|
||||
func (c *aiCardClient) downloadMessageFile(ctx context.Context, robotCode, downloadCode string) (string, error) {
|
||||
return c.downloadMessageFileNamed(ctx, robotCode, downloadCode, "")
|
||||
}
|
||||
|
||||
// downloadMessageFileNamed is downloadMessageFile with an optional original
|
||||
// file name. Keeping its extension materially improves audio/video/file
|
||||
// handling across local agents whose tool selection depends on the path.
|
||||
func (c *aiCardClient) downloadMessageFileNamed(ctx context.Context, robotCode, downloadCode, fileName string) (string, error) {
|
||||
raw, err := c.callRaw(ctx, http.MethodPost, "/v1.0/robot/messageFiles/download", map[string]any{
|
||||
"robotCode": robotCode,
|
||||
"downloadCode": downloadCode,
|
||||
@@ -276,7 +623,7 @@ func (c *aiCardClient) downloadMessageFile(ctx context.Context, robotCode, downl
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := c.httpClient.Do(req)
|
||||
resp, err := connectMediaDownloadClient(c.httpClient).Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -284,18 +631,19 @@ func (c *aiCardClient) downloadMessageFile(ctx context.Context, robotCode, downl
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("媒体下载 HTTP %d", resp.StatusCode)
|
||||
}
|
||||
if resp.ContentLength > mediaMaxDownloadBytes {
|
||||
return "", fmt.Errorf("媒体文件过大:%d 字节,最大允许 %d 字节", resp.ContentLength, mediaMaxDownloadBytes)
|
||||
}
|
||||
dir := filepath.Join(os.TempDir(), "dws-connect-media")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
dest := filepath.Join(dir, uuid.NewString()+mediaExt(parsed.DownloadUrl, resp.Header.Get("Content-Type")))
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
ext := filepath.Ext(strings.TrimSpace(fileName))
|
||||
if ext == "" {
|
||||
ext = mediaExt(parsed.DownloadUrl, resp.Header.Get("Content-Type"))
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := io.Copy(f, io.LimitReader(resp.Body, mediaMaxDownloadBytes)); err != nil {
|
||||
_ = os.Remove(dest)
|
||||
dest := filepath.Join(dir, uuid.NewString()+ext)
|
||||
if err := writeCompleteMediaFile(dest, resp.Body); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dest, nil
|
||||
@@ -344,7 +692,7 @@ func (c *aiCardClient) downloadDentryFile(ctx context.Context, spaceID, dentryID
|
||||
for k, v := range parsed.HeadersMap {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := c.httpClient.Do(req)
|
||||
resp, err := connectMediaDownloadClient(c.httpClient).Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -352,6 +700,9 @@ func (c *aiCardClient) downloadDentryFile(ctx context.Context, spaceID, dentryID
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("钉盘文件下载 HTTP %d", resp.StatusCode)
|
||||
}
|
||||
if resp.ContentLength > mediaMaxDownloadBytes {
|
||||
return "", fmt.Errorf("钉盘文件过大:%d 字节,最大允许 %d 字节", resp.ContentLength, mediaMaxDownloadBytes)
|
||||
}
|
||||
|
||||
dir := filepath.Join(os.TempDir(), "dws-connect-media")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
@@ -362,18 +713,60 @@ func (c *aiCardClient) downloadDentryFile(ctx context.Context, spaceID, dentryID
|
||||
ext = mediaExt(parsed.ResourceURL, resp.Header.Get("Content-Type"))
|
||||
}
|
||||
dest := filepath.Join(dir, uuid.NewString()+ext)
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := io.Copy(f, io.LimitReader(resp.Body, mediaMaxDownloadBytes)); err != nil {
|
||||
_ = os.Remove(dest)
|
||||
if err := writeCompleteMediaFile(dest, resp.Body); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
// writeCompleteMediaFile writes at most mediaMaxDownloadBytes and verifies the
|
||||
// stream ended. Reading one byte past the cap distinguishes an exact-size file
|
||||
// from a larger file; the latter is removed instead of leaving a corrupt local
|
||||
// artifact that an agent could mistake for the original.
|
||||
func writeCompleteMediaFile(dest string, src io.Reader) error {
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, copyErr := io.Copy(f, io.LimitReader(src, mediaMaxDownloadBytes+1))
|
||||
closeErr := f.Close()
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(dest)
|
||||
return copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
_ = os.Remove(dest)
|
||||
return closeErr
|
||||
}
|
||||
if n > mediaMaxDownloadBytes {
|
||||
_ = os.Remove(dest)
|
||||
return fmt.Errorf("媒体文件超过最大允许大小 %d 字节,未保存截断文件", mediaMaxDownloadBytes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func connectMediaDownloadClient(base *http.Client) *http.Client {
|
||||
if base == nil {
|
||||
return &http.Client{Timeout: mediaDownloadTimeout}
|
||||
}
|
||||
clone := *base
|
||||
if clone.Timeout <= 0 || clone.Timeout < mediaDownloadTimeout {
|
||||
clone.Timeout = mediaDownloadTimeout
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
func cleanupConnectMediaAttachments(attachments []connectMediaAttachment) {
|
||||
root := filepath.Join(os.TempDir(), "dws-connect-media")
|
||||
for _, attachment := range attachments {
|
||||
path := filepath.Clean(strings.TrimSpace(attachment.LocalPath))
|
||||
if path == "." || filepath.Dir(path) != root {
|
||||
continue
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}
|
||||
|
||||
// mediaExt picks a file extension from the response content type, falling
|
||||
// back to the URL path, then ".png" (DingTalk screenshots default to png).
|
||||
func mediaExt(rawURL, contentType string) string {
|
||||
|
||||
@@ -16,9 +16,11 @@ package helpers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -42,6 +44,56 @@ func TestPictureDownloadCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichTextPictureDownloadCodes(t *testing.T) {
|
||||
content := map[string]interface{}{"richText": []interface{}{
|
||||
map[string]interface{}{"text": "这个问题示例图如下:"},
|
||||
map[string]interface{}{
|
||||
"pictureDownloadCode": "picture-fallback",
|
||||
"downloadCode": "image-1",
|
||||
"type": "picture",
|
||||
},
|
||||
map[string]interface{}{"text": "中间文字"},
|
||||
map[string]interface{}{"pictureDownloadCode": "image-2", "type": "picture"},
|
||||
map[string]interface{}{"downloadCode": "not-a-picture", "type": "file"},
|
||||
}}
|
||||
|
||||
got := richTextPictureDownloadCodes(content)
|
||||
want := []string{"image-1", "image-2"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("codes = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("codes = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichTextPictureDownloadCodesUnknownShape(t *testing.T) {
|
||||
for _, content := range []interface{}{
|
||||
nil,
|
||||
"plain text",
|
||||
map[string]interface{}{"richText": "not-an-array"},
|
||||
map[string]interface{}{"richText": []interface{}{map[string]interface{}{"text": "only text"}}},
|
||||
} {
|
||||
if got := richTextPictureDownloadCodes(content); len(got) != 0 {
|
||||
t.Fatalf("content %v: codes = %v, want none", content, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackInboundMediaPreservesFutureRichTextAttachment(t *testing.T) {
|
||||
pictures, files, unknown := callbackInboundMedia("renamedRichEnvelope", map[string]interface{}{
|
||||
"richText": []interface{}{
|
||||
map[string]interface{}{"type": "text", "text": "附件如下"},
|
||||
map[string]interface{}{"type": "futureInlineBinary", "downloadCode": "inline-1", "fileName": "demo.mp4"},
|
||||
},
|
||||
})
|
||||
if len(pictures) != 0 || len(files) != 1 || files[0].DownloadCode != "inline-1" || files[0].MediaType != "video" || unknown != 0 {
|
||||
t.Fatalf("pictures=%v files=%#v unknown=%d", pictures, files, unknown)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractCallbackText covers the markdown / richText fallback path used
|
||||
// when SDK data.Text.Content is empty. This is the recovery path for
|
||||
// `dws chat message send --group ... --text ...` (defaults to msgType=markdown)
|
||||
@@ -84,6 +136,156 @@ func TestExtractCallbackText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawCallbackPromptPreservesChatRecordPayload(t *testing.T) {
|
||||
content := map[string]interface{}{
|
||||
"title": "转发的聊天记录",
|
||||
"contents": []interface{}{
|
||||
map[string]interface{}{"senderName": "张三", "text": "请汇总本周风险"},
|
||||
map[string]interface{}{"senderName": "李四", "text": "发布窗口需要延期"},
|
||||
},
|
||||
}
|
||||
|
||||
got := rawCallbackPrompt(" chatRecord ", content)
|
||||
for _, want := range []string{
|
||||
`msgtype="chatRecord"`,
|
||||
`"senderName":"张三"`,
|
||||
`"text":"请汇总本周风险"`,
|
||||
`"text":"发布窗口需要延期"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("rawCallbackPrompt() missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawCallbackPromptForUnknownEmptyPayload(t *testing.T) {
|
||||
got := rawCallbackPrompt("futureMessageType", nil)
|
||||
if !strings.Contains(got, `msgtype="futureMessageType"`) || !strings.HasSuffix(got, "\nnull") {
|
||||
t.Fatalf("rawCallbackPrompt() = %q, want message type and null JSON payload", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatRecordInboundMediaExtractsEveryRecoverableAttachment(t *testing.T) {
|
||||
record := []interface{}{
|
||||
map[string]interface{}{"msgType": "picture", "downloadCode": "pic-1"},
|
||||
map[string]interface{}{"msgType": "picture", "downloadCode": "pic-1"}, // duplicate
|
||||
map[string]interface{}{"msgType": "audio", "downloadCode": "audio-1", "recognition": "语音转写"},
|
||||
map[string]interface{}{"msgType": "video", "downloadCode": "video-1", "fileName": "demo.mov"},
|
||||
map[string]interface{}{"msgType": "file", "downloadCode": "file-1", "fileName": "report.md"},
|
||||
map[string]interface{}{"msgType": "file", "dentryId": "123", "spaceId": "456", "fileName": "spec.pdf"},
|
||||
map[string]interface{}{"msgType": "unknownMsgType"},
|
||||
map[string]interface{}{"msgType": "unknownMsgType"},
|
||||
map[string]interface{}{"msgType": "text", "content": "请分析这些附件"},
|
||||
}
|
||||
raw, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pictures, files, unknown := chatRecordInboundMedia(map[string]interface{}{"chatRecord": string(raw)})
|
||||
if len(pictures) != 1 || pictures[0] != "pic-1" {
|
||||
t.Fatalf("pictures = %v, want [pic-1]", pictures)
|
||||
}
|
||||
if unknown != 2 {
|
||||
t.Fatalf("unknownCount = %d, want 2", unknown)
|
||||
}
|
||||
if len(files) != 4 {
|
||||
t.Fatalf("files = %#v, want 4 attachments", files)
|
||||
}
|
||||
wants := []struct {
|
||||
mediaType string
|
||||
code string
|
||||
name string
|
||||
}{
|
||||
{"audio", "audio-1", "语音消息"},
|
||||
{"video", "video-1", "demo.mov"},
|
||||
{"file", "file-1", "report.md"},
|
||||
{"file", "", "spec.pdf"},
|
||||
}
|
||||
for i, want := range wants {
|
||||
if files[i].MediaType != want.mediaType || files[i].DownloadCode != want.code || files[i].FileName != want.name {
|
||||
t.Fatalf("files[%d] = %#v, want type=%q code=%q name=%q", i, files[i], want.mediaType, want.code, want.name)
|
||||
}
|
||||
}
|
||||
if files[3].DentryID != 123 || files[3].SpaceID != 456 {
|
||||
t.Fatalf("dentry attachment = %#v, want dentry=123 space=456", files[3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatRecordInboundMediaMatchesObservedDegradedCallback(t *testing.T) {
|
||||
record := `[{"msgType":"picture","downloadCode":"pic-live"},{"msgType":"unknownMsgType"},{"msgType":"unknownMsgType"},{"msgType":"text","content":"[合并的聊天记录]"},{"msgType":"unknownMsgType"}]`
|
||||
pictures, files, unknown := chatRecordInboundMedia(map[string]interface{}{"chatRecord": record})
|
||||
if len(pictures) != 1 || pictures[0] != "pic-live" || len(files) != 0 || unknown != 3 {
|
||||
t.Fatalf("pictures=%v files=%v unknown=%d, want one picture, no recoverable files, three unknowns", pictures, files, unknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatRecordInboundMediaRecoversUnknownTypeWhenLocatorSurvives(t *testing.T) {
|
||||
record := `[{"msgType":"unknownMsgType","downloadCode":"opaque-1","fileName":"payload.bin"}]`
|
||||
pictures, files, unknown := chatRecordInboundMedia(map[string]interface{}{"chatRecord": record})
|
||||
if len(pictures) != 0 || len(files) != 1 || files[0].DownloadCode != "opaque-1" || files[0].FileName != "payload.bin" || unknown != 0 {
|
||||
t.Fatalf("pictures=%v files=%#v unknown=%d", pictures, files, unknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatRecordInboundMediaPreservesFutureTypeWithLocator(t *testing.T) {
|
||||
record := `[{"msgType":"futureBinaryEnvelope","downloadCode":"future-1","fileName":"clip.webm"}]`
|
||||
pictures, files, unknown := chatRecordInboundMedia(map[string]interface{}{"chatRecord": record})
|
||||
if len(pictures) != 0 || len(files) != 1 || files[0].DownloadCode != "future-1" || files[0].MediaType != "video" || unknown != 0 {
|
||||
t.Fatalf("pictures=%v files=%#v unknown=%d", pictures, files, unknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackInboundMediaDoesNotGateOnOuterMessageType(t *testing.T) {
|
||||
pictures, files, unknown := callbackInboundMedia("futureAttachmentV2", map[string]interface{}{
|
||||
"downloadCode": "future-2",
|
||||
"fileName": "voice.ogg",
|
||||
})
|
||||
if len(pictures) != 0 || len(files) != 1 || files[0].DownloadCode != "future-2" || files[0].MediaType != "audio" || unknown != 0 {
|
||||
t.Fatalf("pictures=%v files=%#v unknown=%d", pictures, files, unknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackInboundMediaFindsChatRecordByShape(t *testing.T) {
|
||||
content := map[string]interface{}{
|
||||
"chatRecord": `[{"msgType":"picture","downloadCode":"nested-picture"},{"msgType":"futureFile","downloadCode":"nested-file","fileName":"notes.md"},{"msgType":"unknownMsgType"}]`,
|
||||
}
|
||||
pictures, files, unknown := callbackInboundMedia("renamedForwardEnvelope", content)
|
||||
if len(pictures) != 1 || pictures[0] != "nested-picture" {
|
||||
t.Fatalf("pictures=%v, want [nested-picture]", pictures)
|
||||
}
|
||||
if len(files) != 1 || files[0].DownloadCode != "nested-file" || files[0].FileName != "notes.md" {
|
||||
t.Fatalf("files=%#v, want nested-file", files)
|
||||
}
|
||||
if unknown != 1 {
|
||||
t.Fatalf("unknown=%d, want 1", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasChatRecordPayloadUsesShapeNotMessageType(t *testing.T) {
|
||||
if !hasChatRecordPayload(map[string]interface{}{
|
||||
"title": "转发记录",
|
||||
"chatRecord": `[{"msgType":"text","content":"hello"}]`,
|
||||
}) {
|
||||
t.Fatal("chatRecord JSON string should be detected by payload shape")
|
||||
}
|
||||
if hasChatRecordPayload(map[string]interface{}{"title": "普通卡片"}) {
|
||||
t.Fatal("ordinary title payload must not be detected as a chat record")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatRecordInboundMediaAcceptsDecodedContents(t *testing.T) {
|
||||
pictures, files, unknown := chatRecordInboundMedia(map[string]interface{}{
|
||||
"contents": []interface{}{
|
||||
map[string]interface{}{"type": "image", "pictureDownloadCode": "pic-2"},
|
||||
map[string]interface{}{"type": "voice", "downloadCode": "voice-2"},
|
||||
},
|
||||
})
|
||||
if len(pictures) != 1 || pictures[0] != "pic-2" || len(files) != 1 || files[0].MediaType != "audio" || unknown != 0 {
|
||||
t.Fatalf("pictures=%v files=%#v unknown=%d", pictures, files, unknown)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractInteractiveCardText covers the bot→bot @ card: the leading
|
||||
// mention leaf (whose display name may contain spaces) is dropped by leaf
|
||||
// boundary, leaving the clean instruction.
|
||||
@@ -161,6 +363,61 @@ func TestDownloadMessageFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadMessageFileNamedPreservesOriginalExtension(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
var srv *httptest.Server
|
||||
mux.HandleFunc("/v1.0/oauth2/accessToken", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"accessToken": "tok-1", "expireIn": 7200})
|
||||
})
|
||||
mux.HandleFunc("/v1.0/robot/messageFiles/download", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"downloadUrl": srv.URL + "/opaque.file"})
|
||||
})
|
||||
mux.HandleFunc("/opaque.file", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write([]byte("MOVDATA"))
|
||||
})
|
||||
srv = httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
withCardAPIBase(t, srv.URL)
|
||||
|
||||
c := newAICardClient("ding-client", "ding-secret", "")
|
||||
path, err := c.downloadMessageFileNamed(context.Background(), "ding-client", "video-code", "screen.mov")
|
||||
if err != nil {
|
||||
t.Fatalf("downloadMessageFileNamed: %v", err)
|
||||
}
|
||||
defer os.Remove(path)
|
||||
if !strings.HasSuffix(path, ".mov") {
|
||||
t.Fatalf("path = %q, want original .mov extension", path)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil || string(raw) != "MOVDATA" {
|
||||
t.Fatalf("saved file = %q, %v", raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadMessageFileRejectsKnownOversizePayload(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
var srv *httptest.Server
|
||||
mux.HandleFunc("/v1.0/oauth2/accessToken", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"accessToken": "tok-1", "expireIn": 7200})
|
||||
})
|
||||
mux.HandleFunc("/v1.0/robot/messageFiles/download", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"downloadUrl": srv.URL + "/too-large"})
|
||||
})
|
||||
mux.HandleFunc("/too-large", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", mediaMaxDownloadBytes+1))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
srv = httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
withCardAPIBase(t, srv.URL)
|
||||
|
||||
c := newAICardClient("ding-client", "ding-secret", "")
|
||||
if _, err := c.downloadMessageFileNamed(context.Background(), "ding-client", "large-code", "large.mov"); err == nil || !strings.Contains(err.Error(), "文件过大") {
|
||||
t.Fatalf("oversize download error = %v, want explicit size rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadMessageFileNoURL(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1.0/oauth2/accessToken", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -193,6 +450,18 @@ func TestMediaExt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectAttachmentMIMESniffsGenericVoiceFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "voice.bin")
|
||||
// Ogg capture pattern plus a minimal header is sufficient for
|
||||
// http.DetectContentType to identify the real container.
|
||||
if err := os.WriteFile(path, append([]byte("OggS\x00\x02"), make([]byte, 506)...), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := connectAttachmentMIME(path); got != "application/ogg" {
|
||||
t.Fatalf("connectAttachmentMIME() = %q, want application/ogg", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseFileInbound covers both callback shapes so the regression that
|
||||
// dropped every API-sent file (dentryId/spaceId, no downloadCode) can't
|
||||
// silently return: (a) client-sent shape carrying downloadCode + fileName,
|
||||
@@ -222,6 +491,17 @@ func TestParseFileInbound(t *testing.T) {
|
||||
wantName: "log.txt",
|
||||
wantActionable: true,
|
||||
},
|
||||
{
|
||||
name: "media locator fields",
|
||||
content: map[string]interface{}{
|
||||
"mediaId": "media-1",
|
||||
"openMessageId": "message-1",
|
||||
"openConversationId": "conversation-1",
|
||||
"fileName": "recording.m4a",
|
||||
},
|
||||
wantName: "recording.m4a",
|
||||
wantActionable: true,
|
||||
},
|
||||
{
|
||||
name: "API-sent dentryId/spaceId as numbers",
|
||||
content: map[string]interface{}{
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -99,7 +100,15 @@ func (f *opencodeForwarder) forward(ctx context.Context, convID, text string) (s
|
||||
return f.forwardStream(ctx, convID, text, nil)
|
||||
}
|
||||
|
||||
func (f *opencodeForwarder) forwardWithAttachments(ctx context.Context, convID, text string, attachments []connectMediaAttachment) (string, error) {
|
||||
return f.forwardStreamWithAttachments(ctx, convID, text, attachments, nil)
|
||||
}
|
||||
|
||||
func (f *opencodeForwarder) forwardStream(ctx context.Context, convID, text string, _ func(string)) (string, error) {
|
||||
return f.forwardStreamWithAttachments(ctx, convID, text, nil, nil)
|
||||
}
|
||||
|
||||
func (f *opencodeForwarder) forwardStreamWithAttachments(ctx context.Context, convID, text string, attachments []connectMediaAttachment, _ func(string)) (string, error) {
|
||||
ctx, cancel := applyTimeout(ctx, f.timeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -107,15 +116,15 @@ func (f *opencodeForwarder) forwardStream(ctx context.Context, convID, text stri
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
reply, err := f.forwardWithClient(ctx, client, convID, text)
|
||||
reply, err := f.forwardWithClient(ctx, client, convID, text, attachments)
|
||||
if errors.Is(err, errOpencodeSessionMissing) && f.sessions != nil {
|
||||
f.sessions.reset(convID)
|
||||
reply, err = f.forwardWithClient(ctx, client, convID, text)
|
||||
reply, err = f.forwardWithClient(ctx, client, convID, text, attachments)
|
||||
}
|
||||
return reply, err
|
||||
}
|
||||
|
||||
func (f *opencodeForwarder) forwardWithClient(ctx context.Context, client *opencodeHTTPClient, convID, text string) (string, error) {
|
||||
func (f *opencodeForwarder) forwardWithClient(ctx context.Context, client *opencodeHTTPClient, convID, text string, attachments []connectMediaAttachment) (string, error) {
|
||||
sessionID := ""
|
||||
if f.sessions != nil {
|
||||
sessionID = f.sessions.id(convID)
|
||||
@@ -130,7 +139,7 @@ func (f *opencodeForwarder) forwardWithClient(ctx context.Context, client *openc
|
||||
f.sessions.set(convID, sessionID)
|
||||
}
|
||||
}
|
||||
reply, err := client.sendMessage(ctx, sessionID, text, f.model)
|
||||
reply, err := client.sendMessageWithAttachments(ctx, sessionID, text, f.model, attachments)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -459,9 +468,27 @@ func (c *opencodeHTTPClient) deleteSession(ctx context.Context, sessionID string
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *opencodeHTTPClient) sendMessage(ctx context.Context, sessionID, text, model string) (string, error) {
|
||||
func (c *opencodeHTTPClient) sendMessageWithAttachments(ctx context.Context, sessionID, text, model string, attachments []connectMediaAttachment) (string, error) {
|
||||
parts := []map[string]any{{"type": "text", "text": text}}
|
||||
for _, attachment := range attachments {
|
||||
path := strings.TrimSpace(attachment.LocalPath)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
fileURL := (&url.URL{Scheme: "file", Path: path}).String()
|
||||
name := strings.TrimSpace(attachment.FileName)
|
||||
if name == "" {
|
||||
name = filepath.Base(path)
|
||||
}
|
||||
parts = append(parts, map[string]any{
|
||||
"type": "file",
|
||||
"url": fileURL,
|
||||
"filename": name,
|
||||
"mime": connectAttachmentMIME(path),
|
||||
})
|
||||
}
|
||||
body := map[string]any{
|
||||
"parts": []map[string]any{{"type": "text", "text": text}},
|
||||
"parts": parts,
|
||||
}
|
||||
if m := opencodeModelRef(model); m != nil {
|
||||
body["model"] = m
|
||||
|
||||
@@ -19,12 +19,53 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOpencodeForwarderSendsNativeFileParts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "evidence.png")
|
||||
if err := os.WriteFile(path, []byte("png-bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var gotParts []map[string]any
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/global/health":
|
||||
_, _ = w.Write([]byte(`{"healthy":true}`))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/session":
|
||||
_, _ = w.Write([]byte(`{"id":"ses_media"}`))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/session/ses_media/message":
|
||||
var body struct {
|
||||
Parts []map[string]any `json:"parts"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
gotParts = body.Parts
|
||||
_, _ = w.Write([]byte(`{"parts":[{"type":"text","text":"ok"}]}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
f := &opencodeForwarder{
|
||||
bin: "opencode", timeout: 5 * time.Second, workDir: dir,
|
||||
sessions: newOpencodeSessions(""),
|
||||
server: &opencodeServer{baseURL: ts.URL, httpClient: ts.Client()},
|
||||
}
|
||||
_, err := f.forwardWithAttachments(context.Background(), "conv", "看图", []connectMediaAttachment{{LocalPath: path, FileName: "evidence.png", MediaType: "image"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(gotParts) != 2 || gotParts[1]["type"] != "file" || gotParts[1]["filename"] != "evidence.png" || !strings.HasPrefix(fmt.Sprint(gotParts[1]["url"]), "file://") {
|
||||
t.Fatalf("parts = %#v", gotParts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodeForwarderUsesServerSessionAPI(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
storePath := filepath.Join(dir, "opencode-sessions.json")
|
||||
|
||||
@@ -80,6 +80,58 @@ func (f *qoderStreamForwarder) forward(ctx context.Context, convID, text string)
|
||||
return f.forwardStream(ctx, convID, text, nil)
|
||||
}
|
||||
|
||||
// forwardWithAttachments uses qodercli's native --attachment transport for a
|
||||
// media turn. The persistent stream-json protocol has no documented file-part
|
||||
// shape, so sending the same session through a one-shot CLI process is the
|
||||
// only reliable way to provide the original bytes without enabling broad file
|
||||
// tools for every ordinary chat message.
|
||||
func (f *qoderStreamForwarder) forwardWithAttachments(ctx context.Context, convID, text string, attachments []connectMediaAttachment) (string, error) {
|
||||
if len(attachments) == 0 {
|
||||
return f.forward(ctx, convID, text)
|
||||
}
|
||||
ctx, cancel := applyTimeout(ctx, f.timeout)
|
||||
defer cancel()
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
args := []string{"--print", "--output-format", "text", "--max-turns", "30"}
|
||||
if f.sessions != nil {
|
||||
args = append(args, f.sessions.args(convID)...)
|
||||
}
|
||||
if f.yolo {
|
||||
args = append(args, "--permission-mode", "bypass_permissions", "--dangerously-skip-permissions")
|
||||
} else {
|
||||
args = append(args, "--system-prompt", "", "--setting-sources", "", "--tools", "")
|
||||
}
|
||||
if f.model != "" {
|
||||
args = append(args, "--model", f.model)
|
||||
}
|
||||
for _, attachment := range attachments {
|
||||
if path := strings.TrimSpace(attachment.LocalPath); path != "" {
|
||||
args = append(args, "--attachment", path)
|
||||
}
|
||||
}
|
||||
args = append(args, "-p", text)
|
||||
cmd := exec.CommandContext(ctx, f.bin, args...)
|
||||
cmd.Dir = f.cwd()
|
||||
cmd.Env = append(os.Environ(), f.env...)
|
||||
out, err := cmd.Output()
|
||||
reply := strings.TrimSpace(string(out))
|
||||
if reply != "" && !agentReplyIsError(reply) {
|
||||
return brandReply(f.name, reply), nil
|
||||
}
|
||||
if reply != "" {
|
||||
return agentBackendErrorReply(reply), nil
|
||||
}
|
||||
if err != nil {
|
||||
if f.sessions != nil {
|
||||
f.sessions.reset(convID)
|
||||
}
|
||||
return "", fmt.Errorf("本地 %s agent 附件调用失败:%s", f.name, truncateRunes(execErrorMessage(err), 300))
|
||||
}
|
||||
return "(本地 agent 无文本输出)", nil
|
||||
}
|
||||
|
||||
func (f *qoderStreamForwarder) forwardStream(ctx context.Context, convID, text string, onDelta func(string)) (string, error) {
|
||||
ctx, cancel := applyTimeout(ctx, f.timeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -65,11 +65,7 @@ if "--input-format" in sys.argv:
|
||||
}), flush=True)
|
||||
else:
|
||||
prompt = sys.argv[-1] if len(sys.argv) > 1 else ""
|
||||
print(json.dumps({
|
||||
"type": "result",
|
||||
"subtype": "success",
|
||||
"message": {"content": [{"type": "text", "text": "one-shot " + prompt}]},
|
||||
}))
|
||||
print("one-shot " + prompt)
|
||||
`
|
||||
path := filepath.Join(dir, "qodercli")
|
||||
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||
@@ -78,6 +74,35 @@ else:
|
||||
return logPath
|
||||
}
|
||||
|
||||
func TestQoderForwarderUsesNativeAttachmentFlag(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
stubDir := t.TempDir()
|
||||
logPath := writeQoderStreamStub(t, stubDir)
|
||||
t.Setenv("DWS_QODER_STUB_LOG", logPath)
|
||||
attachmentPath := filepath.Join(t.TempDir(), "forwarded.mov")
|
||||
if err := os.WriteFile(attachmentPath, []byte("video-bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &qoderStreamForwarder{
|
||||
name: "qoderwork", bin: filepath.Join(stubDir, "qodercli"),
|
||||
timeout: 5 * time.Second, sessions: newConvSessions(""),
|
||||
}
|
||||
reply, err := f.forwardWithAttachments(context.Background(), "conv", "分析视频", []connectMediaAttachment{{LocalPath: attachmentPath, FileName: "forwarded.mov", MediaType: "video"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reply != "one-shot 分析视频" {
|
||||
t.Fatalf("reply = %q", reply)
|
||||
}
|
||||
raw, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"--attachment", "`+attachmentPath+`"`) || !strings.Contains(string(raw), `"-p", "分析视频"`) {
|
||||
t.Fatalf("qoder args missing attachment: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQoderForwarderKeepsStreamJSONProcessAlive(t *testing.T) {
|
||||
t.Setenv("DWS_CONNECT_NO_INSTALL", "1")
|
||||
t.Setenv("DWS_AGENT_CMD", "")
|
||||
|
||||
@@ -74,6 +74,44 @@ type streamingForwarder interface {
|
||||
forwardStream(ctx context.Context, convID, text string, onDelta func(string)) (string, error)
|
||||
}
|
||||
|
||||
// connectMediaAttachment is a DingTalk attachment that has already been
|
||||
// authenticated and downloaded by the common Stream ingress. Keeping the
|
||||
// attachment separate from the textual prompt lets multimodal backends pass
|
||||
// the original bytes through their native protocol instead of asking the
|
||||
// model to infer a local path from prose.
|
||||
type connectMediaAttachment struct {
|
||||
LocalPath string
|
||||
FileName string
|
||||
MediaType string
|
||||
}
|
||||
|
||||
// attachmentForwarder is implemented by backends with a native attachment
|
||||
// transport (for example OpenCode file parts or Gemini inlineData). Backends
|
||||
// without one still receive the absolute local path in the prompt and can use
|
||||
// their read tool, preserving compatibility with custom agents.
|
||||
type attachmentForwarder interface {
|
||||
forwardWithAttachments(ctx context.Context, convID, text string, attachments []connectMediaAttachment) (string, error)
|
||||
}
|
||||
|
||||
type streamingAttachmentForwarder interface {
|
||||
attachmentForwarder
|
||||
canStream() bool
|
||||
forwardStreamWithAttachments(ctx context.Context, convID, text string, attachments []connectMediaAttachment, onDelta func(string)) (string, error)
|
||||
}
|
||||
|
||||
func forwardConnectTurn(ctx context.Context, fwd forwarder, convID, prompt string, attachments []connectMediaAttachment, onDelta func(string)) (string, error) {
|
||||
if af, ok := fwd.(streamingAttachmentForwarder); ok {
|
||||
return af.forwardStreamWithAttachments(ctx, convID, prompt, attachments, onDelta)
|
||||
}
|
||||
if af, ok := fwd.(attachmentForwarder); ok {
|
||||
return af.forwardWithAttachments(ctx, convID, prompt, attachments)
|
||||
}
|
||||
if sf, ok := fwd.(streamingForwarder); ok {
|
||||
return sf.forwardStream(ctx, convID, prompt, onDelta)
|
||||
}
|
||||
return fwd.forward(ctx, convID, prompt)
|
||||
}
|
||||
|
||||
// sessionResetter is an optional capability: a forwarder that can forget a
|
||||
// conversation's agent session, so a built-in /new or /clear command starts a
|
||||
// fresh context. Forwarders with per-conversation memory (Claude-family exec,
|
||||
@@ -339,6 +377,44 @@ func (f *execForwarder) forward(ctx context.Context, convID, text string) (strin
|
||||
return "", fmt.Errorf("本地 %s agent 调用失败:%s", f.name, truncateRunes(msg, 300))
|
||||
}
|
||||
|
||||
// forwardWithAttachments grants the Claude-family CLIs read-only access to the
|
||||
// exact directories that contain this turn's downloaded attachments. These
|
||||
// agents otherwise run from an isolated scratch directory, so an absolute path
|
||||
// in prose can still be rejected by their external-directory permission gate.
|
||||
// The custom channel is intentionally left untouched because DWS cannot assume
|
||||
// flags understood by an arbitrary user command.
|
||||
func (f *execForwarder) forwardWithAttachments(ctx context.Context, convID, text string, attachments []connectMediaAttachment) (string, error) {
|
||||
switch f.name {
|
||||
case "claudecode", "codebuddy", "workbuddy":
|
||||
default:
|
||||
return f.forward(ctx, convID, text)
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
var dirs []string
|
||||
for _, attachment := range attachments {
|
||||
path := strings.TrimSpace(attachment.LocalPath)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if _, exists := seen[dir]; exists {
|
||||
continue
|
||||
}
|
||||
seen[dir] = struct{}{}
|
||||
dirs = append(dirs, dir)
|
||||
}
|
||||
if len(dirs) == 0 {
|
||||
return f.forward(ctx, convID, text)
|
||||
}
|
||||
clone := *f
|
||||
clone.argv = append([]string{f.argv[0]}, "--allowedTools", "Read")
|
||||
for _, dir := range dirs {
|
||||
clone.argv = append(clone.argv, "--add-dir", dir)
|
||||
}
|
||||
clone.argv = append(clone.argv, f.argv[1:]...)
|
||||
return clone.forward(ctx, convID, text)
|
||||
}
|
||||
|
||||
// convSessions maps a DingTalk conversation to a stable agent session ID, so a
|
||||
// channel CLI with addressable sessions keeps multi-turn context per chat.
|
||||
// First message of a conversation mints a UUID and passes `--session-id <id>`
|
||||
@@ -699,10 +775,10 @@ var agentSpecs = map[string]agentSpec{
|
||||
"workbuddy": {app: "WorkBuddy(自带 codebuddy)", bins: []string{"codebuddy"},
|
||||
globs: []string{"/Applications/WorkBuddy.app/Contents/Resources/app.asar.unpacked/cli/bin/codebuddy"},
|
||||
argvTail: []string{"--append-system-prompt",
|
||||
"你叫「WorkBuddy 助手」,是钉钉群里的智能助手。无论被问到你是谁,都只能自称 WorkBuddy 助手,绝不能提到 CodeBuddy 这个名字。请用简洁自然的中文直接回答问题;不要使用任何工具,不要尝试读写文件或执行命令。",
|
||||
"你叫「WorkBuddy 助手」,是钉钉群里的智能助手。无论被问到你是谁,都只能自称 WorkBuddy 助手,绝不能提到 CodeBuddy 这个名字。请用简洁自然的中文直接回答问题;不要主动使用工具、读写文件或执行命令。仅当用户消息明确附带了本地附件路径时,可以使用 Read 工具只读该附件,不得访问其它文件。",
|
||||
"-p"},
|
||||
streamArgvTail: []string{"--append-system-prompt",
|
||||
"你叫「WorkBuddy 助手」,是钉钉群里的智能助手。无论被问到你是谁,都只能自称 WorkBuddy 助手,绝不能提到 CodeBuddy 这个名字。请用简洁自然的中文直接回答问题;不要使用任何工具,不要尝试读写文件或执行命令。",
|
||||
"你叫「WorkBuddy 助手」,是钉钉群里的智能助手。无论被问到你是谁,都只能自称 WorkBuddy 助手,绝不能提到 CodeBuddy 这个名字。请用简洁自然的中文直接回答问题;不要主动使用工具、读写文件或执行命令。仅当用户消息明确附带了本地附件路径时,可以使用 Read 工具只读该附件,不得访问其它文件。",
|
||||
"-p", "--output-format", "stream-json", "--include-partial-messages"},
|
||||
streamParser: "cc", envFn: codebuddyEnv, hint: "https://www.codebuddy.cn/work/",
|
||||
modelFlag: "--model", ccSessions: true},
|
||||
@@ -1006,17 +1082,18 @@ type connectExtras struct {
|
||||
}
|
||||
|
||||
type connectQueuedTurn struct {
|
||||
convID string
|
||||
text string
|
||||
picCode string
|
||||
fileInfo fileInboundInfo
|
||||
webhook string
|
||||
msgID string
|
||||
msgType string
|
||||
senderStaffID string
|
||||
conversationID string
|
||||
conversationType string
|
||||
callbackData chatbot.BotCallbackDataModel
|
||||
convID string
|
||||
text string
|
||||
picCodes []string
|
||||
fileInfos []fileInboundInfo
|
||||
chatRecordLookups []chatRecordLookup
|
||||
webhook string
|
||||
msgID string
|
||||
msgType string
|
||||
senderStaffID string
|
||||
conversationID string
|
||||
conversationType string
|
||||
callbackData chatbot.BotCallbackDataModel
|
||||
}
|
||||
|
||||
func mergeConnectQueuedTurns(turns []connectQueuedTurn) connectQueuedTurn {
|
||||
@@ -1038,17 +1115,13 @@ func mergeConnectQueuedTurns(turns []connectQueuedTurn) connectQueuedTurn {
|
||||
lines = append(lines, fmt.Sprintf("%d. %s", i+1, connectTurnSummary(turn)))
|
||||
}
|
||||
merged.text = strings.Join(lines, "\n")
|
||||
if merged.picCode == "" && !merged.fileInfo.hasActionable() {
|
||||
for i := len(turns) - 1; i >= 0; i-- {
|
||||
if turns[i].picCode != "" {
|
||||
merged.picCode = turns[i].picCode
|
||||
break
|
||||
}
|
||||
if turns[i].fileInfo.hasActionable() {
|
||||
merged.fileInfo = turns[i].fileInfo
|
||||
break
|
||||
}
|
||||
}
|
||||
merged.picCodes = nil
|
||||
merged.fileInfos = nil
|
||||
merged.chatRecordLookups = nil
|
||||
for i := range turns {
|
||||
merged.picCodes = append(merged.picCodes, turns[i].picCodes...)
|
||||
merged.fileInfos = append(merged.fileInfos, turns[i].fileInfos...)
|
||||
merged.chatRecordLookups = append(merged.chatRecordLookups, turns[i].chatRecordLookups...)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
@@ -1065,16 +1138,21 @@ func connectTurnShouldStayStandalone(turn connectQueuedTurn) bool {
|
||||
|
||||
func connectTurnSummary(turn connectQueuedTurn) string {
|
||||
if text := strings.TrimSpace(turn.text); text != "" {
|
||||
if len(turn.picCodes) > 0 {
|
||||
return text + " [同时附有图片]"
|
||||
}
|
||||
return text
|
||||
}
|
||||
if turn.picCode != "" {
|
||||
if len(turn.picCodes) > 0 {
|
||||
return "[图片]"
|
||||
}
|
||||
if turn.fileInfo.hasActionable() {
|
||||
if name := strings.TrimSpace(turn.fileInfo.FileName); name != "" {
|
||||
return "[文件: " + name + "]"
|
||||
if len(turn.fileInfos) > 0 {
|
||||
if len(turn.fileInfos) == 1 {
|
||||
if name := strings.TrimSpace(turn.fileInfos[0].FileName); name != "" {
|
||||
return "[附件: " + name + "]"
|
||||
}
|
||||
}
|
||||
return "[文件]"
|
||||
return fmt.Sprintf("[%d 个附件]", len(turn.fileInfos))
|
||||
}
|
||||
return "[空消息]"
|
||||
}
|
||||
@@ -1127,33 +1205,40 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
cli.RegisterChatBotCallbackRouter(func(_ context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) {
|
||||
text := strings.TrimSpace(data.Text.Content)
|
||||
msgtype := strings.TrimSpace(data.Msgtype)
|
||||
// Picture messages carry no text — their payload is a downloadCode
|
||||
// resolved to a local file in the forward goroutine below.
|
||||
picCode := ""
|
||||
if strings.EqualFold(msgtype, "picture") {
|
||||
picCode = pictureDownloadCode(data.Content)
|
||||
}
|
||||
// File callbacks come in two shapes: client-sent files carry a
|
||||
// downloadCode; API-sent files (`dws chat message send --msg-type file
|
||||
// --dentry-id --space-id`) carry dentryId + spaceId instead and have
|
||||
// NO downloadCode. Both have to be recognisable or legit file messages
|
||||
// get silently dropped below.
|
||||
var fileInfo fileInboundInfo
|
||||
if strings.EqualFold(msgtype, "file") {
|
||||
fileInfo = parseFileInbound(data.Content)
|
||||
// Discover attachments by their locator fields, never by a msgtype
|
||||
// allowlist. msgtype is only a classification hint for the agent prompt.
|
||||
picCodes, fileInfos, unrecoverableCount := callbackInboundMedia(msgtype, data.Content)
|
||||
var chatRecordLookups []chatRecordLookup
|
||||
if unrecoverableCount > 0 {
|
||||
indexes := chatRecordUnknownIndexes(data.Content)
|
||||
if strings.TrimSpace(data.MsgId) != "" && len(indexes) > 0 {
|
||||
chatRecordLookups = append(chatRecordLookups, chatRecordLookup{
|
||||
MsgID: strings.TrimSpace(data.MsgId),
|
||||
UnknownIndexes: indexes,
|
||||
})
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] 转发记录中有 %d 条 unknownMsgType,将在 ACK 后补拉原始内容 (msgId=%s)\n", unrecoverableCount, data.MsgId)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] 转发记录中有 %d 条 unknownMsgType,但缺少外层消息 ID,保留原始 JSON 降级处理\n", unrecoverableCount)
|
||||
}
|
||||
}
|
||||
// Structured-text fallback: DingTalk leaves data.Text.Content blank on
|
||||
// markdown / richText callbacks (the body ships in data.Content). Without
|
||||
// this, `dws chat message send --group ... --text ...` — which defaults
|
||||
// to msgType=markdown — hits the drop branch below and the bot looks
|
||||
// dead to the sender.
|
||||
if text == "" && picCode == "" {
|
||||
if text == "" {
|
||||
// Forwarded records must keep their complete JSON, even if the outer
|
||||
// msgtype is renamed or a title-like field could be extracted as text.
|
||||
// Detect the record by payload shape rather than message type.
|
||||
if hasChatRecordPayload(data.Content) {
|
||||
text = rawCallbackPrompt(msgtype, data.Content)
|
||||
}
|
||||
// interactiveCard (a bot @-mentioning this bot) nests the body in
|
||||
// content.cardContent and carries the mention as its own leading
|
||||
// leaf; the leaf-aware extractor drops it so the agent gets the
|
||||
// clean instruction. Other structured-text shapes use the generic
|
||||
// extractor.
|
||||
if strings.EqualFold(msgtype, "interactiveCard") {
|
||||
// clean instruction. Detection is based on the payload shape so a
|
||||
// renamed/new type is handled identically.
|
||||
if text == "" {
|
||||
text = extractInteractiveCardText(data.Content)
|
||||
}
|
||||
if text == "" {
|
||||
@@ -1161,13 +1246,15 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
text = fallback
|
||||
}
|
||||
}
|
||||
if text == "" {
|
||||
text = rawCallbackPrompt(msgtype, data.Content)
|
||||
}
|
||||
}
|
||||
if (text == "" && picCode == "" && !fileInfo.hasActionable()) || data.SessionWebhook == "" {
|
||||
// Observability: silent drops are the #1 reason a working connector
|
||||
// looks dead. Log msgtype + a payload summary so an unhandled shape
|
||||
// (e.g. new-style file callback without downloadCode) shows up in
|
||||
// stderr instead of being invisible.
|
||||
fmt.Fprintf(os.Stderr, "[connect] 丢弃消息 msgtype=%q staffId=%s convId=%s msgId=%s content=%s (无正文/图片/可下载文件或 sessionWebhook 为空)\n",
|
||||
if data.SessionWebhook == "" {
|
||||
// A session webhook is required for the fallback reply path. Message
|
||||
// payload shape is deliberately not filtered here: unknown and complex
|
||||
// types are forwarded as raw JSON for the backend model to interpret.
|
||||
fmt.Fprintf(os.Stderr, "[connect] 丢弃消息 msgtype=%q staffId=%s convId=%s msgId=%s content=%s (sessionWebhook 为空,无法回复)\n",
|
||||
msgtype, data.SenderStaffId, data.ConversationId, data.MsgId, summarizeContent(data.Content))
|
||||
return []byte(""), nil
|
||||
}
|
||||
@@ -1198,10 +1285,10 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
sender = strings.TrimSpace(data.SenderStaffId)
|
||||
}
|
||||
shown := text
|
||||
if shown == "" && picCode != "" {
|
||||
if shown == "" && len(picCodes) > 0 {
|
||||
shown = "[图片]"
|
||||
} else if shown == "" && fileInfo.hasActionable() {
|
||||
shown = "[文件: " + fileInfo.FileName + "]"
|
||||
} else if shown == "" && len(fileInfos) > 0 {
|
||||
shown = connectTurnSummary(connectQueuedTurn{fileInfos: fileInfos})
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[connect] 收到 @%s: %s (convType=%s convId=%s staffId=%s msgId=%s)\n",
|
||||
sender, truncateRunes(shown, 80), data.ConversationType, data.ConversationId, data.SenderStaffId, data.MsgId)
|
||||
@@ -1218,17 +1305,18 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
}
|
||||
msgID := strings.TrimSpace(data.MsgId)
|
||||
turn := connectQueuedTurn{
|
||||
convID: convID,
|
||||
text: text,
|
||||
picCode: picCode,
|
||||
fileInfo: fileInfo,
|
||||
webhook: webhook,
|
||||
msgID: msgID,
|
||||
msgType: msgtype,
|
||||
senderStaffID: strings.TrimSpace(data.SenderStaffId),
|
||||
conversationID: strings.TrimSpace(data.ConversationId),
|
||||
conversationType: strings.TrimSpace(data.ConversationType),
|
||||
callbackData: *data,
|
||||
convID: convID,
|
||||
text: text,
|
||||
picCodes: picCodes,
|
||||
fileInfos: fileInfos,
|
||||
chatRecordLookups: chatRecordLookups,
|
||||
webhook: webhook,
|
||||
msgID: msgID,
|
||||
msgType: msgtype,
|
||||
senderStaffID: strings.TrimSpace(data.SenderStaffId),
|
||||
conversationID: strings.TrimSpace(data.ConversationId),
|
||||
conversationType: strings.TrimSpace(data.ConversationType),
|
||||
callbackData: *data,
|
||||
}
|
||||
// Same-conversation agent calls never run in parallel; messages received
|
||||
// while a turn is running are merged into one pending follow-up instead
|
||||
@@ -1239,8 +1327,9 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
fmt.Fprintf(os.Stderr, "[connect] 合并 %d 条待处理消息 (convId=%s, latestMsgId=%s)\n", len(turns), turn.convID, turn.msgID)
|
||||
}
|
||||
text := turn.text
|
||||
picCode := turn.picCode
|
||||
fileInfo := turn.fileInfo
|
||||
picCodes := turn.picCodes
|
||||
fileInfos := turn.fileInfos
|
||||
chatRecordLookups := turn.chatRecordLookups
|
||||
webhook := turn.webhook
|
||||
convID := turn.convID
|
||||
msgID := turn.msgID
|
||||
@@ -1293,34 +1382,96 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
// Assemble the forwarded prompt: resolve an attached picture (the
|
||||
// top Q&A inbound is an error screenshot), then knowledge-augment.
|
||||
prompt := text
|
||||
if picCode != "" {
|
||||
if localPath, derr := mediaCli.downloadMessageFile(context.Background(), clientID, picCode); derr != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] 图片下载失败: %v\n", derr)
|
||||
if prompt == "" {
|
||||
prompt = "(用户发来一张图片,但图片下载失败了。请告知用户图片没收到,建议补充文字描述。)"
|
||||
for _, lookup := range chatRecordLookups {
|
||||
lookupCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
enrichment, lookupErr := recoverChatRecordUnknowns(lookupCtx, lookup, callMCPToolReturnTextOnServer)
|
||||
cancel()
|
||||
if lookupErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] unknownMsgType 补拉失败,保留原始 JSON (msgId=%s): %v\n", lookup.MsgID, lookupErr)
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(enrichment.Prompt) != "" {
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
prompt = enrichment.Prompt
|
||||
} else {
|
||||
prompt += "\n\n" + enrichment.Prompt
|
||||
}
|
||||
} else if prompt == "" {
|
||||
prompt = "用户发来一张图片(本地路径 " + localPath + "),请查看图片内容并回答其中的问题。"
|
||||
} else {
|
||||
prompt = prompt + "\n(用户同时附了一张图片,本地路径 " + localPath + ",请结合图片内容回答。)"
|
||||
}
|
||||
fileInfos = append(fileInfos, enrichment.Files...)
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] unknownMsgType 补拉完成: 原始附件=%d 未定位=%d (msgId=%s)\n", len(enrichment.Files), enrichment.MissingCount, lookup.MsgID)
|
||||
if enrichment.MissingCount > 0 {
|
||||
prompt += fmt.Sprintf("\n(其中 %d 个转发附件仍未能定位原始文件,请明确告知用户未读取到这些附件。)", enrichment.MissingCount)
|
||||
}
|
||||
}
|
||||
if fileInfo.hasActionable() {
|
||||
var attachments []connectMediaAttachment
|
||||
for i, picCode := range picCodes {
|
||||
localPath, derr := mediaCli.downloadMessageFile(context.Background(), clientID, picCode)
|
||||
if derr != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] 第 %d 张图片下载失败: %v\n", i+1, derr)
|
||||
if prompt == "" {
|
||||
prompt = "(用户发来图片,但图片下载失败了。请告知用户图片没收到,建议补充文字描述。)"
|
||||
} else {
|
||||
prompt += "\n(用户同时附了一张图片,但图片下载失败,请基于现有文字回答并说明未能读取图片。)"
|
||||
}
|
||||
continue
|
||||
}
|
||||
if prompt == "" {
|
||||
prompt = "用户发来一张图片(本地路径 " + localPath + "),请查看图片内容并回答其中的问题。"
|
||||
} else {
|
||||
prompt += "\n(用户同时附了一张图片,本地路径 " + localPath + ",请结合图片内容回答。)"
|
||||
}
|
||||
attachments = append(attachments, connectMediaAttachment{
|
||||
LocalPath: localPath,
|
||||
FileName: filepath.Base(localPath),
|
||||
MediaType: "image",
|
||||
})
|
||||
}
|
||||
for i, fileInfo := range fileInfos {
|
||||
if !fileInfo.hasActionable() {
|
||||
continue
|
||||
}
|
||||
fileName := fileInfo.FileName
|
||||
mediaType := inboundMediaType(fileInfo.MediaType)
|
||||
mediaLabel := "文件"
|
||||
successPrompt := "请读取文件内容并回答"
|
||||
switch mediaType {
|
||||
case "image":
|
||||
mediaLabel = "图片"
|
||||
successPrompt = "请查看图片内容并回答"
|
||||
case "audio":
|
||||
mediaLabel = "语音"
|
||||
successPrompt = "请听取或转写语音内容并回答"
|
||||
case "video":
|
||||
mediaLabel = "视频"
|
||||
successPrompt = "请查看并分析视频内容后回答"
|
||||
}
|
||||
var localPath string
|
||||
var derr error
|
||||
if fileInfo.DownloadCode != "" {
|
||||
localPath, derr = mediaCli.downloadMessageFile(context.Background(), clientID, fileInfo.DownloadCode)
|
||||
localPath, derr = mediaCli.downloadMessageFileNamed(context.Background(), clientID, fileInfo.DownloadCode, fileName)
|
||||
if derr != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] 文件下载失败: %v\n", derr)
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] 第 %d 个%s下载失败: %v\n", i+1, mediaLabel, derr)
|
||||
}
|
||||
} else if fileInfo.MediaID != "" || fileInfo.FileID != "" {
|
||||
downloadCtx, cancel := context.WithTimeout(context.Background(), mediaDownloadTimeout)
|
||||
localPath, derr = mediaCli.downloadRecoveredChatRecordFile(downloadCtx, fileInfo)
|
||||
cancel()
|
||||
if derr != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] 第 %d 个转发%s原始内容下载失败: %v\n", i+1, mediaLabel, derr)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case localPath != "":
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] 第 %d 个%s已完整下载: %s\n", i+1, mediaLabel, localPath)
|
||||
attachments = append(attachments, connectMediaAttachment{
|
||||
LocalPath: localPath,
|
||||
FileName: fileName,
|
||||
MediaType: mediaType,
|
||||
})
|
||||
if prompt == "" {
|
||||
prompt = "用户发来一个文件「" + fileName + "」(本地路径 " + localPath + "),请读取文件内容并回答。"
|
||||
prompt = "用户发来一个" + mediaLabel + "「" + fileName + "」(本地路径 " + localPath + ")," + successPrompt + "。"
|
||||
} else {
|
||||
prompt = prompt + "\n(用户同时附了一个文件「" + fileName + "」,本地路径 " + localPath + ",请结合文件内容回答。)"
|
||||
prompt += "\n(用户同时附了一个" + mediaLabel + "「" + fileName + "」,本地路径 " + localPath + "," + successPrompt + "。)"
|
||||
}
|
||||
case fileInfo.DentryID != 0 && fileInfo.SpaceID != 0:
|
||||
// API-sent file: resolve via storage API (userId→unionId,
|
||||
@@ -1335,10 +1486,16 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
localPath = dp
|
||||
}
|
||||
if localPath != "" {
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] 第 %d 个%s已完整下载: %s\n", i+1, mediaLabel, localPath)
|
||||
attachments = append(attachments, connectMediaAttachment{
|
||||
LocalPath: localPath,
|
||||
FileName: fileName,
|
||||
MediaType: mediaType,
|
||||
})
|
||||
if prompt == "" {
|
||||
prompt = "用户发来一个文件「" + fileName + "」(本地路径 " + localPath + "),请读取文件内容并回答。"
|
||||
prompt = "用户发来一个" + mediaLabel + "「" + fileName + "」(本地路径 " + localPath + ")," + successPrompt + "。"
|
||||
} else {
|
||||
prompt = prompt + "\n(用户同时附了一个文件「" + fileName + "」,本地路径 " + localPath + ",请结合文件内容回答。)"
|
||||
prompt += "\n(用户同时附了一个" + mediaLabel + "「" + fileName + "」,本地路径 " + localPath + "," + successPrompt + "。)"
|
||||
}
|
||||
} else {
|
||||
meta := fmt.Sprintf("文件名「%s」,dentryId=%d,spaceId=%d", fileName, fileInfo.DentryID, fileInfo.SpaceID)
|
||||
@@ -1355,11 +1512,22 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
}
|
||||
}
|
||||
default:
|
||||
failure := "用户发来一个" + mediaLabel + "「" + fileName + "」,但原始内容下载失败。请明确告知用户该附件未能读取,建议重新发送或补充文字描述。"
|
||||
if prompt == "" {
|
||||
prompt = "(用户发来一个文件「" + fileName + "」,但文件下载失败了。请告知用户文件没收到,建议重新发送。)"
|
||||
prompt = "(" + failure + ")"
|
||||
} else {
|
||||
prompt += "\n(" + failure + ")"
|
||||
}
|
||||
}
|
||||
}
|
||||
originalAttachments := append([]connectMediaAttachment(nil), attachments...)
|
||||
defer cleanupConnectMediaAttachments(originalAttachments)
|
||||
if _, isOpenCode := fwd.(*opencodeForwarder); isOpenCode {
|
||||
prepareCtx, cancel := context.WithTimeout(context.Background(), mediaDownloadTimeout)
|
||||
prompt, attachments = prepareOpenCodeAttachments(prepareCtx, prompt, attachments)
|
||||
cancel()
|
||||
}
|
||||
defer cleanupConnectMediaAttachments(attachments)
|
||||
if extras.kb != nil {
|
||||
prompt = extras.kb.augment(prompt)
|
||||
}
|
||||
@@ -1417,13 +1585,7 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
}
|
||||
}
|
||||
|
||||
var reply string
|
||||
var err error
|
||||
if streamable {
|
||||
reply, err = sf.forwardStream(context.Background(), convID, prompt, onDelta)
|
||||
} else {
|
||||
reply, err = fwd.forward(context.Background(), convID, prompt)
|
||||
}
|
||||
reply, err := forwardConnectTurn(context.Background(), fwd, convID, prompt, attachments, onDelta)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect] 转发失败 (%s, 耗时 %s): %v\n", channel, time.Since(started).Round(time.Millisecond), err)
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
|
||||
@@ -5,13 +5,97 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type attachmentRecordingForwarder struct {
|
||||
attachments []connectMediaAttachment
|
||||
}
|
||||
|
||||
func (f *attachmentRecordingForwarder) label() string { return "recording" }
|
||||
func (f *attachmentRecordingForwarder) forward(context.Context, string, string) (string, error) {
|
||||
return "text-only", nil
|
||||
}
|
||||
func (f *attachmentRecordingForwarder) forwardWithAttachments(_ context.Context, _ string, _ string, attachments []connectMediaAttachment) (string, error) {
|
||||
f.attachments = append([]connectMediaAttachment(nil), attachments...)
|
||||
return "with-attachments", nil
|
||||
}
|
||||
|
||||
func TestForwardConnectTurnPreservesAttachmentsForCapableAgent(t *testing.T) {
|
||||
fwd := &attachmentRecordingForwarder{}
|
||||
want := []connectMediaAttachment{{LocalPath: "/tmp/original.mov", FileName: "original.mov", MediaType: "video"}}
|
||||
reply, err := forwardConnectTurn(context.Background(), fwd, "conv", "prompt", want, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reply != "with-attachments" || len(fwd.attachments) != 1 || fwd.attachments[0] != want[0] {
|
||||
t.Fatalf("reply=%q attachments=%#v", reply, fwd.attachments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecForwarderAllowsOnlyAttachmentDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "args.log")
|
||||
stub := filepath.Join(dir, "agent")
|
||||
script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$DWS_ARGS_LOG\"\nprintf 'ok\\n'\n"
|
||||
if err := os.WriteFile(stub, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
attachmentDir := t.TempDir()
|
||||
attachmentPath := filepath.Join(attachmentDir, "report.md")
|
||||
if err := os.WriteFile(attachmentPath, []byte("body"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &execForwarder{name: "workbuddy", argv: []string{stub, "-p"}, env: []string{"DWS_ARGS_LOG=" + logPath}, timeout: time.Second}
|
||||
reply, err := f.forwardWithAttachments(context.Background(), "conv", "read it", []connectMediaAttachment{{LocalPath: attachmentPath, FileName: "report.md", MediaType: "file"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reply != "ok" {
|
||||
t.Fatalf("reply = %q", reply)
|
||||
}
|
||||
raw, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
args := string(raw)
|
||||
for _, want := range []string{"--allowedTools", "Read", "--add-dir", attachmentDir, "-p", "read it"} {
|
||||
if !strings.Contains(args, want+"\n") {
|
||||
t.Fatalf("args missing %q:\n%s", want, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryStreamBridgeAgentHasAttachmentDeliveryPath(t *testing.T) {
|
||||
implementations := map[string]any{
|
||||
"claudecode": (*execForwarder)(nil),
|
||||
"codebuddy": (*execForwarder)(nil),
|
||||
"workbuddy": (*execForwarder)(nil),
|
||||
"custom": (*execForwarder)(nil),
|
||||
"qoder": (*qoderStreamForwarder)(nil),
|
||||
"qoderwork": (*qoderStreamForwarder)(nil),
|
||||
"codex": (*codexAppServerForwarder)(nil),
|
||||
"opencode": (*opencodeForwarder)(nil),
|
||||
"gemini": (*geminiAPIForwarder)(nil),
|
||||
}
|
||||
for channel := range agentSpecs {
|
||||
impl, ok := implementations[channel]
|
||||
if !ok {
|
||||
t.Errorf("agent channel %q has no declared attachment delivery path", channel)
|
||||
continue
|
||||
}
|
||||
if _, ok := impl.(attachmentForwarder); !ok {
|
||||
t.Errorf("agent channel %q implementation %T is not attachment-aware", channel, impl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrandReply covers the qoderwork identity rewrite using the exact replies
|
||||
// captured from a real qodercli (QoderWork.app) headless run.
|
||||
func TestBrandReply(t *testing.T) {
|
||||
@@ -179,6 +263,69 @@ func TestMergeConnectQueuedTurnsBuildsSinglePrompt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConnectQueuedTurnsPreservesAllPictures(t *testing.T) {
|
||||
merged := mergeConnectQueuedTurns([]connectQueuedTurn{
|
||||
{convID: "conv-1", text: "第一张", picCodes: []string{"pic-1"}, msgID: "m1"},
|
||||
{convID: "conv-1", text: "再补两张", picCodes: []string{"pic-2", "pic-3"}, msgID: "m2"},
|
||||
})
|
||||
want := []string{"pic-1", "pic-2", "pic-3"}
|
||||
if len(merged.picCodes) != len(want) {
|
||||
t.Fatalf("merged picCodes = %v, want %v", merged.picCodes, want)
|
||||
}
|
||||
for i := range want {
|
||||
if merged.picCodes[i] != want[i] {
|
||||
t.Fatalf("merged picCodes = %v, want %v", merged.picCodes, want)
|
||||
}
|
||||
}
|
||||
for _, text := range []string{"第一张 [同时附有图片]", "再补两张 [同时附有图片]"} {
|
||||
if !strings.Contains(merged.text, text) {
|
||||
t.Fatalf("merged prompt missing %q:\n%s", text, merged.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConnectQueuedTurnsPreservesAllAttachments(t *testing.T) {
|
||||
merged := mergeConnectQueuedTurns([]connectQueuedTurn{
|
||||
{
|
||||
convID: "conv-1",
|
||||
text: "第一批",
|
||||
fileInfos: []fileInboundInfo{{DownloadCode: "audio-1", FileName: "语音消息", MediaType: "audio"}},
|
||||
msgID: "m1",
|
||||
},
|
||||
{
|
||||
convID: "conv-1",
|
||||
text: "第二批",
|
||||
fileInfos: []fileInboundInfo{
|
||||
{DownloadCode: "video-1", FileName: "demo.mov", MediaType: "video"},
|
||||
{DownloadCode: "file-1", FileName: "report.md", MediaType: "file"},
|
||||
},
|
||||
msgID: "m2",
|
||||
},
|
||||
})
|
||||
if len(merged.fileInfos) != 3 {
|
||||
t.Fatalf("merged fileInfos = %#v, want all three attachments", merged.fileInfos)
|
||||
}
|
||||
for i, want := range []string{"audio-1", "video-1", "file-1"} {
|
||||
if merged.fileInfos[i].DownloadCode != want {
|
||||
t.Fatalf("merged fileInfos[%d] = %#v, want code %q", i, merged.fileInfos[i], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConnectQueuedTurnsKeepsEveryChatRecordLookup(t *testing.T) {
|
||||
merged := mergeConnectQueuedTurns([]connectQueuedTurn{
|
||||
{convID: "conv-1", text: "first", chatRecordLookups: []chatRecordLookup{{MsgID: "outer-1", UnknownIndexes: []int{1}}}},
|
||||
{convID: "conv-1", text: "second", chatRecordLookups: []chatRecordLookup{{MsgID: "outer-2", UnknownIndexes: []int{0, 2}}}},
|
||||
})
|
||||
want := []chatRecordLookup{
|
||||
{MsgID: "outer-1", UnknownIndexes: []int{1}},
|
||||
{MsgID: "outer-2", UnknownIndexes: []int{0, 2}},
|
||||
}
|
||||
if !reflect.DeepEqual(merged.chatRecordLookups, want) {
|
||||
t.Fatalf("chatRecordLookups = %#v, want %#v", merged.chatRecordLookups, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConnectQueuedTurnsKeepsControlMessagesStandalone(t *testing.T) {
|
||||
for _, text := range []string{"/clear", "同意", "拒绝", "重试"} {
|
||||
merged := mergeConnectQueuedTurns([]connectQueuedTurn{
|
||||
|
||||
+64
-3
@@ -795,7 +795,7 @@ func newDocCommand() *cobra.Command {
|
||||
dws doc create 创建文档
|
||||
dws doc update 更新文档内容
|
||||
dws doc block [list|insert|update|delete] 块级编辑
|
||||
dws doc comment [list|create|delete] 文档评论管理
|
||||
dws doc comment [list|create|reply|update|delete|create-inline] 文档评论管理
|
||||
dws doc export 导出在线文档 (支持 docx / markdown / pdf,自动完成提交→轮询→下载)
|
||||
dws doc export get 查询导出任务结果 (手动兜底)
|
||||
dws doc import 导入本地文件为在线文档 (支持 docx / xlsx / md 等)
|
||||
@@ -1951,6 +1951,67 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
commentReplyCmd.Flags().Bool("emoji", false, "设为 true 时作为表情贴图回复 (默认 false)")
|
||||
commentReplyCmd.Flags().String("mention", "", "被 @ 的用户 uid 列表,逗号分隔")
|
||||
|
||||
commentUpdateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "更新文档评论",
|
||||
Long: `更新指定文档中的一条评论。
|
||||
|
||||
--comment-key 为待更新评论的唯一标识,可从 comment list、create 或 create-inline 的返回结果中获取。
|
||||
可通过 --mention 指定更新后评论中被 @ 的用户 uid 列表。`,
|
||||
Example: ` dws doc comment update --node DOC_ID --comment-key COMMENT_KEY --content "已按最新数据修正"
|
||||
dws doc comment update --node DOC_ID --comment-key COMMENT_KEY --content "请确认" --mention uid1,uid2`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "comment-key", "content"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"commentKey": mustGetFlag(cmd, "comment-key"),
|
||||
"content": mustGetFlag(cmd, "content"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("mention"); v != "" {
|
||||
toolArgs["mentionedUserIds"] = parseCommentMentionIds(v)
|
||||
}
|
||||
return callMCPToolOnServer("doc-comment", "update_comment", toolArgs)
|
||||
},
|
||||
}
|
||||
commentUpdateCmd.Flags().String("node", "", "目标文档的标识,支持传入 URL 或 ID (必填)")
|
||||
commentUpdateCmd.Flags().String("comment-key", "", "待更新评论的 commentKey,可从 list/create/create-inline 结果获取 (必填)")
|
||||
commentUpdateCmd.Flags().String("content", "", "更新后的评论文字内容,纯文本 (必填)")
|
||||
commentUpdateCmd.Flags().String("mention", "", "被 @ 的用户 uid 列表,逗号分隔")
|
||||
|
||||
commentDeleteCmd := &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "删除文档评论",
|
||||
Long: `删除指定文档中的一条评论。
|
||||
|
||||
这是不可恢复的危险操作。执行前需要交互确认,或在用户已明确同意后传入全局 --yes 跳过确认。`,
|
||||
Example: ` dws doc comment delete --node DOC_ID --comment-key COMMENT_KEY --yes`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "comment-key"); err != nil {
|
||||
return err
|
||||
}
|
||||
commentKey := mustGetFlag(cmd, "comment-key")
|
||||
if !confirmDelete("文档评论", commentKey) {
|
||||
return nil
|
||||
}
|
||||
return callMCPToolOnServer("doc-comment", "delete_comment", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"commentKey": commentKey,
|
||||
})
|
||||
},
|
||||
}
|
||||
commentDeleteCmd.Flags().String("node", "", "目标文档的标识,支持传入 URL 或 ID (必填)")
|
||||
commentDeleteCmd.Flags().String("comment-key", "", "待删除评论的 commentKey,可从 list/create/create-inline 结果获取 (必填)")
|
||||
|
||||
commentCreateInlineCmd := &cobra.Command{
|
||||
Use: "create-inline",
|
||||
Short: "创建划词评论",
|
||||
@@ -2006,7 +2067,7 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
commentCreateInlineCmd.Flags().String("mention", "", "被 @ 的用户 uid 列表,逗号分隔")
|
||||
|
||||
// comment 子命令的 --node 隐藏别名
|
||||
commentNodeAliasCmds := []*cobra.Command{commentListCmd, commentCreateCmd, commentReplyCmd, commentCreateInlineCmd}
|
||||
commentNodeAliasCmds := []*cobra.Command{commentListCmd, commentCreateCmd, commentReplyCmd, commentUpdateCmd, commentDeleteCmd, commentCreateInlineCmd}
|
||||
for _, c := range commentNodeAliasCmds {
|
||||
c.Flags().String("url", "", "--node 的别名")
|
||||
c.Flags().String("id", "", "--node 的别名")
|
||||
@@ -2020,7 +2081,7 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
_ = c.Flags().MarkHidden("file-id")
|
||||
}
|
||||
|
||||
commentCmd.AddCommand(commentListCmd, commentCreateCmd, commentReplyCmd, commentCreateInlineCmd)
|
||||
commentCmd.AddCommand(commentListCmd, commentCreateCmd, commentReplyCmd, commentUpdateCmd, commentDeleteCmd, commentCreateInlineCmd)
|
||||
|
||||
// ── permission (文档协作权限) ────────────────────────────
|
||||
permissionCmd := &cobra.Command{
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type docCommentMutationCall struct {
|
||||
productID string
|
||||
toolName string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type docCommentMutationCaller struct {
|
||||
calls []docCommentMutationCall
|
||||
}
|
||||
|
||||
func (c *docCommentMutationCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, docCommentMutationCall{productID: productID, toolName: toolName, args: args})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
|
||||
}
|
||||
|
||||
func (*docCommentMutationCaller) Format() string { return "json" }
|
||||
func (*docCommentMutationCaller) DryRun() bool { return false }
|
||||
func (*docCommentMutationCaller) Fields() string { return "" }
|
||||
func (*docCommentMutationCaller) JQ() string { return "" }
|
||||
|
||||
func executeDocCommentMutationCommand(t *testing.T, caller *docCommentMutationCaller, processArgs []string, args ...string) error {
|
||||
t.Helper()
|
||||
previousDeps := deps
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
})
|
||||
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
os.Args = processArgs
|
||||
cmd := newDocCommand()
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(args)
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
func TestDocCommentUpdateDeleteCommandsRegistered(t *testing.T) {
|
||||
root := newDocCommand()
|
||||
cases := []struct {
|
||||
path string
|
||||
flags []string
|
||||
}{
|
||||
{path: "update", flags: []string{"node", "url", "id", "node-id", "doc-id", "file-id", "comment-key", "content", "mention"}},
|
||||
{path: "delete", flags: []string{"node", "url", "id", "node-id", "doc-id", "file-id", "comment-key"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
cmd, remaining, err := root.Find([]string{"comment", tc.path})
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("dws doc comment %s not registered: cmd=%v remaining=%v err=%v", tc.path, cmd, remaining, err)
|
||||
}
|
||||
for _, flag := range tc.flags {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("dws doc comment %s missing flag --%s", tc.path, flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCommentUpdateMapsOpenToolArguments(t *testing.T) {
|
||||
caller := &docCommentMutationCaller{}
|
||||
err := executeDocCommentMutationCommand(t, caller, []string{"dws", "doc"},
|
||||
"comment", "update", "--file-id", "doc-1", "--comment-key", "comment-1",
|
||||
"--content", "updated", "--mention", "uid-1, ,uid-2")
|
||||
if err != nil {
|
||||
t.Fatalf("doc comment update returned error: %v", err)
|
||||
}
|
||||
want := docCommentMutationCall{
|
||||
productID: "doc-comment",
|
||||
toolName: "update_comment",
|
||||
args: map[string]any{
|
||||
"nodeId": "doc-1",
|
||||
"commentKey": "comment-1",
|
||||
"content": "updated",
|
||||
"mentionedUserIds": []string{"uid-1", "uid-2"},
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCommentUpdateOmitsMentionWhenUnset(t *testing.T) {
|
||||
caller := &docCommentMutationCaller{}
|
||||
err := executeDocCommentMutationCommand(t, caller, []string{"dws", "doc"},
|
||||
"comment", "update", "--node", "doc-1", "--comment-key", "comment-1", "--content", "updated")
|
||||
if err != nil {
|
||||
t.Fatalf("doc comment update returned error: %v", err)
|
||||
}
|
||||
want := map[string]any{"nodeId": "doc-1", "commentKey": "comment-1", "content": "updated"}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("calls = %#v, want args %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCommentMutationsRejectMissingRequiredFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "update node", args: []string{"comment", "update", "--comment-key", "comment-1", "--content", "updated"}},
|
||||
{name: "update comment key", args: []string{"comment", "update", "--node", "doc-1", "--content", "updated"}},
|
||||
{name: "update content", args: []string{"comment", "update", "--node", "doc-1", "--comment-key", "comment-1"}},
|
||||
{name: "delete node", args: []string{"comment", "delete", "--comment-key", "comment-1"}},
|
||||
{name: "delete comment key", args: []string{"comment", "delete", "--node", "doc-1"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &docCommentMutationCaller{}
|
||||
err := executeDocCommentMutationCommand(t, caller, []string{"dws", "doc", "--yes"}, tt.args...)
|
||||
if err == nil {
|
||||
t.Fatal("command with missing required flag returned nil error")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCommentDeleteMapsArgumentsAfterYesConfirmation(t *testing.T) {
|
||||
caller := &docCommentMutationCaller{}
|
||||
err := executeDocCommentMutationCommand(t, caller, []string{"dws", "doc", "comment", "delete", "--yes"},
|
||||
"comment", "delete", "--id", "doc-1", "--comment-key", "comment-1")
|
||||
if err != nil {
|
||||
t.Fatalf("doc comment delete returned error: %v", err)
|
||||
}
|
||||
want := docCommentMutationCall{
|
||||
productID: "doc-comment",
|
||||
toolName: "delete_comment",
|
||||
args: map[string]any{"nodeId": "doc-1", "commentKey": "comment-1"},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCommentDeleteCancellationSkipsRemoteCall(t *testing.T) {
|
||||
previousStdin := os.Stdin
|
||||
reader, writer, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Pipe: %v", err)
|
||||
}
|
||||
if _, err := writer.WriteString("no\n"); err != nil {
|
||||
t.Fatalf("write confirmation: %v", err)
|
||||
}
|
||||
_ = writer.Close()
|
||||
os.Stdin = reader
|
||||
t.Cleanup(func() {
|
||||
os.Stdin = previousStdin
|
||||
_ = reader.Close()
|
||||
})
|
||||
|
||||
caller := &docCommentMutationCaller{}
|
||||
err = executeDocCommentMutationCommand(t, caller, []string{"dws", "doc"},
|
||||
"comment", "delete", "--node", "doc-1", "--comment-key", "comment-1")
|
||||
if err != nil {
|
||||
t.Fatalf("cancelled doc comment delete returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0 after cancellation", len(caller.calls))
|
||||
}
|
||||
}
|
||||
@@ -256,7 +256,7 @@ func newDriveCommand() *cobra.Command {
|
||||
driveCmd := &cobra.Command{
|
||||
Use: "drive",
|
||||
Short: "钉盘文件管理",
|
||||
Long: `钉盘:列出文件/文件夹、获取元数据、下载链接、创建文件夹、获取上传信息、提交上传。`,
|
||||
Long: `钉盘:列出文件/文件夹、获取元数据和统计信息、创建快捷方式、下载、上传及管理文件。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
@@ -872,6 +872,56 @@ func newDriveCommand() *cobra.Command {
|
||||
driveRenameCmd.Flags().String("node", "", "文档/文件 ID 或 URL (必填)")
|
||||
driveRenameCmd.Flags().String("name", "", "新名称 (必填)")
|
||||
|
||||
driveStatsCmd := &cobra.Command{
|
||||
Use: "stats",
|
||||
Short: "获取节点统计信息",
|
||||
Long: `获取指定节点的统计数据,包括阅读人数、阅读次数、编辑次数、评论数、点赞数、预览次数和下载次数等。
|
||||
|
||||
不同文件类型返回的统计维度可能不同。--node 支持节点 ID 或文档 URL。`,
|
||||
Example: ` dws drive stats --node <dentryUuid>
|
||||
dws drive stats --node https://alidocs.dingtalk.com/i/nodes/<dentryUuid>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("drive", "get_node_stats", map[string]any{"nodeId": nodeID})
|
||||
},
|
||||
}
|
||||
driveStatsCmd.Flags().String("node", "", "节点 ID 或文档 URL (必填)")
|
||||
|
||||
driveShortcutCmd := &cobra.Command{
|
||||
Use: "shortcut",
|
||||
Short: "为节点创建快捷方式",
|
||||
Long: `为指定源节点创建快捷方式,并放置到目标文件夹或知识库。
|
||||
|
||||
通过 --node 指定源节点。--folder 和 --workspace 均为可选;都不传时由服务端选择默认位置。
|
||||
若同时指定,--folder 是目标文件夹,--workspace 用于指定其所属知识库。`,
|
||||
Example: ` dws drive shortcut --node <dentryUuid>
|
||||
dws drive shortcut --node <dentryUuid> --folder <targetFolderId>
|
||||
dws drive shortcut --node <dentryUuid> --workspace <workspaceId>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
if v := docFolderFlag(cmd); v != "" {
|
||||
if err := validateDocFolderID(v); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["targetFolderId"] = v
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
}
|
||||
return callMCPToolOnServer("drive", "create_shortcut", toolArgs)
|
||||
},
|
||||
}
|
||||
driveShortcutCmd.Flags().String("node", "", "源节点 ID 或文档 URL (必填)")
|
||||
driveShortcutCmd.Flags().String("folder", "", "目标文件夹 nodeId (可选)")
|
||||
driveShortcutCmd.Flags().String("workspace", "", "目标知识库 ID (可选)")
|
||||
|
||||
// ── drive permission (文档节点权限管理) ──
|
||||
drivePermissionCmd := &cobra.Command{
|
||||
Use: "permission",
|
||||
@@ -1041,7 +1091,7 @@ func newDriveCommand() *cobra.Command {
|
||||
|
||||
// --node 隐藏别名(保持与迁移前 doc 命令一致)
|
||||
driveNodeAliasCmds := []*cobra.Command{
|
||||
driveCopyCmd, driveMoveCmd, driveRenameCmd,
|
||||
driveCopyCmd, driveMoveCmd, driveRenameCmd, driveStatsCmd, driveShortcutCmd,
|
||||
}
|
||||
for _, c := range driveNodeAliasCmds {
|
||||
c.Flags().String("url", "", "")
|
||||
@@ -1240,7 +1290,7 @@ func newDriveCommand() *cobra.Command {
|
||||
for _, cmd := range []*cobra.Command{
|
||||
driveListCmd, driveListSpacesCmd, driveInfoCmd, driveDownloadCmd,
|
||||
driveMkdirCmd, driveUploadInfoCmd, driveCommitCmd, driveUploadCmd, driveDeleteCmd,
|
||||
driveSearchCmd, driveCopyCmd, driveMoveCmd, driveRenameCmd,
|
||||
driveSearchCmd, driveCopyCmd, driveMoveCmd, driveRenameCmd, driveStatsCmd, driveShortcutCmd,
|
||||
driveFolderCreateCmd,
|
||||
} {
|
||||
RegisterCrossProductAliases(cmd)
|
||||
@@ -1326,6 +1376,8 @@ func newDriveCommand() *cobra.Command {
|
||||
driveCopyCmd,
|
||||
driveMoveCmd,
|
||||
driveRenameCmd,
|
||||
driveStatsCmd,
|
||||
driveShortcutCmd,
|
||||
drivePermissionCmd,
|
||||
drivePublishCmd,
|
||||
recycleCmd,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type driveStatsShortcutCall struct {
|
||||
productID string
|
||||
toolName string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type driveStatsShortcutCaller struct {
|
||||
calls []driveStatsShortcutCall
|
||||
}
|
||||
|
||||
func (c *driveStatsShortcutCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, driveStatsShortcutCall{productID: productID, toolName: toolName, args: args})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
|
||||
}
|
||||
|
||||
func (*driveStatsShortcutCaller) Format() string { return "json" }
|
||||
func (*driveStatsShortcutCaller) DryRun() bool { return false }
|
||||
func (*driveStatsShortcutCaller) Fields() string { return "" }
|
||||
func (*driveStatsShortcutCaller) JQ() string { return "" }
|
||||
|
||||
func executeDriveStatsShortcutCommand(t *testing.T, caller *driveStatsShortcutCaller, args ...string) error {
|
||||
t.Helper()
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
cmd := newDriveCommand()
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(args)
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
func TestDriveStatsShortcutCommandsRegistered(t *testing.T) {
|
||||
root := newDriveCommand()
|
||||
cases := []struct {
|
||||
path string
|
||||
flags []string
|
||||
}{
|
||||
{path: "stats", flags: []string{"node", "url", "id", "node-id", "doc-id", "file-id"}},
|
||||
{path: "shortcut", flags: []string{"node", "url", "id", "node-id", "doc-id", "file-id", "folder", "workspace"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
cmd, remaining, err := root.Find([]string{tc.path})
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("dws drive %s not registered: cmd=%v remaining=%v err=%v", tc.path, cmd, remaining, err)
|
||||
}
|
||||
for _, flag := range tc.flags {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("dws drive %s missing flag --%s", tc.path, flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveStatsMapsOpenToolArguments(t *testing.T) {
|
||||
caller := &driveStatsShortcutCaller{}
|
||||
if err := executeDriveStatsShortcutCommand(t, caller, "stats", "--url", "https://alidocs.dingtalk.com/i/nodes/node-1"); err != nil {
|
||||
t.Fatalf("drive stats returned error: %v", err)
|
||||
}
|
||||
want := driveStatsShortcutCall{
|
||||
productID: "drive",
|
||||
toolName: "get_node_stats",
|
||||
args: map[string]any{"nodeId": "https://alidocs.dingtalk.com/i/nodes/node-1"},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveShortcutMapsOptionalTargetArguments(t *testing.T) {
|
||||
caller := &driveStatsShortcutCaller{}
|
||||
if err := executeDriveStatsShortcutCommand(t, caller,
|
||||
"shortcut", "--file-id", "source-1", "--folder", "folder-1", "--workspace", "workspace-1"); err != nil {
|
||||
t.Fatalf("drive shortcut returned error: %v", err)
|
||||
}
|
||||
want := driveStatsShortcutCall{
|
||||
productID: "drive",
|
||||
toolName: "create_shortcut",
|
||||
args: map[string]any{
|
||||
"nodeId": "source-1",
|
||||
"targetFolderId": "folder-1",
|
||||
"workspaceId": "workspace-1",
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveShortcutOmitsUnsetOptionalArguments(t *testing.T) {
|
||||
caller := &driveStatsShortcutCaller{}
|
||||
if err := executeDriveStatsShortcutCommand(t, caller, "shortcut", "--node", "source-1"); err != nil {
|
||||
t.Fatalf("drive shortcut returned error: %v", err)
|
||||
}
|
||||
want := map[string]any{"nodeId": "source-1"}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("calls = %#v, want args %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveStatsShortcutRejectMissingNode(t *testing.T) {
|
||||
for _, path := range []string{"stats", "shortcut"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
caller := &driveStatsShortcutCaller{}
|
||||
if err := executeDriveStatsShortcutCommand(t, caller, path); err == nil {
|
||||
t.Fatalf("drive %s without --node returned nil error", path)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("drive %s remote calls = %d, want 0", path, len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveShortcutRejectsNumericDocFolder(t *testing.T) {
|
||||
caller := &driveStatsShortcutCaller{}
|
||||
err := executeDriveStatsShortcutCommand(t, caller, "shortcut", "--node", "source-1", "--folder", "123456")
|
||||
if err == nil {
|
||||
t.Fatal("drive shortcut accepted a pure numeric doc folder")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,11 @@ func newSheetCommand() *cobra.Command {
|
||||
dws sheet find 在工作表中搜索单元格内容
|
||||
dws sheet append 在工作表末尾追加数据
|
||||
dws sheet csv-put 将 CSV 数据写入表格指定位置
|
||||
dws sheet table-get 读取结构化 table 数据
|
||||
dws sheet table-put 写入结构化 table 数据
|
||||
dws sheet pivot-table [list|create|update|delete] 透视表管理
|
||||
dws sheet show-gridline 显示工作表网格线
|
||||
dws sheet hide-gridline 隐藏工作表网格线
|
||||
dws sheet merge-cells 合并单元格
|
||||
dws sheet insert-dimension 在指定位置插入行或列
|
||||
dws sheet delete-dimension 删除指定位置的行或列
|
||||
@@ -106,6 +111,8 @@ func newSheetCommand() *cobra.Command {
|
||||
chartCmd := newChartCmd()
|
||||
exportCmd := newExportCmd()
|
||||
templateCmd := newSheetTemplateCmd()
|
||||
tableCmds := newTableCmds()
|
||||
pivotTableCmd := newPivotTableCmd()
|
||||
|
||||
batchUpdateCmd := newBatchUpdateCmd()
|
||||
rangeBatchClearCmd := newRangeBatchClearCmd()
|
||||
@@ -126,6 +133,7 @@ func newSheetCommand() *cobra.Command {
|
||||
standaloneCmds = append(standaloneCmds, dimensionCmds...)
|
||||
standaloneCmds = append(standaloneCmds, mediaCmds...)
|
||||
standaloneCmds = append(standaloneCmds, floatImageCmds...)
|
||||
standaloneCmds = append(standaloneCmds, tableCmds...)
|
||||
standaloneCmds = append(standaloneCmds, exportCmd, batchUpdateCmd)
|
||||
|
||||
// Register cross-product aliases
|
||||
@@ -140,7 +148,7 @@ func newSheetCommand() *cobra.Command {
|
||||
|
||||
// Add all to root
|
||||
root.AddCommand(standaloneCmds...)
|
||||
root.AddCommand(rangeCmd, filterCmd, filterViewCmd, condFormatCmd, chartCmd, templateCmd)
|
||||
root.AddCommand(rangeCmd, filterCmd, filterViewCmd, condFormatCmd, chartCmd, templateCmd, pivotTableCmd)
|
||||
|
||||
// Guards for grouped parent commands
|
||||
attachUnknownSubcommandGuard(root)
|
||||
@@ -149,6 +157,7 @@ func newSheetCommand() *cobra.Command {
|
||||
attachUnknownSubcommandGuard(filterViewCmd)
|
||||
attachUnknownSubcommandGuard(condFormatCmd)
|
||||
attachUnknownSubcommandGuard(chartCmd)
|
||||
attachUnknownSubcommandGuard(pivotTableCmd)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type sheetPivotGridlineCall struct {
|
||||
productID string
|
||||
toolName string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type sheetPivotGridlineCaller struct {
|
||||
calls []sheetPivotGridlineCall
|
||||
}
|
||||
|
||||
func (c *sheetPivotGridlineCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, sheetPivotGridlineCall{productID: productID, toolName: toolName, args: args})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
|
||||
}
|
||||
|
||||
func (*sheetPivotGridlineCaller) Format() string { return "json" }
|
||||
func (*sheetPivotGridlineCaller) DryRun() bool { return false }
|
||||
func (*sheetPivotGridlineCaller) Fields() string { return "" }
|
||||
func (*sheetPivotGridlineCaller) JQ() string { return "" }
|
||||
|
||||
func executeSheetPivotGridlineCommand(t *testing.T, caller *sheetPivotGridlineCaller, args ...string) error {
|
||||
t.Helper()
|
||||
previousDeps := deps
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
})
|
||||
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
os.Args = append([]string{"dws", "sheet"}, args...)
|
||||
root := newSheetCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestSheetPivotAndGridlineCommandsRegistered(t *testing.T) {
|
||||
root := newSheetCommand()
|
||||
cases := []struct {
|
||||
path []string
|
||||
flags []string
|
||||
}{
|
||||
{[]string{"pivot-table", "list"}, []string{"node", "sheet-id", "pivot-table-id"}},
|
||||
{[]string{"pivot-table", "create"}, []string{"node", "source", "properties", "target-sheet-id", "target-position"}},
|
||||
{[]string{"pivot-table", "update"}, []string{"node", "sheet-id", "pivot-table-id", "properties"}},
|
||||
{[]string{"pivot-table", "delete"}, []string{"node", "sheet-id", "pivot-table-id"}},
|
||||
{[]string{"show-gridline"}, []string{"node", "sheet-id"}},
|
||||
{[]string{"hide-gridline"}, []string{"node", "sheet-id"}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
cmd, remaining, err := root.Find(tc.path)
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("dws sheet %s not registered: cmd=%v remaining=%v err=%v", strings.Join(tc.path, " "), cmd, remaining, err)
|
||||
}
|
||||
for _, flag := range tc.flags {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("dws sheet %s: missing flag --%s", strings.Join(tc.path, " "), flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetPivotListBuildsToolArgs(t *testing.T) {
|
||||
caller := &sheetPivotGridlineCaller{}
|
||||
err := executeSheetPivotGridlineCommand(t, caller,
|
||||
"pivot-table", "list", "--node", "node1", "--sheet-id", "sheet1", "--pivot-table-id", "pivot1")
|
||||
if err != nil {
|
||||
t.Fatalf("pivot-table list returned error: %v", err)
|
||||
}
|
||||
want := sheetPivotGridlineCall{
|
||||
productID: "sheet",
|
||||
toolName: "list_pivot_tables",
|
||||
args: map[string]any{
|
||||
"nodeId": "node1",
|
||||
"sheetId": "sheet1",
|
||||
"pivotTableId": "pivot1",
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetPivotCreateBuildsToolArgs(t *testing.T) {
|
||||
caller := &sheetPivotGridlineCaller{}
|
||||
err := executeSheetPivotGridlineCommand(t, caller,
|
||||
"pivot-table", "create",
|
||||
"--node", "node1",
|
||||
"--source", "'Data'!A1:D20",
|
||||
"--properties", `{"rows":[{"field":"team"}],"values":[{"field":"amount","summarize_by":" SUM "}]}`,
|
||||
"--target-sheet-id", "summary",
|
||||
"--target-position", "B2")
|
||||
if err != nil {
|
||||
t.Fatalf("pivot-table create returned error: %v", err)
|
||||
}
|
||||
want := sheetPivotGridlineCall{
|
||||
productID: "sheet",
|
||||
toolName: "create_pivot_table",
|
||||
args: map[string]any{
|
||||
"nodeId": "node1",
|
||||
"source": "'Data'!A1:D20",
|
||||
"targetSheetId": "summary",
|
||||
"targetPosition": "B2",
|
||||
"properties": map[string]any{
|
||||
"rows": []any{map[string]any{"field": "team"}},
|
||||
"values": []any{map[string]any{"field": "amount", "summarize_by": "sum"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetPivotCreateReadsPropertiesFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "pivot.json")
|
||||
if err := os.WriteFile(path, []byte(`{"values":[{"field":"amount","summarize_by":"average"}]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := &sheetPivotGridlineCaller{}
|
||||
if err := executeSheetPivotGridlineCommand(t, caller,
|
||||
"pivot-table", "create", "--node", "node1", "--source", "'Data'!A1:B10", "--properties", "@"+path); err != nil {
|
||||
t.Fatalf("pivot-table create with @file returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].toolName != "create_pivot_table" {
|
||||
t.Fatalf("calls = %#v, want create_pivot_table", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetPivotUpdateAllowsPartialProperties(t *testing.T) {
|
||||
caller := &sheetPivotGridlineCaller{}
|
||||
err := executeSheetPivotGridlineCommand(t, caller,
|
||||
"pivot-table", "update", "--node", "node1", "--sheet-id", "sheet1", "--pivot-table-id", "pivot1",
|
||||
"--properties", `{"show_subtotals":false}`)
|
||||
if err != nil {
|
||||
t.Fatalf("pivot-table update returned error: %v", err)
|
||||
}
|
||||
wantArgs := map[string]any{
|
||||
"nodeId": "node1",
|
||||
"sheetId": "sheet1",
|
||||
"pivotTableId": "pivot1",
|
||||
"properties": map[string]any{"show_subtotals": false},
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].toolName != "update_pivot_table" || !reflect.DeepEqual(caller.calls[0].args, wantArgs) {
|
||||
t.Fatalf("calls = %#v, want update_pivot_table %#v", caller.calls, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetPivotDeleteBuildsToolArgsAfterConfirmation(t *testing.T) {
|
||||
caller := &sheetPivotGridlineCaller{}
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() { os.Args = previousArgs })
|
||||
os.Args = []string{"dws", "sheet", "pivot-table", "delete", "--yes"}
|
||||
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
root := newSheetCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetArgs([]string{"pivot-table", "delete", "--node", "node1", "--sheet-id", "sheet1", "--pivot-table-id", "pivot1"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("pivot-table delete returned error: %v", err)
|
||||
}
|
||||
wantArgs := map[string]any{"nodeId": "node1", "sheetId": "sheet1", "pivotTableId": "pivot1"}
|
||||
if len(caller.calls) != 1 || caller.calls[0].toolName != "delete_pivot_table" || !reflect.DeepEqual(caller.calls[0].args, wantArgs) {
|
||||
t.Fatalf("calls = %#v, want delete_pivot_table %#v", caller.calls, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetPivotRejectsInvalidPropertiesBeforeCall(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
command string
|
||||
props string
|
||||
wantErr string
|
||||
}{
|
||||
{"create invalid JSON", "create", `{`, "JSON 解析失败"},
|
||||
{"create missing values", "create", `{"rows":[{"field":"team"}]}`, "缺少必填字段 values"},
|
||||
{"create empty values", "create", `{"values":[]}`, "至少包含一项"},
|
||||
{"create invalid summarize", "create", `{"values":[{"field":"amount","summarize_by":"total"}]}`, "summarize_by 不支持"},
|
||||
{"create invalid row", "create", `{"rows":[{}],"values":[{"field":"amount"}]}`, "rows[0].field"},
|
||||
{"create invalid collapse", "create", `{"values":[{"field":"amount"}],"collapse":"team"}`, "collapse 必须"},
|
||||
{"update empty properties", "update", `{}`, "不能为空对象"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &sheetPivotGridlineCaller{}
|
||||
args := []string{"pivot-table", tt.command, "--node", "node1", "--properties", tt.props}
|
||||
if tt.command == "create" {
|
||||
args = append(args, "--source", "'Data'!A1:B10")
|
||||
} else {
|
||||
args = append(args, "--sheet-id", "sheet1", "--pivot-table-id", "pivot1")
|
||||
}
|
||||
err := executeSheetPivotGridlineCommand(t, caller, args...)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetPivotAndGridlineRequireMandatoryFlags(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"pivot-table", "list", "--node", "node1"},
|
||||
{"pivot-table", "create", "--node", "node1", "--source", "'Data'!A1:B10"},
|
||||
{"pivot-table", "update", "--node", "node1", "--sheet-id", "sheet1", "--pivot-table-id", "pivot1"},
|
||||
{"pivot-table", "delete", "--node", "node1", "--sheet-id", "sheet1"},
|
||||
{"show-gridline", "--node", "node1"},
|
||||
{"hide-gridline", "--sheet-id", "sheet1"},
|
||||
} {
|
||||
caller := &sheetPivotGridlineCaller{}
|
||||
if err := executeSheetPivotGridlineCommand(t, caller, args...); err == nil {
|
||||
t.Fatalf("dws sheet %s returned nil error", strings.Join(args, " "))
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("dws sheet %s made %d remote calls, want 0", strings.Join(args, " "), len(caller.calls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetGridlineCommandsBuildToolArgs(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
command string
|
||||
visibility string
|
||||
}{
|
||||
{"show-gridline", "visible"},
|
||||
{"hide-gridline", "hidden"},
|
||||
} {
|
||||
t.Run(tt.command, func(t *testing.T) {
|
||||
caller := &sheetPivotGridlineCaller{}
|
||||
if err := executeSheetPivotGridlineCommand(t, caller, tt.command, "--node", "node1", "--sheet-id", "sheet1"); err != nil {
|
||||
t.Fatalf("%s returned error: %v", tt.command, err)
|
||||
}
|
||||
want := sheetPivotGridlineCall{
|
||||
productID: "sheet",
|
||||
toolName: "set_gridline_visibility",
|
||||
args: map[string]any{
|
||||
"nodeId": "node1",
|
||||
"sheetId": "sheet1",
|
||||
"visibility": tt.visibility,
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var validPivotSummarizeBy = map[string]bool{
|
||||
"sum": true, "count": true, "average": true,
|
||||
"max": true, "min": true, "product": true,
|
||||
"count_numbers": true, "std_dev": true, "std_dev_p": true,
|
||||
"var": true, "var_p": true, "distinct": true, "median": true,
|
||||
}
|
||||
|
||||
func validatePivotTableProperties(props map[string]any, requireValues bool) error {
|
||||
valuesRaw, hasValues := props["values"]
|
||||
if requireValues && (!hasValues || valuesRaw == nil) {
|
||||
return fmt.Errorf("--properties 缺少必填字段 values")
|
||||
}
|
||||
if hasValues && valuesRaw != nil {
|
||||
values, ok := valuesRaw.([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("values 必须为数组")
|
||||
}
|
||||
if requireValues && len(values) == 0 {
|
||||
return fmt.Errorf("values 必须至少包含一项")
|
||||
}
|
||||
for i, item := range values {
|
||||
field, err := validatePivotField(item, fmt.Sprintf("values[%d]", i))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = field
|
||||
obj := item.(map[string]any)
|
||||
if raw, ok := obj["summarize_by"]; ok && raw != nil {
|
||||
value, ok := raw.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("values[%d].summarize_by 必须为字符串", i)
|
||||
}
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
if !validPivotSummarizeBy[normalized] {
|
||||
return fmt.Errorf("values[%d].summarize_by 不支持: %q", i, value)
|
||||
}
|
||||
obj["summarize_by"] = normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"rows", "columns", "filters"} {
|
||||
if err := validatePivotFieldArray(props, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if raw, ok := props["collapse"]; ok && raw != nil {
|
||||
switch raw.(type) {
|
||||
case map[string]any, []any:
|
||||
default:
|
||||
return fmt.Errorf("collapse 必须为对象或数组")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePivotField(item any, path string) (string, error) {
|
||||
obj, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s 必须为对象", path)
|
||||
}
|
||||
raw, ok := obj["field"]
|
||||
if !ok || raw == nil {
|
||||
return "", fmt.Errorf("%s.field 为必填字段", path)
|
||||
}
|
||||
field, ok := raw.(string)
|
||||
if !ok || strings.TrimSpace(field) == "" {
|
||||
return "", fmt.Errorf("%s.field 必须为非空字符串", path)
|
||||
}
|
||||
return field, nil
|
||||
}
|
||||
|
||||
func validatePivotFieldArray(props map[string]any, name string) error {
|
||||
raw, ok := props[name]
|
||||
if !ok || raw == nil {
|
||||
return nil
|
||||
}
|
||||
items, ok := raw.([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s 必须为数组", name)
|
||||
}
|
||||
for i, item := range items {
|
||||
if _, err := validatePivotField(item, fmt.Sprintf("%s[%d]", name, i)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readPivotProperties(raw string, requireValues bool) (map[string]any, error) {
|
||||
if strings.HasPrefix(raw, "@") {
|
||||
data, err := os.ReadFile(strings.TrimPrefix(raw, "@"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 properties 文件失败: %w", err)
|
||||
}
|
||||
raw = string(data)
|
||||
}
|
||||
var properties map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &properties); err != nil {
|
||||
return nil, fmt.Errorf("--properties JSON 解析失败: %w", err)
|
||||
}
|
||||
if len(properties) == 0 {
|
||||
return nil, fmt.Errorf("--properties 不能为空对象")
|
||||
}
|
||||
if err := validatePivotTableProperties(properties, requireValues); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return properties, nil
|
||||
}
|
||||
|
||||
func newPivotTableCmd() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "pivot-table",
|
||||
Short: "透视表管理",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
listCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "获取透视表列表或详情",
|
||||
Example: ` dws sheet pivot-table list --node NODE_ID --sheet-id SHEET_ID
|
||||
dws sheet pivot-table list --node NODE_ID --sheet-id SHEET_ID --pivot-table-id PT_ID`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node", "sheet-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
args := map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"sheetId": mustGetFlag(cmd, "sheet-id"),
|
||||
}
|
||||
if value, _ := cmd.Flags().GetString("pivot-table-id"); value != "" {
|
||||
args["pivotTableId"] = value
|
||||
}
|
||||
return callMCPTool("list_pivot_tables", args)
|
||||
},
|
||||
}
|
||||
listCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
listCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
|
||||
listCmd.Flags().String("pivot-table-id", "", "透视表 ID (可选,不传则返回全部)")
|
||||
|
||||
createCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "创建透视表",
|
||||
Long: `在指定数据源区域上创建原生透视表。
|
||||
|
||||
--source 必须使用带工作表前缀的 A1 范围,例如 "'Sheet1'!A1:D100"。
|
||||
--properties 为 JSON 对象或 @file,values 至少一项;可包含 rows、columns、filters、collapse 和总计显示选项。`,
|
||||
Example: ` dws sheet pivot-table create --node NODE_ID \
|
||||
--source "'Sheet1'!A1:D100" \
|
||||
--properties '{"rows":[{"field":"部门"}],"values":[{"field":"销售额","summarize_by":"sum"}]}'`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node", "source", "properties"); err != nil {
|
||||
return err
|
||||
}
|
||||
properties, err := readPivotProperties(mustGetFlag(cmd, "properties"), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
args := map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"source": mustGetFlag(cmd, "source"),
|
||||
"properties": properties,
|
||||
}
|
||||
if value, _ := cmd.Flags().GetString("target-sheet-id"); value != "" {
|
||||
args["targetSheetId"] = value
|
||||
}
|
||||
if value, _ := cmd.Flags().GetString("target-position"); value != "" {
|
||||
args["targetPosition"] = value
|
||||
}
|
||||
return callMCPTool("create_pivot_table", args)
|
||||
},
|
||||
}
|
||||
createCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
createCmd.Flags().String("source", "", "数据源区域,A1 表示法且包含工作表前缀 (必填)")
|
||||
createCmd.Flags().String("properties", "", "透视表配置 JSON 或 @文件路径 (必填)")
|
||||
createCmd.Flags().String("target-sheet-id", "", "目标工作表 ID 或名称 (可选,不传则自动新建)")
|
||||
createCmd.Flags().String("target-position", "", "透视表放置位置,A1 单元格地址 (可选)")
|
||||
|
||||
updateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "更新透视表配置",
|
||||
Example: ` dws sheet pivot-table update --node NODE_ID --sheet-id SHEET_ID \
|
||||
--pivot-table-id PT_ID --properties '{"show_subtotals":false}'`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node", "sheet-id", "pivot-table-id", "properties"); err != nil {
|
||||
return err
|
||||
}
|
||||
properties, err := readPivotProperties(mustGetFlag(cmd, "properties"), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("update_pivot_table", map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"sheetId": mustGetFlag(cmd, "sheet-id"),
|
||||
"pivotTableId": mustGetFlag(cmd, "pivot-table-id"),
|
||||
"properties": properties,
|
||||
})
|
||||
},
|
||||
}
|
||||
updateCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
updateCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
|
||||
updateCmd.Flags().String("pivot-table-id", "", "透视表 ID (必填)")
|
||||
updateCmd.Flags().String("properties", "", "需要更新的透视表配置 JSON 或 @文件路径 (必填)")
|
||||
|
||||
deleteCmd := &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "[危险] 删除透视表",
|
||||
Example: ` dws sheet pivot-table delete --node NODE_ID --sheet-id SHEET_ID --pivot-table-id PT_ID --yes`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node", "sheet-id", "pivot-table-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
pivotTableID := mustGetFlag(cmd, "pivot-table-id")
|
||||
if !confirmDelete("透视表", pivotTableID) {
|
||||
return nil
|
||||
}
|
||||
return callMCPTool("delete_pivot_table", map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"sheetId": mustGetFlag(cmd, "sheet-id"),
|
||||
"pivotTableId": pivotTableID,
|
||||
})
|
||||
},
|
||||
}
|
||||
deleteCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
deleteCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
|
||||
deleteCmd.Flags().String("pivot-table-id", "", "透视表 ID (必填)")
|
||||
|
||||
root.AddCommand(listCmd, createCmd, updateCmd, deleteCmd)
|
||||
attachUnknownSubcommandGuard(root)
|
||||
return root
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newTableCmds() []*cobra.Command {
|
||||
tableGetCmd := &cobra.Command{
|
||||
Use: "table-get",
|
||||
Aliases: []string{"table-read"},
|
||||
Short: "读取结构化 table 数据",
|
||||
Long: `读取结构化 table 数据。
|
||||
|
||||
返回列名、行数据、pandas-style dtypes 和表格 number formats。`,
|
||||
Example: ` dws sheet table-get --node NODE_ID
|
||||
dws sheet table-get --node NODE_ID --sheet-id SHEET_ID --range A1:D20
|
||||
dws sheet table-get --node NODE_ID --sheet-id Sheet1 --no-header`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("sheet-id"); v != "" {
|
||||
toolArgs["sheetId"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("range"); v != "" {
|
||||
toolArgs["range"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("no-header") {
|
||||
v, _ := cmd.Flags().GetBool("no-header")
|
||||
toolArgs["noHeader"] = v
|
||||
}
|
||||
return callMCPTool("table_get", toolArgs)
|
||||
},
|
||||
}
|
||||
tableGetCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
tableGetCmd.Flags().String("sheet-id", "", "工作表 ID 或名称")
|
||||
tableGetCmd.Flags().String("range", "", "读取范围,A1 表示法;可带 sheet 前缀,如 Sheet1!A1:D10")
|
||||
tableGetCmd.Flags().Bool("no-header", false, "首行不作为表头,自动生成 col1/col2/...")
|
||||
|
||||
tablePutCmd := &cobra.Command{
|
||||
Use: "table-put",
|
||||
Aliases: []string{"table-write"},
|
||||
Short: "写入结构化 table 数据",
|
||||
Long: `写入结构化 table 数据。
|
||||
|
||||
支持一次写入一个或多个 sheet;目标 sheet 存在时写入,传 name 且不存在时会创建 sheet。
|
||||
|
||||
--sheets 支持三种输入:
|
||||
JSON 数组 [{"name":"Sheet1","columns":["name"],"data":[["Alice"]]}]
|
||||
JSON 对象 {"sheets":[{"name":"Sheet1","columns":["name"],"data":[["Alice"]]}]}
|
||||
单个 sheet spec JSON 对象 {"name":"Sheet1","columns":["name"],"data":[["Alice"]]}
|
||||
|
||||
--sheets 可传 @filepath 从文件读取,或传 - 从 stdin 读取。`,
|
||||
Example: ` dws sheet table-put --node NODE_ID \
|
||||
--sheets '[{"name":"Sheet1","columns":["name","score"],"data":[["Alice",95]],"dtypes":{"score":"float64"}}]'
|
||||
|
||||
dws sheet table-put --node NODE_ID --sheets @table.json
|
||||
cat table.json | dws sheet table-put --node NODE_ID --sheets -`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node", "sheets"); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := readTableJSONFlag(cmd, "sheets")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheets, err := parseTablePutSheets(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("table_put", map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"sheets": sheets,
|
||||
})
|
||||
},
|
||||
}
|
||||
tablePutCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
tablePutCmd.Flags().String("sheets", "", "sheet table JSON、@文件路径 或 - 表示 stdin (必填)")
|
||||
|
||||
return []*cobra.Command{tableGetCmd, tablePutCmd}
|
||||
}
|
||||
|
||||
func readTableJSONFlag(cmd *cobra.Command, flagName string) (string, error) {
|
||||
value := mustGetFlag(cmd, flagName)
|
||||
switch {
|
||||
case value == "-":
|
||||
data, err := io.ReadAll(cmd.InOrStdin())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取 stdin 失败: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
case strings.HasPrefix(value, "@"):
|
||||
data, err := os.ReadFile(strings.TrimPrefix(value, "@"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取 JSON 文件失败: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
func parseTablePutSheets(raw string) ([]any, error) {
|
||||
var payload any
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return nil, fmt.Errorf("--sheets JSON 解析失败: %w", err)
|
||||
}
|
||||
|
||||
switch value := payload.(type) {
|
||||
case []any:
|
||||
if len(value) == 0 {
|
||||
return nil, fmt.Errorf("--sheets must contain at least one sheet spec")
|
||||
}
|
||||
return value, nil
|
||||
case map[string]any:
|
||||
if sheets, ok := value["sheets"]; ok {
|
||||
items, ok := sheets.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("--sheets.sheets must be a JSON array")
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("--sheets.sheets must contain at least one sheet spec")
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
return []any{value}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("--sheets must be a JSON array, an object with sheets, or a single sheet spec object")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type sheetTableCall struct {
|
||||
productID string
|
||||
toolName string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type sheetTableCaller struct {
|
||||
calls []sheetTableCall
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func (c *sheetTableCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, sheetTableCall{productID: productID, toolName: toolName, args: args})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
|
||||
}
|
||||
|
||||
func (*sheetTableCaller) Format() string { return "json" }
|
||||
func (c *sheetTableCaller) DryRun() bool { return c.dryRun }
|
||||
func (*sheetTableCaller) Fields() string { return "" }
|
||||
func (*sheetTableCaller) JQ() string { return "" }
|
||||
|
||||
func executeSheetTableCommand(t *testing.T, caller *sheetTableCaller, stdin io.Reader, args ...string) error {
|
||||
t.Helper()
|
||||
previousDeps := deps
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
})
|
||||
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
os.Args = append([]string{"dws", "sheet"}, args...)
|
||||
root := newSheetCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
if stdin != nil {
|
||||
root.SetIn(stdin)
|
||||
}
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestSheetTableCommandsRegistered(t *testing.T) {
|
||||
root := newSheetCommand()
|
||||
for _, path := range []string{"table-get", "table-put", "table-read", "table-write"} {
|
||||
cmd, remaining, err := root.Find([]string{path})
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("dws sheet %s not registered: cmd=%v remaining=%v err=%v", path, cmd, remaining, err)
|
||||
}
|
||||
}
|
||||
if cmd, remaining, _ := root.Find([]string{"table"}); cmd != nil && len(remaining) == 0 {
|
||||
t.Fatal("dws sheet table parent should not be registered")
|
||||
}
|
||||
|
||||
getCmd, _, _ := root.Find([]string{"table-get"})
|
||||
for _, flag := range []string{"node", "sheet-id", "range", "no-header"} {
|
||||
if getCmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("table-get: missing flag --%s", flag)
|
||||
}
|
||||
}
|
||||
putCmd, _, _ := root.Find([]string{"table-put"})
|
||||
for _, flag := range []string{"node", "sheets"} {
|
||||
if putCmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("table-put: missing flag --%s", flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetTableGetBuildsToolArgs(t *testing.T) {
|
||||
caller := &sheetTableCaller{}
|
||||
err := executeSheetTableCommand(t, caller, nil,
|
||||
"table-get", "--node", "node1", "--sheet-id", "sheet1", "--range", "A1:B2", "--no-header")
|
||||
if err != nil {
|
||||
t.Fatalf("table-get returned error: %v", err)
|
||||
}
|
||||
want := sheetTableCall{
|
||||
productID: "sheet",
|
||||
toolName: "table_get",
|
||||
args: map[string]any{
|
||||
"nodeId": "node1",
|
||||
"sheetId": "sheet1",
|
||||
"range": "A1:B2",
|
||||
"noHeader": true,
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetTableGetOmitsUnsetOptionalArgs(t *testing.T) {
|
||||
caller := &sheetTableCaller{}
|
||||
if err := executeSheetTableCommand(t, caller, nil, "table-get", "--node", "node1"); err != nil {
|
||||
t.Fatalf("table-get returned error: %v", err)
|
||||
}
|
||||
want := map[string]any{"nodeId": "node1"}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("calls = %#v, want args %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetTableGetPreservesExplicitFalseNoHeader(t *testing.T) {
|
||||
caller := &sheetTableCaller{}
|
||||
if err := executeSheetTableCommand(t, caller, nil, "table-get", "--node", "node1", "--no-header=false"); err != nil {
|
||||
t.Fatalf("table-get returned error: %v", err)
|
||||
}
|
||||
if got, ok := caller.calls[0].args["noHeader"]; !ok || got != false {
|
||||
t.Fatalf("noHeader = %#v, present=%v; want explicit false", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetTableGetRequiresNodeBeforeCall(t *testing.T) {
|
||||
caller := &sheetTableCaller{}
|
||||
if err := executeSheetTableCommand(t, caller, nil, "table-get"); err == nil {
|
||||
t.Fatal("table-get without --node returned nil error")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetTablePutNormalizesAcceptedInputs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{name: "array", input: `[ {"name":"Sheet1","columns":["name"],"data":[["Alice"]]} ]`},
|
||||
{name: "wrapped", input: `{"sheets":[{"name":"Sheet1","columns":["name"],"data":[["Alice"]]}]}`},
|
||||
{name: "single", input: `{"name":"Sheet1","columns":["name"],"data":[["Alice"]]}`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &sheetTableCaller{}
|
||||
if err := executeSheetTableCommand(t, caller, nil, "table-put", "--node", "node1", "--sheets", tt.input); err != nil {
|
||||
t.Fatalf("table-put returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("remote calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.productID != "sheet" || call.toolName != "table_put" || call.args["nodeId"] != "node1" {
|
||||
t.Fatalf("call = %#v", call)
|
||||
}
|
||||
sheets, ok := call.args["sheets"].([]any)
|
||||
if !ok || len(sheets) != 1 {
|
||||
t.Fatalf("sheets = %#v, want one-element array", call.args["sheets"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetTablePutReadsStdinAndFile(t *testing.T) {
|
||||
payload := `[{"name":"Sheet1","columns":["name"],"data":[["Alice"]]}]`
|
||||
|
||||
t.Run("stdin", func(t *testing.T) {
|
||||
caller := &sheetTableCaller{}
|
||||
if err := executeSheetTableCommand(t, caller, strings.NewReader(payload), "table-put", "--node", "node1", "--sheets", "-"); err != nil {
|
||||
t.Fatalf("table-put stdin returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("remote calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "table.json")
|
||||
if err := os.WriteFile(path, []byte(payload), 0o600); err != nil {
|
||||
t.Fatalf("write table JSON: %v", err)
|
||||
}
|
||||
caller := &sheetTableCaller{}
|
||||
if err := executeSheetTableCommand(t, caller, nil, "table-put", "--node", "node1", "--sheets", "@"+path); err != nil {
|
||||
t.Fatalf("table-put file returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("remote calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSheetTablePutRejectsInvalidInputBeforeCall(t *testing.T) {
|
||||
for _, input := range []string{`not-json`, `123`, `[]`, `{"sheets":{}}`, `{"sheets":[]}`} {
|
||||
caller := &sheetTableCaller{}
|
||||
if err := executeSheetTableCommand(t, caller, nil, "table-put", "--node", "node1", "--sheets", input); err == nil {
|
||||
t.Errorf("input %q returned nil error", input)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Errorf("input %q made %d remote calls, want 0", input, len(caller.calls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetTablePutDryRunSkipsRemoteCall(t *testing.T) {
|
||||
caller := &sheetTableCaller{dryRun: true}
|
||||
input := `[{"name":"Sheet1","columns":["name"],"data":[["Alice"]]}]`
|
||||
if err := executeSheetTableCommand(t, caller, nil, "table-put", "--node", "node1", "--sheets", input); err != nil {
|
||||
t.Fatalf("table-put dry-run returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchUpdateRejectsTableCommands(t *testing.T) {
|
||||
for _, toolName := range []string{"table-get", "table-put"} {
|
||||
_, err := translateBatchOp(map[string]any{
|
||||
"toolName": toolName,
|
||||
"input": map[string]any{"sheet-id": "sheet1"},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), toolName) {
|
||||
t.Errorf("translateBatchOp(%q) error = %v, want rejection naming command", toolName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -261,5 +261,29 @@ name 不能包含 / \ ? * [ ] : 等特殊字符,最长 100 字符。`,
|
||||
deleteSheetCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
deleteSheetCmd.Flags().String("sheet-id", "", "要删除的工作表 ID 或名称 (必填)")
|
||||
|
||||
return []*cobra.Command{createCmd, listCmd, infoCmd, newCmd, updateSheetCmd, copySheetCmd, deleteSheetCmd}
|
||||
showGridlineCmd := newGridlineVisibilityCmd("show-gridline", "显示工作表网格线", "visible")
|
||||
hideGridlineCmd := newGridlineVisibilityCmd("hide-gridline", "隐藏工作表网格线", "hidden")
|
||||
|
||||
return []*cobra.Command{createCmd, listCmd, infoCmd, newCmd, updateSheetCmd, copySheetCmd, deleteSheetCmd, showGridlineCmd, hideGridlineCmd}
|
||||
}
|
||||
|
||||
func newGridlineVisibilityCmd(use, short, visibility string) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: use,
|
||||
Short: short,
|
||||
Example: fmt.Sprintf(" dws sheet %s --node NODE_ID --sheet-id SHEET_ID", use),
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node", "sheet-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("set_gridline_visibility", map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"sheetId": mustGetFlag(cmd, "sheet-id"),
|
||||
"visibility": visibility,
|
||||
})
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
cmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -98,6 +98,11 @@ func TestWukongSyncDocCommands(t *testing.T) {
|
||||
|
||||
func TestWukongSyncSheetCommands(t *testing.T) {
|
||||
root := newSheetCommand()
|
||||
tableGetCmd := requireWukongSyncCommand(t, root, "table-get")
|
||||
requireWukongSyncFlags(t, tableGetCmd, "node", "sheet-id", "range", "no-header")
|
||||
|
||||
tablePutCmd := requireWukongSyncCommand(t, root, "table-put")
|
||||
requireWukongSyncFlags(t, tablePutCmd, "node", "sheets")
|
||||
|
||||
groupCmd := requireWukongSyncCommand(t, root, "group-dimension")
|
||||
requireWukongSyncFlags(t, groupCmd, "node", "sheet-id", "range", "group-state")
|
||||
|
||||
@@ -1,441 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package interfacesnapshot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Report combines comparisons against independently supplied compatibility
|
||||
// references, normally the PR merge-base and the latest stable GA release.
|
||||
type Report struct {
|
||||
Compatible bool `json:"compatible"`
|
||||
Comparisons []Comparison `json:"comparisons"`
|
||||
}
|
||||
|
||||
// Comparison is the result for one reference snapshot.
|
||||
type Comparison struct {
|
||||
Reference string `json:"reference"`
|
||||
Compatible bool `json:"compatible"`
|
||||
Blocking []Change `json:"blocking"`
|
||||
Additions []Change `json:"additions"`
|
||||
}
|
||||
|
||||
// Change describes one compatibility decision. Before and After are concise,
|
||||
// human-readable values intended for CI annotations.
|
||||
type Change struct {
|
||||
Kind string `json:"kind"`
|
||||
Path string `json:"path"`
|
||||
Flag string `json:"flag,omitempty"`
|
||||
Before string `json:"before,omitempty"`
|
||||
After string `json:"after,omitempty"`
|
||||
}
|
||||
|
||||
// CompareAll compares current against every named reference in one pass.
|
||||
// Additions are reported but remain compatible.
|
||||
func CompareAll(current Snapshot, references map[string]Snapshot) Report {
|
||||
labels := make([]string, 0, len(references))
|
||||
for label := range references {
|
||||
labels = append(labels, label)
|
||||
}
|
||||
sort.Strings(labels)
|
||||
|
||||
report := Report{Compatible: true, Comparisons: []Comparison{}}
|
||||
for _, label := range labels {
|
||||
comparison := Compare(current, references[label], label)
|
||||
report.Comparisons = append(report.Comparisons, comparison)
|
||||
if !comparison.Compatible {
|
||||
report.Compatible = false
|
||||
}
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
// Compare enforces the deliberately small admission policy:
|
||||
// - every previously accepted command path must still resolve to a runnable,
|
||||
// visible-compatible target (a rename may preserve the old path as an
|
||||
// alias),
|
||||
// - flags accepted at each command path may not disappear, change type, or
|
||||
// become required; an existing path may not gain a new required flag,
|
||||
// - new commands and flags are allowed.
|
||||
//
|
||||
// Comparing the effective local + inherited set is intentional. It catches a
|
||||
// persistent flag whose scope is accidentally narrowed to its declaring
|
||||
// command, while allowing a local flag to move to an ancestor without breaking
|
||||
// the old invocation path.
|
||||
func Compare(current, baseline Snapshot, reference string) Comparison {
|
||||
result := Comparison{
|
||||
Reference: reference,
|
||||
Compatible: true,
|
||||
Blocking: []Change{},
|
||||
Additions: []Change{},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(current.Rules, baseline.Rules) {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "snapshot_rules_changed",
|
||||
Path: "dws",
|
||||
Before: fmt.Sprintf("%v", baseline.Rules),
|
||||
After: fmt.Sprintf("%v", current.Rules),
|
||||
})
|
||||
}
|
||||
result.Blocking = append(result.Blocking, aliasCollisionChanges(current)...)
|
||||
|
||||
currentCommands := commandIndex(current)
|
||||
baselineCommands := commandIndex(baseline)
|
||||
currentAccepted := acceptedPathIndex(current)
|
||||
baselineAccepted := acceptedPathIndex(baseline)
|
||||
|
||||
removedPaths := make([]string, 0)
|
||||
for path := range baselineAccepted {
|
||||
if _, ok := currentAccepted[path]; !ok {
|
||||
removedPaths = append(removedPaths, path)
|
||||
}
|
||||
}
|
||||
for _, path := range minimalPaths(removedPaths) {
|
||||
kind := "command_alias_removed"
|
||||
if _, canonical := baselineCommands[path]; canonical {
|
||||
kind = "command_removed"
|
||||
}
|
||||
result.Blocking = append(result.Blocking, Change{Kind: kind, Path: path})
|
||||
}
|
||||
|
||||
mappedCurrent := make(map[string]bool)
|
||||
for _, acceptedPath := range sortedAcceptedPaths(baselineAccepted) {
|
||||
oldPath := baselineAccepted[acceptedPath]
|
||||
oldCommand := baselineCommands[oldPath]
|
||||
newPath, ok := currentAccepted[acceptedPath]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
newCommand, ok := currentCommands[newPath]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
mappedCurrent[newPath] = true
|
||||
compareCommandContract(&result, acceptedPath, oldCommand, newCommand)
|
||||
compareEffectiveFlags(&result, acceptedPath, oldCommand, newCommand)
|
||||
}
|
||||
|
||||
for _, path := range sortedCommandPaths(current.Commands) {
|
||||
if mappedCurrent[path] {
|
||||
continue
|
||||
}
|
||||
if _, existed := baselineCommands[path]; !existed {
|
||||
result.Additions = append(result.Additions, Change{Kind: "command_added", Path: path})
|
||||
}
|
||||
}
|
||||
|
||||
sortChanges(result.Blocking)
|
||||
sortChanges(result.Additions)
|
||||
result.Blocking = dedupeChanges(result.Blocking)
|
||||
result.Additions = dedupeChanges(result.Additions)
|
||||
result.Compatible = len(result.Blocking) == 0
|
||||
return result
|
||||
}
|
||||
|
||||
func compareCommandContract(result *Comparison, acceptedPath string, oldCommand, newCommand Command) {
|
||||
if oldCommand.Runnable && !newCommand.Runnable {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "command_became_non_runnable",
|
||||
Path: acceptedPath,
|
||||
Before: "runnable",
|
||||
After: "non-runnable",
|
||||
})
|
||||
}
|
||||
if !oldCommand.Hidden && newCommand.Hidden {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "command_became_hidden",
|
||||
Path: acceptedPath,
|
||||
Before: "visible",
|
||||
After: "hidden",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func compareEffectiveFlags(result *Comparison, acceptedPath string, oldCommand, newCommand Command) {
|
||||
oldFlags := effectiveFlagIndex(oldCommand)
|
||||
newFlags := effectiveFlagIndex(newCommand)
|
||||
|
||||
for _, name := range sortedFlagNames(oldFlags) {
|
||||
oldFlag := oldFlags[name]
|
||||
newFlag, ok := newFlags[name]
|
||||
if !ok {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "flag_removed",
|
||||
Path: acceptedPath,
|
||||
Flag: name,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if oldFlag.Type != newFlag.Type {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "flag_type_changed",
|
||||
Path: acceptedPath,
|
||||
Flag: name,
|
||||
Before: oldFlag.Type,
|
||||
After: newFlag.Type,
|
||||
})
|
||||
}
|
||||
if !oldFlag.Required && newFlag.Required {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "flag_became_required",
|
||||
Path: acceptedPath,
|
||||
Flag: name,
|
||||
Before: "optional",
|
||||
After: "required",
|
||||
})
|
||||
}
|
||||
if oldFlag.Shorthand != "" && newFlag.Shorthand != oldFlag.Shorthand {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "flag_shorthand_changed",
|
||||
Path: acceptedPath,
|
||||
Flag: name,
|
||||
Before: oldFlag.Shorthand,
|
||||
After: newFlag.Shorthand,
|
||||
})
|
||||
}
|
||||
if oldFlag.NoOpt != "" && newFlag.NoOpt != oldFlag.NoOpt {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "flag_no_opt_changed",
|
||||
Path: acceptedPath,
|
||||
Flag: name,
|
||||
Before: oldFlag.NoOpt,
|
||||
After: newFlag.NoOpt,
|
||||
})
|
||||
}
|
||||
if !oldFlag.Hidden && newFlag.Hidden {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "flag_became_hidden",
|
||||
Path: acceptedPath,
|
||||
Flag: name,
|
||||
Before: "visible",
|
||||
After: "hidden",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range sortedFlagNames(newFlags) {
|
||||
newFlag := newFlags[name]
|
||||
if _, existed := oldFlags[name]; existed {
|
||||
continue
|
||||
}
|
||||
if newFlag.Required {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "required_flag_added",
|
||||
Path: acceptedPath,
|
||||
Flag: name,
|
||||
Before: "absent",
|
||||
After: "required",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Report optional additions once where they are declared. Required
|
||||
// additions were handled against the effective set above because they can
|
||||
// break every descendant invocation.
|
||||
newLocalFlags := flagIndex(newCommand.LocalFlags)
|
||||
for _, name := range sortedFlagNames(newLocalFlags) {
|
||||
newFlag := newLocalFlags[name]
|
||||
if _, existed := oldFlags[name]; existed || newFlag.Required {
|
||||
continue
|
||||
}
|
||||
result.Additions = append(result.Additions, Change{Kind: "flag_added", Path: newCommand.Path, Flag: name})
|
||||
}
|
||||
}
|
||||
|
||||
func commandIndex(snapshot Snapshot) map[string]Command {
|
||||
out := make(map[string]Command, len(snapshot.Commands))
|
||||
for _, command := range snapshot.Commands {
|
||||
out[command.Path] = command
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func aliasCollisionChanges(snapshot Snapshot) []Change {
|
||||
acceptedSiblings := make(map[string]string)
|
||||
changes := []Change{}
|
||||
for _, command := range snapshot.Commands {
|
||||
parent, name := splitParent(command.Path)
|
||||
for _, acceptedName := range append([]string{name}, command.Aliases...) {
|
||||
acceptedName = strings.TrimSpace(acceptedName)
|
||||
if acceptedName == "" {
|
||||
continue
|
||||
}
|
||||
key := parent + "\x00" + acceptedName
|
||||
if previous, exists := acceptedSiblings[key]; exists && previous != command.Path {
|
||||
changes = append(changes, Change{
|
||||
Kind: "command_alias_collision",
|
||||
Path: strings.TrimSpace(parent + " " + acceptedName),
|
||||
Before: previous,
|
||||
After: command.Path,
|
||||
})
|
||||
continue
|
||||
}
|
||||
acceptedSiblings[key] = command.Path
|
||||
}
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
// acceptedPathIndex expands aliases at every path segment. For example, if
|
||||
// "dws chat" has alias "im", then "dws im message send" remains accepted for
|
||||
// every descendant even though descendants only store their canonical paths.
|
||||
func acceptedPathIndex(snapshot Snapshot) map[string]string {
|
||||
commands := append([]Command(nil), snapshot.Commands...)
|
||||
sort.Slice(commands, func(i, j int) bool {
|
||||
leftDepth, rightDepth := pathDepth(commands[i].Path), pathDepth(commands[j].Path)
|
||||
if leftDepth != rightDepth {
|
||||
return leftDepth < rightDepth
|
||||
}
|
||||
return commands[i].Path < commands[j].Path
|
||||
})
|
||||
|
||||
variants := make(map[string][]string, len(commands))
|
||||
accepted := make(map[string]string)
|
||||
// Canonical paths always win over an alias collision.
|
||||
for _, command := range commands {
|
||||
accepted[command.Path] = command.Path
|
||||
}
|
||||
|
||||
for _, command := range commands {
|
||||
parent, name := splitParent(command.Path)
|
||||
names := compactSorted(append([]string{name}, command.Aliases...))
|
||||
parentVariants := variants[parent]
|
||||
if parent == "" {
|
||||
parentVariants = []string{""}
|
||||
} else if len(parentVariants) == 0 {
|
||||
parentVariants = []string{parent}
|
||||
}
|
||||
|
||||
commandVariants := make([]string, 0, len(parentVariants)*len(names))
|
||||
for _, parentVariant := range parentVariants {
|
||||
for _, candidateName := range names {
|
||||
path := strings.TrimSpace(parentVariant + " " + candidateName)
|
||||
commandVariants = append(commandVariants, path)
|
||||
if _, canonical := accepted[path]; !canonical {
|
||||
accepted[path] = command.Path
|
||||
}
|
||||
}
|
||||
}
|
||||
variants[command.Path] = compactSorted(commandVariants)
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
|
||||
func flagIndex(flags []Flag) map[string]Flag {
|
||||
out := make(map[string]Flag, len(flags))
|
||||
for _, flag := range flags {
|
||||
out[flag.Name] = flag
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func effectiveFlagIndex(command Command) map[string]Flag {
|
||||
out := flagIndex(command.InheritedFlags)
|
||||
for _, flag := range command.LocalFlags {
|
||||
// A local flag is the callable definition when it shadows an inherited
|
||||
// flag with the same name.
|
||||
out[flag.Name] = flag
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedCommandPaths(commands []Command) []string {
|
||||
paths := make([]string, 0, len(commands))
|
||||
for _, command := range commands {
|
||||
paths = append(paths, command.Path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
return paths
|
||||
}
|
||||
|
||||
func sortedAcceptedPaths(accepted map[string]string) []string {
|
||||
paths := make([]string, 0, len(accepted))
|
||||
for path := range accepted {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
return paths
|
||||
}
|
||||
|
||||
func sortedFlagNames(flags map[string]Flag) []string {
|
||||
names := make([]string, 0, len(flags))
|
||||
for name := range flags {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func minimalPaths(paths []string) []string {
|
||||
sort.Slice(paths, func(i, j int) bool {
|
||||
leftDepth, rightDepth := pathDepth(paths[i]), pathDepth(paths[j])
|
||||
if leftDepth != rightDepth {
|
||||
return leftDepth < rightDepth
|
||||
}
|
||||
return paths[i] < paths[j]
|
||||
})
|
||||
minimal := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
covered := false
|
||||
for _, parent := range minimal {
|
||||
if strings.HasPrefix(path, parent+" ") {
|
||||
covered = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !covered {
|
||||
minimal = append(minimal, path)
|
||||
}
|
||||
}
|
||||
return minimal
|
||||
}
|
||||
|
||||
func splitParent(path string) (string, string) {
|
||||
index := strings.LastIndexByte(path, ' ')
|
||||
if index < 0 {
|
||||
return "", path
|
||||
}
|
||||
return path[:index], path[index+1:]
|
||||
}
|
||||
|
||||
func pathDepth(path string) int {
|
||||
return len(strings.Fields(path))
|
||||
}
|
||||
|
||||
func sortChanges(changes []Change) {
|
||||
sort.Slice(changes, func(i, j int) bool {
|
||||
left := changes[i].Path + "\x00" + changes[i].Flag + "\x00" + changes[i].Kind
|
||||
right := changes[j].Path + "\x00" + changes[j].Flag + "\x00" + changes[j].Kind
|
||||
return left < right
|
||||
})
|
||||
}
|
||||
|
||||
func dedupeChanges(changes []Change) []Change {
|
||||
if len(changes) < 2 {
|
||||
return changes
|
||||
}
|
||||
out := changes[:1]
|
||||
for _, change := range changes[1:] {
|
||||
if change == out[len(out)-1] {
|
||||
continue
|
||||
}
|
||||
out = append(out, change)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,367 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package interfacesnapshot
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCompareAdmissionPolicy(t *testing.T) {
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "string", false)),
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
current Snapshot
|
||||
compatible bool
|
||||
kind string
|
||||
}{
|
||||
{
|
||||
name: "command addition allowed",
|
||||
current: testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "string", false)),
|
||||
testCommand("dws status"),
|
||||
),
|
||||
compatible: true,
|
||||
},
|
||||
{
|
||||
name: "flag addition allowed",
|
||||
current: testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search",
|
||||
testFlag("limit", "int", false),
|
||||
testFlag("query", "string", false),
|
||||
),
|
||||
),
|
||||
compatible: true,
|
||||
},
|
||||
{
|
||||
name: "required flag addition blocked on existing command",
|
||||
current: testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search",
|
||||
testFlag("query", "string", false),
|
||||
testFlag("tenant", "string", true),
|
||||
),
|
||||
),
|
||||
kind: "required_flag_added",
|
||||
},
|
||||
{
|
||||
name: "command deletion blocked",
|
||||
current: testSnapshot(
|
||||
testCommand("dws"),
|
||||
),
|
||||
kind: "command_removed",
|
||||
},
|
||||
{
|
||||
name: "rename without alias blocked",
|
||||
current: testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws find", testFlag("query", "string", false)),
|
||||
),
|
||||
kind: "command_removed",
|
||||
},
|
||||
{
|
||||
name: "flag deletion blocked",
|
||||
current: testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search"),
|
||||
),
|
||||
kind: "flag_removed",
|
||||
},
|
||||
{
|
||||
name: "optional becoming required blocked",
|
||||
current: testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "string", true)),
|
||||
),
|
||||
kind: "flag_became_required",
|
||||
},
|
||||
{
|
||||
name: "flag type change blocked",
|
||||
current: testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "stringSlice", false)),
|
||||
),
|
||||
kind: "flag_type_changed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
comparison := Compare(test.current, base, "base")
|
||||
if comparison.Compatible != test.compatible {
|
||||
t.Fatalf("Compatible = %v, want %v; blocking=%#v", comparison.Compatible, test.compatible, comparison.Blocking)
|
||||
}
|
||||
if test.kind == "" {
|
||||
return
|
||||
}
|
||||
if !hasChangeKind(comparison.Blocking, test.kind) {
|
||||
t.Fatalf("blocking=%#v, want kind %q", comparison.Blocking, test.kind)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareAllowsRenameWhenOldPathIsAlias(t *testing.T) {
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "string", false)),
|
||||
)
|
||||
current := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommandWithAliases("dws find", []string{"search"}, testFlag("query", "string", false)),
|
||||
)
|
||||
|
||||
comparison := Compare(current, base, "base")
|
||||
if !comparison.Compatible {
|
||||
t.Fatalf("rename with compatibility alias was blocked: %#v", comparison.Blocking)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareBlocksRemovedAlias(t *testing.T) {
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommandWithAliases("dws search", []string{"find"}),
|
||||
)
|
||||
current := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search"),
|
||||
)
|
||||
|
||||
comparison := Compare(current, base, "base")
|
||||
if comparison.Compatible || !hasChangeKind(comparison.Blocking, "command_alias_removed") {
|
||||
t.Fatalf("removed alias was not blocked: %#v", comparison)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareBlocksAliasRetargetedToIncompatibleCommand(t *testing.T) {
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommandWithAliases("dws search", []string{"find"}, testFlag("query", "string", false)),
|
||||
)
|
||||
current := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "string", false)),
|
||||
testCommand("dws find"),
|
||||
)
|
||||
|
||||
comparison := Compare(current, base, "base")
|
||||
if comparison.Compatible || !hasFlagChange(comparison.Blocking, "flag_removed", "dws find", "query") {
|
||||
t.Fatalf("alias retarget was not checked against its old contract: %#v", comparison)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareBlocksSnapshotRuleChanges(t *testing.T) {
|
||||
base := testSnapshot(testCommand("dws"))
|
||||
current := testSnapshot(testCommand("dws"))
|
||||
current.Rules.ExcludedFlags = append(current.Rules.ExcludedFlags, "legacy")
|
||||
|
||||
comparison := Compare(current, base, "base")
|
||||
if comparison.Compatible || !hasChangeKind(comparison.Blocking, "snapshot_rules_changed") {
|
||||
t.Fatalf("snapshot rule change was not blocked: %#v", comparison)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareBlocksCallableMetadataRegressions(t *testing.T) {
|
||||
baseFlag := testFlag("format", "string", false)
|
||||
baseFlag.Shorthand = "f"
|
||||
baseFlag.NoOpt = "json"
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws export", baseFlag),
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
command Command
|
||||
kind string
|
||||
}{
|
||||
{
|
||||
name: "command became non-runnable",
|
||||
command: func() Command {
|
||||
command := testCommand("dws export", baseFlag)
|
||||
command.Runnable = false
|
||||
return command
|
||||
}(),
|
||||
kind: "command_became_non_runnable",
|
||||
},
|
||||
{
|
||||
name: "command became hidden",
|
||||
command: func() Command {
|
||||
command := testCommand("dws export", baseFlag)
|
||||
command.Hidden = true
|
||||
return command
|
||||
}(),
|
||||
kind: "command_became_hidden",
|
||||
},
|
||||
{
|
||||
name: "flag shorthand removed",
|
||||
command: func() Command {
|
||||
flag := baseFlag
|
||||
flag.Shorthand = ""
|
||||
return testCommand("dws export", flag)
|
||||
}(),
|
||||
kind: "flag_shorthand_changed",
|
||||
},
|
||||
{
|
||||
name: "flag no-opt behavior removed",
|
||||
command: func() Command {
|
||||
flag := baseFlag
|
||||
flag.NoOpt = ""
|
||||
return testCommand("dws export", flag)
|
||||
}(),
|
||||
kind: "flag_no_opt_changed",
|
||||
},
|
||||
{
|
||||
name: "flag became hidden",
|
||||
command: func() Command {
|
||||
flag := baseFlag
|
||||
flag.Hidden = true
|
||||
return testCommand("dws export", flag)
|
||||
}(),
|
||||
kind: "flag_became_hidden",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
current := testSnapshot(testCommand("dws"), test.command)
|
||||
comparison := Compare(current, base, "base")
|
||||
if comparison.Compatible || !hasChangeKind(comparison.Blocking, test.kind) {
|
||||
t.Fatalf("metadata regression %q was not blocked: %#v", test.kind, comparison)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareUsesEffectiveFlagsAtEveryCommandPath(t *testing.T) {
|
||||
base := testSnapshot(
|
||||
testCommand("dws", testFlag("profile", "string", false)),
|
||||
testCommandWithFlagScopes(
|
||||
"dws search",
|
||||
[]Flag{testFlag("query", "string", false)},
|
||||
[]Flag{testFlag("profile", "string", false)},
|
||||
),
|
||||
)
|
||||
|
||||
t.Run("persistent flag scope narrowing is blocked", func(t *testing.T) {
|
||||
current := testSnapshot(
|
||||
testCommand("dws", testFlag("profile", "string", false)),
|
||||
testCommand("dws search", testFlag("query", "string", false)),
|
||||
)
|
||||
comparison := Compare(current, base, "base")
|
||||
if comparison.Compatible || !hasFlagChange(comparison.Blocking, "flag_removed", "dws search", "profile") {
|
||||
t.Fatalf("lost inherited flag was not blocked: %#v", comparison)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("local flag moved to inherited remains compatible", func(t *testing.T) {
|
||||
current := testSnapshot(
|
||||
testCommand("dws",
|
||||
testFlag("profile", "string", false),
|
||||
testFlag("query", "string", false),
|
||||
),
|
||||
testCommandWithFlagScopes(
|
||||
"dws search",
|
||||
nil,
|
||||
[]Flag{
|
||||
testFlag("profile", "string", false),
|
||||
testFlag("query", "string", false),
|
||||
},
|
||||
),
|
||||
)
|
||||
comparison := Compare(current, base, "base")
|
||||
if !comparison.Compatible {
|
||||
t.Fatalf("flag moved to an inherited scope was blocked: %#v", comparison.Blocking)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompareAllRequiresBothReferencesToPass(t *testing.T) {
|
||||
current := testSnapshot(testCommand("dws"), testCommand("dws status"))
|
||||
mergeBase := testSnapshot(testCommand("dws"))
|
||||
stable := testSnapshot(testCommand("dws"), testCommand("dws legacy"))
|
||||
|
||||
report := CompareAll(current, map[string]Snapshot{
|
||||
"stable": stable,
|
||||
"merge-base": mergeBase,
|
||||
})
|
||||
if report.Compatible {
|
||||
t.Fatal("aggregate report passed even though stable comparison removed a command")
|
||||
}
|
||||
if len(report.Comparisons) != 2 || report.Comparisons[0].Reference != "merge-base" || report.Comparisons[1].Reference != "stable" {
|
||||
t.Fatalf("comparisons are not deterministic: %#v", report.Comparisons)
|
||||
}
|
||||
if !report.Comparisons[0].Compatible || report.Comparisons[1].Compatible {
|
||||
t.Fatalf("unexpected per-reference results: %#v", report.Comparisons)
|
||||
}
|
||||
}
|
||||
|
||||
func testSnapshot(commands ...Command) Snapshot {
|
||||
return Snapshot{
|
||||
SchemaVersion: SchemaVersion,
|
||||
Rules: Rules{
|
||||
ExcludedCommandSubtrees: append([]string(nil), excludedCommandSubtrees...),
|
||||
ExcludedFlags: []string{"help"},
|
||||
},
|
||||
Commands: commands,
|
||||
}
|
||||
}
|
||||
|
||||
func testCommand(path string, flags ...Flag) Command {
|
||||
return testCommandWithAliases(path, nil, flags...)
|
||||
}
|
||||
|
||||
func testCommandWithAliases(path string, aliases []string, flags ...Flag) Command {
|
||||
return Command{
|
||||
Path: path,
|
||||
Runnable: true,
|
||||
Aliases: aliases,
|
||||
LocalFlags: flags,
|
||||
InheritedFlags: []Flag{},
|
||||
}
|
||||
}
|
||||
|
||||
func testCommandWithFlagScopes(path string, local, inherited []Flag) Command {
|
||||
return Command{
|
||||
Path: path,
|
||||
Aliases: []string{},
|
||||
LocalFlags: local,
|
||||
InheritedFlags: inherited,
|
||||
}
|
||||
}
|
||||
|
||||
func testFlag(name, flagType string, required bool) Flag {
|
||||
return Flag{Name: name, Type: flagType, Required: required}
|
||||
}
|
||||
|
||||
func hasChangeKind(changes []Change, kind string) bool {
|
||||
for _, change := range changes {
|
||||
if change.Kind == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasFlagChange(changes []Change, kind, path, flag string) bool {
|
||||
for _, change := range changes {
|
||||
if change.Kind == kind && change.Path == path && change.Flag == flag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package interfacesnapshot captures and compares the public Cobra command
|
||||
// surface without executing commands or contacting runtime services.
|
||||
package interfacesnapshot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
const SchemaVersion = 1
|
||||
|
||||
var (
|
||||
// Framework-owned command subtrees are deliberately excluded. They are
|
||||
// generated by Cobra or shell-completion plumbing rather than DWS product
|
||||
// code, so changes to them are not command-surface compatibility changes.
|
||||
excludedCommandSubtrees = []string{
|
||||
"dws __complete",
|
||||
"dws __completeNoDesc",
|
||||
"dws completion",
|
||||
"dws help",
|
||||
}
|
||||
|
||||
// Cobra installs --help lazily on every command. Keeping it would make the
|
||||
// snapshot depend on whether help happened to be rendered before capture.
|
||||
excludedFlags = map[string]bool{"help": true}
|
||||
)
|
||||
|
||||
// Rules records the noise filtering contract in every snapshot. A future rule
|
||||
// change is therefore visible instead of silently changing comparison scope.
|
||||
type Rules struct {
|
||||
ExcludedCommandSubtrees []string `json:"excluded_command_subtrees"`
|
||||
ExcludedFlags []string `json:"excluded_flags"`
|
||||
}
|
||||
|
||||
// Snapshot is a deterministic representation of a Cobra command tree.
|
||||
type Snapshot struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Rules Rules `json:"rules"`
|
||||
Commands []Command `json:"commands"`
|
||||
}
|
||||
|
||||
// Command contains compatibility-relevant command metadata. Path always
|
||||
// includes the root command name (for example, "dws chat message send").
|
||||
type Command struct {
|
||||
Path string `json:"path"`
|
||||
Runnable bool `json:"runnable"`
|
||||
Hidden bool `json:"hidden"`
|
||||
Deprecated string `json:"deprecated,omitempty"`
|
||||
Aliases []string `json:"aliases"`
|
||||
LocalFlags []Flag `json:"local_flags"`
|
||||
InheritedFlags []Flag `json:"inherited_flags"`
|
||||
}
|
||||
|
||||
// Flag contains the stable pflag contract visible at a command node.
|
||||
type Flag struct {
|
||||
Name string `json:"name"`
|
||||
Shorthand string `json:"shorthand,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Default string `json:"default"`
|
||||
NoOpt string `json:"no_opt,omitempty"`
|
||||
Required bool `json:"required"`
|
||||
Hidden bool `json:"hidden"`
|
||||
Deprecated string `json:"deprecated,omitempty"`
|
||||
}
|
||||
|
||||
// Capture walks root without rendering help or executing any command. Both
|
||||
// hidden compatibility commands and hidden flags are retained unless they are
|
||||
// covered by the explicit framework-noise rules above.
|
||||
func Capture(root *cobra.Command) Snapshot {
|
||||
snapshot := Snapshot{
|
||||
SchemaVersion: SchemaVersion,
|
||||
Rules: Rules{
|
||||
ExcludedCommandSubtrees: append([]string(nil), excludedCommandSubtrees...),
|
||||
ExcludedFlags: []string{"help"},
|
||||
},
|
||||
Commands: []Command{},
|
||||
}
|
||||
if root == nil {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// --version is also installed lazily by Cobra, but unlike --help it is a
|
||||
// real root CLI contract and should be captured.
|
||||
root.InitDefaultVersionFlag()
|
||||
|
||||
var walk func(*cobra.Command)
|
||||
walk = func(cmd *cobra.Command) {
|
||||
path := normalizePath(cmd.CommandPath())
|
||||
if commandExcluded(path) {
|
||||
return
|
||||
}
|
||||
|
||||
aliases := append([]string{}, cmd.Aliases...)
|
||||
for i := range aliases {
|
||||
aliases[i] = strings.TrimSpace(aliases[i])
|
||||
}
|
||||
aliases = compactSorted(aliases)
|
||||
|
||||
snapshot.Commands = append(snapshot.Commands, Command{
|
||||
Path: path,
|
||||
Runnable: cmd.Runnable(),
|
||||
Hidden: cmd.Hidden,
|
||||
Deprecated: strings.TrimSpace(cmd.Deprecated),
|
||||
Aliases: aliases,
|
||||
LocalFlags: captureFlags(cmd.LocalFlags()),
|
||||
InheritedFlags: captureFlags(cmd.InheritedFlags()),
|
||||
})
|
||||
|
||||
children := append([]*cobra.Command(nil), cmd.Commands()...)
|
||||
sort.Slice(children, func(i, j int) bool {
|
||||
return children[i].Name() < children[j].Name()
|
||||
})
|
||||
for _, child := range children {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
|
||||
sort.Slice(snapshot.Commands, func(i, j int) bool {
|
||||
return snapshot.Commands[i].Path < snapshot.Commands[j].Path
|
||||
})
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// Write emits canonical, indented JSON with a trailing newline.
|
||||
func Write(w io.Writer, snapshot Snapshot) error {
|
||||
if err := snapshot.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetEscapeHTML(false)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(snapshot)
|
||||
}
|
||||
|
||||
// Read decodes and validates a snapshot. Unknown fields are rejected so a
|
||||
// comparison never silently ignores a newer contract it does not understand.
|
||||
func Read(r io.Reader) (Snapshot, error) {
|
||||
var snapshot Snapshot
|
||||
decoder := json.NewDecoder(r)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&snapshot); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return Snapshot{}, fmt.Errorf("interface snapshot contains multiple JSON values")
|
||||
}
|
||||
return Snapshot{}, fmt.Errorf("read trailing interface snapshot data: %w", err)
|
||||
}
|
||||
if err := snapshot.Validate(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
// Validate checks the invariants needed by the comparison algorithm.
|
||||
func (s Snapshot) Validate() error {
|
||||
if s.SchemaVersion != SchemaVersion {
|
||||
return fmt.Errorf("unsupported interface snapshot schema_version %d (want %d)", s.SchemaVersion, SchemaVersion)
|
||||
}
|
||||
seenCommands := make(map[string]bool, len(s.Commands))
|
||||
for _, command := range s.Commands {
|
||||
if command.Path == "" {
|
||||
return fmt.Errorf("interface snapshot contains an empty command path")
|
||||
}
|
||||
if seenCommands[command.Path] {
|
||||
return fmt.Errorf("interface snapshot contains duplicate command path %q", command.Path)
|
||||
}
|
||||
seenCommands[command.Path] = true
|
||||
if err := validateFlags(command.Path, "local", command.LocalFlags); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateFlags(command.Path, "inherited", command.InheritedFlags); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func captureFlags(set *pflag.FlagSet) []Flag {
|
||||
flags := []Flag{}
|
||||
if set == nil {
|
||||
return flags
|
||||
}
|
||||
set.VisitAll(func(flag *pflag.Flag) {
|
||||
if flag == nil || excludedFlags[flag.Name] {
|
||||
return
|
||||
}
|
||||
flags = append(flags, Flag{
|
||||
Name: flag.Name,
|
||||
Shorthand: flag.Shorthand,
|
||||
Type: flag.Value.Type(),
|
||||
Default: flag.DefValue,
|
||||
NoOpt: flag.NoOptDefVal,
|
||||
Required: isRequired(flag),
|
||||
Hidden: flag.Hidden,
|
||||
Deprecated: strings.TrimSpace(flag.Deprecated),
|
||||
})
|
||||
})
|
||||
sort.Slice(flags, func(i, j int) bool { return flags[i].Name < flags[j].Name })
|
||||
return flags
|
||||
}
|
||||
|
||||
func isRequired(flag *pflag.Flag) bool {
|
||||
for _, value := range flag.Annotations[cobra.BashCompOneRequiredFlag] {
|
||||
if value == "true" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func commandExcluded(path string) bool {
|
||||
for _, prefix := range excludedCommandSubtrees {
|
||||
if path == prefix || strings.HasPrefix(path, prefix+" ") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizePath(path string) string {
|
||||
return strings.Join(strings.Fields(path), " ")
|
||||
}
|
||||
|
||||
func compactSorted(values []string) []string {
|
||||
sort.Strings(values)
|
||||
out := values[:0]
|
||||
for _, value := range values {
|
||||
if value == "" || (len(out) > 0 && out[len(out)-1] == value) {
|
||||
continue
|
||||
}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validateFlags(path, scope string, flags []Flag) error {
|
||||
seen := make(map[string]bool, len(flags))
|
||||
for _, flag := range flags {
|
||||
if flag.Name == "" {
|
||||
return fmt.Errorf("command %q contains an empty %s flag name", path, scope)
|
||||
}
|
||||
if seen[flag.Name] {
|
||||
return fmt.Errorf("command %q contains duplicate %s flag %q", path, scope, flag.Name)
|
||||
}
|
||||
seen[flag.Name] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package interfacesnapshot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCaptureUsesStableNoiseRulesAndFlagScopes(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws", Version: "test"}
|
||||
root.PersistentFlags().String("profile", "", "profile")
|
||||
|
||||
service := &cobra.Command{
|
||||
Use: "service",
|
||||
Aliases: []string{"svc", "api", "svc"},
|
||||
Hidden: true,
|
||||
Deprecated: "use replacement",
|
||||
}
|
||||
leaf := &cobra.Command{Use: "run", Run: func(*cobra.Command, []string) {}}
|
||||
leaf.Flags().String("name", "", "name")
|
||||
leaf.Flags().Bool("force", false, "force")
|
||||
if err := leaf.MarkFlagRequired("name"); err != nil {
|
||||
t.Fatalf("MarkFlagRequired: %v", err)
|
||||
}
|
||||
leaf.InitDefaultHelpFlag()
|
||||
service.AddCommand(leaf)
|
||||
|
||||
help := &cobra.Command{Use: "help"}
|
||||
completion := &cobra.Command{Use: "completion"}
|
||||
completion.AddCommand(&cobra.Command{Use: "zsh"})
|
||||
root.AddCommand(service, help, completion)
|
||||
|
||||
snapshot := Capture(root)
|
||||
wantPaths := []string{"dws", "dws service", "dws service run"}
|
||||
if got := sortedCommandPaths(snapshot.Commands); !reflect.DeepEqual(got, wantPaths) {
|
||||
t.Fatalf("paths = %v, want %v", got, wantPaths)
|
||||
}
|
||||
|
||||
serviceSnapshot := commandIndex(snapshot)["dws service"]
|
||||
if !serviceSnapshot.Hidden || serviceSnapshot.Deprecated != "use replacement" {
|
||||
t.Fatalf("service metadata = %#v", serviceSnapshot)
|
||||
}
|
||||
if want := []string{"api", "svc"}; !reflect.DeepEqual(serviceSnapshot.Aliases, want) {
|
||||
t.Fatalf("aliases = %v, want %v", serviceSnapshot.Aliases, want)
|
||||
}
|
||||
|
||||
leafSnapshot := commandIndex(snapshot)["dws service run"]
|
||||
if !leafSnapshot.Runnable {
|
||||
t.Fatal("runnable leaf was not recorded as runnable")
|
||||
}
|
||||
local := flagIndex(leafSnapshot.LocalFlags)
|
||||
if len(local) != 2 {
|
||||
t.Fatalf("local flags = %#v, want two business flags and no auto help flag", leafSnapshot.LocalFlags)
|
||||
}
|
||||
if local["name"].Type != "string" || !local["name"].Required {
|
||||
t.Fatalf("name flag = %#v, want required string", local["name"])
|
||||
}
|
||||
if local["force"].Type != "bool" || local["force"].Required {
|
||||
t.Fatalf("force flag = %#v, want optional bool", local["force"])
|
||||
}
|
||||
inherited := flagIndex(leafSnapshot.InheritedFlags)
|
||||
if inherited["profile"].Type != "string" {
|
||||
t.Fatalf("inherited flags = %#v, want root --profile", leafSnapshot.InheritedFlags)
|
||||
}
|
||||
if _, exists := inherited["help"]; exists {
|
||||
t.Fatal("auto --help leaked into inherited flags")
|
||||
}
|
||||
|
||||
var first, second bytes.Buffer
|
||||
if err := Write(&first, snapshot); err != nil {
|
||||
t.Fatalf("first Write: %v", err)
|
||||
}
|
||||
if err := Write(&second, Capture(root)); err != nil {
|
||||
t.Fatalf("second Write: %v", err)
|
||||
}
|
||||
if !bytes.Equal(first.Bytes(), second.Bytes()) {
|
||||
t.Fatal("capturing the same Cobra root twice produced different JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRejectsUnknownSnapshotFields(t *testing.T) {
|
||||
input := bytes.NewBufferString(`{
|
||||
"schema_version": 1,
|
||||
"rules": {"excluded_command_subtrees": [], "excluded_flags": []},
|
||||
"commands": [],
|
||||
"future_field": true
|
||||
}`)
|
||||
if _, err := Read(input); err == nil {
|
||||
t.Fatal("Read accepted an unknown field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareBlocksCandidateSiblingAliasCollision(t *testing.T) {
|
||||
base := testSnapshot(testCommand("dws"))
|
||||
current := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommandWithAliases("dws search", []string{"find"}),
|
||||
testCommand("dws find"),
|
||||
)
|
||||
comparison := Compare(current, base, "base")
|
||||
if comparison.Compatible || !hasChangeKind(comparison.Blocking, "command_alias_collision") {
|
||||
t.Fatalf("candidate sibling alias collision was not blocked: %#v", comparison)
|
||||
}
|
||||
}
|
||||
+4
-6
@@ -6,15 +6,13 @@ These repo-local entrypoints are the supported shell entrypoints for building, t
|
||||
- `make test`: run `go test ./...`
|
||||
- `make lint`: run formatting checks and required `golangci-lint`
|
||||
- `make fmt`: format Go source files under `cmd/`, `internal/`, and `test/`
|
||||
- `make package`: build all release artifacts locally
|
||||
- `make release-pre VERSION=vX.Y.Z-beta.N`: validate a prerelease (`PUBLISH=1` pushes its tag)
|
||||
- `make release-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N`: validate a stable promotion (`PUBLISH=1` pushes its tag)
|
||||
- `make package`: build all release artifacts locally via `goreleaser --snapshot`
|
||||
- `make release`: build and publish a release via `goreleaser`
|
||||
|
||||
Script groups:
|
||||
|
||||
- Root installers: `./scripts/install.sh`, `./scripts/install.ps1`, `./scripts/install-skills.sh`
|
||||
- Product convenience installers: `./scripts/install-devapp.sh`, `./scripts/install-devapp.ps1`, `./scripts/install-event.sh`
|
||||
- Dev helpers: `./scripts/dev/build.sh`, `./scripts/dev/lint.sh`, `./scripts/dev/ci-local.sh`, `./scripts/dev/run-mock-e2e.sh`, `./scripts/dev/coverage.sh`
|
||||
- Policy checks: `./scripts/policy/check-generated-drift.sh`, `./scripts/policy/check-command-surface.sh`, `./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>`, `./scripts/policy/check-open-source-assets.sh`
|
||||
- Release entrypoint and contract: `./scripts/release/release.sh`, `./scripts/release/release-contract.sh`; operator guide: [`docs/releasing.md`](../docs/releasing.md)
|
||||
- Release packaging helpers: `./scripts/release/post-goreleaser.sh`, `./scripts/release/stage-npm-package.sh`, `./scripts/release/pack-npm-package.sh`, `./scripts/release/verify-delivered-stable.sh`, `./scripts/release/verify-release-artifacts.sh`, `./scripts/release/verify-github-release-assets.sh`, `./scripts/release/verify-github-tag-authority.sh`, `./scripts/release/verify-package-managers.sh`, `./scripts/release/publish-homebrew-formula.sh`
|
||||
- Policy checks: `./scripts/policy/check-generated-drift.sh`, `./scripts/policy/check-command-surface.sh`, `./scripts/policy/check-open-source-assets.sh`
|
||||
- Release helpers: `./scripts/release/post-goreleaser.sh`, `./scripts/release/verify-package-managers.sh`, `./scripts/release/publish-homebrew-formula.sh`
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Compare the current Cobra command surface with two local Git revisions:
|
||||
# the PR merge-base/main reference and the latest stable GA tag. The caller must
|
||||
# pass the highest non-prerelease SemVer tag (excluding beta/rc tags). Tag
|
||||
# governance treats that tag as proof of a successfully published GA release;
|
||||
# release automation must not leave a GA tag behind when publication fails.
|
||||
#
|
||||
# The script never fetches refs or snapshots from the network. With checkout
|
||||
# fetch-depth=0, both references are materialized in temporary worktrees. A
|
||||
# reference uses its own snapshot helper when available; older revisions are
|
||||
# bootstrapped with the candidate helper.
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
BASE_REF=""
|
||||
STABLE_REF=""
|
||||
|
||||
usage() {
|
||||
printf '%s\n' "usage: $0 --base-ref <ref> --stable-ref <ref>" >&2
|
||||
}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--base-ref)
|
||||
[ "$#" -ge 2 ] || { usage; exit 2; }
|
||||
BASE_REF="$2"
|
||||
shift 2
|
||||
;;
|
||||
--stable-ref)
|
||||
[ "$#" -ge 2 ] || { usage; exit 2; }
|
||||
STABLE_REF="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'unknown argument: %s\n' "$1" >&2
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$BASE_REF" ] && [ -n "$STABLE_REF" ] || { usage; exit 2; }
|
||||
|
||||
cd "$ROOT"
|
||||
git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null || {
|
||||
printf 'base ref is not available locally: %s\n' "$BASE_REF" >&2
|
||||
exit 2
|
||||
}
|
||||
git rev-parse --verify --quiet "${STABLE_REF}^{commit}" >/dev/null || {
|
||||
printf 'stable ref is not available locally: %s\n' "$STABLE_REF" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-command-compat.XXXXXX")"
|
||||
BASE_WORKTREE="$TMP_ROOT/base-worktree"
|
||||
STABLE_WORKTREE="$TMP_ROOT/stable-worktree"
|
||||
|
||||
cleanup() {
|
||||
git -C "$ROOT" worktree remove --force "$BASE_WORKTREE" >/dev/null 2>&1 || true
|
||||
git -C "$ROOT" worktree remove --force "$STABLE_WORKTREE" >/dev/null 2>&1 || true
|
||||
rm -rf "$TMP_ROOT"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 129' HUP
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
ensure_snapshot_helper() {
|
||||
worktree="$1"
|
||||
|
||||
# Once a baseline contains the helper, use that revision's own capture
|
||||
# rules. This makes a rule/filter change in the candidate visible as a
|
||||
# snapshot_rules_changed failure instead of applying the new exclusion to
|
||||
# both sides and accidentally hiding a removed command. Older revisions
|
||||
# created before this helper existed are bootstrapped with the candidate
|
||||
# implementation.
|
||||
if [ -f "$worktree/cmd/interface-snapshot/main.go" ] && \
|
||||
[ -f "$worktree/internal/interfacesnapshot/snapshot.go" ] && \
|
||||
[ -f "$worktree/internal/interfacesnapshot/compare.go" ]; then
|
||||
return
|
||||
fi
|
||||
if [ -e "$worktree/cmd/interface-snapshot" ] || \
|
||||
[ -e "$worktree/internal/interfacesnapshot" ]; then
|
||||
printf 'baseline contains an incomplete interface snapshot helper: %s\n' "$worktree" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p "$worktree/cmd/interface-snapshot" "$worktree/internal/interfacesnapshot"
|
||||
cp -R "$ROOT/cmd/interface-snapshot/." "$worktree/cmd/interface-snapshot/"
|
||||
cp -R "$ROOT/internal/interfacesnapshot/." "$worktree/internal/interfacesnapshot/"
|
||||
}
|
||||
|
||||
generate_ref_snapshot() {
|
||||
ref="$1"
|
||||
worktree="$2"
|
||||
output="$3"
|
||||
|
||||
git -C "$ROOT" worktree add --detach "$worktree" "$ref" >/dev/null
|
||||
ensure_snapshot_helper "$worktree"
|
||||
(
|
||||
cd "$worktree"
|
||||
go run ./cmd/interface-snapshot generate --output "$output"
|
||||
)
|
||||
}
|
||||
|
||||
CANDIDATE="$TMP_ROOT/candidate.json"
|
||||
BASELINE="$TMP_ROOT/merge-base.json"
|
||||
STABLE="$TMP_ROOT/stable.json"
|
||||
|
||||
go run ./cmd/interface-snapshot generate --output "$CANDIDATE"
|
||||
generate_ref_snapshot "$BASE_REF" "$BASE_WORKTREE" "$BASELINE"
|
||||
generate_ref_snapshot "$STABLE_REF" "$STABLE_WORKTREE" "$STABLE"
|
||||
|
||||
go run ./cmd/interface-snapshot compare \
|
||||
--current "$CANDIDATE" \
|
||||
--base "$BASELINE" \
|
||||
--stable "$STABLE"
|
||||
@@ -1,7 +1,50 @@
|
||||
#!/bin/sh
|
||||
#!/usr/bin/env bash
|
||||
# Build release artifacts locally in the current runner and publish them to Gitee.
|
||||
|
||||
set -eu
|
||||
|
||||
printf '%s\n' \
|
||||
'Direct Gitee release builds are disabled.' \
|
||||
'Use make release-pre/release-stable; release.yml mirrors the exact immutable GitHub assets.' >&2
|
||||
exit 2
|
||||
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
SCRIPT_ROOT="$ROOT"
|
||||
cd "$SCRIPT_ROOT"
|
||||
|
||||
VERSION="${VERSION:-$(git describe --tags --exact-match 2>/dev/null || true)}"
|
||||
[ -n "$VERSION" ] || {
|
||||
echo "error: VERSION is required or HEAD must be an exact release tag" >&2
|
||||
exit 1
|
||||
}
|
||||
case "$VERSION" in
|
||||
v*) TAG="$VERSION"; SEMVER="${VERSION#v}" ;;
|
||||
*) TAG="v$VERSION"; SEMVER="$VERSION" ;;
|
||||
esac
|
||||
|
||||
git fetch --tags origin "refs/tags/${TAG}:refs/tags/${TAG}" >/dev/null 2>&1 || true
|
||||
target_commit="$(git rev-parse "${TAG}^{commit}" 2>/dev/null || true)"
|
||||
[ -n "$target_commit" ] || {
|
||||
echo "error: could not resolve tag ${TAG}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
current_commit="$(git rev-parse HEAD)"
|
||||
WORKDIR="$SCRIPT_ROOT"
|
||||
cleanup_worktree() {
|
||||
if [ "$WORKDIR" != "$SCRIPT_ROOT" ]; then
|
||||
git -C "$SCRIPT_ROOT" worktree remove --force "$WORKDIR" >/dev/null 2>&1 || rm -rf "$WORKDIR"
|
||||
fi
|
||||
}
|
||||
trap cleanup_worktree EXIT
|
||||
|
||||
if [ "$current_commit" != "$target_commit" ]; then
|
||||
WORKDIR="$(mktemp -d)"
|
||||
rm -rf "$WORKDIR"
|
||||
git worktree add --detach "$WORKDIR" "$TAG"
|
||||
mkdir -p "$WORKDIR/scripts/release"
|
||||
cp "$SCRIPT_ROOT/scripts/release/publish-gitee-local.sh" "$WORKDIR/scripts/release/publish-gitee-local.sh"
|
||||
chmod +x "$WORKDIR/scripts/release/publish-gitee-local.sh"
|
||||
fi
|
||||
|
||||
cd "$WORKDIR"
|
||||
|
||||
echo "==> Building ${TAG} locally for Gitee"
|
||||
VERSION="$SEMVER" ./scripts/dev/build-all.sh
|
||||
DWS_PACKAGE_VERSION="$TAG" ./scripts/release/post-goreleaser.sh
|
||||
VERSION="$TAG" ./scripts/release/publish-gitee-local.sh
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/release-lib.sh"
|
||||
ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
VERSION=""
|
||||
FROM_BETA=""
|
||||
REMOTE=""
|
||||
MODE="check"
|
||||
MODE_SET=0
|
||||
WIZARD=0
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: dws-release [version] [options]
|
||||
dws-release config [--remote <name>]
|
||||
|
||||
With no arguments, starts an interactive release guide. For a version that has
|
||||
no CHANGELOG section yet, prepares the template and stops. Otherwise, runs the
|
||||
full guarded preflight; add --publish to publish after the preflight succeeds.
|
||||
|
||||
Options:
|
||||
--from-beta <tag> Required stable promotion baseline
|
||||
--remote <name> Override the configured release remote
|
||||
--check Validate only (default)
|
||||
--publish Run the preflight, then create and push the tag
|
||||
-h, --help Show this help
|
||||
|
||||
Examples:
|
||||
dws-release config --remote origin
|
||||
dws-release v1.2.3-beta.1
|
||||
dws-release v1.2.3-beta.1 --publish
|
||||
dws-release v1.2.3 --from-beta v1.2.3-beta.1
|
||||
dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
|
||||
EOF
|
||||
}
|
||||
|
||||
die_usage() {
|
||||
printf '%s\n' "$1" >&2
|
||||
usage
|
||||
exit 2
|
||||
}
|
||||
|
||||
is_interactive() {
|
||||
[ -t 0 ] && [ -t 1 ]
|
||||
}
|
||||
|
||||
repository_identity() {
|
||||
identity_url="$1"
|
||||
case "$identity_url" in
|
||||
https://github.com/*) identity_path="${identity_url#https://github.com/}" ;;
|
||||
http://github.com/*) identity_path="${identity_url#http://github.com/}" ;;
|
||||
git://github.com/*) identity_path="${identity_url#git://github.com/}" ;;
|
||||
git@github.com:*) identity_path="${identity_url#git@github.com:}" ;;
|
||||
ssh://git@github.com/*) identity_path="${identity_url#ssh://git@github.com/}" ;;
|
||||
*) printf '%s\n' "$identity_url"; return 0 ;;
|
||||
esac
|
||||
identity_path="${identity_path%/}"
|
||||
identity_path="${identity_path%.git}"
|
||||
printf 'github.com/%s\n' "$identity_path"
|
||||
}
|
||||
|
||||
release_remote_identity() {
|
||||
identity_remote="$1"
|
||||
identity_fetch_url="$(git remote get-url "$identity_remote" 2>/dev/null)" || {
|
||||
printf 'configured release remote does not exist: %s\n' "$identity_remote" >&2
|
||||
return 1
|
||||
}
|
||||
identity_push_urls="$(git remote get-url --push --all "$identity_remote" 2>/dev/null)" || {
|
||||
printf 'could not resolve push URL for release remote: %s\n' "$identity_remote" >&2
|
||||
return 1
|
||||
}
|
||||
identity_push_count="$(printf '%s\n' "$identity_push_urls" | sed '/^$/d' | wc -l | tr -d '[:space:]')"
|
||||
[ "$identity_push_count" -eq 1 ] || {
|
||||
printf 'release remote must have exactly one push URL: %s\n' "$identity_remote" >&2
|
||||
return 1
|
||||
}
|
||||
identity_push_url="$(printf '%s\n' "$identity_push_urls" | sed -n '1p')"
|
||||
identity_fetch="$(repository_identity "$identity_fetch_url")"
|
||||
identity_push="$(repository_identity "$identity_push_url")"
|
||||
[ "$identity_fetch" = "$identity_push" ] || {
|
||||
printf 'release remote fetch and push URLs target different repositories:\n fetch: %s\n push: %s\n' \
|
||||
"$identity_fetch_url" "$identity_push_url" >&2
|
||||
return 1
|
||||
}
|
||||
printf '%s\n' "$identity_fetch"
|
||||
}
|
||||
|
||||
set_mode() {
|
||||
requested_mode="$1"
|
||||
if [ "$MODE_SET" -eq 1 ] && [ "$MODE" != "$requested_mode" ]; then
|
||||
die_usage '--check and --publish cannot be used together'
|
||||
fi
|
||||
MODE="$requested_mode"
|
||||
MODE_SET=1
|
||||
}
|
||||
|
||||
require_remote() {
|
||||
if [ -z "$REMOTE" ]; then
|
||||
REMOTE="$(git config --local --get dws.releaseRemote 2>/dev/null || true)"
|
||||
fi
|
||||
if [ -z "$REMOTE" ] && is_interactive; then
|
||||
printf 'Configured remotes:\n' >&2
|
||||
git remote -v >&2
|
||||
printf 'Release remote: ' >&2
|
||||
IFS= read -r REMOTE
|
||||
fi
|
||||
[ -n "$REMOTE" ] || {
|
||||
printf '%s\n' 'release remote is not configured; run: dws-release config --remote <name>' >&2
|
||||
exit 1
|
||||
}
|
||||
expected_repository="$(git config --local --get dws.releaseRepository 2>/dev/null || true)"
|
||||
[ -n "$expected_repository" ] || {
|
||||
printf '%s\n' 'release repository identity is not configured; rerun: dws-release config --remote <name>' >&2
|
||||
exit 1
|
||||
}
|
||||
actual_repository="$(release_remote_identity "$REMOTE")" || exit 1
|
||||
[ "$actual_repository" = "$expected_repository" ] || {
|
||||
printf 'release remote %s changed repository identity:\n configured: %s\n current: %s\n' \
|
||||
"$REMOTE" "$expected_repository" "$actual_repository" >&2
|
||||
printf '%s\n' 'Review the remote and run dws-release config again only if this change is intentional.' >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
sync_main_if_safe() {
|
||||
current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
|
||||
[ "$current_branch" = "main" ] || {
|
||||
printf 'release validation must run from the main worktree (current: %s)\n' "${current_branch:-detached HEAD}" >&2
|
||||
exit 1
|
||||
}
|
||||
[ -z "$(git status --porcelain --untracked-files=all)" ] || {
|
||||
printf '%s\n' 'release main worktree must be clean before synchronization' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
printf '==> Synchronizing %s/main\n' "$REMOTE"
|
||||
git fetch --force "$REMOTE" "+refs/heads/main:refs/remotes/$REMOTE/main"
|
||||
remote_main="refs/remotes/$REMOTE/main"
|
||||
head_commit="$(git rev-parse HEAD)"
|
||||
remote_commit="$(git rev-parse "$remote_main^{commit}")"
|
||||
if [ "$head_commit" = "$remote_commit" ]; then
|
||||
return 0
|
||||
fi
|
||||
if git merge-base --is-ancestor HEAD "$remote_main"; then
|
||||
git merge --ff-only "$remote_main"
|
||||
return 0
|
||||
fi
|
||||
if git merge-base --is-ancestor "$remote_main" HEAD; then
|
||||
printf 'local main is ahead of %s/main; publish only after the commits are reviewed and pushed\n' "$REMOTE" >&2
|
||||
else
|
||||
printf 'local main has diverged from %s/main; resolve it explicitly before release\n' "$REMOTE" >&2
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
configure_remote() {
|
||||
shift
|
||||
config_remote=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--remote)
|
||||
[ "$#" -ge 2 ] || die_usage '--remote requires a value'
|
||||
config_remote="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*) die_usage "unknown config argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
cd "$ROOT"
|
||||
if [ -z "$config_remote" ]; then
|
||||
configured="$(git config --local --get dws.releaseRemote 2>/dev/null || true)"
|
||||
configured_repository="$(git config --local --get dws.releaseRepository 2>/dev/null || true)"
|
||||
printf 'Configured release remote: %s\n' "${configured:-none}"
|
||||
printf 'Configured repository: %s\n' "${configured_repository:-none}"
|
||||
git remote -v
|
||||
exit 0
|
||||
fi
|
||||
configured_repository="$(release_remote_identity "$config_remote")" || exit 1
|
||||
git config --local dws.releaseRemote "$config_remote"
|
||||
git config --local dws.releaseRepository "$configured_repository"
|
||||
printf 'Configured release remote: %s\n' "$config_remote"
|
||||
printf 'Configured repository: %s\n' "$configured_repository"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if [ "${1:-}" = "config" ]; then
|
||||
configure_remote "$@"
|
||||
fi
|
||||
|
||||
[ "$#" -gt 0 ] || WIZARD=1
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--from-beta)
|
||||
[ "$#" -ge 2 ] || die_usage '--from-beta requires a value'
|
||||
FROM_BETA="$2"
|
||||
shift 2
|
||||
;;
|
||||
--remote)
|
||||
[ "$#" -ge 2 ] || die_usage '--remote requires a value'
|
||||
REMOTE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--check) set_mode check; shift ;;
|
||||
--publish) set_mode publish; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
-*) die_usage "unknown argument: $1" ;;
|
||||
*)
|
||||
[ -z "$VERSION" ] || die_usage "unexpected argument: $1"
|
||||
VERSION="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
is_interactive || { usage; exit 2; }
|
||||
printf 'Release version (vX.Y.Z-beta.N or vX.Y.Z): ' >&2
|
||||
IFS= read -r VERSION
|
||||
fi
|
||||
|
||||
CHANNEL="$(release_channel_for_version "$VERSION")" || exit 2
|
||||
if [ "$CHANNEL" = "stable" ]; then
|
||||
if [ -z "$FROM_BETA" ] && is_interactive; then
|
||||
printf 'Validated beta tag for %s: ' "$VERSION" >&2
|
||||
IFS= read -r FROM_BETA
|
||||
fi
|
||||
[ -n "$FROM_BETA" ] || die_usage 'stable release requires --from-beta <tag>'
|
||||
release_validate_version_channel prerelease "$FROM_BETA" || exit 2
|
||||
[ "$(release_core_tag "$FROM_BETA")" = "$VERSION" ] || {
|
||||
printf 'stable version %s does not match beta baseline %s\n' "$VERSION" "$FROM_BETA" >&2
|
||||
exit 2
|
||||
}
|
||||
else
|
||||
[ -z "$FROM_BETA" ] || die_usage '--from-beta is only valid for stable releases'
|
||||
fi
|
||||
|
||||
if [ "$WIZARD" -eq 1 ]; then
|
||||
printf 'Mode [check/publish] (default: check): ' >&2
|
||||
IFS= read -r selected_mode
|
||||
case "$selected_mode" in
|
||||
''|check) MODE=check ;;
|
||||
publish) MODE=publish ;;
|
||||
*) die_usage "invalid mode: $selected_mode" ;;
|
||||
esac
|
||||
fi
|
||||
[ -z "${DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE:-}" ] || {
|
||||
printf '%s\n' 'DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE is test-only and cannot be set through dws-release' >&2
|
||||
exit 1
|
||||
}
|
||||
[ -z "${DWS_RELEASE_OFFICIAL_TAGS_URL:-}" ] || {
|
||||
printf '%s\n' 'DWS_RELEASE_OFFICIAL_TAGS_URL cannot override release authority through dws-release' >&2
|
||||
exit 1
|
||||
}
|
||||
[ -z "${DWS_RELEASE_OFFICIAL_REPOSITORY:-}" ] || {
|
||||
printf '%s\n' 'DWS_RELEASE_OFFICIAL_REPOSITORY cannot override release authority through dws-release' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
cd "$ROOT"
|
||||
CHANGELOG="$ROOT/CHANGELOG.md"
|
||||
[ -f "$CHANGELOG" ] || { printf 'CHANGELOG not found: %s\n' "$CHANGELOG" >&2; exit 1; }
|
||||
MAIN_SYNCED=0
|
||||
current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
|
||||
if [ "$current_branch" = "main" ]; then
|
||||
require_remote
|
||||
sync_main_if_safe
|
||||
MAIN_SYNCED=1
|
||||
fi
|
||||
semver="$(release_semver "$VERSION")"
|
||||
section_count="$(awk -v wanted="$semver" '
|
||||
BEGIN { prefix = "## [" wanted "] - "; count = 0 }
|
||||
index($0, prefix) == 1 { count++ }
|
||||
END { print count }
|
||||
' "$CHANGELOG")"
|
||||
|
||||
if [ "$section_count" -eq 0 ]; then
|
||||
printf '==> Preparing the missing CHANGELOG section for %s\n' "$VERSION"
|
||||
if [ "$CHANNEL" = "stable" ]; then
|
||||
"$SCRIPT_DIR/prepare-changelog.sh" stable "$VERSION" --from-beta "$FROM_BETA"
|
||||
else
|
||||
"$SCRIPT_DIR/prepare-changelog.sh" prerelease "$VERSION"
|
||||
fi
|
||||
printf '\nCHANGELOG preparation is complete; publishing intentionally stopped.\n'
|
||||
printf 'Replace TODO, review the diff, commit it, and merge it to main. Then run dws-release for %s again.\n' "$VERSION"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
notes_tmp="$(mktemp "${TMPDIR:-/tmp}/dws-release-entry.XXXXXX")"
|
||||
cleanup() { rm -f "$notes_tmp"; }
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
release_extract_changelog "$CHANGELOG" "$semver" "$notes_tmp"
|
||||
|
||||
if [ "$MAIN_SYNCED" -ne 1 ]; then
|
||||
require_remote
|
||||
sync_main_if_safe
|
||||
fi
|
||||
|
||||
printf '==> DWS release entry\n'
|
||||
printf ' mode: %s\n' "$MODE"
|
||||
printf ' channel: %s\n' "$CHANNEL"
|
||||
printf ' version: %s\n' "$VERSION"
|
||||
printf ' remote: %s\n' "$REMOTE"
|
||||
[ -z "$FROM_BETA" ] || printf ' beta: %s\n' "$FROM_BETA"
|
||||
|
||||
set -- "$CHANNEL" "$VERSION" --remote "$REMOTE"
|
||||
if [ -n "$FROM_BETA" ]; then
|
||||
set -- "$@" --from-beta "$FROM_BETA"
|
||||
fi
|
||||
if [ "$MODE" = "publish" ]; then
|
||||
set -- "$@" --publish
|
||||
fi
|
||||
cleanup
|
||||
trap - EXIT HUP INT TERM
|
||||
exec "$SCRIPT_DIR/release.sh" "$@"
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
PACKAGE_DIR="${1:-}"
|
||||
OUTPUT="${2:-}"
|
||||
|
||||
[ -n "$PACKAGE_DIR" ] && [ -n "$OUTPUT" ] || {
|
||||
printf 'usage: pack-npm-package.sh <package-dir> <output.tgz>\n' >&2
|
||||
exit 2
|
||||
}
|
||||
[ -f "$PACKAGE_DIR/package.json" ] || {
|
||||
printf 'npm package manifest not found: %s/package.json\n' "$PACKAGE_DIR" >&2
|
||||
exit 1
|
||||
}
|
||||
command -v npx >/dev/null 2>&1 || { printf 'npx is required\n' >&2; exit 1; }
|
||||
command -v node >/dev/null 2>&1 || { printf 'node is required\n' >&2; exit 1; }
|
||||
NPM_PACK_VERSION="10.9.2"
|
||||
|
||||
output_dir="$(CDPATH= cd -- "$(dirname -- "$OUTPUT")" && pwd)"
|
||||
output_name="$(basename -- "$OUTPUT")"
|
||||
rm -f "$output_dir/$output_name"
|
||||
|
||||
pack_json="$(
|
||||
npx --yes --package "npm@$NPM_PACK_VERSION" -- \
|
||||
npm pack "$PACKAGE_DIR" --pack-destination "$output_dir" --json --ignore-scripts
|
||||
)"
|
||||
pack_metadata="$(printf '%s' "$pack_json" | node -e '
|
||||
let input = "";
|
||||
process.stdin.on("data", (chunk) => { input += chunk; });
|
||||
process.stdin.on("end", () => {
|
||||
const entries = JSON.parse(input);
|
||||
if (!Array.isArray(entries) || entries.length !== 1) {
|
||||
throw new Error("npm pack did not return exactly one package");
|
||||
}
|
||||
const entry = entries[0];
|
||||
if (!entry.filename || !entry.integrity) {
|
||||
throw new Error("npm pack output is missing filename or integrity");
|
||||
}
|
||||
process.stdout.write(`${entry.filename}\n${entry.integrity}\n`);
|
||||
});
|
||||
')"
|
||||
packed_name="$(printf '%s\n' "$pack_metadata" | sed -n '1p')"
|
||||
integrity="$(printf '%s\n' "$pack_metadata" | sed -n '2p')"
|
||||
[ -f "$output_dir/$packed_name" ] || {
|
||||
printf 'npm pack did not create expected tarball: %s\n' "$packed_name" >&2
|
||||
exit 1
|
||||
}
|
||||
if [ "$packed_name" != "$output_name" ]; then
|
||||
mv "$output_dir/$packed_name" "$output_dir/$output_name"
|
||||
fi
|
||||
printf '%s\n' "$integrity"
|
||||
@@ -90,27 +90,36 @@ resolve_release_base_url() {
|
||||
|
||||
stage_npm_package() {
|
||||
version="$1"
|
||||
DWS_PACKAGE_SOURCE_ROOT="$ROOT" \
|
||||
DWS_PACKAGE_DIST_DIR="$DIST_DIR" \
|
||||
"$ROOT/scripts/release/stage-npm-package.sh" "$version"
|
||||
pkg_root="$DIST_DIR/npm/dingtalk-workspace-cli"
|
||||
|
||||
rm -rf "$pkg_root"
|
||||
mkdir -p "$pkg_root/assets" "$pkg_root/bin"
|
||||
|
||||
cp "$ROOT/build/npm/install.js" "$pkg_root/install.js"
|
||||
cp "$ROOT/build/npm/bin/dws.js" "$pkg_root/bin/dws.js"
|
||||
cp "$ROOT/build/npm/README.md" "$pkg_root/README.md"
|
||||
sed "s|__VERSION__|$version|g" "$ROOT/build/npm/package.json.tmpl" > "$pkg_root/package.json"
|
||||
|
||||
for artifact in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/dws-skills.zip; do
|
||||
if [ -f "$artifact" ]; then
|
||||
cp "$artifact" "$pkg_root/assets/"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# ---------- Homebrew formula staging ----------
|
||||
|
||||
render_homebrew_formula() {
|
||||
class_name="$1"
|
||||
formula_version="$2"
|
||||
archive_url="$3"
|
||||
skills_url="$4"
|
||||
archive_sha="$5"
|
||||
skills_sha="$6"
|
||||
keg_only_line="$7"
|
||||
output_path="$8"
|
||||
archive_url="$2"
|
||||
skills_url="$3"
|
||||
archive_sha="$4"
|
||||
skills_sha="$5"
|
||||
keg_only_line="$6"
|
||||
output_path="$7"
|
||||
|
||||
sed \
|
||||
-e "s|__CLASS_NAME__|$class_name|g" \
|
||||
-e "s|__VERSION__|$formula_version|g" \
|
||||
-e "s|__SKILL_HOME_OVERRIDE__||g" \
|
||||
-e "s|__ARCHIVE_URL__|$archive_url|g" \
|
||||
-e "s|__ARCHIVE_SHA256__|$archive_sha|g" \
|
||||
-e "s|__SKILLS_URL__|$skills_url|g" \
|
||||
@@ -140,7 +149,6 @@ stage_homebrew_formula() {
|
||||
|
||||
render_homebrew_formula \
|
||||
"DingtalkWorkspaceCliLocal" \
|
||||
"$version" \
|
||||
"file://$archive_path" \
|
||||
"file://$DIST_DIR/dws-skills.zip" \
|
||||
"$archive_sha" \
|
||||
@@ -150,7 +158,6 @@ stage_homebrew_formula() {
|
||||
|
||||
render_homebrew_formula \
|
||||
"DingtalkWorkspaceCli" \
|
||||
"$version" \
|
||||
"$release_url_base/$archive_name" \
|
||||
"$release_url_base/$skills_name" \
|
||||
"$archive_sha" \
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/release-lib.sh"
|
||||
ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
CHANNEL="${1:-}"
|
||||
VERSION="${2:-}"
|
||||
FROM_BETA=""
|
||||
FROM_REF=""
|
||||
CHANGELOG="$ROOT/CHANGELOG.md"
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: prepare-changelog.sh <prerelease|stable> <version> [options]
|
||||
|
||||
Options:
|
||||
--from-beta <tag> Required for stable release notes
|
||||
--from-ref <ref> Commit-list baseline for prerelease notes
|
||||
--changelog <path> Override CHANGELOG.md path
|
||||
EOF
|
||||
}
|
||||
|
||||
[ -n "$CHANNEL" ] && [ -n "$VERSION" ] || { usage; exit 2; }
|
||||
shift 2
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--from-beta) [ "$#" -ge 2 ] || { usage; exit 2; }; FROM_BETA="$2"; shift 2 ;;
|
||||
--from-ref) [ "$#" -ge 2 ] || { usage; exit 2; }; FROM_REF="$2"; shift 2 ;;
|
||||
--changelog) [ "$#" -ge 2 ] || { usage; exit 2; }; CHANGELOG="$2"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) printf 'unknown argument: %s\n' "$1" >&2; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
release_validate_version_channel "$CHANNEL" "$VERSION"
|
||||
cd "$ROOT"
|
||||
[ -f "$CHANGELOG" ] || { printf 'CHANGELOG not found: %s\n' "$CHANGELOG" >&2; exit 1; }
|
||||
[ -z "$(git status --porcelain --untracked-files=all)" ] || {
|
||||
printf 'prepare changelog requires a clean worktree\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
semver="$(release_semver "$VERSION")"
|
||||
if grep -Fq "## [$semver] - " "$CHANGELOG"; then
|
||||
printf 'CHANGELOG section already exists for %s\n' "$semver" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$CHANNEL" = "stable" ]; then
|
||||
[ -n "$FROM_BETA" ] || { printf 'stable changelog requires --from-beta\n' >&2; exit 1; }
|
||||
release_is_prerelease_version "$FROM_BETA" || { printf 'invalid beta baseline: %s\n' "$FROM_BETA" >&2; exit 1; }
|
||||
[ "$(release_core_tag "$FROM_BETA")" = "$VERSION" ] || {
|
||||
printf 'stable version %s does not match beta baseline %s\n' "$VERSION" "$FROM_BETA" >&2
|
||||
exit 1
|
||||
}
|
||||
FROM_REF="$FROM_BETA"
|
||||
fi
|
||||
|
||||
release_date="${DWS_RELEASE_DATE:-$(TZ=Asia/Shanghai date +%F)}"
|
||||
section="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-section.XXXXXX")"
|
||||
output="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-output.XXXXXX")"
|
||||
cleanup() { rm -f "$section" "$output"; }
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
{
|
||||
printf '## [%s] - %s\n\n' "$semver" "$release_date"
|
||||
if [ "$CHANNEL" = "stable" ]; then
|
||||
printf 'This release promotes the sealed `%s` contents to stable.\n\n' "$FROM_BETA"
|
||||
else
|
||||
printf '<!-- Summarize what this beta validates. Remove every TODO before publishing. -->\n\n'
|
||||
fi
|
||||
printf '### Changed\n\n'
|
||||
if [ "$CHANNEL" = "stable" ]; then
|
||||
printf -- '- TODO: summarize the complete user-visible release promoted from `%s`.\n' "$FROM_BETA"
|
||||
else
|
||||
printf -- '- TODO: summarize this beta candidate and its validation scope.\n'
|
||||
fi
|
||||
} > "$section"
|
||||
|
||||
inserted=0
|
||||
in_unreleased=0
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
'## [Unreleased]')
|
||||
in_unreleased=1
|
||||
;;
|
||||
'## '*)
|
||||
if [ "$in_unreleased" -eq 1 ]; then
|
||||
cat "$section" >> "$output"
|
||||
printf '\n' >> "$output"
|
||||
inserted=1
|
||||
in_unreleased=0
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
printf '%s\n' "$line" >> "$output"
|
||||
done < "$CHANGELOG"
|
||||
|
||||
if [ "$in_unreleased" -eq 1 ]; then
|
||||
printf '\n' >> "$output"
|
||||
cat "$section" >> "$output"
|
||||
inserted=1
|
||||
fi
|
||||
[ "$inserted" -eq 1 ] || { printf 'CHANGELOG is missing ## [Unreleased]\n' >&2; exit 1; }
|
||||
cp "$output" "$CHANGELOG"
|
||||
|
||||
printf 'Prepared CHANGELOG template for %s. Replace TODO, review, commit, and merge it before release.\n' "$VERSION"
|
||||
if [ -n "$FROM_REF" ] && git rev-parse --verify --quiet "$FROM_REF^{commit}" >/dev/null; then
|
||||
printf '\nCommits since %s:\n' "$FROM_REF"
|
||||
git log --oneline "$FROM_REF..HEAD"
|
||||
fi
|
||||
@@ -1,7 +1,144 @@
|
||||
#!/bin/sh
|
||||
#!/usr/bin/env bash
|
||||
# Publish locally-built release artifacts to the matching Gitee release.
|
||||
#
|
||||
# Intended to run inside Gitee Go after building artifacts in China. This avoids
|
||||
# the unreliable GitHub Actions -> Gitee cross-border upload path.
|
||||
|
||||
set -eu
|
||||
|
||||
printf '%s\n' \
|
||||
'Direct Gitee artifact publication is disabled.' \
|
||||
'Use the guarded Release workflow so Gitee receives the exact immutable GitHub assets.' >&2
|
||||
exit 2
|
||||
DIST_DIR="${DIST_DIR:-dist}"
|
||||
GITEE_API="${GITEE_API:-https://gitee.com/api/v5}"
|
||||
GITEE_REPO="${GITEE_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}"
|
||||
GITEE_TOKEN="${GITEE_TOKEN:-${GITEE_ACCESS_TOKEN:-}}"
|
||||
GITEE_CURL_CONNECT_TIMEOUT="${GITEE_CURL_CONNECT_TIMEOUT:-15}"
|
||||
GITEE_CURL_MAX_TIME="${GITEE_CURL_MAX_TIME:-120}"
|
||||
GITEE_UPLOAD_MAX_TIME="${GITEE_UPLOAD_MAX_TIME:-300}"
|
||||
GITEE_UPLOAD_RETRIES="${GITEE_UPLOAD_RETRIES:-3}"
|
||||
GITEE_UPLOAD_RETRY_DELAY="${GITEE_UPLOAD_RETRY_DELAY:-10}"
|
||||
|
||||
err() {
|
||||
printf 'error: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ -n "$GITEE_TOKEN" ] || err "GITEE_TOKEN is required"
|
||||
[ -d "$DIST_DIR" ] || err "dist dir not found: $DIST_DIR"
|
||||
|
||||
VERSION="${VERSION:-$(git describe --tags --exact-match 2>/dev/null || true)}"
|
||||
[ -n "$VERSION" ] || err "VERSION is required or HEAD must be an exact tag"
|
||||
case "$VERSION" in
|
||||
v*) ;;
|
||||
*) VERSION="v$VERSION" ;;
|
||||
esac
|
||||
|
||||
OWNER="${GITEE_REPO%%/*}"
|
||||
NAME="${GITEE_REPO##*/}"
|
||||
base="${GITEE_API}/repos/${OWNER}/${NAME}"
|
||||
|
||||
sha256_of() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then sha256sum ${1:+"$1"} | awk '{print $1}'
|
||||
else shasum -a 256 ${1:+"$1"} | awk '{print $1}'
|
||||
fi
|
||||
}
|
||||
|
||||
api_get() {
|
||||
curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" "$@"
|
||||
}
|
||||
|
||||
echo "📦 Publishing local artifacts for ${VERSION} to Gitee ${GITEE_REPO}"
|
||||
|
||||
target_commit="$(git rev-parse "${VERSION}^{commit}" 2>/dev/null || git rev-parse HEAD)"
|
||||
|
||||
rel_json="$(api_get "${base}/releases/tags/${VERSION}?access_token=${GITEE_TOKEN}" 2>/dev/null || true)"
|
||||
release_id="$(printf '%s' "$rel_json" | grep -o '"id":[ ]*[0-9]*' | head -1 | grep -o '[0-9]*' || true)"
|
||||
|
||||
if [ -z "$release_id" ]; then
|
||||
echo " No Gitee release for ${VERSION} yet — creating it."
|
||||
rel_json="$(curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" \
|
||||
-X POST "${base}/releases" \
|
||||
-F "access_token=${GITEE_TOKEN}" \
|
||||
-F "tag_name=${VERSION}" \
|
||||
-F "name=${VERSION}" \
|
||||
-F "body=Gitee-local build of ${VERSION} for China users." \
|
||||
-F "target_commitish=${target_commit}" 2>/dev/null || true)"
|
||||
release_id="$(printf '%s' "$rel_json" | grep -o '"id":[ ]*[0-9]*' | head -1 | grep -o '[0-9]*' || true)"
|
||||
fi
|
||||
[ -n "$release_id" ] || err "could not get/create Gitee release for ${VERSION}. Response: ${rel_json}"
|
||||
echo " Gitee release id = ${release_id}"
|
||||
|
||||
assets_map="$(api_get "${base}/releases/${release_id}/attach_files?access_token=${GITEE_TOKEN}" 2>/dev/null \
|
||||
| python3 -c 'import json,sys
|
||||
try:
|
||||
data=json.load(sys.stdin)
|
||||
rows=data if isinstance(data,list) else data.get("attach_files",[])
|
||||
for a in rows:
|
||||
n=a.get("name",""); i=a.get("id",""); u=a.get("browser_download_url","")
|
||||
if n and i!="":
|
||||
print("%s\t%s\t%s" % (n, i, u))
|
||||
except Exception:
|
||||
pass' 2>/dev/null || true)"
|
||||
|
||||
gitee_attach() {
|
||||
file="$1"
|
||||
fn="$(basename "$file")"
|
||||
attempt=1
|
||||
while [ "$attempt" -le "$GITEE_UPLOAD_RETRIES" ]; do
|
||||
response="$(curl -fsS --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_UPLOAD_MAX_TIME" \
|
||||
--retry 2 --retry-delay 5 --retry-all-errors \
|
||||
-X POST "${base}/releases/${release_id}/attach_files" \
|
||||
-F "access_token=${GITEE_TOKEN}" -F "file=@${file}" 2>&1 || true)"
|
||||
if printf '%s' "$response" | grep -q '"browser_download_url"'; then
|
||||
return 0
|
||||
fi
|
||||
echo " ⚠ upload attempt ${attempt}/${GITEE_UPLOAD_RETRIES} failed for ${fn}: $(printf '%s' "$response" | head -c 240)" >&2
|
||||
attempt=$((attempt + 1))
|
||||
[ "$attempt" -le "$GITEE_UPLOAD_RETRIES" ] && sleep "$GITEE_UPLOAD_RETRY_DELAY"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
gitee_delete() {
|
||||
curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" \
|
||||
-X DELETE "${base}/releases/${release_id}/attach_files/${1}?access_token=${GITEE_TOKEN}" \
|
||||
>/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
uploaded=0
|
||||
replaced=0
|
||||
skipped=0
|
||||
failed=0
|
||||
for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt "$DIST_DIR"/dws-skills.zip; do
|
||||
[ -f "$f" ] || continue
|
||||
fn="$(basename "$f")"
|
||||
local_sha="$(sha256_of "$f")"
|
||||
ids="$(printf '%s\n' "$assets_map" | awk -F'\t' -v n="$fn" '$1==n {print $2}')"
|
||||
aurl="$(printf '%s\n' "$assets_map" | awk -F'\t' -v n="$fn" '$1==n {print $3; exit}')"
|
||||
count="$(printf '%s' "$ids" | grep -c . || true)"
|
||||
|
||||
if [ "$count" -eq 1 ]; then
|
||||
gitee_sha="$(api_get "$aurl" 2>/dev/null | sha256_of || true)"
|
||||
if [ "$gitee_sha" = "$local_sha" ]; then
|
||||
echo " ✓ ${fn} already correct on Gitee — skip"
|
||||
skipped=$((skipped + 1))
|
||||
continue
|
||||
fi
|
||||
echo " ↻ ${fn} differs on Gitee — replacing"
|
||||
elif [ "$count" -gt 1 ]; then
|
||||
echo " ↻ ${fn} has ${count} copies on Gitee — replacing"
|
||||
else
|
||||
echo " ⬆ ${fn} (new)"
|
||||
fi
|
||||
|
||||
printf '%s\n' "$ids" | while read -r aid; do
|
||||
[ -n "$aid" ] && gitee_delete "$aid"
|
||||
done
|
||||
if gitee_attach "$f"; then
|
||||
if [ "$count" -eq 0 ]; then uploaded=$((uploaded + 1)); else replaced=$((replaced + 1)); fi
|
||||
else
|
||||
echo " ❌ upload failed for ${fn}" >&2
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
[ "$failed" -eq 0 ] || err "Gitee publish finished with ${failed} failed upload(s)"
|
||||
echo "✅ Gitee release ${VERSION}: uploaded ${uploaded}, replaced ${replaced}, skipped ${skipped}"
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/release-lib.sh"
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
|
||||
CHANNEL=""
|
||||
VERSION=""
|
||||
CONTEXT="local"
|
||||
REMOTE="origin"
|
||||
BRANCH="main"
|
||||
FROM_BETA=""
|
||||
FROM_BETA_COMMIT=""
|
||||
CHANGELOG=""
|
||||
NOTES_OUTPUT=""
|
||||
METADATA_OUTPUT=""
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: release-contract.sh --channel <prerelease|stable> --version <tag> [options]
|
||||
|
||||
Options:
|
||||
--context <local|ci> Local pre-push checks or tag-workflow checks
|
||||
--remote <name> Release remote (default: origin)
|
||||
--branch <name> Sealed release branch (default: main)
|
||||
--from-beta <tag> Required stable promotion baseline
|
||||
--repo-root <path> Override repository root (primarily for tests)
|
||||
--changelog <path> Override CHANGELOG.md path
|
||||
--notes-output <path> Write the exact CHANGELOG section body
|
||||
--metadata-output <path> Append channel/version/baseline key-value output
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--channel) [ "$#" -ge 2 ] || { usage; exit 2; }; CHANNEL="$2"; shift 2 ;;
|
||||
--version) [ "$#" -ge 2 ] || { usage; exit 2; }; VERSION="$2"; shift 2 ;;
|
||||
--context) [ "$#" -ge 2 ] || { usage; exit 2; }; CONTEXT="$2"; shift 2 ;;
|
||||
--remote) [ "$#" -ge 2 ] || { usage; exit 2; }; REMOTE="$2"; shift 2 ;;
|
||||
--branch) [ "$#" -ge 2 ] || { usage; exit 2; }; BRANCH="$2"; shift 2 ;;
|
||||
--from-beta) [ "$#" -ge 2 ] || { usage; exit 2; }; FROM_BETA="$2"; shift 2 ;;
|
||||
--repo-root) [ "$#" -ge 2 ] || { usage; exit 2; }; ROOT="$2"; shift 2 ;;
|
||||
--changelog) [ "$#" -ge 2 ] || { usage; exit 2; }; CHANGELOG="$2"; shift 2 ;;
|
||||
--notes-output) [ "$#" -ge 2 ] || { usage; exit 2; }; NOTES_OUTPUT="$2"; shift 2 ;;
|
||||
--metadata-output) [ "$#" -ge 2 ] || { usage; exit 2; }; METADATA_OUTPUT="$2"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) printf 'unknown argument: %s\n' "$1" >&2; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$CHANNEL" ] && [ -n "$VERSION" ] || { usage; exit 2; }
|
||||
case "$CONTEXT" in local|ci) ;; *) printf 'invalid context: %s\n' "$CONTEXT" >&2; exit 2 ;; esac
|
||||
release_validate_version_channel "$CHANNEL" "$VERSION"
|
||||
|
||||
cd "$ROOT"
|
||||
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || {
|
||||
printf 'not a Git worktree: %s\n' "$ROOT" >&2
|
||||
exit 1
|
||||
}
|
||||
[ -n "$CHANGELOG" ] || CHANGELOG="$ROOT/CHANGELOG.md"
|
||||
[ -f "$CHANGELOG" ] || { printf 'CHANGELOG not found: %s\n' "$CHANGELOG" >&2; exit 1; }
|
||||
|
||||
head_commit="$(git rev-parse HEAD)"
|
||||
remote_main="refs/remotes/$REMOTE/$BRANCH"
|
||||
git remote get-url "$REMOTE" >/dev/null 2>&1 || {
|
||||
printf 'release remote does not exist: %s\n' "$REMOTE" >&2
|
||||
exit 1
|
||||
}
|
||||
git rev-parse --verify --quiet "$remote_main^{commit}" >/dev/null || {
|
||||
printf 'release branch is not available locally: %s/%s\n' "$REMOTE" "$BRANCH" >&2
|
||||
exit 1
|
||||
}
|
||||
remote_main_commit="$(git rev-parse "$remote_main^{commit}")"
|
||||
|
||||
if [ "$CONTEXT" = "local" ]; then
|
||||
[ -z "$(git status --porcelain --untracked-files=all)" ] || {
|
||||
printf 'release worktree must be clean (staged, unstaged, and untracked files are blocked)\n' >&2
|
||||
exit 1
|
||||
}
|
||||
current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
|
||||
[ "$current_branch" = "$BRANCH" ] || {
|
||||
printf 'local release must run from branch %s (current: %s)\n' "$BRANCH" "${current_branch:-detached HEAD}" >&2
|
||||
exit 1
|
||||
}
|
||||
[ "$head_commit" = "$remote_main_commit" ] || {
|
||||
printf 'HEAD must exactly match %s/%s before release\n' "$REMOTE" "$BRANCH" >&2
|
||||
exit 1
|
||||
}
|
||||
if git rev-parse --verify --quiet "refs/tags/$VERSION" >/dev/null; then
|
||||
printf 'release tag already exists locally: %s\n' "$VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
remote_tag="$(git ls-remote --tags "$REMOTE" "refs/tags/$VERSION" "refs/tags/$VERSION^{}")" || {
|
||||
printf 'could not query release tags from remote: %s\n' "$REMOTE" >&2
|
||||
exit 1
|
||||
}
|
||||
[ -z "$remote_tag" ] || {
|
||||
printf 'release tag already exists on %s: %s\n' "$REMOTE" "$VERSION" >&2
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
git rev-parse --verify --quiet "refs/tags/$VERSION^{commit}" >/dev/null || {
|
||||
printf 'CI release tag is not available: %s\n' "$VERSION" >&2
|
||||
exit 1
|
||||
}
|
||||
[ "$(git rev-parse "refs/tags/$VERSION^{commit}")" = "$head_commit" ] || {
|
||||
printf 'CI checkout HEAD does not match tag %s\n' "$VERSION" >&2
|
||||
exit 1
|
||||
}
|
||||
[ "$(git cat-file -t "refs/tags/$VERSION")" = "tag" ] || {
|
||||
printf 'release tag must be annotated: %s\n' "$VERSION" >&2
|
||||
exit 1
|
||||
}
|
||||
git merge-base --is-ancestor HEAD "$remote_main" || {
|
||||
printf 'release tag commit must be contained in %s/%s\n' "$REMOTE" "$BRANCH" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
previous_stable=""
|
||||
previous_stable_commit=""
|
||||
for tag in $(git tag --list 'v*' --sort=-version:refname); do
|
||||
[ "$tag" = "$VERSION" ] && continue
|
||||
if release_is_stable_version "$tag"; then
|
||||
previous_stable="$tag"
|
||||
previous_stable_commit="$(git rev-parse "$tag^{commit}")"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -n "$previous_stable" ] && ! release_core_is_greater "$VERSION" "$previous_stable"; then
|
||||
printf 'release version %s must be greater than latest stable %s\n' "$VERSION" "$previous_stable" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
core_tag="$(release_core_tag "$VERSION")"
|
||||
if [ "$CHANNEL" = "prerelease" ]; then
|
||||
[ -z "$FROM_BETA" ] || { printf -- '--from-beta is only valid for stable releases\n' >&2; exit 1; }
|
||||
if git rev-parse --verify --quiet "refs/tags/$core_tag" >/dev/null; then
|
||||
printf 'cannot publish prerelease after stable tag exists: %s\n' "$core_tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
previous_beta=""
|
||||
for tag in $(git tag --list "$core_tag-beta.*" --sort=-version:refname); do
|
||||
[ "$tag" = "$VERSION" ] && continue
|
||||
if release_is_prerelease_version "$tag"; then
|
||||
previous_beta="$tag"
|
||||
break
|
||||
fi
|
||||
done
|
||||
beta_number="$(release_beta_number "$VERSION")"
|
||||
if [ -z "$previous_beta" ]; then
|
||||
[ "$beta_number" -eq 1 ] || {
|
||||
printf 'the first prerelease for %s must be beta.1\n' "$core_tag" >&2
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
previous_beta_number="$(release_beta_number "$previous_beta")"
|
||||
expected_beta_number=$((previous_beta_number + 1))
|
||||
[ "$beta_number" -eq "$expected_beta_number" ] || {
|
||||
printf 'prerelease after %s must be %s-beta.%s\n' "$previous_beta" "$core_tag" "$expected_beta_number" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
else
|
||||
if [ -z "$FROM_BETA" ] && [ "$CONTEXT" = "ci" ]; then
|
||||
FROM_BETA="$(git for-each-ref "refs/tags/$VERSION" --format='%(contents)' | awk '/^From-Beta:[[:space:]]*/ { sub(/^From-Beta:[[:space:]]*/, ""); print }')"
|
||||
fi
|
||||
[ -n "$FROM_BETA" ] || {
|
||||
printf 'stable release requires an explicit --from-beta tag\n' >&2
|
||||
exit 1
|
||||
}
|
||||
release_is_prerelease_version "$FROM_BETA" || {
|
||||
printf 'invalid stable beta baseline: %s\n' "$FROM_BETA" >&2
|
||||
exit 1
|
||||
}
|
||||
[ "$(release_core_tag "$FROM_BETA")" = "$VERSION" ] || {
|
||||
printf 'stable version %s does not match beta baseline %s\n' "$VERSION" "$FROM_BETA" >&2
|
||||
exit 1
|
||||
}
|
||||
git rev-parse --verify --quiet "refs/tags/$FROM_BETA^{commit}" >/dev/null || {
|
||||
printf 'stable beta baseline is not available locally: %s\n' "$FROM_BETA" >&2
|
||||
exit 1
|
||||
}
|
||||
FROM_BETA_COMMIT="$(git rev-parse "refs/tags/$FROM_BETA^{commit}")"
|
||||
git merge-base --is-ancestor "$FROM_BETA^{commit}" HEAD || {
|
||||
printf 'stable beta baseline is not an ancestor of HEAD: %s\n' "$FROM_BETA" >&2
|
||||
exit 1
|
||||
}
|
||||
if ! git diff --quiet "$FROM_BETA^{commit}" HEAD -- . ':(exclude)CHANGELOG.md'; then
|
||||
printf 'stable source drifted from %s; only CHANGELOG.md may differ\n' "$FROM_BETA" >&2
|
||||
git diff --name-only "$FROM_BETA^{commit}" HEAD -- . ':(exclude)CHANGELOG.md' >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
semver="$(release_semver "$VERSION")"
|
||||
if [ -n "$NOTES_OUTPUT" ]; then
|
||||
release_extract_changelog "$CHANGELOG" "$semver" "$NOTES_OUTPUT"
|
||||
else
|
||||
notes_tmp="$(mktemp "${TMPDIR:-/tmp}/dws-release-contract.XXXXXX")"
|
||||
trap 'rm -f "$notes_tmp"' EXIT HUP INT TERM
|
||||
release_extract_changelog "$CHANGELOG" "$semver" "$notes_tmp"
|
||||
fi
|
||||
|
||||
if [ -n "$METADATA_OUTPUT" ]; then
|
||||
mkdir -p "$(dirname "$METADATA_OUTPUT")"
|
||||
{
|
||||
printf 'channel=%s\n' "$CHANNEL"
|
||||
printf 'version=%s\n' "$VERSION"
|
||||
printf 'semver=%s\n' "$semver"
|
||||
printf 'previous_stable=%s\n' "$previous_stable"
|
||||
printf 'previous_stable_commit=%s\n' "$previous_stable_commit"
|
||||
printf 'from_beta=%s\n' "$FROM_BETA"
|
||||
printf 'from_beta_commit=%s\n' "$FROM_BETA_COMMIT"
|
||||
} >> "$METADATA_OUTPUT"
|
||||
fi
|
||||
|
||||
printf 'Release contract passed: channel=%s version=%s commit=%s' "$CHANNEL" "$VERSION" "$head_commit"
|
||||
[ -z "$FROM_BETA" ] || printf ' from-beta=%s' "$FROM_BETA"
|
||||
printf '\n'
|
||||
@@ -1,167 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Shared release version and CHANGELOG helpers. This file is sourced by the
|
||||
# local release command, the CI contract, and mirror publishing so every stage
|
||||
# agrees on what "prerelease" and "stable" mean.
|
||||
|
||||
release_stable_pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
|
||||
release_prerelease_pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-beta\.[1-9][0-9]*$'
|
||||
|
||||
release_is_stable_version() {
|
||||
printf '%s\n' "$1" | grep -Eq "$release_stable_pattern"
|
||||
}
|
||||
|
||||
release_is_prerelease_version() {
|
||||
printf '%s\n' "$1" | grep -Eq "$release_prerelease_pattern"
|
||||
}
|
||||
|
||||
release_channel_for_version() {
|
||||
version="$1"
|
||||
if release_is_stable_version "$version"; then
|
||||
printf '%s\n' stable
|
||||
return 0
|
||||
fi
|
||||
if release_is_prerelease_version "$version"; then
|
||||
printf '%s\n' prerelease
|
||||
return 0
|
||||
fi
|
||||
printf 'invalid release version: %s (expected vX.Y.Z or vX.Y.Z-beta.N)\n' "$version" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
release_validate_version_channel() {
|
||||
expected="$1"
|
||||
version="$2"
|
||||
case "$expected" in
|
||||
stable|prerelease) ;;
|
||||
*)
|
||||
printf 'invalid release channel: %s (expected prerelease or stable)\n' "$expected" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
actual="$(release_channel_for_version "$version")" || return 1
|
||||
if [ "$actual" != "$expected" ]; then
|
||||
printf 'release channel/version mismatch: channel=%s version=%s\n' "$expected" "$version" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
release_semver() {
|
||||
printf '%s\n' "${1#v}"
|
||||
}
|
||||
|
||||
release_core_tag() {
|
||||
version="$1"
|
||||
printf '%s\n' "${version%%-beta.*}"
|
||||
}
|
||||
|
||||
release_beta_number() {
|
||||
printf '%s\n' "${1##*.}"
|
||||
}
|
||||
|
||||
release_core_is_greater() {
|
||||
candidate="$(release_core_tag "$1")"
|
||||
baseline="$(release_core_tag "$2")"
|
||||
candidate="${candidate#v}"
|
||||
baseline="${baseline#v}"
|
||||
awk -v candidate="$candidate" -v baseline="$baseline" 'BEGIN {
|
||||
split(candidate, c, ".")
|
||||
split(baseline, b, ".")
|
||||
for (i = 1; i <= 3; i++) {
|
||||
if ((c[i] + 0) > (b[i] + 0)) exit 0
|
||||
if ((c[i] + 0) < (b[i] + 0)) exit 1
|
||||
}
|
||||
exit 1
|
||||
}'
|
||||
}
|
||||
|
||||
release_version_is_greater() {
|
||||
_rvig_candidate="$1"
|
||||
_rvig_baseline="$2"
|
||||
_rvig_candidate_channel="$(release_channel_for_version "$_rvig_candidate")" || return 1
|
||||
_rvig_baseline_channel="$(release_channel_for_version "$_rvig_baseline")" || return 1
|
||||
|
||||
if release_core_is_greater "$_rvig_candidate" "$_rvig_baseline"; then
|
||||
return 0
|
||||
fi
|
||||
if release_core_is_greater "$_rvig_baseline" "$_rvig_candidate"; then
|
||||
return 1
|
||||
fi
|
||||
if [ "$_rvig_candidate_channel" = "stable" ] && [ "$_rvig_baseline_channel" = "prerelease" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$_rvig_candidate_channel" = "prerelease" ] && [ "$_rvig_baseline_channel" = "prerelease" ]; then
|
||||
[ "$(release_beta_number "$_rvig_candidate")" -gt "$(release_beta_number "$_rvig_baseline")" ]
|
||||
return
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Extract one exact CHANGELOG section (without its H2 heading) and validate that
|
||||
# it is dated, unique, non-placeholder content with at least one bullet.
|
||||
release_extract_changelog() {
|
||||
changelog="$1"
|
||||
semver="$2"
|
||||
output="$3"
|
||||
tmp="$(mktemp "${TMPDIR:-/tmp}/dws-release-notes.XXXXXX")"
|
||||
|
||||
set +e
|
||||
awk -v wanted="$semver" '
|
||||
BEGIN {
|
||||
prefix = "## [" wanted "] - "
|
||||
found = 0
|
||||
active = 0
|
||||
invalid_date = 0
|
||||
meaningful = 0
|
||||
bullet = 0
|
||||
placeholder = 0
|
||||
}
|
||||
/^## / {
|
||||
active = 0
|
||||
if (index($0, prefix) == 1) {
|
||||
found++
|
||||
active = 1
|
||||
date = substr($0, length(prefix) + 1)
|
||||
if (date !~ /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/) invalid_date = 1
|
||||
}
|
||||
next
|
||||
}
|
||||
active {
|
||||
print
|
||||
if ($0 ~ /[^[:space:]]/) meaningful = 1
|
||||
if ($0 ~ /^- /) bullet = 1
|
||||
lowered = tolower($0)
|
||||
if (lowered ~ /todo|tbd/) placeholder = 1
|
||||
}
|
||||
END {
|
||||
if (found != 1) exit 41
|
||||
if (invalid_date) exit 42
|
||||
if (!meaningful || !bullet) exit 43
|
||||
if (placeholder) exit 44
|
||||
}
|
||||
' "$changelog" > "$tmp"
|
||||
status=$?
|
||||
set -e
|
||||
|
||||
case "$status" in
|
||||
0) ;;
|
||||
41) printf 'CHANGELOG must contain exactly one section: ## [%s] - YYYY-MM-DD\n' "$semver" >&2 ;;
|
||||
42) printf 'CHANGELOG section for %s has an invalid date\n' "$semver" >&2 ;;
|
||||
43) printf 'CHANGELOG section for %s must contain release notes and at least one bullet\n' "$semver" >&2 ;;
|
||||
44) printf 'CHANGELOG section for %s still contains TODO/TBD placeholders\n' "$semver" >&2 ;;
|
||||
*) printf 'failed to parse CHANGELOG section for %s\n' "$semver" >&2 ;;
|
||||
esac
|
||||
if [ "$status" -ne 0 ]; then
|
||||
rm -f "$tmp"
|
||||
return "$status"
|
||||
fi
|
||||
|
||||
if [ "$output" = "-" ]; then
|
||||
cat "$tmp"
|
||||
else
|
||||
mkdir -p "$(dirname "$output")"
|
||||
mv "$tmp" "$output"
|
||||
tmp=""
|
||||
fi
|
||||
[ -z "$tmp" ] || rm -f "$tmp"
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/release-lib.sh"
|
||||
ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
CHANNEL="${1:-}"
|
||||
VERSION="${2:-}"
|
||||
REMOTE=""
|
||||
BRANCH="main"
|
||||
FROM_BETA=""
|
||||
PUBLISH=0
|
||||
YES=0
|
||||
OFFICIAL_TAGS_URL="${DWS_RELEASE_OFFICIAL_TAGS_URL:-https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git}"
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: release.sh <prerelease|stable> <version> [options]
|
||||
|
||||
Runs the full test, command-compatibility, package, and install preflight.
|
||||
The default is validation only; add --publish to create and push the tag.
|
||||
|
||||
Options:
|
||||
--remote <name> Required tag destination
|
||||
--from-beta <tag> Required for stable releases
|
||||
--publish Create and push the annotated release tag
|
||||
--yes Skip the interactive version confirmation
|
||||
EOF
|
||||
}
|
||||
|
||||
[ -n "$CHANNEL" ] && [ -n "$VERSION" ] || { usage; exit 2; }
|
||||
shift 2
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--remote) [ "$#" -ge 2 ] || { usage; exit 2; }; REMOTE="$2"; shift 2 ;;
|
||||
--from-beta) [ "$#" -ge 2 ] || { usage; exit 2; }; FROM_BETA="$2"; shift 2 ;;
|
||||
--publish) PUBLISH=1; shift ;;
|
||||
--yes) YES=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) printf 'unknown argument: %s\n' "$1" >&2; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
release_validate_version_channel "$CHANNEL" "$VERSION"
|
||||
[ -n "$REMOTE" ] || { printf '%s\n' '--remote is required' >&2; exit 2; }
|
||||
cd "$ROOT"
|
||||
fetch_url="$(git remote get-url "$REMOTE" 2>/dev/null)" || { printf 'unknown release remote: %s\n' "$REMOTE" >&2; exit 1; }
|
||||
push_urls="$(git remote get-url --push --all "$REMOTE" 2>/dev/null)" || {
|
||||
printf 'could not resolve push URL for release remote: %s\n' "$REMOTE" >&2
|
||||
exit 1
|
||||
}
|
||||
[ "$(printf '%s\n' "$push_urls" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || {
|
||||
printf 'release remote must have exactly one push URL: %s\n' "$REMOTE" >&2
|
||||
exit 1
|
||||
}
|
||||
push_url="$(printf '%s\n' "$push_urls" | sed -n '1p')"
|
||||
|
||||
repository_identity() {
|
||||
identity_url="$1"
|
||||
case "$identity_url" in
|
||||
https://github.com/*) identity_path="${identity_url#https://github.com/}" ;;
|
||||
http://github.com/*) identity_path="${identity_url#http://github.com/}" ;;
|
||||
git://github.com/*) identity_path="${identity_url#git://github.com/}" ;;
|
||||
git@github.com:*) identity_path="${identity_url#git@github.com:}" ;;
|
||||
ssh://git@github.com/*) identity_path="${identity_url#ssh://git@github.com/}" ;;
|
||||
*) printf '%s\n' "$identity_url"; return 0 ;;
|
||||
esac
|
||||
identity_path="${identity_path%/}"
|
||||
identity_path="${identity_path%.git}"
|
||||
printf 'github.com/%s\n' "$identity_path"
|
||||
}
|
||||
|
||||
fetch_identity="$(repository_identity "$fetch_url")"
|
||||
push_identity="$(repository_identity "$push_url")"
|
||||
[ "$fetch_identity" = "$push_identity" ] || {
|
||||
printf 'release remote fetch and push URLs target different repositories:\n fetch: %s\n push: %s\n' "$fetch_url" "$push_url" >&2
|
||||
exit 1
|
||||
}
|
||||
printf 'Release target: %s\n fetch: %s\n push: %s\n' "$REMOTE" "$fetch_url" "$push_url"
|
||||
|
||||
github_repository_from_url() {
|
||||
github_identity="$(repository_identity "$1")"
|
||||
case "$github_identity" in
|
||||
github.com/*) printf '%s\n' "${github_identity#github.com/}" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
require_delivered_previous_stable() {
|
||||
github_repository="$(github_repository_from_url "$push_url" 2>/dev/null || true)"
|
||||
if [ -z "$github_repository" ]; then
|
||||
[ "${DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE:-0}" = "1" ] || {
|
||||
printf 'release validation requires a github.com remote to prove the stable baseline was delivered\n' >&2
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
fi
|
||||
[ -n "$previous_stable" ] || {
|
||||
printf 'a delivered previous stable baseline is required\n' >&2
|
||||
return 1
|
||||
}
|
||||
stable_commit="$(git rev-parse "$previous_stable^{commit}")"
|
||||
DWS_RELEASE_OFFICIAL_REPOSITORY="${DWS_RELEASE_OFFICIAL_REPOSITORY:-DingTalk-Real-AI/dingtalk-workspace-cli}" \
|
||||
"$SCRIPT_DIR/verify-delivered-stable.sh" "$previous_stable" "$stable_commit"
|
||||
}
|
||||
|
||||
require_github_publication_authority() {
|
||||
github_repository="$(github_repository_from_url "$push_url" 2>/dev/null || true)"
|
||||
if [ -z "$github_repository" ]; then
|
||||
[ "${DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE:-0}" = "1" ] || {
|
||||
printf 'publishing requires a github.com remote so CI and delivery authority can be verified\n' >&2
|
||||
return 1
|
||||
}
|
||||
printf 'warning: GitHub publication checks explicitly disabled for non-GitHub test remote\n' >&2
|
||||
return 0
|
||||
fi
|
||||
command -v gh >/dev/null 2>&1 || {
|
||||
printf 'gh is required to verify CI and release authority before publishing\n' >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
immutable_enabled="$(
|
||||
gh api \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
-H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||
"repos/$github_repository/immutable-releases" \
|
||||
--jq '.enabled'
|
||||
)" || {
|
||||
printf 'immutable releases are not enabled for %s; enable them before allocating a tag\n' "$github_repository" >&2
|
||||
return 1
|
||||
}
|
||||
[ "$immutable_enabled" = "true" ] || {
|
||||
printf 'immutable releases are not enabled for %s\n' "$github_repository" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
active_runs="$(
|
||||
gh api -H 'Accept: application/vnd.github+json' \
|
||||
"repos/$github_repository/actions/workflows/release.yml/runs?per_page=100" \
|
||||
--jq '.workflow_runs[] | select(.status != "completed") | [.id, .event, .status, .head_branch] | @tsv'
|
||||
)" || {
|
||||
printf 'could not query active Release workflow runs for %s\n' "$github_repository" >&2
|
||||
return 1
|
||||
}
|
||||
[ -z "$active_runs" ] || {
|
||||
printf 'another Release workflow is still active; wait for it before allocating a new tag:\n%s\n' "$active_runs" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
sealed_commit="$(git rev-parse HEAD)"
|
||||
passed_gate="$(
|
||||
gh api -H 'Accept: application/vnd.github+json' \
|
||||
"repos/$github_repository/commits/$sealed_commit/check-runs?check_name=CI%20Gate&filter=latest&per_page=100" \
|
||||
--jq '[.check_runs[] | select(.name == "CI Gate" and .conclusion == "success")] | length'
|
||||
)" || {
|
||||
printf 'could not query CI Gate for %s\n' "$sealed_commit" >&2
|
||||
return 1
|
||||
}
|
||||
[ "$passed_gate" -gt 0 ] || {
|
||||
printf 'CI Gate has not succeeded for sealed commit %s in %s\n' "$sealed_commit" "$github_repository" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
if [ "$CHANNEL" = "stable" ]; then
|
||||
beta_state="$(
|
||||
gh api -H 'Accept: application/vnd.github+json' \
|
||||
"repos/$github_repository/releases/tags/$FROM_BETA" \
|
||||
--jq '[.tag_name, .draft, .prerelease, .immutable] | @tsv'
|
||||
)" || {
|
||||
printf 'could not query beta release %s in %s\n' "$FROM_BETA" "$github_repository" >&2
|
||||
return 1
|
||||
}
|
||||
[ "$beta_state" = "$(printf '%s\tfalse\ttrue\ttrue' "$FROM_BETA")" ] || {
|
||||
printf '%s must be a public immutable prerelease before stable tag allocation (got: %s)\n' "$FROM_BETA" "$beta_state" >&2
|
||||
return 1
|
||||
}
|
||||
beta_assets="$(
|
||||
gh api -H 'Accept: application/vnd.github+json' \
|
||||
"repos/$github_repository/releases/tags/$FROM_BETA" \
|
||||
--jq '.assets[].name'
|
||||
)" || return 1
|
||||
[ "$(printf '%s\n' "$beta_assets" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 8 ] || {
|
||||
printf 'beta release %s must contain exactly eight supported assets\n' "$FROM_BETA" >&2
|
||||
return 1
|
||||
}
|
||||
for beta_asset in \
|
||||
dws-darwin-amd64.tar.gz dws-darwin-arm64.tar.gz \
|
||||
dws-linux-amd64.tar.gz dws-linux-arm64.tar.gz \
|
||||
dws-windows-amd64.zip dws-windows-arm64.zip \
|
||||
dws-skills.zip checksums.txt; do
|
||||
[ "$(printf '%s\n' "$beta_assets" | grep -Fxc "$beta_asset")" -eq 1 ] || {
|
||||
printf 'beta release %s must contain %s exactly once\n' "$FROM_BETA" "$beta_asset" >&2
|
||||
return 1
|
||||
}
|
||||
done
|
||||
beta_commit="$(git rev-parse "$FROM_BETA^{commit}")"
|
||||
beta_runs="$(
|
||||
gh api -H 'Accept: application/vnd.github+json' \
|
||||
"repos/$github_repository/actions/workflows/release.yml/runs?branch=$FROM_BETA&event=push&status=completed&per_page=100" \
|
||||
--jq '.workflow_runs[] | [.head_sha, .head_branch, .conclusion] | @tsv'
|
||||
)" || return 1
|
||||
printf '%s\n' "$beta_runs" | awk -F '\t' -v sha="$beta_commit" -v tag="$FROM_BETA" '
|
||||
$1 == sha && $2 == tag && $3 == "success" { found = 1 }
|
||||
END { exit(found ? 0 : 1) }
|
||||
' || {
|
||||
printf 'Release workflow did not succeed for %s at %s\n' "$FROM_BETA" "$beta_commit" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
}
|
||||
|
||||
fetch_release_tags() {
|
||||
git fetch --force "$REMOTE" '+refs/tags/v*:refs/tags/v*'
|
||||
git fetch --force --no-tags "$OFFICIAL_TAGS_URL" '+refs/tags/v*:refs/tags/v*'
|
||||
}
|
||||
|
||||
printf '==> Refreshing %s/%s and release tags\n' "$REMOTE" "$BRANCH"
|
||||
git fetch --force "$REMOTE" "+refs/heads/$BRANCH:refs/remotes/$REMOTE/$BRANCH"
|
||||
fetch_release_tags
|
||||
|
||||
metadata="$(mktemp "${TMPDIR:-/tmp}/dws-release-metadata.XXXXXX")"
|
||||
final_metadata="$(mktemp "${TMPDIR:-/tmp}/dws-release-final-metadata.XXXXXX")"
|
||||
cleanup() { rm -f "$metadata" "$final_metadata"; }
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
run_contract() {
|
||||
if [ -n "$FROM_BETA" ]; then
|
||||
"$SCRIPT_DIR/release-contract.sh" \
|
||||
--channel "$CHANNEL" \
|
||||
--version "$VERSION" \
|
||||
--context local \
|
||||
--remote "$REMOTE" \
|
||||
--branch "$BRANCH" \
|
||||
--from-beta "$FROM_BETA" \
|
||||
"$@"
|
||||
else
|
||||
"$SCRIPT_DIR/release-contract.sh" \
|
||||
--channel "$CHANNEL" \
|
||||
--version "$VERSION" \
|
||||
--context local \
|
||||
--remote "$REMOTE" \
|
||||
--branch "$BRANCH" \
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
run_contract --metadata-output "$metadata"
|
||||
previous_stable="$(sed -n 's/^previous_stable=//p' "$metadata" | tail -1)"
|
||||
|
||||
printf '==> Verifying delivered stable baseline %s\n' "${previous_stable:-none}"
|
||||
require_delivered_previous_stable
|
||||
|
||||
if [ "$PUBLISH" -eq 1 ]; then
|
||||
printf '==> Verifying GitHub publication authority before local gates\n'
|
||||
require_github_publication_authority
|
||||
fi
|
||||
|
||||
printf '==> Running repository test and policy gates\n'
|
||||
make test
|
||||
make policy
|
||||
|
||||
if [ -n "$previous_stable" ]; then
|
||||
printf '==> Comparing command tree with %s\n' "$previous_stable"
|
||||
"$ROOT/scripts/policy/check-command-compatibility.sh" \
|
||||
--base-ref "$REMOTE/$BRANCH" \
|
||||
--stable-ref "$previous_stable"
|
||||
fi
|
||||
|
||||
printf '==> Building local release artifacts for %s\n' "$VERSION"
|
||||
make package VERSION="$VERSION"
|
||||
|
||||
printf '==> Verifying release artifact set and checksums\n'
|
||||
"$SCRIPT_DIR/verify-release-artifacts.sh" "$VERSION"
|
||||
|
||||
printf '==> Verifying npm package installation\n'
|
||||
"$SCRIPT_DIR/verify-package-managers.sh" --npm-only --expected-version "$VERSION"
|
||||
if command -v brew >/dev/null 2>&1; then
|
||||
printf '==> Verifying Homebrew package installation\n'
|
||||
"$SCRIPT_DIR/verify-package-managers.sh" --brew-only --expected-version "$VERSION"
|
||||
fi
|
||||
|
||||
# Collect human confirmation before the final authority refresh. Nothing may
|
||||
# block between that refresh and tag creation.
|
||||
if [ "$PUBLISH" -eq 1 ] && [ "$YES" -ne 1 ]; then
|
||||
if [ ! -t 0 ]; then
|
||||
printf 'interactive confirmation is unavailable; pass --yes after reviewing the preflight\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'Type %s to create and push the release tag: ' "$VERSION"
|
||||
IFS= read -r confirmation
|
||||
[ "$confirmation" = "$VERSION" ] || { printf 'release cancelled\n' >&2; exit 1; }
|
||||
fi
|
||||
|
||||
# Re-check after every local gate so tag creation cannot race with a modified
|
||||
# source tree. dist/ is ignored and therefore does not make the tree dirty.
|
||||
printf '==> Refreshing %s/%s before sealing the tag\n' "$REMOTE" "$BRANCH"
|
||||
git fetch --force "$REMOTE" "+refs/heads/$BRANCH:refs/remotes/$REMOTE/$BRANCH"
|
||||
fetch_release_tags
|
||||
run_contract --metadata-output "$final_metadata"
|
||||
final_previous_stable="$(sed -n 's/^previous_stable=//p' "$final_metadata" | tail -1)"
|
||||
if [ "$final_previous_stable" != "$previous_stable" ]; then
|
||||
printf '==> Stable authority advanced from %s to %s; rechecking command compatibility\n' \
|
||||
"${previous_stable:-none}" "${final_previous_stable:-none}"
|
||||
[ -n "$final_previous_stable" ] || {
|
||||
printf 'latest stable authority unexpectedly became empty\n' >&2
|
||||
exit 1
|
||||
}
|
||||
"$ROOT/scripts/policy/check-command-compatibility.sh" \
|
||||
--base-ref "$REMOTE/$BRANCH" \
|
||||
--stable-ref "$final_previous_stable"
|
||||
fi
|
||||
|
||||
if [ "$PUBLISH" -ne 1 ]; then
|
||||
printf '\nPreflight passed. No tag was created.\n'
|
||||
printf 'Publish with the same command plus --publish.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf '==> Reconfirming GitHub publication authority immediately before tag creation\n'
|
||||
require_github_publication_authority
|
||||
|
||||
printf '==> Creating annotated tag %s\n' "$VERSION"
|
||||
if [ "$CHANNEL" = "stable" ]; then
|
||||
git tag -a "$VERSION" -m "Release $VERSION" -m 'Channel: stable' -m "From-Beta: $FROM_BETA"
|
||||
else
|
||||
git tag -a "$VERSION" -m "Release $VERSION" -m 'Channel: prerelease'
|
||||
fi
|
||||
|
||||
if ! git push "$push_url" "refs/tags/$VERSION"; then
|
||||
set +e
|
||||
remote_refs="$(git ls-remote --tags "$push_url" "refs/tags/$VERSION" "refs/tags/$VERSION^{}")"
|
||||
query_status=$?
|
||||
set -e
|
||||
if [ "$query_status" -ne 0 ]; then
|
||||
printf 'tag push reported failure and remote state could not be verified; keeping local tag %s for investigation\n' "$VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
remote_object="$(printf '%s\n' "$remote_refs" | awk '$2 !~ /\^\{\}$/ { print $1; exit }')"
|
||||
local_object="$(git rev-parse "refs/tags/$VERSION")"
|
||||
if [ -z "$remote_object" ]; then
|
||||
git tag -d "$VERSION" >/dev/null 2>&1 || true
|
||||
printf 'tag push failed; remote has no tag and the new local tag was removed: %s\n' "$VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$remote_object" != "$local_object" ]; then
|
||||
printf 'tag push failed and remote %s points elsewhere; keeping local tag for investigation\n' "$VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'warning: push reported failure, but push target %s has the exact sealed tag; treating it as published\n' "$push_url" >&2
|
||||
fi
|
||||
|
||||
printf 'Release tag pushed: %s -> %s. CI/CD now owns artifact publication.\n' "$VERSION" "$push_url"
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
DEFAULT_ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
|
||||
SOURCE_ROOT="${DWS_PACKAGE_SOURCE_ROOT:-$DEFAULT_ROOT}"
|
||||
DIST_DIR="${DWS_PACKAGE_DIST_DIR:-$DEFAULT_ROOT/dist}"
|
||||
VERSION="${1:-${DWS_PACKAGE_VERSION:-}}"
|
||||
|
||||
[ -n "$VERSION" ] || { printf 'package version is required\n' >&2; exit 2; }
|
||||
SEMVER="${VERSION#v}"
|
||||
printf '%s\n' "$SEMVER" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' || {
|
||||
printf 'invalid npm package version: %s\n' "$VERSION" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
PKG_ROOT="$DIST_DIR/npm/dingtalk-workspace-cli"
|
||||
rm -rf "$PKG_ROOT"
|
||||
mkdir -p "$PKG_ROOT/assets" "$PKG_ROOT/bin"
|
||||
|
||||
cp "$SOURCE_ROOT/build/npm/install.js" "$PKG_ROOT/install.js"
|
||||
cp "$SOURCE_ROOT/build/npm/bin/dws.js" "$PKG_ROOT/bin/dws.js"
|
||||
cp "$SOURCE_ROOT/build/npm/README.md" "$PKG_ROOT/README.md"
|
||||
sed "s|__VERSION__|$SEMVER|g" "$SOURCE_ROOT/build/npm/package.json.tmpl" > "$PKG_ROOT/package.json"
|
||||
|
||||
command -v node >/dev/null 2>&1 || { printf 'node is required to validate the npm manifest\n' >&2; exit 1; }
|
||||
node - "$PKG_ROOT/package.json" "$SEMVER" <<'NODE'
|
||||
const fs = require("fs");
|
||||
const [manifestPath, version] = process.argv.slice(2);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
const expectedScripts = { postinstall: "node install.js" };
|
||||
if (manifest.name !== "dingtalk-workspace-cli") {
|
||||
throw new Error(`unexpected npm package name: ${manifest.name}`);
|
||||
}
|
||||
if (manifest.version !== version) {
|
||||
throw new Error(`unexpected npm package version: ${manifest.version}`);
|
||||
}
|
||||
if (JSON.stringify(manifest.scripts) !== JSON.stringify(expectedScripts)) {
|
||||
throw new Error(`unexpected npm lifecycle scripts: ${JSON.stringify(manifest.scripts)}`);
|
||||
}
|
||||
if (!manifest.bin || manifest.bin.dws !== "./bin/dws.js") {
|
||||
throw new Error("unexpected npm binary entrypoint");
|
||||
}
|
||||
NODE
|
||||
|
||||
copied=0
|
||||
for artifact in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt; do
|
||||
[ -f "$artifact" ] || continue
|
||||
cp "$artifact" "$PKG_ROOT/assets/"
|
||||
copied=$((copied + 1))
|
||||
done
|
||||
[ "$copied" -gt 0 ] || { printf 'no release assets found in %s\n' "$DIST_DIR" >&2; exit 1; }
|
||||
[ -f "$PKG_ROOT/assets/dws-skills.zip" ] || { printf 'dws-skills.zip is missing\n' >&2; exit 1; }
|
||||
[ -f "$PKG_ROOT/assets/checksums.txt" ] || { printf 'checksums.txt is missing\n' >&2; exit 1; }
|
||||
|
||||
printf 'Staged npm package %s at %s\n' "$SEMVER" "$PKG_ROOT"
|
||||
@@ -24,9 +24,8 @@
|
||||
# Gating: if GITEE_TOKEN / GITEE_USER / GITEE_REPO are unset, exit 0 with a
|
||||
# notice so the step can live in release.yml without breaking forks.
|
||||
|
||||
set -euo pipefail
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
DIST_DIR="${DIST_DIR:-dist}"
|
||||
GITEE_API="${GITEE_API:-https://gitee.com/api/v5}"
|
||||
GITEE_CURL_CONNECT_TIMEOUT="${GITEE_CURL_CONNECT_TIMEOUT:-15}"
|
||||
@@ -40,17 +39,12 @@ missing=""
|
||||
[ -z "${GITEE_USER:-}" ] && missing="$missing GITEE_USER"
|
||||
[ -z "${GITEE_REPO:-}" ] && missing="$missing GITEE_REPO"
|
||||
if [ -n "$missing" ]; then
|
||||
if [ "${DWS_REQUIRE_GITEE:-0}" = "1" ]; then
|
||||
echo "❌ Gitee mirror sync is enabled but credentials are missing:${missing}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "ℹ️ Gitee mirror sync skipped — missing:${missing}"
|
||||
echo " Set these as repo secrets to auto-mirror releases to Gitee for China users."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
VERSION="${VERSION:-$(git describe --tags --always 2>/dev/null || echo dev)}"
|
||||
DWS_PACKAGE_DIST_DIR="$DIST_DIR" "$SCRIPT_DIR/verify-release-artifacts.sh" "$VERSION"
|
||||
OWNER="${GITEE_REPO%%/*}"
|
||||
NAME="${GITEE_REPO##*/}"
|
||||
base="${GITEE_API}/repos/${OWNER}/${NAME}"
|
||||
@@ -78,18 +72,10 @@ gitee_tag_commit() {
|
||||
}'
|
||||
}
|
||||
|
||||
gitee_commit="$(gitee_tag_commit || true)"
|
||||
if [ -n "$gitee_commit" ] && [ "$gitee_commit" != "$target_commit" ]; then
|
||||
echo "❌ Existing Gitee tag ${VERSION} points to ${gitee_commit}, expected ${target_commit}; refusing to move it." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$gitee_commit" ]; then
|
||||
echo " Creating Gitee tag ${VERSION} -> ${target_commit}"
|
||||
git push "$git_remote" "refs/tags/${VERSION}:refs/tags/${VERSION}" >/dev/null
|
||||
else
|
||||
echo " Gitee tag ${VERSION} already points to ${target_commit}."
|
||||
fi
|
||||
echo " Syncing Gitee tag ${VERSION} -> ${target_commit}"
|
||||
git push --force "$git_remote" "refs/tags/${VERSION}:refs/tags/${VERSION}" >/dev/null
|
||||
|
||||
gitee_commit=""
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10 11 12; do
|
||||
gitee_commit="$(gitee_tag_commit || true)"
|
||||
[ "$gitee_commit" = "$target_commit" ] && break
|
||||
@@ -138,10 +124,8 @@ echo " Gitee release id = ${release_id}"
|
||||
# detail (/releases/{id}) endpoint: the latter's "assets" array omits the attach
|
||||
# id, so DELETE /attach_files/{id} was previously called with an empty id and
|
||||
# silently no-op'd — leaving stale + duplicate darwin binaries on Gitee.
|
||||
load_assets_map() {
|
||||
curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" \
|
||||
"${base}/releases/${release_id}/attach_files?access_token=${GITEE_TOKEN}" \
|
||||
| python3 -c 'import json,sys
|
||||
assets_map="$(curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" "${base}/releases/${release_id}/attach_files?access_token=${GITEE_TOKEN}" 2>/dev/null \
|
||||
| python3 -c 'import json,sys
|
||||
try:
|
||||
data=json.load(sys.stdin)
|
||||
rows=data if isinstance(data,list) else data.get("attach_files",[])
|
||||
@@ -150,13 +134,7 @@ try:
|
||||
if n and i!="":
|
||||
print("%s\t%s\t%s" % (n, i, u))
|
||||
except Exception:
|
||||
sys.exit(1)'
|
||||
}
|
||||
|
||||
assets_map="$(load_assets_map)" || {
|
||||
echo "❌ Could not list Gitee release attachments; refusing a blind upload." >&2
|
||||
exit 1
|
||||
}
|
||||
pass' 2>/dev/null || true)"
|
||||
|
||||
sha256_of() { # sha256 of a file ($1) or, with no arg, of stdin
|
||||
if command -v sha256sum >/dev/null 2>&1; then sha256sum ${1:+"$1"} | awk '{print $1}';
|
||||
@@ -185,13 +163,12 @@ gitee_attach() { # upload file $1; success when the response carries a download
|
||||
gitee_delete() { # delete attachment by id $1
|
||||
curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" \
|
||||
-X DELETE "${base}/releases/${release_id}/attach_files/${1}?access_token=${GITEE_TOKEN}" \
|
||||
>/dev/null
|
||||
>/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
uploaded=0
|
||||
replaced=0
|
||||
skipped=0
|
||||
failed=0
|
||||
for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt; do
|
||||
[ -f "$f" ] || continue
|
||||
fn="$(basename "$f")"
|
||||
@@ -203,12 +180,7 @@ for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.tx
|
||||
|
||||
if [ "$count" -eq 0 ]; then
|
||||
echo " ⬆ ${fn} (new)"
|
||||
if gitee_attach "$f"; then
|
||||
uploaded=$((uploaded + 1))
|
||||
else
|
||||
echo " ❌ upload failed for ${fn}" >&2
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
if gitee_attach "$f"; then uploaded=$((uploaded + 1)); else echo " ⚠ upload may have failed for ${fn}" >&2; fi
|
||||
continue
|
||||
fi
|
||||
|
||||
@@ -225,58 +197,16 @@ for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.tx
|
||||
fi
|
||||
|
||||
# Delete every copy, then upload exactly one fresh, correct file.
|
||||
delete_failed=0
|
||||
for aid in $ids; do
|
||||
if ! gitee_delete "$aid"; then
|
||||
echo " ❌ failed to delete stale Gitee attachment ${aid} for ${fn}" >&2
|
||||
delete_failed=1
|
||||
fi
|
||||
printf '%s\n' "$ids" | while read -r aid; do
|
||||
[ -n "$aid" ] && gitee_delete "$aid"
|
||||
done
|
||||
if [ "$delete_failed" -ne 0 ]; then
|
||||
failed=$((failed + 1))
|
||||
continue
|
||||
fi
|
||||
if gitee_attach "$f"; then
|
||||
replaced=$((replaced + 1))
|
||||
else
|
||||
echo " ❌ re-upload failed for ${fn}" >&2
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
if gitee_attach "$f"; then replaced=$((replaced + 1)); else echo " ⚠ re-upload may have failed for ${fn}" >&2; fi
|
||||
done
|
||||
|
||||
if [ "$uploaded" -eq 0 ] && [ "$replaced" -eq 0 ] && [ "$skipped" -eq 0 ]; then
|
||||
echo "❌ No artifacts found to mirror. Did the build (goreleaser) run / were assets downloaded into ${DIST_DIR}?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Gitee may accept an upload before the attachment list is consistent. Re-read
|
||||
# the release and require every supported asset to be unique and byte-identical.
|
||||
verified=0
|
||||
for verify_attempt in 1 2 3 4 5; do
|
||||
final_map="$(load_assets_map || true)"
|
||||
verify_failed=0
|
||||
for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt; do
|
||||
fn="$(basename "$f")"
|
||||
rows="$(printf '%s\n' "$final_map" | awk -F'\t' -v n="$fn" '$1==n')"
|
||||
count="$(printf '%s\n' "$rows" | grep -c . || true)"
|
||||
if [ "$count" -ne 1 ]; then
|
||||
verify_failed=1
|
||||
continue
|
||||
fi
|
||||
aurl="$(printf '%s\n' "$rows" | awk -F'\t' 'NR==1 {print $3}')"
|
||||
remote_sha="$(curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" "$aurl" 2>/dev/null | sha256_of || true)"
|
||||
[ "$remote_sha" = "$(sha256_of "$f")" ] || verify_failed=1
|
||||
done
|
||||
if [ "$verify_failed" -eq 0 ] && [ "$failed" -eq 0 ]; then
|
||||
verified=1
|
||||
break
|
||||
fi
|
||||
[ "$verify_attempt" -lt 5 ] && sleep 5
|
||||
done
|
||||
[ "$verified" -eq 1 ] || {
|
||||
echo "❌ Gitee release ${VERSION} does not contain one byte-identical copy of every release asset." >&2
|
||||
exit 1
|
||||
}
|
||||
echo "✅ Gitee release ${VERSION}: uploaded ${uploaded}, replaced ${replaced}, skipped ${skipped} (already correct)."
|
||||
echo " China install: DWS_GITEE_REPO=${GITEE_REPO} \\"
|
||||
echo " curl -fsSL https://gitee.com/${GITEE_REPO}/raw/main/scripts/install.sh | sh"
|
||||
|
||||
+27
-133
@@ -2,16 +2,19 @@
|
||||
# Copyright 2026 Alibaba Group
|
||||
# Licensed under the Apache License, Version 2.0
|
||||
#
|
||||
# Sync release artifacts to a China-accessible OSS mirror. Channel pointers are
|
||||
# mirror metadata; repository installers currently resolve GitHub/Gitee and do
|
||||
# not consume these OSS pointers directly.
|
||||
# Sync release artifacts to a China-accessible OSS mirror so that the install
|
||||
# scripts' DWS_RELEASE_BASE switch can serve domestic users.
|
||||
#
|
||||
# Mirror layout produced:
|
||||
# Mirror layout produced (matches install.sh RELEASE_BASE="<base>/<version>/<file>"):
|
||||
# oss://$OSS_BUCKET/$OSS_PREFIX/download/<version>/dws-<os>-<arch>.tar.gz|.zip
|
||||
# oss://$OSS_BUCKET/$OSS_PREFIX/download/<version>/checksums.txt
|
||||
# oss://$OSS_BUCKET/$OSS_PREFIX/download/<version>/dws-skills.zip
|
||||
# oss://$OSS_BUCKET/$OSS_PREFIX/latest.txt (stable version only)
|
||||
# oss://$OSS_BUCKET/$OSS_PREFIX/beta.txt (prerelease version only)
|
||||
# oss://$OSS_BUCKET/$OSS_PREFIX/latest.txt (plain version string)
|
||||
#
|
||||
# So with the CDN/custom domain CNAME'd to the bucket, China users run:
|
||||
# DWS_RELEASE_BASE=https://<domain>/$OSS_PREFIX/download \
|
||||
# DWS_VERSION=<version> \
|
||||
# curl -fsSL https://<domain>/$OSS_PREFIX/install.sh | sh
|
||||
#
|
||||
# Required environment (CI secrets):
|
||||
# OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET, OSS_ENDPOINT, OSS_BUCKET
|
||||
@@ -25,9 +28,6 @@
|
||||
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/release-lib.sh"
|
||||
|
||||
DIST_DIR="${DIST_DIR:-dist}"
|
||||
OSS_PREFIX="${OSS_PREFIX:-dws}"
|
||||
|
||||
@@ -38,10 +38,6 @@ for v in OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET OSS_ENDPOINT OSS_BUCKET; do
|
||||
[ -z "$val" ] && missing="$missing $v"
|
||||
done
|
||||
if [ -n "$missing" ]; then
|
||||
if [ "${DWS_REQUIRE_OSS:-0}" = "1" ]; then
|
||||
echo "❌ OSS mirror sync is required but credentials are missing:${missing}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "ℹ️ OSS mirror sync skipped — missing:${missing}"
|
||||
echo " Set these as repo secrets to auto-publish releases to the China mirror."
|
||||
exit 0
|
||||
@@ -49,60 +45,20 @@ fi
|
||||
|
||||
# ── Resolve version ──────────────────────────────────────────────────────────
|
||||
VERSION="${VERSION:-$(git describe --tags --always 2>/dev/null || echo dev)}"
|
||||
CHANNEL="${DWS_RELEASE_CHANNEL:-$(release_channel_for_version "$VERSION")}"
|
||||
release_validate_version_channel "$CHANNEL" "$VERSION"
|
||||
echo "📦 Syncing release ${VERSION} → oss://${OSS_BUCKET}/${OSS_PREFIX}/download/${VERSION}/"
|
||||
|
||||
# Never move a channel pointer to an incomplete or mixed-version directory,
|
||||
# even when this helper is invoked outside the normal workflow.
|
||||
DWS_PACKAGE_DIST_DIR="$DIST_DIR" "$SCRIPT_DIR/verify-release-artifacts.sh" "$VERSION"
|
||||
|
||||
# ── Ensure ossutil is available ───────────────────────────────────────────────
|
||||
OSSUTIL="${OSSUTIL:-ossutil}"
|
||||
OSSUTIL_INSTALL_DIR=""
|
||||
if ! command -v "$OSSUTIL" >/dev/null 2>&1; then
|
||||
echo "⬇ ossutil not found; installing a verified temporary copy ..."
|
||||
case "$(uname -s)" in
|
||||
Linux) os=linux ;;
|
||||
Darwin) os=mac ;;
|
||||
*) printf 'unsupported ossutil operating system: %s\n' "$(uname -s)" >&2; exit 1 ;;
|
||||
esac
|
||||
echo "⬇ ossutil not found; installing to ./.ossutil ..."
|
||||
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
arch="$(uname -m)"
|
||||
case "$arch" in x86_64|amd64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; esac
|
||||
case "${os}-${arch}" in
|
||||
linux-amd64) expected_ossutil_sha256=3ae4d9fc85a7a6e9f5654d1599766f1a3a42a3692870887b5ae9338d582ef65a ;;
|
||||
linux-arm64) expected_ossutil_sha256=f6c95ba0c2d2ef30290af686ce4d706c701f4734ce8090bee4288a77e3f1d764 ;;
|
||||
mac-amd64) expected_ossutil_sha256=8437fdd3ef1a3eb12310f61fcf1c00a5bff5cdab47b4fea815527472e7cf896c ;;
|
||||
mac-arm64) expected_ossutil_sha256=058fd048f321f8c80def8b748030531646eefe3a82837bf16b581ba7d9c84ac7 ;;
|
||||
*) printf 'unsupported ossutil architecture: %s-%s\n' "$os" "$arch" >&2; exit 1 ;;
|
||||
esac
|
||||
ossutil_archive="$(mktemp "${TMPDIR:-/tmp}/ossutil-2.3.0.XXXXXX.zip")"
|
||||
ossutil_extract="$(mktemp -d "${TMPDIR:-/tmp}/ossutil-2.3.0.XXXXXX")"
|
||||
env -u OSS_ACCESS_KEY_ID -u OSS_ACCESS_KEY_SECRET \
|
||||
curl -fsSL \
|
||||
"https://gosspublic.alicdn.com/ossutil/v2/2.3.0/ossutil-2.3.0-${os}-${arch}.zip" \
|
||||
-o "$ossutil_archive"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
actual_ossutil_sha256="$(sha256sum "$ossutil_archive" | awk '{print $1}')"
|
||||
else
|
||||
actual_ossutil_sha256="$(shasum -a 256 "$ossutil_archive" | awk '{print $1}')"
|
||||
fi
|
||||
[ "$actual_ossutil_sha256" = "$expected_ossutil_sha256" ] || {
|
||||
rm -rf "$ossutil_archive" "$ossutil_extract"
|
||||
printf 'ossutil archive checksum mismatch for %s-%s\n' "$os" "$arch" >&2
|
||||
exit 1
|
||||
}
|
||||
unzip -qo "$ossutil_archive" -d "$ossutil_extract"
|
||||
found="$(find "$ossutil_extract" -name ossutil -type f | head -1)"
|
||||
[ -n "$found" ] || {
|
||||
rm -rf "$ossutil_archive" "$ossutil_extract"
|
||||
printf 'verified ossutil archive does not contain the executable\n' >&2
|
||||
exit 1
|
||||
}
|
||||
OSSUTIL_INSTALL_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dws-ossutil.XXXXXX")"
|
||||
cp "$found" "$OSSUTIL_INSTALL_DIR/ossutil" && chmod +x "$OSSUTIL_INSTALL_DIR/ossutil"
|
||||
rm -rf "$ossutil_archive" "$ossutil_extract"
|
||||
OSSUTIL="$OSSUTIL_INSTALL_DIR/ossutil"
|
||||
curl -fsSL "https://gosspublic.alicdn.com/ossutil/v2/2.0.0/ossutil-2.0.0-${os}-${arch}.zip" -o /tmp/ossutil.zip
|
||||
unzip -qo /tmp/ossutil.zip -d /tmp/ossutil-extract
|
||||
found="$(find /tmp/ossutil-extract -name ossutil -type f | head -1)"
|
||||
mkdir -p ./.ossutil && cp "$found" ./.ossutil/ossutil && chmod +x ./.ossutil/ossutil
|
||||
OSSUTIL="./.ossutil/ossutil"
|
||||
fi
|
||||
|
||||
oss_cp() {
|
||||
@@ -114,59 +70,8 @@ oss_cp() {
|
||||
"$1" "oss://${OSS_BUCKET}/$2"
|
||||
}
|
||||
|
||||
oss_get() {
|
||||
# oss_get <oss-key> <local-file>
|
||||
"$OSSUTIL" cp -f \
|
||||
--access-key-id "$OSS_ACCESS_KEY_ID" \
|
||||
--access-key-secret "$OSS_ACCESS_KEY_SECRET" \
|
||||
--endpoint "$OSS_ENDPOINT" \
|
||||
"oss://${OSS_BUCKET}/$1" "$2"
|
||||
}
|
||||
|
||||
base="${OSS_PREFIX}/download/${VERSION}"
|
||||
|
||||
if [ "$CHANNEL" = "stable" ]; then
|
||||
pointer_name="latest.txt"
|
||||
else
|
||||
pointer_name="beta.txt"
|
||||
fi
|
||||
current_pointer_file="$(mktemp "${TMPDIR:-/tmp}/dws-current-pointer.XXXXXX")"
|
||||
pointer_file="$(mktemp "${TMPDIR:-/tmp}/dws-release-pointer.XXXXXX")"
|
||||
pointer_status_file="$(mktemp "${TMPDIR:-/tmp}/dws-pointer-status.XXXXXX")"
|
||||
trap 'rm -f "$current_pointer_file" "$pointer_file" "$pointer_status_file"; [ -z "$OSSUTIL_INSTALL_DIR" ] || rm -rf "$OSSUTIL_INSTALL_DIR"' EXIT HUP INT TERM
|
||||
rm -f "$current_pointer_file"
|
||||
current_version=""
|
||||
update_pointer=1
|
||||
if oss_get "${OSS_PREFIX}/${pointer_name}" "$current_pointer_file" >"$pointer_status_file" 2>&1; then
|
||||
[ -s "$current_pointer_file" ] || {
|
||||
cat "$pointer_status_file" >&2
|
||||
echo "❌ OSS ${pointer_name} was read successfully but is empty; refusing to publish." >&2
|
||||
exit 1
|
||||
}
|
||||
current_version="$(tr -d '[:space:]' < "$current_pointer_file")"
|
||||
release_validate_version_channel "$CHANNEL" "$current_version" || {
|
||||
printf '❌ OSS %s contains an invalid %s channel version: %s\n' "$pointer_name" "$CHANNEL" "$current_version" >&2
|
||||
exit 1
|
||||
}
|
||||
if [ "$current_version" != "$VERSION" ]; then
|
||||
if release_version_is_greater "$VERSION" "$current_version"; then
|
||||
:
|
||||
elif release_version_is_greater "$current_version" "$VERSION"; then
|
||||
update_pointer=0
|
||||
echo "ℹ️ OSS ${pointer_name} already points to newer ${current_version}; assets will be repaired without moving it."
|
||||
else
|
||||
echo "❌ Cannot order OSS ${pointer_name}=${current_version} against ${VERSION}." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
elif grep -Eq '(^|[^[:alnum:]])NoSuchKey([^[:alnum:]]|$)' "$pointer_status_file"; then
|
||||
printf 'ℹ️ OSS %s does not exist yet; creating it.\n' "$pointer_name"
|
||||
else
|
||||
cat "$pointer_status_file" >&2
|
||||
printf '❌ Could not read OSS %s; refusing to move the channel pointer.\n' "$pointer_name" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Upload binaries + checksums + skills ──────────────────────────────────────
|
||||
uploaded=0
|
||||
for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt; do
|
||||
@@ -180,27 +85,16 @@ if [ "$uploaded" -eq 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Publish a channel-specific pointer ───────────────────────────────────────
|
||||
# A beta must never move latest.txt: that pointer is consumed by ordinary
|
||||
# installs and is the stable channel contract.
|
||||
pointer_summary="${pointer_name}"
|
||||
if [ "$update_pointer" -eq 1 ]; then
|
||||
echo "$VERSION" > "$pointer_file"
|
||||
oss_cp "$pointer_file" "${OSS_PREFIX}/${pointer_name}"
|
||||
else
|
||||
pointer_summary="${pointer_name} unchanged at ${current_version}"
|
||||
fi
|
||||
# ── Publish the latest pointer (for DWS_VERSION=latest resolution on mirror) ──
|
||||
echo "$VERSION" > /tmp/dws-latest.txt
|
||||
oss_cp /tmp/dws-latest.txt "${OSS_PREFIX}/latest.txt"
|
||||
|
||||
# Shared installer entrypoints are stable-only. A prerelease must not replace
|
||||
# the stable entry layer, even though those scripts currently fetch from
|
||||
# GitHub/Gitee rather than this asset mirror.
|
||||
installer_summary=""
|
||||
if [ "$CHANNEL" = "stable" ] && [ "$update_pointer" -eq 1 ]; then
|
||||
for s in install.sh install.ps1 install-skills.sh; do
|
||||
[ -f "scripts/$s" ] && oss_cp "scripts/$s" "${OSS_PREFIX}/$s"
|
||||
done
|
||||
installer_summary=" + stable install scripts"
|
||||
fi
|
||||
# ── Publish the install scripts themselves (entry layer) ──────────────────────
|
||||
for s in install.sh install.ps1 install-skills.sh; do
|
||||
[ -f "scripts/$s" ] && oss_cp "scripts/$s" "${OSS_PREFIX}/$s"
|
||||
done
|
||||
|
||||
echo "✅ Synced ${uploaded} artifact(s) + ${pointer_summary}${installer_summary} to the OSS mirror."
|
||||
echo " Mirror path: ${OSS_PREFIX}/download/${VERSION}/"
|
||||
echo "✅ Synced ${uploaded} artifact(s) + install scripts + latest.txt to the OSS mirror."
|
||||
echo " China install:"
|
||||
echo " DWS_RELEASE_BASE=https://<your-domain>/${OSS_PREFIX}/download DWS_VERSION=${VERSION} \\"
|
||||
echo " curl -fsSL https://<your-domain>/${OSS_PREFIX}/install.sh | sh"
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
. "$SCRIPT_DIR/release-lib.sh"
|
||||
|
||||
TAG="${1:-}"
|
||||
EXPECTED_COMMIT="${2:-}"
|
||||
REPOSITORY="${DWS_RELEASE_OFFICIAL_REPOSITORY:-DingTalk-Real-AI/dingtalk-workspace-cli}"
|
||||
|
||||
[ -n "$TAG" ] && [ -n "$EXPECTED_COMMIT" ] || {
|
||||
printf 'usage: verify-delivered-stable.sh <stable-tag> <commit>\n' >&2
|
||||
exit 2
|
||||
}
|
||||
release_is_stable_version "$TAG" || {
|
||||
printf 'invalid stable delivery baseline: %s\n' "$TAG" >&2
|
||||
exit 2
|
||||
}
|
||||
command -v curl >/dev/null 2>&1 || { printf 'curl is required to verify stable delivery\n' >&2; exit 1; }
|
||||
command -v python3 >/dev/null 2>&1 || { printf 'python3 is required to verify stable delivery\n' >&2; exit 1; }
|
||||
|
||||
API_TOKEN="${DWS_RELEASE_GITHUB_TOKEN:-}"
|
||||
if [ -z "$API_TOKEN" ] && [ "${GITHUB_ACTIONS:-false}" != "true" ] && command -v gh >/dev/null 2>&1; then
|
||||
API_TOKEN="$(gh auth token 2>/dev/null || true)"
|
||||
fi
|
||||
if [ -z "$API_TOKEN" ]; then
|
||||
API_TOKEN="${GITHUB_TOKEN:-}"
|
||||
fi
|
||||
github_get() {
|
||||
if [ -n "$API_TOKEN" ]; then
|
||||
curl -fsSL \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
-H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||
-H "Authorization: Bearer $API_TOKEN" \
|
||||
"https://api.github.com/$1" 2>/dev/null && return 0
|
||||
fi
|
||||
curl -fsSL \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
-H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||
"https://api.github.com/$1"
|
||||
}
|
||||
|
||||
if git rev-parse --verify --quiet "refs/tags/$TAG^{commit}" >/dev/null; then
|
||||
local_commit="$(git rev-parse "refs/tags/$TAG^{commit}")"
|
||||
[ "$local_commit" = "$EXPECTED_COMMIT" ] || {
|
||||
printf 'stable tag %s resolves to %s, expected %s\n' "$TAG" "$local_commit" "$EXPECTED_COMMIT" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
stable_state="$(
|
||||
github_get "repos/$REPOSITORY/releases/tags/$TAG" \
|
||||
| python3 -c 'import json,sys
|
||||
r=json.load(sys.stdin)
|
||||
print("%s\t%s\t%s" % (r.get("tag_name", ""), str(r.get("draft", "")).lower(), str(r.get("prerelease", "")).lower()))'
|
||||
)" || {
|
||||
printf 'could not query previous stable release %s in %s\n' "$TAG" "$REPOSITORY" >&2
|
||||
exit 1
|
||||
}
|
||||
[ "$stable_state" = "$(printf '%s\tfalse\tfalse' "$TAG")" ] || {
|
||||
printf '%s is not a public stable GitHub Release in %s (got: %s)\n' \
|
||||
"$TAG" "$REPOSITORY" "$stable_state" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
stable_runs="$(
|
||||
github_get "repos/$REPOSITORY/actions/workflows/release.yml/runs?branch=$TAG&event=push&status=completed&per_page=100" \
|
||||
| python3 -c 'import json,sys
|
||||
for run in json.load(sys.stdin).get("workflow_runs", []):
|
||||
print("%s\t%s\t%s" % (run.get("head_sha", ""), run.get("head_branch", ""), run.get("conclusion", "")))'
|
||||
)" || {
|
||||
printf 'could not query Release workflow delivery for %s in %s\n' "$TAG" "$REPOSITORY" >&2
|
||||
exit 1
|
||||
}
|
||||
printf '%s\n' "$stable_runs" | awk -F '\t' -v sha="$EXPECTED_COMMIT" -v tag="$TAG" '
|
||||
$1 == sha && $2 == tag && $3 == "success" { found = 1 }
|
||||
END { exit(found ? 0 : 1) }
|
||||
' || {
|
||||
printf 'Release workflow did not complete successfully for stable baseline %s at %s\n' \
|
||||
"$TAG" "$EXPECTED_COMMIT" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
printf 'Delivered stable baseline verified: %s -> %s\n' "$TAG" "$EXPECTED_COMMIT"
|
||||
@@ -1,35 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
TAG="${1:-}"
|
||||
REPOSITORY="${GITHUB_REPOSITORY:-}"
|
||||
|
||||
[ -n "$TAG" ] && [ -n "$REPOSITORY" ] || {
|
||||
printf 'usage: GITHUB_REPOSITORY=owner/repo verify-github-release-assets.sh <tag>\n' >&2
|
||||
exit 2
|
||||
}
|
||||
command -v gh >/dev/null 2>&1 || { printf 'gh is required\n' >&2; exit 1; }
|
||||
|
||||
tmp="$(mktemp -d "${TMPDIR:-/tmp}/dws-github-assets.XXXXXX")"
|
||||
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
|
||||
cat > "$tmp/expected" <<'EOF'
|
||||
checksums.txt
|
||||
dws-darwin-amd64.tar.gz
|
||||
dws-darwin-arm64.tar.gz
|
||||
dws-linux-amd64.tar.gz
|
||||
dws-linux-arm64.tar.gz
|
||||
dws-skills.zip
|
||||
dws-windows-amd64.zip
|
||||
dws-windows-arm64.zip
|
||||
EOF
|
||||
LC_ALL=C sort "$tmp/expected" -o "$tmp/expected"
|
||||
|
||||
gh api -H 'Accept: application/vnd.github+json' \
|
||||
"repos/$REPOSITORY/releases/tags/$TAG" \
|
||||
--jq '.assets[].name' | LC_ALL=C sort > "$tmp/actual"
|
||||
if ! diff -u "$tmp/expected" "$tmp/actual"; then
|
||||
printf 'GitHub Release %s must contain exactly the supported assets\n' "$TAG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'GitHub Release asset set verified: %s\n' "$TAG"
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
TAG="${1:-}"
|
||||
EXPECTED_COMMIT="${2:-}"
|
||||
REPOSITORY="${GITHUB_REPOSITORY:-}"
|
||||
|
||||
[ -n "$TAG" ] && [ -n "$EXPECTED_COMMIT" ] && [ -n "$REPOSITORY" ] || {
|
||||
printf 'usage: GITHUB_REPOSITORY=owner/repo verify-github-tag-authority.sh <tag> <commit>\n' >&2
|
||||
exit 2
|
||||
}
|
||||
command -v gh >/dev/null 2>&1 || { printf 'gh is required\n' >&2; exit 1; }
|
||||
|
||||
local_object="$(git rev-parse "refs/tags/$TAG")"
|
||||
remote_ref="$(
|
||||
gh api -H 'Accept: application/vnd.github+json' \
|
||||
"repos/$REPOSITORY/git/ref/tags/$TAG" \
|
||||
--jq '[.object.type, .object.sha] | @tsv'
|
||||
)"
|
||||
[ "$remote_ref" = "$(printf 'tag\t%s' "$local_object")" ] || {
|
||||
printf 'remote tag object for %s differs from the sealed annotated tag (local=%s remote=%s)\n' \
|
||||
"$TAG" "$local_object" "$remote_ref" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
remote_tag="$(
|
||||
gh api -H 'Accept: application/vnd.github+json' \
|
||||
"repos/$REPOSITORY/git/tags/$local_object" \
|
||||
--jq '[.tag, .object.type, .object.sha] | @tsv'
|
||||
)"
|
||||
[ "$remote_tag" = "$(printf '%s\tcommit\t%s' "$TAG" "$EXPECTED_COMMIT")" ] || {
|
||||
printf 'remote annotated tag %s does not peel to expected commit %s (got: %s)\n' \
|
||||
"$TAG" "$EXPECTED_COMMIT" "$remote_tag" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
printf 'GitHub tag authority verified: %s -> %s\n' "$TAG" "$EXPECTED_COMMIT"
|
||||
@@ -5,10 +5,6 @@ ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
DIST_DIR="${DWS_PACKAGE_DIST_DIR:-$ROOT/dist}"
|
||||
FORMULA_PATH="$DIST_DIR/homebrew/dingtalk-workspace-cli-local.rb"
|
||||
NPM_STAGE_DIR="$DIST_DIR/npm/dingtalk-workspace-cli"
|
||||
RUN_NPM=1
|
||||
RUN_BREW=1
|
||||
EXPECTED_VERSION=""
|
||||
VERIFY_SKILL_TARGETS=1
|
||||
|
||||
say() {
|
||||
printf '%s\n' "$*"
|
||||
@@ -27,34 +23,6 @@ need_file() {
|
||||
[ -f "$1" ] || err "required file not found: $1"
|
||||
}
|
||||
|
||||
usage() {
|
||||
printf '%s\n' "usage: $0 [--npm-only|--brew-only] [--expected-version <vX.Y.Z>] [--skip-skill-targets]" >&2
|
||||
}
|
||||
|
||||
mode_seen=0
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--npm-only)
|
||||
[ "$mode_seen" -eq 0 ] || { usage; exit 2; }
|
||||
RUN_NPM=1; RUN_BREW=0; mode_seen=1; shift
|
||||
;;
|
||||
--brew-only)
|
||||
[ "$mode_seen" -eq 0 ] || { usage; exit 2; }
|
||||
RUN_NPM=0; RUN_BREW=1; mode_seen=1; shift
|
||||
;;
|
||||
--expected-version)
|
||||
[ "$#" -ge 2 ] || { usage; exit 2; }
|
||||
EXPECTED_VERSION="${2#v}"
|
||||
shift 2
|
||||
;;
|
||||
--skip-skill-targets) VERIFY_SKILL_TARGETS=0; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -z "$EXPECTED_VERSION" ] || need_cmd strings
|
||||
|
||||
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-package-verify-XXXXXX")"
|
||||
HOME_AGENT_PARENTS="
|
||||
.claude
|
||||
@@ -92,11 +60,13 @@ HOME_SKILL_TARGETS="
|
||||
.hermes/skills/dws
|
||||
"
|
||||
cleanup() {
|
||||
if [ "$RUN_BREW" -eq 1 ] && command -v brew >/dev/null 2>&1 && [ -n "${BREW_TAP_NAME:-}" ]; then
|
||||
HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
brew uninstall --force "$BREW_TAP_NAME/dingtalk-workspace-cli-local" >/dev/null 2>&1 || true
|
||||
HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
brew untap --force "$BREW_TAP_NAME" >/dev/null 2>&1 || true
|
||||
if command -v brew >/dev/null 2>&1; then
|
||||
HOME="$TMP_ROOT/brew-home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
brew uninstall --force dingtalk-workspace-cli-local >/dev/null 2>&1 || true
|
||||
if [ -n "${BREW_TAP_NAME:-}" ]; then
|
||||
HOME="$TMP_ROOT/brew-home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
brew untap --force "$BREW_TAP_NAME" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
rm -rf "$TMP_ROOT"
|
||||
}
|
||||
@@ -141,18 +111,8 @@ verify_npm() {
|
||||
npm install -g "$tarball_path" >/dev/null
|
||||
|
||||
[ -x "$npm_prefix/bin/dws" ] || err "npm install did not expose dws in $npm_prefix/bin"
|
||||
HOME="$npm_home" "$npm_prefix/bin/dws" --help >/dev/null
|
||||
if [ -n "$EXPECTED_VERSION" ]; then
|
||||
vendor_bin="$npm_prefix/lib/node_modules/dingtalk-workspace-cli/vendor/dws"
|
||||
need_file "$vendor_bin"
|
||||
strings "$vendor_bin" | grep -Fqx "v$EXPECTED_VERSION" || \
|
||||
err "npm-installed binary does not embed expected version v$EXPECTED_VERSION"
|
||||
EXPECTED_VERSION="$EXPECTED_VERSION" node -e '
|
||||
const pkg = require(process.argv[1]);
|
||||
if (pkg.version !== process.env.EXPECTED_VERSION) process.exit(1);
|
||||
' "$NPM_STAGE_DIR/package.json" || err "npm package.json version mismatch"
|
||||
fi
|
||||
[ "$VERIFY_SKILL_TARGETS" -eq 0 ] || verify_skill_targets "$npm_home"
|
||||
"$npm_prefix/bin/dws" --help >/dev/null
|
||||
verify_skill_targets "$npm_home"
|
||||
|
||||
HOME="$npm_home" npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \
|
||||
npm uninstall -g dingtalk-workspace-cli >/dev/null
|
||||
@@ -163,10 +123,6 @@ verify_npm() {
|
||||
verify_brew() {
|
||||
need_cmd brew
|
||||
need_file "$FORMULA_PATH"
|
||||
if [ -n "$EXPECTED_VERSION" ]; then
|
||||
grep -Fq " version \"$EXPECTED_VERSION\"" "$FORMULA_PATH" || \
|
||||
err "Homebrew formula does not declare version $EXPECTED_VERSION"
|
||||
fi
|
||||
|
||||
brew_home="$TMP_ROOT/brew-home"
|
||||
mkdir -p "$brew_home"
|
||||
@@ -174,39 +130,36 @@ verify_brew() {
|
||||
BREW_TAP_NAME="local/dws-package-verify-$$"
|
||||
|
||||
say "==> verifying Homebrew formula install"
|
||||
HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
brew uninstall --force dingtalk-workspace-cli-local >/dev/null 2>&1 || true
|
||||
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
brew untap --force "$BREW_TAP_NAME" >/dev/null 2>&1 || true
|
||||
|
||||
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
brew tap-new --no-git "$BREW_TAP_NAME" >/dev/null
|
||||
|
||||
tap_repo="$(
|
||||
HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
brew --repository "$BREW_TAP_NAME"
|
||||
)"
|
||||
mkdir -p "$tap_repo/Formula"
|
||||
sed "s|skill_home_override = \"\"|skill_home_override = \"$brew_home\"|" \
|
||||
"$FORMULA_PATH" > "$tap_repo/Formula/dingtalk-workspace-cli-local.rb"
|
||||
cp "$FORMULA_PATH" "$tap_repo/Formula/dingtalk-workspace-cli-local.rb"
|
||||
|
||||
HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
brew install "$BREW_TAP_NAME/dingtalk-workspace-cli-local" >/dev/null
|
||||
|
||||
prefix="$(
|
||||
HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
brew --prefix "$BREW_TAP_NAME/dingtalk-workspace-cli-local"
|
||||
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
brew --prefix dingtalk-workspace-cli-local
|
||||
)"
|
||||
[ -x "$prefix/bin/dws" ] || err "brew install did not create $prefix/bin/dws"
|
||||
HOME="$brew_home" "$prefix/bin/dws" --help >/dev/null
|
||||
if [ -n "$EXPECTED_VERSION" ]; then
|
||||
strings "$prefix/bin/dws" | grep -Fqx "v$EXPECTED_VERSION" || \
|
||||
err "Homebrew-installed binary does not embed expected version v$EXPECTED_VERSION"
|
||||
installed_version="$(HOMEBREW_NO_AUTO_UPDATE=1 brew list --versions "$BREW_TAP_NAME/dingtalk-workspace-cli-local" | awk '{ print $2 }')"
|
||||
[ "$installed_version" = "$EXPECTED_VERSION" ] || \
|
||||
err "Homebrew installed version $installed_version, expected $EXPECTED_VERSION"
|
||||
fi
|
||||
[ "$VERIFY_SKILL_TARGETS" -eq 0 ] || verify_skill_targets "$brew_home"
|
||||
"$prefix/bin/dws" --help >/dev/null
|
||||
verify_skill_targets "$brew_home"
|
||||
}
|
||||
|
||||
need_file "$DIST_DIR/dws-skills.zip"
|
||||
|
||||
[ "$RUN_NPM" -eq 0 ] || verify_npm
|
||||
[ "$RUN_BREW" -eq 0 ] || verify_brew
|
||||
verify_npm
|
||||
verify_brew
|
||||
|
||||
say "Package-manager verification complete."
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
|
||||
DIST_DIR="${DWS_PACKAGE_DIST_DIR:-$ROOT/dist}"
|
||||
VERSION="${1:-${DWS_PACKAGE_VERSION:-}}"
|
||||
|
||||
[ -n "$VERSION" ] || { printf 'expected release version is required\n' >&2; exit 2; }
|
||||
SEMVER="${VERSION#v}"
|
||||
CHECKSUMS="$DIST_DIR/checksums.txt"
|
||||
EXPECTED_PLATFORM_ASSETS="
|
||||
dws-darwin-amd64.tar.gz
|
||||
dws-darwin-arm64.tar.gz
|
||||
dws-linux-amd64.tar.gz
|
||||
dws-linux-arm64.tar.gz
|
||||
dws-windows-amd64.zip
|
||||
dws-windows-arm64.zip
|
||||
"
|
||||
EXPECTED_ASSETS="$EXPECTED_PLATFORM_ASSETS
|
||||
dws-skills.zip
|
||||
"
|
||||
|
||||
[ -f "$CHECKSUMS" ] || { printf 'missing checksums.txt in %s\n' "$DIST_DIR" >&2; exit 1; }
|
||||
|
||||
tmp="$(mktemp -d "${TMPDIR:-/tmp}/dws-release-binary.XXXXXX")"
|
||||
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
|
||||
|
||||
# The public asset namespace is an exact set. GoReleaser may also leave local
|
||||
# metadata files (artifacts.json/config.yaml/metadata.json), but the upload and
|
||||
# npm staging globs never publish them.
|
||||
printf '%s\nchecksums.txt\n' "$EXPECTED_ASSETS" | sed '/^$/d' | LC_ALL=C sort > "$tmp/expected-root"
|
||||
for path in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$CHECKSUMS"; do
|
||||
[ -f "$path" ] || continue
|
||||
basename "$path"
|
||||
done | LC_ALL=C sort > "$tmp/actual-root"
|
||||
if ! diff -u "$tmp/expected-root" "$tmp/actual-root"; then
|
||||
printf 'public release assets must contain exactly the supported files\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
checksum_format_ok=1
|
||||
awk '
|
||||
NF != 2 { bad = 1; next }
|
||||
$1 !~ /^[0-9a-fA-F]{64}$/ { bad = 1; next }
|
||||
{ print $2 }
|
||||
END { if (bad) exit 1 }
|
||||
' "$CHECKSUMS" > "$tmp/checksum-assets" || checksum_format_ok=0
|
||||
[ "$checksum_format_ok" -eq 1 ] || {
|
||||
printf 'checksums.txt must contain only SHA-256 and filename pairs\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
skills_checksum_count="$(awk '$2 == "dws-skills.zip" { count++ } END { print count + 0 }' "$CHECKSUMS")"
|
||||
[ "$skills_checksum_count" -eq 1 ] || {
|
||||
printf 'checksums.txt must contain dws-skills.zip exactly once (found %s)\n' "$skills_checksum_count" >&2
|
||||
exit 1
|
||||
}
|
||||
printf '%s\n' "$EXPECTED_ASSETS" | sed '/^$/d' | LC_ALL=C sort > "$tmp/expected-checksums"
|
||||
LC_ALL=C sort "$tmp/checksum-assets" > "$tmp/actual-checksums"
|
||||
if ! diff -u "$tmp/expected-checksums" "$tmp/actual-checksums"; then
|
||||
printf 'checksums.txt must describe exactly the supported release assets\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
(cd "$DIST_DIR" && sha256sum --check checksums.txt)
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
(cd "$DIST_DIR" && shasum -a 256 --check checksums.txt)
|
||||
else
|
||||
printf 'sha256sum or shasum is required\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
verify_binary_version() {
|
||||
asset="$1"
|
||||
extract_dir="$tmp/extract-${asset}"
|
||||
mkdir -p "$extract_dir"
|
||||
case "$asset" in
|
||||
*.tar.gz)
|
||||
tar -xzf "$DIST_DIR/$asset" -C "$extract_dir"
|
||||
binary="$extract_dir/dws"
|
||||
;;
|
||||
*.zip)
|
||||
unzip -q "$DIST_DIR/$asset" -d "$extract_dir"
|
||||
binary="$extract_dir/dws.exe"
|
||||
;;
|
||||
*)
|
||||
printf 'unsupported release archive: %s\n' "$asset" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
[ -f "$binary" ] || {
|
||||
printf '%s does not contain the expected dws binary\n' "$asset" >&2
|
||||
return 1
|
||||
}
|
||||
strings "$binary" | grep -Fqx "v$SEMVER" || {
|
||||
printf '%s binary does not embed expected version v%s\n' "$asset" "$SEMVER" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
for asset in $EXPECTED_PLATFORM_ASSETS; do
|
||||
verify_binary_version "$asset"
|
||||
done
|
||||
|
||||
printf 'Release artifacts verified for v%s.\n' "$SEMVER"
|
||||
@@ -34,7 +34,7 @@ cli_version: ">=1.0.15"
|
||||
| `aitable` | AI表格:Base/数据表/字段/记录/视图/附件/图表/仪表盘/导入导出/模板搜索 | [aitable.md](./references/products/aitable.md) |
|
||||
| `attendance` | 考勤:打卡结果/打卡流水/考勤组查询/考勤规则/汇总统计/假期类型/假期余额(P0 已落地,部分管理类命令仍属 P1) | [attendance.md](./references/products/attendance.md) |
|
||||
| `calendar` | 日历:日历列表/日程/参与者/附件/响应/会议室/闲忙查询/时间建议 | [calendar.md](./references/products/calendar.md) |
|
||||
| `chat` | 群聊与机器人:搜索群/建群/群成员管理/改群名/消息发送(文本/Markdown/图片/文件)/拉取消息/@我/特别关注/机器人群发/单聊/撤回/转发/引用回复/Webhook/机器人搜索 | [chat.md](./references/products/chat.md) |
|
||||
| `chat` | 群聊与机器人:搜索群/建群/群成员管理/改群名/消息发送(文本/Markdown/图片/文件)/拉取消息/消息收藏/@我/特别关注/机器人群发/单聊/撤回/转发/引用回复/Webhook/机器人搜索 | [chat.md](./references/products/chat.md) |
|
||||
| `contact` | 通讯录:用户查询(当前用户/搜索/详情/手机号)/花名册档案(学历/家庭/银行卡/合同)/离职员工查询(姓名/时间范围/部门)/部门查询(搜索/详情/子部门/成员)/角色查询(主管/管理员/财务/HR 等 label)/特别关注列表 | [contact.md](./references/products/contact.md) |
|
||||
| `devdoc` | 开放平台文档:搜索开发文档 | [devdoc.md](./references/products/devdoc.md) |
|
||||
| `ding` | DING消息:发送/撤回(应用内/短信/电话) | [ding.md](./references/products/ding.md) |
|
||||
@@ -132,18 +132,22 @@ Step 3 → 加 --yes 执行命令
|
||||
dws <command-path> --help
|
||||
# 例:dws calendar event list --help
|
||||
|
||||
# helper-only schema 查询(如 dev.*),普通产品命令不要依赖 schema 推断参数
|
||||
# 结构化 schema 查询:helper-only 子树(dev.*,source=mcp:<server>)
|
||||
dws schema "dev app create"
|
||||
# 登记的本地命令子树(event.*,source=cobra,从二进制 flag 合成)
|
||||
dws schema "event consume" # 叶子:parameters{<flag>:{type,required,description,default?}} + arguments[位置参数]
|
||||
dws schema event # 中间节点:列子命令
|
||||
# 其余普通产品命令不走 schema,直接看 --help。
|
||||
# 注:--jq 对 schema 输出无效(不过滤,仍返回完整对象);schema 结构里必填标在
|
||||
# .parameters.<字段>.required,没有 .tool 键。要看必填字段自行读 .parameters 即可。
|
||||
```
|
||||
|
||||
**何时用哪条路径:**
|
||||
- 只需看某个命令怎么调用 → `dws <cmd> --help`
|
||||
- 构造 `--params` / `--json` 时不确定字段类型、必填、别名 → 先看 `dws <cmd> --help`,helper-only 命令再看 `dws schema`
|
||||
- 构造 `--params` / `--json` 时不确定字段类型、必填、别名 → 先看 `dws <cmd> --help`,helper-only(dev.*)和登记的本地命令(event.*)可看 `dws schema` 取机读结构
|
||||
- 参考文档和 `--help` 冲突时 → **以 `--help` 为准**,文档视为过期
|
||||
|
||||
`dws schema` 在静态端点模式下只保留 helper-only 子树;普通产品命令和 flag 不再通过远程 schema 动态发现。写/删操作须先向用户确认再加 `--yes`。
|
||||
`dws schema` 覆盖两类命令:helper-only 子树(dev,CONTENT 从 MCP 实时取)和登记的本地命令(event,从 cobra flag 合成、source=cobra);其余普通产品命令和 flag 以 `--help` 为准。写/删操作须先向用户确认再加 `--yes`。
|
||||
|
||||
## 错误处理
|
||||
1. 遇到错误,加 `--verbose` 重试一次
|
||||
|
||||
@@ -1317,6 +1317,45 @@ Flags:
|
||||
- 分页: hasMore=true 时,用返回的 nextCursor 作为下次 --cursor 继续翻页
|
||||
```
|
||||
|
||||
#### 收藏消息 — 为当前用户收藏指定会话中的一条消息
|
||||
```
|
||||
Usage:
|
||||
dws chat message add-favorite [flags]
|
||||
Example:
|
||||
dws chat message add-favorite --open-message-id <openMessageId> --open-conversation-id <openConversationId>
|
||||
Flags:
|
||||
--open-message-id string 消息 openMessageId (必填)
|
||||
--open-conversation-id string 消息所在会话 openConversationId (必填)
|
||||
```
|
||||
|
||||
#### 取消收藏消息 — 移除当前用户的收藏标记,不删除原消息
|
||||
```
|
||||
Usage:
|
||||
dws chat message remove-favorite [flags]
|
||||
Example:
|
||||
dws chat message remove-favorite --open-message-id <openMessageId> --open-conversation-id <openConversationId>
|
||||
Flags:
|
||||
--open-message-id string 消息 openMessageId (必填)
|
||||
--open-conversation-id string 消息所在会话 openConversationId (必填)
|
||||
```
|
||||
|
||||
#### 查询收藏消息 — 分页查询当前用户收藏的消息
|
||||
```
|
||||
Usage:
|
||||
dws chat message list-favorites [flags]
|
||||
Example:
|
||||
dws chat message list-favorites
|
||||
dws chat message list-favorites --size 50
|
||||
dws chat message list-favorites --cursor 20 --size 20
|
||||
Flags:
|
||||
--cursor int 数字分页游标,默认 0;翻页时传上次返回的 nextCursor
|
||||
--size int 一次拉取的收藏数量,默认 20,范围 1-100
|
||||
|
||||
注意:
|
||||
- 首次请求可省略分页参数,CLI 会自动向 Open 服务传入 cursor=0、size="20"
|
||||
- hasMore=true 时,将 nextCursor 作为下一次的 --cursor
|
||||
```
|
||||
|
||||
### bot (机器人管理)
|
||||
|
||||
#### 搜索【我创建的】机器人 — 仅返回当前用户自己创建的机器人
|
||||
|
||||
@@ -338,6 +338,22 @@ Flags:
|
||||
--mention string 被 @ 的用户 uid 列表,逗号分隔
|
||||
```
|
||||
|
||||
### 更新文档评论
|
||||
```text
|
||||
Usage:
|
||||
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content <CONTENT> [--mention uid1,uid2]
|
||||
```
|
||||
|
||||
`commentKey` 从 `comment list/create/create-inline` 返回中获取。只有显式传入 `--mention` 时才更新被 @ 用户列表。
|
||||
|
||||
### 删除文档评论
|
||||
```text
|
||||
Usage:
|
||||
dws doc comment delete --node <DOC_ID> --comment-key <COMMENT_KEY> --yes
|
||||
```
|
||||
|
||||
删除评论不可恢复,必须先获得用户明确确认,再添加全局 `--yes`。
|
||||
|
||||
### 文件内容获取路由规则
|
||||
|
||||
> 当用户请求"分析/查看/读取某个文件内容"时,**必须先调用 `dws doc info` 获取文件元数据**,再根据返回的 `contentType` 和 `extension` 字段选择对应链路:
|
||||
@@ -893,18 +909,24 @@ dws doc comment create --node <DOC_ID> --content "这里需要补充数据来源
|
||||
# 再将 userId 传入 --mention
|
||||
dws doc comment create --node <DOC_ID> --content "请确认这部分内容" --mention <userId1>,<userId2> --format json
|
||||
|
||||
# 4. 回复某条评论(commentKey 从 list 或 create 返回中获取)
|
||||
# 4. 更新某条评论(可选 --mention)
|
||||
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正" --format json
|
||||
|
||||
# 5. 删除某条评论(不可恢复,必须先确认)
|
||||
dws doc comment delete --node <DOC_ID> --comment-key <COMMENT_KEY> --yes --format json
|
||||
|
||||
# 6. 回复某条评论(commentKey 从 list 或 create 返回中获取)
|
||||
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已修改" --format json
|
||||
|
||||
# 5. 用表情回复评论
|
||||
# 7. 用表情回复评论
|
||||
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji --format json
|
||||
|
||||
# 6. 创建划词评论(针对文档中某段选中文本)
|
||||
# 8. 创建划词评论(针对文档中某段选中文本)
|
||||
# 先获取块列表: dws doc block list --node <DOC_ID> --format json → 提取 blockId 和文本内容
|
||||
# 确定选中文本在块内的起始偏移量 (start) 和结束偏移量 (end)
|
||||
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 0 --end 10 --content "这里需要修改" --format json
|
||||
|
||||
# 7. 创建划词评论并附带引用原文 + @ 相关人
|
||||
# 9. 创建划词评论并附带引用原文 + @ 相关人
|
||||
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 5 --end 20 --content "请确认这部分" --selected-text "被选中的原文内容" --mention <userId1>,<userId2> --format json
|
||||
|
||||
# ── 工作流 10: 导出在线文档为 docx ──
|
||||
@@ -926,10 +948,10 @@ dws doc export --node <DOC_ID_OR_URL> --output ./exported.docx
|
||||
| `import` | `nodeId` / `documentUrl` / `documentName` / `documentType`;中断时提取 `taskId` | 后续 read / info / sheet 操作;中断后用 `doc import get --task-id` |
|
||||
| `drive mkdir`(原 `doc folder create`,已弃用) | `nodeId` | create 的 --folder |
|
||||
| `block list` | `blockId` | block insert 的 --ref-block, block update/delete 的 --block-id |
|
||||
| `comment list` | `commentList[].commentKey` | comment reply 的 --comment-key |
|
||||
| `comment create` / `comment create-inline` | `commentKey` | comment reply 的 --comment-key |
|
||||
| `comment list` | `commentList[].commentKey` | comment reply/update/delete 的 --comment-key |
|
||||
| `comment create` / `comment create-inline` | `commentKey` | comment reply/update/delete 的 --comment-key |
|
||||
| `block list` | `blockId` + 文本内容 | comment create-inline 的 --block-id 及 --start/--end 计算 |
|
||||
| `contact user search` | `userId` | comment create / create-inline / reply 的 --mention |
|
||||
| `contact user search` | `userId` | comment create / create-inline / reply / update 的 --mention |
|
||||
| `wiki node create`(原 `doc file create`,已弃用) | `nodeId` | 后续 read / update / block 操作的 --node(仅 adoc 支持 read/update,axls/amind 等类型用各自产品的命令) |
|
||||
| `copy` / `move` | 新 `nodeId`(copy)或原 nodeId(move) | 后续 read / info 等的 --node |
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# doc comment(文档评论:list / create / reply / create-inline)
|
||||
# doc comment(文档评论:list / create / reply / update / delete / create-inline)
|
||||
|
||||
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
|
||||
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
|
||||
@@ -61,6 +61,39 @@ Flags:
|
||||
|
||||
---
|
||||
|
||||
## doc comment update(更新评论)
|
||||
|
||||
```text
|
||||
Usage:
|
||||
dws doc comment update [flags]
|
||||
Example:
|
||||
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正"
|
||||
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "请确认" --mention uid1,uid2
|
||||
Flags:
|
||||
--node string 目标文档 ID 或 URL (必填)
|
||||
--comment-key string 待更新评论的 commentKey (必填)
|
||||
--content string 更新后的评论内容 (必填)
|
||||
--mention string 被 @ 的用户 uid 列表,逗号分隔
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## doc comment delete(删除评论)
|
||||
|
||||
```text
|
||||
Usage:
|
||||
dws doc comment delete [flags]
|
||||
Example:
|
||||
dws doc comment delete --node <DOC_ID> --comment-key <COMMENT_KEY> --yes
|
||||
Flags:
|
||||
--node string 目标文档 ID 或 URL (必填)
|
||||
--comment-key string 待删除评论的 commentKey (必填)
|
||||
```
|
||||
|
||||
删除不可恢复,必须先获得用户明确确认,再传全局 `--yes`。
|
||||
|
||||
---
|
||||
|
||||
## doc comment create-inline(创建划词评论)
|
||||
|
||||
```
|
||||
@@ -83,7 +116,7 @@ Flags:
|
||||
## 关键说明
|
||||
|
||||
- `--mention` 接受 `userId` 列表(逗号分隔),需要先用 `dws contact user search --query "<姓名>"` 拿到 userId。
|
||||
- `--comment-key` 是 13 位毫秒时间戳 + 32 位 UUID 的拼接字符串,从 `list` / `create` / `create-inline` 返回中提取。
|
||||
- `--comment-key` 是评论唯一标识,从 `list` / `create` / `create-inline` 返回中提取,可用于 `reply` / `update` / `delete`。
|
||||
- 划词评论的 `--start` / `--end` 是块内文本字符偏移量,从 0 开始;通过 [`./doc-block.md`](./doc-block.md) `block list` 取 `paragraph.text` 后人工或脚本计算。
|
||||
- `reply` 加 `--emoji` 时 `--content` 填表情名称(如 `比心`、`赞`),不是文字内容。
|
||||
|
||||
@@ -91,9 +124,9 @@ Flags:
|
||||
|
||||
| 从返回中提取 | 用于 |
|
||||
|-------------|------|
|
||||
| `commentList[].commentKey` | `comment reply` 的 `--comment-key` |
|
||||
| `comment create` `commentKey` | `comment reply` 的 `--comment-key` |
|
||||
| `comment create-inline` `commentKey` | `comment reply` 的 `--comment-key` |
|
||||
| `commentList[].commentKey` | `comment reply/update/delete` 的 `--comment-key` |
|
||||
| `comment create` `commentKey` | `comment reply/update/delete` 的 `--comment-key` |
|
||||
| `comment create-inline` `commentKey` | `comment reply/update/delete` 的 `--comment-key` |
|
||||
| [`./doc-block.md`](./doc-block.md) `block list` 的 `blocks[].element.id` | `comment create-inline` 的 `--block-id` |
|
||||
| [`./doc-block.md`](./doc-block.md) `block list` 的 `blocks[].element.paragraph.text` | 计算 `create-inline` 的 `--start` / `--end` 偏移量 |
|
||||
| `dws contact user search` 的 `userId` | `comment create/reply/create-inline` 的 `--mention` |
|
||||
@@ -121,6 +154,12 @@ dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已
|
||||
# 表情回复(--content 填表情名称)
|
||||
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji --format json
|
||||
|
||||
# 更新评论(可选 --mention)
|
||||
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已修正" --format json
|
||||
|
||||
# 删除评论(危险操作,需用户先明确确认)
|
||||
dws doc comment delete --node <DOC_ID> --comment-key <COMMENT_KEY> --yes --format json
|
||||
|
||||
# 划词评论(先 block list 取 blockId + paragraph.text,计算 start/end)
|
||||
dws doc block list --node <DOC_ID> --format json
|
||||
# 计算偏移后:
|
||||
|
||||
@@ -15,6 +15,8 @@ dws drive list --help
|
||||
dws drive search --help
|
||||
dws drive upload --help
|
||||
dws drive download --help
|
||||
dws drive stats --help
|
||||
dws drive shortcut --help
|
||||
```
|
||||
|
||||
规则:
|
||||
@@ -156,6 +158,28 @@ Flags:
|
||||
--space-id string 节点所属空间 ID (可选)
|
||||
```
|
||||
|
||||
### 获取节点统计信息
|
||||
|
||||
```text
|
||||
Usage:
|
||||
dws drive stats --node <NODE_ID_OR_URL>
|
||||
```
|
||||
|
||||
返回节点可用的阅读、编辑、评论、点赞、预览或下载等统计维度;不同文件类型返回字段可能不同。本命令只读。
|
||||
|
||||
### 创建节点快捷方式
|
||||
|
||||
```text
|
||||
Usage:
|
||||
dws drive shortcut --node <SOURCE_NODE> [--folder <TARGET_FOLDER>] [--workspace <WORKSPACE_ID>]
|
||||
Example:
|
||||
dws drive shortcut --node <SOURCE_NODE>
|
||||
dws drive shortcut --node <SOURCE_NODE> --folder <TARGET_FOLDER>
|
||||
dws drive shortcut --node <SOURCE_NODE> --workspace <WORKSPACE_ID>
|
||||
```
|
||||
|
||||
`--folder` 和 `--workspace` 均可省略,此时由服务端选择默认位置。创建后应通过 `drive list` 回读目标位置。
|
||||
|
||||
### 文件内容获取路由规则
|
||||
|
||||
> 当用户请求"分析/查看/读取某个钉盘文件内容"时,**必须先调用 `dws drive info` 获取文件元数据**,再根据返回的 `extension` 字段选择对应链路。
|
||||
@@ -312,6 +336,8 @@ Flags:
|
||||
用户说"钉盘空间/团队文件/有哪些空间/空间列表/团队文件列表" → `wiki space list --type orgSpace`(`drive list-spaces` 已 deprecated)
|
||||
用户说"搜索钉盘文件/钉盘里找个文件/查找某个钉盘文件/钉盘中搜索" → `search`
|
||||
用户说"文件详情/文件信息" → `info`
|
||||
用户说"文件阅读量/编辑量/评论数/下载数/节点统计" → `stats`
|
||||
用户说"给文件创建快捷方式/放一个链接到目标文件夹" → `shortcut`
|
||||
用户说"下载文件" → `download` 指定 `--output` 保存到本地
|
||||
用户说"新建文件夹/创建目录" → `mkdir`(钉盘空间)/ `wiki node create --type folder`(文档空间)
|
||||
用户说"上传文件/传文件到钉盘" → `upload`(首选此命令,自动完成三步流程)
|
||||
@@ -492,7 +518,7 @@ dws drive copy --node <源文件dentryUuid> --folder <目标文件夹fileId> --f
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `list` | **`fileId`**(UUID 格式,注意:不是 `dentryId`) | info / download / delete 的 --node;list / mkdir 的 --folder;`drive copy/move` 的 --node 或 --folder |
|
||||
| `list` | **`fileId`**(UUID 格式,注意:不是 `dentryId`) | info / stats / shortcut / download / delete 的 --node;list / mkdir 的 --folder;`drive copy/move/shortcut` 的 --node 或 --folder |
|
||||
| `list` | `spaceId` | info / download / mkdir / upload 的 --space-id |
|
||||
| `list` | `nextCursor` | 下次 list 的 --cursor |
|
||||
| `list-spaces` / `wiki space list` | `rootFolderId` | `drive copy/move` 的 --folder(复制/移动到钉盘 space 根目录时) |
|
||||
@@ -520,6 +546,7 @@ dws drive copy --node <源文件dentryUuid> --folder <目标文件夹fileId> --f
|
||||
- `--file-name` 必须包含扩展名(如 `report.pdf`)
|
||||
- `download` 需要指定 `--output`,CLI 会把文件保存到本地路径或目录
|
||||
- 文件名规则:头尾不能有空格;不能含 `*`、`"`、`<`、`>`、`|`、制表符;不能以 `.` 结尾
|
||||
- `shortcut` 会创建新节点,执行后必须通过 `drive list` 回读确认目标位置;`stats` 为只读命令
|
||||
|
||||
## 自动化脚本
|
||||
|
||||
|
||||
@@ -1,92 +1,101 @@
|
||||
# dws event — 个人消息事件
|
||||
|
||||
通过个人 Stream 长连接监听当前用户收到的钉钉消息事件,NDJSON 输出到 stdout,用于驱动事件触发的 Agent。实时监听、自动回复、订阅事件都必须使用 `dws event consume`,不要写脚本轮询消息历史。
|
||||
监听当前用户收到的钉钉消息事件,NDJSON 输出到 stdout。实时监听 / 自动回复 / 订阅一律用 `dws event consume`,不要轮询消息历史。
|
||||
|
||||
## 运行方式
|
||||
|
||||
- bus 后台进程持有对钉钉的个人 Stream 长连;consume 从 bus 读事件、按 NDJSON 打到 stdout。consume 只读,不发消息(回复用 `dws chat message send`)。
|
||||
- 没有 bus 时 consume 自动拉起;通常只跑 consume。
|
||||
- 一个组织一个 bus,互不干扰、可同时跑;同组织内多个 consume 共享一个 bus。
|
||||
- 非默认组织加全局 `--profile <corpId 或 profile 名>`;漏传会退回默认 profile 而失败。
|
||||
|
||||
## Core commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `dws event schema <event_key>` | 查看事件参数和输出字段 schema,默认 JSON |
|
||||
| `dws event consume <event_key> [flags]` | 阻塞消费;事件写到 stdout,推荐 `-f ndjson` |
|
||||
| `dws event status --event <event_key>` | 查看个人订阅、personal bus 和本地 consume |
|
||||
| `dws event stop <subscribe_id>` | 取消个人订阅并停止对应本地消费 |
|
||||
| `dws event stop --all` | 清理当前身份下本地记录的全部个人订阅 |
|
||||
| `dws event schema <event_key>` | 查看事件参数和输出字段 schema |
|
||||
| `dws event consume <event_key> [flags]` | 阻塞消费,事件写到 stdout,用 `-f ndjson` |
|
||||
| `dws event status --event <event_key>` | 查看个人订阅、bus、本地 consume |
|
||||
| `dws event stop <subscribe_id>` | 取消订阅并停止对应本地消费 |
|
||||
| `dws event stop --all` | 清理当前身份下全部个人订阅 |
|
||||
|
||||
注意区分两个 schema:`dws event schema <event_key>` 查事件的输出字段;`dws schema "event consume"` 查 consume 命令自身的入参(机读结构,source=cobra,含 parameters + 位置参数)。
|
||||
|
||||
## Event catalog
|
||||
|
||||
| 事件码 | 场景 | 必填参数 |
|
||||
|---|---|---|
|
||||
| `user_im_message_receive_at` | 当前用户被 @ 的消息 | 无 |
|
||||
| `user_im_message_receive_o2o` | 当前用户与指定用户的单聊消息 | `--user` |
|
||||
| `user_im_message_receive_group` | 当前用户所在指定群聊/会话的消息 | `--group` |
|
||||
| `user_im_message_receive_o2o` | 与指定用户的单聊消息 | `--user` |
|
||||
| `user_im_message_receive_group` | 指定群聊 / 会话的消息 | `--group` |
|
||||
|
||||
只承认上表 3 个事件码。默认身份就是当前用户,不要额外加身份切换 flag。
|
||||
只承认这 3 个事件码。默认身份即当前用户,不加身份切换 flag。
|
||||
|
||||
## Intent mapping
|
||||
|
||||
| 用户说 | 下一步 |
|
||||
|---|---|
|
||||
| "监听有人 @ 我的消息" | `event consume`,事件码 `user_im_message_receive_at`,参数 `-f ndjson` |
|
||||
| "监听我和 userId 507971 的单聊消息" | `event consume`,事件码 `user_im_message_receive_o2o`,参数 `--user 507971 -f ndjson` |
|
||||
| "监听 XX 群消息" | 先 `dws chat search --query "XX" --format json`,确认后 consume group |
|
||||
| "监听并自动回复某人的单聊消息" | 先解析对端 userId,再启动 o2o consume;不要写轮询脚本 |
|
||||
| "查看个人消息事件 schema" | `dws event schema <event_key>` |
|
||||
| "看个人事件订阅状态" | `dws event status --event <event_key>` |
|
||||
| "停止这个个人事件订阅" | `dws event stop <subscribe_id>` |
|
||||
| 监听有人 @ 我 | consume `user_im_message_receive_at` |
|
||||
| 监听我和某用户的单聊 | 解析对端 userId,consume `user_im_message_receive_o2o --user <id>` |
|
||||
| 监听某群 | 先 `dws chat search --query "<群名>"` 拿 openConversationId,再 consume group |
|
||||
| 监听并自动回复某人单聊 | 解析对端 userId,启动 o2o consume;回复用 `dws chat message send` |
|
||||
| 查看事件 schema | `dws event schema <event_key>` |
|
||||
| 看订阅状态 | `dws event status --event <event_key>` |
|
||||
| 停止订阅 | `dws event stop <subscribe_id>` |
|
||||
|
||||
多候选必须让用户确认。缺少必填 ID 且无法解析时先追问,不要猜测。
|
||||
多候选让用户确认。缺必填 ID 且解析不出先追问,不要猜。
|
||||
|
||||
## Call flow
|
||||
|
||||
1. 从用户意图选择事件码;人名或群名先解析成必填 ID。
|
||||
2. 需要了解字段时运行 `dws event schema <event_key>`,读取 `jq_root_path` 和 `schema.properties`。
|
||||
3. 启动 `dws event consume <event_key> ... -f ndjson`,等待 stderr 出现 `connected bus pid=...` 后开始读 stdout。
|
||||
4. stdout 每行是一个事件 JSON;业务字段在 `data` JSON 字符串内,按 `jq_root_path` 解析。
|
||||
5. 需要确认监听状态时运行 `dws event status --event <event_key>`,查看 `Subscriptions` 和 `Consumers`。
|
||||
6. 任务完成后用 `dws event stop <subscribe_id>` 取消订阅;临时测试可以在 consume 上加 `--max-events` 或 `--duration`。
|
||||
1. 按意图选事件码;人名 / 群名先解析成必填 ID。
|
||||
2. 需要字段时 `dws event schema <event_key>`,读 `jq_root_path` 和 `schema.properties`。
|
||||
3. 启动 `dws event consume <event_key> ... -f ndjson`,等 stderr 出现 `[event] ready event_key=<key> ...` 再读 stdout,不要 sleep。
|
||||
4. stdout 每行一个事件 JSON;`data` 字段是 JSON 字符串,按 `jq_root_path` 再 parse。
|
||||
5. `dws event status --event <event_key>` 看 Subscriptions / Consumers。
|
||||
6. `dws event stop <subscribe_id>` 取消订阅;自测可加 `--max-events` / `--duration` 自动退出。
|
||||
|
||||
## Common commands
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
dws event schema user_im_message_receive_at
|
||||
dws event schema user_im_message_receive_o2o
|
||||
dws event schema user_im_message_receive_group
|
||||
```
|
||||
|
||||
```bash
|
||||
dws event consume user_im_message_receive_at -f ndjson
|
||||
```
|
||||
dws event consume user_im_message_receive_o2o --user 507971 -f ndjson
|
||||
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
|
||||
|
||||
```bash
|
||||
dws event consume user_im_message_receive_o2o \
|
||||
--user 507971 \
|
||||
-f ndjson
|
||||
```
|
||||
|
||||
```bash
|
||||
dws event consume user_im_message_receive_group \
|
||||
--group <openConversationId> \
|
||||
-f ndjson
|
||||
```
|
||||
|
||||
```bash
|
||||
dws event status --event user_im_message_receive_at
|
||||
dws event status --event user_im_message_receive_o2o
|
||||
dws event status --event user_im_message_receive_group
|
||||
dws event stop <subscribe_id>
|
||||
dws event stop --all
|
||||
```
|
||||
|
||||
`status` 的 `Consumers` 表展示本地 consume 的 PID、事件码、`subscribe_id` 和 received/dropped 计数,可用于确认监听是否仍在 personal bus 上。
|
||||
## Subprocess contract
|
||||
|
||||
裸 `dws event stop` 不会取消订阅;批量清理必须显式使用 `dws event stop --all`。Ctrl+C、`--duration`、`--max-events` 只结束本地前台消费进程,不等价于取消服务端订阅。
|
||||
- 就绪:连上后 stderr 打 `[event] ready event_key=<key> bus_pid=<pid>`,父进程等这行再读 stdout。不要 `--quiet`(会抑制它)。
|
||||
- 退出:末行 `[event] exited — received N event(s) in Xs (reason: limit|timeout|signal|bus_shutdown)`;受控退出码 0,失败非 0 且无 exited 行、有 Error 行。
|
||||
- stdin 关闭 = 停机:仅当 stdin 是管道且未设 `--max-events/--duration` 时生效;交互终端和 `< /dev/null` 不触发。用管道 stdin 又要常驻就喂 `< <(tail -f /dev/null)`。
|
||||
- 订阅清理:本次新建的订阅任意退出即自动退订;`--subscribe-id` 复用的保留;`--ephemeral` 强制退订。优雅停用 SIGTERM、关 stdin,或外部 `dws event stop <subscribe_id>`。不要 `kill -9`(跳过退订、泄漏服务端订阅)。
|
||||
- 一 consume 一 event_key;监听 N 个就起 N 个 consume,共用一个 bus。
|
||||
|
||||
## Output parsing
|
||||
|
||||
- 推荐 `-f ndjson`:一行一个事件 JSON,适合 Agent 管道读取。
|
||||
- 人工取样可用 `-f json --max-events 1`。
|
||||
- `data` 是服务端业务 payload 的 JSON 字符串;读取消息内容前按 schema 的 `jq_root_path` 再解析一次。
|
||||
- 当前消息正文在 `payload.body.content`,发送人展示名在 `payload.body.sender`,会话 ID 在 `payload.body.openConversationId`。
|
||||
- `--debug-raw-events` 仅用于服务端联调,正常消费不要使用。
|
||||
- 用 `-f ndjson`,一行一个事件 JSON。抓样本用 `-f json --max-events 1`。
|
||||
- 两层解析:外层事件 JSON 的 `data` 字段是 JSON 字符串,`fromjson` 后取 `payload.body.content`(正文)/ `payload.body.sender`(发送人)/ `payload.body.openConversationId`(会话)。样例:
|
||||
|
||||
```json
|
||||
{"type":"event","event_type":"user_im_message_receive_group",
|
||||
"data":"{\"payload\":{\"body\":{\"sender\":\"张三\",\"content\":\"你好\",\"openConversationId\":\"cid...==\"}},\"subject\":{\"isSelfLoop\":false}}"}
|
||||
```
|
||||
|
||||
- 自己发的消息不作为事件回来(`isSelfLoop` 过滤):边听边回不成环;自发验证会看到 0 事件,测投递用别人 / 机器人发。
|
||||
- `--jq <表达式>` 把过滤 / 投影下推到 consume,减少输出。
|
||||
- `--debug-raw-events` 仅联调用。
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- consume 报 bus 启动失败:报错已带子进程真实原因。多为登录问题,`dws --profile <x> auth status` 看登录态(非默认组织带对 `--profile`),过期就 `auth login` 重登。
|
||||
- 本地日志:`~/.dws/events/<edition>/personal_stream/<hash>/bus.log`(`edition` 一般 `open`,`hash` 见 `dws event status` 的 Workdir);极早期失败可能无日志,以 consume 报错为准。
|
||||
- 有残留 / 连不上:`dws event status` 查 stale,`dws event stop --all` 清理重试。
|
||||
- 挂住无输出:多是误加 `--foreground`(跑 bus、不打印事件),去掉。
|
||||
|
||||
## Full reference
|
||||
|
||||
|
||||
@@ -81,6 +81,8 @@ dws sheet filter-view --help
|
||||
| `sheet new` | 新建工作表 |
|
||||
| `sheet update` | 更新工作表属性(标题/位置/隐藏/冻结) |
|
||||
| `sheet copy` | 复制工作表 |
|
||||
| `sheet show-gridline` | 显示工作表网格线 |
|
||||
| `sheet hide-gridline` | 隐藏工作表网格线 |
|
||||
| `sheet range read` | 读取工作表数据(别名: range get) |
|
||||
| `sheet range update` | 更新指定区域内容(值/公式/超链接) |
|
||||
| `sheet range clear` | 清除区域(值/格式/全部) |
|
||||
@@ -91,11 +93,17 @@ dws sheet filter-view --help
|
||||
| `sheet range batch-clear` | 批量清除多个区域(原子事务) |
|
||||
| `sheet batch-update` | 批量执行多个写操作(原子事务) |
|
||||
| `sheet csv-get` | 以 CSV 格式读取区域数据 |
|
||||
| `sheet table-get` | 读取结构化 table 数据(别名: table-read) |
|
||||
| `sheet range set-style` | 设置单元格样式 |
|
||||
| `sheet range batch-set-style` | 按配置文件批量设置样式 |
|
||||
| `sheet find` | 搜索单元格内容 |
|
||||
| `sheet append` | 在末尾追加数据行 |
|
||||
| `sheet csv-put` | 将 CSV 数据写入指定位置(纯值,自动扩容) |
|
||||
| `sheet table-put` | 写入一个或多个结构化 table(别名: table-write) |
|
||||
| `sheet pivot-table list` | 列出透视表或获取指定透视表详情 |
|
||||
| `sheet pivot-table create` | 创建原生透视表 |
|
||||
| `sheet pivot-table update` | 更新透视表配置 |
|
||||
| `sheet pivot-table delete` | 删除透视表(不可逆,删除前必须确认) |
|
||||
| `sheet delete-sheet` | 删除工作表(不可逆,删除前必须确认) |
|
||||
| `sheet replace` | 全局查找替换文本 |
|
||||
| `sheet merge-cells` | 合并单元格 |
|
||||
@@ -153,9 +161,10 @@ dws sheet filter-view --help
|
||||
|
||||
| 主题 | 子文档 | 覆盖命令 |
|
||||
|------|--------|---------|
|
||||
| 表格与工作表管理 | [sheet/sheet-workbook.md](./sheet/sheet-workbook.md) | create / list / info / new / update / copy / delete-sheet |
|
||||
| 写入数据 | [sheet/sheet-write-data.md](./sheet/sheet-write-data.md) | range update(对象协议详解)/ append / csv-put |
|
||||
| 读取数据 | [sheet/sheet-read-data.md](./sheet/sheet-read-data.md) | range read / csv-get |
|
||||
| 表格与工作表管理 | [sheet/sheet-workbook.md](./sheet/sheet-workbook.md) | create / list / info / new / update / copy / show-gridline / hide-gridline / delete-sheet |
|
||||
| 透视表 | [sheet/sheet-pivot-table.md](./sheet/sheet-pivot-table.md) | pivot-table list / create / update / delete |
|
||||
| 写入数据 | [sheet/sheet-write-data.md](./sheet/sheet-write-data.md) | table-put / range update(对象协议详解)/ append / csv-put |
|
||||
| 读取数据 | [sheet/sheet-read-data.md](./sheet/sheet-read-data.md) | table-get / range read / csv-get |
|
||||
| 区域操作 | [sheet/sheet-range-operations.md](./sheet/sheet-range-operations.md) | range clear / sort / fill / copy-to / move-to |
|
||||
| 批量操作 | [sheet/sheet-batch-operations.md](./sheet/sheet-batch-operations.md) | range batch-clear / batch-update |
|
||||
| 行列操作 | [sheet/sheet-dimension-operations.md](./sheet/sheet-dimension-operations.md) | insert / delete / update / move / add-dimension / group-dimension / ungroup-dimension |
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# 透视表管理
|
||||
|
||||
## 使用场景
|
||||
|
||||
- 用户要按部门、月份等维度汇总数据 → `pivot-table create`
|
||||
- 用户要查看现有透视表或取得 `pivotTableId` → `pivot-table list`
|
||||
- 用户要调整行列、聚合方式或显示选项 → `pivot-table update`
|
||||
- 用户明确要求删除透视表 → `pivot-table delete`(不可逆,必须先确认)
|
||||
|
||||
透视表是原生对象,会随源数据更新;不要用一组普通汇总公式冒充透视表。
|
||||
|
||||
## 列出与查看
|
||||
|
||||
```bash
|
||||
dws sheet pivot-table list --node <NODE_ID> --sheet-id <SHEET_ID>
|
||||
dws sheet pivot-table list --node <NODE_ID> --sheet-id <SHEET_ID> --pivot-table-id <PIVOT_TABLE_ID>
|
||||
```
|
||||
|
||||
不传 `--pivot-table-id` 时列出工作表中的全部透视表;传入时返回单个透视表详情。
|
||||
|
||||
## 创建
|
||||
|
||||
```bash
|
||||
dws sheet pivot-table create --node <NODE_ID> \
|
||||
--source "'Data'!A1:D100" \
|
||||
--properties '{
|
||||
"rows":[{"field":"部门"}],
|
||||
"columns":[{"field":"月份"}],
|
||||
"values":[{"field":"销售额","summarize_by":"sum"}],
|
||||
"show_row_grand_total":true
|
||||
}'
|
||||
```
|
||||
|
||||
可选参数:
|
||||
|
||||
- `--target-sheet-id`:将透视表放到指定工作表;不传时由服务端创建目标工作表。
|
||||
- `--target-position`:目标起始单元格,例如 `B2`。
|
||||
- `--properties @pivot.json`:从文件读取配置。
|
||||
|
||||
`--source` 必须包含工作表前缀并覆盖表头和数据,例如 `"'Data'!A1:D100"`。创建时 `properties.values` 必须是非空数组。
|
||||
|
||||
## properties 结构
|
||||
|
||||
- `rows` / `columns` / `filters`:字段数组,每项至少包含非空 `field`。
|
||||
- `values`:聚合字段数组,每项至少包含 `field`;创建时至少一项。
|
||||
- `summarize_by`:`sum`、`count`、`average`、`max`、`min`、`product`、`count_numbers`、`std_dev`、`std_dev_p`、`var`、`var_p`、`distinct`、`median`。
|
||||
- `collapse`:字段折叠配置,可为对象或数组。
|
||||
- 显示选项:`show_row_grand_total`、`show_col_grand_total`、`show_subtotals`、`repeat_row_labels`。
|
||||
|
||||
## 更新
|
||||
|
||||
```bash
|
||||
dws sheet pivot-table update --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--pivot-table-id <PIVOT_TABLE_ID> \
|
||||
--properties '{"show_subtotals":false}'
|
||||
```
|
||||
|
||||
Open 接口支持部分更新,因此更新显示选项时不需要重复传入 `values`。更新行列或聚合字段时,应先用 `list --pivot-table-id` 读取当前配置,再传入需要变更的字段。
|
||||
|
||||
## 删除
|
||||
|
||||
```bash
|
||||
dws sheet pivot-table delete --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--pivot-table-id <PIVOT_TABLE_ID> --yes
|
||||
```
|
||||
|
||||
删除不可恢复。Agent 必须先展示目标文档、工作表和 `pivotTableId`,获得用户明确同意后才能附加 `--yes`。
|
||||
@@ -3,6 +3,7 @@
|
||||
## 使用场景
|
||||
|
||||
用户说"读数据/看表格内容":
|
||||
- 需要列名、二维数据、dtype 和 number format,或准备与 `table-put` 配套处理 → `table-get`
|
||||
- 快速查看纯值数据、批量处理、大表分批读 → `csv-get`(token 消耗低,防爆保护)
|
||||
- 需要结构化信息(值+样式+数据验证+富文本+单元格级超链接)、查看公式或原始值 → `range read`
|
||||
- 需要查看合并单元格 / 表头合并结构 → `sheet info`,读取返回的 `mergedRanges`;不要在 `csv-get` 或 `range read` 里找合并信息
|
||||
@@ -11,6 +12,7 @@
|
||||
|
||||
| 读取目的 | 推荐命令 | 说明 |
|
||||
|---------|---------|------|
|
||||
| DataFrame 风格结构化读取 | `table-get` | 返回 columns / data / dtypes / formats,可与 table-put 配套 |
|
||||
| 快速查看纯值、数据分析、大表分批读取 | `csv-get` | CSV 格式,token 消耗约为 JSON 的 1/3,内置 maxChars 防爆 |
|
||||
| 查看数据验证配置(下拉/复选框) | `range read` | 返回 per-cell 结构,含 dataValidation |
|
||||
| 查看单元格样式(背景色/字体/对齐等) | `range read` | 返回 per-cell 结构,含 cellStyles(仅显式设置的样式) |
|
||||
@@ -21,6 +23,23 @@
|
||||
|
||||
## 命令详细参考
|
||||
|
||||
### 读取结构化 table 数据
|
||||
```
|
||||
Usage:
|
||||
dws sheet table-get [flags] # 别名: dws sheet table-read
|
||||
Example:
|
||||
dws sheet table-get --node <NODE_ID>
|
||||
dws sheet table-get --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D20"
|
||||
dws sheet table-get --node <NODE_ID> --sheet-id <SHEET_ID> --no-header
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称
|
||||
--range string 读取范围,A1 表示法;可带 sheet 前缀
|
||||
--no-header 首行不作为表头,自动生成 col1/col2/...
|
||||
```
|
||||
|
||||
`table-get` 面向结构化数据处理,返回 `columns`、二维 `data`、`dtypes` 和可用时的 `formats`。未传 `--sheet-id` / `--range` 时由服务端读取目标文档中的 table 数据;大表仍应通过 `--range` 限定范围。需要单元格样式、公式、数据验证或富文本细节时,继续使用 `range read`。
|
||||
|
||||
### 以 CSV 格式读取工作表数据(推荐)
|
||||
```
|
||||
Usage:
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
用户说"删除工作表/移除工作表/删掉这个Sheet":
|
||||
- 删除工作表 → `delete-sheet`(不可逆操作,执行前必须向用户确认)
|
||||
|
||||
用户说"显示网格线/隐藏网格线/去掉单元格网格":
|
||||
- 显示网格线 → `show-gridline`
|
||||
- 隐藏网格线 → `hide-gridline`
|
||||
|
||||
## 命令详细参考
|
||||
|
||||
### 创建钉钉表格文档
|
||||
@@ -165,6 +169,18 @@ Flags:
|
||||
- 不能删除隐藏的工作表(需先通过 `sheet update --hidden false` 取消隐藏再删除)
|
||||
- 不能删除最后一个可见工作表(至少保留一个可见工作表)
|
||||
|
||||
### 显示或隐藏网格线
|
||||
```
|
||||
Usage:
|
||||
dws sheet show-gridline --node <NODE_ID> --sheet-id <SHEET_ID>
|
||||
dws sheet hide-gridline --node <NODE_ID> --sheet-id <SHEET_ID>
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
```
|
||||
|
||||
这两个命令只修改指定工作表的网格线显示状态,不修改单元格内容或边框样式。执行后可用同一命令反向恢复。
|
||||
|
||||
## 核心工作流
|
||||
|
||||
```bash
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
## 使用场景
|
||||
|
||||
用户说"写数据/填表/更新单元格/写入公式":
|
||||
- 从 DataFrame/结构化对象一次写入一个或多个工作表 → `table-put`
|
||||
- 更新数据 → `range update`
|
||||
- 【强制】`--sheet-id` 必填:即使是单工作表也不能省略,不要参照 `range read` 的默认行为;未知时先执行 `dws sheet list --node <NODE_ID> --format json` 获取 `sheetId`,禁止凭空臆测为 `Sheet1`、`sheet1`、`0`、`default` 等
|
||||
- 注意:如果用户的目的是替换文本、移动行列、追加空行空列、清空区域、排序、填充、复制区域或移动区域,请勿使用 `range update`,必须使用对应的专用命令(`replace`/`move-dimension`/`add-dimension`/`range clear`/`range sort`/`range fill`/`range copy-to`/`range move-to`)
|
||||
@@ -16,23 +17,38 @@
|
||||
- 与 `range update` 的区别:`csv-put` 接受 CSV 文本直接写入,无需手动构造二维 JSON 数组;适合大批量纯值写入
|
||||
- 与 `append` 的区别:`csv-put` 写入指定位置(--start-cell),`append` 在末尾追加
|
||||
|
||||
**三种写入命令能力对比**:
|
||||
**四种写入命令能力对比**:
|
||||
|
||||
| 能力 | `range update` | `append` | `csv-put` |
|
||||
|------|---------------|----------|-----------|
|
||||
| 公式(`=` 开头) | 支持 | 不支持 | 不支持(当文本) |
|
||||
| 单元格级超链接(`hyperlink`) | 支持 | 不支持 | 不支持 |
|
||||
| 富文本(片段链接/附件/图片) | 支持 | 不支持 | 不支持 |
|
||||
| richText 片段样式(bold/color) | 支持 | 不支持 | 不支持 |
|
||||
| `cellStyles`(背景色/字号/对齐等 cell-level 样式) | 支持 | 不支持 | 不支持 |
|
||||
| `{}` 跳过(保留原值) | 支持 | 不适用 | 不适用 |
|
||||
| `dataValidation`(下拉/复选框) | 支持 | 不支持 | 不支持 |
|
||||
| 原始值(纯数字/字符串) | 支持 | 支持 | 支持 |
|
||||
| 自动定位末尾 | 不支持 | 支持 | 不支持 |
|
||||
| 自动扩容行列 | 不支持 | 支持 | 支持 |
|
||||
| 能力 | `table-put` | `range update` | `append` | `csv-put` |
|
||||
|------|-------------|----------------|----------|-----------|
|
||||
| 多工作表结构化写入 | 支持 | 不支持 | 不支持 | 不支持 |
|
||||
| columns / dtypes / formats | 支持 | 不支持 | 不支持 | 不支持 |
|
||||
| 公式(`=` 开头) | 按 dtype/底层能力 | 支持 | 不支持 | 不支持(当文本) |
|
||||
| 单元格级超链接(`hyperlink`) | 不使用此命令 | 支持 | 不支持 | 不支持 |
|
||||
| 富文本(片段链接/附件/图片) | 不使用此命令 | 支持 | 不支持 | 不支持 |
|
||||
| 原始值(纯数字/字符串) | 支持 | 支持 | 支持 | 支持 |
|
||||
| 自动定位末尾 | mode=append | 不支持 | 支持 | 不支持 |
|
||||
| 自动扩容行列 | 支持 | 不支持 | 支持 | 支持 |
|
||||
|
||||
## 命令详细参考
|
||||
|
||||
### 写入结构化 table 数据
|
||||
```
|
||||
Usage:
|
||||
dws sheet table-put [flags] # 别名: dws sheet table-write
|
||||
Example:
|
||||
dws sheet table-put --node <NODE_ID> \
|
||||
--sheets '[{"name":"Data","columns":["name","score"],"data":[["Alice",95]],"dtypes":{"score":"float64"}}]'
|
||||
|
||||
dws sheet table-put --node <NODE_ID> --sheets @table.json
|
||||
cat table.json | dws sheet table-put --node <NODE_ID> --sheets -
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheets string sheet table JSON、@文件路径 或 - 表示 stdin (必填)
|
||||
```
|
||||
|
||||
`--sheets` 接受 JSON 数组、`{"sheets":[...]}` 包装对象或单个 sheet 对象,CLI 会统一转换为非空数组。每个 sheet 至少需要 `columns`,并提供 `sheetId` 或 `name`;`name` 必须非空且少于 31 个字符。写入后必须用 `table-get` 回读验证。`table-get/table-put` 不支持放入 `sheet batch-update`。
|
||||
|
||||
### 更新工作表指定区域内容
|
||||
```
|
||||
Usage:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dingtalk-chat
|
||||
description: 钉钉群聊与消息。Use when 用户提到 发消息/单聊/群聊/建群/拉人进群/改群名/搜索群/群成员管理/@消息/撤回消息/机器人群发/Webhook通知/发图片或文件到群。Distinct from dingtalk-ding(紧急DING消息/短信/电话)、dingtalk-mail(邮件)、dingtalk-edu-group(班级群)。命令前缀:dws chat。
|
||||
description: 钉钉群聊与消息。Use when 用户提到 发消息/单聊/群聊/建群/拉人进群/改群名/搜索群/群成员管理/@消息/收藏消息/撤回消息/机器人群发/Webhook通知/发图片或文件到群。Distinct from dingtalk-ding(紧急DING消息/短信/电话)、dingtalk-mail(邮件)、dingtalk-edu-group(班级群)。命令前缀:dws chat。
|
||||
cli_version: ">=0.2.14"
|
||||
metadata:
|
||||
category: product
|
||||
@@ -33,6 +33,8 @@ metadata:
|
||||
| "改群名" / "踢人" | `dws chat group rename` / `dws chat group members remove --yes`(踢人不可逆,确认目标后加 --yes;踢群主会被 CLI 拦截,需先 `transfer-owner`)|
|
||||
| "@我消息" | `dws chat message list-mentions` |
|
||||
| "查群聊记录" | `dws chat message list` |
|
||||
| "收藏/取消收藏这条消息" | `dws chat message add-favorite` / `dws chat message remove-favorite`(均需 `openMessageId` 和 `openConversationId`)|
|
||||
| "查看我收藏的消息" | `dws chat message list-favorites`(默认 `--cursor 0 --size 20`)|
|
||||
| "用机器人发消息" | `dws chat message send-by-bot --robot-code <code> --group <id> --title "<标题>" --text "<内容>"` |
|
||||
| "Webhook 推一条" | `dws chat message send-by-webhook --token <token> --title "<标题>" --text "<内容>"` |
|
||||
| "撤回我发的消息" | `dws chat message recall`(撤回当前用户发送的消息)|
|
||||
|
||||
@@ -13,3 +13,4 @@
|
||||
| forward-message | 1. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message list --group <openConversationId> --time "<起始时间>"` 拉源消息<br>2. `contact user search --query "<姓名>"` → 取 `openDingTalkId`(推荐);或 `chat search --query "<群名>"` → 取目标 `openConversationId`<br>3. `chat message send --open-dingtalk-id <openDingTalkId> --text "<内容>"`(推荐)或 `--group <openConversationId> --text "<内容>"` 发送。仅当无法获取 openDingTalkId 时才用 `--user <userId>`(备选) |
|
||||
| search-common-group | `chat search-common --nicks "<昵称1>,<昵称2>" --limit 20 --cursor 0`(`--match-mode AND`=全在/`OR`=任一在,翻页:`hasMore=true` 时用 `nextCursor`)<br>用户说"我和XX的共同群" → nicks 包含"我"时,需先 `contact user get-self` 取自己昵称再拼接 |
|
||||
| focus-messages | **零参数一行命令**:`chat message list-focused --limit 50`(拉特别关注人发的消息聚合)<br>触发 query:`"我特别关注的人最近发了什么消息"`、`"关注的人最近聊了啥"`、`"星标联系人最近的动态"`<br>**强消歧**:query 含动词【发/说/聊/讲】或名词【消息/聊天/动态】 → **必须**走本命令,**不要**先去拉 `contact relation list-my-followings`;仅当用户终点是"人员列表"(如"我关注了谁")才走 `relation list-my-followings`(详见 `dingtalk-contact/references/contact.md` 意图判断)<br>翻页:`hasMore=true` 时用 `nextCursor` 作为下次 `--cursor`<br>按人精控(可选):先 `contact relation list-my-followings` 取 `openDingTalkId`,再 `chat message list-by-sender --sender-open-dingtalk-id <openDingTalkId> --start <ISO> --end <ISO>` |
|
||||
| favorite-message | 收藏:先从消息查询结果取得 `openMessageId` 和 `openConversationId`,再执行 `chat message add-favorite --open-message-id <id> --open-conversation-id <cid>`<br>取消收藏:对应执行 `remove-favorite`,不会删除原消息<br>查询收藏:`chat message list-favorites [--cursor N] [--size 20]`,`hasMore=true` 时继续传 `nextCursor` |
|
||||
|
||||
@@ -1282,6 +1282,45 @@ Flags:
|
||||
- 分页: hasMore=true 时,用返回的 nextCursor 作为下次 --cursor 继续翻页
|
||||
```
|
||||
|
||||
#### 收藏消息 — 为当前用户收藏指定会话中的一条消息
|
||||
```
|
||||
Usage:
|
||||
dws chat message add-favorite [flags]
|
||||
Example:
|
||||
dws chat message add-favorite --open-message-id <openMessageId> --open-conversation-id <openConversationId>
|
||||
Flags:
|
||||
--open-message-id string 消息 openMessageId (必填)
|
||||
--open-conversation-id string 消息所在会话 openConversationId (必填)
|
||||
```
|
||||
|
||||
#### 取消收藏消息 — 移除当前用户的收藏标记,不删除原消息
|
||||
```
|
||||
Usage:
|
||||
dws chat message remove-favorite [flags]
|
||||
Example:
|
||||
dws chat message remove-favorite --open-message-id <openMessageId> --open-conversation-id <openConversationId>
|
||||
Flags:
|
||||
--open-message-id string 消息 openMessageId (必填)
|
||||
--open-conversation-id string 消息所在会话 openConversationId (必填)
|
||||
```
|
||||
|
||||
#### 查询收藏消息 — 分页查询当前用户收藏的消息
|
||||
```
|
||||
Usage:
|
||||
dws chat message list-favorites [flags]
|
||||
Example:
|
||||
dws chat message list-favorites
|
||||
dws chat message list-favorites --size 50
|
||||
dws chat message list-favorites --cursor 20 --size 20
|
||||
Flags:
|
||||
--cursor int 数字分页游标,默认 0;翻页时传上次返回的 nextCursor
|
||||
--size int 一次拉取的收藏数量,默认 20,范围 1-100
|
||||
|
||||
注意:
|
||||
- 首次请求可省略分页参数,CLI 会自动向 Open 服务传入 cursor=0、size="20"
|
||||
- hasMore=true 时,将 nextCursor 作为下一次的 --cursor
|
||||
```
|
||||
|
||||
### bot (机器人管理)
|
||||
|
||||
#### 搜索【我创建的】机器人 — 仅返回当前用户自己创建的机器人
|
||||
|
||||
@@ -47,6 +47,8 @@ metadata:
|
||||
| "读文档内容" | `dws doc read --node <nodeId>` |
|
||||
| "更新文档内容 / 分块追加" | `dws doc update --node <nodeId> --content "<分块>" --mode append` |
|
||||
| "删除块" | `dws doc block delete`(需用户确认) |
|
||||
| "更新文档评论" | `dws doc comment update --node <nodeId> --comment-key <key> --content "<内容>"` |
|
||||
| "删除文档评论" | `dws doc comment delete --node <nodeId> --comment-key <key> --yes`(需用户确认) |
|
||||
|
||||
## 评测/多步文档短路径
|
||||
|
||||
@@ -60,7 +62,7 @@ metadata:
|
||||
|
||||
## 危险操作
|
||||
|
||||
`block delete` 不可逆,必须确认再加 `--yes`。
|
||||
`block delete` 和 `comment delete` 不可逆,必须确认再加 `--yes`。
|
||||
|
||||
## 跨产品协作
|
||||
|
||||
|
||||
@@ -338,6 +338,22 @@ Flags:
|
||||
--mention string 被 @ 的用户 uid 列表,逗号分隔
|
||||
```
|
||||
|
||||
### 更新文档评论
|
||||
```text
|
||||
Usage:
|
||||
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content <CONTENT> [--mention uid1,uid2]
|
||||
```
|
||||
|
||||
`commentKey` 从 `comment list/create/create-inline` 返回中获取。只有显式传入 `--mention` 时才更新被 @ 用户列表。
|
||||
|
||||
### 删除文档评论
|
||||
```text
|
||||
Usage:
|
||||
dws doc comment delete --node <DOC_ID> --comment-key <COMMENT_KEY> --yes
|
||||
```
|
||||
|
||||
删除评论不可恢复,必须先获得用户明确确认,再添加全局 `--yes`。
|
||||
|
||||
### 文件内容获取路由规则
|
||||
|
||||
> 当用户请求"分析/查看/读取某个文件内容"时,**必须先调用 `dws doc info` 获取文件元数据**,再根据返回的 `contentType` 和 `extension` 字段选择对应链路:
|
||||
@@ -893,18 +909,24 @@ dws doc comment create --node <DOC_ID> --content "这里需要补充数据来源
|
||||
# 再将 userId 传入 --mention
|
||||
dws doc comment create --node <DOC_ID> --content "请确认这部分内容" --mention <userId1>,<userId2> --format json
|
||||
|
||||
# 4. 回复某条评论(commentKey 从 list 或 create 返回中获取)
|
||||
# 4. 更新某条评论(可选 --mention)
|
||||
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正" --format json
|
||||
|
||||
# 5. 删除某条评论(不可恢复,必须先确认)
|
||||
dws doc comment delete --node <DOC_ID> --comment-key <COMMENT_KEY> --yes --format json
|
||||
|
||||
# 6. 回复某条评论(commentKey 从 list 或 create 返回中获取)
|
||||
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已修改" --format json
|
||||
|
||||
# 5. 用表情回复评论
|
||||
# 7. 用表情回复评论
|
||||
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji --format json
|
||||
|
||||
# 6. 创建划词评论(针对文档中某段选中文本)
|
||||
# 8. 创建划词评论(针对文档中某段选中文本)
|
||||
# 先获取块列表: dws doc block list --node <DOC_ID> --format json → 提取 blockId 和文本内容
|
||||
# 确定选中文本在块内的起始偏移量 (start) 和结束偏移量 (end)
|
||||
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 0 --end 10 --content "这里需要修改" --format json
|
||||
|
||||
# 7. 创建划词评论并附带引用原文 + @ 相关人
|
||||
# 9. 创建划词评论并附带引用原文 + @ 相关人
|
||||
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 5 --end 20 --content "请确认这部分" --selected-text "被选中的原文内容" --mention <userId1>,<userId2> --format json
|
||||
|
||||
# ── 工作流 10: 导出在线文档为 docx ──
|
||||
@@ -926,10 +948,10 @@ dws doc export --node <DOC_ID_OR_URL> --output ./exported.docx
|
||||
| `import` | `nodeId` / `documentUrl` / `documentName` / `documentType`;中断时提取 `taskId` | 后续 read / info / sheet 操作;中断后用 `doc import get --task-id` |
|
||||
| `drive mkdir`(原 `doc folder create`,已弃用) | `nodeId` | create 的 --folder |
|
||||
| `block list` | `blockId` | block insert 的 --ref-block, block update/delete 的 --block-id |
|
||||
| `comment list` | `commentList[].commentKey` | comment reply 的 --comment-key |
|
||||
| `comment create` / `comment create-inline` | `commentKey` | comment reply 的 --comment-key |
|
||||
| `comment list` | `commentList[].commentKey` | comment reply/update/delete 的 --comment-key |
|
||||
| `comment create` / `comment create-inline` | `commentKey` | comment reply/update/delete 的 --comment-key |
|
||||
| `block list` | `blockId` + 文本内容 | comment create-inline 的 --block-id 及 --start/--end 计算 |
|
||||
| `contact user search` | `userId` | comment create / create-inline / reply 的 --mention |
|
||||
| `contact user search` | `userId` | comment create / create-inline / reply / update 的 --mention |
|
||||
| `wiki node create`(原 `doc file create`,已弃用) | `nodeId` | 后续 read / update / block 操作的 --node(仅 adoc 支持 read/update,axls/amind 等类型用各自产品的命令) |
|
||||
| `copy` / `move` | 新 `nodeId`(copy)或原 nodeId(move) | 后续 read / info 等的 --node |
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# doc comment(文档评论:list / create / reply / create-inline)
|
||||
# doc comment(文档评论:list / create / reply / update / delete / create-inline)
|
||||
|
||||
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
|
||||
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
|
||||
@@ -61,6 +61,39 @@ Flags:
|
||||
|
||||
---
|
||||
|
||||
## doc comment update(更新评论)
|
||||
|
||||
```text
|
||||
Usage:
|
||||
dws doc comment update [flags]
|
||||
Example:
|
||||
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正"
|
||||
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "请确认" --mention uid1,uid2
|
||||
Flags:
|
||||
--node string 目标文档 ID 或 URL (必填)
|
||||
--comment-key string 待更新评论的 commentKey (必填)
|
||||
--content string 更新后的评论内容 (必填)
|
||||
--mention string 被 @ 的用户 uid 列表,逗号分隔
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## doc comment delete(删除评论)
|
||||
|
||||
```text
|
||||
Usage:
|
||||
dws doc comment delete [flags]
|
||||
Example:
|
||||
dws doc comment delete --node <DOC_ID> --comment-key <COMMENT_KEY> --yes
|
||||
Flags:
|
||||
--node string 目标文档 ID 或 URL (必填)
|
||||
--comment-key string 待删除评论的 commentKey (必填)
|
||||
```
|
||||
|
||||
删除不可恢复,必须先获得用户明确确认,再传全局 `--yes`。
|
||||
|
||||
---
|
||||
|
||||
## doc comment create-inline(创建划词评论)
|
||||
|
||||
```
|
||||
@@ -83,7 +116,7 @@ Flags:
|
||||
## 关键说明
|
||||
|
||||
- `--mention` 接受 `userId` 列表(逗号分隔),需要先用 `dws contact user search --query "<姓名>"` 拿到 userId。
|
||||
- `--comment-key` 是 13 位毫秒时间戳 + 32 位 UUID 的拼接字符串,从 `list` / `create` / `create-inline` 返回中提取。
|
||||
- `--comment-key` 是评论唯一标识,从 `list` / `create` / `create-inline` 返回中提取,可用于 `reply` / `update` / `delete`。
|
||||
- 划词评论的 `--start` / `--end` 是块内文本字符偏移量,从 0 开始;通过 [`./doc-block.md`](./doc-block.md) `block list` 取 `paragraph.text` 后人工或脚本计算。
|
||||
- `reply` 加 `--emoji` 时 `--content` 填表情名称(如 `比心`、`赞`),不是文字内容。
|
||||
|
||||
@@ -91,9 +124,9 @@ Flags:
|
||||
|
||||
| 从返回中提取 | 用于 |
|
||||
|-------------|------|
|
||||
| `commentList[].commentKey` | `comment reply` 的 `--comment-key` |
|
||||
| `comment create` `commentKey` | `comment reply` 的 `--comment-key` |
|
||||
| `comment create-inline` `commentKey` | `comment reply` 的 `--comment-key` |
|
||||
| `commentList[].commentKey` | `comment reply/update/delete` 的 `--comment-key` |
|
||||
| `comment create` `commentKey` | `comment reply/update/delete` 的 `--comment-key` |
|
||||
| `comment create-inline` `commentKey` | `comment reply/update/delete` 的 `--comment-key` |
|
||||
| [`./doc-block.md`](./doc-block.md) `block list` 的 `blocks[].element.id` | `comment create-inline` 的 `--block-id` |
|
||||
| [`./doc-block.md`](./doc-block.md) `block list` 的 `blocks[].element.paragraph.text` | 计算 `create-inline` 的 `--start` / `--end` 偏移量 |
|
||||
| `dws contact user search` 的 `userId` | `comment create/reply/create-inline` 的 `--mention` |
|
||||
@@ -121,6 +154,12 @@ dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已
|
||||
# 表情回复(--content 填表情名称)
|
||||
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji --format json
|
||||
|
||||
# 更新评论(可选 --mention)
|
||||
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已修正" --format json
|
||||
|
||||
# 删除评论(危险操作,需用户先明确确认)
|
||||
dws doc comment delete --node <DOC_ID> --comment-key <COMMENT_KEY> --yes --format json
|
||||
|
||||
# 划词评论(先 block list 取 blockId + paragraph.text,计算 start/end)
|
||||
dws doc block list --node <DOC_ID> --format json
|
||||
# 计算偏移后:
|
||||
|
||||
@@ -33,6 +33,8 @@ metadata:
|
||||
| "最近访问 / 最近编辑的文档" | `dws drive recent` |
|
||||
| "钉盘目录树" | `python scripts/drive_tree_list.py --depth 2` |
|
||||
| "查文件元数据" | `dws drive info --node <fileId>` |
|
||||
| "查阅读/编辑/评论/下载等节点统计" | `dws drive stats --node <fileId>` |
|
||||
| "创建文件快捷方式" | `dws drive shortcut --node <fileId> [--folder <targetFolderId>] [--workspace <workspaceId>]` |
|
||||
| "下载文件" | `dws drive download --node <fileId> --output <path>` |
|
||||
| "上传本地文件" | `dws drive upload --file ./report.pdf [--folder <fileId>]` |
|
||||
| "建文件夹" | `dws drive mkdir --name "<名称>" [--folder <fileId>]` |
|
||||
@@ -49,6 +51,7 @@ metadata:
|
||||
- `rename` 的 `--name` **只传主名,不带扩展名**;服务端会按原扩展名自动补后缀,带了扩展名会变成双扩展名(如 `报告.txt` → `报告.txt.txt`)。
|
||||
- `drive download` 需要 `--output` 指定本地保存路径或目录;不要省略必填输出位置。
|
||||
- 删除、覆盖、移动、公开(publish set/unset)等破坏性操作必须先确认;上传、创建文件夹、下载后要读回或列目录验证。
|
||||
- `shortcut` 会创建新节点,执行后必须用 `drive list` 回读目标位置;`stats` 是只读操作。
|
||||
- 所有 `dws drive` 命令加 `--format json`。
|
||||
|
||||
## 跨产品协作
|
||||
|
||||
@@ -15,6 +15,8 @@ dws drive list --help
|
||||
dws drive search --help
|
||||
dws drive upload --help
|
||||
dws drive download --help
|
||||
dws drive stats --help
|
||||
dws drive shortcut --help
|
||||
```
|
||||
|
||||
规则:
|
||||
@@ -156,6 +158,28 @@ Flags:
|
||||
--space-id string 节点所属空间 ID (可选)
|
||||
```
|
||||
|
||||
### 获取节点统计信息
|
||||
|
||||
```text
|
||||
Usage:
|
||||
dws drive stats --node <NODE_ID_OR_URL>
|
||||
```
|
||||
|
||||
返回节点可用的阅读、编辑、评论、点赞、预览或下载等统计维度;不同文件类型返回字段可能不同。本命令只读。
|
||||
|
||||
### 创建节点快捷方式
|
||||
|
||||
```text
|
||||
Usage:
|
||||
dws drive shortcut --node <SOURCE_NODE> [--folder <TARGET_FOLDER>] [--workspace <WORKSPACE_ID>]
|
||||
Example:
|
||||
dws drive shortcut --node <SOURCE_NODE>
|
||||
dws drive shortcut --node <SOURCE_NODE> --folder <TARGET_FOLDER>
|
||||
dws drive shortcut --node <SOURCE_NODE> --workspace <WORKSPACE_ID>
|
||||
```
|
||||
|
||||
`--folder` 和 `--workspace` 均可省略,此时由服务端选择默认位置。创建后应通过 `drive list` 回读目标位置。
|
||||
|
||||
### 文件内容获取路由规则
|
||||
|
||||
> 当用户请求"分析/查看/读取某个钉盘文件内容"时,**必须先调用 `dws drive info` 获取文件元数据**,再根据返回的 `extension` 字段选择对应链路。
|
||||
@@ -312,6 +336,8 @@ Flags:
|
||||
用户说"钉盘空间/团队文件/有哪些空间/空间列表/团队文件列表" → `wiki space list --type orgSpace`(`drive list-spaces` 已 deprecated)
|
||||
用户说"搜索钉盘文件/钉盘里找个文件/查找某个钉盘文件/钉盘中搜索" → `search`
|
||||
用户说"文件详情/文件信息" → `info`
|
||||
用户说"文件阅读量/编辑量/评论数/下载数/节点统计" → `stats`
|
||||
用户说"给文件创建快捷方式/放一个链接到目标文件夹" → `shortcut`
|
||||
用户说"下载文件" → `download` 指定 `--output` 保存到本地
|
||||
用户说"新建文件夹/创建目录" → `mkdir`(钉盘空间)/ `wiki node create --type folder`(文档空间)
|
||||
用户说"上传文件/传文件到钉盘" → `upload`(首选此命令,自动完成三步流程)
|
||||
@@ -492,7 +518,7 @@ dws drive copy --node <源文件dentryUuid> --folder <目标文件夹fileId> --f
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `list` | **`fileId`**(UUID 格式,注意:不是 `dentryId`) | info / download / delete 的 --node;list / mkdir 的 --folder;`drive copy/move` 的 --node 或 --folder |
|
||||
| `list` | **`fileId`**(UUID 格式,注意:不是 `dentryId`) | info / stats / shortcut / download / delete 的 --node;list / mkdir 的 --folder;`drive copy/move/shortcut` 的 --node 或 --folder |
|
||||
| `list` | `spaceId` | info / download / mkdir / upload 的 --space-id |
|
||||
| `list` | `nextCursor` | 下次 list 的 --cursor |
|
||||
| `list-spaces` / `wiki space list` | `rootFolderId` | `drive copy/move` 的 --folder(复制/移动到钉盘 space 根目录时) |
|
||||
@@ -520,6 +546,7 @@ dws drive copy --node <源文件dentryUuid> --folder <目标文件夹fileId> --f
|
||||
- `--file-name` 必须包含扩展名(如 `report.pdf`)
|
||||
- `download` 需要指定 `--output`,CLI 会把文件保存到本地路径或目录
|
||||
- 文件名规则:头尾不能有空格;不能含 `*`、`"`、`<`、`>`、`|`、制表符;不能以 `.` 结尾
|
||||
- `shortcut` 会创建新节点,执行后必须通过 `drive list` 回读确认目标位置;`stats` 为只读命令
|
||||
|
||||
## 自动化脚本
|
||||
|
||||
|
||||
@@ -7,6 +7,13 @@ description: 钉钉个人消息事件长连接监听、订阅与消费,输出
|
||||
|
||||
只使用 `dws event consume` 建立个人消息事件长连接。用户要求实时监听、订阅、自动回复或驱动 Agent 时,不要写轮询脚本,不要用消息历史查询模拟事件。
|
||||
|
||||
## 运行方式
|
||||
|
||||
- bus 后台进程持有对钉钉的个人 Stream 长连;consume 从 bus 读事件、按 NDJSON 打到 stdout。consume 只读,不发消息(回复用 `dws chat message send`)。
|
||||
- 没有 bus 时 consume 自动拉起;通常只跑 consume。
|
||||
- 一个组织一个 bus,可同时跑;同组织内多个 consume 共享一个 bus。
|
||||
- 非默认组织加全局 `--profile <corpId 或 profile 名>`;漏传会退回默认 profile 而失败。
|
||||
|
||||
## Core commands
|
||||
|
||||
| Command | Purpose |
|
||||
@@ -18,6 +25,8 @@ description: 钉钉个人消息事件长连接监听、订阅与消费,输出
|
||||
| `dws event stop <subscribe_id>` | 取消个人订阅并停止对应本地消费 |
|
||||
| `dws event stop --all` | 清理当前身份下本地记录的全部个人订阅 |
|
||||
|
||||
区分两个 schema:`dws event schema <event_key>` 查事件输出字段;`dws schema "event consume"` 查 consume 命令入参(机读结构,source=cobra,含 parameters + 位置参数)。
|
||||
|
||||
## Event catalog
|
||||
|
||||
| 事件码 | 场景 | 必填参数 |
|
||||
@@ -37,25 +46,30 @@ description: 钉钉个人消息事件长连接监听、订阅与消费,输出
|
||||
- 用户只给单聊对端人名时,先运行 `dws aisearch person --keyword "<name>" --dimension name --format json` 解析 userId;多候选必须让用户确认。
|
||||
- 用户只给群名时,先运行 `dws chat search --query "<group>" --format json` 解析 openConversationId;多候选必须让用户确认。
|
||||
- 正常 Agent 消费使用 `-f ndjson`。抓一条样本可用 `--max-events 1 -f json`。
|
||||
- 监听非默认组织时带 `--profile <corpId 或 profile 名>`;漏传会退回默认 profile 而失败。
|
||||
- 自己发的消息不作为事件回来(`isSelfLoop` 过滤):边监听边 `dws chat message send` 回复不成环;测试投递用别人 / 机器人发(自发会看到 0 事件)。
|
||||
- `--debug-raw-events` 只用于联调确认服务端推送是否到达本地连接;正常任务不要使用。
|
||||
- 排查:consume 报 bus 启动失败 → 报错已带真实原因,先查 `dws --profile <x> auth status`(非默认组织带对 `--profile`);本地日志见 `~/.dws/events/<edition>/personal_stream/<hash>/bus.log`(`hash` 见 `dws event status` 的 Workdir);有残留用 `dws event stop --all` 清理。看着"挂住"无输出多是误加了 `--foreground`(那是跑 bus、不打印事件),去掉即可。
|
||||
|
||||
## Call flow
|
||||
|
||||
1. 从用户意图选择事件码;人名或群名先解析成必填 ID。
|
||||
2. 需要了解字段时运行 `dws event schema <event_key>`,读取 `jq_root_path` 和 `schema.properties`。
|
||||
3. 启动 `dws event consume <event_key> ... -f ndjson`,等待 stderr 出现 `connected bus pid=...` 后开始读 stdout。
|
||||
3. 启动 `dws event consume <event_key> ... -f ndjson`,阻塞等 stderr 出现固定就绪行 `[event] ready event_key=<key> ...` 后再开始读 stdout(不要用 sleep 猜)。
|
||||
4. stdout 每行是一个事件 JSON;业务字段在 `data` JSON 字符串内,按 `jq_root_path` 解析。
|
||||
5. 需要确认监听状态时运行 `dws event status --event <event_key>`,查看 `Subscriptions` 和 `Consumers`。
|
||||
6. 任务完成后用 `dws event stop <subscribe_id>` 取消订阅;如果是临时测试,可以在 consume 上加 `--max-events` 或 `--duration` 自动退出。
|
||||
|
||||
## Subprocess contract
|
||||
|
||||
- `event consume` 是阻塞式长连接命令。stdout 只处理事件;stderr 只处理状态、debug 和错误。
|
||||
- 不要使用 `--quiet`,否则 Agent 会看不到建联状态和排障信息。
|
||||
- 无界监听需要外部进程管理;有界自测优先用 `--max-events N` 或 `--duration 10m`。
|
||||
- 不要 `kill -9` 消费进程。Ctrl+C、duration、max-events 只结束本地前台进程;取消订阅必须使用 `dws event stop <subscribe_id>`。
|
||||
- 不要运行裸 `dws event stop`;批量清理必须显式使用 `dws event stop --all`。
|
||||
- 一个 consume 对应一个事件订阅。监听多个对象时启动多个 consume;底层本机连接可以复用,但输出按 `subscribe_id` 隔离。
|
||||
- `event consume` 阻塞式长连接。stdout 只出事件;stderr 只出状态 / debug / 错误。
|
||||
- 就绪:连上后 stderr 打 `[event] ready event_key=<key> bus_pid=<pid>`,父进程等这行再读 stdout。不要 `--quiet`(会抑制它)。
|
||||
- 退出:末行 `[event] exited — received N event(s) in Xs (reason: limit|timeout|signal|bus_shutdown)`;受控退出码 0,失败非 0 无 exited 行。
|
||||
- stdin 关闭 = 停机:仅当 stdin 是管道且未设 `--max-events/--duration` 时生效;交互终端和 `< /dev/null` 不触发。用管道 stdin 又要常驻就喂 `< <(tail -f /dev/null)`。
|
||||
- 无界监听需外部进程管理;有界自测用 `--max-events N` 或 `--duration 10m`。
|
||||
- 订阅清理:本次新建的订阅任意干净退出即自动退订;`--subscribe-id` 复用的保留,`--ephemeral` 强制退订。优雅停用 SIGTERM、关 stdin,或外部 `dws event stop <subscribe_id>`。不要 `kill -9`(跳过退订、泄漏服务端订阅)。
|
||||
- 批量清理用 `dws event stop --all`。
|
||||
- 一 consume 一事件订阅;监听多个对象起多个 consume,本机连接可复用,输出按 `subscribe_id` 隔离。
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ dws event consume user_im_message_receive_group \
|
||||
| `user_im_message_receive_o2o` | `--user <userId> --duration 10m -f ndjson` | 让对端用户给当前登录用户发送单聊消息 |
|
||||
| `user_im_message_receive_group` | `--group <openConversationId> --duration 10m -f ndjson` | 让任意用户在该群发送消息 |
|
||||
|
||||
stderr 出现 `connected bus pid=...` 表示本地 consume 已连接到事件 bus。stdout 每行是一个事件 JSON。
|
||||
stderr 出现固定就绪行 `[event] ready event_key=<key> bus_pid=<pid>` 表示本地 consume 已连接到事件 bus;父进程等这行再读 stdout。stdout 每行是一个事件 JSON。
|
||||
|
||||
## Runtime flags
|
||||
|
||||
@@ -154,7 +154,7 @@ dws event stop --all
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- 没有输出:先确认 stderr 已出现 `connected bus pid=...`。
|
||||
- 没有输出:先确认 stderr 已出现 `[event] ready event_key=...`。
|
||||
- 参数缺失:单聊必须有对端 ID,群消息必须有 openConversationId。
|
||||
- 收到非预期消息:检查 stdout 的 `subscribe_id` 是否等于当前命令创建/复用的订阅 ID。
|
||||
- 需要判断服务端是否推到当前连接:临时加 `--debug --debug-raw-events`,排查后去掉。
|
||||
|
||||
@@ -35,6 +35,11 @@ metadata:
|
||||
| "新建工作表" | `dws sheet new --node <nodeId或URL> --name "<sheet名>"` |
|
||||
| "读取单元格" | `dws sheet range read --node <nodeId或URL> --sheet-id <sheetId> --range A1:B2` |
|
||||
| "写入单元格" | `dws sheet range update --node <nodeId或URL> --sheet-id <sheetId> --range A1:B2 --values '[[..]]'` |
|
||||
| "结构化读取 / DataFrame 读取" | `dws sheet table-get --node <nodeId或URL> [--sheet-id <sheetId>]` |
|
||||
| "结构化写入 / DataFrame 写入" | `dws sheet table-put --node <nodeId或URL> --sheets '<JSON>'` |
|
||||
| "创建透视表 / 数据透视" | `dws sheet pivot-table create --node <nodeId或URL> --source "'Sheet1'!A1:D100" --properties '<JSON>'` |
|
||||
| "查看透视表" | `dws sheet pivot-table list --node <nodeId或URL> --sheet-id <sheetId>` |
|
||||
| "显示 / 隐藏网格线" | `dws sheet show-gridline|hide-gridline --node <nodeId或URL> --sheet-id <sheetId>` |
|
||||
| "追加一行" | `dws sheet append --node <nodeId或URL> --sheet-id <sheetId> --values '[[..]]'` |
|
||||
| "查找 / 替换" | `dws sheet find --node <nodeId或URL> --sheet-id <sheetId> --find "<关键词>"` / `dws sheet replace --node <nodeId或URL> --sheet-id <sheetId> --find "<旧值>" --replacement "<新值>"` |
|
||||
| "插入图片到单元格" | `dws sheet write-image --node <nodeId或URL> --sheet-id <sheetId> --range A1:A1 --file <本地图片路径>`(CLI 自动上传本地图片并写入单元格;没有 --resource-id/--resource-url 这两个 flag) |
|
||||
|
||||
@@ -81,6 +81,8 @@ dws sheet filter-view --help
|
||||
| `sheet new` | 新建工作表 |
|
||||
| `sheet update` | 更新工作表属性(标题/位置/隐藏/冻结) |
|
||||
| `sheet copy` | 复制工作表 |
|
||||
| `sheet show-gridline` | 显示工作表网格线 |
|
||||
| `sheet hide-gridline` | 隐藏工作表网格线 |
|
||||
| `sheet range read` | 读取工作表数据(别名: range get) |
|
||||
| `sheet range update` | 更新指定区域内容(值/公式/超链接) |
|
||||
| `sheet range clear` | 清除区域(值/格式/全部) |
|
||||
@@ -91,11 +93,17 @@ dws sheet filter-view --help
|
||||
| `sheet range batch-clear` | 批量清除多个区域(原子事务) |
|
||||
| `sheet batch-update` | 批量执行多个写操作(原子事务) |
|
||||
| `sheet csv-get` | 以 CSV 格式读取区域数据 |
|
||||
| `sheet table-get` | 读取结构化 table 数据(别名: table-read) |
|
||||
| `sheet range set-style` | 设置单元格样式 |
|
||||
| `sheet range batch-set-style` | 按配置文件批量设置样式 |
|
||||
| `sheet find` | 搜索单元格内容 |
|
||||
| `sheet append` | 在末尾追加数据行 |
|
||||
| `sheet csv-put` | 将 CSV 数据写入指定位置(纯值,自动扩容) |
|
||||
| `sheet table-put` | 写入一个或多个结构化 table(别名: table-write) |
|
||||
| `sheet pivot-table list` | 列出透视表或获取指定透视表详情 |
|
||||
| `sheet pivot-table create` | 创建原生透视表 |
|
||||
| `sheet pivot-table update` | 更新透视表配置 |
|
||||
| `sheet pivot-table delete` | 删除透视表(不可逆,删除前必须确认) |
|
||||
| `sheet delete-sheet` | 删除工作表(不可逆,删除前必须确认) |
|
||||
| `sheet replace` | 全局查找替换文本 |
|
||||
| `sheet merge-cells` | 合并单元格 |
|
||||
@@ -153,9 +161,10 @@ dws sheet filter-view --help
|
||||
|
||||
| 主题 | 子文档 | 覆盖命令 |
|
||||
|------|--------|---------|
|
||||
| 表格与工作表管理 | [sheet/sheet-workbook.md](./sheet/sheet-workbook.md) | create / list / info / new / update / copy / delete-sheet |
|
||||
| 写入数据 | [sheet/sheet-write-data.md](./sheet/sheet-write-data.md) | range update(对象协议详解)/ append / csv-put |
|
||||
| 读取数据 | [sheet/sheet-read-data.md](./sheet/sheet-read-data.md) | range read / csv-get |
|
||||
| 表格与工作表管理 | [sheet/sheet-workbook.md](./sheet/sheet-workbook.md) | create / list / info / new / update / copy / show-gridline / hide-gridline / delete-sheet |
|
||||
| 透视表 | [sheet/sheet-pivot-table.md](./sheet/sheet-pivot-table.md) | pivot-table list / create / update / delete |
|
||||
| 写入数据 | [sheet/sheet-write-data.md](./sheet/sheet-write-data.md) | table-put / range update(对象协议详解)/ append / csv-put |
|
||||
| 读取数据 | [sheet/sheet-read-data.md](./sheet/sheet-read-data.md) | table-get / range read / csv-get |
|
||||
| 区域操作 | [sheet/sheet-range-operations.md](./sheet/sheet-range-operations.md) | range clear / sort / fill / copy-to / move-to |
|
||||
| 批量操作 | [sheet/sheet-batch-operations.md](./sheet/sheet-batch-operations.md) | range batch-clear / batch-update |
|
||||
| 行列操作 | [sheet/sheet-dimension-operations.md](./sheet/sheet-dimension-operations.md) | insert / delete / update / move / add-dimension / group-dimension / ungroup-dimension |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user