diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fbc81c48..39fabf86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: [main] pull_request: branches: [main] + types: [opened, synchronize, reopened, edited] workflow_dispatch: permissions: @@ -70,6 +71,7 @@ jobs: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: fetch-depth: 0 + persist-credentials: false - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version-file: go.mod @@ -87,6 +89,23 @@ jobs: - name: Run errs/ lint guards (lintcheck) run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" .. + script-test: + needs: fast-gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '22' + - name: Run script tests + run: make script-test + deterministic-gate: needs: fast-gate runs-on: ubuntu-latest @@ -109,8 +128,28 @@ jobs: env: QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }} run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV" + - name: Write public content metadata + if: ${{ github.event_name == 'pull_request' }} + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_BRANCH: ${{ github.head_ref }} + run: | + mkdir -p .tmp/quality-gate + python3 - <<'PY' + import json + import os + + with open(".tmp/quality-gate/public-content-metadata.json", "w", encoding="utf-8") as f: + json.dump({ + "title": os.environ.get("PR_TITLE", ""), + "body": os.environ.get("PR_BODY", ""), + "branch": os.environ.get("PR_BRANCH", ""), + }, f) + f.write("\n") + PY - name: Run CLI deterministic gate - run: make quality-gate + run: PUBLIC_CONTENT_METADATA=.tmp/quality-gate/public-content-metadata.json make quality-gate - name: Upload quality gate facts if: ${{ always() && github.event_name == 'pull_request' }} uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 @@ -220,7 +259,7 @@ jobs: # ── Layer 3: E2E Gate ────────────────────────────────────────────── e2e-dry-run: - needs: [unit-test, lint, deterministic-gate] + needs: [unit-test, lint, script-test, deterministic-gate] runs-on: ubuntu-latest steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 @@ -241,7 +280,7 @@ jobs: run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression' e2e-live: - needs: [unit-test, lint, deterministic-gate] + needs: [unit-test, lint, script-test, deterministic-gate] if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }} runs-on: ubuntu-latest permissions: @@ -333,7 +372,7 @@ jobs: # ── Results Gate (single required check for branch protection) ───── results: if: ${{ always() }} - needs: [fast-gate, unit-test, lint, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header] + needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header] runs-on: ubuntu-latest steps: - name: Evaluate results @@ -345,6 +384,7 @@ jobs: echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| L2 | script-test | ${{ needs.script-test.result }} |" >> $GITHUB_STEP_SUMMARY echo "| L2 | deterministic-gate | ${{ needs.deterministic-gate.result }} |" >> $GITHUB_STEP_SUMMARY echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY @@ -361,6 +401,7 @@ jobs: "${{ needs.fast-gate.result }}" \ "${{ needs.unit-test.result }}" \ "${{ needs.lint.result }}" \ + "${{ needs.script-test.result }}" \ "${{ needs.deterministic-gate.result }}" \ "${{ needs.coverage.result }}" \ "${{ needs.deadcode.result }}" \ diff --git a/.github/workflows/comment-audit.yml b/.github/workflows/comment-audit.yml new file mode 100644 index 00000000..0508fa52 --- /dev/null +++ b/.github/workflows/comment-audit.yml @@ -0,0 +1,28 @@ +name: Comment Audit + +on: + issue_comment: + types: [created, edited] + pull_request_review: + types: [submitted, edited] + pull_request_review_comment: + types: [created, edited] + +permissions: + contents: read + +jobs: + public-content-comment-audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + persist-credentials: false + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + - name: Post-publication comment audit + run: | + mkdir -p .tmp/comment-audit + cp "$GITHUB_EVENT_PATH" .tmp/comment-audit/event.json + go run ./internal/qualitygate/cmd/comment-audit --event .tmp/comment-audit/event.json --kind "$GITHUB_EVENT_NAME" diff --git a/.github/workflows/semantic-review.yml b/.github/workflows/semantic-review.yml index 7d39a4b4..2fcf298c 100644 --- a/.github/workflows/semantic-review.yml +++ b/.github/workflows/semantic-review.yml @@ -88,31 +88,44 @@ jobs: commit_sha: targetHeadSha, }); const candidatePRs = associatedPRs.filter((candidate) => - candidate.state === "open" && candidate.base?.repo?.id === context.payload.repository.id && candidate.head?.sha === targetHeadSha ); - if (candidatePRs.length > 1) { - throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`); + const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open"); + if (openCandidatePRs.length > 1) { + throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`); } - if (candidatePRs.length === 1) { - prNumber = candidatePRs[0].number; + if (openCandidatePRs.length === 1) { + prNumber = openCandidatePRs[0].number; + } else if (candidatePRs.length > 0) { + core.notice("PR quality summary skipped: workflow_run target PR is no longer open"); + core.setOutput("stale", "true"); + return; } } if (!prNumber) { const candidatePRs = await github.paginate(github.rest.pulls.list, { owner: context.repo.owner, repo: context.repo.repo, - state: "open", + state: "all", per_page: 100, }).then((prs) => prs.filter((candidate) => candidate.base?.repo?.id === context.payload.repository.id && candidate.head?.sha === targetHeadSha )); - if (candidatePRs.length !== 1) { + const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open"); + if (openCandidatePRs.length > 1) { + throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`); + } + if (openCandidatePRs.length === 1) { + prNumber = openCandidatePRs[0].number; + } else if (candidatePRs.length > 0) { + core.notice("PR quality summary skipped: workflow_run target PR is no longer open"); + core.setOutput("stale", "true"); + return; + } else { throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`); } - prNumber = candidatePRs[0].number; } if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding"); const { data: pr } = await github.rest.pulls.get({ @@ -121,6 +134,11 @@ jobs: pull_number: prNumber, }); if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch"); + if (pr.state !== "open") { + core.notice("PR quality summary skipped: workflow_run target PR is no longer open"); + core.setOutput("stale", "true"); + return; + } if (pr.head.sha !== targetHeadSha) { core.notice("PR quality summary skipped: workflow_run is stale for this PR head"); core.setOutput("stale", "true"); @@ -299,31 +317,44 @@ jobs: commit_sha: targetHeadSha, }); const candidatePRs = associatedPRs.filter((candidate) => - candidate.state === "open" && candidate.base?.repo?.id === context.payload.repository.id && candidate.head?.sha === targetHeadSha ); - if (candidatePRs.length > 1) { - throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`); + const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open"); + if (openCandidatePRs.length > 1) { + throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`); } - if (candidatePRs.length === 1) { - prNumber = candidatePRs[0].number; + if (openCandidatePRs.length === 1) { + prNumber = openCandidatePRs[0].number; + } else if (candidatePRs.length > 0) { + core.notice("semantic review skipped: workflow_run target PR is no longer open"); + core.setOutput("stale", "true"); + return; } } if (!prNumber) { const candidatePRs = await github.paginate(github.rest.pulls.list, { owner: context.repo.owner, repo: context.repo.repo, - state: "open", + state: "all", per_page: 100, }).then((prs) => prs.filter((candidate) => candidate.base?.repo?.id === context.payload.repository.id && candidate.head?.sha === targetHeadSha )); - if (candidatePRs.length !== 1) { + const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open"); + if (openCandidatePRs.length > 1) { + throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`); + } + if (openCandidatePRs.length === 1) { + prNumber = openCandidatePRs[0].number; + } else if (candidatePRs.length > 0) { + core.notice("semantic review skipped: workflow_run target PR is no longer open"); + core.setOutput("stale", "true"); + return; + } else { throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`); } - prNumber = candidatePRs[0].number; } if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding"); const { data: pr } = await github.rest.pulls.get({ @@ -332,6 +363,16 @@ jobs: pull_number: prNumber, }); if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch"); + if (pr.state !== "open") { + core.notice("semantic review skipped: workflow_run target PR is no longer open"); + core.setOutput("stale", "true"); + return; + } + if (!pr.head.repo) { + core.notice("semantic review skipped: workflow_run target PR head repository is unavailable"); + core.setOutput("stale", "true"); + return; + } if (pr.head.sha !== targetHeadSha) { core.notice("semantic review skipped: workflow_run is stale for this PR head"); core.setOutput("stale", "true"); @@ -389,6 +430,10 @@ jobs: repo: context.repo.repo, pull_number: pr, }); + if (pull.state !== "open") { + core.notice("semantic review skipped infrastructure failure check: PR is no longer open"); + return; + } if (pull.head.sha !== headSha) { core.notice("semantic review skipped infrastructure failure check: PR head changed"); return; diff --git a/.gitignore b/.gitignore index 8b7fc5ef..5666621b 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ app.log cover*.out lark-env.sh +/automations/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ef2bc094..c4a3c0ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,47 @@ All notable changes to this project will be documented in this file. +## [v1.0.59] - 2026-06-26 + +### Features + +- **slides**: Add `+replace-pages` and `xml get` shortcuts, and expose the presentation URL (#1585) +- **minutes**: Support speaker list and no-Lark speaker replace (#1594) +- **calendar/vc/minutes**: Optimize and extend calendar, vc, minutes, and note shortcuts and skills (#1571) + +### Bug Fixes + +- **docs**: Hide docs `api-version` compat flag (#1580) + +## [v1.0.58] - 2026-06-25 + +### Features + +- **sheets**: Typed table I/O and error contract, workbook import/export, and skill refresh (#1355) +- **base**: Add Base URL and title resolve shortcuts (#1338) +- **drive**: Add `+member-add` shortcut with wiki space member collection collaborator support (#1204) +- **doc**: Support `create` title option (#1536) +- **doc**: Add `im-markdown` output format for doc fetch (#1550) +- **whiteboard**: Export whiteboard as SVG and update whiteboard via SVG (#1559) +- **card**: Support `card.action.trigger` event with auto-fetched card content (#1528) +- **task**: Add task event consumer (#1510) + +### Bug Fixes + +- **doc**: Prefix docs resource shortcuts (#1564) +- **binding**: Skip unix mode audit on Windows (#1525) + +### Documentation + +- **approval**: Sync approval skill for meta API commands (#1499) +- **doc**: Restore lark-doc style requirements (#1579) +- **im**: Document `chat.nickname` get/update/delete (#1378) +- **im**: Clarify audio message opus requirement (#1271) + +### Build + +- **ci**: Add public content safeguards and reduce false positives + ## [v1.0.57] - 2026-06-23 ### Features @@ -1236,6 +1277,8 @@ Bundled AI agent skills for intelligent assistance: - Bilingual documentation (English & Chinese). - CI/CD pipelines: linting, testing, coverage reporting, and automated releases. +[v1.0.59]: https://github.com/larksuite/cli/releases/tag/v1.0.59 +[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58 [v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57 [v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56 [v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55 diff --git a/Makefile b/Makefile index 3d8c9861..d401cd17 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,7 @@ QUALITY_GATE_DIR ?= .tmp/quality-gate QUALITY_GATE_MANIFEST_OUT ?= $(QUALITY_GATE_DIR)/command-manifest.json QUALITY_GATE_COMMAND_INDEX_OUT ?= $(QUALITY_GATE_DIR)/command-index.json QUALITY_GATE_FACTS_OUT ?= $(QUALITY_GATE_DIR)/facts.json +PUBLIC_CONTENT_METADATA ?= $(QUALITY_GATE_DIR)/public-content-metadata.json LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE) PREFIX ?= /usr/local @@ -69,7 +70,8 @@ integration-test: build test: vet fmt-check script-test unit-test examples-build integration-test quality-gate: build - mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT)) + mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT)) $(dir $(PUBLIC_CONTENT_METADATA)) + test -f $(PUBLIC_CONTENT_METADATA) || printf '{}\n' > $(PUBLIC_CONTENT_METADATA) LARKSUITE_CLI_REMOTE_META=off \ LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \ LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \ @@ -89,6 +91,7 @@ quality-gate: build --changed-from $(QUALITY_GATE_CHANGED_FROM_RESOLVED) \ --manifest $(QUALITY_GATE_MANIFEST_OUT) \ --command-index $(QUALITY_GATE_COMMAND_INDEX_OUT) \ + --public-content-metadata $(PUBLIC_CONTENT_METADATA) \ --facts-out $(QUALITY_GATE_FACTS_OUT) install: build diff --git a/README.md b/README.md index 5ea47838..e0f371f6 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ Prefixed with `+`, designed to be friendly for both humans and AI, with smart de ```bash lark-cli calendar +agenda lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello" -lark-cli docs +create --api-version v2 --doc-format markdown --content $'Weekly Report\n# Progress\n- Completed feature X' +lark-cli docs +create --doc-format markdown --content $'Weekly Report\n# Progress\n- Completed feature X' ``` Run `lark-cli --help` to see all shortcut commands. diff --git a/README.zh.md b/README.zh.md index f597ca68..6d8221f2 100644 --- a/README.zh.md +++ b/README.zh.md @@ -199,7 +199,7 @@ CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义 ```bash lark-cli calendar +agenda lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello" -lark-cli docs +create --api-version v2 --doc-format markdown --content $'周报\n# 本周进展\n- 完成了 X 功能' +lark-cli docs +create --doc-format markdown --content $'周报\n# 本周进展\n- 完成了 X 功能' ``` 运行 `lark-cli --help` 查看所有快捷命令。 diff --git a/internal/qualitygate/cmd/comment-audit/main.go b/internal/qualitygate/cmd/comment-audit/main.go new file mode 100644 index 00000000..4425206d --- /dev/null +++ b/internal/qualitygate/cmd/comment-audit/main.go @@ -0,0 +1,92 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/qualitygate/publiccontent" + "github.com/larksuite/cli/internal/qualitygate/report" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/internal/vfs" +) + +type eventPayload struct { + Comment *struct { + Body string `json:"body"` + } `json:"comment"` + Review *struct { + Body string `json:"body"` + } `json:"review"` +} + +func main() { + eventPath := flag.String("event", os.Getenv("GITHUB_EVENT_PATH"), "GitHub event payload path") + kind := flag.String("kind", os.Getenv("GITHUB_EVENT_NAME"), "GitHub event kind") + flag.Parse() + + if *eventPath == "" { + fmt.Fprintln(os.Stderr, "comment-audit: --event or GITHUB_EVENT_PATH is required") + os.Exit(2) + } + body, err := commentBody(*eventPath) + if err != nil { + fmt.Fprintf(os.Stderr, "comment-audit: %v\n", err) + os.Exit(2) + } + diags := diagnostics(publiccontent.ScanComment(*kind, body)) + if len(diags) > 0 { + fmt.Fprintln(os.Stderr, auditFailureSummary(len(diags))) + } + report.Print(os.Stderr, diags) + os.Exit(report.ExitCode(diags)) +} + +func auditFailureSummary(count int) string { + return fmt.Sprintf("post-publication audit found public content findings: %d", count) +} + +func commentBody(path string) (string, error) { + safePath, err := validate.SafeInputPath(path) + if err != nil { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err). + WithParam("--event"). + WithCause(err) + } + data, err := vfs.ReadFile(safePath) + if err != nil { + return "", err + } + var payload eventPayload + if err := json.Unmarshal(data, &payload); err != nil { + return "", err + } + switch { + case payload.Comment != nil: + return payload.Comment.Body, nil + case payload.Review != nil: + return payload.Review.Body, nil + default: + return "", nil + } +} + +func diagnostics(items []publiccontent.Finding) []report.Diagnostic { + out := make([]report.Diagnostic, 0, len(items)) + for _, item := range items { + out = append(out, report.Diagnostic{ + Rule: item.Rule, + Action: item.Action, + File: item.File, + Line: item.Line, + Message: item.Message, + Suggestion: item.Suggestion, + }) + } + return out +} diff --git a/internal/qualitygate/cmd/comment-audit/main_test.go b/internal/qualitygate/cmd/comment-audit/main_test.go new file mode 100644 index 00000000..5e7aea46 --- /dev/null +++ b/internal/qualitygate/cmd/comment-audit/main_test.go @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package main + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/larksuite/cli/errs" +) + +func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) { + dir := t.TempDir() + if err := writeTestFile(filepath.Join(dir, "event.json"), `{"comment":{"body":"clean comment"}}`); err != nil { + t.Fatal(err) + } + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = os.Chdir(origDir) + }) + + got, err := commentBody("event.json") + if err != nil { + t.Fatalf("commentBody() error = %v", err) + } + if got != "clean comment" { + t.Fatalf("comment body = %q", got) + } +} + +func TestCommentBodyRejectsUnsafeEventPath(t *testing.T) { + path := filepath.Join(t.TempDir(), "event.json") + if err := writeTestFile(path, `{"comment":{"body":"clean"}}`); err != nil { + t.Fatal(err) + } + + _, err := commentBody(path) + problem, ok := errs.ProblemOf(err) + if err == nil || !ok { + t.Fatalf("commentBody(%q) error = %v, want unsafe path validation error", path, err) + } + if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("commentBody(%q) problem = %#v, want invalid argument validation", path, problem) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) || validationErr.Param != "--event" { + t.Fatalf("commentBody(%q) error = %v, want --event validation param", path, err) + } +} + +func TestAuditFailureSummaryStatesPostPublicationAudit(t *testing.T) { + got := auditFailureSummary(2) + want := "post-publication audit found public content findings: 2" + if got != want { + t.Fatalf("auditFailureSummary() = %q, want %q", got, want) + } +} + +func writeTestFile(path, data string) error { + return os.WriteFile(path, []byte(data), 0o644) +} diff --git a/internal/qualitygate/cmd/quality-gate/main.go b/internal/qualitygate/cmd/quality-gate/main.go index 08cf3951..1ec3c90b 100644 --- a/internal/qualitygate/cmd/quality-gate/main.go +++ b/internal/qualitygate/cmd/quality-gate/main.go @@ -13,6 +13,7 @@ import ( "github.com/larksuite/cli/internal/qualitygate/manifest" "github.com/larksuite/cli/internal/qualitygate/report" "github.com/larksuite/cli/internal/qualitygate/rules" + "github.com/larksuite/cli/internal/validate" ) func main() { @@ -41,6 +42,7 @@ func runCheck(args []string) int { fs.StringVar(&opts.FactsOut, "facts-out", "", "write facts JSON to this path") fs.StringVar(&opts.ManifestPath, "manifest", "", "hand-authored command manifest JSON") fs.StringVar(&opts.CommandIndexPath, "command-index", "", "full command index JSON") + fs.StringVar(&opts.PublicContentMetadataPath, "public-content-metadata", "", "PR title/body metadata JSON for public content checks") fs.BoolVar(&printLegacyCommandCandidates, "print-legacy-command-candidates", false, "print current non-kebab-case hand-authored command candidates") fs.BoolVar(&printLegacyFlagCandidates, "print-legacy-flag-candidates", false, "print current non-kebab-case flag candidates") if err := fs.Parse(args); err != nil { @@ -48,6 +50,15 @@ func runCheck(args []string) int { return 2 } + if opts.PublicContentMetadataPath != "" { + safePath, err := validate.SafeInputPath(opts.PublicContentMetadataPath) + if err != nil { + fmt.Fprintf(os.Stderr, "quality-gate check: --public-content-metadata: %v\n", err) + return 2 + } + opts.PublicContentMetadataPath = safePath + } + if opts.ManifestPath == "" || opts.CommandIndexPath == "" { fmt.Fprintln(os.Stderr, "quality-gate check: --manifest and --command-index are required") return 2 diff --git a/internal/qualitygate/cmd/quality-gate/main_test.go b/internal/qualitygate/cmd/quality-gate/main_test.go index cc89695e..9420de89 100644 --- a/internal/qualitygate/cmd/quality-gate/main_test.go +++ b/internal/qualitygate/cmd/quality-gate/main_test.go @@ -37,6 +37,37 @@ func TestCheckRequiresManifestInputs(t *testing.T) { } } +func TestCheckAcceptsPublicContentMetadataFlag(t *testing.T) { + code, stderr := runCheckCaptureStderr(t, []string{ + "--repo", t.TempDir(), + "--cli-bin", "./lark-cli", + "--public-content-metadata", ".tmp/quality-gate/pr.json", + }) + if code != 2 { + t.Fatalf("exit code = %d, stderr=%s", code, stderr) + } + if strings.Contains(stderr, "flag provided but not defined") { + t.Fatalf("public content metadata flag was not registered: %s", stderr) + } + if !strings.Contains(stderr, "--manifest and --command-index are required") { + t.Fatalf("stderr = %s", stderr) + } +} + +func TestCheckRejectsUnsafePublicContentMetadataPath(t *testing.T) { + code, stderr := runCheckCaptureStderr(t, []string{ + "--repo", t.TempDir(), + "--cli-bin", "./lark-cli", + "--public-content-metadata", filepath.Join(t.TempDir(), "pr.json"), + }) + if code != 2 { + t.Fatalf("exit code = %d, stderr=%s", code, stderr) + } + if !strings.Contains(stderr, "--public-content-metadata") || !strings.Contains(stderr, "--file") { + t.Fatalf("stderr = %s, want unsafe public content metadata path error", stderr) + } +} + func TestCheckReportsManifestReadErrorsWithFlagName(t *testing.T) { dir := t.TempDir() manifestPath := filepath.Join(dir, "command-manifest.json") diff --git a/internal/qualitygate/cmd/semantic-review/main.go b/internal/qualitygate/cmd/semantic-review/main.go index 8d4603a0..9ef67674 100644 --- a/internal/qualitygate/cmd/semantic-review/main.go +++ b/internal/qualitygate/cmd/semantic-review/main.go @@ -56,6 +56,14 @@ func run(args []string) int { _ = semantic.WriteMarkdown(markdownOut, decision) return 0 } + if reviewPath == "" && !semantic.BuildInputView(f).HasReviewableFacts() { + decision := finalizeDecision(block, waiverDiags, semantic.Decision{}) + if err := writeSemanticOutputs(decisionOut, markdownOut, decision); err != nil { + fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err) + return 2 + } + return decisionExitCode(decision) + } review, err := semantic.LoadOrReviewWithConfig(context.Background(), f, reviewPath, modelConfig) if err != nil { fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err) @@ -72,6 +80,15 @@ func run(args []string) int { return 0 } decision := semantic.DecideWithWaivers(f, review, policy, waivers) + decision = finalizeDecision(block, waiverDiags, decision) + if err := writeSemanticOutputs(decisionOut, markdownOut, decision); err != nil { + fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err) + return 2 + } + return decisionExitCode(decision) +} + +func finalizeDecision(block bool, waiverDiags []report.Diagnostic, decision semantic.Decision) semantic.Decision { decision.BlockMode = block if !block && len(decision.Blockers) > 0 { for i := range decision.Blockers { @@ -81,15 +98,21 @@ func run(args []string) int { decision.Blockers = nil } decision.SystemWarnings = append(diagnosticSystemWarnings(waiverDiags), decision.SystemWarnings...) + return decision +} + +func writeSemanticOutputs(decisionOut, markdownOut string, decision semantic.Decision) error { if err := semantic.WriteDecision(decisionOut, decision); err != nil { - fmt.Fprintf(os.Stderr, "semantic-review: write decision: %v\n", err) - return 2 + return fmt.Errorf("write decision: %w", err) } if err := semantic.WriteMarkdown(markdownOut, decision); err != nil { - fmt.Fprintf(os.Stderr, "semantic-review: write markdown: %v\n", err) - return 2 + return fmt.Errorf("write markdown: %w", err) } - if block && len(decision.Blockers) > 0 { + return nil +} + +func decisionExitCode(decision semantic.Decision) int { + if decision.BlockMode && len(decision.Blockers) > 0 { return 1 } return 0 diff --git a/internal/qualitygate/cmd/semantic-review/main_test.go b/internal/qualitygate/cmd/semantic-review/main_test.go index 6bf80a38..366e24ac 100644 --- a/internal/qualitygate/cmd/semantic-review/main_test.go +++ b/internal/qualitygate/cmd/semantic-review/main_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "github.com/larksuite/cli/internal/qualitygate/facts" @@ -211,7 +212,19 @@ func TestRunWritesSkippedDecisionForUnavailableReviewer(t *testing.T) { "allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"] }`, "") factsPath := filepath.Join(t.TempDir(), "facts.json") - if err := (facts.Facts{SchemaVersion: 1}).WriteFile(factsPath); err != nil { + f := facts.Facts{ + SchemaVersion: 1, + Skills: []facts.SkillFact{{ + SourceFile: "skills/lark-wiki/SKILL.md", + Line: 30, + Changed: true, + ReferencesInvalidCommand: true, + }}, + } + if !semantic.BuildInputView(f).HasReviewableFacts() { + t.Fatal("test setup must contain reviewable facts") + } + if err := f.WriteFile(factsPath); err != nil { t.Fatalf("write facts: %v", err) } decisionPath := filepath.Join(t.TempDir(), "decision.json") @@ -228,6 +241,71 @@ func TestRunWritesSkippedDecisionForUnavailableReviewer(t *testing.T) { } } +func TestRunShortCircuitsEmptySemanticInputWithoutReviewer(t *testing.T) { + t.Setenv("ARK_API_KEY", "") + t.Setenv("ARK_BASE_URL", "") + t.Setenv("ARK_MODEL", "") + + repo := t.TempDir() + writeSemanticConfig(t, repo, `{ + "schema_version": 1, + "default_enforcement": "observe", + "block_categories": ["skill_quality"] + }`, `{ + "allowed": ["semantic-review-v1"], + "allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"] + }`, "") + factsPath := filepath.Join(t.TempDir(), "facts.json") + f := facts.Facts{ + SchemaVersion: 1, + Commands: []facts.CommandFact{{ + Path: "service command 1", + Domain: "service", + Changed: true, + Source: "service", + }}, + Outputs: []facts.OutputFact{{ + Command: "service command 1", + Domain: "service", + Changed: true, + Source: "service", + IsList: true, + HasDefaultLimit: true, + HasDecisionField: true, + }}, + } + if semantic.BuildInputView(f).HasReviewableFacts() { + t.Fatal("test setup must not contain reviewable facts") + } + if err := f.WriteFile(factsPath); err != nil { + t.Fatalf("write facts: %v", err) + } + decisionPath := filepath.Join(t.TempDir(), "decision.json") + markdownPath := filepath.Join(t.TempDir(), "semantic.md") + code := run([]string{"--repo", repo, "--facts", factsPath, "--decision-out", decisionPath, "--markdown-out", markdownPath, "--block"}) + if code != 0 { + t.Fatalf("run() = %d, want clean pass", code) + } + decision := readDecision(t, decisionPath) + if decision.Skipped || decision.Degraded || decision.InfrastructureFailure || !decision.BlockMode { + t.Fatalf("expected non-degraded pass decision: %#v", decision) + } + if len(decision.SystemWarnings) != 0 || len(decision.Warnings) != 0 || len(decision.Blockers) != 0 { + t.Fatalf("empty semantic view should not produce findings: %#v", decision) + } + data, err := os.ReadFile(markdownPath) + if err != nil { + t.Fatalf("read markdown: %v", err) + } + markdown := string(data) + if !strings.Contains(markdown, "No semantic blockers.") { + t.Fatalf("markdown missing pass summary: %s", markdown) + } + if strings.Contains(strings.ToLower(markdown), "skipped") || strings.Contains(strings.ToLower(markdown), "degraded") { + t.Fatalf("markdown should not report semantic review as skipped/degraded: %s", markdown) + } +} + func TestRunWritesInfrastructureFailureDecisionForInvalidReviewerConfig(t *testing.T) { t.Setenv("ARK_API_KEY", "test-key") t.Setenv("ARK_BASE_URL", "") @@ -243,7 +321,19 @@ func TestRunWritesInfrastructureFailureDecisionForInvalidReviewerConfig(t *testi "allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"] }`, "") factsPath := filepath.Join(t.TempDir(), "facts.json") - if err := (facts.Facts{SchemaVersion: 1}).WriteFile(factsPath); err != nil { + f := facts.Facts{ + SchemaVersion: 1, + Skills: []facts.SkillFact{{ + SourceFile: "skills/lark-wiki/SKILL.md", + Line: 30, + Changed: true, + ReferencesInvalidCommand: true, + }}, + } + if !semantic.BuildInputView(f).HasReviewableFacts() { + t.Fatal("test setup must contain reviewable facts") + } + if err := f.WriteFile(factsPath); err != nil { t.Fatalf("write facts: %v", err) } decisionPath := filepath.Join(t.TempDir(), "decision.json") diff --git a/internal/qualitygate/config/semantic/policy.json b/internal/qualitygate/config/semantic/policy.json index fac5e11f..3fd4e9cb 100644 --- a/internal/qualitygate/config/semantic/policy.json +++ b/internal/qualitygate/config/semantic/policy.json @@ -5,7 +5,8 @@ "error_hint", "default_output", "naming", - "skill_quality" + "skill_quality", + "public_content_leakage" ], "rollout_groups": [ { @@ -16,7 +17,8 @@ }, "categories": [ "error_hint", - "skill_quality" + "skill_quality", + "public_content_leakage" ], "owner": "cli-owner", "reason": "first semantic blocking rollout only affects changed facts" diff --git a/internal/qualitygate/facts/schema.go b/internal/qualitygate/facts/schema.go index 8133dd66..a6e37ca7 100644 --- a/internal/qualitygate/facts/schema.go +++ b/internal/qualitygate/facts/schema.go @@ -13,14 +13,15 @@ import ( ) type Facts struct { - SchemaVersion int `json:"schema_version"` - Commands []CommandFact `json:"commands,omitempty"` - Skills []SkillFact `json:"skills,omitempty"` - SkillQuality []SkillQualityFact `json:"skill_quality,omitempty"` - Errors []ErrorFact `json:"errors,omitempty"` - Outputs []OutputFact `json:"outputs,omitempty"` - Examples []CommandExample `json:"examples,omitempty"` - Diagnostics []DiagnosticFact `json:"diagnostics,omitempty"` + SchemaVersion int `json:"schema_version"` + Commands []CommandFact `json:"commands,omitempty"` + Skills []SkillFact `json:"skills,omitempty"` + SkillQuality []SkillQualityFact `json:"skill_quality,omitempty"` + Errors []ErrorFact `json:"errors,omitempty"` + Outputs []OutputFact `json:"outputs,omitempty"` + Examples []CommandExample `json:"examples,omitempty"` + PublicContent []PublicContentFact `json:"public_content,omitempty"` + Diagnostics []DiagnosticFact `json:"diagnostics,omitempty"` } type CommandFact struct { @@ -109,6 +110,17 @@ type OutputFact struct { HasDecisionField bool `json:"has_decision_field,omitempty"` } +type PublicContentFact struct { + Rule string `json:"rule"` + Action report.Action `json:"action"` + File string `json:"file"` + Line int `json:"line"` + Source string `json:"source,omitempty"` + Excerpt string `json:"excerpt,omitempty"` + Message string `json:"message,omitempty"` + Suggestion string `json:"suggestion,omitempty"` +} + type DryRunRequest struct { Method string `json:"method"` URL string `json:"url"` @@ -206,6 +218,11 @@ func BuildWithCommandLookup(m manifest.Manifest, commandLookup manifest.Manifest } } +func WithPublicContent(f Facts, publicContent []PublicContentFact) Facts { + f.PublicContent = publicContent + return f +} + type commandScope struct { Domain string Source string diff --git a/internal/qualitygate/facts/schema_test.go b/internal/qualitygate/facts/schema_test.go index 551bed7b..0a790ea0 100644 --- a/internal/qualitygate/facts/schema_test.go +++ b/internal/qualitygate/facts/schema_test.go @@ -34,6 +34,7 @@ func TestFactsSchemaCarriesGatekeeperFields(t *testing.T) { Errors: []ErrorFact{{Code: "invalid_input", Message: "bad path", Hint: "pass --file", Retryable: false, HintActionCount: 1, RequiredHint: true}}, Outputs: []OutputFact{{Command: "im messages list", Fields: []string{"message_id", "sender", "create_time"}, IsList: true, HasDefaultLimit: true, HasDecisionField: true}}, Skills: []SkillFact{{SourceFile: "skills/lark-doc/SKILL.md", Line: 1, DestructiveWithoutGuard: true, ScopeConflict: true}}, + PublicContent: []PublicContentFact{{Rule: "public_content_generic_credential", Action: report.ActionReject, File: "docs/public.md", Line: 4, Excerpt: "api_key = "}}, } data, err := json.Marshal(f) if err != nil { @@ -43,7 +44,10 @@ func TestFactsSchemaCarriesGatekeeperFields(t *testing.T) { if err := json.Unmarshal(data, &got); err != nil { t.Fatalf("unmarshal facts: %v", err) } - if !got.Errors[0].RequiredHint || got.Outputs[0].Fields[0] != "message_id" || !got.Skills[0].ScopeConflict { + if !got.Errors[0].RequiredHint || + got.Outputs[0].Fields[0] != "message_id" || + !got.Skills[0].ScopeConflict || + got.PublicContent[0].Rule != "public_content_generic_credential" { t.Fatalf("facts lost gatekeeper fields: %#v", got) } } diff --git a/internal/qualitygate/publiccontent/collect.go b/internal/qualitygate/publiccontent/collect.go new file mode 100644 index 00000000..f21a0e5e --- /dev/null +++ b/internal/qualitygate/publiccontent/collect.go @@ -0,0 +1,343 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package publiccontent + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" +) + +func Collect(ctx context.Context, opts Options) ([]Finding, error) { + metadata, err := LoadMetadata(opts.MetadataPath) + if err != nil { + return nil, err + } + + var out []Finding + changedFiles, base, err := changedFiles(ctx, opts.Repo, opts.ChangedFrom) + if err != nil { + return nil, err + } + patches := map[string][]changedChunk{} + if base != "" { + patches, err = changedPatches(ctx, opts.Repo, base) + if err != nil { + return nil, err + } + } + for _, file := range changedFiles { + if !scanChangedFile(file) { + continue + } + for _, chunk := range patches[file] { + findings := scanText(file, "file", chunk.Text, isDetectorRuleFile(file)) + for i := range findings { + findings[i].Line += chunk.StartLine - 1 + } + out = append(out, findings...) + out = append(out, semanticCandidate(file, "file", chunk.Text, chunk.StartLine)...) + } + privateKeyFindings, err := scanTouchedPrivateKeyBlocks(ctx, opts.Repo, file, patches[file]) + if err != nil { + return nil, err + } + out = appendUniqueFindings(out, privateKeyFindings...) + } + if base != "" { + commitFindings, err := scanCommitMessages(ctx, opts.Repo, base) + if err != nil { + return nil, err + } + out = append(out, commitFindings...) + } + branchName := opts.BranchName + if branchName == "" { + branchName = metadata.Branch + } + if branchName == "" { + branchName = branchFromEnv() + } + if branchName == "" { + branchName = currentBranch(ctx, opts.Repo) + } + if branchName != "" { + out = append(out, scanText("branch", "branch", branchName, false)...) + } + out = append(out, scanMetadata(metadata)...) + sort.SliceStable(out, func(i, j int) bool { + if out[i].File != out[j].File { + return out[i].File < out[j].File + } + if out[i].Line != out[j].Line { + return out[i].Line < out[j].Line + } + return out[i].Rule < out[j].Rule + }) + return out, nil +} + +func currentBranch(ctx context.Context, repo string) string { + data, err := gitOutput(ctx, repo, "branch", "--show-current") + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +func branchFromEnv() string { + for _, key := range []string{"PR_BRANCH", "GITHUB_HEAD_REF", "GITHUB_REF_NAME"} { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + } + return "" +} + +func changedFiles(ctx context.Context, repo, changedFrom string) ([]string, string, error) { + if changedFrom == "" { + return nil, "", nil + } + baseBytes, err := gitOutput(ctx, repo, "merge-base", changedFrom, "HEAD") + if err != nil { + return nil, "", err + } + base := strings.TrimSpace(string(baseBytes)) + files, err := diffFileNames(ctx, repo, base) + if err != nil { + return nil, "", err + } + sort.Strings(files) + return files, base, nil +} + +func diffFileNames(ctx context.Context, repo, base string) ([]string, error) { + data, err := gitOutput(ctx, repo, "diff", "--name-only", "-z", "--diff-filter=ACMR", base+"..HEAD") + if err != nil { + return nil, err + } + var files []string + for _, file := range bytes.Split(data, []byte{0}) { + if len(file) == 0 { + continue + } + files = append(files, filepath.ToSlash(string(file))) + } + return files, nil +} + +var detectorFixtureExclusions = map[string]bool{ + "internal/qualitygate/publiccontent/collect_test.go": true, + "internal/qualitygate/publiccontent/rules.go": true, + "internal/qualitygate/publiccontent/scan.go": true, + "internal/qualitygate/publiccontent/scan_test.go": true, +} + +func scanChangedFile(file string) bool { + normalized := strings.TrimPrefix(strings.ReplaceAll(file, "\\", "/"), "./") + return !detectorFixtureExclusions[normalized] +} + +type changedChunk struct { + StartLine int + Text string +} + +func (c changedChunk) endLine() int { + lines := strings.Count(strings.TrimRight(c.Text, "\n"), "\n") + 1 + if lines < 1 { + lines = 1 + } + return c.StartLine + lines - 1 +} + +func changedPatches(ctx context.Context, repo, base string) (map[string][]changedChunk, error) { + files, err := diffFileNames(ctx, repo, base) + if err != nil { + return nil, err + } + data, err := gitOutput(ctx, repo, "diff", "--no-ext-diff", "--unified=0", "--diff-filter=ACMR", base+"..HEAD") + if err != nil { + return nil, err + } + out := map[string][]changedChunk{} + var file string + var chunk *changedChunk + nextLine := 0 + nextFile := 0 + flush := func() { + if file == "" || chunk == nil || chunk.Text == "" { + chunk = nil + return + } + out[file] = append(out[file], *chunk) + chunk = nil + } + for _, raw := range strings.Split(string(data), "\n") { + switch { + case strings.HasPrefix(raw, "diff --git "): + flush() + file = "" + if nextFile < len(files) { + file = files[nextFile] + nextFile++ + } + case strings.HasPrefix(raw, "@@ "): + flush() + start, ok := parseNewHunkStart(raw) + if !ok { + nextLine = 0 + continue + } + nextLine = start + chunk = &changedChunk{StartLine: start} + case strings.HasPrefix(raw, "+") && !strings.HasPrefix(raw, "+++"): + if chunk == nil { + chunk = &changedChunk{StartLine: max(nextLine, 1)} + } + chunk.Text += strings.TrimPrefix(raw, "+") + "\n" + nextLine++ + case strings.HasPrefix(raw, "-"): + continue + default: + if chunk != nil && strings.HasPrefix(raw, `\ No newline at end of file`) { + continue + } + flush() + } + } + flush() + return out, nil +} + +func parseNewHunkStart(header string) (int, bool) { + parts := strings.Split(header, " ") + for _, part := range parts { + if !strings.HasPrefix(part, "+") { + continue + } + raw := strings.TrimPrefix(part, "+") + if before, _, ok := strings.Cut(raw, ","); ok { + raw = before + } + start, err := strconv.Atoi(raw) + return start, err == nil && start > 0 + } + return 0, false +} + +func scanCommitMessages(ctx context.Context, repo, base string) ([]Finding, error) { + data, err := gitOutput(ctx, repo, "log", "--format=%H%x00%B%x00", base+"..HEAD") + if err != nil { + return nil, err + } + parts := bytes.Split(data, []byte{0}) + var out []Finding + for i := 0; i+1 < len(parts); i += 2 { + sha := strings.TrimSpace(string(parts[i])) + body := string(parts[i+1]) + if sha == "" || body == "" { + continue + } + short := sha + if len(short) > 12 { + short = short[:12] + } + out = append(out, scanText("commit:"+short, "commit", body, false)...) + out = append(out, semanticCandidate("commit:"+short, "commit", body, 1)...) + } + return out, nil +} + +type lineRange struct { + Start int + End int +} + +func scanTouchedPrivateKeyBlocks(ctx context.Context, repo, file string, chunks []changedChunk) ([]Finding, error) { + if len(chunks) == 0 { + return nil, nil + } + data, err := gitOutput(ctx, repo, "show", "HEAD:"+file) + if err != nil { + return nil, err + } + var added []lineRange + for _, chunk := range chunks { + added = append(added, lineRange{Start: chunk.StartLine, End: chunk.endLine()}) + } + var out []Finding + for _, block := range privateKeyBlocks(string(data)) { + if !rangesIntersectAny(block, added) { + continue + } + out = append(out, newFinding("public_content_private_key_block", file, block.Start, "file", "private key block")) + } + return out, nil +} + +func privateKeyBlocks(text string) []lineRange { + lines := strings.Split(text, "\n") + var out []lineRange + inPrivateKey := false + start := 0 + for i, line := range lines { + lineNo := i + 1 + if !inPrivateKey && strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) { + inPrivateKey = true + start = lineNo + } + if inPrivateKey && strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) { + out = append(out, lineRange{Start: start, End: lineNo}) + inPrivateKey = false + } + } + return out +} + +func rangesIntersectAny(block lineRange, ranges []lineRange) bool { + for _, r := range ranges { + if block.Start <= r.End && r.Start <= block.End { + return true + } + } + return false +} + +func appendUniqueFindings(items []Finding, additions ...Finding) []Finding { + for _, addition := range additions { + duplicate := false + for _, item := range items { + if item.Rule == addition.Rule && + item.File == addition.File && + item.Line == addition.Line && + item.Source == addition.Source { + duplicate = true + break + } + } + if !duplicate { + items = append(items, addition) + } + } + return items +} + +func gitOutput(ctx context.Context, repo string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = repo + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, stderr.Bytes()) + } + return stdout.Bytes(), nil +} diff --git a/internal/qualitygate/publiccontent/collect_test.go b/internal/qualitygate/publiccontent/collect_test.go new file mode 100644 index 00000000..5ea92779 --- /dev/null +++ b/internal/qualitygate/publiccontent/collect_test.go @@ -0,0 +1,885 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package publiccontent + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) { + repo := t.TempDir() + runGit(t, repo, "init") + runGit(t, repo, "config", "user.email", "test@example.com") + runGit(t, repo, "config", "user.name", "Test User") + + writeFile(t, filepath.Join(repo, "baseline.md"), `BASE_`+`TOKEN="baseline-only" +`) + runGit(t, repo, "add", "baseline.md") + runGit(t, repo, "commit", "-m", "base") + + writeFile(t, filepath.Join(repo, "docs", "public.md"), `# Public change + +api_`+`key = "example-public-key" +`) + runGit(t, repo, "add", "docs/public.md") + runGit(t, repo, "commit", "-m", "add public doc", "-m", "Change"+"-Id: I0123456789abcdef0123456789abcdef01234567") + + metadataPath := filepath.Join(repo, "pr-metadata.json") + writeFile(t, metadataPath, `{"title":"publish public docs","body":"Reviewed`+`-on: https://review.example.test/c/project/+/123"}`) + + got, err := Collect(context.Background(), Options{ + Repo: repo, + ChangedFrom: "HEAD~1", + MetadataPath: metadataPath, + }) + if err != nil { + t.Fatalf("Collect() error = %v", err) + } + + rules := findingRules(got) + for _, want := range []string{ + "public_content_generic_credential", + "public_content_change_id_trailer", + "public_content_reviewed_on_trailer", + } { + if !rules[want] { + t.Fatalf("missing rule %s in findings %#v", want, got) + } + } + for _, item := range got { + if item.File == "baseline.md" { + t.Fatalf("collector scanned unchanged baseline file: %#v", got) + } + } +} + +func TestCollectScansOnlyChangedLinesInChangedFiles(t *testing.T) { + repo := t.TempDir() + runGit(t, repo, "init") + runGit(t, repo, "config", "user.email", "test@example.com") + runGit(t, repo, "config", "user.name", "Test User") + + writeFile(t, filepath.Join(repo, "docs", "workflow.md"), "SECRET_TOKEN=legacy-example\npublic baseline\n") + runGit(t, repo, "add", "docs/workflow.md") + runGit(t, repo, "commit", "-m", "base") + + writeFile(t, filepath.Join(repo, "docs", "workflow.md"), "SECRET_TOKEN=legacy-example\npublic baseline\nnew public line\n") + runGit(t, repo, "add", "docs/workflow.md") + runGit(t, repo, "commit", "-m", "add public line") + + metadataPath := filepath.Join(repo, "pr-metadata.json") + writeFile(t, metadataPath, `{}`) + + got, err := Collect(context.Background(), Options{ + Repo: repo, + ChangedFrom: "HEAD~1", + MetadataPath: metadataPath, + }) + if err != nil { + t.Fatalf("Collect() error = %v", err) + } + for _, item := range got { + if item.Rule == "public_content_generic_credential" && item.File == "docs/workflow.md" { + t.Fatalf("collector scanned unchanged legacy line in changed file: %#v", got) + } + } +} + +func TestCollectSemanticCandidatesStoreSanitizedReviewText(t *testing.T) { + repo := t.TempDir() + runGit(t, repo, "init") + runGit(t, repo, "config", "user.email", "test@example.com") + runGit(t, repo, "config", "user.name", "Test User") + + writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n") + runGit(t, repo, "add", "docs/public.md") + runGit(t, repo, "commit", "-m", "base") + + raw := "private launch plan for alpha-service rollout on Friday with SERVICE_" + "TOKEN=real-" + "secret-value" + writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n") + runGit(t, repo, "add", "docs/public.md") + runGit(t, repo, "commit", "-m", "add semantic candidate") + + metadataPath := filepath.Join(repo, "pr-metadata.json") + writeFile(t, metadataPath, `{}`) + + got, err := Collect(context.Background(), Options{ + Repo: repo, + ChangedFrom: "HEAD~1", + MetadataPath: metadataPath, + }) + if err != nil { + t.Fatalf("Collect() error = %v", err) + } + var found bool + for _, item := range got { + if item.Rule != "public_content_semantic_candidate" || item.File != "docs/public.md" { + continue + } + found = true + if !strings.Contains(item.Excerpt, "alpha-service rollout on Friday") { + t.Fatalf("semantic candidate should include sanitized review text, got %#v", item) + } + if strings.Contains(item.Excerpt, "real-"+"secret-value") { + t.Fatalf("semantic candidate leaked credential value: %#v", item) + } + if !strings.Contains(item.Excerpt, "SERVICE_TOKEN=") { + t.Fatalf("semantic candidate should redact credentials in review text, got %#v", item) + } + if !strings.Contains(item.Excerpt, "semantic signals") || !strings.Contains(item.Excerpt, "roadmap_timing") { + t.Fatalf("semantic candidate excerpt should preserve semantic signals, got %#v", item) + } + } + if !found { + t.Fatalf("missing semantic candidate in findings %#v", got) + } +} + +func TestCollectSemanticCandidatesDoNotLeakWhitespaceCredentialTail(t *testing.T) { + repo := newGitRepo(t) + writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n") + runGit(t, repo, "add", "docs/public.md") + runGit(t, repo, "commit", "-m", "base") + + raw := "private launch plan for internal rollout on Friday with SERVICE_" + "TOKEN=\"real " + "secret value\"" + writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n") + runGit(t, repo, "add", "docs/public.md") + runGit(t, repo, "commit", "-m", "add semantic candidate") + + got := collectFromPreviousCommit(t, repo) + for _, item := range got { + if item.Rule != "public_content_semantic_candidate" || item.File != "docs/public.md" { + continue + } + if strings.Contains(item.Excerpt, "secret value") || strings.Contains(item.Excerpt, "real "+"secret value") { + t.Fatalf("semantic candidate leaked credential tail: %#v", item) + } + if !strings.Contains(item.Excerpt, "SERVICE_TOKEN=") { + t.Fatalf("semantic candidate should redact full credential assignment, got %#v", item) + } + return + } + t.Fatalf("missing semantic candidate in findings %#v", got) +} + +func TestCollectJSONBearerHeadersDoNotLeakIntoSemanticCandidates(t *testing.T) { + repo := newGitRepo(t) + writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n") + runGit(t, repo, "add", "docs/public.md") + runGit(t, repo, "commit", "-m", "base") + + token := "abcdefghijklmnopqrstuvwxyz" + raw := "private launch plan for internal rollout on Friday with " + + `{"headers":{"Authorization":"Bearer ` + token + `"}}` + writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n") + runGit(t, repo, "add", "docs/public.md") + runGit(t, repo, "commit", "-m", "add json bearer") + + got := collectFromPreviousCommit(t, repo) + requireFinding(t, got, "docs/public.md", "public_content_bearer_header") + for _, item := range got { + if item.File != "docs/public.md" { + continue + } + if strings.Contains(item.Excerpt, token) { + t.Fatalf("finding leaked JSON bearer token: %#v", item) + } + } +} + +func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) { + repo := newGitRepo(t) + writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n") + runGit(t, repo, "add", "docs/public.json") + runGit(t, repo, "commit", "-m", "base") + + writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{ + `{"access_` + `token":"real-json-token"}`, + `{"client_` + `secret": "real ` + `secret value"}`, + `{"tenantAccess` + `Token":"real-tenant-camel-token"}`, + `{"github` + `Token":"real-github-token"}`, + `{"vendorApi` + `Key":"real-vendor-key"}`, + `{"slackBot` + `Token":"xoxb-real-token"}`, + }, "\n")+"\n") + runGit(t, repo, "add", "docs/public.json") + runGit(t, repo, "commit", "-m", "add json config") + + got := collectFromPreviousCommit(t, repo) + var count int + for _, item := range got { + if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" { + count++ + for _, forbidden := range []string{ + "real-json-token", + "real secret value", + "real-tenant-camel-token", + "real-github-token", + "real-vendor-key", + "xoxb-real-token", + } { + if strings.Contains(item.Excerpt, forbidden) { + t.Fatalf("JSON credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt) + } + } + } + } + if count != 6 { + t.Fatalf("JSON credential findings = %d, want 6: %#v", count, got) + } +} + +func TestCollectAllowsBenignJSONTokenFields(t *testing.T) { + repo := newGitRepo(t) + writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n") + runGit(t, repo, "add", "docs/public.json") + runGit(t, repo, "commit", "-m", "base") + + writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{ + `{"tokenizer":"cl100k_base"}`, + `{"token_count": 42}`, + `{"page_token":"next"}`, + `{"next_page_token":"next"}`, + `{"file_token":"file-example"}`, + `{"doc_token":"doc-example"}`, + `{"node_token":"node-example"}`, + `{"wiki_token":"wikcn_public_doc_example"}`, + `{"folder_token":"folder-example"}`, + `{"obj_token":"obj-example"}`, + `{"spreadsheet_token":"sheet-example"}`, + `{"parent_node_token":"parent-example"}`, + `{"origin_node_token":"origin-example"}`, + `{"drive_route_token":"route-example"}`, + `{"token":""}`, + `{"token":"wiki_token"}`, + `{"token_url":"https://example.com/oauth/token"}`, + `{"token_endpoint":"https://example.com/oauth/token"}`, + `{"token_format":"Bearer"}`, + `{"secret_name":"public-example-secret"}`, + `{"base_token":"base-example"}`, + `{"app_token":"app-example"}`, + `{"sync_token":"sync-example"}`, + `{"parent_token":"parent-example"}`, + `{"target_token":"target-example"}`, + `{"parent_file_token":"parent-file-example"}`, + `{"refresh_token_expires_in": 7200}`, + `{"access_token_expires_in": 7200}`, + `{"token_expires_in": 7200}`, + `{"token_status":"active"}`, + }, "\n")+"\n") + runGit(t, repo, "add", "docs/public.json") + runGit(t, repo, "commit", "-m", "add benign json token fields") + + got := collectFromPreviousCommit(t, repo) + for _, item := range got { + if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" { + t.Fatalf("benign JSON token field should not be credential finding: %#v", got) + } + } +} + +func TestCollectDetectsAngleWrappedRealisticCredentialValues(t *testing.T) { + repo := newGitRepo(t) + writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n") + runGit(t, repo, "add", "docs/config.yaml") + runGit(t, repo, "commit", "-m", "base") + stripeLike := "sk_" + "live_1234567890abcdef" + patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234" + + writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{ + "API_KEY: <" + stripeLike + ">", + "SECRET_TOKEN: <" + patLike + ">", + "CLIENT_SECRET: ", + }, "\n")+"\n") + runGit(t, repo, "add", "docs/config.yaml") + runGit(t, repo, "commit", "-m", "add credential config") + + got := collectFromPreviousCommit(t, repo) + var count int + for _, item := range got { + if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" { + count++ + } + } + if count != 3 { + t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got) + } +} + +func TestCollectDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) { + repo := newGitRepo(t) + writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n") + runGit(t, repo, "add", "docs/public.json") + runGit(t, repo, "commit", "-m", "base") + stripeLike := "sk_" + "live_1234567890abcdef" + patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234" + + writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{ + `{"access_token_expires_in":"` + patLike + `"}`, + `{"refresh_token_expires_in":"` + stripeLike + `"}`, + `{"client_secret_status":"real-client-secret-value"}`, + `{"client_secret_name":"real-client-secret-value"}`, + `{"app_token":"` + patLike + `"}`, + `{"sync_token":"` + stripeLike + `"}`, + `{"target_token":"real-client-secret-value"}`, + }, "\n")+"\n") + runGit(t, repo, "add", "docs/public.json") + runGit(t, repo, "commit", "-m", "add credential-shaped benign fields") + + got := collectFromPreviousCommit(t, repo) + var count int + for _, item := range got { + if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" { + count++ + } + } + if count != 7 { + t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got) + } +} + +func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) { + repo := newGitRepo(t) + writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n") + runGit(t, repo, "add", "docs/config.yaml") + runGit(t, repo, "commit", "-m", "base") + + writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{ + "API_KEY_NAME: prod_key", + "CLIENT_SECRET_NAME: prod_secret", + "SECRET_STATUS: prod_secret", + }, "\n")+"\n") + runGit(t, repo, "add", "docs/config.yaml") + runGit(t, repo, "commit", "-m", "add credential config") + + got := collectFromPreviousCommit(t, repo) + var count int + for _, item := range got { + if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" { + count++ + } + } + if count != 3 { + t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got) + } +} + +func TestCollectDetectsAccessKeyCredentials(t *testing.T) { + repo := newGitRepo(t) + writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n") + runGit(t, repo, "add", "docs/config.yaml") + runGit(t, repo, "commit", "-m", "base") + accessKey := "AK" + "IAIOSFODNN7EXAMPX" + + writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{ + "AWS_ACCESS_KEY_ID: " + accessKey, + "ACCESS_KEY_ID: " + accessKey, + "ACCESS_KEY: " + accessKey, + }, "\n")+"\n") + runGit(t, repo, "add", "docs/config.yaml") + runGit(t, repo, "commit", "-m", "add access key config") + + got := collectFromPreviousCommit(t, repo) + var count int + for _, item := range got { + if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" { + continue + } + count++ + if strings.Contains(item.Excerpt, "AKIAIOSFODNN7EXAMPX") { + t.Fatalf("access key finding leaked value in excerpt %q", item.Excerpt) + } + } + if count != 3 { + t.Fatalf("access key credential findings = %d, want 3: %#v", count, got) + } +} + +func TestCollectDetectsPrivateKeyAssignments(t *testing.T) { + repo := newGitRepo(t) + writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n") + runGit(t, repo, "add", "docs/config.yaml") + runGit(t, repo, "commit", "-m", "base") + + privateKey := "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0t" + writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{ + "PRIVATE_KEY: " + privateKey, + "SSH_PRIVATE_KEY: " + privateKey, + "JWT_PRIVATE_KEY: " + privateKey, + "SIGNING_PRIVATE_KEY: " + privateKey, + }, "\n")+"\n") + runGit(t, repo, "add", "docs/config.yaml") + runGit(t, repo, "commit", "-m", "add private key config") + + got := collectFromPreviousCommit(t, repo) + var count int + for _, item := range got { + if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" { + continue + } + count++ + if strings.Contains(item.Excerpt, privateKey) { + t.Fatalf("private key finding leaked value in excerpt %q", item.Excerpt) + } + } + if count != 4 { + t.Fatalf("private key assignment findings = %d, want 4: %#v", count, got) + } +} + +func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) { + repo := newGitRepo(t) + writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n") + runGit(t, repo, "add", "docs/config.yaml") + runGit(t, repo, "commit", "-m", "base") + + writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{ + "API_KEY_OPENAI: prod_key", + "CLIENT_SECRET_GOOGLE: prod_secret", + "TOKEN_GITHUB: github_token", + "APP_PASSWORD_PROD: prod_password", + }, "\n")+"\n") + runGit(t, repo, "add", "docs/config.yaml") + runGit(t, repo, "commit", "-m", "add credential config") + + got := collectFromPreviousCommit(t, repo) + var count int + for _, item := range got { + if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" { + count++ + } + } + if count != 4 { + t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got) + } +} + +func TestCollectAllowsBenignUnquotedTokenFields(t *testing.T) { + repo := newGitRepo(t) + writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n") + runGit(t, repo, "add", "docs/config.yaml") + runGit(t, repo, "commit", "-m", "base") + + writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{ + "tokens: 128", + "token_type: bearer", + "max_tokens: 2000", + "completion_tokens: 200", + "prompt_tokens: 100", + }, "\n")+"\n") + runGit(t, repo, "add", "docs/config.yaml") + runGit(t, repo, "commit", "-m", "add benign token config") + + got := collectFromPreviousCommit(t, repo) + for _, item := range got { + if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" { + t.Fatalf("benign unquoted token field should not be credential finding: %#v", got) + } + } +} + +func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) { + repo := newGitRepo(t) + writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n") + runGit(t, repo, "add", "docs/config.yaml") + runGit(t, repo, "commit", "-m", "base") + + writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{ + "API_KEY_OPENAI: real-openai-key", + "TOKEN_GITHUB: real-github-token", + "CLIENT_SECRET_GOOGLE: real-google-secret", + "SECRET_KEY_BASE: real-secret-key-base", + "APP_PASSWORD_PROD: real-prod-password", + }, "\n")+"\n") + runGit(t, repo, "add", "docs/config.yaml") + runGit(t, repo, "commit", "-m", "add credential config") + + got := collectFromPreviousCommit(t, repo) + var count int + for _, item := range got { + if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" { + continue + } + count++ + for _, forbidden := range []string{ + "real-openai-key", + "real-github-token", + "real-google-secret", + "real-secret-key-base", + "real-prod-password", + } { + if strings.Contains(item.Excerpt, forbidden) { + t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt) + } + } + } + if count != 5 { + t.Fatalf("credential suffix variants findings = %d, want 5: %#v", count, got) + } +} + +func TestCollectDetectsPrivateKeyWhenOnlyEndIsAdded(t *testing.T) { + repo := newGitRepo(t) + + writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n") + runGit(t, repo, "add", "docs/key.pem") + runGit(t, repo, "commit", "-m", "base") + + writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\nnew-body\n"+privateKeyEnd()) + runGit(t, repo, "add", "docs/key.pem") + runGit(t, repo, "commit", "-m", "complete key") + + got := collectFromPreviousCommit(t, repo) + requireFinding(t, got, "docs/key.pem", "public_content_private_key_block") +} + +func TestCollectDetectsPrivateKeyWhenOnlyBeginIsAdded(t *testing.T) { + repo := newGitRepo(t) + + writeFile(t, filepath.Join(repo, "docs", "key.pem"), "legacy-body\n"+privateKeyEnd()) + runGit(t, repo, "add", "docs/key.pem") + runGit(t, repo, "commit", "-m", "base") + + writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd()) + runGit(t, repo, "add", "docs/key.pem") + runGit(t, repo, "commit", "-m", "complete key") + + got := collectFromPreviousCommit(t, repo) + requireFinding(t, got, "docs/key.pem", "public_content_private_key_block") +} + +func TestCollectDetectsPrivateKeyWhenOnlyBodyIsAdded(t *testing.T) { + repo := newGitRepo(t) + + writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+privateKeyEnd()) + runGit(t, repo, "add", "docs/key.pem") + runGit(t, repo, "commit", "-m", "base") + + writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"new-body\n"+privateKeyEnd()) + runGit(t, repo, "add", "docs/key.pem") + runGit(t, repo, "commit", "-m", "add body") + + got := collectFromPreviousCommit(t, repo) + requireFinding(t, got, "docs/key.pem", "public_content_private_key_block") +} + +func TestCollectIgnoresUntouchedHistoricalPrivateKey(t *testing.T) { + repo := newGitRepo(t) + + writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd()) + runGit(t, repo, "add", "docs/key.pem") + runGit(t, repo, "commit", "-m", "base") + + writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd()) + writeFile(t, filepath.Join(repo, "docs", "public.md"), "public docs update\n") + runGit(t, repo, "add", "docs/public.md") + runGit(t, repo, "commit", "-m", "docs update") + + got := collectFromPreviousCommit(t, repo) + for _, item := range got { + if item.File == "docs/key.pem" && item.Rule == "public_content_private_key_block" { + t.Fatalf("collector reported untouched historical private key: %#v", got) + } + } +} + +func TestCollectIgnoresDeletedPrivateKeyLine(t *testing.T) { + repo := newGitRepo(t) + + writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd()) + runGit(t, repo, "add", "docs/key.pem") + runGit(t, repo, "commit", "-m", "base") + + writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+privateKeyEnd()) + runGit(t, repo, "add", "docs/key.pem") + runGit(t, repo, "commit", "-m", "remove body") + + got := collectFromPreviousCommit(t, repo) + for _, item := range got { + if item.File == "docs/key.pem" && item.Rule == "public_content_private_key_block" { + t.Fatalf("collector reported delete-only private key cleanup: %#v", got) + } + } +} + +func TestCollectSkipsOnlyKnownQualityGateFixtureFiles(t *testing.T) { + repo := t.TempDir() + runGit(t, repo, "init") + runGit(t, repo, "config", "user.email", "test@example.com") + runGit(t, repo, "config", "user.name", "Test User") + + writeFile(t, filepath.Join(repo, "README.md"), "base\n") + runGit(t, repo, "add", "README.md") + runGit(t, repo, "commit", "-m", "base") + + writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "collect_test.go"), "SECRET_TOKEN=fixture\n") + writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan_test.go"), "SECRET_TOKEN=fixture\n") + writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan.go"), "const privateKeyFixture = \""+privateKeyBeginPrefix+privateKeyMarker+"\"\n") + writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "rules.go"), "markers := []string{\"generated with automation\"}\n") + writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN=real-leak\n") + runGit(t, repo, "add", ".") + runGit(t, repo, "commit", "-m", "add scanner fixtures") + + metadataPath := filepath.Join(repo, "pr-metadata.json") + writeFile(t, metadataPath, `{}`) + + got, err := Collect(context.Background(), Options{ + Repo: repo, + ChangedFrom: "HEAD~1", + MetadataPath: metadataPath, + }) + if err != nil { + t.Fatalf("Collect() error = %v", err) + } + var foundOrdinaryTestLeak bool + for _, item := range got { + switch item.File { + case "internal/qualitygate/publiccontent/collect_test.go", + "internal/qualitygate/publiccontent/scan.go", + "internal/qualitygate/publiccontent/scan_test.go", + "internal/qualitygate/publiccontent/rules.go": + t.Fatalf("collector scanned known fixture or detector implementation file: %#v", got) + } + if item.File == "tests/e2e/new-public-workflow.test.sh" && item.Rule == "public_content_generic_credential" { + foundOrdinaryTestLeak = true + } + } + if !foundOrdinaryTestLeak { + t.Fatalf("collector should still scan ordinary test files for real leaks: %#v", got) + } +} + +func TestScanChangedFileDocumentsFixtureExclusions(t *testing.T) { + excluded := []string{ + "internal/qualitygate/publiccontent/collect_test.go", + "internal/qualitygate/publiccontent/rules.go", + "internal/qualitygate/publiccontent/scan.go", + "internal/qualitygate/publiccontent/scan_test.go", + } + for _, file := range excluded { + if scanChangedFile(file) { + t.Fatalf("scanChangedFile(%q) = true, want false for detector fixture/implementation path", file) + } + } + + included := []string{ + "internal/qualitygate/publiccontent/new_test.go", + "tests/e2e/new-public-workflow.test.sh", + "docs/public.md", + } + for _, file := range included { + if !scanChangedFile(file) { + t.Fatalf("scanChangedFile(%q) = false, want true", file) + } + } +} + +func TestCollectScansAddedLinesInSpecialPathNames(t *testing.T) { + repo := newGitRepo(t) + writeFile(t, filepath.Join(repo, "docs", "old.md"), "base\n") + runGit(t, repo, "add", ".") + runGit(t, repo, "commit", "-m", "base") + + writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN=space-value\n") + writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN=quote-value\n") + runGit(t, repo, "mv", "docs/old.md", "docs/new name.md") + writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN=rename-value\n") + runGit(t, repo, "add", ".") + runGit(t, repo, "commit", "-m", "add special paths") + + got := collectFromPreviousCommit(t, repo) + requireFinding(t, got, "docs/has space.md", "public_content_generic_credential") + requireFinding(t, got, `weird"quote.md`, "public_content_generic_credential") + requireFinding(t, got, "docs/new name.md", "public_content_generic_credential") +} + +func TestCollectScansBranchNameAsWarning(t *testing.T) { + repo := t.TempDir() + metadataPath := filepath.Join(repo, "pr-metadata.json") + writeFile(t, metadataPath, `{"branch":"bot/public-doc-update"}`) + got, err := Collect(context.Background(), Options{ + Repo: repo, + MetadataPath: metadataPath, + }) + if err != nil { + t.Fatalf("Collect() error = %v", err) + } + if len(got) != 1 || got[0].Rule != "public_content_automation_branch" { + t.Fatalf("branch findings = %#v", got) + } +} + +func TestCollectUsesExplicitBranchNameWhenDetached(t *testing.T) { + repo := newGitRepo(t) + writeFile(t, filepath.Join(repo, "README.md"), "base\n") + runGit(t, repo, "add", "README.md") + runGit(t, repo, "commit", "-m", "base") + runGit(t, repo, "checkout", "-b", "bot/public-doc-update") + writeFile(t, filepath.Join(repo, "docs.md"), "safe docs\n") + runGit(t, repo, "add", "docs.md") + runGit(t, repo, "commit", "-m", "docs") + head := strings.TrimSpace(string(runGitOutput(t, repo, "rev-parse", "HEAD"))) + runGit(t, repo, "checkout", "--detach", head) + + metadataPath := filepath.Join(repo, "pr-metadata.json") + writeFile(t, metadataPath, `{}`) + got, err := Collect(context.Background(), Options{ + Repo: repo, + MetadataPath: metadataPath, + BranchName: "bot/public-doc-update", + }) + if err != nil { + t.Fatalf("Collect() error = %v", err) + } + requireFinding(t, got, "branch", "public_content_automation_branch") +} + +func TestCollectUsesBranchEnvironmentWhenDetached(t *testing.T) { + repo := newGitRepo(t) + writeFile(t, filepath.Join(repo, "README.md"), "base\n") + runGit(t, repo, "add", "README.md") + runGit(t, repo, "commit", "-m", "base") + runGit(t, repo, "checkout", "-b", "bot/public-env-update") + writeFile(t, filepath.Join(repo, "docs.md"), "safe docs\n") + runGit(t, repo, "add", "docs.md") + runGit(t, repo, "commit", "-m", "docs") + head := strings.TrimSpace(string(runGitOutput(t, repo, "rev-parse", "HEAD"))) + runGit(t, repo, "checkout", "--detach", head) + t.Setenv("GITHUB_HEAD_REF", "bot/public-env-update") + + metadataPath := filepath.Join(repo, "pr-metadata.json") + writeFile(t, metadataPath, `{}`) + got, err := Collect(context.Background(), Options{ + Repo: repo, + MetadataPath: metadataPath, + }) + if err != nil { + t.Fatalf("Collect() error = %v", err) + } + requireFinding(t, got, "branch", "public_content_automation_branch") +} + +func TestCollectPreservesFindingAttributionForChangedLines(t *testing.T) { + repo := newGitRepo(t) + writeFile(t, filepath.Join(repo, "docs", "auth.md"), "intro\n") + runGit(t, repo, "add", "docs/auth.md") + runGit(t, repo, "commit", "-m", "base") + + writeFile(t, filepath.Join(repo, "docs", "auth.md"), "intro\nAuthorization: Bearer abcdefghijklmnopqrstuvwxyz\n") + runGit(t, repo, "add", "docs/auth.md") + runGit(t, repo, "commit", "-m", "add auth docs") + + got := collectFromPreviousCommit(t, repo) + for _, item := range got { + if item.Rule == "public_content_bearer_header" { + if item.File != "docs/auth.md" || item.Line != 2 || item.Source != "file" { + t.Fatalf("changed-line attribution = %#v", item) + } + return + } + } + t.Fatalf("missing bearer finding: %#v", got) +} + +func TestAppendUniqueFindingsDeduplicatesByRuleFileLineAndSource(t *testing.T) { + base := []Finding{newFinding("public_content_private_key_block", "docs/key.pem", 1, "file", "private key block")} + got := appendUniqueFindings(base, + newFinding("public_content_private_key_block", "docs/key.pem", 1, "file", "private key block"), + newFinding("public_content_private_key_block", "docs/key.pem", 2, "file", "private key block"), + ) + if len(got) != 2 { + t.Fatalf("appendUniqueFindings len = %d, want 2: %#v", len(got), got) + } +} + +func newGitRepo(t *testing.T) string { + t.Helper() + repo := t.TempDir() + runGit(t, repo, "init") + runGit(t, repo, "config", "user.email", "test@example.com") + runGit(t, repo, "config", "user.name", "Test User") + return repo +} + +func privateKeyBegin() string { + return privateKeyBeginPrefix + privateKeyMarker + "\n" +} + +func privateKeyEnd() string { + return privateKeyEndPrefix + privateKeyMarker + "\n" +} + +func collectFromPreviousCommit(t *testing.T, repo string) []Finding { + t.Helper() + metadataPath := filepath.Join(repo, "pr-metadata.json") + writeFile(t, metadataPath, `{}`) + got, err := Collect(context.Background(), Options{ + Repo: repo, + ChangedFrom: "HEAD~1", + MetadataPath: metadataPath, + }) + if err != nil { + t.Fatalf("Collect() error = %v", err) + } + return got +} + +func requireFinding(t *testing.T, got []Finding, file, rule string) { + t.Helper() + for _, item := range got { + if item.File == file && item.Rule == rule { + return + } + } + t.Fatalf("missing %s in %s findings: %#v", rule, file, got) +} + +func TestCollectRequiresValidMetadataJSON(t *testing.T) { + repo := t.TempDir() + metadataPath := filepath.Join(repo, "pr-metadata.json") + writeFile(t, metadataPath, `{"title":`) + + _, err := Collect(context.Background(), Options{Repo: repo, MetadataPath: metadataPath}) + if err == nil || !strings.Contains(err.Error(), "public content metadata") { + t.Fatalf("Collect() error = %v, want metadata parse error", err) + } +} + +func runGit(t *testing.T, repo string, args ...string) { + t.Helper() + if len(args) > 0 && args[0] == "commit" { + args = append([]string{"commit", "--no-verify"}, args[1:]...) + } + cmd := exec.Command("git", args...) + cmd.Dir = repo + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } +} + +func runGitOutput(t *testing.T, repo string, args ...string) []byte { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = repo + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } + return out +} + +func writeFile(t *testing.T, path, data string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(data), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/qualitygate/publiccontent/comment_audit.go b/internal/qualitygate/publiccontent/comment_audit.go new file mode 100644 index 00000000..760fdcf9 --- /dev/null +++ b/internal/qualitygate/publiccontent/comment_audit.go @@ -0,0 +1,11 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package publiccontent + +func ScanComment(kind, body string) []Finding { + if kind == "" { + kind = "comment" + } + return scanText(kind, "comment", body, false) +} diff --git a/internal/qualitygate/publiccontent/comment_audit_test.go b/internal/qualitygate/publiccontent/comment_audit_test.go new file mode 100644 index 00000000..6d05e675 --- /dev/null +++ b/internal/qualitygate/publiccontent/comment_audit_test.go @@ -0,0 +1,19 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package publiccontent + +import "testing" + +func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) { + got := ScanComment("issue_comment", `The published comment included /tmp/harness`+`-agent/run and CCM`+`-Harness: stage-4`) + rules := findingRules(got) + if !rules["public_content_harness_metadata"] || !rules["public_content_ccm_harness_trailer"] { + t.Fatalf("comment audit findings = %#v", got) + } + for _, item := range got { + if item.File != "issue_comment" { + t.Fatalf("comment finding file = %q, want issue_comment", item.File) + } + } +} diff --git a/internal/qualitygate/publiccontent/metadata.go b/internal/qualitygate/publiccontent/metadata.go new file mode 100644 index 00000000..14fd9907 --- /dev/null +++ b/internal/qualitygate/publiccontent/metadata.go @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package publiccontent + +import ( + "encoding/json" + "fmt" + + "github.com/larksuite/cli/internal/vfs" +) + +func LoadMetadata(path string) (Metadata, error) { + if path == "" { + return Metadata{}, nil + } + data, err := vfs.ReadFile(path) + if err != nil { + return Metadata{}, fmt.Errorf("public content metadata: %w", err) + } + if len(data) == 0 { + return Metadata{}, nil + } + var out Metadata + if err := json.Unmarshal(data, &out); err != nil { + return Metadata{}, fmt.Errorf("public content metadata: %w", err) + } + return out, nil +} + +func scanMetadata(m Metadata) []Finding { + text := "" + if m.Title != "" { + text += "title: " + m.Title + "\n" + } + if m.Body != "" { + text += "body:\n" + m.Body + "\n" + } + if text == "" { + return nil + } + out := scanText("pull_request_metadata", "metadata", text, false) + out = append(out, semanticCandidate("pull_request_metadata", "metadata", text, 1)...) + return out +} diff --git a/internal/qualitygate/publiccontent/metadata_test.go b/internal/qualitygate/publiccontent/metadata_test.go new file mode 100644 index 00000000..a9e6616c --- /dev/null +++ b/internal/qualitygate/publiccontent/metadata_test.go @@ -0,0 +1,22 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package publiccontent + +import ( + "path/filepath" + "testing" +) + +func TestLoadMetadataReadsTitleAndBody(t *testing.T) { + path := filepath.Join(t.TempDir(), "metadata.json") + writeFile(t, path, `{"title":"public change","body":"pass`+`word = \"example-password\""}`) + + got, err := LoadMetadata(path) + if err != nil { + t.Fatalf("LoadMetadata() error = %v", err) + } + if got.Title != "public change" || got.Body == "" { + t.Fatalf("metadata = %#v", got) + } +} diff --git a/internal/qualitygate/publiccontent/rules.go b/internal/qualitygate/publiccontent/rules.go new file mode 100644 index 00000000..e517c31f --- /dev/null +++ b/internal/qualitygate/publiccontent/rules.go @@ -0,0 +1,441 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package publiccontent + +import ( + "net/url" + "path/filepath" + "regexp" + "strings" + + "github.com/larksuite/cli/internal/qualitygate/report" +) + +var ( + credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*[:=]\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\s,}\]]+))`) + jwtLikeRE = regexp.MustCompile(`\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`) + credentialURLRE = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^/\s:@]*:[^@\s/]+@[^)\s]+`) + bearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+|["']Authorization["']\s*:\s*["']Bearer\s+)[A-Za-z0-9._+/=-]{12,}`) + semanticBearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+[^"'\s,}\]]+|["']Authorization["']\s*:\s*["']Bearer\s+[^"'\\\s,}\]]+)`) + changeIDTrailerRE = regexp.MustCompile(`(?i)^\s*Change-Id:\s*\S+`) + reviewedOnTrailerRE = regexp.MustCompile(`(?i)^\s*Reviewed-on:\s*\S+`) + ccmHarnessTrailerRE = regexp.MustCompile(`(?i)\bCCM-Harness:\s*\S+`) + privateIPv4RE = regexp.MustCompile(`\b(?:10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3}|172\.(?:1[6-9]|2[0-9]|3[0-1])\.[0-9]{1,3}\.[0-9]{1,3})\b`) + automationBranchRE = regexp.MustCompile(`(?i)(^|/)(bot|automation)[-/]`) +) + +func actionForRule(rule string) report.Action { + switch rule { + case "public_content_generic_credential", + "public_content_private_key_block", + "public_content_jwt_like_token", + "public_content_bearer_header", + "public_content_credential_url", + "public_content_change_id_trailer", + "public_content_reviewed_on_trailer", + "public_content_provenance_marker", + "public_content_detector_fingerprint", + "public_content_harness_metadata", + "public_content_ccm_harness_trailer": + return report.ActionReject + case "public_content_private_ipv4", + "public_content_automation_branch": + return report.ActionWarning + default: + return report.ActionWarning + } +} + +func isPlaceholderValue(value string) bool { + trimmed := strings.Trim(value, `"'`) + normalized := strings.ToLower(trimmed) + if normalized == "" || + normalized == "=" || + percentWrappedPlaceholder(normalized) || + angleWrappedPlaceholder(normalized) || + urlWithAnglePlaceholder(normalized) || + isCredentialReferenceValue(trimmed) { + return true + } + return namedPlaceholderValue(normalized) +} + +func namedPlaceholderValue(value string) bool { + switch value { + case "...", "placeholder", "redacted", "", "xxxx", "test-secret": + return true + } + return strings.Contains(value, "cli_example") || allXPlaceholder(value) +} + +func allXPlaceholder(value string) bool { + if len(value) < 4 { + return false + } + for _, r := range value { + if r != 'x' { + return false + } + } + return true +} + +func urlWithAnglePlaceholder(value string) bool { + if !strings.Contains(value, "://") || + !strings.Contains(value, "<") || + !strings.Contains(value, ">") { + return false + } + return !urlRemainderLooksCredentialLike(removeAnglePlaceholders(value)) +} + +func removeAnglePlaceholders(value string) string { + var out strings.Builder + for len(value) > 0 { + start := strings.Index(value, "<") + if start < 0 { + out.WriteString(value) + break + } + out.WriteString(value[:start]) + end := strings.Index(value[start+1:], ">") + if end < 0 { + out.WriteString(value[start:]) + break + } + value = value[start+end+2:] + } + return out.String() +} + +func urlRemainderLooksCredentialLike(value string) bool { + normalized := strings.ToLower(value) + for _, marker := range []string{ + "secret", + "token", + "password", + "passwd", + "api_key", + "apikey", + "private_key", + "privatekey", + "client_secret", + "clientsecret", + } { + if strings.Contains(normalized, marker) { + return true + } + } + for _, part := range strings.FieldsFunc(normalized, func(r rune) bool { + return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-') + }) { + if credentialShapedIdentifier(part) || longCredentialSegment(part) { + return true + } + } + return false +} + +func longCredentialSegment(value string) bool { + if len(value) < 16 { + return false + } + var hasLetter, hasDigit bool + for _, r := range value { + switch { + case r >= 'a' && r <= 'z': + hasLetter = true + case r >= '0' && r <= '9': + hasDigit = true + case r == '_' || r == '-': + default: + return false + } + } + return hasLetter || hasDigit +} + +func isCredentialReferenceValue(value string) bool { + normalized := strings.ToLower(value) + switch { + case strings.HasPrefix(normalized, "${{"): + return githubExpressionReference(normalized) + case strings.HasPrefix(normalized, "$("): + return !commandSubstitutionLooksCredentialLike(normalized) + case strings.HasPrefix(normalized, "process.env."): + return credentialReferenceIdentifier(strings.TrimPrefix(normalized, "process.env.")) + case strings.HasPrefix(normalized, "${"): + return credentialReferenceIdentifier(strings.TrimSuffix(strings.TrimPrefix(normalized, "${"), "}")) + case strings.HasPrefix(value, "$"): + return credentialReferenceIdentifier(strings.TrimPrefix(normalized, "$")) + default: + return false + } +} + +func commandSubstitutionLooksCredentialLike(value string) bool { + if !strings.HasPrefix(value, "$(") || !strings.HasSuffix(value, ")") { + return false + } + inner := strings.TrimSuffix(strings.TrimPrefix(value, "$("), ")") + for _, part := range strings.FieldsFunc(inner, func(r rune) bool { + return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-') + }) { + if credentialShapedIdentifier(part) || longCredentialSegment(part) { + return true + } + } + return false +} + +func githubExpressionReference(value string) bool { + if !strings.HasPrefix(value, "${{") || !strings.HasSuffix(value, "}}") { + return false + } + expr := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}")) + switch { + case strings.HasPrefix(expr, "secrets."): + return dottedReferenceIdentifier(strings.TrimPrefix(expr, "secrets.")) + case strings.HasPrefix(expr, "env."): + return dottedReferenceIdentifier(strings.TrimPrefix(expr, "env.")) + case strings.HasPrefix(expr, "vars."): + return dottedReferenceIdentifier(strings.TrimPrefix(expr, "vars.")) + case expr == "github.token": + return true + default: + return false + } +} + +func dottedReferenceIdentifier(value string) bool { + if value == "" { + return false + } + for _, part := range strings.Split(value, ".") { + if !referenceIdentifier(part) { + return false + } + } + return true +} + +func credentialReferenceIdentifier(value string) bool { + return referenceIdentifier(value) && !credentialShapedIdentifier(value) +} + +func referenceIdentifier(value string) bool { + if value == "" { + return false + } + for i, r := range value { + switch { + case r >= 'a' && r <= 'z': + case r >= '0' && r <= '9' && i > 0: + case r == '_' && i > 0: + default: + return false + } + } + return true +} + +func angleWrappedPlaceholder(value string) bool { + if len(value) < 3 || !strings.HasPrefix(value, "<") || !strings.HasSuffix(value, ">") { + return false + } + return anglePlaceholderIdentifier(strings.Trim(value, "<>")) +} + +func percentWrappedPlaceholder(value string) bool { + if len(value) < 3 || !strings.HasPrefix(value, "%") || !strings.HasSuffix(value, "%") { + return false + } + inner := strings.Trim(value, "%") + return delimitedPlaceholderIdentifier(inner) && !credentialShapedIdentifier(inner) +} + +func delimitedPlaceholderIdentifier(value string) bool { + if value == "" { + return false + } + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' { + continue + } + return false + } + return true +} + +func anglePlaceholderIdentifier(value string) bool { + if value == "" { + return false + } + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' { + continue + } + return false + } + if credentialShapedIdentifier(value) { + return false + } + switch value { + case "token", + "id", + "userid", + "openid", + "key", + "secret", + "password", + "api-key", + "user-id", + "open-id", + "client-secret", + "access-token", + "refresh-token", + "auth-token", + "bearer-token", + "session-token", + "service-token": + return true + } + for _, suffix := range []string{"_token", "_id", "_key", "_secret", "_password"} { + if strings.HasSuffix(value, suffix) { + return true + } + } + for _, suffix := range []string{"-token", "-id", "-key", "-secret", "-password"} { + if strings.HasSuffix(value, suffix) { + return true + } + } + return false +} + +func credentialShapedValue(value string) bool { + normalized := strings.ToLower(strings.Trim(value, `"'<>`)) + return credentialShapedIdentifier(normalized) +} + +func credentialShapedIdentifier(value string) bool { + switch { + case strings.HasPrefix(value, "sk_live_"), + strings.HasPrefix(value, "sk_test_"), + strings.HasPrefix(value, "ghp_"), + strings.HasPrefix(value, "gho_"), + strings.HasPrefix(value, "ghu_"), + strings.HasPrefix(value, "github_pat_"), + strings.HasPrefix(value, "xoxb_"), + strings.HasPrefix(value, "xoxp_"), + strings.HasPrefix(value, "xoxa_"): + return true + case strings.HasPrefix(value, "real-") && + (strings.Contains(value, "secret") || + strings.Contains(value, "token") || + strings.Contains(value, "key") || + strings.Contains(value, "password")): + return true + default: + return false + } +} + +func resourceTokenPlaceholderValue(value string) bool { + normalized := strings.ToLower(strings.Trim(value, `"'`)) + switch normalized { + case "wiki_token", + "folder_token", + "obj_token", + "spreadsheet_token", + "file_token", + "doc_token", + "node_token", + "parent_node_token", + "origin_node_token", + "drive_route_token": + return true + default: + return minuteTokenFixturePlaceholder(normalized) + } +} + +func minuteTokenFixturePlaceholder(value string) bool { + if value == "minute_no_meta" { + return true + } + suffix, ok := strings.CutPrefix(value, "minute_") + if !ok || suffix == "" { + return false + } + for _, r := range suffix { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func provenanceMarker(line string) bool { + normalized := strings.ToLower(line) + markers := []string{ + "generat" + "ed by tool", + "creat" + "ed by tool", + "generat" + "ed by automation", + "creat" + "ed by automation", + "machine-" + "generated", + "generated with automated", + "generated with automation", + "🤖 generated", + } + for _, marker := range markers { + if strings.Contains(normalized, marker) { + return true + } + } + if strings.HasPrefix(normalized, "co-authored-by:") && + (strings.Contains(normalized, "" + } + u.User = url.UserPassword("", "") + return u.String() +} diff --git a/internal/qualitygate/publiccontent/scan.go b/internal/qualitygate/publiccontent/scan.go new file mode 100644 index 00000000..9e9f72a5 --- /dev/null +++ b/internal/qualitygate/publiccontent/scan.go @@ -0,0 +1,797 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package publiccontent + +import ( + "fmt" + "path/filepath" + "sort" + "strings" + "unicode" +) + +const ( + privateKeyBeginPrefix = "-----" + "BEGIN " + privateKeyEndPrefix = "-----" + "END " + privateKeyMarker = "PRIVATE " + "KEY-----" +) + +func ScanFile(path string, data []byte) []Finding { + return scanText(filepath.ToSlash(path), "file", string(data), isDetectorRuleFile(path)) +} + +func semanticCandidate(file, source, text string, line int) []Finding { + excerpt := redactedSemanticExcerpt(text) + if excerpt == "" { + return nil + } + return []Finding{newFinding("public_content_semantic_candidate", file, line, source, excerpt)} +} + +func scanText(file, source, text string, detectorFile bool) []Finding { + var out []Finding + lines := strings.Split(text, "\n") + inPrivateKey := false + privateKeyLine := 0 + for i, line := range lines { + lineNo := i + 1 + if strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) { + inPrivateKey = true + privateKeyLine = lineNo + } + if inPrivateKey && strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) { + out = append(out, newFinding("public_content_private_key_block", file, privateKeyLine, source, "private key block")) + inPrivateKey = false + } + for _, match := range credentialAssignmentRE.FindAllStringSubmatch(line, -1) { + if !isCredentialAssignmentMatch(match[0]) { + continue + } + value := credentialAssignmentValue(match) + keyName, _ := normalizedCredentialAssignmentKey(match[0]) + if value == "" || + isNonSecretLiteralValue(value) || + isBenignCodeCredentialExpression(file, value) || + isPlaceholderValue(value) || + isResourceTokenPlaceholderAssignment(keyName, value) { + continue + } + if looksLikeEqualityComparison(value) { + continue + } + out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0]))) + } + for _, match := range jwtLikeRE.FindAllString(line, -1) { + if isSchemaDottedIdentifier(line, match) { + continue + } + out = append(out, newFinding("public_content_jwt_like_token", file, lineNo, source, redactToken(match))) + } + for range bearerHeaderRE.FindAllString(line, -1) { + out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer ")) + } + for _, match := range credentialURLRE.FindAllString(line, -1) { + if isPlaceholderCredentialURL(match) { + continue + } + out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match))) + } + for _, match := range privateIPv4RE.FindAllString(line, -1) { + out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match)) + } + if source == "branch" && automationBranchRE.MatchString(line) { + out = append(out, newFinding("public_content_automation_branch", file, lineNo, source, "automation branch marker")) + } + switch { + case changeIDTrailerRE.MatchString(line): + out = append(out, newFinding("public_content_change_id_trailer", file, lineNo, source, "Change-Id: ")) + case reviewedOnTrailerRE.MatchString(line): + out = append(out, newFinding("public_content_reviewed_on_trailer", file, lineNo, source, "Reviewed-on: ")) + case ccmHarnessTrailerRE.MatchString(line): + out = append(out, newFinding("public_content_ccm_harness_trailer", file, lineNo, source, "CCM-Harness: ")) + } + if provenanceMarker(line) { + out = append(out, newFinding("public_content_provenance_marker", file, lineNo, source, "provenance marker")) + } + if strings.Contains(line, "/tmp/harness-agent") { + out = append(out, newFinding("public_content_harness_metadata", file, lineNo, source, "/tmp/harness-agent")) + } + if detectorFile && detectorFingerprint(line) { + out = append(out, newFinding("public_content_detector_fingerprint", file, lineNo, source, "public detector fingerprint")) + } + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].File != out[j].File { + return out[i].File < out[j].File + } + if out[i].Line != out[j].Line { + return out[i].Line < out[j].Line + } + return out[i].Rule < out[j].Rule + }) + return out +} + +func isCredentialAssignmentMatch(match string) bool { + name, value, ok := normalizedCredentialAssignment(match) + if !ok { + return false + } + if isWebhookCredentialKey(name) && webhookAssignmentValueLooksCredentialLike(value) { + return true + } + if isBenignTokenField(name) && !credentialShapedValue(value) { + return false + } + return isExplicitCredentialKey(name) +} + +func normalizedCredentialAssignmentKey(match string) (string, bool) { + key, _, ok := normalizedCredentialAssignment(match) + return key, ok +} + +func normalizedCredentialAssignment(match string) (string, string, bool) { + key, ok := credentialAssignmentKey(match) + if !ok { + return "", "", false + } + key = strings.TrimSpace(key) + if key == "" { + return "", "", false + } + submatches := credentialAssignmentRE.FindStringSubmatch(match) + return normalizedCredentialKey(strings.Trim(key, `"'`)), credentialAssignmentValue(submatches), true +} + +func normalizedCredentialKey(key string) string { + key = strings.TrimSpace(key) + var out []rune + var prev rune + for i, r := range key { + if r == '-' { + r = '_' + } + if i > 0 && isCredentialKeyBoundary(prev, r) { + out = append(out, '_') + } + out = append(out, unicode.ToLower(r)) + prev = r + } + key = string(out) + key = strings.ReplaceAll(key, "-", "_") + return key +} + +func isCredentialKeyBoundary(prev, current rune) bool { + if prev == '_' || current == '_' { + return false + } + return (unicode.IsLower(prev) || unicode.IsDigit(prev)) && unicode.IsUpper(current) +} + +func isBenignTokenField(key string) bool { + if isTokenMetricField(key) || + isTokenMetadataField(key) || + isResourceTokenField(key) || + isPaginationOrSyncTokenField(key) { + return true + } + return false +} + +func isTokenMetricField(key string) bool { + switch key { + case "tokenizer", + "token_count", + "tokens", + "max_tokens", + "completion_tokens", + "prompt_tokens": + return true + default: + return false + } +} + +func isTokenMetadataField(key string) bool { + switch key { + case "access_token_expires_in", + "refresh_token_expires_in", + "token_expires_in", + "token_status", + "token_type", + "token_url", + "token_endpoint", + "token_format", + "secret_name": + return true + default: + return false + } +} + +func isPaginationOrSyncTokenField(key string) bool { + switch key { + case "page_token", + "next_page_token", + "sync_token": + return true + default: + return false + } +} + +func isResourceTokenField(key string) bool { + if !strings.HasSuffix(key, "_token") { + return false + } + prefix := strings.TrimSuffix(key, "_token") + switch prefix { + case "app", + "base", + "board", + "doc", + "drive_route", + "file", + "folder", + "host_node", + "minute", + "node", + "obj", + "origin_node", + "parent", + "parent_file", + "parent_node", + "share", + "spreadsheet", + "target", + "wiki": + return true + default: + return false + } +} + +func isResourceTokenPlaceholderAssignment(key, value string) bool { + switch { + case key == "client_token" && idempotencyTokenPlaceholderValue(value): + return true + case key == "retry_without_token" && numericStringPlaceholderValue(value): + return true + case tokenLikePlaceholderKey(key): + return tokenLikePlaceholderValue(value) + default: + return false + } +} + +func tokenLikePlaceholderKey(key string) bool { + return key == "token" || + strings.HasSuffix(key, "_token") || + strings.HasSuffix(key, "-token") +} + +func tokenLikePlaceholderValue(value string) bool { + normalized := strings.ToLower(strings.Trim(value, `"'`)) + if normalized == "" || credentialShapedIdentifier(normalized) { + return false + } + return resourceTokenPlaceholderValue(value) || + isPlaceholderValue(value) || + normalized == "token" || + strings.Contains(normalized, "...") || + strings.Contains(normalized, "xxx") || + strings.Contains(normalized, "_or_") || + strings.HasSuffix(normalized, "_token") || + strings.HasPrefix(normalized, ".") +} + +func idempotencyTokenPlaceholderValue(value string) bool { + return numericStringPlaceholderValue(value) || uuidStringPlaceholderValue(value) +} + +func uuidStringPlaceholderValue(value string) bool { + normalized := strings.Trim(value, `"'`) + parts := strings.Split(normalized, "-") + if len(parts) != 5 { + return false + } + for i, part := range parts { + want := []int{8, 4, 4, 4, 12}[i] + if len(part) != want { + return false + } + for _, r := range part { + if (r >= '0' && r <= '9') || + (r >= 'a' && r <= 'f') || + (r >= 'A' && r <= 'F') { + continue + } + return false + } + } + return true +} + +func numericStringPlaceholderValue(value string) bool { + normalized := strings.Trim(value, `"'`) + if normalized == "" { + return false + } + for _, r := range normalized { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func isBenignCodeCredentialExpression(file, value string) bool { + normalized := strings.TrimSpace(value) + if strings.HasPrefix(normalized, "regexp.MustCompile(") { + return true + } + if !sourceCodeFile(file) || quotedLiteral(value) || credentialShapedValue(value) { + return false + } + return codeReferenceExpression(normalized) +} + +func sourceCodeFile(file string) bool { + switch filepath.Ext(file) { + case ".go", ".py": + return true + default: + return false + } +} + +func quotedLiteral(value string) bool { + normalized := strings.TrimSpace(value) + return len(normalized) >= 2 && + ((strings.HasPrefix(normalized, `"`) && strings.HasSuffix(normalized, `"`)) || + (strings.HasPrefix(normalized, `'`) && strings.HasSuffix(normalized, `'`))) +} + +func codeReferenceExpression(value string) bool { + if value == "" { + return false + } + for _, marker := range []string{".", "(", ")", "[", "]", "{"} { + if strings.Contains(value, marker) { + return true + } + } + return codeIdentifier(value) && !credentialNameFragment(value) +} + +func codeIdentifier(value string) bool { + for i, r := range value { + switch { + case r >= 'a' && r <= 'z': + case r >= 'A' && r <= 'Z': + case r == '_' && i > 0: + case r >= '0' && r <= '9' && i > 0: + default: + return false + } + } + return true +} + +func credentialNameFragment(value string) bool { + normalized := strings.ToLower(value) + for _, marker := range []string{"secret", "token", "password", "passwd", "key"} { + if strings.Contains(normalized, marker) { + return true + } + } + return false +} + +func isSchemaDottedIdentifier(line, match string) bool { + return strings.Contains(line, "schema ") && strings.Contains(match, "_") +} + +func isNonSecretLiteralValue(value string) bool { + switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) { + case "true", "false", "null", "nil", "{", "[": + return true + default: + return false + } +} + +func isWebhookCredentialKey(key string) bool { + return strings.Contains(strings.ReplaceAll(key, "_", ""), "webhook") +} + +func webhookAssignmentValueLooksCredentialLike(value string) bool { + normalized := strings.ToLower(strings.Trim(value, `"'`)) + if normalized == "" || isPlaceholderValue(normalized) || isNonSecretLiteralValue(normalized) { + return false + } + return urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)) || + credentialShapedIdentifier(strings.Trim(normalized, "$")) +} + +func isExplicitCredentialKey(key string) bool { + compact := strings.ReplaceAll(key, "_", "") + switch compact { + case "token", + "accesstoken", + "refreshtoken", + "authtoken", + "bearertoken", + "sessiontoken", + "servicetoken", + "apikey", + "accesskey", + "privatekey", + "apisecret", + "secret", + "secretkey", + "clientsecret", + "password", + "passwd": + return true + } + for _, phrase := range []string{ + "accesstoken", + "refreshtoken", + "authtoken", + "bearertoken", + "sessiontoken", + "servicetoken", + "bottoken", + "apikey", + "accesskey", + "privatekey", + "apisecret", + "clientsecret", + "secretkey", + } { + if strings.Contains(compact, phrase) { + return true + } + } + parts := credentialKeyParts(key) + for _, phrase := range [][2]string{ + {"access", "token"}, + {"refresh", "token"}, + {"auth", "token"}, + {"bearer", "token"}, + {"session", "token"}, + {"service", "token"}, + {"bot", "token"}, + {"api", "key"}, + {"access", "key"}, + {"private", "key"}, + {"api", "secret"}, + {"client", "secret"}, + {"secret", "key"}, + } { + if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) { + return true + } + } + for _, part := range parts { + switch part { + case "token", "secret", "password", "passwd": + return true + } + } + for _, suffix := range []string{ + "token", + "accesstoken", + "refreshtoken", + "authtoken", + "bearertoken", + "sessiontoken", + "servicetoken", + "bottoken", + "apikey", + "accesskey", + "privatekey", + "apisecret", + "clientsecret", + "secret", + "secretkey", + "password", + "passwd", + } { + if strings.HasSuffix(compact, suffix) { + return true + } + } + for _, suffix := range []string{ + "_access_token", + "_refresh_token", + "_auth_token", + "_bearer_token", + "_session_token", + "_service_token", + "_api_key", + "_access_key", + "_private_key", + "_api_secret", + "_client_secret", + "_secret", + "_secret_key", + "_password", + "_passwd", + } { + if strings.HasSuffix(key, suffix) { + return true + } + } + return false +} + +func credentialKeyParts(key string) []string { + var parts []string + for _, part := range strings.Split(key, "_") { + if part != "" { + parts = append(parts, part) + } + } + return parts +} + +func hasAdjacentCredentialParts(parts []string, first, second string) bool { + for i := 0; i+1 < len(parts); i++ { + if parts[i] == first && parts[i+1] == second { + return true + } + } + return false +} + +func credentialAssignmentValue(match []string) string { + for _, value := range match[1:] { + if value != "" { + return value + } + } + return "" +} + +func looksLikeEqualityComparison(value string) bool { + return strings.HasPrefix(strings.TrimSpace(value), "=") +} + +func isPlaceholderCredentialURL(raw string) bool { + userInfo, ok := credentialURLUserInfo(raw) + if !ok { + return false + } + _, password, ok := strings.Cut(userInfo, ":") + if !ok { + return false + } + return credentialURLPasswordPlaceholder(password) +} + +func credentialURLPasswordPlaceholder(password string) bool { + normalized := strings.ToLower(password) + decoded := strings.ReplaceAll(normalized, "%3c", "<") + decoded = strings.ReplaceAll(decoded, "%3e", ">") + switch decoded { + case "placeholder", "redacted", "", "xxxx": + return true + } + return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded) +} + +func credentialURLUserInfo(raw string) (string, bool) { + schemeIdx := strings.Index(raw, "://") + if schemeIdx < 0 { + return "", false + } + rest := raw[schemeIdx+len("://"):] + atIdx := strings.Index(rest, "@") + if atIdx < 0 { + return "", false + } + return rest[:atIdx], true +} + +func newFinding(rule, file string, line int, source, excerpt string) Finding { + return Finding{ + Rule: rule, + Action: actionForRule(rule), + File: file, + Line: line, + Source: source, + Excerpt: excerpt, + Message: messageForRule(rule), + Suggestion: suggestionForRule(rule), + } +} + +func messageForRule(rule string) string { + switch rule { + case "public_content_generic_credential": + return "public contribution contains a generic credential assignment" + case "public_content_private_key_block": + return "public contribution contains a private key block" + case "public_content_jwt_like_token": + return "public contribution contains a JWT-like token" + case "public_content_bearer_header": + return "public contribution contains an Authorization bearer token" + case "public_content_credential_url": + return "public contribution contains credentials embedded in a URL" + case "public_content_private_ipv4": + return "public contribution contains a private-network IP address" + case "public_content_automation_branch": + return "public contribution uses an automation-shaped branch name" + case "public_content_change_id_trailer": + return "public contribution contains a Change-Id trailer" + case "public_content_reviewed_on_trailer": + return "public contribution contains a Reviewed-on trailer" + case "public_content_provenance_marker": + return "public contribution contains a prohibited provenance marker" + case "public_content_detector_fingerprint": + return "public rule/config content exposes public detector fingerprints" + case "public_content_harness_metadata": + return "public contribution contains visible harness pipeline metadata" + case "public_content_ccm_harness_trailer": + return "public contribution contains a CCM-Harness trailer" + case "public_content_semantic_candidate": + return "public contribution contains text for semantic public content review" + default: + return "public contribution contains content that should not be published" + } +} + +func suggestionForRule(rule string) string { + switch actionForRule(rule) { + case "REJECT": + return "remove the value from the public contribution and replace it with a non-sensitive placeholder" + default: + return "remove private workflow metadata before publishing the public contribution" + } +} + +func redactAssignment(match string) string { + key, ok := credentialAssignmentKey(match) + if !ok { + return "" + } + return fmt.Sprintf("%s= ", strings.TrimSpace(key)) +} + +func credentialAssignmentKey(match string) (string, bool) { + idx := -1 + for _, sep := range []string{":", "="} { + if candidate := strings.Index(match, sep); candidate >= 0 && (idx < 0 || candidate < idx) { + idx = candidate + } + } + if idx < 0 { + return "", false + } + return match[:idx], true +} + +func redactToken(_ string) string { + return "" +} + +func redactedSemanticExcerpt(text string) string { + normalized := strings.Join(strings.Fields(text), " ") + if normalized == "" { + return "" + } + signals := semanticSignals(normalized) + if len(signals) == 0 { + return "" + } + sanitized := truncateRunes(sanitizeSemanticExcerpt(text), 600) + return fmt.Sprintf("semantic signals: %s; excerpt: %q", strings.Join(signals, ","), sanitized) +} + +func semanticSignals(normalized string) []string { + lower := strings.ToLower(normalized) + var signals []string + add := func(signal string) { + for _, existing := range signals { + if existing == signal { + return + } + } + signals = append(signals, signal) + } + + hasPrivateScope := strings.Contains(lower, "private") || strings.Contains(lower, "internal-only") + hasRequestMetadata := strings.Contains(lower, "request header") || strings.Contains(lower, "request headers") || strings.Contains(lower, "authorization header") || strings.Contains(lower, "metadata header") + hasTrustBoundary := strings.Contains(lower, "spoof") || strings.Contains(lower, "trust") || strings.Contains(lower, "risk scoring") || strings.Contains(lower, "classification") + hasRoadmap := strings.Contains(lower, "roadmap") || strings.Contains(lower, "migration") || strings.Contains(lower, "rollout") || strings.Contains(lower, "cutover") || strings.Contains(lower, "unpublished") + hasTiming := strings.Contains(lower, "target date") || strings.Contains(lower, "friday") || strings.Contains(lower, "monday") || strings.Contains(lower, "tuesday") || strings.Contains(lower, "wednesday") || strings.Contains(lower, "thursday") || strings.Contains(lower, "customer-visible") + hasImplementation := strings.Contains(lower, "server-side") || strings.Contains(lower, "implementation") + + if hasPrivateScope && hasRequestMetadata && hasTrustBoundary { + add("private_scope") + add("request_metadata") + add("trust_boundary_detail") + } + if hasRoadmap && (hasPrivateScope || hasTiming) { + add("roadmap_detail") + if hasPrivateScope { + add("private_scope") + } + if hasTiming { + add("roadmap_timing") + } + } + if hasPrivateScope && hasImplementation && hasTrustBoundary { + add("private_scope") + add("implementation_detail") + add("trust_boundary_detail") + } + + return signals +} + +func sanitizeSemanticExcerpt(text string) string { + text = redactPrivateKeyBlocks(text) + text = credentialAssignmentRE.ReplaceAllStringFunc(text, sanitizeCredentialAssignment) + text = strings.ReplaceAll(text, `"`, ``) + text = strings.ReplaceAll(text, `'`, ``) + text = semanticBearerHeaderRE.ReplaceAllString(text, "Authorization: Bearer ") + text = jwtLikeRE.ReplaceAllString(text, "") + text = credentialURLRE.ReplaceAllStringFunc(text, sanitizeCredentialURL) + return strings.Join(strings.Fields(text), " ") +} + +func redactPrivateKeyBlocks(text string) string { + lines := strings.Split(text, "\n") + var out []string + inPrivateKey := false + for _, line := range lines { + if strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) { + out = append(out, "") + inPrivateKey = true + if strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) { + inPrivateKey = false + } + continue + } + if inPrivateKey { + if strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) { + inPrivateKey = false + } + continue + } + out = append(out, line) + } + return strings.Join(out, "\n") +} + +func sanitizeCredentialAssignment(match string) string { + key, ok := credentialAssignmentKey(match) + if !ok { + return "" + } + return strings.TrimSpace(key) + "=" +} + +func sanitizeCredentialURL(raw string) string { + redacted := redactCredentialURL(raw) + redacted = strings.ReplaceAll(redacted, "%3Cuser%3E", "") + redacted = strings.ReplaceAll(redacted, "%3Credacted%3E", "") + return redacted +} + +func truncateRunes(text string, limit int) string { + if limit <= 0 { + return "" + } + runes := []rune(text) + if len(runes) <= limit { + return text + } + return string(runes[:limit]) + "..." +} diff --git a/internal/qualitygate/publiccontent/scan_test.go b/internal/qualitygate/publiccontent/scan_test.go new file mode 100644 index 00000000..8faed8d2 --- /dev/null +++ b/internal/qualitygate/publiccontent/scan_test.go @@ -0,0 +1,1056 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package publiccontent + +import ( + "strings" + "testing" +) + +func TestScanFileDetectsPublicLeakSignalsInPRDocs(t *testing.T) { + text := `# Pull Request + +The public README accidentally contains realistic leak-shaped content: + +` + "```bash\n" + `export SERVICE_` + `PASSWORD="example-password" +curl https://user:pass@` + `example.com/repo.git +` + "```\n" + ` + ` + privateKeyBeginPrefix + privateKeyMarker + ` + example-key-body + ` + privateKeyEndPrefix + privateKeyMarker + ` + +session_token: "` + jwtFixture("ZXhhbXBsZQ") + `" + +Change` + `-Id: I0123456789abcdef0123456789abcdef01234567 +Reviewed` + `-on: https://review.example.test/c/project/+/123 +` + "Generated by " + "auto" + "mation" + ` +/tmp/harness` + `-agent/work +CCM` + `-Harness: stage-17 +` + + got := ScanFile("docs/public-pr.md", []byte(text)) + rules := findingRules(got) + for _, want := range []string{ + "public_content_generic_credential", + "public_content_private_key_block", + "public_content_jwt_like_token", + "public_content_credential_url", + "public_content_change_id_trailer", + "public_content_reviewed_on_trailer", + "public_content_provenance_marker", + "public_content_harness_metadata", + "public_content_ccm_harness_trailer", + } { + if !rules[want] { + t.Fatalf("missing rule %s in findings %#v", want, got) + } + } +} + +func TestScanFileWarnsForPrivateIPv4Examples(t *testing.T) { + got := ScanFile("docs/network.md", []byte("Local lab address: 192.168."+"0.10\n")) + rules := findingRules(got) + if !rules["public_content_private_ipv4"] { + t.Fatalf("missing private IPv4 warning, got %#v", got) + } + for _, item := range got { + if item.Rule == "public_content_private_ipv4" && string(item.Action) != "WARNING" { + t.Fatalf("private IPv4 action = %s, want WARNING", item.Action) + } + } +} + +func TestSemanticCandidateRequiresSpecificRiskSignals(t *testing.T) { + benign := semanticCandidate("docs/network.md", "file", "For a local lab, use RFC1918 example host 192.168."+"0.10 only.", 1) + if len(benign) != 0 { + t.Fatalf("benign RFC1918 documentation should not produce semantic candidates: %#v", benign) + } + + risky := semanticCandidate("docs/roadmap.md", "file", "private launch plan for internal migration rollout on Friday", 1) + if len(risky) != 1 { + t.Fatalf("risky semantic text should produce one semantic candidate, got %#v", risky) + } + if !strings.Contains(risky[0].Excerpt, "private_scope") || !strings.Contains(risky[0].Excerpt, "roadmap_detail") { + t.Fatalf("semantic candidate should retain redacted risk signals, got %#v", risky[0]) + } + if !strings.Contains(risky[0].Excerpt, "private launch plan") { + t.Fatalf("semantic candidate should include sanitized review text, got %#v", risky[0]) + } +} + +func TestSemanticCandidateIgnoresBroadBenignSignals(t *testing.T) { + cases := []string{ + "internal package refactor", + "internal request handling docs", + "request header behavior", + "implementation detail cleanup", + } + for _, tc := range cases { + if got := semanticCandidate("docs/public.md", "file", tc, 1); len(got) != 0 { + t.Fatalf("semanticCandidate(%q) = %#v, want none", tc, got) + } + } +} + +func TestSemanticCandidateKeepsHighRiskCombinations(t *testing.T) { + cases := []string{ + "private request header controls trust classification and spoof-prevention behavior", + "unpublished migration rollout has target date next Tuesday", + "private roadmap cutover exposes customer-visible timing", + } + for _, tc := range cases { + if got := semanticCandidate("docs/public.md", "file", tc, 1); len(got) != 1 { + t.Fatalf("semanticCandidate(%q) len = %d, want 1: %#v", tc, len(got), got) + } + } +} + +func TestSemanticCandidateSanitizesReviewText(t *testing.T) { + text := `private rollout uses internal request headers. +SERVICE_PASSWORD="real-password-value" +Authorization: Bearer abcdefghijklmnopqrstuvwxyz +Authorization: Bearer abcdefghijkl+/Zm9vQmFy== +callback=https://user:secretpass@example.com/hook +token: ` + jwtFixture("c2VjcmV0") + ` +standalone ` + jwtFixture("c3RhbmRhbG9uZQ") + ` +` + privateKeyBeginPrefix + privateKeyMarker + ` +secret-key-body +` + privateKeyEndPrefix + privateKeyMarker + ` +` + + got := semanticCandidate("docs/public.md", "file", text, 1) + if len(got) != 1 { + t.Fatalf("semantic candidate len = %d, want 1: %#v", len(got), got) + } + excerpt := got[0].Excerpt + for _, forbidden := range []string{ + "real-password-value", + "abcdefghijklmnopqrstuvwxyz", + "Zm9vQmFy", + "user:secretpass@example.com", + jwtHeaderFixture(), + "secret-key-body", + } { + if strings.Contains(excerpt, forbidden) { + t.Fatalf("semantic candidate leaked %q in excerpt %q", forbidden, excerpt) + } + } + for _, want := range []string{ + "SERVICE_PASSWORD=", + "Authorization: Bearer ", + "https://:@example.com/hook", + "", + "", + } { + if !strings.Contains(excerpt, want) { + t.Fatalf("semantic candidate missing sanitized marker %q in excerpt %q", want, excerpt) + } + } +} + +func TestSemanticCandidateRedactsCredentialValuesWithWhitespace(t *testing.T) { + text := "private launch plan for internal rollout on Friday\n" + + "SERVICE_" + "TOKEN=\"real " + "secret value\"\n" + + got := semanticCandidate("docs/public.md", "file", text, 1) + if len(got) != 1 { + t.Fatalf("semantic candidate len = %d, want 1: %#v", len(got), got) + } + excerpt := got[0].Excerpt + for _, forbidden := range []string{"real " + "secret value", "secret value"} { + if strings.Contains(excerpt, forbidden) { + t.Fatalf("semantic candidate leaked credential tail %q in excerpt %q", forbidden, excerpt) + } + } + if !strings.Contains(excerpt, "SERVICE_TOKEN=") { + t.Fatalf("semantic candidate should redact full credential assignment, got %q", excerpt) + } +} + +func TestSemanticCandidateCoversRealE2ESemanticCases(t *testing.T) { + cases := []struct { + name string + text string + signals []string + }{ + { + name: "private header detail", + text: "Public docs describe a private request header, server-side trust classification, and spoof-prevention behavior in enough detail for an implementation review.", + signals: []string{ + "private_scope", + "request_metadata", + "trust_boundary_detail", + "implementation_detail", + }, + }, + { + name: "specific roadmap", + text: "Public release notes mention a specific unpublished migration phase, target date, and rollout direction for an internal-only plan.", + signals: []string{ + "private_scope", + "roadmap_detail", + "roadmap_timing", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := semanticCandidate("docs/public.md", "file", tc.text, 1) + if len(got) != 1 { + t.Fatalf("semantic candidate len = %d, want 1: %#v", len(got), got) + } + for _, signal := range tc.signals { + if !strings.Contains(got[0].Excerpt, signal) { + t.Fatalf("semantic candidate missing signal %q: %#v", signal, got[0]) + } + } + }) + } +} + +func TestScanFileDetectsDetectorFingerprintOnlyInPublicRuleFiles(t *testing.T) { + got := ScanFile(".gitleaks.toml", []byte("[[rules]]\nid = \"public"+"-content-leakage\"\n")) + if !findingRules(got)["public_content_detector_fingerprint"] { + t.Fatalf("expected detector fingerprint finding, got %#v", got) + } + + clean := ScanFile("docs/release-notes.md", []byte("public-content-leakage is discussed as ordinary release text\n")) + if findingRules(clean)["public_content_detector_fingerprint"] { + t.Fatalf("detector fingerprint should be scoped to public rule/config files: %#v", clean) + } +} + +func TestScanFileIgnoresBenignPublicPlaceholders(t *testing.T) { + got := ScanFile("docs/examples.md", []byte(`Use APP_ID=cli_example_app_id and APP_SECRET=cli_example_app_secret in examples. +The docs may mention bearer-token placeholders, but they should not contain realistic tokens. +`)) + if len(got) != 0 { + t.Fatalf("benign placeholders produced findings: %#v", got) + } +} + +func TestScanFileDoesNotTreatURLEncodedCredentialAsPlaceholder(t *testing.T) { + got := ScanFile("docs/config.md", []byte("client_secret=abc%2Fdef%3Drealvalue\n")) + if !findingRules(got)["public_content_generic_credential"] { + t.Fatalf("URL-encoded credential should still be reported, got %#v", got) + } +} + +func TestScanFileDoesNotTreatPlaceholderMarkerSubstringsAsPlaceholders(t *testing.T) { + got := ScanFile("docs/config.md", []byte(strings.Join([]string{ + "API_KEY=notredactedreal", + "API_KEY=notplaceholdersecret", + "API_KEY=abcxxxxreal", + }, "\n")+"\n")) + var count int + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + count++ + } + } + if count != 3 { + t.Fatalf("placeholder-marker substring findings = %d, want 3: %#v", count, got) + } +} + +func TestScanFileDetectsBase64PaddedCredentialAssignments(t *testing.T) { + paddedSecretPrefix := "dGhpc2lz" + "YXNlY3JldA" + paddedTokenPrefix := "YWJj" + "ZGVmZ2g" + paddedSecret := base64PaddedFixture(paddedSecretPrefix) + paddedToken := base64PaddedFixture(paddedTokenPrefix) + got := ScanFile("docs/config.md", []byte(strings.Join([]string{ + `API_SECRET="` + paddedSecret + `"`, + "api_secret=" + paddedToken, + "api_secret: " + paddedToken, + }, "\n")+"\n")) + var count int + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + count++ + for _, forbidden := range []string{paddedSecret, paddedToken, paddedSecretPrefix, paddedTokenPrefix} { + if strings.Contains(item.Excerpt, forbidden) { + t.Fatalf("credential finding leaked base64 value %q in excerpt %q", forbidden, item.Excerpt) + } + } + } + } + if count != 3 { + t.Fatalf("base64 padded credentials findings = %d, want 3: %#v", count, got) + } +} + +func TestScanFileDetectsQuotedJSONCredentialAssignments(t *testing.T) { + jsonToken := "real-json-token" + jsonSecret := "real " + "secret value" + jsonKey := "real-json-key" + jsonTenantToken := "real-tenant-json-token" + jsonAppSecret := "real-app-secret" + jsonPrefixedKey := "real-prefixed-key" + jsonTenantCamelToken := "real-tenant-camel-token" + jsonGithubToken := "real-github-token" + jsonVendorKey := "real-vendor-key" + jsonSlackBotToken := "xoxb-real-token" + got := ScanFile("docs/public.json", []byte(strings.Join([]string{ + `{"access_` + `token":"` + jsonToken + `"}`, + `{"client_` + `secret": "` + jsonSecret + `"}`, + `{'api_` + `key': '` + jsonKey + `'}`, + `{"tenant_access_` + `token":"` + jsonTenantToken + `"}`, + `{"app_` + `secret":"` + jsonAppSecret + `"}`, + `{"x_api_` + `key":"` + jsonPrefixedKey + `"}`, + `{"tenantAccess` + `Token":"` + jsonTenantCamelToken + `"}`, + `{"github` + `Token":"` + jsonGithubToken + `"}`, + `{"vendorApi` + `Key":"` + jsonVendorKey + `"}`, + `{"slackBot` + `Token":"` + jsonSlackBotToken + `"}`, + }, "\n")+"\n")) + var count int + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + count++ + for _, forbidden := range []string{jsonToken, jsonSecret, jsonKey, jsonTenantToken, jsonAppSecret, jsonPrefixedKey, jsonTenantCamelToken, jsonGithubToken, jsonVendorKey, jsonSlackBotToken} { + if strings.Contains(item.Excerpt, forbidden) { + t.Fatalf("JSON credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt) + } + } + } + } + if count != 10 { + t.Fatalf("JSON credential findings = %d, want 10: %#v", count, got) + } +} + +func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) { + got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{ + "API_KEY_OPENAI: real-openai-key", + "TOKEN_GITHUB: real-github-token", + "CLIENT_SECRET_GOOGLE: real-google-secret", + "SECRET_KEY_BASE: real-secret-key-base", + "APP_PASSWORD_PROD: real-prod-password", + }, "\n")+"\n")) + var count int + for _, item := range got { + if item.Rule != "public_content_generic_credential" { + continue + } + count++ + for _, forbidden := range []string{ + "real-openai-key", + "real-github-token", + "real-google-secret", + "real-secret-key-base", + "real-prod-password", + } { + if strings.Contains(item.Excerpt, forbidden) { + t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt) + } + } + } + if count != 5 { + t.Fatalf("credential suffix variants findings = %d, want 5: %#v", count, got) + } +} + +func TestScanFileDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) { + got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{ + "API_KEY_OPENAI: prod_key", + "CLIENT_SECRET_GOOGLE: prod_secret", + "TOKEN_GITHUB: github_token", + "APP_PASSWORD_PROD: prod_password", + }, "\n")+"\n")) + var count int + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + count++ + } + } + if count != 4 { + t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got) + } +} + +func TestScanFileDetectsAngleWrappedRealisticCredentialValues(t *testing.T) { + stripeLike := "sk_" + "live_1234567890abcdef" + patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234" + got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{ + "API_KEY: <" + stripeLike + ">", + "SECRET_TOKEN: <" + patLike + ">", + "CLIENT_SECRET: ", + }, "\n")+"\n")) + var count int + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + count++ + } + } + if count != 3 { + t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got) + } +} + +func TestScanFileDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) { + stripeLike := "sk_" + "live_1234567890abcdef" + patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234" + got := ScanFile("docs/public.json", []byte(strings.Join([]string{ + `{"access_token_expires_in":"` + patLike + `"}`, + `{"refresh_token_expires_in":"` + stripeLike + `"}`, + `{"client_secret_status":"real-client-secret-value"}`, + `{"client_secret_name":"real-client-secret-value"}`, + `{"app_token":"` + patLike + `"}`, + `{"sync_token":"` + stripeLike + `"}`, + `{"target_token":"real-client-secret-value"}`, + }, "\n")+"\n")) + var count int + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + count++ + } + } + if count != 7 { + t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got) + } +} + +func TestScanFileDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) { + got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{ + "API_KEY_NAME: prod_key", + "CLIENT_SECRET_NAME: prod_secret", + "SECRET_STATUS: prod_secret", + }, "\n")+"\n")) + var count int + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + count++ + } + } + if count != 3 { + t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got) + } +} + +func TestScanFileDetectsAccessKeyCredentials(t *testing.T) { + accessKey := "AK" + "IAIOSFODNN7EXAMPX" + got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{ + "AWS_ACCESS_KEY_ID: " + accessKey, + "ACCESS_KEY_ID: " + accessKey, + "ACCESS_KEY: " + accessKey, + }, "\n")+"\n")) + var count int + for _, item := range got { + if item.Rule != "public_content_generic_credential" { + continue + } + count++ + for _, forbidden := range []string{ + accessKey, + } { + if strings.Contains(item.Excerpt, forbidden) { + t.Fatalf("access key finding leaked value %q in excerpt %q", forbidden, item.Excerpt) + } + } + } + if count != 3 { + t.Fatalf("access key credential findings = %d, want 3: %#v", count, got) + } +} + +func TestScanFileDetectsPrivateKeyAssignments(t *testing.T) { + privateKey := "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0t" + got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{ + "PRIVATE_KEY: " + privateKey, + "SSH_PRIVATE_KEY: " + privateKey, + "JWT_PRIVATE_KEY: " + privateKey, + "SIGNING_PRIVATE_KEY: " + privateKey, + }, "\n")+"\n")) + var count int + for _, item := range got { + if item.Rule != "public_content_generic_credential" { + continue + } + count++ + if strings.Contains(item.Excerpt, privateKey) { + t.Fatalf("private key finding leaked value in excerpt %q", item.Excerpt) + } + } + if count != 4 { + t.Fatalf("private key assignment findings = %d, want 4: %#v", count, got) + } +} + +func TestScanFileDetectsWebhookURLs(t *testing.T) { + got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{ + "SLACK_WEBHOOK_URL=https://hooks." + "slack.com/services/T00000000/B00000000/abcdefghijklmnopqrstuvwx", + "DISCORD_WEBHOOK_URL=https://discord.com/api/" + "webhooks/123456789012345678/abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", + "WEBHOOK_URL=https://example.invalid/hooks/secret-path-token-1234567890", + }, "\n")+"\n")) + var count int + for _, item := range got { + if item.Rule != "public_content_generic_credential" { + continue + } + count++ + for _, forbidden := range []string{ + "hooks." + "slack.com/services", + "discord.com/api/" + "webhooks", + "secret-path-token-1234567890", + } { + if strings.Contains(item.Excerpt, forbidden) { + t.Fatalf("webhook finding leaked value %q in excerpt %q", forbidden, item.Excerpt) + } + } + } + if count != 3 { + t.Fatalf("webhook URL findings = %d, want 3: %#v", count, got) + } +} + +func TestScanFileDetectsWebhookURLsWithHostPlaceholders(t *testing.T) { + got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{ + "WEBHOOK_URL=https:///hooks/real-secret-token-1234567890", + "SLACK_WEBHOOK_URL=https:///services/T00000000/B00000000/abcdefghijklmnopqrstuvwx", + }, "\n")+"\n")) + var count int + for _, item := range got { + if item.Rule != "public_content_generic_credential" { + continue + } + count++ + } + if count != 2 { + t.Fatalf("host-placeholder webhook findings = %d, want 2: %#v", count, got) + } +} + +func TestScanFileAllowsBenignWebhookFields(t *testing.T) { + got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{ + "webhook_count: 2", + "webhook_retries=3", + "webhook_endpoint=https://example.invalid/hooks/example", + "webhook_path=/hooks/example", + }, "\n")+"\n")) + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + t.Fatalf("benign webhook field should not be credential finding: %#v", got) + } + } +} + +func TestScanFileDetectsCredentialURLWithEmptyUsername(t *testing.T) { + got := ScanFile("docs/config.yaml", []byte("REDIS_URL=redis://:password@example.invalid/0\n")) + for _, item := range got { + if item.Rule == "public_content_credential_url" { + if strings.Contains(item.Excerpt, "password") { + t.Fatalf("credential URL finding leaked password in excerpt %q", item.Excerpt) + } + return + } + } + t.Fatalf("missing empty-username credential URL finding: %#v", got) +} + +func TestScanFileAllowsPrivateKeyStateBooleans(t *testing.T) { + got := ScanFile("internal/qualitygate/publiccontent/collect.go", []byte(strings.Join([]string{ + "inPrivateKey = true", + "inPrivateKey = false", + "hasPrivateKey: false", + }, "\n")+"\n")) + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + t.Fatalf("private key state boolean should not be credential finding: %#v", got) + } + } +} + +func TestScanFileAllowsCredentialReferenceValues(t *testing.T) { + got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{ + "API_KEY=${API_KEY}", + "API_KEY=$API_KEY", + "API_KEY=process.env.API_KEY", + "API_KEY: ${{ secrets.API_KEY }}", + "TOKEN: ${{ env.TOKEN }}", + "GITHUB_TOKEN: ${{ github.token }}", + "TOKEN=$(vault kv get -field=token secret/path)", + }, "\n")+"\n")) + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + t.Fatalf("credential reference should not be generic credential finding: %#v", got) + } + } +} + +func TestScanFileDetectsMalformedGithubExpressionCredentialValues(t *testing.T) { + stripeLike := "sk_" + "live_1234567890abcdef" + got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{ + "API_KEY=${{" + stripeLike + "}}", + "TOKEN=${{real-secret-token-value}}", + }, "\n")+"\n")) + var count int + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + count++ + } + } + if count != 2 { + t.Fatalf("malformed GitHub expression credential findings = %d, want 2: %#v", count, got) + } +} + +func TestScanFileDetectsDollarPrefixedCredentialValues(t *testing.T) { + stripeLike := "sk_" + "live_1234567890abcdef" + patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234" + got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{ + "API_KEY=$" + stripeLike, + "GITHUB_TOKEN=$" + patLike, + "TOKEN=$(echo " + stripeLike + ")", + "API_KEY=process.env." + stripeLike, + "GITHUB_TOKEN=process.env." + patLike, + }, "\n")+"\n")) + var count int + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + count++ + } + } + if count != 5 { + t.Fatalf("reference-shaped credential findings = %d, want 5: %#v", count, got) + } +} + +func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) { + got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{ + "DATABASE_URL=postgres://:@example.invalid/db", + "DATABASE_URL=postgres://user:%3Cpassword%3E@example.invalid/db", + "WEBHOOK_URL=https://example.invalid/hooks/", + }, "\n")+"\n")) + for _, item := range got { + if item.Rule == "public_content_credential_url" { + t.Fatalf("credential URL placeholder should not be credential URL finding: %#v", got) + } + if item.Rule == "public_content_generic_credential" { + t.Fatalf("credential URL placeholder should not be generic credential finding: %#v", got) + } + } +} + +func TestScanFileDetectsCredentialURLsWithRedactedSubstringPasswords(t *testing.T) { + got := ScanFile("docs/config.yaml", []byte("DATABASE_URL=postgres://user:notredactedreal@example.invalid/db\n")) + for _, item := range got { + if item.Rule == "public_content_credential_url" { + return + } + } + t.Fatalf("missing credential URL with redacted substring password: %#v", got) +} + +func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *testing.T) { + stripeLike := "sk_" + "live_1234567890abcdef" + got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{ + "DATABASE_URL=postgres://:real-secret@example.invalid/db", + "DATABASE_URL=postgres://:" + stripeLike + "@example.invalid/db", + "URL=https://:real-secret@example.invalid/path", + }, "\n")+"\n")) + var count int + for _, item := range got { + if item.Rule != "public_content_credential_url" { + continue + } + count++ + for _, forbidden := range []string{"real-secret", stripeLike} { + if strings.Contains(item.Excerpt, forbidden) { + t.Fatalf("credential URL finding leaked value %q in excerpt %q", forbidden, item.Excerpt) + } + } + } + if count != 3 { + t.Fatalf("placeholder-user credential URL findings = %d, want 3: %#v", count, got) + } +} + +func TestScanFileAllowsCommonAngleWrappedCredentialPlaceholders(t *testing.T) { + got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{ + "API_KEY=", + "CLIENT_SECRET=", + "ACCESS_TOKEN=", + "API_KEY=", + "SECRET_TOKEN=", + "CLIENT_SECRET=", + }, "\n")+"\n")) + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + t.Fatalf("common angle-wrapped placeholder should not be credential finding: %#v", got) + } + } +} + +func TestScanFileAllowsBenignJSONTokenFields(t *testing.T) { + got := ScanFile("docs/public.json", []byte(strings.Join([]string{ + `{"tokenizer":"cl100k_base"}`, + `{"token_count": 42}`, + `{"page_token":"next"}`, + `{"next_page_token":"next"}`, + `{"file_token":"file-example"}`, + `{"doc_token":"doc-example"}`, + `{"node_token":"node-example"}`, + `{"wiki_token":"wikcn_public_doc_example"}`, + `{"folder_token":"folder-example"}`, + `{"obj_token":"obj-example"}`, + `{"spreadsheet_token":"sheet-example"}`, + `{"parent_node_token":"parent-example"}`, + `{"origin_node_token":"origin-example"}`, + `{"drive_route_token":"route-example"}`, + `{"token":""}`, + `{"token":"wiki_token"}`, + `{"token":"minute_1"}`, + `{"token":"minute_no_meta"}`, + `{"token_url":"https://example.com/oauth/token"}`, + `{"token_endpoint":"https://example.com/oauth/token"}`, + `{"token_format":"Bearer"}`, + `{"secret_name":"public-example-secret"}`, + `{"base_token":"base-example"}`, + `{"app_token":"app-example"}`, + `{"sync_token":"sync-example"}`, + `{"parent_token":"parent-example"}`, + `{"target_token":"target-example"}`, + `{"parent_file_token":"parent-file-example"}`, + `{"refresh_token_expires_in": 7200}`, + `{"access_token_expires_in": 7200}`, + `{"token_expires_in": 7200}`, + `{"token_status":"active"}`, + }, "\n")+"\n")) + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + t.Fatalf("benign JSON token field should not be credential finding: %#v", got) + } + } +} + +func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) { + got := ScanFile("shortcuts/calendar/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n")) + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + t.Fatalf("test fixture secret should not be credential finding: %#v", got) + } + } +} + +func TestScanFileAllowsRegexpTokenValidators(t *testing.T) { + got := ScanFile("shortcuts/minutes/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n")) + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + t.Fatalf("regexp token validator should not be credential finding: %#v", got) + } + } +} + +func TestScanFileAllowsBenignSourceCodeCredentialExpressions(t *testing.T) { + got := ScanFile("cmd/config/binder.go", []byte(strings.Join([]string{ + "AppSecret: stored,", + "AccessToken: result.Token.AccessToken,", + `token := runtime.Str("token")`, + }, "\n")+"\n")) + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + t.Fatalf("source code credential expressions should not be credential findings: %#v", got) + } + } +} + +func TestScanFileAllowsPythonArgumentTokens(t *testing.T) { + got := ScanFile("skills/lark-slides/scripts/iconpark_tool.py", []byte(strings.Join([]string{ + "def normalize_token(value: str) -> str:", + " token = rest[index]", + " next_token = rest[index + 1] if index + 1 < len(rest) else None", + "def append_unique(target: list[str], token: str) -> None:", + ` fail(f"invalid range token: {trimmed}")`, + }, "\n")+"\n")) + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + t.Fatalf("python token variables should not be credential findings: %#v", got) + } + } +} + +func TestScanFileAllowsEllipsisCredentialPlaceholders(t *testing.T) { + got := ScanFile("skills/lark-doc/references/lark-doc-fetch.md", []byte(strings.Join([]string{ + ``, + ``, + }, "\n")+"\n")) + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + t.Fatalf("ellipsis placeholders should not be credential findings: %#v", got) + } + } +} + +func TestScanFileAllowsSchemaDottedIdentifiers(t *testing.T) { + got := ScanFile("skills/lark-mail/references/lark-mail-recall.md", []byte("lark-cli schema mail.user_mailbox.sent_messages.get_recall_detail\n")) + for _, item := range got { + if item.Rule == "public_content_jwt_like_token" { + t.Fatalf("schema dotted identifier should not be jwt finding: %#v", got) + } + } +} + +func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) { + got := ScanFile("skills/idempotency.md", []byte(strings.Join([]string{ + `{"client_token":"1704067200"}`, + `{"client_token":"fe599b60-450f-46ff-b2ef-9f6675625b97"}`, + }, "\n")+"\n")) + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + t.Fatalf("client_token idempotency examples should not be credential findings: %#v", got) + } + } +} + +func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) { + stripeLike := "sk_" + "live_1234567890abcdef" + got := ScanFile("skills/idempotency.md", []byte(strings.Join([]string{ + `{"client_token":"` + stripeLike + `"}`, + `{"client_token":"real-client-secret-value"}`, + }, "\n")+"\n")) + var count int + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + count++ + } + } + if count != 2 { + t.Fatalf("credential-shaped client_token findings = %d, want 2: %#v", count, got) + } +} + +func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) { + got := ScanFile("skills/placeholders.md", []byte(strings.Join([]string{ + `{ "block_token": "boardXXXX" }`, + `{ "resource_token": "doc_token_or_url" }`, + `{ "token": "canonical_token" }`, + `{ "target_parent_token": "wikcparent_xxx" }`, + `{ "mention_token": "" }`, + `{ "22-doc_token_xxx": { "objType": 22 } }`, + `{ "token": "12101..." }`, + `{ token: .token }`, + `retry_without_token = 0`, + }, "\n")+"\n")) + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + t.Fatalf("token-like placeholders should not be credential findings: %#v", got) + } + } +} + +func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T) { + stripeLike := "sk_" + "live_1234567890abcdef" + got := ScanFile("skills/placeholders.md", []byte(strings.Join([]string{ + `{ "resource_token": "` + stripeLike + `" }`, + `{ "block_token": "real-client-secret-value" }`, + }, "\n")+"\n")) + var count int + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + count++ + } + } + if count != 2 { + t.Fatalf("credential-shaped token-like placeholders findings = %d, want 2: %#v", count, got) + } +} + +func TestScanFileDetectsNonFixtureMinuteTokenValues(t *testing.T) { + got := ScanFile("shortcuts/minutes/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n")) + if !findingRules(got)["public_content_generic_credential"] { + t.Fatalf("non-fixture minute token should be credential finding: %#v", got) + } +} + +func TestScanFileAllowsBenignUnquotedTokenFields(t *testing.T) { + got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{ + "tokens: 128", + "token_type: bearer", + "max_tokens: 2000", + "completion_tokens: 200", + "prompt_tokens: 100", + }, "\n")+"\n")) + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + t.Fatalf("benign unquoted token field should not be credential finding: %#v", got) + } + } +} + +func TestSemanticCandidateRedactsColonAssignmentsWithEqualsInValue(t *testing.T) { + paddedSecretPrefix := "YWJj" + "ZGVmZ2g" + paddedSecret := base64PaddedFixture(paddedSecretPrefix) + text := "private launch plan for internal rollout on Friday\n" + + "api_" + "secret: " + paddedSecret + "\n" + + `{"access_` + `token":"` + paddedSecret + `"}` + "\n" + + got := semanticCandidate("docs/public.md", "file", text, 1) + if len(got) != 1 { + t.Fatalf("semantic candidate len = %d, want 1: %#v", len(got), got) + } + if strings.Contains(got[0].Excerpt, paddedSecret) || strings.Contains(got[0].Excerpt, paddedSecretPrefix) { + t.Fatalf("semantic candidate leaked colon assignment with padding: %#v", got[0]) + } +} + +func TestSemanticCandidateRedactsEscapedQuoteCredentialValues(t *testing.T) { + doubleQuotedValue := "abc\\\"def-secret" + singleQuotedValue := "abc\\'def-secret" + text := "private launch plan for internal rollout on Friday\n" + + `{"access_` + `token":"` + doubleQuotedValue + `"}` + "\n" + + `{'client_` + `secret': '` + singleQuotedValue + `'}` + "\n" + + got := semanticCandidate("docs/public.md", "file", text, 1) + if len(got) != 1 { + t.Fatalf("semantic candidate len = %d, want 1: %#v", len(got), got) + } + for _, forbidden := range []string{doubleQuotedValue, singleQuotedValue, "def-secret"} { + if strings.Contains(got[0].Excerpt, forbidden) { + t.Fatalf("semantic candidate leaked escaped-quote credential value %q: %#v", forbidden, got[0]) + } + } +} + +func TestScanFileDoesNotTreatEqualityComparisonAsCredential(t *testing.T) { + got := ScanFile("docs/example.md", []byte("if token == \"expected\" { return nil }\n")) + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + t.Fatalf("equality comparison should not be credential assignment: %#v", got) + } + } +} + +func TestScanFileDetectsBearerHeaderFinding(t *testing.T) { + got := ScanFile("docs/auth.md", []byte("Authorization: Bearer abcdefghijklmnopqrstuvwxyz\n")) + for _, item := range got { + if item.Rule == "public_content_bearer_header" { + if item.Action != "REJECT" || item.File != "docs/auth.md" || item.Line != 1 || item.Source != "file" { + t.Fatalf("bearer finding attribution = %#v", item) + } + return + } + } + t.Fatalf("missing bearer finding: %#v", got) +} + +func TestScanFileDetectsJSONBearerHeaders(t *testing.T) { + token := "abcdefghijklmnopqrstuvwxyz" + got := ScanFile("docs/auth.json", []byte(strings.Join([]string{ + `{"Authorization":"Bearer ` + token + `"}`, + `{"headers":{"Authorization":"Bearer ` + token + `"}}`, + }, "\n")+"\n")) + var count int + for _, item := range got { + if item.Rule != "public_content_bearer_header" { + continue + } + count++ + if item.Action != "REJECT" || item.File != "docs/auth.json" || item.Source != "file" { + t.Fatalf("bearer finding attribution = %#v", item) + } + if strings.Contains(item.Excerpt, token) { + t.Fatalf("bearer finding leaked token: %#v", item) + } + } + if count != 2 { + t.Fatalf("JSON bearer findings = %d, want 2: %#v", count, got) + } +} + +func TestSemanticCandidateRedactsJSONBearerHeaders(t *testing.T) { + token := "abcdefghijklmnopqrstuvwxyz" + text := "private launch plan for internal rollout on Friday\n" + + `{"headers":{"Authorization":"Bearer ` + token + `"}}` + "\n" + + got := semanticCandidate("docs/public.md", "file", text, 1) + if len(got) != 1 { + t.Fatalf("semantic candidate len = %d, want 1: %#v", len(got), got) + } + if strings.Contains(got[0].Excerpt, token) { + t.Fatalf("semantic candidate leaked JSON bearer token: %#v", got[0]) + } + if !strings.Contains(got[0].Excerpt, "Authorization: Bearer ") { + t.Fatalf("semantic candidate should redact JSON bearer header, got %#v", got[0]) + } +} + +func TestScanFileDetectsCommonProvenanceMarkers(t *testing.T) { + text := strings.Join([]string{ + "Generated with automated code assistant", + "Co-authored-by: automated-code-assistant ", + "🤖 generated by automation", + }, "\n") + got := ScanFile("docs/public.md", []byte(text)) + var count int + for _, item := range got { + if item.Rule == "public_content_provenance_marker" { + count++ + } + } + if count != 3 { + t.Fatalf("provenance marker count = %d, want 3: %#v", count, got) + } +} + +func TestScanFileAllowsHumanCoAuthorTrailer(t *testing.T) { + got := ScanFile("docs/public.md", []byte(strings.Join([]string{ + "Co-authored-by: Jane Doe ", + "Co-authored-by: Alice Abbot ", + }, "\n"))) + for _, item := range got { + if item.Rule == "public_content_provenance_marker" { + t.Fatalf("human co-author trailer should not be blocked: %#v", got) + } + } +} + +func TestScanFileAllowsPercentWrappedPlaceholder(t *testing.T) { + got := ScanFile("docs/config.md", []byte("client_secret=%CLIENT_SECRET%\n")) + if len(got) != 0 { + t.Fatalf("percent-wrapped placeholder produced findings: %#v", got) + } +} + +func TestScanFileDetectsPercentWrappedCredentialValues(t *testing.T) { + stripeLike := "sk_" + "live_1234567890abcdef" + patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234" + got := ScanFile("docs/config.md", []byte(strings.Join([]string{ + "CLIENT_SECRET=%" + stripeLike + "%", + "GITHUB_TOKEN=%" + patLike + "%", + "TOKEN=%real-secret-token-value%", + }, "\n")+"\n")) + var count int + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + count++ + } + } + if count != 3 { + t.Fatalf("percent-wrapped credential findings = %d, want 3: %#v", count, got) + } +} + +func findingRules(items []Finding) map[string]bool { + out := map[string]bool{} + for _, item := range items { + out[item.Rule] = true + } + return out +} + +func jwtFixture(subject string) string { + return strings.Join([]string{ + jwtHeaderFixture(), + "eyJzdWIiOiJ" + subject + "In0", + "signature" + "part", + }, ".") +} + +func jwtHeaderFixture() string { + return "eyJhbGciOiJI" + "UzI1NiJ9" +} + +func base64PaddedFixture(prefix string) string { + return prefix + "==" +} diff --git a/internal/qualitygate/publiccontent/types.go b/internal/qualitygate/publiccontent/types.go new file mode 100644 index 00000000..cd933868 --- /dev/null +++ b/internal/qualitygate/publiccontent/types.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package publiccontent + +import "github.com/larksuite/cli/internal/qualitygate/report" + +type Options struct { + Repo string + ChangedFrom string + MetadataPath string + BranchName string +} + +type Metadata struct { + Title string `json:"title"` + Body string `json:"body"` + Branch string `json:"branch"` +} + +type Finding struct { + Rule string + Action report.Action + File string + Line int + Source string + Excerpt string + Message string + Suggestion string +} diff --git a/internal/qualitygate/rules/dryrun.go b/internal/qualitygate/rules/dryrun.go index d1fb0033..f4ac3d56 100644 --- a/internal/qualitygate/rules/dryrun.go +++ b/internal/qualitygate/rules/dryrun.go @@ -174,8 +174,9 @@ type materializedExample struct { } type placeholderContext struct { - FlagName string - FlagUsage string + FlagName string + FlagUsage string + FlagDefault string } func materializePlaceholderExample(raw string, cmd manifest.Command) (materializedExample, bool) { @@ -247,6 +248,7 @@ func placeholderContextForFlag(name string, flag *manifest.Flag) placeholderCont ctx := placeholderContext{FlagName: name} if flag != nil { ctx.FlagUsage = flag.Usage + ctx.FlagDefault = flag.DefValue } return ctx } @@ -309,11 +311,17 @@ func fakeValueForPlaceholder(raw string, ctx placeholderContext) (string, bool) if name == "" { return "", false } + if value, ok := fakeNumericValueForPlaceholder(name, ctx); ok { + return value, true + } + if value, ok := fakeContextualURLValueForPlaceholder(name, ctx); ok { + return value, true + } if value, ok := fakeValueFromPlaceholderName(name); ok { return value, true } if isGenericPlaceholderName(name) { - return fakeValueFromUsageHint(ctx.FlagUsage) + return fakeValueFromContextHint(ctx) } return "", false } @@ -336,16 +344,26 @@ func fakeValueFromPlaceholderName(name string) (string, bool) { return "file_test123", true case hasPlaceholderToken(tokens, "file") && hasPlaceholderToken(tokens, "token"): return "file_test123", true + case hasPlaceholderToken(tokens, "folder") && hasPlaceholderToken(tokens, "token"): + return "fld_test123", true case hasPlaceholderToken(tokens, "image", "img"): return "img_test123", true case hasPlaceholderToken(tokens, "app"): return "app_test123", true + case hasPlaceholderToken(tokens, "draft"): + return "draft_test123", true + case hasPlaceholderToken(tokens, "label"): + return "label_test123", true + case hasPlaceholderToken(tokens, "share"): + return "share_test123", true case hasPlaceholderToken(tokens, "doc", "document"): return "doc_test123", true case hasPlaceholderToken(tokens, "sheet", "spreadsheet"): return "shtcn_test123", true case hasPlaceholderToken(tokens, "base"): return "base_test123", true + case hasPlaceholderToken(tokens, "space"): + return "space_test123", true case hasPlaceholderToken(tokens, "table"): return "tbl_test123", true case hasPlaceholderToken(tokens, "view"): @@ -377,17 +395,98 @@ func fakeValueFromPlaceholderName(name string) (string, bool) { } } -func fakeValueFromUsageHint(usage string) (string, bool) { - match := placeholderValuePattern.FindStringSubmatch(strings.ToLower(usage)) +func fakeValueFromContextHint(ctx placeholderContext) (string, bool) { + if value, ok := fakeNumericValueForPlaceholder("", ctx); ok { + return value, true + } + if value, ok := fakeContextualURLValueForPlaceholder("", ctx); ok { + return value, true + } + match := placeholderValuePattern.FindStringSubmatch(strings.ToLower(ctx.FlagUsage)) if len(match) != 2 || !knownTokenPrefix(match[1]) { return "", false } return match[1] + "_test123", true } +func fakeContextualURLValueForPlaceholder(name string, ctx placeholderContext) (string, bool) { + nameTokens := placeholderTokenSet(name) + flagName := strings.ReplaceAll(strings.ToLower(ctx.FlagName), "-", "_") + flagTokens := placeholderTokenSet(flagName) + if !hasPlaceholderToken(nameTokens, "url", "link") && !hasPlaceholderToken(flagTokens, "url", "link") { + return "", false + } + usage := strings.ToLower(ctx.FlagUsage) + if strings.Contains(usage, "lark") || strings.Contains(usage, "feishu") || strings.Contains(usage, "document url") { + return "https://example.feishu.cn/docx/doc_test123", true + } + return "", false +} + +func fakeNumericValueForPlaceholder(name string, ctx placeholderContext) (string, bool) { + nameTokens := placeholderTokenSet(name) + flagName := strings.ReplaceAll(strings.ToLower(ctx.FlagName), "-", "_") + flagTokens := placeholderTokenSet(flagName) + usage := strings.ToLower(ctx.FlagUsage) + + switch { + case placeholderTokenPair(nameTokens, "meeting", "id") || placeholderTokenPair(flagTokens, "meeting", "id"): + return "400000000001", true + case placeholderTokenPair(nameTokens, "meeting", "ids") || placeholderTokenPair(flagTokens, "meeting", "ids"): + return "400000000001", true + case placeholderTokenPair(nameTokens, "meeting", "no") || placeholderTokenPair(flagTokens, "meeting", "no"): + return "123456789", true + case placeholderTokenPair(nameTokens, "meeting", "number") || placeholderTokenPair(flagTokens, "meeting", "number"): + return "123456789", true + case hasPlaceholderToken(nameTokens, "timestamp") || hasPlaceholderToken(flagTokens, "timestamp") || strings.Contains(usage, "unix timestamp"): + return defaultPositiveInteger(ctx.FlagDefault, "1893456000"), true + case placeholderTokenPair(nameTokens, "page", "size") || placeholderTokenPair(flagTokens, "page", "size"): + return defaultPositiveInteger(ctx.FlagDefault, "20"), true + case placeholderTokenPair(nameTokens, "page", "limit") || placeholderTokenPair(flagTokens, "page", "limit"): + return defaultPositiveInteger(ctx.FlagDefault, "10"), true + case numericPlaceholderName(nameTokens) || numericPlaceholderName(flagTokens) || numericUsageHint(usage): + return defaultPositiveInteger(ctx.FlagDefault, "20"), true + default: + return "", false + } +} + +func numericPlaceholderName(tokens map[string]bool) bool { + if len(tokens) == 0 || hasPlaceholderToken(tokens, "token", "format", "type", "status", "mode") { + return false + } + return hasPlaceholderToken(tokens, + "amount", "count", "depth", "height", "index", "length", "limit", "max", + "number", "revision", "size", "width", + ) +} + +func numericUsageHint(usage string) bool { + if usage == "" { + return false + } + return strings.Contains(usage, "positive integer") || + strings.Contains(usage, "decimal integer") || + strings.Contains(usage, "number of ") || + strings.Contains(usage, "(number)") +} + +func defaultPositiveInteger(raw, fallback string) string { + raw = strings.TrimSpace(raw) + if raw == "" || strings.HasPrefix(raw, "-") || raw == "0" { + return fallback + } + for _, r := range raw { + if r < '0' || r > '9' { + return fallback + } + } + return raw +} + func knownTokenPrefix(prefix string) bool { switch prefix { - case "app", "base", "doc", "file", "fld", "img", "item", "meeting", "obcn", "oc", "od", "om", "ou", "page", "rec", "shtcn", "task", "tbl", "token", "viw", "wiki": + case "app", "base", "doc", "draft", "file", "fld", "img", "item", "label", "meeting", "obcn", "oc", "od", "om", "ou", "page", "rec", "share", "shtcn", "space", "task", "tbl", "token", "viw", "wiki": return true default: return false @@ -431,6 +530,10 @@ func hasPlaceholderToken(tokens map[string]bool, wants ...string) bool { return false } +func placeholderTokenPair(tokens map[string]bool, first, second string) bool { + return tokens[first] && tokens[second] +} + func hasUnresolvedDryRunPlaceholder(value string) bool { if skillscan.HasPlaceholder(value) { return true @@ -623,6 +726,7 @@ func appendDryRunArg(raw string) ([]string, error) { return nil, fmt.Errorf("not a lark-cli command") } argv = truncateShellTail(argv) + argv = forceDryRunJSONFormat(argv) hasDryRunArg := false dryRunEnabled := false for _, arg := range argv[1:] { @@ -642,6 +746,23 @@ func appendDryRunArg(raw string) ([]string, error) { return append(argv[1:], "--dry-run"), nil } +func forceDryRunJSONFormat(argv []string) []string { + for i := 1; i < len(argv); i++ { + arg := argv[i] + if arg == "--format" { + if i+1 < len(argv) && argv[i+1] == "pretty" { + argv[i+1] = "json" + } + return argv + } + if arg == "--format=pretty" { + argv[i] = "--format=json" + return argv + } + } + return argv +} + func truncateShellTail(argv []string) []string { for i, arg := range argv { if i == 0 { diff --git a/internal/qualitygate/rules/dryrun_test.go b/internal/qualitygate/rules/dryrun_test.go index 7082be3e..8c109ca4 100644 --- a/internal/qualitygate/rules/dryrun_test.go +++ b/internal/qualitygate/rules/dryrun_test.go @@ -305,6 +305,161 @@ func TestRunDryRunsMaterializesInlinePlaceholderFlagValues(t *testing.T) { } } +func TestRunDryRunsMaterializesNumericPlaceholderFlagValues(t *testing.T) { + cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/vc/v1/bots/events","params":{"meeting_id":"400000000001","page_size":50}}]}`) + m := manifest.Manifest{Commands: []manifest.Command{{ + Path: "vc +meeting-events", + Runnable: true, + Flags: []manifest.Flag{ + {Name: "meeting-id", TakesValue: true, Usage: "meeting ID to query; must be a long positive integer, not a 9-digit meeting number"}, + {Name: "page-size", TakesValue: true, Usage: "page size, 20-100 (default 50)", DefValue: "50"}, + {Name: "dry-run"}, + }, + }}} + ex := skillscan.Example{ + Raw: "lark-cli vc +meeting-events --meeting-id --page-size ", + SourceFile: "skills/lark-vc-agent/SKILL.md", + Line: 120, + HasPlaceholder: true, + } + + diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex}) + if len(diags) != 0 { + t.Fatalf("RunDryRuns() diagnostics = %#v", diags) + } + if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" { + t.Fatalf("numeric placeholder example should be executable after materialization: %#v", facts) + } + wantArgs := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--page-size", "50", "--dry-run"} + if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) { + t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs) + } +} + +func TestRunDryRunsMaterializesNumericPlaceholdersInsideJSONFlags(t *testing.T) { + cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/test","params":{"timestamp":"1893456000","count":"20"}}]}`) + m := manifest.Manifest{Commands: []manifest.Command{{ + Path: "api GET", + Runnable: true, + Flags: []manifest.Flag{ + {Name: "params", TakesValue: true}, + {Name: "dry-run"}, + }, + }}} + ex := skillscan.Example{ + Raw: `lark-cli api GET /open-apis/test --params '{"timestamp":"","count":""}'`, + SourceFile: "skills/lark-demo/SKILL.md", + Line: 20, + HasPlaceholder: true, + } + + diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex}) + if len(diags) != 0 { + t.Fatalf("RunDryRuns() diagnostics = %#v", diags) + } + if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" { + t.Fatalf("JSON numeric placeholder example should be executable after materialization: %#v", facts) + } + wantArgs := []string{"api", "GET", "/open-apis/test", "--params", `{"timestamp":"1893456000","count":"20"}`, "--dry-run"} + if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) { + t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs) + } +} + +func TestRunDryRunsMaterializesLarkDocumentURLPlaceholders(t *testing.T) { + cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/drive/v1/metas/batch_query"}]}`) + m := manifest.Manifest{Commands: []manifest.Command{{ + Path: "drive +inspect", + Runnable: true, + Flags: []manifest.Flag{ + {Name: "url", TakesValue: true, Usage: "Lark/Feishu document URL (docx, doc, sheet, bitable, wiki, file, folder, mindnote, slides)"}, + {Name: "format", TakesValue: true}, + {Name: "dry-run"}, + }, + }}} + ex := skillscan.Example{ + Raw: "lark-cli drive +inspect --url '' --format json", + SourceFile: "skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md", + Line: 15, + HasPlaceholder: true, + } + + diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex}) + if len(diags) != 0 { + t.Fatalf("RunDryRuns() diagnostics = %#v", diags) + } + if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" { + t.Fatalf("Lark URL placeholder example should be executable after materialization: %#v", facts) + } + wantArgs := []string{"drive", "+inspect", "--url", "https://example.feishu.cn/docx/doc_test123", "--format", "json", "--dry-run"} + if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) { + t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs) + } +} + +func TestRunDryRunsMaterializesResourceIDPlaceholderFlagValues(t *testing.T) { + cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/wiki/v2/spaces/space_test123/nodes"}]}`) + m := manifest.Manifest{Commands: []manifest.Command{{ + Path: "wiki +node-list", + Runnable: true, + Flags: []manifest.Flag{ + {Name: "space-id", TakesValue: true, Usage: "wiki space ID"}, + {Name: "page-token", TakesValue: true, Usage: "page token"}, + {Name: "format", TakesValue: true}, + {Name: "dry-run"}, + }, + }}} + ex := skillscan.Example{ + Raw: "lark-cli wiki +node-list --space-id --page-token --format json", + SourceFile: "skills/lark-wiki/references/lark-wiki-node-list.md", + Line: 24, + HasPlaceholder: true, + } + + diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex}) + if len(diags) != 0 { + t.Fatalf("RunDryRuns() diagnostics = %#v", diags) + } + if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" { + t.Fatalf("resource ID placeholder example should be executable after materialization: %#v", facts) + } + wantArgs := []string{"wiki", "+node-list", "--space-id", "space_test123", "--page-token", "page_test123", "--format", "json", "--dry-run"} + if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) { + t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs) + } +} + +func TestRunDryRunsMaterializesResourcePlaceholdersInsideJSONFlags(t *testing.T) { + cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"POST","url":"/open-apis/mail/v1/user_mailboxes/me/drafts/draft_test123/send"}]}`) + m := manifest.Manifest{Commands: []manifest.Command{{ + Path: "mail user_mailbox.drafts send", + Runnable: true, + Flags: []manifest.Flag{ + {Name: "params", TakesValue: true}, + {Name: "data", TakesValue: true}, + {Name: "dry-run"}, + }, + }}} + ex := skillscan.Example{ + Raw: `lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":""}' --data '{"send_time":""}'`, + SourceFile: "skills/lark-mail/references/lark-mail-send.md", + Line: 172, + HasPlaceholder: true, + } + + diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex}) + if len(diags) != 0 { + t.Fatalf("RunDryRuns() diagnostics = %#v", diags) + } + if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" { + t.Fatalf("JSON resource placeholder example should be executable after materialization: %#v", facts) + } + wantArgs := []string{"mail", "user_mailbox.drafts", "send", "--params", `{"user_mailbox_id":"me","draft_id":"draft_test123"}`, "--data", `{"send_time":"1893456000"}`, "--dry-run"} + if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) { + t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs) + } +} + func TestRunDryRunsSkipsUnknownFlagsBeforeDryRun(t *testing.T) { m := manifest.Manifest{Commands: []manifest.Command{{ Path: "im +chat-messages-list", @@ -600,6 +755,51 @@ func TestAppendDryRunArgDoesNotDuplicate(t *testing.T) { } } +func TestAppendDryRunArgForcesJSONFormat(t *testing.T) { + got, err := appendDryRunArg("lark-cli vc +meeting-events --meeting-id 400000000001 --format pretty") + if err != nil { + t.Fatalf("appendDryRunArg() error = %v", err) + } + want := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--format", "json", "--dry-run"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("appendDryRunArg() = %#v, want %#v", got, want) + } +} + +func TestAppendDryRunArgForcesInlineJSONFormat(t *testing.T) { + got, err := appendDryRunArg("lark-cli vc +meeting-events --meeting-id 400000000001 --format=pretty --dry-run") + if err != nil { + t.Fatalf("appendDryRunArg() error = %v", err) + } + want := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--format=json", "--dry-run"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("appendDryRunArg() = %#v, want %#v", got, want) + } +} + +func TestAppendDryRunArgPreservesNonPrettyFormat(t *testing.T) { + for _, raw := range []string{ + "lark-cli mail +watch --format data --dry-run", + "lark-cli export +events --format=ndjson --dry-run", + "lark-cli docs +fetch --format table", + } { + got, err := appendDryRunArg(raw) + if err != nil { + t.Fatalf("appendDryRunArg(%q) error = %v", raw, err) + } + for _, arg := range got { + if arg == "--format=json" { + t.Fatalf("appendDryRunArg(%q) unexpectedly rewrote inline format: %#v", raw, got) + } + } + for i, arg := range got { + if arg == "--format" && i+1 < len(got) && got[i+1] == "json" { + t.Fatalf("appendDryRunArg(%q) unexpectedly rewrote split format: %#v", raw, got) + } + } + } +} + func TestAppendDryRunArgForcesDryRunWhenExplicitlyDisabled(t *testing.T) { got, err := appendDryRunArg("lark-cli docs +fetch --dry-run=false --doc abc") if err != nil { diff --git a/internal/qualitygate/rules/run.go b/internal/qualitygate/rules/run.go index 6026671c..35acea1c 100644 --- a/internal/qualitygate/rules/run.go +++ b/internal/qualitygate/rules/run.go @@ -15,18 +15,20 @@ import ( manifestexamples "github.com/larksuite/cli/internal/qualitygate/examples" "github.com/larksuite/cli/internal/qualitygate/facts" "github.com/larksuite/cli/internal/qualitygate/manifest" + "github.com/larksuite/cli/internal/qualitygate/publiccontent" "github.com/larksuite/cli/internal/qualitygate/report" "github.com/larksuite/cli/internal/qualitygate/skillscan" "github.com/larksuite/cli/internal/vfs" ) type Options struct { - Repo string - CLIBin string - ChangedFrom string - FactsOut string - ManifestPath string - CommandIndexPath string + Repo string + CLIBin string + ChangedFrom string + FactsOut string + ManifestPath string + CommandIndexPath string + PublicContentMetadataPath string } func Run(ctx context.Context, opts Options) ([]report.Diagnostic, facts.Facts, error) { @@ -98,9 +100,60 @@ func Run(ctx context.Context, opts Options) ([]report.Diagnostic, facts.Facts, e if opts.ChangedFrom != "" { diags = append(diags, errorDiags...) } + publicContent, err := publiccontent.Collect(ctx, publiccontent.Options{ + Repo: opts.Repo, + ChangedFrom: opts.ChangedFrom, + MetadataPath: opts.PublicContentMetadataPath, + }) + if err != nil { + return nil, facts.Facts{}, err + } + diags = append(diags, publicContentDiagnostics(publicContent)...) diags = filterPRDiagnostics(opts.Repo, opts.ChangedFrom, scope, m, diags) - return diags, facts.BuildWithCommandLookup(m, commandIndex, skillFacts, skillQualityFacts, errorFacts, exampleFacts, outputFacts, diags, scope.Files), nil + builtFacts := facts.BuildWithCommandLookup(m, commandIndex, skillFacts, skillQualityFacts, errorFacts, exampleFacts, outputFacts, diags, scope.Files) + return diags, facts.WithPublicContent(builtFacts, publicContentFacts(publicContent)), nil +} + +func publicContentDiagnostics(items []publiccontent.Finding) []report.Diagnostic { + if len(items) == 0 { + return nil + } + out := make([]report.Diagnostic, 0, len(items)) + for _, item := range items { + if item.Rule == "public_content_semantic_candidate" { + continue + } + out = append(out, report.Diagnostic{ + Rule: item.Rule, + Action: item.Action, + File: item.File, + Line: item.Line, + Message: item.Message, + Suggestion: item.Suggestion, + }) + } + return out +} + +func publicContentFacts(items []publiccontent.Finding) []facts.PublicContentFact { + if len(items) == 0 { + return nil + } + out := make([]facts.PublicContentFact, 0, len(items)) + for _, item := range items { + out = append(out, facts.PublicContentFact{ + Rule: item.Rule, + Action: item.Action, + File: item.File, + Line: item.Line, + Source: item.Source, + Excerpt: item.Excerpt, + Message: item.Message, + Suggestion: item.Suggestion, + }) + } + return out } func readManifestInput(path, kind, flag string) (manifest.Manifest, error) { @@ -167,6 +220,9 @@ func filterPRDiagnostics(repo, changedFrom string, scope qdiff.Scope, m manifest } func prDiagnosticRelevant(repo string, changedFiles map[string]bool, commandScope diagnosticCommandScope, m manifest.Manifest, diag report.Diagnostic) bool { + if strings.HasPrefix(diag.Rule, "public_content_") { + return true + } file := normalizeDiagnosticFile(repo, diag.File) if file != "" && changedFiles[file] { return true diff --git a/internal/qualitygate/rules/run_test.go b/internal/qualitygate/rules/run_test.go index 99188b44..b60a2c63 100644 --- a/internal/qualitygate/rules/run_test.go +++ b/internal/qualitygate/rules/run_test.go @@ -189,6 +189,99 @@ description: Manage Drive comments with service command references. } } +func TestRunCollectsPublicContentFindingsIntoDiagnosticsAndFacts(t *testing.T) { + repo := t.TempDir() + runGit(t, repo, "init") + runGit(t, repo, "config", "user.email", "test@example.com") + runGit(t, repo, "config", "user.name", "Test User") + if err := vfs.WriteFile(filepath.Join(repo, "README.md"), []byte("# test\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", "README.md") + runGit(t, repo, "commit", "-m", "base") + + if err := vfs.MkdirAll(filepath.Join(repo, "docs"), 0o755); err != nil { + t.Fatal(err) + } + publicDoc := "api_" + "key = \"example-public-key\"\n" + + "Public docs describe a pri" + "vate request header and trust classification detail.\n" + if err := vfs.WriteFile(filepath.Join(repo, "docs", "public.md"), []byte(publicDoc), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", "docs/public.md") + runGit(t, repo, "commit", "-m", "add public doc") + + metadataPath := filepath.Join(repo, "pr-metadata.json") + if err := vfs.WriteFile(metadataPath, []byte(`{"title":"public docs","body":"Change`+`-Id: I0123456789abcdef0123456789abcdef01234567"}`), 0o644); err != nil { + t.Fatal(err) + } + + manifestPath := filepath.Join(repo, "command-manifest.json") + indexPath := filepath.Join(repo, "command-index.json") + m := manifest.Manifest{SchemaVersion: 1, Commands: []manifest.Command{{ + Path: "docs +fetch", + CanonicalPath: "docs +fetch", + Domain: "docs", + Source: manifest.SourceShortcut, + }}} + if err := manifest.WriteFile(manifestPath, manifest.KindCommandManifest, m); err != nil { + t.Fatal(err) + } + idx := manifest.Manifest{SchemaVersion: 1, Commands: append([]manifest.Command{}, m.Commands...)} + idx.Commands = append(idx.Commands, manifest.Command{ + Path: "drive files get", + CanonicalPath: "drive files get", + Domain: "drive", + Source: manifest.SourceService, + Generated: true, + Runnable: true, + }) + if err := manifest.WriteFile(indexPath, manifest.KindCommandIndex, idx); err != nil { + t.Fatal(err) + } + + diags, gotFacts, err := Run(context.Background(), Options{ + Repo: repo, + CLIBin: "./lark-cli", + ChangedFrom: "HEAD~1", + ManifestPath: manifestPath, + CommandIndexPath: indexPath, + PublicContentMetadataPath: metadataPath, + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + actions := map[string]report.Action{} + for _, diag := range diags { + actions[diag.Rule] = diag.Action + } + if actions["public_content_generic_credential"] != report.ActionReject { + t.Fatalf("generic credential diagnostic action = %q, diagnostics=%#v", actions["public_content_generic_credential"], diags) + } + if actions["public_content_change_id_trailer"] != report.ActionReject { + t.Fatalf("change-id diagnostic action = %q, diagnostics=%#v", actions["public_content_change_id_trailer"], diags) + } + if actions["public_content_semantic_candidate"] != "" { + t.Fatalf("semantic candidates should not become deterministic diagnostics: %#v", diags) + } + factRules := map[string]bool{} + for _, item := range gotFacts.PublicContent { + factRules[item.Rule] = true + } + for _, want := range []string{ + "public_content_generic_credential", + "public_content_change_id_trailer", + "public_content_semantic_candidate", + } { + if !factRules[want] { + t.Fatalf("missing public content fact %s: %#v", want, gotFacts.PublicContent) + } + } + if len(gotFacts.PublicContent) < 3 { + t.Fatalf("public content facts = %#v", gotFacts.PublicContent) + } +} + func TestLoadBaseReferenceManifestReadsCommandGolden(t *testing.T) { repo := t.TempDir() runGit(t, repo, "init") @@ -506,7 +599,7 @@ func TestNormalizeDiagnosticFileHandlesAbsoluteRepo(t *testing.T) { func runGit(t *testing.T, repo string, args ...string) { t.Helper() - cmd := exec.Command("git", append([]string{"-C", repo}, args...)...) + cmd := exec.Command("git", append([]string{"-c", "core.hooksPath=/dev/null", "-C", repo}, args...)...) cmd.Env = append(os.Environ(), "GIT_AUTHOR_DATE=2026-06-17T00:00:00Z", "GIT_COMMITTER_DATE=2026-06-17T00:00:00Z") out, err := cmd.CombinedOutput() if err != nil { diff --git a/internal/qualitygate/semantic/client.go b/internal/qualitygate/semantic/client.go index e66f2862..5a7c873f 100644 --- a/internal/qualitygate/semantic/client.go +++ b/internal/qualitygate/semantic/client.go @@ -339,7 +339,7 @@ func jsonSchemaResponseFormat() map[string]any { "properties": map[string]any{ "category": map[string]any{ "type": "string", - "enum": []string{"error_hint", "default_output", "naming", "skill_quality"}, + "enum": []string{"error_hint", "default_output", "naming", "skill_quality", "public_content_leakage"}, }, "severity": map[string]any{ "type": "string", diff --git a/internal/qualitygate/semantic/gatekeeper.go b/internal/qualitygate/semantic/gatekeeper.go index 23da39f4..57e0cbcb 100644 --- a/internal/qualitygate/semantic/gatekeeper.go +++ b/internal/qualitygate/semantic/gatekeeper.go @@ -10,9 +10,10 @@ import ( "strings" "github.com/larksuite/cli/internal/qualitygate/facts" + "github.com/larksuite/cli/internal/qualitygate/report" ) -var evidencePattern = regexp.MustCompile(`^facts\.(commands|skills|errors|outputs)\[(\d+)\]$`) +var evidencePattern = regexp.MustCompile(`^facts\.(commands|skills|errors|outputs|public_content)\[(\d+)\]$`) func Decide(f facts.Facts, r Review, p Policy) Decision { return DecideWithWaivers(f, r, p, Waivers{}) @@ -172,6 +173,16 @@ func evidenceFingerprint(f facts.Facts, ev string) string { "has_default_limit:" + strconv.FormatBool(out.HasDefaultLimit), "has_decision_field:" + strconv.FormatBool(out.HasDecisionField), }, ":") + case "public_content": + item := f.PublicContent[idx] + return strings.Join([]string{ + "public_content", + "rule:" + item.Rule, + "action:" + string(item.Action), + "file:" + item.File, + "line:" + strconv.Itoa(item.Line), + "source:" + item.Source, + }, ":") default: return "ref:" + ev } @@ -201,7 +212,7 @@ func validFinding(f Finding) bool { func allowedCategory(category string) bool { switch category { - case "error_hint", "default_output", "naming", "skill_quality": + case "error_hint", "default_output", "naming", "skill_quality", "public_content_leakage": return true default: return false @@ -247,6 +258,12 @@ func reproducibleEvidence(f facts.Facts, category, kind string, idx int) bool { } skill := f.Skills[idx] return skill.ReferencesInvalidCommand + case "public_content_leakage": + if kind != "public_content" { + return false + } + item := f.PublicContent[idx] + return item.Action == report.ActionReject || item.Rule == "public_content_semantic_candidate" default: return false } @@ -277,6 +294,8 @@ func evidenceExists(f facts.Facts, kind string, idx int) bool { return idx < len(f.Errors) case "outputs": return idx < len(f.Outputs) + case "public_content": + return idx < len(f.PublicContent) default: return false } diff --git a/internal/qualitygate/semantic/gatekeeper_test.go b/internal/qualitygate/semantic/gatekeeper_test.go index c1d3e897..8e8ac676 100644 --- a/internal/qualitygate/semantic/gatekeeper_test.go +++ b/internal/qualitygate/semantic/gatekeeper_test.go @@ -242,6 +242,7 @@ func TestGatekeeperBlockerMatrix(t *testing.T) { Outputs: []facts.OutputFact{{Command: "im messages list", IsList: true, HasDefaultLimit: false, HasDecisionField: false}}, Commands: []facts.CommandFact{{Path: "docs fetch", NameConflictsExisting: true}}, Skills: []facts.SkillFact{{SourceFile: "skills/lark-doc/SKILL.md", Line: 3, ReferencesInvalidCommand: true}}, + PublicContent: []facts.PublicContentFact{{Rule: "public_content_generic_credential", Action: "REJECT", File: "docs/public.md", Line: 4, Source: "metadata"}}, } for _, tc := range []struct { category string @@ -251,6 +252,7 @@ func TestGatekeeperBlockerMatrix(t *testing.T) { {"default_output", "facts.outputs[0]"}, {"naming", "facts.commands[0]"}, {"skill_quality", "facts.skills[0]"}, + {"public_content_leakage", "facts.public_content[0]"}, } { t.Run(tc.category, func(t *testing.T) { r := Review{Findings: []Finding{{ @@ -268,6 +270,59 @@ func TestGatekeeperBlockerMatrix(t *testing.T) { } } +func TestGatekeeperDoesNotPromotePublicContentWarningsToBlockers(t *testing.T) { + f := facts.Facts{ + SchemaVersion: 1, + PublicContent: []facts.PublicContentFact{{ + Rule: "public_content_" + "pri" + "vate_ipv4", + Action: "WARNING", + File: "docs/network.md", + Line: 1, + Source: "file", + }}, + } + review := Review{Findings: []Finding{{ + Category: "public_content_leakage", + Severity: "minor", + Evidence: []string{"facts.public_content[0]"}, + Message: "pri" + "vate network address appears in public docs", + SuggestedAction: "confirm the public docs do not expose pri" + "vate deployment details", + }}} + + got := Decide(f, review, DefaultPolicy()) + if len(got.Blockers) != 0 || len(got.Warnings) != 1 { + t.Fatalf("public content warning should not become a blocker: %#v", got) + } + if got.Warnings[0].ReviewAction != ReviewActionObserve { + t.Fatalf("review action = %q, want %q", got.Warnings[0].ReviewAction, ReviewActionObserve) + } +} + +func TestGatekeeperAllowsPublicContentSemanticCandidatesAsBlockers(t *testing.T) { + f := facts.Facts{ + SchemaVersion: 1, + PublicContent: []facts.PublicContentFact{{ + Rule: "public_content_semantic_candidate", + Action: "WARNING", + File: "docs/public.md", + Line: 1, + Source: "file", + }}, + } + review := Review{Findings: []Finding{{ + Category: "public_content_leakage", + Severity: "major", + Evidence: []string{"facts.public_content[0]"}, + Message: "semantic review found pri" + "vate rollout detail", + SuggestedAction: "remove pri" + "vate rollout detail from public docs", + }}} + + got := Decide(f, review, DefaultPolicy()) + if len(got.Blockers) != 1 { + t.Fatalf("semantic candidate should remain blockable, got %#v", got) + } +} + func TestGatekeeperSkillQualityOnlyBlocksInvalidCommandReferences(t *testing.T) { f := facts.Facts{ SchemaVersion: 1, diff --git a/internal/qualitygate/semantic/prompt.go b/internal/qualitygate/semantic/prompt.go index 29c5d0e5..e2ae17f7 100644 --- a/internal/qualitygate/semantic/prompt.go +++ b/internal/qualitygate/semantic/prompt.go @@ -24,7 +24,7 @@ func BuildPrompt(f facts.Facts) []Message { "Use only the provided JSON view.", "The changed_summary may summarize broad changed surfaces; review only listed facts, not omitted summarized items.", "Use fact_ref values exactly when writing finding evidence.", - "Only facts.commands, facts.skills, facts.errors, and facts.outputs fact_ref values may be blocker evidence.", + "Only facts.commands, facts.skills, facts.errors, facts.outputs, and facts.public_content fact_ref values may be blocker evidence.", "Evidence entries must be exact fact_ref strings such as \"facts.commands[0]\" with no explanations, labels, or suffix text.", "facts.examples and facts.skill_quality entries are context only.", "Report an error_hint finding for any facts.errors item where boundary is true, required_hint is true, and hint_action_count is 0.", @@ -38,6 +38,9 @@ func BuildPrompt(f facts.Facts) []Message { "For naming findings, use category \"naming\" and evidence containing that facts.commands fact_ref.", "Report a skill_quality finding for any facts.skills item where references_invalid_command is true.", "For skill_quality findings, use category \"skill_quality\" and evidence containing that facts.skills fact_ref.", + "Review public content leakage findings and semantic candidates without private dictionaries.", + "Do not reveal internal rule lists when explaining public content leakage.", + "For public_content_leakage findings, preserve the deterministic finding source and excerpt.", "Report each distinct issue as a separate finding.", "The verdict value must be \"pass\" when findings is empty and \"warn\" when findings is non-empty; never use \"fail\".", "Severity must be one of \"minor\", \"major\", or \"critical\"; never use \"error\", \"warning\", \"medium\", or \"high\".", diff --git a/internal/qualitygate/semantic/prompt_contract_test.go b/internal/qualitygate/semantic/prompt_contract_test.go index 98948383..fefa07ef 100644 --- a/internal/qualitygate/semantic/prompt_contract_test.go +++ b/internal/qualitygate/semantic/prompt_contract_test.go @@ -23,7 +23,10 @@ func TestBuildPromptContainsSemanticReviewContract(t *testing.T) { "A facts.outputs item with is_list true, has_default_limit false, and has_decision_field true must still produce a default_output finding.", "Report a naming finding for any facts.commands item where name_conflicts_existing is true or flag_alias_conflict is true.", "Report a skill_quality finding for any facts.skills item where references_invalid_command is true.", - "Only facts.commands, facts.skills, facts.errors, and facts.outputs fact_ref values may be blocker evidence.", + "Review public content leakage findings and semantic candidates without private dictionaries.", + "Do not reveal internal rule lists when explaining public content leakage.", + "For public_content_leakage findings, preserve the deterministic finding source and excerpt.", + "Only facts.commands, facts.skills, facts.errors, facts.outputs, and facts.public_content fact_ref values may be blocker evidence.", "Evidence entries must be exact fact_ref strings such as \"facts.commands[0]\" with no explanations, labels, or suffix text.", "facts.examples and facts.skill_quality entries are context only.", "Report each distinct issue as a separate finding.", diff --git a/internal/qualitygate/semantic/schema.go b/internal/qualitygate/semantic/schema.go index 2ab4572b..5097a870 100644 --- a/internal/qualitygate/semantic/schema.go +++ b/internal/qualitygate/semantic/schema.go @@ -78,11 +78,11 @@ func DefaultPolicy() Policy { return Policy{ SchemaVersion: 1, DefaultEnforcement: "observe", - BlockCategories: []string{"error_hint", "default_output", "naming", "skill_quality"}, + BlockCategories: []string{"error_hint", "default_output", "naming", "skill_quality", "public_content_leakage"}, RolloutGroups: []RolloutGroup{{ ID: "all", Enforcement: "blocking", - Categories: []string{"error_hint", "default_output", "naming", "skill_quality"}, + Categories: []string{"error_hint", "default_output", "naming", "skill_quality", "public_content_leakage"}, Owner: "test", Reason: "default in-memory policy", }}, diff --git a/internal/qualitygate/semantic/scope.go b/internal/qualitygate/semantic/scope.go index 88a2cf93..d1738d17 100644 --- a/internal/qualitygate/semantic/scope.go +++ b/internal/qualitygate/semantic/scope.go @@ -82,6 +82,15 @@ func factScope(f facts.Facts, kind string, idx int) (FactScope, bool) { Source: item.Source, CommandPath: item.Command, }, true + case "public_content": + item := f.PublicContent[idx] + return FactScope{ + FactKind: "public_content", + Changed: true, + Source: item.Source, + SourceFile: item.File, + Line: item.Line, + }, true default: return FactScope{}, false } @@ -195,7 +204,7 @@ func containsString(values []string, want string) bool { func allowedFactKind(kind string) bool { switch kind { - case "skill", "command", "error", "output": + case "skill", "command", "error", "output", "public_content": return true default: return false diff --git a/internal/qualitygate/semantic/scope_test.go b/internal/qualitygate/semantic/scope_test.go index 863dbdbd..e5e50dc7 100644 --- a/internal/qualitygate/semantic/scope_test.go +++ b/internal/qualitygate/semantic/scope_test.go @@ -81,6 +81,30 @@ func TestGatekeeperSkillQualityUsesSkillEvidence(t *testing.T) { } } +func TestGatekeeperUsesPublicContentEvidence(t *testing.T) { + f := facts.Facts{ + SchemaVersion: 1, + PublicContent: []facts.PublicContentFact{{ + Rule: "public_content_generic_credential", + Action: "REJECT", + File: "docs/public.md", + Line: 12, + Source: "metadata", + }}, + } + review := Review{Findings: []Finding{{ + Category: "public_content_leakage", + Severity: "critical", + Evidence: []string{"facts.public_content[0]"}, + Message: "public content finding needs review", + SuggestedAction: "remove the sensitive public content", + }}} + got := Decide(f, review, DefaultPolicy()) + if len(got.Blockers) != 1 || got.Blockers[0].RolloutGroups[0] != "all" { + t.Fatalf("expected public content blocker, got %#v", got) + } +} + func TestGatekeeperAppliesSharedWaiverID(t *testing.T) { f := facts.Facts{ SchemaVersion: 1, diff --git a/internal/qualitygate/semantic/view.go b/internal/qualitygate/semantic/view.go index a0e6f6dc..1bf61065 100644 --- a/internal/qualitygate/semantic/view.go +++ b/internal/qualitygate/semantic/view.go @@ -13,27 +13,29 @@ import ( ) type InputView struct { - SchemaVersion int `json:"schema_version"` - ChangedSummary ChangedSummary `json:"changed_summary"` - RuleSummary []RuleSummaryItem `json:"rule_summary,omitempty"` - Commands []CommandInput `json:"commands,omitempty"` - Skills []SkillInput `json:"skills,omitempty"` - SkillQuality []SkillQualityInput `json:"skill_quality,omitempty"` - Errors []ErrorInput `json:"errors,omitempty"` - Outputs []OutputInput `json:"outputs,omitempty"` - Examples []ExampleInput `json:"examples,omitempty"` - Diagnostics []facts.DiagnosticFact `json:"diagnostics,omitempty"` + SchemaVersion int `json:"schema_version"` + ChangedSummary ChangedSummary `json:"changed_summary"` + RuleSummary []RuleSummaryItem `json:"rule_summary,omitempty"` + Commands []CommandInput `json:"commands,omitempty"` + Skills []SkillInput `json:"skills,omitempty"` + SkillQuality []SkillQualityInput `json:"skill_quality,omitempty"` + Errors []ErrorInput `json:"errors,omitempty"` + Outputs []OutputInput `json:"outputs,omitempty"` + Examples []ExampleInput `json:"examples,omitempty"` + PublicContentLeakage []PublicContentInput `json:"public_content_leakage,omitempty"` + Diagnostics []facts.DiagnosticFact `json:"diagnostics,omitempty"` } type ChangedSummary struct { - Commands int `json:"commands,omitempty"` - Skills int `json:"skills,omitempty"` - SkillQuality int `json:"skill_quality,omitempty"` - Errors int `json:"errors,omitempty"` - Outputs int `json:"outputs,omitempty"` - Examples int `json:"examples,omitempty"` - Domains []string `json:"domains,omitempty"` - Sources []string `json:"sources,omitempty"` + Commands int `json:"commands,omitempty"` + Skills int `json:"skills,omitempty"` + SkillQuality int `json:"skill_quality,omitempty"` + Errors int `json:"errors,omitempty"` + Outputs int `json:"outputs,omitempty"` + Examples int `json:"examples,omitempty"` + PublicContent int `json:"public_content,omitempty"` + Domains []string `json:"domains,omitempty"` + Sources []string `json:"sources,omitempty"` } type RuleSummaryItem struct { @@ -86,6 +88,22 @@ type ExampleInput struct { facts.CommandExample } +type PublicContentInput struct { + FactRef string `json:"fact_ref"` + facts.PublicContentFact +} + +func (v InputView) HasReviewableFacts() bool { + return len(v.Commands) > 0 || + len(v.Skills) > 0 || + len(v.SkillQuality) > 0 || + len(v.Errors) > 0 || + len(v.Outputs) > 0 || + len(v.Examples) > 0 || + len(v.PublicContentLeakage) > 0 || + len(v.Diagnostics) > 0 +} + func BuildInputView(f facts.Facts) InputView { selected := newInputSelection(f) selected.addChangedReviewCandidates() @@ -104,16 +122,17 @@ func BuildInputView(f facts.Facts) InputView { } return InputView{ - SchemaVersion: f.SchemaVersion, - ChangedSummary: changedSummary(f), - RuleSummary: ruleSummary(f.Diagnostics), - Commands: selected.commandInputs(), - Skills: selected.skillInputs(), - SkillQuality: selected.skillQualityInputs(), - Errors: selected.errorInputs(), - Outputs: selected.outputInputs(), - Examples: selected.exampleInputs(), - Diagnostics: viewDiagnostics, + SchemaVersion: f.SchemaVersion, + ChangedSummary: changedSummary(f), + RuleSummary: ruleSummary(f.Diagnostics), + Commands: selected.commandInputs(), + Skills: selected.skillInputs(), + SkillQuality: selected.skillQualityInputs(), + Errors: selected.errorInputs(), + Outputs: selected.outputInputs(), + Examples: selected.exampleInputs(), + PublicContentLeakage: selected.publicContentInputs(), + Diagnostics: viewDiagnostics, } } @@ -138,6 +157,11 @@ func (s *inputSelection) addChangedReviewCandidates() { s.outputs[i] = true } } + for i, item := range s.f.PublicContent { + if publicContentReviewCandidate(item) { + s.publicContent[i] = true + } + } } func commandReviewCandidate(cmd facts.CommandFact) bool { @@ -157,25 +181,31 @@ func outputReviewCandidate(_ facts.OutputFact) bool { return false } +func publicContentReviewCandidate(item facts.PublicContentFact) bool { + return item.Rule == "public_content_semantic_candidate" +} + type inputSelection struct { - f facts.Facts - commands []bool - skills []bool - skillQuality []bool - errors []bool - outputs []bool - examples []bool + f facts.Facts + commands []bool + skills []bool + skillQuality []bool + errors []bool + outputs []bool + examples []bool + publicContent []bool } func newInputSelection(f facts.Facts) *inputSelection { return &inputSelection{ - f: f, - commands: make([]bool, len(f.Commands)), - skills: make([]bool, len(f.Skills)), - skillQuality: make([]bool, len(f.SkillQuality)), - errors: make([]bool, len(f.Errors)), - outputs: make([]bool, len(f.Outputs)), - examples: make([]bool, len(f.Examples)), + f: f, + commands: make([]bool, len(f.Commands)), + skills: make([]bool, len(f.Skills)), + skillQuality: make([]bool, len(f.SkillQuality)), + errors: make([]bool, len(f.Errors)), + outputs: make([]bool, len(f.Outputs)), + examples: make([]bool, len(f.Examples)), + publicContent: make([]bool, len(f.PublicContent)), } } @@ -194,6 +224,8 @@ func (s *inputSelection) diagnosticContext(diag facts.DiagnosticFact) *inputSele s.addDiagnosticExamples(out, diag) case diag.Rule == "no_bare_helper_error": s.addDiagnosticErrors(out, diag) + case strings.HasPrefix(diag.Rule, "public_content_"): + s.addDiagnosticPublicContent(out, diag) } return out } @@ -256,6 +288,15 @@ func (s *inputSelection) addDiagnosticExamples(out *inputSelection, diag facts.D } } +func (s *inputSelection) addDiagnosticPublicContent(out *inputSelection, diag facts.DiagnosticFact) { + for i, item := range s.f.PublicContent { + if diagnosticLocationMatches(diag.File, diag.Line, item.File, item.Line) || + diag.Rule == item.Rule { + out.publicContent[i] = true + } + } +} + func includeDiagnosticInView(diag facts.DiagnosticFact, selected, context *inputSelection) bool { if diag.Action == report.ActionReject { return true @@ -270,6 +311,7 @@ func (s *inputSelection) merge(other *inputSelection) { mergeSelections(s.errors, other.errors) mergeSelections(s.outputs, other.outputs) mergeSelections(s.examples, other.examples) + mergeSelections(s.publicContent, other.publicContent) } func (s *inputSelection) intersects(other *inputSelection) bool { @@ -278,7 +320,8 @@ func (s *inputSelection) intersects(other *inputSelection) bool { selectionsIntersect(s.skillQuality, other.skillQuality) || selectionsIntersect(s.errors, other.errors) || selectionsIntersect(s.outputs, other.outputs) || - selectionsIntersect(s.examples, other.examples) + selectionsIntersect(s.examples, other.examples) || + selectionsIntersect(s.publicContent, other.publicContent) } func (s *inputSelection) commandInputs() []CommandInput { @@ -351,6 +394,16 @@ func (s *inputSelection) exampleInputs() []ExampleInput { return out } +func (s *inputSelection) publicContentInputs() []PublicContentInput { + out := make([]PublicContentInput, 0, countSelected(s.publicContent)) + for i, ok := range s.publicContent { + if ok { + out = append(out, PublicContentInput{FactRef: factRef("public_content", i), PublicContentFact: s.f.PublicContent[i]}) + } + } + return out +} + func changedSummary(f facts.Facts) ChangedSummary { domains := map[string]bool{} sources := map[string]bool{} @@ -402,6 +455,10 @@ func changedSummary(f facts.Facts) ChangedSummary { addNonEmpty(domains, example.Domain) addNonEmpty(sources, example.Source) } + for _, item := range f.PublicContent { + out.PublicContent++ + addNonEmpty(sources, item.Source) + } out.Domains = sortedViewSetKeys(domains) out.Sources = sortedViewSetKeys(sources) return out @@ -434,7 +491,8 @@ func semanticDiagnosticRule(rule string) bool { strings.HasPrefix(rule, "default_output") || strings.HasPrefix(rule, "skill_") || strings.HasPrefix(rule, "example_dry_run") || - rule == "no_bare_helper_error" + rule == "no_bare_helper_error" || + strings.HasPrefix(rule, "public_content_") } func diagnosticCommandMatches(diag facts.DiagnosticFact, values ...string) bool { diff --git a/internal/qualitygate/semantic/view_test.go b/internal/qualitygate/semantic/view_test.go index 05200933..0da1c11f 100644 --- a/internal/qualitygate/semantic/view_test.go +++ b/internal/qualitygate/semantic/view_test.go @@ -77,6 +77,122 @@ func TestInputViewKeepsChangedReviewCandidatesWithOriginalRefs(t *testing.T) { } } +func TestInputViewIncludesPublicContentLeakage(t *testing.T) { + f := facts.Facts{ + SchemaVersion: 1, + PublicContent: []facts.PublicContentFact{{ + Rule: "public_content_generic_credential", + Action: report.ActionReject, + File: "docs/public.md", + Line: 4, + Excerpt: "api_key = ", + Message: "generic credential assignment", + }}, + Diagnostics: []facts.DiagnosticFact{{ + Rule: "public_content_generic_credential", + Action: report.ActionReject, + File: "docs/public.md", + Line: 4, + Message: "generic credential assignment", + }}, + } + + view := BuildInputView(f) + if len(view.PublicContentLeakage) != 1 { + t.Fatalf("public content leakage len = %d, want 1", len(view.PublicContentLeakage)) + } + if got := view.PublicContentLeakage[0].FactRef; got != "facts.public_content[0]" { + t.Fatalf("public content fact ref = %q", got) + } + if len(view.Diagnostics) != 1 { + t.Fatalf("diagnostics len = %d, want 1", len(view.Diagnostics)) + } +} + +func TestInputViewIncludesPublicContentSemanticCandidatesWithoutDiagnostics(t *testing.T) { + f := facts.Facts{ + SchemaVersion: 1, + PublicContent: []facts.PublicContentFact{{ + Rule: "public_content_semantic_candidate", + Action: report.ActionWarning, + File: "docs/public.md", + Line: 1, + Source: "file", + Excerpt: "public prose that needs semantic review", + Message: "public contribution contains text for semantic public content review", + }}, + } + + view := BuildInputView(f) + if len(view.PublicContentLeakage) != 1 { + t.Fatalf("semantic candidate len = %d, want 1", len(view.PublicContentLeakage)) + } + if got := view.PublicContentLeakage[0].FactRef; got != "facts.public_content[0]" { + t.Fatalf("semantic candidate fact ref = %q", got) + } + if len(view.Diagnostics) != 0 { + t.Fatalf("semantic candidate should not require diagnostics, got %#v", view.Diagnostics) + } +} + +func TestPromptIncludesSanitizedPublicContentExcerpt(t *testing.T) { + scopeText := "pri" + "vate rollout" + f := facts.Facts{ + SchemaVersion: 1, + PublicContent: []facts.PublicContentFact{{ + Rule: "public_content_semantic_candidate", + Action: report.ActionWarning, + File: "docs/public.md", + Line: 1, + Source: "file", + Excerpt: `semantic signals: pri` + `vate_scope,roadmap_detail; excerpt: "` + scopeText + ` token="`, + Message: "public contribution contains text for semantic public content review", + }}, + } + + view := BuildInputView(f) + if len(view.PublicContentLeakage) != 1 { + t.Fatalf("semantic candidate len = %d, want 1", len(view.PublicContentLeakage)) + } + if got := view.PublicContentLeakage[0].Excerpt; !strings.Contains(got, scopeText) || !strings.Contains(got, "token=") { + t.Fatalf("semantic candidate excerpt missing from view: %q", got) + } + + messages := BuildPrompt(f) + if len(messages) != 2 { + t.Fatalf("messages len = %d, want 2", len(messages)) + } + if !strings.Contains(messages[1].Content, scopeText) || !strings.Contains(messages[1].Content, "redacted") { + t.Fatalf("prompt missing sanitized public content excerpt: %s", messages[1].Content) + } + if strings.Contains(messages[1].Content, "real-"+"secret-value") { + t.Fatalf("prompt leaked raw sensitive value %q", messages[1].Content) + } +} + +func TestInputViewExcludesPublicContentWarningsWithoutSemanticCandidate(t *testing.T) { + f := facts.Facts{ + SchemaVersion: 1, + PublicContent: []facts.PublicContentFact{{ + Rule: "public_content_" + "pri" + "vate_ipv4", + Action: report.ActionWarning, + File: "docs/network.md", + Line: 1, + Source: "file", + Excerpt: "192.168." + "0.10", + Message: "public contribution contains a pri" + "vate-network IP address", + }}, + } + + view := BuildInputView(f) + if len(view.PublicContentLeakage) != 0 { + t.Fatalf("warning-only public content should not enter semantic view: %#v", view.PublicContentLeakage) + } + if len(view.Diagnostics) != 0 { + t.Fatalf("warning-only public content should not add diagnostics: %#v", view.Diagnostics) + } +} + func TestInputViewSummarizesBroadChangedCommandSurface(t *testing.T) { f := broadChangedFacts(434, 44) diff --git a/internal/qualitygate/semantic/waiver.go b/internal/qualitygate/semantic/waiver.go index 971a8432..7d914d2c 100644 --- a/internal/qualitygate/semantic/waiver.go +++ b/internal/qualitygate/semantic/waiver.go @@ -138,6 +138,10 @@ func parseWaiver(parts []string, lineNo int) (Waiver, error) { if item.SourceFile == "" || item.Line == 0 { return Waiver{}, fmt.Errorf("%s:%d: %s waiver requires source_file and line", waiverPath, lineNo, item.FactKind) } + case "public_content": + if item.SourceFile == "" || item.Line == 0 || item.CommandPath != "" { + return Waiver{}, fmt.Errorf("%s:%d: public_content waiver requires source_file and line only", waiverPath, lineNo) + } case "command", "output": if item.CommandPath == "" { return Waiver{}, fmt.Errorf("%s:%d: %s waiver requires command_path", waiverPath, lineNo, item.FactKind) diff --git a/internal/qualitygate/semantic/waiver_test.go b/internal/qualitygate/semantic/waiver_test.go index 601315f5..9ac12066 100644 --- a/internal/qualitygate/semantic/waiver_test.go +++ b/internal/qualitygate/semantic/waiver_test.go @@ -21,24 +21,27 @@ func TestLoadWaivers(t *testing.T) { writeSemanticFile(t, repo, "waivers.txt", "# waiver_id\tcategory\tfact_kind\tsource_file\tline\tcommand_path\towner\treason\tadded_at\texpires_at\n"+ "wiki-move-202606\tskill_quality\tskill\tskills/lark-wiki/SKILL.md\t30\t\twiki-owner\tmigration\t2026-06-08\t2026-07-15\n"+ - "wiki-move-202606\tskill_quality\tskill\tskills/lark-wiki/references/move.md\t12\t\twiki-owner\tmigration\t2026-06-08\t2026-07-15\n") + "wiki-move-202606\tskill_quality\tskill\tskills/lark-wiki/references/move.md\t12\t\twiki-owner\tmigration\t2026-06-08\t2026-07-15\n"+ + "public-doc-202606\tpublic_content_leakage\tpublic_content\tdocs/public.md\t4\t\tsecurity-owner\treviewed false positive\t2026-06-08\t2026-07-15\n") w, diags, err = LoadWaivers(repo, now) if err != nil { t.Fatalf("LoadWaivers() error = %v", err) } - if len(diags) != 0 || len(w.Items) != 2 { + if len(diags) != 0 || len(w.Items) != 3 { t.Fatalf("LoadWaivers() = %#v %#v", w, diags) } for name, body := range map[string]string{ - "bad columns": "one\ttoo-few\n", - "bad id": "BAD\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n", - "bad fact kind": "id1\terror_hint\tskill_quality\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n", - "missing owner": "id1\terror_hint\terror\tcmd/root.go\t1\t\t\tr\t2026-06-08\t2026-07-15\n", - "missing line": "id1\terror_hint\terror\tcmd/root.go\t\t\to\tr\t2026-06-08\t2026-07-15\n", - "missing command": "id1\tdefault_output\toutput\t\t\t\to\tr\t2026-06-08\t2026-07-15\n", - "bad source path": "id1\terror_hint\terror\t../cmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n", - "bad date format": "id1\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t20260608\t2026-07-15\n", + "bad columns": "one\ttoo-few\n", + "bad id": "BAD\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n", + "bad fact kind": "id1\terror_hint\tskill_quality\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n", + "missing owner": "id1\terror_hint\terror\tcmd/root.go\t1\t\t\tr\t2026-06-08\t2026-07-15\n", + "missing line": "id1\terror_hint\terror\tcmd/root.go\t\t\to\tr\t2026-06-08\t2026-07-15\n", + "missing command": "id1\tdefault_output\toutput\t\t\t\to\tr\t2026-06-08\t2026-07-15\n", + "public content missing line": "id1\tpublic_content_leakage\tpublic_content\tdocs/public.md\t\t\to\tr\t2026-06-08\t2026-07-15\n", + "public content command selector": "id1\tpublic_content_leakage\tpublic_content\t\t\tcmd/foo\to\tr\t2026-06-08\t2026-07-15\n", + "bad source path": "id1\terror_hint\terror\t../cmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n", + "bad date format": "id1\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t20260608\t2026-07-15\n", } { t.Run(name, func(t *testing.T) { writeSemanticFile(t, repo, "waivers.txt", body) diff --git a/internal/registry/scope_priorities.json b/internal/registry/scope_priorities.json index 99c3c660..916d81df 100644 --- a/internal/registry/scope_priorities.json +++ b/internal/registry/scope_priorities.json @@ -5609,6 +5609,21 @@ "final_score": "80.0587", "recommend": "false" }, + { + "scope_name": "im:chat.nickname:read", + "final_score": "88.0587", + "recommend": "true" + }, + { + "scope_name": "im:chat.nickname:write", + "final_score": "79.5982", + "recommend": "true" + }, + { + "scope_name": "im:chat.user_setting:write", + "final_score": "83.6587", + "recommend": "true" + }, { "scope_name": "im:chat.user_setting:read", "final_score": "88.0587", diff --git a/package.json b/package.json index b76c50ad..61342a1a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@larksuite/cli", - "version": "1.0.57", + "version": "1.0.59", "description": "The official CLI for Lark/Feishu open platform", "bin": { "lark-cli": "scripts/run.js" diff --git a/scripts/ci-quality-summary-publish.js b/scripts/ci-quality-summary-publish.js index 900fe0f3..07e8d8fb 100644 --- a/scripts/ci-quality-summary-publish.js +++ b/scripts/ci-quality-summary-publish.js @@ -45,6 +45,10 @@ async function publishTargetStillCurrent(github, context, core, target, phase = repo: context.repo.repo, pull_number: target.pr, }); + if (pr.state !== "open") { + core.notice(`PR quality summary skipped: PR is no longer open before ${phase}`); + return false; + } if (pr.head.sha !== target.headSha) { core.notice(`PR quality summary skipped: PR head changed before ${phase}`); return false; diff --git a/scripts/ci-quality-summary-publish.test.js b/scripts/ci-quality-summary-publish.test.js index fb20fdb0..fb2c9b8c 100644 --- a/scripts/ci-quality-summary-publish.test.js +++ b/scripts/ci-quality-summary-publish.test.js @@ -152,6 +152,25 @@ describe("ci-quality-summary-publish", () => { }); }); + it("does not publish a summary when the PR closes before comment creation", async () => { + await withPublishTempDir(async ({ calls }) => { + await publish({ + github: fakeGithub(calls, { + jobs: [{ name: "unit-test", conclusion: "failure", html_url: "https://github.example/jobs/1" }], + pullResponses: [ + currentPullResponse(), + currentPullResponse({ state: "closed" }), + ], + }), + context: workflowRunContext({ conclusion: "failure" }), + core: silentCore(calls), + }); + + assert.equal(calls.comments.length, 0); + assert.match(calls.notices.join("\n"), /PR is no longer open/); + }); + }); + it("does not delete an existing summary when the PR base changes before cleanup", async () => { await withPublishTempDir(async ({ calls }) => { await publish({ @@ -338,6 +357,7 @@ function fakeGithub(calls, options = {}) { function currentPullResponse(overrides = {}) { return { data: { + state: overrides.state || "open", head: { sha: overrides.headSha || process.env.CI_QUALITY_SUMMARY_HEAD_SHA }, base: { sha: overrides.baseSha || process.env.CI_QUALITY_SUMMARY_BASE_SHA, diff --git a/scripts/ci-workflow.test.sh b/scripts/ci-workflow.test.sh index d5bbc2a2..b8bdcd98 100644 --- a/scripts/ci-workflow.test.sh +++ b/scripts/ci-workflow.test.sh @@ -5,26 +5,42 @@ set -euo pipefail workflow=".github/workflows/ci.yml" +job_section() { + local job="$1" + awk -v job="$job" ' + $0 == " " job ":" { in_job = 1; print; next } + in_job && /^ [A-Za-z0-9_-]+:/ { exit } + in_job { print } + ' "$workflow" +} workflow_permissions="$(awk ' /^permissions:/ { in_permissions = 1; print; next } in_permissions && /^[^[:space:]]/ { exit } in_permissions { print } ' "$workflow")" +fast_gate_section="$(job_section fast-gate)" +unit_test_section="$(job_section unit-test)" lint_section="$(awk ' /^ lint:/ { in_job = 1 } in_job { print } - /^ deterministic-gate:/ { exit } + /^ script-test:/ { exit } ' "$workflow")" +script_test_section="$(job_section script-test)" deterministic_section="$(awk ' /^ deterministic-gate:/ { in_job = 1 } in_job { print } /^ coverage:/ { exit } ' "$workflow")" +coverage_job_section="$(job_section coverage)" +deadcode_section="$(job_section deadcode)" +dry_run_section="$(job_section e2e-dry-run)" section="$(awk ' /^ e2e-live:/ { in_job = 1 } in_job { print } /^ security:/ { exit } ' "$workflow")" +security_section="$(job_section security)" +license_header_section="$(job_section license-header)" results_section="$(awk ' /^ results:/ { in_job = 1 } in_job { print } @@ -98,13 +114,94 @@ if ! grep -Fq "make quality-gate" <<<"$deterministic_section"; then exit 1 fi +if ! grep -Fq "Write public content metadata" <<<"$deterministic_section"; then + echo "deterministic-gate should write PR title/body metadata before quality-gate" + exit 1 +fi + +if ! grep -Fq "types: [opened, synchronize, reopened, edited]" "$workflow"; then + echo "CI pull_request trigger should include edited so PR title/body changes are rescanned" + exit 1 +fi + +if ! grep -Fq "script-test:" <<<"$script_test_section"; then + echo "CI should run make script-test so workflow and publisher contract tests are not local-only" + exit 1 +fi + +if ! grep -Fq "make script-test" <<<"$script_test_section"; then + echo "script-test job should invoke make script-test" + exit 1 +fi + +if ! grep -Fq "actions/setup-node" <<<"$script_test_section"; then + echo "script-test job should install Node for JavaScript workflow tests" + exit 1 +fi + +if grep -Fq '${{ secrets.' <<<"$script_test_section"; then + echo "script-test must not reference secrets" + exit 1 +fi + +if grep -Fq "metadata-gate:" "$workflow"; then + echo "metadata-gate should not run alongside deterministic-gate because both would upload the same facts artifact" + exit 1 +fi + +if grep -Fq "github.event.action != 'edited'" <<<"$fast_gate_section"; then + echo "fast-gate must run on pull_request edited events so title/body edits cannot replace failed CI with a light success" + exit 1 +fi + +for full_job in \ + "$unit_test_section" \ + "$lint_section" \ + "$script_test_section" \ + "$deterministic_section" \ + "$coverage_job_section" \ + "$dry_run_section" \ + "$security_section"; do + if grep -Fq "github.event.action != 'edited'" <<<"$full_job"; then + echo "full CI jobs must run on pull_request edited events; do not skip title/body-only edits" + exit 1 + fi +done + +for pull_request_job in "$deadcode_section" "$license_header_section"; do + if grep -Fq "github.event.action != 'edited'" <<<"$pull_request_job"; then + echo "pull_request-only CI jobs must run on edited events" + exit 1 + fi +done + +if grep -Fq '${{ secrets.' <<<"$deterministic_section"; then + echo "deterministic-gate must not reference secrets" + exit 1 +fi + +if ! grep -Fq "PUBLIC_CONTENT_METADATA=" <<<"$deterministic_section"; then + echo "deterministic-gate should pass public content metadata into make quality-gate" + exit 1 +fi + +if ! grep -Fq "PR_BRANCH:" <<<"$deterministic_section"; then + echo "deterministic-gate should pass the pull request branch into public content metadata" + exit 1 +fi + if ! grep -Fq "name: quality-gate-facts-\${{ github.event.pull_request.base.sha }}-\${{ github.event.pull_request.head.sha }}" <<<"$deterministic_section"; then echo "deterministic-gate should upload base/head-bound quality-gate-facts for semantic review" exit 1 fi -if ! grep -Fq "needs: [unit-test, lint, deterministic-gate]" "$workflow"; then - echo "E2E jobs should wait for deterministic-gate" +if ! grep -Fq "needs: [unit-test, lint, script-test, deterministic-gate]" "$workflow"; then + echo "E2E jobs should wait for script-test and deterministic-gate" + exit 1 +fi + +if ! grep -Fq "script-test" <<<"$results_section"; then + echo "results job should include script-test" exit 1 fi @@ -210,6 +307,11 @@ if ! grep -Fq "go run ./internal/qualitygate/cmd/manifest-export" <<<"$make_outp exit 1 fi +if ! grep -Fq -- "--public-content-metadata .tmp/quality-gate/public-content-metadata.json" <<<"$make_output"; then + echo "quality-gate check should consume public content metadata" + exit 1 +fi + if ! grep -Fq -- "--manifest .tmp/quality-gate/command-manifest.json" <<<"$make_output" || ! grep -Fq -- "--command-index .tmp/quality-gate/command-index.json" <<<"$make_output"; then echo "quality-gate check should consume both exported command snapshots" diff --git a/scripts/semantic-review-publish.js b/scripts/semantic-review-publish.js index 61a57592..afae3132 100644 --- a/scripts/semantic-review-publish.js +++ b/scripts/semantic-review-publish.js @@ -175,7 +175,7 @@ function inlineCode(value) { } function parseEvidenceRef(ref) { - const match = /^facts\.(commands|skills|errors|outputs)\[(\d+)\]$/.exec(String(ref || "")); + const match = /^facts\.(commands|skills|errors|outputs|public_content)\[(\d+)\]$/.exec(String(ref || "")); if (!match) { return null; } @@ -230,6 +230,20 @@ function evidenceLocation(facts, ref) { return { kind: parsed.kind, command: item.path, label: item.path }; } return null; + case "public_content": + if (item.file && Number.isInteger(item.line) && item.line > 0) { + const label = `${item.file}:${item.line}`; + if (item.file === "branch" || item.file === "pull_request_metadata" || String(item.file).startsWith("commit:")) { + return { kind: parsed.kind, label }; + } + return { + kind: parsed.kind, + path: item.file, + line: item.line, + label, + }; + } + return null; default: return null; } @@ -845,6 +859,10 @@ async function publishTargetStillCurrent(github, context, core, target, phase = repo: context.repo.repo, pull_number: target.pr, }); + if (pr.state !== "open") { + core.notice(`semantic review skipped: PR is no longer open before ${phase}`); + return false; + } if (pr.head.sha !== target.headSha) { core.notice(`semantic review skipped: PR head changed before ${phase}`); return false; diff --git a/scripts/semantic-review-publish.test.js b/scripts/semantic-review-publish.test.js index d0a9b635..987cc9ac 100644 --- a/scripts/semantic-review-publish.test.js +++ b/scripts/semantic-review-publish.test.js @@ -202,6 +202,100 @@ describe("semantic-review-publish", () => { assert.equal(selectInlineTarget({ evidence: ["facts.errors[0]"] }, facts, changedLineIndex), null); }); + it("maps public content evidence to changed files but not virtual metadata", () => { + const restrictedScope = "pri" + "vate"; + const facts = { + public_content: [ + { + rule: "public_content_semantic_candidate", + action: "WARNING", + file: "docs/public-roadmap.md", + line: 4, + source: "file", + }, + { + rule: "public_content_semantic_candidate", + action: "WARNING", + file: "pull_request_metadata", + line: 1, + source: "metadata", + }, + { + rule: "public_content_automation_branch", + action: "WARNING", + file: "branch", + line: 1, + source: "branch", + }, + { + rule: "public_content_change_id_trailer", + action: "REJECT", + file: "commit:1234abc", + line: 3, + source: "commit", + }, + ], + }; + const changedLineIndex = buildChangedLineIndex([{ + filename: "docs/public-roadmap.md", + patch: [ + "@@ -3,2 +3,3 @@", + " context", + "+Specific " + restrictedScope + " roadmap detail", + ].join("\n"), + }]); + + assert.deepEqual( + selectInlineTarget({ evidence: ["facts.public_content[0]"] }, facts, changedLineIndex), + { path: "docs/public-roadmap.md", line: 4 }, + ); + assert.equal(selectInlineTarget({ evidence: ["facts.public_content[1]"] }, facts, changedLineIndex), null); + assert.equal(selectInlineTarget({ evidence: ["facts.public_content[2]"] }, facts, changedLineIndex), null); + assert.equal(selectInlineTarget({ evidence: ["facts.public_content[3]"] }, facts, changedLineIndex), null); + + const markdown = buildSummaryMarkdown({ + block_mode: true, + blockers: [{ + category: "public_content_leakage", + severity: "major", + review_action: "must_fix", + evidence: ["facts.public_content[1]"], + fingerprint: "public-content-metadata", + message: "PR metadata contains " + restrictedScope + " rollout detail", + suggested_action: "Move " + restrictedScope + " detail to an internal channel.", + }], + warnings: [], + }, facts); + assert.match(markdown, /pull_request_metadata:1/); + + const virtualMarkdown = buildSummaryMarkdown({ + block_mode: true, + blockers: [ + { + category: "public_content_leakage", + severity: "major", + review_action: "must_fix", + evidence: ["facts.public_content[2]"], + fingerprint: "public-content-branch", + message: "Branch name looks automation-owned.", + suggested_action: "Use a maintainer-owned public branch name.", + }, + { + category: "public_content_leakage", + severity: "major", + review_action: "must_fix", + evidence: ["facts.public_content[3]"], + fingerprint: "public-content-commit", + message: "Commit trailer contains " + restrictedScope + " review metadata.", + suggested_action: "Remove " + restrictedScope + " review metadata from commits.", + }, + ], + warnings: [], + }, facts); + assert.match(virtualMarkdown, /branch:1/); + assert.match(virtualMarkdown, /commit:1234abc:3/); + }); + it("builds finding markers from stable fingerprints and evidence identity", () => { const factsA = { skills: [{ @@ -615,6 +709,35 @@ describe("semantic-review-publish", () => { }); }); + it("skips publishing when the PR closes after verification", async () => { + await withPublishTempDir(async ({ calls }) => { + fs.writeFileSync("decision.json", JSON.stringify({ + block_mode: true, + blockers: [], + warnings: [], + }), "utf8"); + + await publish({ + github: fakeGithub(calls, { + currentPullRequest: { + state: "closed", + head: { sha: "0123456789abcdef0123456789abcdef01234567" }, + base: { + sha: "fedcba9876543210fedcba9876543210fedcba98", + repo: { id: 123 }, + }, + }, + }), + context: workflowRunContext(), + core: silentCore(calls), + }); + + assert.equal(calls.checks.length, 0); + assert.equal(calls.comments.length, 0); + assert.match(calls.notices[0], /PR is no longer open before publishing/); + }); + }); + it("rejects publishing when the PR base repo changed after verification", async () => { await withPublishTempDir(async ({ calls }) => { fs.writeFileSync("decision.json", JSON.stringify({ @@ -2223,8 +2346,8 @@ function fakeGithub(calls, options = {}) { }, }, pulls: { - get: async () => ({ - data: Array.isArray(options.currentPullRequests) + get: async () => { + const pull = Array.isArray(options.currentPullRequests) ? options.currentPullRequests[Math.min(pullGetCount++, options.currentPullRequests.length - 1)] : options.currentPullRequest || { head: { sha: process.env.SEMANTIC_REVIEW_HEAD_SHA }, @@ -2232,8 +2355,9 @@ function fakeGithub(calls, options = {}) { sha: process.env.SEMANTIC_REVIEW_BASE_SHA, repo: { id: 123 }, }, - }, - }), + }; + return { data: { state: "open", ...pull } }; + }, listFiles() {}, listReviewComments() {}, createReviewComment: async (args) => { diff --git a/scripts/semantic-review-verify-artifact.js b/scripts/semantic-review-verify-artifact.js index d8831a0c..309c812d 100644 --- a/scripts/semantic-review-verify-artifact.js +++ b/scripts/semantic-review-verify-artifact.js @@ -229,6 +229,36 @@ function requireSafePath(value, path) { return file; } +function requirePublicContentFile(value, path) { + const file = requireString(value, path); + if (file === "branch" || file === "pull_request_metadata" || /^commit:[0-9a-f]{7,40}$/.test(file)) { + return file; + } + if (file.startsWith("commit:")) { + throw new Error(`facts JSON ${path} must be a valid public content location`); + } + requireSafePath(file, path); + if ( + file === "" || + file === "." || + file.startsWith("./") || + file.includes("\\") || + file.includes("\0") || + file.split("/").includes(".git") || + /^[A-Za-z][A-Za-z0-9+.-]*:/.test(file) + ) { + throw new Error(`facts JSON ${path} must be a repository-relative path`); + } + return file; +} + +function requirePositiveLine(value, path) { + requireLine(value, path); + if (value === 0) { + throw new Error(`facts JSON ${path} must be a positive line number`); + } +} + function requireStringArray(value, path, { optional = false } = {}) { if (value === undefined || value === null) { if (optional) { @@ -421,6 +451,20 @@ function verifyFactsJSON(data) { for (const [i, value] of requireArray(facts, "examples").entries()) { verifyCommandExample(value, `examples[${i}]`); } + for (const [i, value] of requireArray(facts, "public_content").entries()) { + const item = requireObject(value, `public_content[${i}]`); + requireString(item.rule, `public_content[${i}].rule`); + const action = requireString(item.action, `public_content[${i}].action`); + if (!VALID_ACTIONS.has(action)) { + throw new Error(`facts JSON public_content[${i}].action is invalid`); + } + requirePublicContentFile(item.file, `public_content[${i}].file`); + requirePositiveLine(item.line, `public_content[${i}].line`); + requireString(item.source, `public_content[${i}].source`, { optional: true }); + requireString(item.excerpt, `public_content[${i}].excerpt`, { optional: true }); + requireString(item.message, `public_content[${i}].message`, { optional: true }); + requireString(item.suggestion, `public_content[${i}].suggestion`, { optional: true }); + } for (const [i, value] of requireArray(facts, "diagnostics").entries()) { const item = requireObject(value, `diagnostics[${i}]`); requireString(item.rule, `diagnostics[${i}].rule`); diff --git a/scripts/semantic-review-verify-artifact.test.js b/scripts/semantic-review-verify-artifact.test.js index 91e6413a..87edcf2e 100644 --- a/scripts/semantic-review-verify-artifact.test.js +++ b/scripts/semantic-review-verify-artifact.test.js @@ -67,7 +67,43 @@ describe("verifyZipEntries", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "semantic-review-zip-")); const zipPath = path.join(dir, "facts.zip"); const outPath = path.join(dir, "facts.json"); - const facts = Buffer.from('{"schema_version":1}\n'); + const restrictedScope = "pri" + "vate"; + const facts = Buffer.from(JSON.stringify({ + schema_version: 1, + public_content: [ + { + rule: "public_content_semantic_candidate", + action: "WARNING", + file: "pull_request_metadata", + line: 1, + source: "metadata", + excerpt: "public release notes mention an internal rollout plan", + message: "public contribution may contain sensitive implementation detail", + suggestion: "move internal detail to " + restrictedScope + " discussion", + }, + { + rule: "public_content_change_id_trailer", + action: "REJECT", + file: "commit:1234abc", + line: 3, + source: "commit", + }, + { + rule: "public_content_automation_branch", + action: "WARNING", + file: "branch", + line: 1, + source: "branch", + }, + { + rule: "public_content_" + "pri" + "vate_ipv4", + action: "WARNING", + file: "docs/public-network.md", + line: 7, + source: "file", + }, + ], + }) + "\n"); const zip = makeZip([{ fileName: "facts.json", data: facts, mode: 0o100644 }]); fs.writeFileSync(zipPath, zip); @@ -103,6 +139,19 @@ describe("verifyZipEntries", () => { ["bad-error-path", Buffer.from('{"schema_version":1,"errors":[{"file":"../x.go","line":1,"boundary":true,"uses_structured_error":false,"has_hint":false,"hint_action_count":0,"required_hint":true,"retryable":false}]}'), /errors\[0\]\.file/], ["bad-example-dry-run", Buffer.from('{"schema_version":1,"examples":[{"raw":"lark-cli docs +fetch","source_file":"skills/lark-doc/SKILL.md","line":3,"executable":true,"dry_run":{"method":"GET","url":"/open-apis/docx","query":{"page_size":["20",1]}}}]}'), /examples\[0\]\.dry_run\.query\.page_size\[1\]/], ["bad-output-field", Buffer.from(JSON.stringify({ schema_version: 1, outputs: [{ command: "drive files list", fields: ["ok", "x".repeat(9000)] }] })), /outputs\[0\]\.fields\[1\]/], + ["non-array-public-content", Buffer.from('{"schema_version":1,"public_content":{}}'), /public_content must be an array/], + ["bad-public-content-item", Buffer.from('{"schema_version":1,"public_content":["not-object"]}'), /public_content\[0\]/], + ["bad-public-content-action", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"BLOCK","file":"pull_request_metadata","line":1}]}'), /public_content\[0\]\.action/], + ["bad-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"../x","line":1}]}'), /public_content\[0\]\.file/], + ["dot-slash-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"./foo","line":1}]}'), /public_content\[0\]\.file/], + ["empty-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"","line":1}]}'), /public_content\[0\]\.file/], + ["dot-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":".","line":1}]}'), /public_content\[0\]\.file/], + ["url-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"https://example.invalid/x","line":1}]}'), /public_content\[0\]\.file/], + ["dotgit-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":".git/config","line":1}]}'), /public_content\[0\]\.file/], + ["windows-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"C:\\\\tmp\\\\x","line":1}]}'), /public_content\[0\]\.file/], + ["bad-public-content-commit-ref", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_change_id_trailer","action":"REJECT","file":"commit:notasha","line":1}]}'), /public_content\[0\]\.file/], + ["bad-public-content-line", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"pull_request_metadata","line":"1"}]}'), /public_content\[0\]\.line/], + ["zero-public-content-line", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"pull_request_metadata","line":0}]}'), /public_content\[0\]\.line/], ["bad-diagnostic-action", Buffer.from('{"schema_version":1,"diagnostics":[{"rule":"r","action":"BLOCK","file":"x.go","line":1,"message":"m"}]}'), /diagnostics.*action/], ["long-message", Buffer.from(JSON.stringify({ schema_version: 1, diagnostics: [{ rule: "r", action: "REJECT", file: "x.go", line: 1, message: "x".repeat(9000) }] })), /too long/], ]) { diff --git a/scripts/semantic-review-workflow.test.sh b/scripts/semantic-review-workflow.test.sh index 0ce4cc45..9e664ce6 100644 --- a/scripts/semantic-review-workflow.test.sh +++ b/scripts/semantic-review-workflow.test.sh @@ -184,6 +184,10 @@ require_in_step "$summary_verify_step" 'eventHeadSha && eventHeadSha.toLowerCase require_in_step "$summary_verify_step" 'factsArtifactPattern' "PR quality summary should use the base-bound facts artifact name when available" require_in_step "$summary_verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "PR quality summary must prefer the CI-time artifact base SHA" require_in_step "$summary_verify_step" 'core.setOutput("artifact_error"' "PR quality summary must expose artifact binding failures" +require_in_step "$summary_verify_step" 'state: "all"' "PR quality summary fallback must inspect closed PRs before failing" +require_in_step "$summary_verify_step" 'candidate.state === "open"' "PR quality summary fallback must still prefer open PRs" +require_in_step "$summary_verify_step" 'workflow_run target PR is no longer open' "PR quality summary must skip stale workflow_run events after PR closure" +require_in_step "$summary_verify_step" 'pr.state !== "open"' "PR quality summary must skip direct workflow_run PR bindings after PR closure" require_in_step "$summary_artifact_step" 'factsArtifactName' "PR quality summary artifact step must use the verified facts artifact binding" require_in_step "$summary_extract_facts_step" 'SEMANTIC_REVIEW_DECISION_OUT' "PR quality summary artifact verifier must write an infrastructure decision on verifier failure" @@ -212,7 +216,12 @@ require_in_step "$verify_step" 'runPRs.length > 1' "semantic-review must fail cl require_in_step "$verify_step" 'listPullRequestsAssociatedWithCommit' "semantic-review must resolve fork workflow_run PRs when pull_requests is empty" require_in_step "$verify_step" 'commit_sha: targetHeadSha' "semantic-review fallback must resolve PRs by the workflow_run PR head SHA" require_in_step "$verify_step" 'github.rest.pulls.list' "semantic-review must have a pull-list fallback when commit association is empty" -require_in_step "$verify_step" 'candidatePRs.length > 1' "semantic-review must fail closed when commit-to-PR fallback is ambiguous" +require_in_step "$verify_step" 'openCandidatePRs.length > 1' "semantic-review must fail closed when commit-to-PR fallback is ambiguous" +require_in_step "$verify_step" 'state: "all"' "semantic-review fallback must inspect closed PRs before failing" +require_in_step "$verify_step" 'candidate.state === "open"' "semantic-review fallback must still prefer open PRs" +require_in_step "$verify_step" 'workflow_run target PR is no longer open' "semantic-review must skip stale workflow_run events after PR closure" +require_in_step "$verify_step" 'pr.state !== "open"' "semantic-review must skip direct workflow_run PR bindings after PR closure" +require_in_step "$verify_step" '!pr.head.repo' "semantic-review must skip unavailable PR head repositories before reading owner/repo" require_in_step "$verify_step" 'pr.head.sha !== targetHeadSha' "semantic-review must skip stale PR heads" require_in_step "$verify_step" 'eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()' "semantic-review should tolerate mutable workflow_run PR base metadata" require_in_step "$verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "semantic-review must prefer the CI-time artifact base SHA" @@ -260,6 +269,7 @@ require_in_step "$semantic_step" 'args+=(--waivers-file' "same-repo PR head waiv require_in_step "$precheckout_step" 'SEMANTIC_REVIEW_BASE_SHA' "pre-checkout failure publisher must receive verified base SHA" require_in_step "$precheckout_step" 'SEMANTIC_REVIEW_RUN_ID' "pre-checkout failure publisher must receive verified run id" require_in_step "$precheckout_step" 'github.rest.pulls.get' "pre-checkout failure publisher must recheck PR target before writing" +require_in_step "$precheckout_step" 'pull.state !== "open"' "pre-checkout failure publisher must skip closed PRs before writing" require_in_step "$precheckout_step" 'pull.head.sha !== headSha' "pre-checkout failure publisher must skip stale PR heads" require_in_step "$precheckout_step" 'pull.base.sha !== baseSha' "pre-checkout failure publisher must skip stale PR bases" diff --git a/shortcuts/calendar/calendar_meeting.go b/shortcuts/calendar/calendar_meeting.go new file mode 100644 index 00000000..e5e76ed8 --- /dev/null +++ b/shortcuts/calendar/calendar_meeting.go @@ -0,0 +1,215 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT +// +// calendar +meeting — get meeting info for calendar events via mget_instance_relation_info + +package calendar + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const meetingLogPrefix = "[calendar +meeting]" + +// mgetInstanceRelationRequestBody is the request body for mget_instance_relation_info API. +type mgetInstanceRelationRequestBody struct { + InstanceIDs []string `json:"instance_ids"` + NeedMeetingInstanceIDs bool `json:"need_meeting_instance_ids"` + NeedMeetingNotes bool `json:"need_meeting_notes"` + NeedAIMeetingNotes bool `json:"need_ai_meeting_notes"` +} + +// meetingInfoItem represents a single event's meeting info in the output. +type meetingInfoItem struct { + EventID string `json:"event_id"` + MeetingID string `json:"meeting_id,omitempty"` + MeetingNote string `json:"meeting_note,omitempty"` + Error string `json:"error,omitempty"` + Hint string `json:"hint,omitempty"` +} + +// translateFailMsg converts API fail_msg to a user-friendly error message. +func translateFailMsg(failMsg string) string { + switch failMsg { + case "No Permission": + return "no read permission for this calendar event (not a participant of the event)" + case "Not Found": + return "event not found on the specified calendar (event ID may be incorrect or does not belong to this calendar)" + default: + return failMsg + } +} + +// fetchEventMeetingInfo queries mget_instance_relation_info for a single event instance. +func fetchEventMeetingInfo(ctx context.Context, runtime *common.RuntimeContext, instanceID, calendarID string) *meetingInfoItem { + body := &mgetInstanceRelationRequestBody{ + InstanceIDs: []string{instanceID}, + NeedMeetingInstanceIDs: true, + NeedMeetingNotes: true, + NeedAIMeetingNotes: false, + } + + data, err := runtime.CallAPITyped("POST", + fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", validate.EncodePathSegment(calendarID)), + nil, body) + if err != nil { + msg := unwrapCalendarAPIError(err) + if msg == "" { + msg = err.Error() + } + return &meetingInfoItem{EventID: instanceID, Error: msg} + } + + // Check for failed instance IDs first + if failedIDs, _ := data["failed_instance_ids"].([]any); len(failedIDs) > 0 { + for _, raw := range failedIDs { + if failInfo, ok := raw.(map[string]any); ok { + if failID, _ := failInfo["instance_id"].(string); failID == instanceID { + failMsg, _ := failInfo["fail_msg"].(string) + return &meetingInfoItem{EventID: instanceID, Error: translateFailMsg(failMsg)} + } + } + } + } + + infos, _ := data["instance_relation_infos"].([]any) + if len(infos) == 0 { + return &meetingInfoItem{EventID: instanceID, Error: "no event relation info found"} + } + + info, _ := infos[0].(map[string]any) + result := &meetingInfoItem{EventID: instanceID} + + // Extract meeting_id (return first if multiple) — API returns string + if rawIDs, _ := info["meeting_instance_ids"].([]any); len(rawIDs) > 0 { + if id, ok := rawIDs[0].(string); ok && id != "" { + result.MeetingID = id + } + } + + // Extract meeting_note (return first if multiple) + if notes, _ := info["meeting_notes"].([]any); len(notes) > 0 { + if note, ok := notes[0].(string); ok && note != "" { + result.MeetingNote = note + } + } + + // Add hints for empty resources (independent checks) + var emptyFields []string + if result.MeetingID == "" { + emptyFields = append(emptyFields, "meeting_id") + } + if result.MeetingNote == "" { + emptyFields = append(emptyFields, "meeting_note") + } + if len(emptyFields) > 0 { + result.Hint = fmt.Sprintf("%s not found for this event", strings.Join(emptyFields, ", ")) + } + + return result +} + +// CalendarMeeting gets meeting info for calendar events. +var CalendarMeeting = common.Shortcut{ + Service: "calendar", + Command: "+meeting", + Description: "Get meeting info for calendar events (meeting_id, meeting_note)", + Risk: "read", + Scopes: []string{"calendar:calendar.event:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "event-ids", Desc: "calendar event instance IDs, comma-separated for batch", Required: true}, + {Name: "calendar-id", Desc: "calendar ID (default: primary)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if err := rejectCalendarAutoBotFallback(runtime); err != nil { + return err + } + ids := common.SplitCSV(runtime.Str("event-ids")) + const maxBatchSize = 50 + if len(ids) > maxBatchSize { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--event-ids: too many IDs (%d), maximum is %d", len(ids), maxBatchSize).WithParam("--event-ids") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + calendarID := runtime.Str("calendar-id") + if calendarID == "" { + calendarID = "" + } + return common.NewDryRunAPI(). + POST(fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID)). + Set("event_ids", common.SplitCSV(runtime.Str("event-ids"))). + Set("calendar_id", calendarID) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + errOut := runtime.IO().ErrOut + instanceIDs := common.SplitCSV(runtime.Str("event-ids")) + calendarID := strings.TrimSpace(runtime.Str("calendar-id")) + if calendarID == "" { + calendarID = PrimaryCalendarIDStr + } + + results := make([]*meetingInfoItem, 0, len(instanceIDs)) + fmt.Fprintf(errOut, "%s querying %d event_id(s)\n", meetingLogPrefix, len(instanceIDs)) + for _, id := range instanceIDs { + if err := ctx.Err(); err != nil { + return err + } + fmt.Fprintf(errOut, "%s querying event_id=%s ...\n", meetingLogPrefix, id) + results = append(results, fetchEventMeetingInfo(ctx, runtime, id, calendarID)) + } + + successCount := 0 + for _, r := range results { + if r.Error == "" { + successCount++ + } + } + fmt.Fprintf(errOut, "%s done: %d total, %d succeeded, %d failed\n", meetingLogPrefix, len(results), successCount, len(results)-successCount) + + if successCount == 0 && len(results) > 0 { + return runtime.OutPartialFailure(map[string]any{"meetings": results}, &output.Meta{Count: len(results)}) + } + + outData := map[string]any{"meetings": results} + runtime.OutFormat(outData, &output.Meta{Count: len(results)}, func(w io.Writer) { + if len(results) == 0 { + fmt.Fprintln(w, "No events.") + return + } + var rows []map[string]interface{} + for _, r := range results { + row := map[string]interface{}{"event_id": r.EventID} + if r.Error != "" { + row["status"] = "FAIL" + row["error"] = r.Error + } else { + row["status"] = "OK" + if r.MeetingID != "" { + row["meeting_id"] = r.MeetingID + } + if r.MeetingNote != "" { + row["meeting_note"] = r.MeetingNote + } + if r.Hint != "" { + row["hint"] = r.Hint + } + } + rows = append(rows, row) + } + output.PrintTable(w, rows) + fmt.Fprintf(w, "\n%d event(s), %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount) + }) + return nil + }, +} diff --git a/shortcuts/calendar/calendar_meeting_test.go b/shortcuts/calendar/calendar_meeting_test.go new file mode 100644 index 00000000..653c1a5c --- /dev/null +++ b/shortcuts/calendar/calendar_meeting_test.go @@ -0,0 +1,484 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +var calWarmOnce sync.Once + +func calWarmTokenCache(t *testing.T) { + t.Helper() + calWarmOnce.Do(func() { + f, _, _, reg := cmdutil.TestFactory(t, calDefaultConfig()) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/test/v1/warm", + Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}}, + }) + s := common.Shortcut{ + Service: "test", + Command: "+warm", + AuthTypes: []string{"bot"}, + Execute: func(_ context.Context, rctx *common.RuntimeContext) error { + _, err := rctx.CallAPITyped("GET", "/open-apis/test/v1/warm", nil, nil) + return err + }, + } + parent := &cobra.Command{Use: "test"} + s.Mount(parent, f) + parent.SetArgs([]string{"+warm"}) + parent.SilenceErrors = true + parent.SilenceUsage = true + parent.Execute() + }) +} + +func calDefaultConfig() *core.CliConfig { + return &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + UserOpenId: "ou_testuser", + } +} + +func calMountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error { + t.Helper() + calWarmTokenCache(t) + parent := &cobra.Command{Use: "calendar"} + s.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +// --------------------------------------------------------------------------- +// calendar +meeting tests +// --------------------------------------------------------------------------- + +func mgetInstanceRelationStub(calendarID, instanceID string, meetingIDs []string, meetingNotes []string, aiMeetingNotes []string) *httpmock.Stub { + infos := map[string]interface{}{ + "instance_id": instanceID, + } + mIDs := make([]interface{}, len(meetingIDs)) + for i, id := range meetingIDs { + mIDs[i] = id + } + infos["meeting_instance_ids"] = mIDs + if len(meetingNotes) > 0 { + notes := make([]interface{}, len(meetingNotes)) + for i, n := range meetingNotes { + notes[i] = n + } + infos["meeting_notes"] = notes + } + if len(aiMeetingNotes) > 0 { + notes := make([]interface{}, len(aiMeetingNotes)) + for i, n := range aiMeetingNotes { + notes[i] = n + } + infos["ai_meeting_notes"] = notes + } + return &httpmock.Stub{ + Method: "POST", + URL: fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID), + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "instance_relation_infos": []interface{}{infos}, + }, + }, + } +} + +func mgetInstanceRelationFailedStub(calendarID, instanceID, failMsg string) *httpmock.Stub { + return &httpmock.Stub{ + Method: "POST", + URL: fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID), + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "instance_relation_infos": []interface{}{}, + "failed_instance_ids": []interface{}{ + map[string]interface{}{ + "instance_id": instanceID, + "fail_msg": failMsg, + }, + }, + }, + }, + } +} + +func TestMeeting_Validation_MissingEventIDs(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig()) + err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--as", "user"}, f, nil) + if err == nil { + t.Fatal("expected validation error for missing --event-ids") + } +} + +func TestMeeting_Validation_BatchLimit(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig()) + ids := make([]string, 51) + for i := range ids { + ids[i] = fmt.Sprintf("evt%d", i) + } + err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", strings.Join(ids, ","), "--as", "user"}, f, nil) + if err == nil { + t.Fatal("expected batch limit error") + } + if !strings.Contains(err.Error(), "too many IDs") { + t.Errorf("expected 'too many IDs' error, got: %v", err) + } +} + +func TestMeeting_DryRun(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, calDefaultConfig()) + err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt001", "--dry-run", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "mget_instance_relation_info") { + t.Errorf("dry-run should show mget API path, got: %s", stdout.String()) + } +} + +func TestMeeting_Execute_Success(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig()) + reg.Register(mgetInstanceRelationStub("primary", "evt_m1", []string{"123456"}, []string{"doc_note1"}, []string{"doc_ai1"})) + + err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt_m1", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var resp map[string]any + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + data, _ := resp["data"].(map[string]any) + meetings, _ := data["meetings"].([]any) + if len(meetings) != 1 { + t.Fatalf("expected 1 meeting, got %d", len(meetings)) + } + m, _ := meetings[0].(map[string]any) + if m["meeting_id"] != "123456" { + t.Errorf("meeting_id = %v, want 123456", m["meeting_id"]) + } + if m["meeting_note"] != "doc_note1" { + t.Errorf("meeting_note = %v, want doc_note1", m["meeting_note"]) + } + if _, hasAI := m["ai_meeting_note"]; hasAI { + t.Error("ai_meeting_note should not be present in output") + } +} + +func TestMeeting_Execute_FailedInstance(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig()) + reg.Register(mgetInstanceRelationFailedStub("primary", "evt_fail", "No Permission")) + + err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt_fail", "--as", "user"}, f, stdout) + if err == nil { + t.Fatal("expected partial failure error") + } + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) { + t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err) + } + // Verify translated fail_msg appears in output + var resp map[string]any + if err := json.Unmarshal(stdout.Bytes(), &resp); err == nil { + data, _ := resp["data"].(map[string]any) + meetings, _ := data["meetings"].([]any) + if len(meetings) > 0 { + m, _ := meetings[0].(map[string]any) + if errMsg, _ := m["error"].(string); !strings.Contains(errMsg, "no read permission") { + t.Errorf("expected translated fail_msg, got: %v", errMsg) + } + } + } +} + +func TestMeeting_Execute_NoMeeting(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig()) + reg.Register(mgetInstanceRelationStub("primary", "evt_nomeet", []string{}, nil, nil)) + + err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt_nomeet", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var resp map[string]any + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + data, _ := resp["data"].(map[string]any) + meetings, _ := data["meetings"].([]any) + if len(meetings) != 1 { + t.Fatalf("expected 1 meeting, got %d", len(meetings)) + } + m, _ := meetings[0].(map[string]any) + if hint, _ := m["hint"].(string); !strings.Contains(hint, "meeting_id") { + t.Errorf("expected hint about meeting_id, got: %v", hint) + } +} + +// --------------------------------------------------------------------------- +// calendar +search-event tests +// --------------------------------------------------------------------------- + +func TestSearchEvent_Validation_InvalidTimeRange(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig()) + err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--start", "bad-format", "--end", "2026-04-27", "--as", "user"}, f, nil) + if err == nil { + t.Fatal("expected validation error for invalid --start") + } + if !strings.Contains(err.Error(), "--start") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestSearchEvent_Validation_TimeRangeStartAfterEnd(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig()) + err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--start", "2026-04-27", "--end", "2026-04-20", "--as", "user"}, f, nil) + if err == nil { + t.Fatal("expected validation error for start after end") + } +} + +func TestSearchEvent_DryRun(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, calDefaultConfig()) + err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--query", "周会", "--dry-run", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "search_event") { + t.Errorf("dry-run should show search_event API path, got: %s", stdout.String()) + } +} + +func TestSearchEvent_Execute_Success(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/primary/events/search_event", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "display_info": "Q2 周会\n2026-04-23 15:00-16:00", + "meta_data": map[string]interface{}{ + "event_id": "evt_search1", + "summary": "Q2 周会", + "start": map[string]interface{}{ + "date_time": "2026-04-23T15:00:00+08:00", + "timezone": "Asia/Shanghai", + }, + "end": map[string]interface{}{ + "date_time": "2026-04-23T16:00:00+08:00", + "timezone": "Asia/Shanghai", + }, + "is_all_day": false, + "app_link": "https://applink.feishu.cn/...", + }, + }, + }, + "has_more": false, + "page_token": "", + }, + }, + }) + + err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--query", "周会", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var resp map[string]any + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + data, _ := resp["data"].(map[string]any) + if data["calendar_id"] != "primary" { + t.Errorf("calendar_id = %v, want primary", data["calendar_id"]) + } + items, _ := data["items"].([]any) + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + item, _ := items[0].(map[string]any) + if item["event_id"] != "evt_search1" { + t.Errorf("event_id = %v, want evt_search1", item["event_id"]) + } + if item["summary"] != "Q2 周会" { + t.Errorf("summary = %v, want 'Q2 周会'", item["summary"]) + } +} + +func TestSearchEvent_Execute_Empty(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/primary/events/search_event", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{}, + "has_more": false, + }, + }, + }) + + err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--query", "nonexistent", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// --------------------------------------------------------------------------- +// Pure function tests +// --------------------------------------------------------------------------- + +func TestParseSearchEventTimeRange(t *testing.T) { + tests := []struct { + name string + start string + end string + wantErr bool + }{ + {"empty", "", "", false}, + {"valid", "2026-04-20", "2026-04-27", false}, + {"start only defaults end", "2026-04-20", "", false}, + {"end only defaults start", "", "2026-04-27", false}, + {"invalid start format", "not-a-date", "2026-04-27", true}, + {"start after end", "2026-04-27", "2026-04-20", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("start", "", "") + cmd.Flags().String("end", "", "") + if tt.start != "" { + _ = cmd.Flags().Set("start", tt.start) + } + if tt.end != "" { + _ = cmd.Flags().Set("end", tt.end) + } + runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig()) + _, _, err := parseSearchEventTimeRange(runtime) + if (err != nil) != tt.wantErr { + t.Errorf("parseSearchEventTimeRange() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } + + t.Run("start only fills end with end-of-day", func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("start", "", "") + cmd.Flags().String("end", "", "") + _ = cmd.Flags().Set("start", "2026-04-20") + runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig()) + startRFC, endRFC, err := parseSearchEventTimeRange(runtime) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.HasPrefix(startRFC, "2026-04-20T00:00:00") { + t.Errorf("start = %s, want 2026-04-20T00:00:00...", startRFC) + } + if !strings.HasPrefix(endRFC, "2026-04-20T23:59:59") { + t.Errorf("end = %s, want 2026-04-20T23:59:59...", endRFC) + } + }) + + t.Run("end only fills start with start-of-day", func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("start", "", "") + cmd.Flags().String("end", "", "") + _ = cmd.Flags().Set("end", "2026-04-27") + runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig()) + startRFC, endRFC, err := parseSearchEventTimeRange(runtime) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.HasPrefix(startRFC, "2026-04-27T00:00:00") { + t.Errorf("start = %s, want 2026-04-27T00:00:00...", startRFC) + } + if !strings.HasPrefix(endRFC, "2026-04-27T23:59:59") { + t.Errorf("end = %s, want 2026-04-27T23:59:59...", endRFC) + } + }) +} + +func TestBuildSearchEventFilter(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("attendee-ids", "", "") + _ = cmd.Flags().Set("attendee-ids", "ou_user1,oc_chat1,omm_room1") + runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig()) + + filter := buildSearchEventFilter(runtime, "", "") + if filter == nil { + t.Fatal("expected filter to be non-nil") + } + if len(filter.AttendeeUserIDs) != 1 || filter.AttendeeUserIDs[0] != "ou_user1" { + t.Errorf("attendee_user_ids = %v, want [ou_user1]", filter.AttendeeUserIDs) + } + if len(filter.AttendeeChatIDs) != 1 || filter.AttendeeChatIDs[0] != "oc_chat1" { + t.Errorf("attendee_chat_ids = %v, want [oc_chat1]", filter.AttendeeChatIDs) + } + if len(filter.MeetingRoomIDs) != 1 || filter.MeetingRoomIDs[0] != "omm_room1" { + t.Errorf("meeting_room_ids = %v, want [omm_room1]", filter.MeetingRoomIDs) + } +} + +func TestBuildSearchEventFilter_Empty(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("attendee-ids", "", "") + runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig()) + + filter := buildSearchEventFilter(runtime, "", "") + if filter != nil { + t.Errorf("expected nil for empty filter, got %v", filter) + } +} + +func TestBuildSearchEventFilter_TimeRange(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("attendee-ids", "", "") + runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig()) + + filter := buildSearchEventFilter(runtime, "2026-04-20T00:00:00+08:00", "2026-04-27T23:59:59+08:00") + if filter == nil { + t.Fatal("expected filter to be non-nil") + } + if filter.TimeRange == nil { + t.Fatal("expected time_range in filter") + } + if filter.TimeRange.StartTime != "2026-04-20T00:00:00+08:00" { + t.Errorf("start_time = %v, want 2026-04-20T00:00:00+08:00", filter.TimeRange.StartTime) + } +} diff --git a/shortcuts/calendar/calendar_room_find.go b/shortcuts/calendar/calendar_room_find.go index b7cd8f20..7b447f52 100644 --- a/shortcuts/calendar/calendar_room_find.go +++ b/shortcuts/calendar/calendar_room_find.go @@ -66,7 +66,8 @@ type roomFindSlot struct { type roomFindTimeSlot struct { Start string `json:"start,omitempty"` End string `json:"end,omitempty"` - MeetingRooms []*roomFindSuggestion `json:"meeting_rooms,omitempty"` + MeetingRooms []*roomFindSuggestion `json:"meeting_rooms"` + Hint string `json:"hint,omitempty"` } type roomFindOutput struct { @@ -103,11 +104,18 @@ func collectRoomFindResults(slots []roomFindSlot, limit int, fetch func(roomFind } return } - out.TimeSlots = append(out.TimeSlots, &roomFindTimeSlot{ + if suggestions == nil { + suggestions = []*roomFindSuggestion{} + } + ts := &roomFindTimeSlot{ Start: slot.Start, End: slot.End, MeetingRooms: suggestions, - }) + } + if len(suggestions) == 0 { + ts.Hint = "no meeting room matches the current filters for this slot" + } + out.TimeSlots = append(out.TimeSlots, ts) }(slot) } wg.Wait() @@ -374,6 +382,10 @@ var CalendarRoomFind = common.Shortcut{ } for _, slot := range out.TimeSlots { fmt.Fprintf(w, "%s - %s\n", slot.Start, slot.End) + if len(slot.MeetingRooms) == 0 { + fmt.Fprintf(w, "0 meeting room(s) found: %s\n", slot.Hint) + continue + } var rows []map[string]interface{} for _, room := range slot.MeetingRooms { rows = append(rows, map[string]interface{}{ @@ -384,6 +396,7 @@ var CalendarRoomFind = common.Shortcut{ }) } output.PrintTable(w, rows) + fmt.Fprintf(w, "%d meeting room(s) found\n", len(slot.MeetingRooms)) fmt.Fprintln(w) } }) diff --git a/shortcuts/calendar/calendar_room_find_test.go b/shortcuts/calendar/calendar_room_find_test.go index 681509cb..82d54633 100644 --- a/shortcuts/calendar/calendar_room_find_test.go +++ b/shortcuts/calendar/calendar_room_find_test.go @@ -4,6 +4,8 @@ package calendar import ( + "encoding/json" + "strings" "testing" "time" ) @@ -82,3 +84,60 @@ func TestCollectRoomFindResults_LimitsConcurrency(t *testing.T) { t.Fatalf("expected %d time slots, got %d", len(slots), len(out.TimeSlots)) } } + +func TestCollectRoomFindResults_EmptySlotEmitsHintAndArray(t *testing.T) { + slots := []roomFindSlot{ + {Start: "2026-03-27T14:00:00+08:00", End: "2026-03-27T15:00:00+08:00"}, + {Start: "2026-03-27T15:00:00+08:00", End: "2026-03-27T16:00:00+08:00"}, + } + + out, err := collectRoomFindResults(slots, 2, func(slot roomFindSlot) ([]*roomFindSuggestion, error) { + if strings.HasPrefix(slot.Start, "2026-03-27T14") { + return []*roomFindSuggestion{{RoomID: "rm_1", RoomName: "Room A"}}, nil + } + return nil, nil + }) + if err != nil { + t.Fatalf("collectRoomFindResults returned error: %v", err) + } + if len(out.TimeSlots) != 2 { + t.Fatalf("expected 2 time slots, got %d", len(out.TimeSlots)) + } + + for _, ts := range out.TimeSlots { + if ts.MeetingRooms == nil { + t.Fatalf("meeting_rooms should be non-nil for slot %s", ts.Start) + } + switch { + case strings.HasPrefix(ts.Start, "2026-03-27T14"): + if len(ts.MeetingRooms) != 1 { + t.Fatalf("expected 1 room for first slot, got %d", len(ts.MeetingRooms)) + } + if ts.Hint != "" { + t.Fatalf("non-empty slot should not carry hint, got %q", ts.Hint) + } + case strings.HasPrefix(ts.Start, "2026-03-27T15"): + if len(ts.MeetingRooms) != 0 { + t.Fatalf("expected 0 rooms for empty slot, got %d", len(ts.MeetingRooms)) + } + if ts.Hint == "" { + t.Fatal("empty slot should carry a hint explaining the filters") + } + } + } + + emptySlot := out.TimeSlots[0] + if !strings.HasPrefix(emptySlot.Start, "2026-03-27T15") { + emptySlot = out.TimeSlots[1] + } + raw, err := json.Marshal(emptySlot) + if err != nil { + t.Fatalf("marshal empty slot: %v", err) + } + if !strings.Contains(string(raw), `"meeting_rooms":[]`) { + t.Fatalf("expected meeting_rooms:[] in JSON, got %s", raw) + } + if !strings.Contains(string(raw), `"hint"`) { + t.Fatalf("expected hint field in JSON, got %s", raw) + } +} diff --git a/shortcuts/calendar/calendar_search_event.go b/shortcuts/calendar/calendar_search_event.go new file mode 100644 index 00000000..1200230a --- /dev/null +++ b/shortcuts/calendar/calendar_search_event.go @@ -0,0 +1,331 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT +// +// calendar +search-event — search calendar events by keyword, time range, and attendees + +package calendar + +import ( + "context" + "fmt" + "io" + "strconv" + "strings" + "time" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + defaultSearchEventPageSize = 20 + maxSearchEventPageSize = 30 +) + +// searchEventTimeRange represents the time range filter for search_event API. +type searchEventTimeRange struct { + StartTime string `json:"start_time,omitempty"` + EndTime string `json:"end_time,omitempty"` +} + +// searchEventFilter represents the filter object for the search_event API request. +type searchEventFilter struct { + AttendeeUserIDs []string `json:"attendee_user_ids,omitempty"` + AttendeeChatIDs []string `json:"attendee_chat_ids,omitempty"` + MeetingRoomIDs []string `json:"meeting_room_ids,omitempty"` + TimeRange *searchEventTimeRange `json:"time_range,omitempty"` +} + +// searchEventRequestBody is the request body for the search_event API. +type searchEventRequestBody struct { + Query string `json:"query"` + Filter *searchEventFilter `json:"filter,omitempty"` +} + +// searchEventTimeInfo represents start/end time info in the search result. +type searchEventTimeInfo struct { + Date string `json:"date,omitempty"` + DateTime string `json:"date_time,omitempty"` + Timezone string `json:"timezone,omitempty"` +} + +// searchEventItem represents a single event in the search result output. +type searchEventItem struct { + EventID string `json:"event_id"` + Summary string `json:"summary"` + Start *searchEventTimeInfo `json:"start,omitempty"` + End *searchEventTimeInfo `json:"end,omitempty"` + IsAllDay bool `json:"is_all_day,omitempty"` + AppLink string `json:"app_link,omitempty"` +} + +// searchEventOutput is the structured output for +search-event. +type searchEventOutput struct { + CalendarID string `json:"calendar_id"` + Items []searchEventItem `json:"items"` + HasMore bool `json:"has_more"` + PageToken string `json:"page_token"` +} + +// parseSearchEventTimeRange parses --start / --end into RFC3339 strings. +// When only one side is provided, the other defaults to the same day's +// boundary (start → end-of-day, end → start-of-day). +func parseSearchEventTimeRange(runtime *common.RuntimeContext) (string, string, error) { + startInput := strings.TrimSpace(runtime.Str("start")) + endInput := strings.TrimSpace(runtime.Str("end")) + if startInput == "" && endInput == "" { + return "", "", nil + } + + var startSec, endSec int64 + + if startInput != "" { + ts, err := common.ParseTime(startInput) + if err != nil { + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start") + } + startSec, _ = strconv.ParseInt(ts, 10, 64) + } + if endInput != "" { + ts, err := common.ParseTime(endInput, "end") + if err != nil { + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end") + } + endSec, _ = strconv.ParseInt(ts, 10, 64) + } + + if startInput == "" { + t := time.Unix(endSec, 0).In(time.Local) + startSec = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).Unix() + } + if endInput == "" { + t := time.Unix(startSec, 0).In(time.Local) + endSec = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, t.Location()).Unix() + } + + if startSec > endSec { + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start must be before --end").WithParam("--start") + } + + return time.Unix(startSec, 0).Format(time.RFC3339), time.Unix(endSec, 0).Format(time.RFC3339), nil +} + +// buildSearchEventFilter builds the filter object for the search_event API. +func buildSearchEventFilter(runtime *common.RuntimeContext, startTime, endTime string) *searchEventFilter { + attendeeIDs := common.SplitCSV(runtime.Str("attendee-ids")) + + var userIDs, chatIDs, roomIDs []string + for _, id := range attendeeIDs { + switch { + case strings.HasPrefix(id, "ou_"): + userIDs = append(userIDs, id) + case strings.HasPrefix(id, "oc_"): + chatIDs = append(chatIDs, id) + case strings.HasPrefix(id, "omm_"): + roomIDs = append(roomIDs, id) + default: + userIDs = append(userIDs, id) + } + } + + var tr *searchEventTimeRange + if startTime != "" || endTime != "" { + tr = &searchEventTimeRange{StartTime: startTime, EndTime: endTime} + } + + if len(userIDs) == 0 && len(chatIDs) == 0 && len(roomIDs) == 0 && tr == nil { + return nil + } + return &searchEventFilter{ + AttendeeUserIDs: userIDs, + AttendeeChatIDs: chatIDs, + MeetingRoomIDs: roomIDs, + TimeRange: tr, + } +} + +// extractTimeInfo extracts time info from a meta_data start/end map. +func extractTimeInfo(m map[string]any) *searchEventTimeInfo { + if m == nil { + return nil + } + info := &searchEventTimeInfo{} + if v, ok := m["date"].(string); ok && v != "" { + info.Date = v + } + if v, ok := m["date_time"].(string); ok && v != "" { + info.DateTime = v + } + if v, ok := m["timezone"].(string); ok && v != "" { + info.Timezone = v + } + if info.Date == "" && info.DateTime == "" { + return nil + } + return info +} + +// CalendarSearchEvent searches calendar events by keyword, time range, and attendees. +var CalendarSearchEvent = common.Shortcut{ + Service: "calendar", + Command: "+search-event", + Description: "Search calendar events by keyword, time range, and attendees", + Risk: "read", + Scopes: []string{"calendar:calendar.event:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "calendar-id", Desc: "calendar ID (default: primary)"}, + {Name: "query", Desc: "search keyword"}, + {Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"}, + {Name: "start", Desc: "search time range start (ISO 8601 or YYYY-MM-DD)"}, + {Name: "end", Desc: "search time range end (ISO 8601 or YYYY-MM-DD)"}, + {Name: "page-token", Desc: "page token for next page"}, + {Name: "page-size", Default: "20", Desc: "page size, 1-30 (default 20)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if err := rejectCalendarAutoBotFallback(runtime); err != nil { + return err + } + if _, _, err := parseSearchEventTimeRange(runtime); err != nil { + return err + } + if _, err := common.ValidatePageSizeTyped(runtime, "page-size", defaultSearchEventPageSize, 1, maxSearchEventPageSize); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + calendarID := runtime.Str("calendar-id") + if calendarID == "" { + calendarID = "" + } + return common.NewDryRunAPI(). + POST(fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/search_event", calendarID)). + Set("calendar_id", calendarID) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + calendarID := strings.TrimSpace(runtime.Str("calendar-id")) + if calendarID == "" { + calendarID = PrimaryCalendarIDStr + } + + startTime, endTime, err := parseSearchEventTimeRange(runtime) + if err != nil { + return err + } + + // Build request body — always send query (even if empty) + body := &searchEventRequestBody{ + Query: strings.TrimSpace(runtime.Str("query")), + } + if filter := buildSearchEventFilter(runtime, startTime, endTime); filter != nil { + body.Filter = filter + } + + // Build query params + params := map[string]any{} + pageSize, _ := strconv.Atoi(strings.TrimSpace(runtime.Str("page-size"))) + if pageSize <= 0 { + pageSize = defaultSearchEventPageSize + } + params["page_size"] = strconv.Itoa(pageSize) + if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" { + params["page_token"] = pt + } + + data, err := runtime.CallAPITyped("POST", + fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/search_event", validate.EncodePathSegment(calendarID)), + params, body) + if err != nil { + return err + } + if data == nil { + data = map[string]any{} + } + + items := common.GetSlice(data, "items") + hasMore, _ := data["has_more"].(bool) + pageToken, _ := data["page_token"].(string) + + // Transform items to structured output + outItems := make([]searchEventItem, 0, len(items)) + for _, raw := range items { + item, _ := raw.(map[string]any) + if item == nil { + continue + } + meta, _ := item["meta_data"].(map[string]any) + out := searchEventItem{} + if meta != nil { + if v, ok := meta["event_id"].(string); ok { + out.EventID = v + } + if v, ok := meta["summary"].(string); ok { + out.Summary = v + } + if v, ok := meta["is_all_day"].(bool); ok { + out.IsAllDay = v + } + if v, ok := meta["app_link"].(string); ok { + out.AppLink = v + } + if start, ok := meta["start"].(map[string]any); ok { + out.Start = extractTimeInfo(start) + } + if end, ok := meta["end"].(map[string]any); ok { + out.End = extractTimeInfo(end) + } + } + outItems = append(outItems, out) + } + + outData := searchEventOutput{ + CalendarID: calendarID, + Items: outItems, + HasMore: hasMore, + PageToken: pageToken, + } + + runtime.OutFormat(outData, &output.Meta{Count: len(outItems)}, func(w io.Writer) { + if len(outItems) == 0 { + fmt.Fprintln(w, "No events found.") + return + } + var rows []map[string]interface{} + for _, item := range outItems { + row := map[string]interface{}{ + "event_id": item.EventID, + "summary": common.TruncateStr(item.Summary, 40), + } + if item.Start != nil { + if item.Start.DateTime != "" { + row["start"] = item.Start.DateTime + } else if item.Start.Date != "" { + row["start"] = item.Start.Date + } + } + if item.End != nil { + if item.End.DateTime != "" { + row["end"] = item.End.DateTime + } else if item.End.Date != "" { + row["end"] = item.End.Date + } + } + if item.IsAllDay { + row["is_all_day"] = true + } + rows = append(rows, row) + } + output.PrintTable(w, rows) + fmt.Fprintf(w, "\n%d event(s) found\n", len(outItems)) + }) + + if hasMore && runtime.Format != "json" && runtime.Format != "" { + fmt.Fprintf(runtime.IO().Out, "\n(more available, page_token: %s)\n", pageToken) + } + return nil + }, +} diff --git a/shortcuts/calendar/calendar_test.go b/shortcuts/calendar/calendar_test.go index 67c386e6..970a141e 100644 --- a/shortcuts/calendar/calendar_test.go +++ b/shortcuts/calendar/calendar_test.go @@ -2234,10 +2234,10 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) { // Shortcuts() registration test // --------------------------------------------------------------------------- -func TestShortcuts_Returns7(t *testing.T) { +func TestShortcuts_Returns9(t *testing.T) { shortcuts := Shortcuts() - if len(shortcuts) != 7 { - t.Fatalf("expected 7 shortcuts, got %d", len(shortcuts)) + if len(shortcuts) != 9 { + t.Fatalf("expected 9 shortcuts, got %d", len(shortcuts)) } names := map[string]bool{} diff --git a/shortcuts/calendar/errors.go b/shortcuts/calendar/errors.go index ff260f19..9abf3d4a 100644 --- a/shortcuts/calendar/errors.go +++ b/shortcuts/calendar/errors.go @@ -42,3 +42,30 @@ func withParam(err error, flag string) error { } return err } + +// unwrapCalendarAPIError returns a user-facing message extracted from a +// calendar business-domain *errs.APIError, or "" when the error is not an +// APIError or its Code is not specialized here. Callers should fall back to +// err.Error() on "". +// +// Today it handles: +// - 190014 (invalid_parameters): returns Problem.Hint, which carries the +// server-supplied field-level detail (e.g. "end_time should be later +// than start_time") lifted by errclass.BuildAPIError. +// +// Add additional 19xxxx codes here as they become worth surfacing — keep this +// the single switch site so call sites stay readable. +func unwrapCalendarAPIError(err error) string { + if err == nil { + return "" + } + var ae *errs.APIError + if !errors.As(err, &ae) { + return "" + } + switch ae.Code { + case 190014: + return ae.Hint + } + return "" +} diff --git a/shortcuts/calendar/errors_attribution_test.go b/shortcuts/calendar/errors_attribution_test.go index f45d7a00..18cd0f8c 100644 --- a/shortcuts/calendar/errors_attribution_test.go +++ b/shortcuts/calendar/errors_attribution_test.go @@ -240,3 +240,62 @@ func TestParseCalendarAttendeeIDs_Valid(t *testing.T) { t.Errorf("dedup/trim failed: got %v", ids) } } + +// --------------------------------------------------------------------------- +// unwrapCalendarAPIError helper +// --------------------------------------------------------------------------- + +func TestUnwrapCalendarAPIError_NilReturnsEmpty(t *testing.T) { + if got := unwrapCalendarAPIError(nil); got != "" { + t.Errorf("nil err should return empty string, got %q", got) + } +} + +func TestUnwrapCalendarAPIError_NonAPIErrorReturnsEmpty(t *testing.T) { + // Validation, internal, and plain errors are not calendar API business + // errors; the helper must signal "no specialization" so callers fall back. + cases := []error{ + errs.NewValidationError(errs.SubtypeInvalidArgument, "bad input"), + errs.NewInternalError(errs.SubtypeSDKError, "io failure"), + errors.New("plain error"), + } + for _, e := range cases { + if got := unwrapCalendarAPIError(e); got != "" { + t.Errorf("unwrapCalendarAPIError(%T) = %q, want empty", e, got) + } + } +} + +func TestUnwrapCalendarAPIError_Code190014_ReturnsHint(t *testing.T) { + ae := errs.NewAPIError(errs.SubtypeInvalidParameters, "invalid params"). + WithCode(190014). + WithHint("end_time should be later than start_time") + got := unwrapCalendarAPIError(ae) + if got != "end_time should be later than start_time" { + t.Errorf("expected lifted hint, got %q", got) + } +} + +func TestUnwrapCalendarAPIError_Code190014_WrappedStillResolves(t *testing.T) { + // withStepContext wraps the typed error but errors.As must still find it. + inner := errs.NewAPIError(errs.SubtypeInvalidParameters, "invalid params"). + WithCode(190014). + WithHint("calendar_id is required") + wrapped := withStepContext(inner, "while fetching meeting info for %s", "evt_x") + got := unwrapCalendarAPIError(wrapped) + if !strings.Contains(got, "calendar_id is required") { + t.Errorf("expected wrapped 190014 to surface hint, got %q", got) + } +} + +func TestUnwrapCalendarAPIError_UnhandledCodeReturnsEmpty(t *testing.T) { + // An APIError carrying a code that isn't specialized here should return + // "" so callers fall back to err.Error() — keeps the helper conservative + // while we add 19xxxx codes incrementally. + ae := errs.NewAPIError(errs.SubtypeInvalidParameters, "some other error"). + WithCode(190099). + WithHint("ignore me") + if got := unwrapCalendarAPIError(ae); got != "" { + t.Errorf("unhandled code should return empty, got %q", got) + } +} diff --git a/shortcuts/calendar/shortcuts.go b/shortcuts/calendar/shortcuts.go index f959068b..b927bf78 100644 --- a/shortcuts/calendar/shortcuts.go +++ b/shortcuts/calendar/shortcuts.go @@ -15,5 +15,7 @@ func Shortcuts() []common.Shortcut { CalendarRoomFind, CalendarRsvp, CalendarSuggestion, + CalendarMeeting, + CalendarSearchEvent, } } diff --git a/shortcuts/common/artifact_path.go b/shortcuts/common/artifact_path.go index 42ba3beb..de68770e 100644 --- a/shortcuts/common/artifact_path.go +++ b/shortcuts/common/artifact_path.go @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT // This file defines artifact-path conventions shared between -// `minutes +download` and `vc +notes`. Callers outside those two shortcuts +// `minutes +download` and `minutes +detail`. Callers outside those two shortcuts // should not take a dependency on these symbols. package common diff --git a/shortcuts/doc/doc_errors_test.go b/shortcuts/doc/doc_errors_test.go index 56871a87..62ace658 100644 --- a/shortcuts/doc/doc_errors_test.go +++ b/shortcuts/doc/doc_errors_test.go @@ -203,6 +203,13 @@ func TestValidateCreateV2Contract(t *testing.T) { } } +func TestValidateCreateV2AllowsTitleWithoutContent(t *testing.T) { + rt := docValidateRuntime(t, map[string]string{"title": "Only Title"}, nil, nil) + if err := validateCreateV2(context.Background(), rt); err != nil { + t.Fatalf("validateCreateV2() error = %v, want nil", err) + } +} + func TestValidateFetchV2Contract(t *testing.T) { cases := []struct { name string diff --git a/shortcuts/doc/docs_create_test.go b/shortcuts/doc/docs_create_test.go index a701aa6a..089a0c3f 100644 --- a/shortcuts/doc/docs_create_test.go +++ b/shortcuts/doc/docs_create_test.go @@ -90,7 +90,6 @@ func TestDocsCreateV2BotAutoGrantSkippedWithoutCurrentUser(t *testing.T) { err := runDocsCreateShortcut(t, f, stdout, []string{ "+create", - "--api-version", "v2", "--content", "内容

正文

", "--as", "bot", }) @@ -125,7 +124,6 @@ func TestDocsCreateV2UserSkipsPermissionGrantAugmentation(t *testing.T) { err := runDocsCreateShortcut(t, f, stdout, []string{ "+create", - "--api-version", "v2", "--content", "内容

正文

", "--as", "user", }) @@ -163,7 +161,6 @@ func TestDocsCreateV2BotAutoGrantFailureDoesNotFailCreate(t *testing.T) { err := runDocsCreateShortcut(t, f, stdout, []string{ "+create", - "--api-version", "v2", "--content", "内容

正文

", "--as", "bot", }) @@ -201,7 +198,6 @@ func TestDocsCreateV2FallbackURLWhenBackendOmitsIt(t *testing.T) { err := runDocsCreateShortcut(t, f, stdout, []string{ "+create", - "--api-version", "v2", "--content", "内容

正文

", "--as", "user", }) @@ -233,7 +229,6 @@ func TestDocsCreateV2PreservesBackendURL(t *testing.T) { err := runDocsCreateShortcut(t, f, stdout, []string{ "+create", - "--api-version", "v2", "--content", "内容

正文

", "--as", "user", }) @@ -248,7 +243,7 @@ func TestDocsCreateV2PreservesBackendURL(t *testing.T) { } } -func TestDocsCreateAPIVersionV1StillUsesV2Endpoint(t *testing.T) { +func TestDocsCreateAPIVersionCompatFlagIsIgnored(t *testing.T) { t.Parallel() f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) @@ -262,7 +257,7 @@ func TestDocsCreateAPIVersionV1StillUsesV2Endpoint(t *testing.T) { err := runDocsCreateShortcut(t, f, stdout, []string{ "+create", - "--api-version", "v1", + "--api-version", "legacy", "--content", "项目计划", "--as", "user", }) @@ -282,7 +277,6 @@ func TestDocsCreateRejectsLegacyV1Flags(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) err := runDocsCreateShortcut(t, f, stdout, []string{ "+create", - "--title", "项目计划", "--markdown", "## 目标", "--as", "user", }) @@ -292,8 +286,7 @@ func TestDocsCreateRejectsLegacyV1Flags(t *testing.T) { for _, want := range []string{ "docs +create is v2-only", "the old v1 interface has been shut down", - "legacy v1 flag(s) --title, --markdown are no longer supported", - "--title -> put the title in --content", + "legacy v1 flag(s) --markdown are no longer supported", "--markdown -> use --content with --doc-format markdown", "lark-cli skills read lark-doc references/lark-doc-create.md", "lark-cli skills read lark-doc references/lark-doc-xml.md", diff --git a/shortcuts/doc/docs_create_v2.go b/shortcuts/doc/docs_create_v2.go index 6f0e1471..ffa48745 100644 --- a/shortcuts/doc/docs_create_v2.go +++ b/shortcuts/doc/docs_create_v2.go @@ -4,7 +4,9 @@ package doc import ( + "bytes" "context" + "encoding/xml" "strings" "github.com/larksuite/cli/errs" @@ -14,6 +16,7 @@ import ( // v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path. func v2CreateFlags() []common.Flag { return []common.Flag{ + {Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as ... so the title wins over later content titles"}, {Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}}, {Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}}, {Name: "parent-token", Desc: "parent folder token or wiki node token; mutually exclusive with --parent-position"}, @@ -25,8 +28,12 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error { if err := validateDocsV2Only(runtime, "+create", docsCreateLegacyFlags()); err != nil { return err } - if runtime.Str("content") == "" { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required").WithParam("--content") + title := strings.TrimSpace(runtime.Str("title")) + if runtime.Changed("title") && title == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--title must not be empty").WithParam("--title") + } + if runtime.Str("content") == "" && title == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content") } if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parent-token and --parent-position are mutually exclusive").WithParams( @@ -66,7 +73,7 @@ func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error { func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} { body := map[string]interface{}{ "format": runtime.Str("doc-format"), - "content": runtime.Str("content"), + "content": buildCreateContent(runtime), } if v := runtime.Str("parent-token"); v != "" { body["parent_token"] = v @@ -78,6 +85,26 @@ func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} { return body } +func buildCreateContent(runtime *common.RuntimeContext) string { + content := runtime.Str("content") + title := strings.TrimSpace(runtime.Str("title")) + if title == "" { + return content + } + + titleTag := "" + escapeDocTitleText(title) + "" + if content == "" { + return titleTag + } + return titleTag + "\n" + content +} + +func escapeDocTitleText(title string) string { + var buf bytes.Buffer + _ = xml.EscapeText(&buf, []byte(title)) + return buf.String() +} + // augmentDocsCreatePermission grants full_access to the current CLI user when // the document was created with bot identity. func augmentDocsCreatePermission(runtime *common.RuntimeContext, data map[string]interface{}) { diff --git a/shortcuts/doc/docs_fetch_im_markdown.go b/shortcuts/doc/docs_fetch_im_markdown.go new file mode 100644 index 00000000..13b37002 --- /dev/null +++ b/shortcuts/doc/docs_fetch_im_markdown.go @@ -0,0 +1,861 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "fmt" + "html" + "net/url" + "regexp" + "strings" + "unicode/utf8" +) + +type imMarkdownContext struct { + baseURL string + blockquoteDepth int +} + +type imMarkdownHandleFunc func(segment, inner string, attrs map[string]string, imCtx imMarkdownContext) string + +type imMarkdownTagHandler struct { + closeRE *regexp.Regexp + handle imMarkdownHandleFunc +} + +func registerIMMarkdownHandler(tag string, handle imMarkdownHandleFunc) { + imMarkdownHandlers[tag] = imMarkdownTagHandler{ + closeRE: regexp.MustCompile(`(?is)<(/?)` + regexp.QuoteMeta(tag) + `(?:\s[^<>]*?)?\s*/?>`), + handle: handle, + } +} + +var ( + imMarkdownTagStartRE = regexp.MustCompile(`(?s)<([A-Za-z][A-Za-z0-9:_-]*)(?:\s[^<>]*?)?\s*/?>`) + imMarkdownAttrRE = regexp.MustCompile(`([A-Za-z_:][A-Za-z0-9_:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')`) + imMarkdownRowTagRE = regexp.MustCompile(`(?is)<(/?)tr\b[^>]*?\s*/?>`) + imMarkdownCellTagRE = regexp.MustCompile(`(?is)<(/?)t[dh]\b[^>]*?\s*/?>`) + imMarkdownCellBreakRE = regexp.MustCompile(`(?i)`) + imMarkdownAnyTagRE = regexp.MustCompile(`(?s)]*?)?>`) + imMarkdownLinkRE = regexp.MustCompile(`(?is)]*\bhref=(?:"([^"]*)"|'([^']*)')[^>]*>(.*?)`) + imMarkdownCodeBlockRE = regexp.MustCompile(`(?is)^\s*]*?)?>(.*?)\s*$`) + imMarkdownLiOpenRE = regexp.MustCompile(`(?is)]*?)?>`) + imMarkdownLiCloseRE = regexp.MustCompile(`(?is)<(/?)li(?:\s[^<>]*?)?\s*/?>`) +) + +var imMarkdownHandlers = map[string]imMarkdownTagHandler{} + +func init() { + registerIMMarkdownHandler("title", handleIMMarkdownTitle) + for level := 1; level <= 9; level++ { + registerIMMarkdownHandler(fmt.Sprintf("h%d", level), handleIMMarkdownHeading(level)) + } + registerIMMarkdownHandler("p", handleIMMarkdownParagraph) + registerIMMarkdownHandler("ul", handleIMMarkdownUnorderedList) + registerIMMarkdownHandler("ol", handleIMMarkdownOrderedList) + registerIMMarkdownHandler("li", handleIMMarkdownListItem) + registerIMMarkdownHandler("callout", handleIMMarkdownCallout) + registerIMMarkdownHandler("blockquote", handleIMMarkdownBlockquote) + registerIMMarkdownHandler("grid", handleIMMarkdownPassthroughContainer) + registerIMMarkdownHandler("column", handleIMMarkdownColumn) + registerIMMarkdownHandler("table", handleIMMarkdownTable) + registerIMMarkdownHandler("colgroup", handleIMMarkdownDiscard) + registerIMMarkdownHandler("col", handleIMMarkdownDiscard) + registerIMMarkdownHandler("pre", handleIMMarkdownPre) + registerIMMarkdownHandler("code", handleIMMarkdownCode) + registerIMMarkdownHandler("latex", handleIMMarkdownLatex) + registerIMMarkdownHandler("hr", handleIMMarkdownHorizontalRule) + registerIMMarkdownHandler("img", handleIMMarkdownImage) + registerIMMarkdownHandler("figure", handleIMMarkdownDiscard) + registerIMMarkdownHandler("source", handleIMMarkdownSource) + registerIMMarkdownHandler("button", handleIMMarkdownDiscard) + registerIMMarkdownHandler("time", handleIMMarkdownDiscard) + registerIMMarkdownHandler("whiteboard", handleIMMarkdownInlineCode) + registerIMMarkdownHandler("sheet", handleIMMarkdownSheet) + registerIMMarkdownHandler("task", handleIMMarkdownConditionalResourceLabel("任务", "task-id", "guid", "token", "id")) + registerIMMarkdownHandler("chat_card", handleIMMarkdownConditionalResourceLabel("群聊卡片", "chat-id", "chat_id", "id")) + registerIMMarkdownHandler("bitable", handleIMMarkdownResourceLabel("多维表格")) + registerIMMarkdownHandler("base_refer", handleIMMarkdownResourceLabel("多维表格")) + registerIMMarkdownHandler("okr", handleIMMarkdownResourceLabel("OKR")) + registerIMMarkdownHandler("poll", handleIMMarkdownDiscard) + registerIMMarkdownHandler("agenda", handleIMMarkdownDiscard) + registerIMMarkdownHandler("folder_manager", handleIMMarkdownDiscard) + registerIMMarkdownHandler("wiki_catalog", handleIMMarkdownDiscard) + registerIMMarkdownHandler("wiki_recent_update", handleIMMarkdownDiscard) + registerIMMarkdownHandler("chart_refer_host_perm", handleIMMarkdownDiscard) + registerIMMarkdownHandler("synced_reference", handleIMMarkdownDiscard) + registerIMMarkdownHandler("synced-source", handleIMMarkdownDiscard) + registerIMMarkdownHandler("mindnote", handleIMMarkdownDiscard) + registerIMMarkdownHandler("bookmark", handleIMMarkdownBookmark) + registerIMMarkdownHandler("cite", handleIMMarkdownCite) + registerIMMarkdownHandler("b", handleIMMarkdownStrong) + registerIMMarkdownHandler("em", handleIMMarkdownEmphasis) + registerIMMarkdownHandler("del", handleIMMarkdownDelete) + registerIMMarkdownHandler("u", handleIMMarkdownPlainInline) + registerIMMarkdownHandler("span", handleIMMarkdownPlainInline) + registerIMMarkdownHandler("a", handleIMMarkdownAnchor) +} + +func isIMMarkdownFetch(runtime interface{ Str(string) string }) bool { + return strings.TrimSpace(runtime.Str("doc-format")) == "im-markdown" +} + +func applyFetchIMMarkdown(data map[string]interface{}, docInput string) { + doc, ok := data["document"].(map[string]interface{}) + if !ok { + return + } + content, ok := doc["content"].(string) + if !ok { + return + } + doc["content"] = convertToIMMarkdown(content, newIMMarkdownContext(docInput)) +} + +func newIMMarkdownContext(docInput string) imMarkdownContext { + base := "https://larkoffice.com" + raw := strings.TrimSpace(docInput) + if extracted, ok := imMarkdownBaseURLFromInput(raw); ok { + base = extracted + } + return imMarkdownContext{baseURL: base} +} + +func (c imMarkdownContext) withBlockquote() imMarkdownContext { + c.blockquoteDepth++ + return c +} + +func (c imMarkdownContext) inBlockquote() bool { + return c.blockquoteDepth > 0 +} + +// imMarkdownBaseURLFromInput keeps the tenant host from --doc when it is a URL +// so generated doc/sheet links point back to the same tenant. parseDocumentRef +// intentionally strips host information, so it cannot serve this formatting path. +func imMarkdownBaseURLFromInput(raw string) (string, bool) { + if raw == "" { + return "", false + } + if u, err := url.Parse(raw); err == nil && u.Scheme != "" && u.Host != "" { + return u.Scheme + "://" + u.Host, true + } + for _, marker := range []string{"/docx/", "/wiki/", "/doc/"} { + idx := strings.Index(raw, marker) + if idx <= 0 { + continue + } + candidate := strings.Trim(raw[:idx], "/") + if candidate == "" { + continue + } + if u, err := url.Parse(candidate); err == nil && u.Scheme != "" && u.Host != "" { + return u.Scheme + "://" + u.Host, true + } + if u, err := url.Parse("https://" + candidate); err == nil && u.Host != "" && strings.Contains(u.Host, ".") { + return "https://" + u.Host, true + } + } + return "", false +} + +func convertToIMMarkdown(content string, imCtx imMarkdownContext) string { + var out strings.Builder + for offset := 0; offset < len(content); { + // Scan only to the next XML-like opening tag. Plain Markdown text between + // registered tags is copied unchanged, so ordinary Markdown is not re-parsed. + loc := imMarkdownTagStartRE.FindStringSubmatchIndex(content[offset:]) + if loc == nil { + out.WriteString(content[offset:]) + break + } + start := offset + loc[0] + openEnd := offset + loc[1] + tag := strings.ToLower(content[offset+loc[2] : offset+loc[3]]) + handler, ok := imMarkdownHandlers[tag] + if !ok { + // Unknown tags are left intact. im-markdown only downgrades tags with + // explicit handlers so future server output does not get guessed at. + out.WriteString(content[offset:openEnd]) + offset = openEnd + continue + } + + out.WriteString(content[offset:start]) + opening := content[start:openEnd] + attrs := parseIMMarkdownAttrs(opening) + if isSelfClosingIMMarkdownTag(opening) { + out.WriteString(handler.handle(opening, "", attrs, imCtx)) + offset = openEnd + continue + } + + // Use the handler's precompiled close regexp to find the matching end tag. + // Depth tracking keeps nested same-name containers paired correctly. + closeStart, closeEnd, found := findIMMarkdownClosingTag(content, openEnd, handler) + if !found { + // Malformed or truncated fragments are preserved as-is from the opening + // tag onward; do not drop content when the XML-ish structure is incomplete. + out.WriteString(content[start:]) + break + } + segment := content[start:closeEnd] + inner := content[openEnd:closeStart] + out.WriteString(handler.handle(segment, inner, attrs, imCtx)) + offset = closeEnd + } + return out.String() +} + +func findIMMarkdownClosingTag(content string, from int, handler imMarkdownTagHandler) (int, int, bool) { + depth := 1 + for _, loc := range handler.closeRE.FindAllStringSubmatchIndex(content[from:], -1) { + start := from + loc[0] + end := from + loc[1] + token := content[start:end] + if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" { + depth-- + if depth == 0 { + return start, end, true + } + continue + } + if !isSelfClosingIMMarkdownTag(token) { + depth++ + } + } + return 0, 0, false +} + +func parseIMMarkdownAttrs(opening string) map[string]string { + attrs := map[string]string{} + for _, match := range imMarkdownAttrRE.FindAllStringSubmatch(opening, -1) { + value := match[2] + if value == "" { + value = match[3] + } + attrs[strings.ToLower(match[1])] = html.UnescapeString(value) + } + return attrs +} + +func isSelfClosingIMMarkdownTag(tag string) bool { + return strings.HasSuffix(strings.TrimSpace(tag), "/>") +} + +func handleIMMarkdownTitle(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string { + text := strings.TrimSpace(convertToIMMarkdown(inner, imCtx)) + if text == "" { + return "" + } + return "# " + text +} + +func handleIMMarkdownHeading(level int) imMarkdownHandleFunc { + return func(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string { + text := strings.TrimSpace(convertToIMMarkdown(inner, imCtx)) + if text == "" { + return "" + } + markdownLevel := level + if markdownLevel > 6 { + markdownLevel = 6 + } + return strings.Repeat("#", markdownLevel) + " " + text + } +} + +func handleIMMarkdownParagraph(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string { + body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx)) + if body == "" { + return "" + } + if imCtx.inBlockquote() { + return body + "\n" + } + return body +} + +func handleIMMarkdownUnorderedList(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string { + return convertIMMarkdownListItems(inner, false, imCtx) +} + +func handleIMMarkdownOrderedList(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string { + return convertIMMarkdownListItems(inner, true, imCtx) +} + +func handleIMMarkdownListItem(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string { + prefix := "-" + if seq := strings.TrimSpace(attrs["seq"]); seq != "" && seq != "auto" { + prefix = strings.TrimSuffix(seq, ".") + "." + } + body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx)) + if body == "" { + return "" + } + return prefix + " " + indentIMMarkdownListContinuation(body) + "\n" +} + +func handleIMMarkdownCallout(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string { + body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx)) + emoji := strings.TrimSpace(attrs["emoji"]) + if emoji != "" { + if body == "" { + body = emoji + } else { + body = emoji + " " + body + } + } + if body == "" { + return "---\n---" + } + return fmt.Sprintf("---\n%s\n---", body) +} + +func handleIMMarkdownBlockquote(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string { + body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx.withBlockquote())) + if body == "" { + return "" + } + lines := strings.Split(body, "\n") + for i, line := range lines { + if strings.TrimSpace(line) == "" { + lines[i] = ">" + continue + } + lines[i] = "> " + line + } + return strings.Join(lines, "\n") +} + +func handleIMMarkdownPassthroughContainer(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string { + return strings.TrimSpace(convertToIMMarkdown(inner, imCtx)) +} + +func handleIMMarkdownColumn(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string { + body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx)) + if body == "" { + return "" + } + return body + "\n" +} + +func handleIMMarkdownDiscard(_ string, _ string, _ map[string]string, _ imMarkdownContext) string { + return "" +} + +func handleIMMarkdownInlineCode(segment string, _ string, _ map[string]string, _ imMarkdownContext) string { + return imMarkdownInlineCode(segment) +} + +func handleIMMarkdownPre(_ string, inner string, attrs map[string]string, _ imMarkdownContext) string { + lang := strings.TrimSpace(attrs["lang"]) + code := strings.TrimSpace(inner) + if match := imMarkdownCodeBlockRE.FindStringSubmatch(code); match != nil { + code = match[1] + } + return imMarkdownFencedCode(html.UnescapeString(code), lang) +} + +func handleIMMarkdownCode(_ string, inner string, _ map[string]string, _ imMarkdownContext) string { + return imMarkdownInlineCode(markdownPlainText(inner)) +} + +func handleIMMarkdownLatex(_ string, inner string, _ map[string]string, _ imMarkdownContext) string { + expr := strings.TrimSpace(markdownPlainText(inner)) + if expr == "" { + return "" + } + return "$" + strings.ReplaceAll(expr, "$", `\$`) + "$" +} + +func handleIMMarkdownHorizontalRule(_ string, _ string, _ map[string]string, _ imMarkdownContext) string { + return "---" +} + +func handleIMMarkdownImage(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string { + href := firstNonEmpty(attrs["href"], attrs["src"], attrs["url"]) + if href == "" { + return "" + } + alt := firstNonEmpty(attrs["alt"], attrs["name"], attrs["title"]) + return fmt.Sprintf("![%s](%s)", escapeMarkdownLinkText(alt), escapeMarkdownLinkDestination(href)) +} + +func handleIMMarkdownSource(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string { + name := strings.TrimSpace(attrs["name"]) + if name == "" { + return "" + } + return imMarkdownInlineCode(name) +} + +func handleIMMarkdownResourceLabel(label string) imMarkdownHandleFunc { + return func(_ string, _ string, _ map[string]string, _ imMarkdownContext) string { + return imMarkdownInlineCode(label) + } +} + +func handleIMMarkdownConditionalResourceLabel(label string, attrNames ...string) imMarkdownHandleFunc { + return func(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string { + for _, attrName := range attrNames { + if strings.TrimSpace(attrs[attrName]) != "" { + return imMarkdownInlineCode(label) + } + } + return "" + } +} + +func handleIMMarkdownSheet(segment string, _ string, attrs map[string]string, imCtx imMarkdownContext) string { + token := strings.TrimSpace(attrs["token"]) + if token == "" { + return imMarkdownInlineCode(segment) + } + label := "sheet" + if sheetID := strings.TrimSpace(attrs["sheet-id"]); sheetID != "" { + label = "sheet " + sheetID + } + return markdownLink(label, strings.TrimRight(imCtx.baseURL, "/")+"/sheets/"+token) +} + +func handleIMMarkdownBookmark(segment string, inner string, attrs map[string]string, imCtx imMarkdownContext) string { + href := strings.TrimSpace(attrs["href"]) + name := firstNonEmpty(attrs["name"], attrs["title"], markdownLinkLabelText(convertToIMMarkdown(inner, imCtx)), href) + if href == "" { + return name + } + return markdownLink(name, href) +} + +func handleIMMarkdownStrong(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string { + body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx)) + if body == "" { + return "" + } + return "**" + body + "**" +} + +func handleIMMarkdownEmphasis(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string { + body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx)) + if body == "" { + return "" + } + return "*" + body + "*" +} + +func handleIMMarkdownDelete(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string { + body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx)) + if body == "" { + return "" + } + return "~~" + body + "~~" +} + +func handleIMMarkdownPlainInline(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string { + return strings.TrimSpace(convertToIMMarkdown(inner, imCtx)) +} + +func handleIMMarkdownAnchor(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string { + href := strings.TrimSpace(attrs["href"]) + text := firstNonEmpty(markdownLinkLabelText(convertToIMMarkdown(inner, imCtx)), attrs["name"], attrs["title"], href) + if href == "" { + return text + } + return markdownLink(text, href) +} + +func handleIMMarkdownCite(segment string, inner string, attrs map[string]string, imCtx imMarkdownContext) string { + switch strings.ToLower(strings.TrimSpace(attrs["type"])) { + case "user": + userID := firstNonEmpty(attrs["user-id"], attrs["open-id"], attrs["id"]) + name := firstNonEmpty(attrs["user-name"], attrs["name"], markdownPlainText(inner), userID) + if userID == "" { + return name + } + return fmt.Sprintf(`%s`, html.EscapeString(userID), html.EscapeString(name)) + case "doc": + title := firstNonEmpty(attrs["title"], attrs["name"], attrs["doc-id"], "document") + if href := firstNonEmpty(attrs["href"], attrs["url"]); href != "" { + return markdownLink(title, href) + } + docID := firstNonEmpty(attrs["doc-id"], attrs["token"]) + if docID == "" { + return imMarkdownInlineCode(segment) + } + fileType := strings.Trim(strings.ToLower(firstNonEmpty(attrs["file-type"], "docx")), "/") + return markdownLink(title, strings.TrimRight(imCtx.baseURL, "/")+"/"+fileType+"/"+docID) + case "citation": + if text, href, ok := extractIMMarkdownInnerLink(inner); ok { + return markdownLink(text, href) + } + if href := firstNonEmpty(attrs["href"], attrs["url"]); href != "" { + return markdownLink(firstNonEmpty(attrs["title"], attrs["name"], href), href) + } + return markdownPlainText(convertToIMMarkdown(inner, imCtx)) + default: + return imMarkdownInlineCode(segment) + } +} + +func handleIMMarkdownTable(segment string, inner string, _ map[string]string, imCtx imMarkdownContext) string { + // Rows and cells are matched with tag-depth tracking instead of non-greedy + // regex captures. A table nested inside a cell can contain its own and + // ; treating those as the outer row/cell boundary corrupts the table. + rowBodies := extractIMMarkdownElementBodies(inner, imMarkdownRowTagRE) + if len(rowBodies) == 0 { + return imMarkdownInlineCode(segment) + } + + rows := make([][]string, 0, len(rowBodies)) + for _, rowBody := range rowBodies { + cellBodies := extractIMMarkdownElementBodies(rowBody, imMarkdownCellTagRE) + if len(cellBodies) == 0 { + continue + } + row := make([]string, 0, len(cellBodies)) + for _, cellBody := range cellBodies { + row = append(row, normalizeIMMarkdownTableCell(convertToIMMarkdown(cellBody, imCtx))) + } + rows = append(rows, row) + } + if len(rows) == 0 { + return imMarkdownInlineCode(segment) + } + + cols := 0 + for _, row := range rows { + if len(row) > cols { + cols = len(row) + } + } + var out strings.Builder + writeIMMarkdownTableRow(&out, padIMMarkdownTableRow(rows[0], cols)) + separator := make([]string, cols) + for i := range separator { + separator[i] = "-" + } + writeIMMarkdownTableRow(&out, separator) + for _, row := range rows[1:] { + writeIMMarkdownTableRow(&out, padIMMarkdownTableRow(row, cols)) + } + return strings.TrimRight(out.String(), "\n") +} + +// extractIMMarkdownElementBodies returns the inner content of each top-level +// element matched by tagRE. tagRE must expose the optional closing slash as its +// first capture group, matching the row/cell regexes above. +func extractIMMarkdownElementBodies(content string, tagRE *regexp.Regexp) []string { + var bodies []string + for offset := 0; offset < len(content); { + loc := tagRE.FindStringSubmatchIndex(content[offset:]) + if loc == nil { + break + } + openStart := offset + loc[0] + openEnd := offset + loc[1] + opening := content[openStart:openEnd] + if loc[2] >= 0 && content[offset+loc[2]:offset+loc[3]] == "/" { + offset = openEnd + continue + } + if isSelfClosingIMMarkdownTag(opening) { + bodies = append(bodies, "") + offset = openEnd + continue + } + closeStart, closeEnd, found := findIMMarkdownElementClosingTag(content, openEnd, tagRE) + if !found { + break + } + bodies = append(bodies, content[openEnd:closeStart]) + offset = closeEnd + } + return bodies +} + +func findIMMarkdownElementClosingTag(content string, from int, tagRE *regexp.Regexp) (int, int, bool) { + depth := 1 + for _, loc := range tagRE.FindAllStringSubmatchIndex(content[from:], -1) { + start := from + loc[0] + end := from + loc[1] + token := content[start:end] + if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" { + depth-- + if depth == 0 { + return start, end, true + } + continue + } + if !isSelfClosingIMMarkdownTag(token) { + depth++ + } + } + return 0, 0, false +} + +func normalizeIMMarkdownTableCell(cell string) string { + const brPlaceholder = "\x00BR\x00" + cell = imMarkdownCellBreakRE.ReplaceAllString(cell, brPlaceholder) + cell = imMarkdownAnyTagRE.ReplaceAllStringFunc(cell, func(tag string) string { + name := strings.ToLower(strings.TrimPrefix(imMarkdownAnyTagRE.FindStringSubmatch(tag)[1], "/")) + if name == "at" { + return tag + } + return "" + }) + cell = html.UnescapeString(cell) + cell = strings.ReplaceAll(cell, brPlaceholder, "
") + cell = strings.ReplaceAll(cell, " \n", "
") + cell = strings.ReplaceAll(cell, "\n", "
") + cell = strings.ReplaceAll(cell, "|", `\|`) + lines := strings.Fields(cell) + if len(lines) == 0 { + return "" + } + return strings.Join(lines, " ") +} + +func writeIMMarkdownTableRow(out *strings.Builder, row []string) { + out.WriteString("| ") + out.WriteString(strings.Join(row, " | ")) + out.WriteString(" |\n") +} + +func padIMMarkdownTableRow(row []string, cols int) []string { + if len(row) >= cols { + return row + } + padded := make([]string, cols) + copy(padded, row) + return padded +} + +func convertIMMarkdownListItems(inner string, ordered bool, imCtx imMarkdownContext) string { + var out strings.Builder + for offset, index := 0, 1; offset < len(inner); { + loc := imMarkdownLiOpenRE.FindStringIndex(inner[offset:]) + if loc == nil { + break + } + openStart := offset + loc[0] + openEnd := offset + loc[1] + opening := inner[openStart:openEnd] + closeStart, closeEnd, found := findIMMarkdownListItemClosingTag(inner, openEnd) + if !found { + break + } + body := strings.TrimSpace(convertToIMMarkdown(inner[openEnd:closeStart], imCtx)) + if body != "" { + prefix := "-" + if ordered { + attrs := parseIMMarkdownAttrs(opening) + if seq := strings.TrimSpace(attrs["seq"]); seq != "" && seq != "auto" { + prefix = strings.TrimSuffix(seq, ".") + "." + } else { + prefix = fmt.Sprintf("%d.", index) + } + index++ + } + out.WriteString(prefix) + out.WriteString(" ") + out.WriteString(indentIMMarkdownListContinuation(body)) + out.WriteString("\n") + } + offset = closeEnd + } + return strings.TrimRight(out.String(), "\n") +} + +func findIMMarkdownListItemClosingTag(content string, from int) (int, int, bool) { + depth := 1 + for _, loc := range imMarkdownLiCloseRE.FindAllStringSubmatchIndex(content[from:], -1) { + start := from + loc[0] + end := from + loc[1] + token := content[start:end] + if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" { + depth-- + if depth == 0 { + return start, end, true + } + continue + } + if !isSelfClosingIMMarkdownTag(token) { + depth++ + } + } + return 0, 0, false +} + +func indentIMMarkdownListContinuation(body string) string { + return strings.ReplaceAll(body, "\n", "\n ") +} + +func extractIMMarkdownInnerLink(inner string) (string, string, bool) { + match := imMarkdownLinkRE.FindStringSubmatch(inner) + if match == nil { + return "", "", false + } + href := match[1] + if href == "" { + href = match[2] + } + text := strings.TrimSpace(markdownPlainText(match[3])) + if text == "" { + text = href + } + return text, html.UnescapeString(href), true +} + +func markdownPlainText(s string) string { + s = imMarkdownCellBreakRE.ReplaceAllString(s, "\n") + s = imMarkdownAnyTagRE.ReplaceAllString(s, "") + return strings.TrimSpace(html.UnescapeString(s)) +} + +func markdownLinkLabelText(s string) string { + text := markdownPlainText(s) + if !strings.Contains(text, "---") { + return text + } + lines := strings.Split(text, "\n") + kept := lines[:0] + for _, line := range lines { + if strings.TrimSpace(line) == "---" { + continue + } + kept = append(kept, line) + } + return strings.TrimSpace(strings.Join(kept, "\n")) +} + +func markdownLink(text, href string) string { + cleanHref := strings.TrimSpace(href) + return fmt.Sprintf("[%s](%s)", escapeMarkdownLinkText(firstNonEmpty(text, cleanHref)), escapeMarkdownLinkDestination(cleanHref)) +} + +func escapeMarkdownLinkText(text string) string { + text = strings.ReplaceAll(text, `\`, `\\`) + text = strings.ReplaceAll(text, `[`, `\[`) + text = strings.ReplaceAll(text, `]`, `\]`) + return text +} + +func escapeMarkdownLinkDestination(href string) string { + // Lark/Feishu IM Markdown does not reliably parse raw spaces or parentheses + // inside (...). Keep URL delimiters like :/?#&= intact, but percent-encode + // characters that can terminate or split the Markdown link destination. + var out strings.Builder + out.Grow(len(href)) + for i := 0; i < len(href); { + if href[i] == '%' { + if i+2 < len(href) && isHexDigit(href[i+1]) && isHexDigit(href[i+2]) { + out.WriteString(href[i : i+3]) + i += 3 + } else { + writePercentEncodedByte(&out, href[i]) + i++ + } + continue + } + if href[i] < utf8.RuneSelf { + if shouldPercentEncodeIMMarkdownURLByte(href[i]) { + writePercentEncodedByte(&out, href[i]) + } else { + out.WriteByte(href[i]) + } + i++ + continue + } + r, size := utf8.DecodeRuneInString(href[i:]) + if r == utf8.RuneError && size == 1 { + writePercentEncodedByte(&out, href[i]) + i++ + continue + } + for _, b := range []byte(href[i : i+size]) { + writePercentEncodedByte(&out, b) + } + i += size + } + return out.String() +} + +func shouldPercentEncodeIMMarkdownURLByte(b byte) bool { + if b <= ' ' || b >= 0x7f { + return true + } + switch b { + case '(', ')', '<', '>', '"', '\\', '^', '`', '{', '|', '}': + return true + default: + return false + } +} + +func writePercentEncodedByte(out *strings.Builder, b byte) { + const hex = "0123456789ABCDEF" + out.WriteByte('%') + out.WriteByte(hex[b>>4]) + out.WriteByte(hex[b&0x0f]) +} + +func isHexDigit(b byte) bool { + return ('0' <= b && b <= '9') || ('a' <= b && b <= 'f') || ('A' <= b && b <= 'F') +} + +func imMarkdownInlineCode(s string) string { + maxRun := 0 + run := 0 + for _, r := range s { + if r == '`' { + run++ + if run > maxRun { + maxRun = run + } + continue + } + run = 0 + } + fence := strings.Repeat("`", maxRun+1) + if strings.HasPrefix(s, "`") || strings.HasSuffix(s, "`") { + return fence + " " + s + " " + fence + } + return fence + s + fence +} + +func imMarkdownFencedCode(code, lang string) string { + maxRun := 0 + for _, line := range strings.Split(code, "\n") { + if run := leadingBacktickRun(line); run > maxRun { + maxRun = run + } + } + fenceLen := maxRun + 1 + if fenceLen < 3 { + fenceLen = 3 + } + fence := strings.Repeat("`", fenceLen) + return fence + strings.TrimSpace(lang) + "\n" + strings.Trim(code, "\n") + "\n" + fence +} + +func leadingBacktickRun(s string) int { + run := 0 + for _, r := range s { + if r != '`' { + break + } + run++ + } + return run +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/shortcuts/doc/docs_fetch_im_markdown_test.go b/shortcuts/doc/docs_fetch_im_markdown_test.go new file mode 100644 index 00000000..971b4878 --- /dev/null +++ b/shortcuts/doc/docs_fetch_im_markdown_test.go @@ -0,0 +1,1305 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "reflect" + "strings" + "testing" +) + +func TestApplyFetchIMMarkdown(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data map[string]interface{} + docInput string + want map[string]interface{} + }{ + { + name: "missing document leaves data unchanged", + data: map[string]interface{}{ + "content": `Roadmap`, + }, + docInput: "https://tenant.example.com/docx/doc_token", + want: map[string]interface{}{ + "content": `Roadmap`, + }, + }, + { + name: "non string content leaves data unchanged", + data: map[string]interface{}{ + "document": map[string]interface{}{ + "content": 123, + }, + }, + docInput: "https://tenant.example.com/docx/doc_token", + want: map[string]interface{}{ + "document": map[string]interface{}{ + "content": 123, + }, + }, + }, + { + name: "converts content with tenant base url", + data: map[string]interface{}{ + "document": map[string]interface{}{ + "content": `Roadmap` + "\n" + ``, + }, + }, + docInput: "https://tenant.example.com/docx/doc_token", + want: map[string]interface{}{ + "document": map[string]interface{}{ + "content": "# Roadmap\n[sheet S1](https://tenant.example.com/sheets/sht_token)", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + applyFetchIMMarkdown(tt.data, tt.docInput) + if !reflect.DeepEqual(tt.data, tt.want) { + t.Fatalf("data = %#v, want %#v", tt.data, tt.want) + } + }) + } +} + +func TestConvertToIMMarkdownTitle(t *testing.T) { + t.Parallel() + + assertIMMarkdownCases(t, []imMarkdownCase{ + { + name: "plain title", + input: `Roadmap`, + want: "# Roadmap", + }, + { + name: "trim title whitespace", + input: "\n Roadmap \n", + want: "# Roadmap", + }, + { + name: "convert title inner markup", + input: `<b>Bold</b> Title`, + want: "# **Bold** Title", + }, + { + name: "empty title", + input: ` `, + want: "", + }, + { + name: "title followed by text", + input: `Roadmaptail`, + want: "# Roadmaptail", + }, + { + name: "uppercase title is handled case-insensitively", + input: `Roadmap`, + want: "# Roadmap", + }, + { + name: "missing closing title is preserved", + input: `beforeRoadmap`, + want: `before<title>Roadmap`, + }, + }) +} + +func TestConvertToIMMarkdownCallout(t *testing.T) { + t.Parallel() + + assertIMMarkdownCases(t, []imMarkdownCase{ + { + name: "emoji and body", + input: `<callout emoji="💡">Read **this**.</callout>`, + want: "---\n💡 Read **this**.\n---", + }, + { + name: "body without emoji", + input: `<callout>Plain body</callout>`, + want: "---\nPlain body\n---", + }, + { + name: "emoji only", + input: `<callout emoji="✅"></callout>`, + want: "---\n✅\n---", + }, + { + name: "empty callout", + input: `<callout></callout>`, + want: "---\n---", + }, + { + name: "nested callout", + input: `<callout emoji="✅">Outer <callout emoji="💡">Inner</callout></callout>`, + want: "---\n✅ Outer ---\n💡 Inner\n---\n---", + }, + { + name: "callout contains registered tags", + input: `<callout emoji="📝"><bookmark name="Spec" href="https://example.com"></bookmark></callout>`, + want: "---\n📝 [Spec](https://example.com)\n---", + }, + { + name: "callout contains grid and cite", + input: `<callout emoji="📣"><grid><column><cite type="user" user-id="ou_1" user-name="Alice"></cite></column><column><bookmark name="Spec" href="https://example.com"></bookmark></column></grid></callout>`, + want: "---\n📣 <at user_id=\"ou_1\">Alice</at>\n[Spec](https://example.com)\n---", + }, + { + name: "same-name nested callout with trailing text", + input: `<callout emoji="1">a<callout emoji="2">b</callout>c</callout>d`, + want: "---\n1 a---\n2 b\n---c\n---d", + }, + { + name: "missing closing callout is preserved", + input: `before<callout emoji="💡">body`, + want: `before<callout emoji="💡">body`, + }, + }) +} + +func TestConvertToIMMarkdownBlockquote(t *testing.T) { + t.Parallel() + + assertIMMarkdownCases(t, []imMarkdownCase{ + { + name: "single paragraph", + input: `<blockquote><p>quote <a href="https://example.com">link</a></p></blockquote>`, + want: "> quote [link](https://example.com)", + }, + { + name: "multiple paragraphs keep line breaks", + input: `<blockquote><p>first</p><p><b>second</b></p></blockquote>`, + want: "> first\n> **second**", + }, + { + name: "nested blockquote keeps nested markers", + input: `<blockquote><p>outer</p><blockquote><p>inner</p></blockquote></blockquote>`, + want: "> outer\n> > inner", + }, + { + name: "blank line keeps quote marker", + input: "<blockquote>first\n\nsecond</blockquote>", + want: "> first\n>\n> second", + }, + { + name: "empty blockquote", + input: `<blockquote> </blockquote>`, + want: "", + }, + { + name: "plain adjacent paragraphs outside blockquote stay compact", + input: `<p>first</p><p>second</p>`, + want: "firstsecond", + }, + }) +} + +func TestConvertToIMMarkdownParagraphHeadingAndListItemEdges(t *testing.T) { + t.Parallel() + + assertIMMarkdownCases(t, []imMarkdownCase{ + { + name: "empty heading", + input: `<h2> </h2>`, + want: "", + }, + { + name: "empty paragraph", + input: `<p> </p>`, + want: "", + }, + { + name: "top level list item uses seq", + input: "<li seq=\"7\">first\nsecond</li>", + want: "7. first\n second\n", + }, + { + name: "top level empty list item", + input: `<li></li>`, + want: "", + }, + { + name: "unordered list skips non item text and empty items", + input: `<ul>prefix<li>first</li><li> </li><li>second</li></ul>`, + want: "- first\n- second", + }, + { + name: "unclosed list item stops list scan", + input: `<ul><li>first</li><li>second</ul>`, + want: "- first", + }, + }) +} + +func TestConvertToIMMarkdownGridAndColumn(t *testing.T) { + t.Parallel() + + assertIMMarkdownCases(t, []imMarkdownCase{ + { + name: "two columns", + input: `<grid><column width-ratio="0.5">Left</column><column width-ratio="0.5">Right</column></grid>`, + want: "Left\nRight", + }, + { + name: "column converts nested registered tags", + input: `<column><bookmark name="Spec" href="https://example.com"></bookmark></column>`, + want: "[Spec](https://example.com)\n", + }, + { + name: "empty column", + input: `<column> </column>`, + want: "", + }, + { + name: "nested grid", + input: `<grid><column>A</column><column><grid><column>B</column><column>C</column></grid></column></grid>`, + want: "A\nB\nC", + }, + { + name: "grid inside callout", + input: `<callout emoji="📌"><grid><column>A</column><column>B</column></grid></callout>`, + want: "---\n📌 A\nB\n---", + }, + { + name: "adjacent grids do not merge", + input: `<grid><column>A</column></grid><grid><column>B</column></grid>`, + want: "AB", + }, + { + name: "column with nested callout keeps recursive output", + input: `<column><callout emoji="💡">Tip</callout></column>`, + want: "---\n💡 Tip\n---\n", + }, + { + name: "missing closing grid is preserved", + input: `<grid><column>A</column>`, + want: `<grid><column>A</column>`, + }, + }) +} + +func TestConvertToIMMarkdownTable(t *testing.T) { + t.Parallel() + + assertIMMarkdownCases(t, []imMarkdownCase{ + { + name: "basic table", + input: `<table><tr><th>A</th><th>B</th></tr><tr><td>1</td><td>2</td></tr></table>`, + want: "| A | B |\n| - | - |\n| 1 | 2 |", + }, + { + name: "table strips attrs and preserves cell line break", + input: `<table><tr><th vertical-align="top">A</th><th>B</th></tr><tr><td rowspan="2">1</td><td><b>two</b><br/>lines</td></tr></table>`, + want: "| A | B |\n| - | - |\n| 1 | **two**<br>lines |", + }, + { + name: "table escapes pipe", + input: `<table><tr><th>A|B</th></tr><tr><td>x|y</td></tr></table>`, + want: "| A\\|B |\n| - |\n| x\\|y |", + }, + { + name: "table pads ragged rows", + input: `<table><tr><th>A</th><th>B</th></tr><tr><td>1</td></tr></table>`, + want: "| A | B |\n| - | - |\n| 1 | |", + }, + { + name: "table converts nested cite", + input: `<table><tr><th>User</th></tr><tr><td><cite type="user" user-id="ou_1" user-name="Alice"></cite></td></tr></table>`, + want: "| User |\n| - |\n| <at user_id=\"ou_1\">Alice</at> |", + }, + { + name: "table converts nested bookmark and sheet", + input: `<table><tr><th>Link</th><th>Sheet</th></tr><tr><td><bookmark name="Spec" href="https://example.com"></bookmark></td><td><sheet token="sht_1" sheet-id="S1"></sheet></td></tr></table>`, + want: "| Link | Sheet |\n| - | - |\n| [Spec](https://example.com) | [sheet S1](https://larkoffice.com/sheets/sht_1) |", + }, + { + name: "table strips nested unknown html but preserves text", + input: `<table><tr><th>A</th></tr><tr><td><span color="red">red</span> <u>under</u></td></tr></table>`, + want: "| A |\n| - |\n| red under |", + }, + { + name: "table normalizes markdown hard breaks", + input: "<table><tr><th>A</th></tr><tr><td>line1 \nline2</td></tr></table>", + want: "| A |\n| - |\n| line1<br>line2 |", + }, + { + name: "table cell keeps nested table whole", + input: `<table><tr><th>Outer</th></tr><tr><td>before <table><tr><th>Inner</th></tr><tr><td>x</td></tr></table> after</td></tr></table>`, + want: "| Outer |\n| - |\n| before \\| Inner \\|<br>\\| - \\|<br>\\| x \\| after |", + }, + { + name: "table with only data row treats first row as header", + input: `<table><tr><td>A</td><td>B</td></tr></table>`, + want: "| A | B |\n| - | - |", + }, + { + name: "table without rows falls back to inline code", + input: `<table><tbody></tbody></table>`, + want: "`<table><tbody></tbody></table>`", + }, + { + name: "table row without cells falls back to inline code", + input: `<table><tr></tr></table>`, + want: "`<table><tr></tr></table>`", + }, + { + name: "table self closing row falls back to inline code", + input: `<table><tr/></table>`, + want: "`<table><tr/></table>`", + }, + { + name: "table empty cell stays empty", + input: `<table><tr><td> </td></tr></table>`, + want: "| |\n| - |", + }, + { + name: "missing closing table is preserved", + input: `before<table><tr><td>A</td></tr>`, + want: `before<table><tr><td>A</td></tr>`, + }, + }) +} + +func TestIMMarkdownElementExtractionEdges(t *testing.T) { + t.Parallel() + + bodies := extractIMMarkdownElementBodies(`</tr><tr/> <tr><td>x</td></tr><tr>open`, imMarkdownRowTagRE) + if want := []string{"", "<td>x</td>"}; !reflect.DeepEqual(bodies, want) { + t.Fatalf("extractIMMarkdownElementBodies() = %#v, want %#v", bodies, want) + } + + if _, _, ok := findIMMarkdownElementClosingTag(`<tr><td>x`, len("<tr>"), imMarkdownRowTagRE); ok { + t.Fatal("findIMMarkdownElementClosingTag() found closing tag, want false") + } + + start, end, ok := findIMMarkdownListItemClosingTag(`<li>outer<li/>tail</li>`, len("<li>")) + if !ok { + t.Fatal("findIMMarkdownListItemClosingTag() did not find closing tag") + } + if got, want := `<li>outer<li/>tail</li>`[start:end], "</li>"; got != want { + t.Fatalf("closing tag = %q, want %q", got, want) + } + + if _, _, ok := findIMMarkdownListItemClosingTag(`<li>open`, len("<li>")); ok { + t.Fatal("findIMMarkdownListItemClosingTag() found closing tag, want false") + } + + start, end, ok = findIMMarkdownListItemClosingTag(`<li>outer<li>inner</li>tail</li>`, len("<li>")) + if !ok { + t.Fatal("findIMMarkdownListItemClosingTag() did not find nested closing tag") + } + if got, want := `<li>outer<li>inner</li>tail</li>`[start:end], "</li>"; got != want { + t.Fatalf("nested closing tag = %q, want %q", got, want) + } + + if got := convertIMMarkdownListItems("plain text", false, imMarkdownContext{}); got != "" { + t.Fatalf("convertIMMarkdownListItems() = %q, want empty", got) + } +} + +func TestNormalizeIMMarkdownTableCellStripsUnknownTags(t *testing.T) { + t.Parallel() + + got := normalizeIMMarkdownTableCell(`<span style="x">red</span>`) + if want := "red"; got != want { + t.Fatalf("normalizeIMMarkdownTableCell() = %q, want %q", got, want) + } +} + +func TestConvertToIMMarkdownDiscardTags(t *testing.T) { + t.Parallel() + + assertIMMarkdownCases(t, []imMarkdownCase{ + { + name: "figure discarded", + input: `before<figure view-type="Card">hidden</figure>after`, + want: "beforeafter", + }, + { + name: "figure with source discarded", + input: `<figure view-type="Preview"><source href="https://example.com/a.md"/></figure>`, + want: "", + }, + { + name: "self-closing source discarded", + input: `a<source href="https://example.com/a.md"/>b`, + want: "ab", + }, + { + name: "source name becomes inline code", + input: "a<source name=\"report`v1`.pdf\" href=\"https://example.com/a.md\"/>b", + want: "a``report`v1`.pdf``b", + }, + { + name: "button discarded", + input: `a<button>Click</button>b`, + want: "ab", + }, + { + name: "time discarded", + input: `a<time expire-time="123"></time>b`, + want: "ab", + }, + { + name: "colgroup discarded", + input: `a<colgroup><col width="120"/></colgroup>b`, + want: "ab", + }, + { + name: "col discarded", + input: `a<col width="120"/>b`, + want: "ab", + }, + { + name: "self-closing button discarded", + input: `a<button/>b`, + want: "ab", + }, + { + name: "missing closing discard tag is preserved", + input: `a<figure>hidden`, + want: `a<figure>hidden`, + }, + }) +} + +func TestConvertToIMMarkdownWhiteboard(t *testing.T) { + t.Parallel() + + assertIMMarkdownCases(t, []imMarkdownCase{ + { + name: "paired whiteboard", + input: `<whiteboard token="wb_token"></whiteboard>`, + want: "`<whiteboard token=\"wb_token\"></whiteboard>`", + }, + { + name: "self-closing whiteboard", + input: `<whiteboard token="wb_token"/>`, + want: "`<whiteboard token=\"wb_token\"/>`", + }, + { + name: "whiteboard with backticks", + input: "<whiteboard token=\"`wb`\"></whiteboard>", + want: "``<whiteboard token=\"`wb`\"></whiteboard>``", + }, + { + name: "whiteboard preserves inner text as opaque", + input: `<whiteboard token="wb">not exported</whiteboard>`, + want: "`<whiteboard token=\"wb\">not exported</whiteboard>`", + }, + { + name: "missing closing whiteboard is preserved", + input: `<whiteboard token="wb">`, + want: `<whiteboard token="wb">`, + }, + }) +} + +func TestConvertToIMMarkdownSheet(t *testing.T) { + t.Parallel() + + assertIMMarkdownCasesWithContext(t, imMarkdownContext{baseURL: "https://bytedance.larkoffice.com"}, []imMarkdownCase{ + { + name: "sheet with sheet id", + input: `<sheet token="sht_token" sheet-id="S1"></sheet>`, + want: "[sheet S1](https://bytedance.larkoffice.com/sheets/sht_token)", + }, + { + name: "sheet without sheet id", + input: `<sheet token="sht_token"></sheet>`, + want: "[sheet](https://bytedance.larkoffice.com/sheets/sht_token)", + }, + { + name: "sheet without token falls back to inline code", + input: `<sheet sheet-id="S1"></sheet>`, + want: "`<sheet sheet-id=\"S1\"></sheet>`", + }, + { + name: "self-closing sheet", + input: `<sheet token="sht_token" sheet-id="S1"/>`, + want: "[sheet S1](https://bytedance.larkoffice.com/sheets/sht_token)", + }, + { + name: "sheet token is trimmed", + input: `<sheet token=" sht_token " sheet-id="S1"></sheet>`, + want: "[sheet S1](https://bytedance.larkoffice.com/sheets/sht_token)", + }, + { + name: "sheet inside text", + input: `before <sheet token="sht_token"></sheet> after`, + want: "before [sheet](https://bytedance.larkoffice.com/sheets/sht_token) after", + }, + }) +} + +func TestConvertToIMMarkdownBookmark(t *testing.T) { + t.Parallel() + + assertIMMarkdownCases(t, []imMarkdownCase{ + { + name: "name and href", + input: `<bookmark name="Example" href="https://example.com"></bookmark>`, + want: "[Example](https://example.com)", + }, + { + name: "title fallback", + input: `<bookmark title="Example" href="https://example.com"></bookmark>`, + want: "[Example](https://example.com)", + }, + { + name: "inner text fallback", + input: `<bookmark href="https://example.com">Example</bookmark>`, + want: "[Example](https://example.com)", + }, + { + name: "missing href returns label", + input: `<bookmark name="Example"></bookmark>`, + want: "Example", + }, + { + name: "escaped link label", + input: `<bookmark name="A [B]" href="https://example.com"></bookmark>`, + want: "[A \\[B\\]](https://example.com)", + }, + { + name: "href is percent encoded", + input: `<bookmark name="Spec" href="https://example.com/wiki/A B (draft)?q=x y#frag(1)"></bookmark>`, + want: "[Spec](https://example.com/wiki/A%20B%20%28draft%29?q=x%20y#frag%281%29)", + }, + { + name: "href keeps existing percent escapes", + input: `<bookmark name="Spec" href="https://example.com/wiki/A%20B"></bookmark>`, + want: "[Spec](https://example.com/wiki/A%20B)", + }, + { + name: "href escapes invalid percent and unicode", + input: `<bookmark name="Spec" href="https://example.com/wiki/研发%zz?x=1%"></bookmark>`, + want: "[Spec](https://example.com/wiki/%E7%A0%94%E5%8F%91%25zz?x=1%25)", + }, + { + name: "href escapes markdown delimiter bytes", + input: "<bookmark name=\"Spec\" href=\"https://example.com/a<b>|c`d\"></bookmark>", + want: "[Spec](https://example.com/a%3Cb%3E%7Cc%60d)", + }, + { + name: "inner registered tag fallback", + input: `<bookmark href="https://example.com"><cite type="user" user-id="ou_1" user-name="Alice"></cite></bookmark>`, + want: "[Alice](https://example.com)", + }, + { + name: "href fallback as label", + input: `<bookmark href="https://example.com"></bookmark>`, + want: "[https://example.com](https://example.com)", + }, + { + name: "self-closing bookmark without href", + input: `<bookmark name="Example"/>`, + want: "Example", + }, + }) +} + +func TestConvertToIMMarkdownInlineEdges(t *testing.T) { + t.Parallel() + + assertIMMarkdownCases(t, []imMarkdownCase{ + { + name: "empty strong emphasis and delete", + input: `<b> </b><em> </em><del> </del>`, + want: "", + }, + { + name: "anchor without href returns text", + input: `<a>plain <b>text</b></a>`, + want: "plain **text**", + }, + { + name: "anchor without text falls back to href", + input: `<a href="https://example.com/a b"></a>`, + want: "[https://example.com/a b](https://example.com/a%20b)", + }, + { + name: "latex escapes dollars", + input: `<latex>price=$5</latex>`, + want: "$price=\\$5$", + }, + { + name: "empty latex", + input: `<latex> </latex>`, + want: "", + }, + { + name: "image missing href", + input: `<img alt="A"/>`, + want: "", + }, + { + name: "image uses src and title fallback", + input: `<img src="https://example.com/i 1.png" title="A [img]"/>`, + want: "![A \\[img\\]](https://example.com/i%201.png)", + }, + { + name: "plain fenced code", + input: `<pre><code>plain</code></pre>`, + want: "```\nplain\n```", + }, + { + name: "code inline trims nested markup", + input: `<code><b>x</b></code>`, + want: "`x`", + }, + }) +} + +func TestConvertToIMMarkdownCiteUser(t *testing.T) { + t.Parallel() + + assertIMMarkdownCases(t, []imMarkdownCase{ + { + name: "user id and name", + input: `<cite type="user" user-id="ou_abc" user-name="Alice"></cite>`, + want: `<at user_id="ou_abc">Alice</at>`, + }, + { + name: "open id fallback", + input: `<cite type="user" open-id="ou_open" name="Bob"></cite>`, + want: `<at user_id="ou_open">Bob</at>`, + }, + { + name: "name falls back to user id", + input: `<cite type="user" user-id="ou_abc"></cite>`, + want: `<at user_id="ou_abc">ou_abc</at>`, + }, + { + name: "missing user id returns name", + input: `<cite type="user" user-name="Alice"></cite>`, + want: "Alice", + }, + { + name: "escape at XML", + input: `<cite type="user" user-id="ou_"" user-name="A&B"></cite>`, + want: `<at user_id="ou_"">A&B</at>`, + }, + { + name: "inner text fallback when attrs missing name", + input: `<cite type="user" user-id="ou_abc">Alice</cite>`, + want: `<at user_id="ou_abc">Alice</at>`, + }, + { + name: "self-closing user cite", + input: `<cite type="user" user-id="ou_abc" user-name="Alice"/>`, + want: `<at user_id="ou_abc">Alice</at>`, + }, + }) +} + +func TestConvertToIMMarkdownCiteDoc(t *testing.T) { + t.Parallel() + + assertIMMarkdownCasesWithContext(t, imMarkdownContext{baseURL: "https://bytedance.larkoffice.com"}, []imMarkdownCase{ + { + name: "doc id to link", + input: `<cite type="doc" doc-id="doc_token" file-type="docx" title="Spec"></cite>`, + want: "[Spec](https://bytedance.larkoffice.com/docx/doc_token)", + }, + { + name: "href wins", + input: `<cite type="doc" href="https://example.com/doc (draft)" title="Spec"></cite>`, + want: "[Spec](https://example.com/doc%20%28draft%29)", + }, + { + name: "default title and file type", + input: `<cite type="doc" token="doc_token"></cite>`, + want: "[document](https://bytedance.larkoffice.com/docx/doc_token)", + }, + { + name: "missing doc id falls back to inline code", + input: `<cite type="doc" title="Spec"></cite>`, + want: "`<cite type=\"doc\" title=\"Spec\"></cite>`", + }, + { + name: "wiki file type link", + input: `<cite type="doc" doc-id="wiki_token" file-type="wiki" title="Wiki"></cite>`, + want: "[Wiki](https://bytedance.larkoffice.com/wiki/wiki_token)", + }, + { + name: "doc title is escaped", + input: `<cite type="doc" doc-id="doc_token" title="A [B]"></cite>`, + want: "[A \\[B\\]](https://bytedance.larkoffice.com/docx/doc_token)", + }, + }) +} + +func TestConvertToIMMarkdownCiteCitation(t *testing.T) { + t.Parallel() + + assertIMMarkdownCases(t, []imMarkdownCase{ + { + name: "inner anchor", + input: `<cite type="citation"><a href="https://example.com/ref">Ref</a></cite>`, + want: "[Ref](https://example.com/ref)", + }, + { + name: "href attr", + input: `<cite type="citation" href="https://example.com/ref" title="Ref"></cite>`, + want: "[Ref](https://example.com/ref)", + }, + { + name: "plain inner fallback", + input: `<cite type="citation">Plain Ref</cite>`, + want: "Plain Ref", + }, + { + name: "inner anchor text strips markup", + input: `<cite type="citation"><a href="https://example.com/ref"><b>Ref</b></a></cite>`, + want: "[Ref](https://example.com/ref)", + }, + { + name: "single quoted inner anchor falls back to href text", + input: `<cite type="citation"><a href='https://example.com/ref'></a></cite>`, + want: "[https://example.com/ref](https://example.com/ref)", + }, + { + name: "href attr falls back to href label", + input: `<cite type="citation" href="https://example.com/ref"></cite>`, + want: "[https://example.com/ref](https://example.com/ref)", + }, + }) +} + +func TestEscapeMarkdownLinkDestinationInvalidUTF8(t *testing.T) { + t.Parallel() + + got := escapeMarkdownLinkDestination(string([]byte{'a', 0xff, 'b'})) + if want := "a%FFb"; got != want { + t.Fatalf("escapeMarkdownLinkDestination() = %q, want %q", got, want) + } +} + +func TestConvertToIMMarkdownCiteUnknown(t *testing.T) { + t.Parallel() + + assertIMMarkdownCases(t, []imMarkdownCase{ + { + name: "unknown paired cite", + input: `<cite type="unknown">x</cite>`, + want: "`<cite type=\"unknown\">x</cite>`", + }, + { + name: "unknown self-closing cite", + input: `<cite type="unknown"/>`, + want: "`<cite type=\"unknown\"/>`", + }, + }) +} + +func TestConvertToIMMarkdownScannerBoundaries(t *testing.T) { + t.Parallel() + + assertIMMarkdownCases(t, []imMarkdownCase{ + { + name: "unknown tag preserved with known child untouched", + input: `<unknown><bookmark name="Spec" href="https://example.com"></bookmark></unknown>`, + want: `<unknown>[Spec](https://example.com)</unknown>`, + }, + { + name: "registered tag attributes single quotes", + input: `<bookmark name='Spec' href='https://example.com'></bookmark>`, + want: "[Spec](https://example.com)", + }, + { + name: "registered tag name with leading text", + input: `alpha<title>Betagamma`, + want: "alpha# Betagamma", + }, + { + name: "xml comment is preserved", + input: `aT`, + want: "a# T", + }, + { + name: "br is preserved", + input: `a
b`, + want: "a
b", + }, + { + name: "malformed attribute still allows handler", + input: `Inner`, + want: "[Inner](https://example.com)", + }, + }) +} + +func TestConvertToIMMarkdownCompositeNesting(t *testing.T) { + t.Parallel() + + assertIMMarkdownCasesWithContext(t, imMarkdownContext{baseURL: "https://tenant.example.com"}, []imMarkdownCase{ + { + name: "callout grid table and resources", + input: `
OwnerDoc
`, + want: "---\n📌 | Owner | Doc |\n| - | - |\n| Alice | [Spec](https://tenant.example.com/docx/doc_1) |\n[sheet S1](https://tenant.example.com/sheets/sht_1)\n---", + }, + { + name: "grid inside table cell", + input: `
Outer
AB
`, + want: "| Outer |\n| - |\n| A
B |", + }, + { + name: "table inside table cell", + input: `
OuterTail
Inner
x
done
`, + want: "| Outer | Tail |\n| - | - |\n| \\| Inner \\|
\\| - \\|
\\| x \\| | done |", + }, + { + name: "bookmark wraps callout fallback text", + input: `Tip`, + want: "[💡 Tip](https://example.com)", + }, + }) +} + +func TestConvertToIMMarkdownUnclosedFragments(t *testing.T) { + t.Parallel() + + assertIMMarkdownCases(t, []imMarkdownCase{ + { + name: "unclosed title preserves nested registered tag", + input: `before<bookmark name="Spec" href="https://example.com"></bookmark>`, + want: `before<title><bookmark name="Spec" href="https://example.com"></bookmark>`, + }, + { + name: "unclosed callout preserves nested registered tag", + input: `before<callout emoji="💡"><bookmark name="Spec" href="https://example.com"></bookmark>`, + want: `before<callout emoji="💡"><bookmark name="Spec" href="https://example.com"></bookmark>`, + }, + { + name: "unclosed grid preserves closed child", + input: `before<grid><column>A</column>`, + want: `before<grid><column>A</column>`, + }, + { + name: "unclosed column preserves nested registered tag", + input: `before<column><bookmark name="Spec" href="https://example.com"></bookmark>`, + want: `before<column><bookmark name="Spec" href="https://example.com"></bookmark>`, + }, + { + name: "unclosed table preserves nested cite", + input: `before<table><tr><td><cite type="user" user-id="ou_1" user-name="Alice"></cite></td></tr>`, + want: `before<table><tr><td><cite type="user" user-id="ou_1" user-name="Alice"></cite></td></tr>`, + }, + { + name: "unclosed figure preserves nested source", + input: `before<figure><source href="https://example.com/a.md"/>`, + want: `before<figure><source href="https://example.com/a.md"/>`, + }, + { + name: "unclosed whiteboard preserves nested registered tag", + input: `before<whiteboard token="wb"><bookmark name="Spec" href="https://example.com"></bookmark>`, + want: `before<whiteboard token="wb"><bookmark name="Spec" href="https://example.com"></bookmark>`, + }, + { + name: "unclosed sheet preserves nested registered tag", + input: `before<sheet token="sht"><bookmark name="Spec" href="https://example.com"></bookmark>`, + want: `before<sheet token="sht"><bookmark name="Spec" href="https://example.com"></bookmark>`, + }, + { + name: "unclosed bookmark preserves nested cite", + input: `before<bookmark href="https://example.com"><cite type="user" user-id="ou_1" user-name="Alice"></cite>`, + want: `before<bookmark href="https://example.com"><cite type="user" user-id="ou_1" user-name="Alice"></cite>`, + }, + { + name: "unclosed cite preserves inner anchor", + input: `before<cite type="citation"><a href="https://example.com/ref">Ref</a>`, + want: `before<cite type="citation"><a href="https://example.com/ref">Ref</a>`, + }, + }) +} + +func TestConvertToIMMarkdownDeepRegisteredContainers(t *testing.T) { + t.Parallel() + + deepGrid := "leaf" + for i := 0; i < 32; i++ { + deepGrid = "<grid><column>" + deepGrid + "</column></grid>" + } + if got := convertToIMMarkdown(deepGrid, imMarkdownContext{}); got != "leaf" { + t.Fatalf("deep grid conversion = %q, want %q", got, "leaf") + } + + deepCallout := "leaf" + for i := 0; i < 16; i++ { + deepCallout = `<callout emoji="💡">` + deepCallout + `</callout>` + } + got := convertToIMMarkdown(deepCallout, imMarkdownContext{}) + if !strings.Contains(got, "leaf") { + t.Fatalf("deep callout conversion missing leaf:\n%s", got) + } + if count := strings.Count(got, "💡"); count != 16 { + t.Fatalf("deep callout emoji count = %d, want 16\n%s", count, got) + } +} + +func TestConvertToIMMarkdownDocumentExpectedTagsAndEscaping(t *testing.T) { + t.Parallel() + + imCtx := imMarkdownContext{baseURL: "https://bytedance.larkoffice.com"} + input := strings.Join([]string{ + `<h1>Roadmap <span text-color="red">Q1</span></h1>`, + `<h7>Deep Heading</h7>`, + `<p>plain<br/>next <b>Bold</b> <em>Italic</em> <del>Gone</del> <u>Under</u> <span background-color="yellow">Plain</span> <a href="https://example.com/a(b)">A [B]</a></p>`, + `<blockquote><p>quote <a type="url-preview" href="https://example.com/card">Card</a></p></blockquote>`, + `<ul><li>first</li><li><b>second</b></li></ul>`, + `<ol><li seq="auto">one</li><li seq="3">three</li></ol>`, + `<pre lang="Go"><code>fmt.Println("hi")` + "\n```" + `</code></pre>`, + `<p><code>` + "`edge`" + `</code> <latex>E=mc^2</latex> <hr/> <img href="https://example.com/i(1).png" alt="A [img]"/></p>`, + `<source name="report` + "`v1`" + `.pdf"/><source href="https://example.com/no-name"/>`, + `<task task-id="task_1"></task><task></task><chat_card chat-id="chat_1"></chat_card><chat_card></chat_card>`, + `<bitable></bitable><base_refer></base_refer><okr></okr><poll></poll><agenda></agenda><folder_manager></folder_manager><wiki_catalog></wiki_catalog><wiki_recent_update></wiki_recent_update><chart_refer_host_perm></chart_refer_host_perm><synced_reference></synced_reference><synced-source></synced-source><mindnote></mindnote>`, + }, "\n") + + want := strings.Join([]string{ + `# Roadmap Q1`, + `###### Deep Heading`, + `plain<br/>next **Bold** *Italic* ~~Gone~~ Under Plain [A \[B\]](https://example.com/a%28b%29)`, + `> quote [Card](https://example.com/card)`, + `- first`, + `- **second**`, + `1. one`, + `3. three`, + "````Go\nfmt.Println(\"hi\")\n```\n````", + "`` `edge` `` $E=mc^2$ --- ![A \\[img\\]](https://example.com/i%281%29.png)", + "``report`v1`.pdf``", + "`任务``群聊卡片`", + "`多维表格``多维表格``OKR`", + }, "\n") + + if got := convertToIMMarkdown(input, imCtx); got != want { + t.Fatalf("convertToIMMarkdown() = %q, want %q", got, want) + } +} + +func TestConvertToIMMarkdownMixedDocumentSmoke(t *testing.T) { + t.Parallel() + + imCtx := imMarkdownContext{baseURL: "https://bytedance.larkoffice.com"} + input := strings.Join([]string{ + `<title>Roadmap`, + `### LeftRight`, + `
AB
1two
lines
`, + ``, + ``, + `Ref`, + ``, + `
`, + }, "\n") + + got := convertToIMMarkdown(input, imCtx) + + for _, want := range []string{ + "# Roadmap", + "### Left", + "Right", + "| A | B |\n| - | - |\n| 1 | **two**
lines |", + `Alice`, + "[Spec](https://bytedance.larkoffice.com/docx/doc_token)", + "[Ref](https://example.com/ref)", + "[sheet S1](https://bytedance.larkoffice.com/sheets/sht_token)", + } { + if !strings.Contains(got, want) { + t.Fatalf("converted content missing %q:\n%s", want, got) + } + } + for _, dropped := range []string{" first\n>\n> second", + }, + { + name: "empty latex", + got: handleIMMarkdownLatex("", " ", nil, ctx), + want: "", + }, + { + name: "image without URL", + got: handleIMMarkdownImage("", "", map[string]string{"alt": "A"}, ctx), + want: "", + }, + { + name: "empty strong", + got: handleIMMarkdownStrong("", " ", nil, ctx), + want: "", + }, + { + name: "empty emphasis", + got: handleIMMarkdownEmphasis("", " ", nil, ctx), + want: "", + }, + { + name: "empty delete", + got: handleIMMarkdownDelete("", " ", nil, ctx), + want: "", + }, + { + name: "anchor without href", + got: handleIMMarkdownAnchor("", "plain", nil, ctx), + want: "**plain**", + }, + { + name: "table skips rows without cells", + got: handleIMMarkdownTable("
", "", nil, ctx), + want: "`
`", + }, + { + name: "empty normalized table cell", + got: normalizeIMMarkdownTableCell(" "), + want: "", + }, + { + name: "plain fenced code uses minimum fence", + got: imMarkdownFencedCode("plain", ""), + want: "```\nplain\n```", + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if tt.got != tt.want { + t.Fatalf("got %q, want %q", tt.got, tt.want) + } + }) + } +} + +func TestIMMarkdownExtractionAndListBreakBranches(t *testing.T) { + t.Parallel() + + rowBodies := extractIMMarkdownElementBodies(`open`, imMarkdownRowTagRE) + if want := []string{""}; !reflect.DeepEqual(rowBodies, want) { + t.Fatalf("extractIMMarkdownElementBodies() = %#v, want %#v", rowBodies, want) + } + + if _, _, ok := findIMMarkdownElementClosingTag(`open`, len(""), imMarkdownRowTagRE); ok { + t.Fatal("findIMMarkdownElementClosingTag() found closing tag, want false") + } + + if got := convertIMMarkdownListItems("", false, imMarkdownContext{}); got != "" { + t.Fatalf("empty list conversion = %q, want empty", got) + } + if got := convertIMMarkdownListItems("
  • open", false, imMarkdownContext{}); got != "" { + t.Fatalf("unclosed list conversion = %q, want empty", got) + } + if _, _, ok := findIMMarkdownListItemClosingTag(`
  • outer
  • inner
  • `, len("
  • ")); ok { + t.Fatal("findIMMarkdownListItemClosingTag() found closing tag for unbalanced nested item") + } +} + +func TestIMMarkdownLinkAndEncodingFallbackBranches(t *testing.T) { + t.Parallel() + + text, href, ok := extractIMMarkdownInnerLink(``) + if !ok { + t.Fatal("extractIMMarkdownInnerLink() ok = false, want true") + } + if text != "https://example.com/ref" || href != "https://example.com/ref" { + t.Fatalf("inner link = (%q, %q), want href fallback", text, href) + } + + if got := escapeMarkdownLinkDestination("a%zz%"); got != "a%25zz%25" { + t.Fatalf("escaped invalid percent = %q, want %q", got, "a%25zz%25") + } + if got := escapeMarkdownLinkDestination("研发"); got != "%E7%A0%94%E5%8F%91" { + t.Fatalf("escaped unicode = %q, want encoded UTF-8 bytes", got) + } + if got := escapeMarkdownLinkDestination(string([]byte{'a', 0xff, 'b'})); got != "a%FFb" { + t.Fatalf("escaped invalid UTF-8 = %q, want %q", got, "a%FFb") + } +} + +type imMarkdownCase struct { + name string + input string + want string +} + +func assertIMMarkdownCases(t *testing.T, cases []imMarkdownCase) { + t.Helper() + assertIMMarkdownCasesWithContext(t, imMarkdownContext{baseURL: "https://larkoffice.com"}, cases) +} + +func assertIMMarkdownCasesWithContext(t *testing.T, imCtx imMarkdownContext, cases []imMarkdownCase) { + t.Helper() + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := convertToIMMarkdown(tt.input, imCtx); got != tt.want { + t.Fatalf("convertToIMMarkdown() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/shortcuts/doc/docs_fetch_v2.go b/shortcuts/doc/docs_fetch_v2.go index b3e4f2fc..16ca133f 100644 --- a/shortcuts/doc/docs_fetch_v2.go +++ b/shortcuts/doc/docs_fetch_v2.go @@ -17,7 +17,7 @@ import ( // v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path. func v2FetchFlags() []common.Flag { return []common.Flag{ - {Name: "doc-format", Desc: "output content format; xml keeps DocxXML structure and optional block ids, markdown is plain export", Default: "xml", Enum: []string{"xml", "markdown"}}, + {Name: "doc-format", Desc: "output content format; xml keeps DocxXML structure and optional block ids, markdown is plain export, im-markdown downgrades residual DocxXML fragments for IM messages", Default: "xml", Enum: []string{"xml", "markdown", "im-markdown"}}, {Name: "detail", Desc: "detail level; simple for reading, with-ids for block references, full for styles and edit metadata", Default: "simple", Enum: []string{"simple", "with-ids", "full"}}, {Name: "lang", Desc: "user cite display language, e.g. en-US, zh-CN, ja-JP"}, {Name: "revision-id", Desc: "document revision id; -1 means latest", Type: "int", Default: "-1"}, @@ -72,6 +72,9 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error { if warning := addFetchDetailDowngradeWarning(runtime, data); warning != "" && runtime.Format == "pretty" { fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning) } + if isIMMarkdownFetch(runtime) { + applyFetchIMMarkdown(data, runtime.Str("doc")) + } runtime.OutFormatRaw(data, nil, func(w io.Writer) { if doc, ok := data["document"].(map[string]interface{}); ok { @@ -85,7 +88,7 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error { func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} { body := map[string]interface{}{ - "format": runtime.Str("doc-format"), + "format": effectiveFetchFormat(runtime), } if v := runtime.Int("revision-id"); v > 0 { body["revision_id"] = v @@ -122,6 +125,14 @@ func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} { return body } +func effectiveFetchFormat(runtime *common.RuntimeContext) string { + format := strings.TrimSpace(runtime.Str("doc-format")) + if format == "im-markdown" { + return "markdown" + } + return format +} + func resolveFetchLang(runtime *common.RuntimeContext) string { if runtime.Changed("lang") { return strings.TrimSpace(runtime.Str("lang")) diff --git a/shortcuts/doc/docs_fetch_v2_test.go b/shortcuts/doc/docs_fetch_v2_test.go index 1210f0ac..63aba4a7 100644 --- a/shortcuts/doc/docs_fetch_v2_test.go +++ b/shortcuts/doc/docs_fetch_v2_test.go @@ -6,9 +6,12 @@ package doc import ( "context" "encoding/json" + "errors" + "reflect" "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" @@ -40,6 +43,23 @@ func TestBuildCreateBodyIncludesSceneFromContext(t *testing.T) { } } +func TestBuildCreateBodyPrependsTitleToContent(t *testing.T) { + t.Parallel() + + runtime := newCreateBodyTestRuntime(context.Background()) + if err := runtime.Cmd.Flags().Set("title", "A & B "); err != nil { + t.Fatalf("set title: %v", err) + } + if err := runtime.Cmd.Flags().Set("content", "## Body"); err != nil { + t.Fatalf("set content: %v", err) + } + + body := buildCreateBody(runtime) + if got, want := body["content"], "A & B <C>\n## Body"; got != want { + t.Fatalf("content = %#v, want %q", got, want) + } +} + func TestBuildUpdateBodyIncludesSceneFromContext(t *testing.T) { t.Parallel() @@ -104,6 +124,369 @@ func TestBuildFetchBodyExplicitBlankLangOmitsLang(t *testing.T) { } } +func TestBuildFetchBodyIncludesRevisionAndFullDetail(t *testing.T) { + t.Parallel() + + runtime := newFetchBodyTestRuntime(context.Background()) + mustSetFetchFlag(t, runtime, "revision-id", "42") + mustSetFetchFlag(t, runtime, "detail", "full") + + body := buildFetchBody(runtime) + if got := body["revision_id"]; got != 42 { + t.Fatalf("revision_id = %#v, want 42", got) + } + exportOption, _ := body["export_option"].(map[string]interface{}) + want := map[string]interface{}{ + "export_block_id": true, + "export_style_attrs": true, + "export_cite_extra_data": true, + } + if !reflect.DeepEqual(exportOption, want) { + t.Fatalf("export_option = %#v, want %#v", exportOption, want) + } +} + +func TestBuildFetchBodyIncludesWithIDsDetail(t *testing.T) { + t.Parallel() + + runtime := newFetchBodyTestRuntime(context.Background()) + mustSetFetchFlag(t, runtime, "detail", "with-ids") + + body := buildFetchBody(runtime) + exportOption, _ := body["export_option"].(map[string]interface{}) + want := map[string]interface{}{ + "export_block_id": true, + } + if !reflect.DeepEqual(exportOption, want) { + t.Fatalf("export_option = %#v, want %#v", exportOption, want) + } +} + +func TestBuildFetchBodyIncludesReadOption(t *testing.T) { + t.Parallel() + + runtime := newFetchBodyTestRuntime(context.Background()) + mustSetFetchFlag(t, runtime, "scope", "section") + mustSetFetchFlag(t, runtime, "start-block-id", "blk_heading") + + body := buildFetchBody(runtime) + want := map[string]interface{}{ + "read_mode": "section", + "start_block_id": "blk_heading", + } + if got := body["read_option"]; !reflect.DeepEqual(got, want) { + t.Fatalf("read_option = %#v, want %#v", got, want) + } +} + +func TestBuildReadOptionModes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setFlags map[string]string + want map[string]interface{} + }{ + { + name: "full omits read option", + setFlags: map[string]string{ + "scope": "full", + }, + want: nil, + }, + { + name: "outline with max depth", + setFlags: map[string]string{ + "scope": "outline", + "max-depth": "3", + }, + want: map[string]interface{}{ + "read_mode": "outline", + "max_depth": "3", + }, + }, + { + name: "range with block ids and context", + setFlags: map[string]string{ + "scope": "range", + "start-block-id": "blk_start", + "end-block-id": "blk_end", + "context-before": "2", + "context-after": "1", + "max-depth": "0", + }, + want: map[string]interface{}{ + "read_mode": "range", + "start_block_id": "blk_start", + "end_block_id": "blk_end", + "context_before": "2", + "context_after": "1", + "max_depth": "0", + }, + }, + { + name: "keyword with query", + setFlags: map[string]string{ + "scope": "keyword", + "keyword": "foo|bar", + "context-before": "1", + }, + want: map[string]interface{}{ + "read_mode": "keyword", + "keyword": "foo|bar", + "context_before": "1", + }, + }, + { + name: "section keeps unlimited depth omitted", + setFlags: map[string]string{ + "scope": "section", + "start-block-id": "blk_heading", + "max-depth": "-1", + }, + want: map[string]interface{}{ + "read_mode": "section", + "start_block_id": "blk_heading", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + runtime := newFetchBodyTestRuntime(context.Background()) + for name, value := range tt.setFlags { + mustSetFetchFlag(t, runtime, name, value) + } + + if got := buildReadOption(runtime); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("buildReadOption() = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestValidateReadModeFlagsRejectsInvalidScopeOptions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setFlags map[string]string + wantParam string + wantParams []string + }{ + { + name: "negative context before", + setFlags: map[string]string{ + "scope": "range", + "start-block-id": "blk_start", + "context-before": "-1", + }, + wantParam: "--context-before", + }, + { + name: "negative context after", + setFlags: map[string]string{ + "scope": "range", + "start-block-id": "blk_start", + "context-after": "-1", + }, + wantParam: "--context-after", + }, + { + name: "max depth below unlimited sentinel", + setFlags: map[string]string{ + "scope": "range", + "start-block-id": "blk_start", + "max-depth": "-2", + }, + wantParam: "--max-depth", + }, + { + name: "range needs boundary", + setFlags: map[string]string{ + "scope": "range", + }, + wantParams: []string{ + "--start-block-id", + "--end-block-id", + }, + }, + { + name: "keyword needs keyword", + setFlags: map[string]string{ + "scope": "keyword", + }, + wantParam: "--keyword", + }, + { + name: "section needs start block", + setFlags: map[string]string{ + "scope": "section", + }, + wantParam: "--start-block-id", + }, + { + name: "unknown scope", + setFlags: map[string]string{ + "scope": "bad", + }, + wantParam: "--scope", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + runtime := newFetchBodyTestRuntime(context.Background()) + for name, value := range tt.setFlags { + mustSetFetchFlag(t, runtime, name, value) + } + + err := validateReadModeFlags(runtime) + if err == nil { + t.Fatal("validateReadModeFlags() succeeded, want error") + } + assertValidationContract(t, err, errs.SubtypeInvalidArgument, tt.wantParam, tt.wantParams...) + }) + } +} + +func TestValidateReadModeFlagsAcceptsValidScopeOptions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setFlags map[string]string + }{ + { + name: "outline", + setFlags: map[string]string{ + "scope": "outline", + }, + }, + { + name: "range with end block", + setFlags: map[string]string{ + "scope": "range", + "end-block-id": "blk_end", + }, + }, + { + name: "keyword with keyword", + setFlags: map[string]string{ + "scope": "keyword", + "keyword": "bug|缺陷", + }, + }, + { + name: "section with start block", + setFlags: map[string]string{ + "scope": "section", + "start-block-id": "blk_heading", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + runtime := newFetchBodyTestRuntime(context.Background()) + for name, value := range tt.setFlags { + mustSetFetchFlag(t, runtime, name, value) + } + + if err := validateReadModeFlags(runtime); err != nil { + t.Fatalf("validateReadModeFlags() error = %v", err) + } + }) + } +} + +func TestValidateFetchV2RejectsInvalidDocAndScope(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setFlags map[string]string + wantParam string + }{ + { + name: "invalid doc", + setFlags: map[string]string{ + "doc": "https://example.com/sheets/sht_token", + }, + wantParam: "--doc", + }, + { + name: "invalid scope", + setFlags: map[string]string{ + "scope": "bad", + }, + wantParam: "--scope", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + runtime := newFetchShortcutTestRuntime(t, "", tt.setFlags) + err := validateFetchV2(context.Background(), runtime) + if err == nil { + t.Fatal("validateFetchV2() succeeded, want error") + } + assertValidationContract(t, err, errs.SubtypeInvalidArgument, tt.wantParam) + }) + } +} + +func TestAddFetchDetailDowngradeWarningNoops(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setFlags map[string]string + }{ + { + name: "xml format", + setFlags: map[string]string{ + "doc-format": "xml", + "detail": "full", + }, + }, + { + name: "markdown simple detail", + setFlags: map[string]string{ + "doc-format": "markdown", + "detail": "simple", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + runtime := newFetchBodyTestRuntime(context.Background()) + for name, value := range tt.setFlags { + mustSetFetchFlag(t, runtime, name, value) + } + + data := map[string]interface{}{} + if got := addFetchDetailDowngradeWarning(runtime, data); got != "" { + t.Fatalf("warning = %q, want empty", got) + } + if _, ok := data["warnings"]; ok { + t.Fatalf("unexpected warnings: %#v", data["warnings"]) + } + }) + } +} + func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) { t.Parallel() @@ -124,10 +507,10 @@ func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) { } } -func TestDocsFetchAPIVersionV1StillUsesV2Endpoint(t *testing.T) { +func TestDocsFetchAPIVersionCompatFlagIsIgnored(t *testing.T) { t.Parallel() - runtime := newFetchShortcutTestRuntime(t, "v1", nil) + runtime := newFetchShortcutTestRuntime(t, "legacy", nil) if err := validateFetchV2(context.Background(), runtime); err != nil { t.Fatalf("validateFetchV2() error = %v", err) } @@ -141,36 +524,54 @@ func TestDocsFetchAPIVersionV1StillUsesV2Endpoint(t *testing.T) { } } +func TestDocsFetchIMMarkdownRequestsMarkdownFromAPI(t *testing.T) { + t.Parallel() + + runtime := newFetchShortcutTestRuntime(t, "", map[string]string{ + "doc-format": "im-markdown", + }) + if err := validateFetchV2(context.Background(), runtime); err != nil { + t.Fatalf("validateFetchV2() error = %v", err) + } + + dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime)) + if got, want := dry.API[0].Body["format"], "markdown"; got != want { + t.Fatalf("dry-run format = %#v, want %q", got, want) + } +} + func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) { t.Parallel() - for _, detail := range []string{"with-ids", "full"} { - t.Run(detail, func(t *testing.T) { - t.Parallel() + for _, format := range []string{"markdown", "im-markdown"} { + for _, detail := range []string{"with-ids", "full"} { + t.Run(format+"/"+detail, func(t *testing.T) { + t.Parallel() - runtime := newFetchShortcutTestRuntime(t, "", map[string]string{ - "doc-format": "markdown", - "detail": detail, + runtime := newFetchShortcutTestRuntime(t, "", map[string]string{ + "doc-format": format, + "detail": detail, + }) + if err := validateFetchV2(context.Background(), runtime); err != nil { + t.Fatalf("validateFetchV2() error = %v", err) + } + + dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime)) + exportOption, _ := dry.API[0].Body["export_option"].(map[string]interface{}) + if exportOption == nil { + t.Fatalf("missing export_option: %#v", dry.API[0].Body) + } + if got := exportOption["export_block_id"]; got != false { + t.Fatalf("export_block_id = %#v, want false after markdown detail downgrade", got) + } + if got := exportOption["export_style_attrs"]; got != false { + t.Fatalf("export_style_attrs = %#v, want false after markdown detail downgrade", got) + } + if got := exportOption["export_cite_extra_data"]; got != false { + t.Fatalf("export_cite_extra_data = %#v, want false after markdown detail downgrade", got) + } }) - if err := validateFetchV2(context.Background(), runtime); err != nil { - t.Fatalf("validateFetchV2() error = %v", err) - } - - dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime)) - exportOption, _ := dry.API[0].Body["export_option"].(map[string]interface{}) - if exportOption == nil { - t.Fatalf("missing export_option: %#v", dry.API[0].Body) - } - if got := exportOption["export_block_id"]; got != false { - t.Fatalf("export_block_id = %#v, want false after markdown detail downgrade", got) - } - if got := exportOption["export_style_attrs"]; got != false { - t.Fatalf("export_style_attrs = %#v, want false after markdown detail downgrade", got) - } - if got := exportOption["export_cite_extra_data"]; got != false { - t.Fatalf("export_cite_extra_data = %#v, want false after markdown detail downgrade", got) - } - }) + } } } @@ -261,6 +662,107 @@ func TestDocsFetchMarkdownDetailDowngradeWarnsInPrettyOutput(t *testing.T) { } } +func TestDocsFetchV2ReturnsAPIError(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-api-error")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docs_ai/v1/documents/doxcnFetchAPIError/fetch", + Body: map[string]interface{}{ + "code": 999999, + "msg": "fetch failed", + }, + }) + + err := mountAndRunDocs(t, DocsFetch, []string{ + "+fetch", + "--doc", "doxcnFetchAPIError", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatal("mountAndRunDocs() succeeded, want API error") + } + var apiErr *errs.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error type = %T, want *errs.APIError (%v)", err, err) + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("ProblemOf() ok = false for %T (%v)", err, err) + } + if p.Category != errs.CategoryAPI { + t.Errorf("category = %q, want %q", p.Category, errs.CategoryAPI) + } + if p.Subtype != errs.SubtypeUnknown { + t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeUnknown) + } + if p.Code != 999999 { + t.Errorf("code = %d, want 999999", p.Code) + } + if p.Message != "fetch failed" { + t.Errorf("message = %q, want %q", p.Message, "fetch failed") + } + if cause := errors.Unwrap(err); cause != nil { + t.Fatalf("unexpected wrapped cause for API response error: %T %v", cause, cause) + } +} + +func TestDocsFetchIMMarkdownConvertsContentInJSONOutput(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-im-markdown")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docs_ai/v1/documents/doxcnFetchIMMarkdown/fetch", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "document": map[string]interface{}{ + "document_id": "doxcnFetchIMMarkdown", + "revision_id": float64(1), + "content": strings.Join([]string{ + `Doc Title`, + `Read **this**.`, + ``, + }, "\n\n"), + }, + }, + }, + }) + + err := mountAndRunDocs(t, DocsFetch, []string{ + "+fetch", + "--doc", "doxcnFetchIMMarkdown", + "--doc-format", "im-markdown", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var envelope map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode output: %v\nraw=%s", err, stdout.String()) + } + data, _ := envelope["data"].(map[string]interface{}) + doc, _ := data["document"].(map[string]interface{}) + content, _ := doc["content"].(string) + for _, want := range []string{ + "# Doc Title", + "---\n💡 Read **this**.\n---", + "[Example](https://example.com)", + } { + if !strings.Contains(content, want) { + t.Fatalf("converted content missing %q:\n%s", want, content) + } + } + if strings.Contains(content, "") || strings.Contains(content, "<callout") || strings.Contains(content, "<bookmark") { + t.Fatalf("converted content still contains downgraded XML tags:\n%s", content) + } +} + func TestDocsFetchRejectsLegacyFlags(t *testing.T) { tests := []struct { name string @@ -291,6 +793,7 @@ func TestDocsFetchRejectsLegacyFlags(t *testing.T) { if err == nil { t.Fatal("expected v2-only validation error") } + assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--offset") for _, want := range tt.want { if !strings.Contains(err.Error(), want) { t.Fatalf("error missing %q: %v", want, err) @@ -316,6 +819,14 @@ func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext { return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil) } +func mustSetFetchFlag(t *testing.T, runtime *common.RuntimeContext, name, value string) { + t.Helper() + + if err := runtime.Cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set %s: %v", name, err) + } +} + func newFetchShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[string]string) *common.RuntimeContext { t.Helper() @@ -351,6 +862,7 @@ func newFetchShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[s func newCreateBodyTestRuntime(ctx context.Context) *common.RuntimeContext { cmd := &cobra.Command{Use: "+create"} cmd.Flags().String("doc-format", "xml", "") + cmd.Flags().String("title", "", "") cmd.Flags().String("content", "<title>hello", "") cmd.Flags().String("parent-token", "", "") cmd.Flags().String("parent-position", "", "") diff --git a/shortcuts/doc/docs_update_test.go b/shortcuts/doc/docs_update_test.go index dd856188..b6b757cb 100644 --- a/shortcuts/doc/docs_update_test.go +++ b/shortcuts/doc/docs_update_test.go @@ -34,8 +34,8 @@ func TestValidCommandsV2(t *testing.T) { } } -func TestDocsUpdateDryRunAcceptsDeprecatedAPIVersionValues(t *testing.T) { - for _, apiVersion := range []string{"v1", "v2"} { +func TestDocsUpdateDryRunIgnoresAPIVersionCompatFlag(t *testing.T) { + for _, apiVersion := range []string{"v1", "v2", "legacy"} { t.Run(apiVersion, func(t *testing.T) { t.Parallel() diff --git a/shortcuts/doc/v2_only.go b/shortcuts/doc/v2_only.go index 1d051516..cb49ea62 100644 --- a/shortcuts/doc/v2_only.go +++ b/shortcuts/doc/v2_only.go @@ -17,15 +17,14 @@ type docsLegacyFlag struct { func docsAPIVersionCompatFlag() common.Flag { return common.Flag{ - Name: "api-version", - Desc: "deprecated compatibility flag; docs shortcuts always use v2, and both v1/v2 are accepted for rollback-safe skill examples", - Default: "v2", + Name: "api-version", + Desc: "deprecated compatibility flag; ignored by docs shortcuts", + Hidden: true, } } func docsCreateLegacyFlags() []docsLegacyFlag { return []docsLegacyFlag{ - {Name: "title", Replacement: "put the title in --content, for example Title"}, {Name: "markdown", Replacement: "use --content with --doc-format markdown"}, {Name: "folder-token", Replacement: "use --parent-token"}, {Name: "wiki-node", Replacement: "use --parent-token"}, @@ -55,7 +54,7 @@ func docsLegacyFlagDefinitions(flags []docsLegacyFlag) []common.Flag { for _, flag := range flags { out = append(out, common.Flag{ Name: flag.Name, - Desc: "deprecated v1 compatibility flag; run `lark-cli skills read lark-doc` for the v2 CLI skill", + Desc: "deprecated compatibility flag; run `lark-cli skills read lark-doc` for the current CLI skill", Hidden: true, }) } @@ -63,12 +62,6 @@ func docsLegacyFlagDefinitions(flags []docsLegacyFlag) []common.Flag { } func validateDocsV2Only(runtime *common.RuntimeContext, shortcut string, legacyFlags []docsLegacyFlag) error { - switch apiVersion := strings.TrimSpace(runtime.Str("api-version")); apiVersion { - case "", "v1", "v2": - default: - return docsV2OnlyError(shortcut, "--api-version is deprecated and only accepts v1 or v2; both values execute the v2 API", "--api-version") - } - var used []string var replacements []string for _, flag := range legacyFlags { diff --git a/shortcuts/doc/v2_only_test.go b/shortcuts/doc/v2_only_test.go index ce216c8a..2ebd6dab 100644 --- a/shortcuts/doc/v2_only_test.go +++ b/shortcuts/doc/v2_only_test.go @@ -11,8 +11,8 @@ import ( "github.com/spf13/cobra" ) -func TestValidateDocsV2OnlyAllowsDefaultAndDeprecatedAPIVersionValues(t *testing.T) { - for _, apiVersion := range []string{"", "v1", "v2"} { +func TestValidateDocsV2OnlyIgnoresAPIVersionValues(t *testing.T) { + for _, apiVersion := range []string{"", "v1", "v2", "v0", "legacy"} { t.Run(apiVersion, func(t *testing.T) { runtime := docsV2OnlyTestRuntime(t, apiVersion, false) if err := validateDocsV2Only(runtime, "+update", []docsLegacyFlag{{Name: "mode", Replacement: "use --command"}}); err != nil { @@ -22,28 +22,6 @@ func TestValidateDocsV2OnlyAllowsDefaultAndDeprecatedAPIVersionValues(t *testing } } -func TestValidateDocsV2OnlyRejectsUnknownAPIVersion(t *testing.T) { - runtime := docsV2OnlyTestRuntime(t, "v0", false) - err := validateDocsV2Only(runtime, "+fetch", nil) - if err == nil { - t.Fatal("expected unknown --api-version to be rejected") - } - for _, want := range []string{ - "docs +fetch is v2-only", - "--api-version is deprecated and only accepts v1 or v2", - "both values execute the v2 API", - "lark-cli skills read lark-doc references/lark-doc-fetch.md", - "lark-cli skills read lark-doc references/lark-doc-xml.md", - "lark-cli skills read lark-doc references/lark-doc-md.md", - "MUST NOT grep/open local SKILL.md files", - "lark-cli docs +fetch --help", - } { - if !strings.Contains(err.Error(), want) { - t.Fatalf("error missing %q: %v", want, err) - } - } -} - func TestValidateDocsV2OnlyRejectsChangedLegacyFlags(t *testing.T) { runtime := docsV2OnlyTestRuntime(t, "", true) err := validateDocsV2Only(runtime, "+update", []docsLegacyFlag{{Name: "mode", Replacement: "use --command"}}) diff --git a/shortcuts/drive/drive_member_add.go b/shortcuts/drive/drive_member_add.go new file mode 100644 index 00000000..69e89772 --- /dev/null +++ b/shortcuts/drive/drive_member_add.go @@ -0,0 +1,686 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "errors" + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// driveMemberAddIDTypes covers every user-facing --member-type value accepted +// by the shortcut. Some values are normalized before hitting the API. +var driveMemberAddIDTypes = []string{ + "email", "openid", "unionid", "openchat", "opendepartmentid", + "groupid", "appid", "wikispaceid", +} + +var driveMemberAddPerms = []string{"view", "edit", "full_access"} +var driveMemberAddPermTypes = []string{"container", "single_page"} +var driveMemberAddWikiSpaceMemberKinds = []string{"wiki_space_member", "wiki_space_viewer", "wiki_space_editor"} + +// driveMemberAddPrefixToType maps ID prefixes to their expected member_type +// for conflict validation when --member-type is provided explicitly. +var driveMemberAddPrefixToType = map[string]string{ + "ou_": "openid", + "on_": "unionid", + "oc_": "openchat", + "od_": "opendepartmentid", +} + +var driveMemberAddURLPathToType = []struct { + Prefix string + Type string +}{ + {"/drive/folder/", "folder"}, + {"/docx/", "docx"}, + {"/doc/", "doc"}, + {"/sheets/", "sheet"}, + {"/base/", "bitable"}, + {"/bitable/", "bitable"}, + {"/wiki/", "wiki"}, + {"/file/", "file"}, + {"/mindnotes/", "mindnote"}, + {"/slides/", "slides"}, + {"/minutes/", "minutes"}, +} + +var driveMemberAddResourceTypes = []string{"docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes"} + +const driveMemberAddBatchLimit = 10 + +// DriveMemberAdd adds a collaborator/member permission to a Drive resource. +var DriveMemberAdd = common.Shortcut{ + Service: "drive", + Command: "+member-add", + Description: "Add a collaborator/member permission to a Drive document, file, folder, or wiki node", + Risk: "high-risk-write", + Scopes: []string{"docs:permission.member:create"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "token", Desc: "target token or document URL; type is auto-inferred from URL path when omitted", Required: true}, + {Name: "type", Desc: "target resource type; required when --token is a bare token"}, + {Name: "member-id", Desc: "collaborator ID; comma-separated for batch (max 10). Interpretation is decided by --member-type", Required: true}, + {Name: "member-type", Desc: "ID type for --member-id; supported: email|openid|unionid|openchat|opendepartmentid|groupid|appid|wikispaceid", Required: true}, + {Name: "member-kind", Desc: "request body type when --member-type=wikispaceid; one of wiki_space_member|wiki_space_viewer|wiki_space_editor"}, + {Name: "perm", Desc: "permission role to grant; defaults to view"}, + {Name: "perm-type", Desc: "wiki permission scope; defaults to container; rejected for non-wiki types"}, + {Name: "need-notification", Type: "bool", Desc: "send an in-app notification after the grant (user identity only)"}, + }, + Tips: []string{ + "Resource type is auto-inferred from URL paths; pass --type when --token is a bare token.", + "Supported --member-type values: email, openid, unionid, openchat, opendepartmentid, groupid, appid, wikispaceid.", + "When --member-type=wikispaceid, pass --member-kind wiki_space_member, wiki_space_viewer, or wiki_space_editor.", + "--member-type is required; if the ID prefix conflicts with --member-type (e.g. ou_xxx with email), the command rejects it.", + "--perm defaults to view (safest); use --dry-run first when granting edit or full_access.", + "For wiki nodes, --perm-type defaults to container (current page and sub-pages), except --member-type=wikispaceid where --member-kind provides the wiki-space role.", + "Department collaborator (--member-type=opendepartmentid) requires --as user; bot identity is not supported for department authorization.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := readDriveMemberAddSpec(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + spec, err := readDriveMemberAddSpec(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + return buildDriveMemberAddDryRun(spec) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + spec, err := readDriveMemberAddSpec(runtime) + if err != nil { + return err + } + + if len(spec.MemberIDs) == 1 { + return executeDriveMemberAddSingle(runtime, spec) + } + return executeDriveMemberAddBatch(runtime, spec) + }, +} + +// driveMemberAddSpec is the normalized request model shared by Validate, +// DryRun, Execute, and output shaping so they all observe the same defaults. +type driveMemberAddSpec struct { + Token string + ResourceType string + MemberIDs []string + MemberType string + // MemberKind is the explicit --member-kind value for member_type=wikispaceid. + MemberKind string + Perm string + PermType string + NeedNotification bool + NotificationSet bool +} + +// DryRunParams builds the preview query string while preserving the semantic +// difference between an omitted notification flag and an explicit false. +func (spec driveMemberAddSpec) DryRunParams() map[string]interface{} { + params := map[string]interface{}{"type": spec.ResourceType} + if spec.NotificationSet { + params["need_notification"] = spec.NeedNotification + } + return params +} + +// APIQueryParams builds the query params for permission.members.create. +func (spec driveMemberAddSpec) APIQueryParams() map[string]interface{} { + params := map[string]interface{}{"type": spec.ResourceType} + if spec.NotificationSet { + params["need_notification"] = strconv.FormatBool(spec.NeedNotification) + } + return params +} + +// buildMemberBody builds a single member object for the request body. +func buildMemberBody(memberID, memberType, wikiSpaceMemberKind, perm, permType string) map[string]interface{} { + body := map[string]interface{}{ + "member_id": memberID, + "member_type": memberType, + "perm": perm, + } + if bodyType := driveMemberAddBodyType(memberType, wikiSpaceMemberKind); bodyType != "" { + body["type"] = bodyType + } + if permType != "" { + body["perm_type"] = permType + } + return body +} + +// readDriveMemberAddSpec parses runtime flags into a normalized request model, +// applying inference, defaults, and cross-field validation in one place. +func readDriveMemberAddSpec(runtime *common.RuntimeContext) (driveMemberAddSpec, error) { + token, resourceType, err := resolveDriveMemberAddTarget(runtime.Str("token"), runtime.Str("type")) + if err != nil { + return driveMemberAddSpec{}, err + } + + // Parse member-id: comma-separated for batch. + rawMemberID := strings.TrimSpace(runtime.Str("member-id")) + if rawMemberID == "" { + return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id is required and cannot be blank").WithParam("--member-id") + } + memberIDs := splitAndTrimMembers(rawMemberID) + if len(memberIDs) == 0 { + return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id is required and must contain at least one non-blank ID").WithParam("--member-id") + } + if len(memberIDs) > driveMemberAddBatchLimit { + return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id accepts at most %d IDs, got %d", driveMemberAddBatchLimit, len(memberIDs)).WithParam("--member-id") + } + if duplicate, first, second, ok := firstDuplicateDriveMemberID(memberIDs); ok { + return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--member-id contains duplicate collaborator ID %q at positions %d and %d; remove duplicates before retrying", + duplicate, first+1, second+1, + ).WithParam("--member-id") + } + + memberType, err := resolveDriveMemberAddMemberType(memberIDs, runtime.Str("member-type")) + if err != nil { + return driveMemberAddSpec{}, err + } + memberKind, err := resolveDriveMemberAddMemberKind(memberType, runtime.Str("member-kind")) + if err != nil { + return driveMemberAddSpec{}, err + } + + // perm: default to view. + perm, err := normalizeDriveMemberAddEnumValue(runtime.Str("perm"), driveMemberAddPerms, "--perm") + if err != nil { + return driveMemberAddSpec{}, err + } + if perm == "" { + perm = "view" + } + + // perm-type: only meaningful for wiki; default container except for wiki-space collaborators. + permType, err := normalizeDriveMemberAddEnumValue(runtime.Str("perm-type"), driveMemberAddPermTypes, "--perm-type") + if err != nil { + return driveMemberAddSpec{}, err + } + if resourceType == "wiki" && memberType == "wikispaceid" { + if runtime.Changed("perm-type") { + return driveMemberAddSpec{}, errs.NewValidationError( + errs.SubtypeInvalidArgument, + "--perm-type is not supported when --member-type=wikispaceid; use --member-kind wiki_space_member|wiki_space_viewer|wiki_space_editor to set the wiki-space role", + ).WithParam("--perm-type") + } + permType = "" + } else if resourceType == "wiki" && permType == "" { + permType = driveMemberAddDefaultPermType(resourceType) + } else if resourceType != "wiki" && runtime.Changed("perm-type") { + return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--perm-type only applies when resource type is wiki; got %q", resourceType).WithParam("--perm-type") + } else if resourceType != "wiki" { + permType = "" + } + + spec := driveMemberAddSpec{ + Token: token, + ResourceType: resourceType, + MemberIDs: memberIDs, + MemberType: memberType, + MemberKind: memberKind, + Perm: perm, + PermType: permType, + NeedNotification: runtime.Bool("need-notification"), + NotificationSet: runtime.Changed("need-notification"), + } + if runtime.As().IsBot() && spec.NotificationSet { + return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--need-notification is only valid with --as user; omit it when using --as bot").WithParam("--need-notification") + } + if runtime.As().IsBot() && spec.MemberType == "opendepartmentid" { + return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-type=opendepartmentid requires --as user; bot identity does not support adding department collaborators").WithParam("--member-type") + } + return spec, nil +} + +// resolveDriveMemberAddTarget extracts (token, type) from a user-supplied +// --token value that may be either a bare token or a full resource URL, plus an +// optional explicit --type. Explicit --type wins over URL inference. +func resolveDriveMemberAddTarget(raw, explicitType string) (token, resourceType string, err error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token") + } + explicitType = strings.ToLower(strings.TrimSpace(explicitType)) + + if strings.Contains(raw, "://") { + parsed, parseErr := url.Parse(raw) + if parseErr != nil || parsed.Hostname() == "" { + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token URL is malformed: %q", raw).WithParam("--token") + } + urlToken, urlType, ok := parseDriveMemberAddResourceURLPath(parsed.Path) + if !ok { + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, + "unsupported URL path %q: expected one of %s followed by a token", + parsed.Path, strings.Join(driveMemberAddSupportedURLPaths(), ", "), + ).WithParam("--token") + } + token = urlToken + if explicitType == "" { + resourceType = urlType + } + } else { + token = raw + } + + if explicitType != "" { + if !isSupportedDriveMemberAddResourceType(explicitType) { + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--type must be one of: %s", strings.Join(driveMemberAddResourceTypes, ", ")).WithParam("--type") + } + resourceType = explicitType + } + + if resourceType == "" { + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, + "--type is required when --token is a bare token; accepted values: %s", + strings.Join(driveMemberAddResourceTypes, ", "), + ).WithParam("--type") + } + return token, resourceType, nil +} + +func driveMemberAddSupportedURLPaths() []string { + paths := make([]string, 0, len(driveMemberAddURLPathToType)) + for _, mapping := range driveMemberAddURLPathToType { + paths = append(paths, mapping.Prefix) + } + return paths +} + +func parseDriveMemberAddResourceURLPath(path string) (token, resourceType string, ok bool) { + for _, mapping := range driveMemberAddURLPathToType { + if !strings.HasPrefix(path, mapping.Prefix) { + continue + } + token := path[len(mapping.Prefix):] + token = strings.TrimRight(token, "/") + if idx := strings.IndexByte(token, '/'); idx >= 0 { + token = token[:idx] + } + token = strings.TrimSpace(token) + if token == "" { + return "", "", false + } + return token, mapping.Type, true + } + return "", "", false +} + +func isSupportedDriveMemberAddResourceType(resourceType string) bool { + switch resourceType { + case "docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes": + return true + default: + return false + } +} + +func resolveDriveMemberAddMemberType(memberIDs []string, explicit string) (string, error) { + var err error + explicit, err = normalizeDriveMemberAddEnumValue(explicit, driveMemberAddIDTypes, "--member-type") + if err != nil { + return "", err + } + if explicit == "" { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-type is required; accepted values: %s", strings.Join(driveMemberAddIDTypes, ", ")).WithParam("--member-type") + } + for i, memberID := range memberIDs { + if expected := inferMemberTypeFromID(memberID); expected != "" && expected != explicit { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, + "member-id[%d] %q prefix implies --member-type %s, but --member-type %s was provided; fix the ID or use the matching member type", + i+1, memberID, expected, explicit, + ).WithParam("--member-id") + } + } + return normalizeDriveMemberAddMemberType(explicit), nil +} + +func resolveDriveMemberAddMemberKind(memberType, raw string) (string, error) { + memberKind, err := normalizeDriveMemberAddEnumValue(raw, driveMemberAddWikiSpaceMemberKinds, "--member-kind") + if err != nil { + return "", err + } + if memberType == "wikispaceid" { + if memberKind == "" { + return "", errs.NewValidationError( + errs.SubtypeInvalidArgument, + "--member-kind is required when --member-type=wikispaceid; allowed: %s", + strings.Join(driveMemberAddWikiSpaceMemberKinds, ", "), + ).WithParam("--member-kind") + } + return memberKind, nil + } + if memberKind != "" { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-kind only applies when --member-type=wikispaceid").WithParam("--member-kind") + } + return "", nil +} + +func normalizeDriveMemberAddMemberType(memberType string) string { + return strings.ToLower(strings.TrimSpace(memberType)) +} + +func normalizeDriveMemberAddEnumValue(raw string, allowed []string, flagName string) (string, error) { + value := strings.TrimSpace(raw) + if value == "" { + return "", nil + } + for _, candidate := range allowed { + if strings.EqualFold(value, candidate) { + return candidate, nil + } + } + return "", errs.NewValidationError( + errs.SubtypeInvalidArgument, + "invalid value %q for %s, allowed: %s", + value, + flagName, + strings.Join(allowed, ", "), + ).WithParam(flagName) +} + +// splitAndTrimMembers splits a comma-separated member-id string and trims whitespace. +func splitAndTrimMembers(raw string) []string { + parts := strings.Split(raw, ",") + var result []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + result = append(result, p) + } + } + return result +} + +func firstDuplicateDriveMemberID(memberIDs []string) (duplicate string, first, second int, ok bool) { + seen := make(map[string]int, len(memberIDs)) + for i, memberID := range memberIDs { + if prev, exists := seen[memberID]; exists { + return memberID, prev, i, true + } + seen[memberID] = i + } + return "", 0, 0, false +} + +// inferMemberTypeFromID returns the expected member_type for a member-id +// based on its prefix, or "" if no prefix matches (e.g. groupid). +func inferMemberTypeFromID(memberID string) string { + memberID = strings.TrimSpace(memberID) + if memberID == "" { + return "" + } + if strings.Contains(memberID, "@") { + return "email" + } + for prefix, mtype := range driveMemberAddPrefixToType { + if strings.HasPrefix(memberID, prefix) { + return mtype + } + } + return "" +} + +// driveMemberAddDefaultPermType returns the default perm_type for a given +// resource type. For wiki nodes, container is the default for regular +// collaborators. Wiki-space collaborators omit perm_type because their role is +// carried by the body type field. +func driveMemberAddDefaultPermType(resourceType string) string { + switch resourceType { + case "wiki": + return "container" + default: + return "" + } +} + +// inferDriveMemberKind derives the request-body collaborator kind from +// member-type for all supported member-type values. +func inferDriveMemberKind(memberType string) string { + switch memberType { + case "email", "openid", "unionid", "userid": + return "user" + case "openchat": + return "chat" + case "opendepartmentid": + return "department" + case "groupid": + return "group" + default: + return "" + } +} + +func driveMemberAddBodyType(memberType, wikiSpaceMemberKind string) string { + if memberType == "wikispaceid" { + return wikiSpaceMemberKind + } + return inferDriveMemberKind(memberType) +} + +// buildDriveMemberAddDryRun renders the exact request preview for --dry-run. +func buildDriveMemberAddDryRun(spec driveMemberAddSpec) *common.DryRunAPI { + if len(spec.MemberIDs) == 1 { + body := buildMemberBody(spec.MemberIDs[0], spec.MemberType, spec.MemberKind, spec.Perm, spec.PermType) + return common.NewDryRunAPI(). + Desc("Add Drive collaborator/member permission"). + POST(fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(spec.Token))). + Params(spec.DryRunParams()). + Body(body) + } + + members := buildDriveMemberAddMemberBodies(spec) + return common.NewDryRunAPI(). + Desc("Batch add Drive collaborator/member permissions"). + POST(fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members/batch_create", validate.EncodePathSegment(spec.Token))). + Params(spec.DryRunParams()). + Body(map[string]interface{}{"members": members}) +} + +// executeDriveMemberAddSingle calls the single-member create API. +func executeDriveMemberAddSingle(runtime *common.RuntimeContext, spec driveMemberAddSpec) error { + fmt.Fprintf(runtime.IO().ErrOut, "Adding Drive member %s (type=%s, perm=%s) to %s %s...\n", + common.MaskToken(spec.MemberIDs[0]), spec.MemberType, spec.Perm, spec.ResourceType, common.MaskToken(spec.Token)) + + body := buildMemberBody(spec.MemberIDs[0], spec.MemberType, spec.MemberKind, spec.Perm, spec.PermType) + data, err := runtime.CallAPITyped( + "POST", + fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(spec.Token)), + spec.APIQueryParams(), + body, + ) + if err != nil { + return err + } + + out := driveMemberAddOutput(spec, spec.MemberIDs[0], common.GetMap(data, "member")) + fmt.Fprintf(runtime.IO().ErrOut, "Added Drive member %s\n", common.MaskToken(common.GetString(out, "member_id"))) + runtime.Out(out, nil) + return nil +} + +// executeDriveMemberAddBatch calls the batch_create API. A successful HTTP/API +// response is treated as complete only when the server returns every requested +// member_id, regardless of response array order. +func executeDriveMemberAddBatch(runtime *common.RuntimeContext, spec driveMemberAddSpec) error { + members := buildDriveMemberAddMemberBodies(spec) + + fmt.Fprintf(runtime.IO().ErrOut, "Adding %d Drive members (type=%s, perm=%s) to %s %s...\n", + len(spec.MemberIDs), spec.MemberType, spec.Perm, spec.ResourceType, common.MaskToken(spec.Token)) + + data, err := runtime.CallAPITyped( + "POST", + fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members/batch_create", validate.EncodePathSegment(spec.Token)), + spec.APIQueryParams(), + map[string]interface{}{"members": members}, + ) + if err != nil { + return wrapDriveMemberAddBatchAPIError(err) + } + + result := buildDriveMemberAddBatchResult(spec, data) + if common.GetBool(result, "partial") { + return runtime.OutPartialFailure(result, nil) + } + + fmt.Fprintf(runtime.IO().ErrOut, "Added %d Drive member(s)\n", result["succeeded_count"]) + runtime.Out(result, nil) + return nil +} + +const ( + driveMemberAddInvalidParameterCode = 1063001 + driveMemberAddInvalidOperationCode = 1063003 +) + +func wrapDriveMemberAddBatchAPIError(err error) error { + var apiErr *errs.APIError + if !errors.As(err, &apiErr) { + return err + } + + wrapped := *apiErr + switch apiErr.Code { + case driveMemberAddInvalidOperationCode: + wrapped.Message = "Drive batch member add failed: one or more requested members may already be collaborators on this resource" + wrapped.Hint = "For batch add, remove members that already have access (especially a bot/app being added again), then retry only the missing collaborators." + case driveMemberAddInvalidParameterCode: + wrapped.Message = "Drive batch member add failed: one or more requested members may be invalid for this resource or identity" + wrapped.Hint = "Check whether each --member-id exists, belongs to the same tenant, and is visible to the current identity; remove invalid members and retry only the valid collaborators." + default: + return err + } + wrapped.Cause = err + return &wrapped +} + +func buildDriveMemberAddMemberBodies(spec driveMemberAddSpec) []map[string]interface{} { + members := make([]map[string]interface{}, len(spec.MemberIDs)) + for i, mid := range spec.MemberIDs { + members[i] = buildMemberBody(mid, spec.MemberType, spec.MemberKind, spec.Perm, spec.PermType) + } + return members +} + +func buildDriveMemberAddBatchResult(spec driveMemberAddSpec, data map[string]interface{}) map[string]interface{} { + rawMembers, _ := data["members"].([]interface{}) + + // Build set of requested IDs for O(1) lookup. + requestedSet := make(map[string]bool, len(spec.MemberIDs)) + for _, id := range spec.MemberIDs { + requestedSet[id] = true + } + + // First pass: build returned map and results array. + // Matching is done by member_id, not by array index, so the server may + // return members in any order without causing false partial_failure. + results := make([]map[string]interface{}, 0, len(rawMembers)) + succeededIDs := make(map[string]bool, len(rawMembers)) + var mismatched []map[string]interface{} + + for _, raw := range rawMembers { + m, ok := raw.(map[string]interface{}) + if !ok { + continue + } + rawMemberID := common.GetString(m, "member_id") + + out := driveMemberAddOutputWithOptions(spec, "", m, false) + results = append(results, out) + + if rawMemberID != "" { + if requestedSet[rawMemberID] { + succeededIDs[rawMemberID] = true + } else { + mismatched = append(mismatched, map[string]interface{}{ + "returned": rawMemberID, + }) + } + } + } + + // Second pass: find requested IDs missing from the response. + missing := make([]string, 0) + for _, memberID := range spec.MemberIDs { + if !succeededIDs[memberID] { + missing = append(missing, memberID) + } + } + + partial := len(results) != len(spec.MemberIDs) || len(missing) > 0 || len(mismatched) > 0 + result := map[string]interface{}{ + "resource_token": spec.Token, + "resource_type": spec.ResourceType, + "requested_count": len(spec.MemberIDs), + "succeeded_count": len(succeededIDs), + "partial": partial, + "members": results, + "missing_member_ids": missing, + } + if len(mismatched) > 0 { + result["mismatched_member_ids"] = mismatched + } + return result +} + +// driveMemberAddOutput flattens the server response into a stable envelope and +// backfills fields from spec when the server omits them. +func driveMemberAddOutput(spec driveMemberAddSpec, fallbackMemberID string, raw map[string]interface{}) map[string]interface{} { + return driveMemberAddOutputWithOptions(spec, fallbackMemberID, raw, true) +} + +func driveMemberAddOutputWithOptions(spec driveMemberAddSpec, fallbackMemberID string, raw map[string]interface{}, allowDefaultMemberID bool) map[string]interface{} { + out := map[string]interface{}{ + "resource_token": spec.Token, + "resource_type": spec.ResourceType, + } + if raw != nil { + for _, key := range []string{"member_id", "member_type", "perm", "type"} { + if v, ok := raw[key]; ok { + out[key] = v + } + } + if spec.ResourceType == "wiki" { + if v, ok := raw["perm_type"]; ok { + out["perm_type"] = v + } + } + } + if common.GetString(out, "member_id") == "" { + if fallbackMemberID == "" && allowDefaultMemberID && len(spec.MemberIDs) > 0 { + fallbackMemberID = spec.MemberIDs[0] + } + if fallbackMemberID != "" { + out["member_id"] = fallbackMemberID + } + } + if common.GetString(out, "member_type") == "" { + out["member_type"] = spec.MemberType + } + if common.GetString(out, "perm") == "" { + out["perm"] = spec.Perm + } + if spec.PermType != "" && common.GetString(out, "perm_type") == "" { + out["perm_type"] = spec.PermType + } + if bodyType := driveMemberAddBodyType(spec.MemberType, spec.MemberKind); bodyType != "" && common.GetString(out, "type") == "" { + out["type"] = bodyType + } + if t := common.GetString(out, "type"); t != "" { + out["member_kind"] = t + } + delete(out, "type") + return out +} diff --git a/shortcuts/drive/drive_member_add_test.go b/shortcuts/drive/drive_member_add_test.go new file mode 100644 index 00000000..66278044 --- /dev/null +++ b/shortcuts/drive/drive_member_add_test.go @@ -0,0 +1,1507 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// ── resolveDriveMemberAddTarget unit tests ────────────────────────────────── + +func TestResolveDriveMemberAddTarget_URLAndBareToken(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + explicit string + wantTok string + wantType string + }{ + {"docx URL", "https://example.feishu.cn/docx/doxTok?from=share", "", "doxTok", "docx"}, + {"folder URL", "https://example.feishu.cn/drive/folder/fldTok", "", "fldTok", "folder"}, + {"wiki URL", "https://example.feishu.cn/wiki/wikTok", "", "wikTok", "wiki"}, + {"mindnotes URL", "https://example.feishu.cn/mindnotes/mndTok", "", "mndTok", "mindnote"}, + {"larkoffice URL", "https://tenant.larkoffice.com/docx/doxTok", "", "doxTok", "docx"}, + {"explicit type overrides URL", "https://example.feishu.cn/docx/doxTok", "wiki", "doxTok", "wiki"}, + {"bare token with explicit docx type", "N83ZduEnHooFswxnVWGcazlLnFf", "docx", "N83ZduEnHooFswxnVWGcazlLnFf", "docx"}, + {"bare token with explicit folder type", "fldToken123", "folder", "fldToken123", "folder"}, + } + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + token, resourceType, err := resolveDriveMemberAddTarget(tt.raw, tt.explicit) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token != tt.wantTok || resourceType != tt.wantType { + t.Fatalf("got token=%q type=%q, want %q/%q", token, resourceType, tt.wantTok, tt.wantType) + } + }) + } +} + +func TestResolveDriveMemberAddTarget_RejectsBareTokenWithoutType(t *testing.T) { + t.Parallel() + + _, _, err := resolveDriveMemberAddTarget("N83ZduEnHooFswxnVWGcazlLnFf", "") + if err == nil || !strings.Contains(err.Error(), "--type is required when --token is a bare token") { + t.Fatalf("expected bare token type-required error, got: %v", err) + } +} + +func TestResolveDriveMemberAddTarget_AcceptsAnyHost(t *testing.T) { + t.Parallel() + + token, resourceType, err := resolveDriveMemberAddTarget("https://google.com/docx/doxTok", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token != "doxTok" { + t.Fatalf("expected token 'doxTok', got %q", token) + } + if resourceType != "docx" { + t.Fatalf("expected resourceType 'docx', got %q", resourceType) + } +} + +func TestResolveDriveMemberAddTarget_RejectsUnsupportedURLPath(t *testing.T) { + t.Parallel() + + _, _, err := resolveDriveMemberAddTarget("https://example.feishu.cn/calendar/calTok", "") + if err == nil || !strings.Contains(err.Error(), "unsupported URL path") { + t.Fatalf("expected unsupported URL path error, got: %v", err) + } +} + +func TestResolveDriveMemberAddTarget_AcceptsMinutesURL(t *testing.T) { + t.Parallel() + + token, resourceType, err := resolveDriveMemberAddTarget("https://example.feishu.cn/minutes/obcnTok", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token != "obcnTok" { + t.Fatalf("expected token 'obcnTok', got %q", token) + } + if resourceType != "minutes" { + t.Fatalf("expected resourceType 'minutes', got %q", resourceType) + } +} + +func TestResolveDriveMemberAddTarget_RejectsInvalidExplicitType(t *testing.T) { + t.Parallel() + + _, _, err := resolveDriveMemberAddTarget("mincnTok", "invalidtype") + if err == nil || !strings.Contains(err.Error(), "--type must be one of") { + t.Fatalf("expected invalid type error, got: %v", err) + } +} + +func TestResolveDriveMemberAddTarget_RejectsEmpty(t *testing.T) { + t.Parallel() + + _, _, err := resolveDriveMemberAddTarget("", "") + if err == nil || !strings.Contains(err.Error(), "--token is required") { + t.Fatalf("expected --token required error, got: %v", err) + } +} + +// ── inferMemberTypeFromID unit tests ──────────────────────────────────────── + +func TestInferMemberTypeFromID(t *testing.T) { + t.Parallel() + + tests := []struct { + memberID string + want string + }{ + {"ou_xxx", "openid"}, + {"on_xxx", "unionid"}, + {"oc_xxx", "openchat"}, + {"od_xxx", "opendepartmentid"}, + {"user@example.com", "email"}, + {"ambiguous", ""}, + {"", ""}, + } + for _, tt := range tests { + got := inferMemberTypeFromID(tt.memberID) + if got != tt.want { + t.Errorf("inferMemberTypeFromID(%q) = %q, want %q", tt.memberID, got, tt.want) + } + } +} + +func TestResolveDriveMemberAddMemberType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + memberIDs []string + explicit string + wantType string + wantErr string + }{ + { + name: "single explicit openid", + memberIDs: []string{"ou_x"}, + explicit: "openid", + wantType: "openid", + }, + { + name: "explicit openchat", + memberIDs: []string{"oc_a"}, + explicit: "openchat", + wantType: "openchat", + }, + { + name: "explicit groupid", + memberIDs: []string{"group_1"}, + explicit: "groupid", + wantType: "groupid", + }, + { + name: "explicit appid", + memberIDs: []string{"cli_xxx"}, + explicit: "appid", + wantType: "appid", + }, + { + name: "explicit wikispaceid", + memberIDs: []string{"space_xxx"}, + explicit: "wikispaceid", + wantType: "wikispaceid", + }, + { + name: "missing member-type rejected", + memberIDs: []string{"ou_a"}, + wantErr: "--member-type is required", + }, + { + name: "prefix conflicts with explicit type", + memberIDs: []string{"oc_chat"}, + explicit: "openid", + wantErr: "implies --member-type openchat", + }, + { + name: "email prefix matches explicit email", + memberIDs: []string{"user@example.com"}, + explicit: "email", + wantType: "email", + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + gotType, err := resolveDriveMemberAddMemberType(tt.memberIDs, tt.explicit) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotType != tt.wantType { + t.Fatalf("got type=%q, want %q", gotType, tt.wantType) + } + }) + } +} + +func TestResolveDriveMemberAddMemberKind(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + memberType string + raw string + want string + wantErr string + }{ + { + name: "wikispaceid requires explicit wiki-space kind", + memberType: "wikispaceid", + wantErr: "--member-kind is required when --member-type=wikispaceid", + }, + { + name: "wikispaceid accepts member kind", + memberType: "wikispaceid", + raw: "wiki_space_member", + want: "wiki_space_member", + }, + { + name: "wikispaceid accepts uppercase member kind", + memberType: "wikispaceid", + raw: "WIKI_SPACE_EDITOR", + want: "wiki_space_editor", + }, + { + name: "reject invalid member kind", + memberType: "wikispaceid", + raw: "user", + wantErr: "invalid value \"user\" for --member-kind", + }, + { + name: "reject member kind for other member type", + memberType: "openid", + raw: "wiki_space_viewer", + wantErr: "--member-kind only applies when --member-type=wikispaceid", + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := resolveDriveMemberAddMemberKind(tt.memberType, tt.raw) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("memberKind = %q, want %q", got, tt.want) + } + }) + } +} + +func TestNormalizeDriveMemberAddMemberType(t *testing.T) { + t.Parallel() + + tests := []struct { + in string + want string + }{ + {"openid", "openid"}, + {"groupid", "groupid"}, + {"appid", "appid"}, + {"wikispaceid", "wikispaceid"}, + } + for _, tt := range tests { + if got := normalizeDriveMemberAddMemberType(tt.in); got != tt.want { + t.Fatalf("normalizeDriveMemberAddMemberType(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +// ── splitAndTrimMembers unit tests ────────────────────────────────────────── + +func TestSplitAndTrimMembers(t *testing.T) { + t.Parallel() + + tests := []struct { + raw string + want []string + }{ + {"ou_a", []string{"ou_a"}}, + {"ou_a,ou_b,ou_c", []string{"ou_a", "ou_b", "ou_c"}}, + {" ou_a , ou_b ", []string{"ou_a", "ou_b"}}, + {"ou_a,,ou_b", []string{"ou_a", "ou_b"}}, + } + for _, tt := range tests { + got := splitAndTrimMembers(tt.raw) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("splitAndTrimMembers(%q) = %#v, want %#v", tt.raw, got, tt.want) + } + } +} + +// ── spec body/query construction unit tests ───────────────────────────────── + +func TestDriveMemberAddSpec_BuildsBodyAndQuery(t *testing.T) { + t.Parallel() + + spec := driveMemberAddSpec{ + Token: "doxTok", + ResourceType: "docx", + MemberIDs: []string{"ou_x"}, + MemberType: "openid", + Perm: "edit", + } + if got := spec.DryRunParams(); !reflect.DeepEqual(got, map[string]interface{}{"type": "docx"}) { + t.Fatalf("DryRunParams() = %#v", got) + } + if got := spec.APIQueryParams(); !reflect.DeepEqual(got, map[string]interface{}{"type": "docx"}) { + t.Fatalf("APIQueryParams() = %#v", got) + } + wantBody := map[string]interface{}{ + "member_id": "ou_x", + "member_type": "openid", + "perm": "edit", + "type": "user", + } + if got := buildMemberBody("ou_x", "openid", "", "edit", ""); !reflect.DeepEqual(got, wantBody) { + t.Fatalf("buildMemberBody() = %#v, want %#v", got, wantBody) + } +} + +func TestDriveMemberAddSpec_BuildsWikiSpaceIDBody(t *testing.T) { + t.Parallel() + + wantBody := map[string]interface{}{ + "member_id": "spc_x", + "member_type": "wikispaceid", + "perm": "view", + "type": "wiki_space_editor", + } + if got := buildMemberBody("spc_x", "wikispaceid", "wiki_space_editor", "view", ""); !reflect.DeepEqual(got, wantBody) { + t.Fatalf("buildMemberBody() = %#v, want %#v", got, wantBody) + } +} + +func TestDriveMemberAddBodyType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + memberType string + wikiSpaceMemberKind string + want string + }{ + {name: "regular user infers body type", memberType: "openid", want: "user"}, + {name: "regular group infers body type", memberType: "groupid", want: "group"}, + {name: "wiki space uses explicit member kind", memberType: "wikispaceid", wikiSpaceMemberKind: "wiki_space_viewer", want: "wiki_space_viewer"}, + {name: "wiki space does not infer fallback type", memberType: "wikispaceid", want: ""}, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := driveMemberAddBodyType(tt.memberType, tt.wikiSpaceMemberKind); got != tt.want { + t.Fatalf("driveMemberAddBodyType() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestDriveMemberAddSpec_HonorsExplicitNotificationFalse(t *testing.T) { + t.Parallel() + + spec := driveMemberAddSpec{ + ResourceType: "docx", + NotificationSet: true, + NeedNotification: false, + } + if got := spec.DryRunParams(); !reflect.DeepEqual(got, map[string]interface{}{"type": "docx", "need_notification": false}) { + t.Fatalf("DryRunParams() = %#v", got) + } + if got := spec.APIQueryParams(); !reflect.DeepEqual(got, map[string]interface{}{"type": "docx", "need_notification": "false"}) { + t.Fatalf("APIQueryParams() = %#v", got) + } +} + +func TestDriveMemberAddOutputBackfillsProvidedMemberID(t *testing.T) { + t.Parallel() + + spec := driveMemberAddSpec{ + Token: "doxTok", + ResourceType: "docx", + MemberIDs: []string{"ou_a", "ou_b"}, + MemberType: "openid", + Perm: "view", + } + out := driveMemberAddOutput(spec, "ou_b", map[string]interface{}{"perm": "view"}) + if out["member_id"] != "ou_b" { + t.Fatalf("member_id = %v, want ou_b", out["member_id"]) + } +} + +func TestDriveMemberAddOutput_BackfillsAppIDMemberType(t *testing.T) { + t.Parallel() + + spec := driveMemberAddSpec{ + Token: "doxTok", + ResourceType: "docx", + MemberIDs: []string{"cli_app_123"}, + MemberType: "appid", + Perm: "view", + } + out := driveMemberAddOutput(spec, "cli_app_123", map[string]interface{}{"perm": "view"}) + if out["member_type"] != "appid" { + t.Fatalf("member_type = %v, want appid", out["member_type"]) + } +} + +func TestDriveMemberAddOutput_OmitsPermTypeForNonWiki(t *testing.T) { + t.Parallel() + + spec := driveMemberAddSpec{ + Token: "doxTok", + ResourceType: "docx", + MemberIDs: []string{"ou_x"}, + MemberType: "openid", + Perm: "view", + } + out := driveMemberAddOutput(spec, "ou_x", map[string]interface{}{ + "member_id": "ou_x", + "member_type": "openid", + "perm": "view", + "perm_type": "container", + "type": "user", + }) + if _, ok := out["perm_type"]; ok { + t.Fatalf("perm_type should be omitted for non-wiki output, got %#v", out["perm_type"]) + } +} + +func TestBuildDriveMemberAddBatchResult_OmitsPermTypeForNonWiki(t *testing.T) { + t.Parallel() + + spec := driveMemberAddSpec{ + Token: "doxTok", + ResourceType: "docx", + MemberIDs: []string{"ou_a", "ou_b"}, + MemberType: "openid", + Perm: "view", + } + result := buildDriveMemberAddBatchResult(spec, map[string]interface{}{ + "members": []interface{}{ + map[string]interface{}{"member_id": "ou_a", "member_type": "openid", "perm": "view", "perm_type": "container", "type": "user"}, + map[string]interface{}{"member_id": "ou_b", "member_type": "openid", "perm": "view", "perm_type": "container", "type": "user"}, + }, + }) + members, ok := result["members"].([]map[string]interface{}) + if !ok { + t.Fatalf("members = %#v, want []map[string]interface{}", result["members"]) + } + for i, member := range members { + if _, exists := member["perm_type"]; exists { + t.Fatalf("members[%d].perm_type should be omitted for non-wiki output, got %#v", i, member["perm_type"]) + } + } +} + +// ── shortcut integration tests ────────────────────────────────────────────── + +func TestDriveMemberAdd_PermDefaultsToView(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "doxcnTok", + "--type", "docx", + "--member-id", "ou_x", + "--member-type", "openid", + "--dry-run", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got struct { + API []struct { + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String()) + } + if got.API[0].Body["perm"] != "view" { + t.Fatalf("perm = %v, want view", got.API[0].Body["perm"]) + } +} + +func TestDriveMemberAdd_RejectsNotificationWithBot(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "doxcnTok", + "--type", "docx", + "--member-id", "ou_x", + "--member-type", "openid", + "--perm", "view", + "--need-notification", + "--as", "bot", + "--yes", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "--need-notification is only valid with --as user") { + t.Fatalf("expected bot notification validation error, got: %v", err) + } +} + +func TestDriveMemberAdd_RejectsDepartmentWithBot(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "doxcnTok", + "--type", "docx", + "--member-id", "od_dept", + "--member-type", "opendepartmentid", + "--perm", "view", + "--as", "bot", + "--yes", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "--member-type=opendepartmentid requires --as user") { + t.Fatalf("expected bot+opendepartmentid validation error, got: %v", err) + } +} + +func TestDriveMemberAdd_AcceptsAmbiguousIDWithExplicitType(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/doxcnTok/members", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "member": map[string]interface{}{ + "member_id": "ambiguous_id", + "member_type": "openid", + "perm": "view", + "type": "user", + }, + }, + }, + } + reg.Register(stub) + + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "doxcnTok", + "--type", "docx", + "--member-id", "ambiguous_id", + "--member-type", "openid", + "--perm", "view", + "--as", "user", + "--yes", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDriveMemberAdd_DryRunAcceptsAppID(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "doxcnTok", + "--type", "docx", + "--member-id", "cli_app_123", + "--member-type", "appid", + "--perm", "view", + "--dry-run", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got struct { + API []struct { + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String()) + } + if got.API[0].Body["member_type"] != "appid" { + t.Fatalf("member_type = %v, want appid", got.API[0].Body["member_type"]) + } + if _, ok := got.API[0].Body["type"]; ok { + t.Fatalf("type = %v, want omitted for appid", got.API[0].Body["type"]) + } +} + +func TestDriveMemberAdd_DryRunAcceptsWikiSpaceID(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "doxcnTok", + "--type", "docx", + "--member-id", "spc_x", + "--member-type", "wikispaceid", + "--member-kind", "wiki_space_viewer", + "--perm", "view", + "--dry-run", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got struct { + API []struct { + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String()) + } + if got.API[0].Body["member_type"] != "wikispaceid" || got.API[0].Body["type"] != "wiki_space_viewer" { + t.Fatalf("body = %#v, want wikispaceid + wiki_space_viewer", got.API[0].Body) + } +} + +func TestDriveMemberAdd_RejectsWikiSpaceIDWithoutMemberKind(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "doxcnTok", + "--type", "docx", + "--member-id", "spc_x", + "--member-type", "wikispaceid", + "--perm", "view", + "--dry-run", + "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "--member-kind is required when --member-type=wikispaceid") { + t.Fatalf("expected wikispaceid member-kind validation error, got: %v", err) + } +} + +func TestDriveMemberAdd_RejectsMemberKindForNonWikiSpaceID(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "doxcnTok", + "--type", "docx", + "--member-id", "ou_x", + "--member-type", "openid", + "--member-kind", "wiki_space_member", + "--perm", "view", + "--dry-run", + "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "--member-kind only applies when --member-type=wikispaceid") { + t.Fatalf("expected non-wikispaceid member-kind validation error, got: %v", err) + } +} + +func TestDriveMemberAdd_RejectsBlankMemberIDList(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "doxcnTok", + "--type", "docx", + "--member-id", ",,,", + "--member-type", "openid", + "--perm", "view", + "--as", "user", + "--yes", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "at least one non-blank ID") { + t.Fatalf("expected blank member-id validation error, got: %v", err) + } +} + +func TestDriveMemberAdd_RejectsBatchOverLimit(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + ids := make([]string, 11) + for i := range ids { + ids[i] = fmt.Sprintf("ou_%d", i) + } + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "doxcnTok", + "--type", "docx", + "--member-id", strings.Join(ids, ","), + "--member-type", "openid", + "--perm", "view", + "--as", "user", + "--yes", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "at most 10") { + t.Fatalf("expected batch limit error, got: %v", err) + } +} + +func TestDriveMemberAdd_RejectsDuplicateBatchMemberIDs(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "doxcnTok", + "--type", "docx", + "--member-id", "ou_a,ou_b,ou_a", + "--member-type", "openid", + "--perm", "view", + "--as", "user", + "--yes", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "duplicate collaborator ID") { + t.Fatalf("expected duplicate member-id validation error, got: %v", err) + } +} + +func TestDriveMemberAdd_DryRunInfersTypeAndDefaultsWikiPermType(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "https://example.feishu.cn/wiki/wikTok?from=share", + "--member-id", "ou_x", + "--member-type", "openid", + "--perm", "full_access", + "--need-notification=false", + "--dry-run", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String()) + } + if len(got.API) != 1 { + t.Fatalf("api count = %d, want 1; stdout=%s", len(got.API), stdout.String()) + } + api := got.API[0] + if api.Method != "POST" || api.URL != "/open-apis/drive/v1/permissions/wikTok/members" { + t.Fatalf("api = %#v", api) + } + if api.Params["type"] != "wiki" || api.Params["need_notification"] != false { + t.Fatalf("params = %#v", api.Params) + } + if api.Body["member_id"] != "ou_x" || api.Body["member_type"] != "openid" || api.Body["perm"] != "full_access" || api.Body["type"] != "user" || api.Body["perm_type"] != "container" { + t.Fatalf("body = %#v", api.Body) + } +} + +func TestDriveMemberAdd_DryRunAcceptsUppercaseEnumsForDocx(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "doxcnTok", + "--type", "DOCX", + "--member-id", "ou_x", + "--member-type", "OPENID", + "--perm", "EDIT", + "--dry-run", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got struct { + API []struct { + Params map[string]interface{} `json:"params"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String()) + } + if got.API[0].Params["type"] != "docx" { + t.Fatalf("params.type = %v, want docx", got.API[0].Params["type"]) + } + if got.API[0].Body["member_type"] != "openid" || got.API[0].Body["perm"] != "edit" { + t.Fatalf("body = %#v, want canonical lowercase enum values", got.API[0].Body) + } +} + +func TestDriveMemberAdd_DryRunAcceptsUppercaseWikiPermType(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "wikcnTok", + "--type", "WIKI", + "--member-id", "ou_x", + "--member-type", "OPENID", + "--perm", "EDIT", + "--perm-type", "CONTAINER", + "--dry-run", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got struct { + API []struct { + Params map[string]interface{} `json:"params"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String()) + } + if got.API[0].Params["type"] != "wiki" { + t.Fatalf("params.type = %v, want wiki", got.API[0].Params["type"]) + } + if got.API[0].Body["member_type"] != "openid" || got.API[0].Body["perm"] != "edit" || got.API[0].Body["perm_type"] != "container" { + t.Fatalf("body = %#v, want canonical lowercase enum values", got.API[0].Body) + } +} + +func TestDriveMemberAdd_RejectsInvalidPermLocallyWithoutGlobalEnum(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "doxcnTok", + "--type", "DOCX", + "--member-id", "ou_x", + "--member-type", "OPENID", + "--perm", "INVALID_EDIT", + "--dry-run", + "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "invalid value \"INVALID_EDIT\" for --perm") { + t.Fatalf("expected local invalid --perm validation error, got: %v", err) + } +} + +func TestDriveMemberAdd_PermTypeRejectedForNonWiki(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "doxcnTok", + "--type", "docx", + "--member-id", "ou_x", + "--member-type", "openid", + "--perm", "edit", + "--perm-type", "single_page", + "--dry-run", + "--as", "user", + }, f, stdout) + if err == nil { + t.Fatalf("expected validation error for --perm-type on non-wiki resource") + } + if got, want := err.Error(), "--perm-type only applies when resource type is wiki"; !strings.Contains(got, want) { + t.Fatalf("error %q does not contain %q", got, want) + } +} + +func TestDriveMemberAdd_DryRunBatch(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "shtcnTok", + "--type", "sheet", + "--member-id", "ou_a,ou_b,ou_c", + "--member-type", "openid", + "--perm", "edit", + "--dry-run", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String()) + } + if len(got.API) != 1 { + t.Fatalf("api count = %d, want 1", len(got.API)) + } + api := got.API[0] + if api.Method != "POST" || api.URL != "/open-apis/drive/v1/permissions/shtcnTok/members/batch_create" { + t.Fatalf("api = %#v", api) + } + members, ok := api.Body["members"].([]interface{}) + if !ok || len(members) != 3 { + t.Fatalf("body.members = %#v, want 3 items", api.Body["members"]) + } +} + +func TestDriveMemberAdd_ExecuteSuccessFlattensMember(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig()) + + var capturedQuery string + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/doxcnTok/members", + OnMatch: func(req *http.Request) { + capturedQuery = req.URL.RawQuery + }, + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "member": map[string]interface{}{ + "member_id": "ou_x", + "member_type": "openid", + "perm": "view", + "type": "user", + }, + }, + }, + } + reg.Register(stub) + + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "doxcnTok", + "--type", "docx", + "--member-id", "ou_x", + "--member-type", "openid", + "--perm", "view", + "--need-notification", + "--as", "user", + "--yes", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var captured map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil { + t.Fatalf("decode captured body: %v\n%s", err, string(stub.CapturedBody)) + } + wantBody := map[string]interface{}{"member_id": "ou_x", "member_type": "openid", "perm": "view", "type": "user"} + if !reflect.DeepEqual(captured, wantBody) { + t.Fatalf("captured body = %#v, want %#v", captured, wantBody) + } + if !strings.Contains(capturedQuery, "type=docx") || !strings.Contains(capturedQuery, "need_notification=true") { + t.Fatalf("captured query = %q", capturedQuery) + } + + data := decodeDriveEnvelope(t, stdout) + if data["resource_token"] != "doxcnTok" || data["resource_type"] != "docx" || + data["member_id"] != "ou_x" || data["member_type"] != "openid" || + data["perm"] != "view" || data["member_kind"] != "user" { + t.Fatalf("flattened output = %#v", data) + } + if !strings.Contains(stderr.String(), "Added Drive member") { + t.Fatalf("stderr = %q, want success log", stderr.String()) + } +} + +func TestDriveMemberAdd_ExecuteBatchSuccess(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig()) + + var capturedQuery string + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/bascnTok/members/batch_create", + OnMatch: func(req *http.Request) { + capturedQuery = req.URL.RawQuery + }, + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "members": []interface{}{ + map[string]interface{}{"member_id": "ou_a", "member_type": "openid", "perm": "view", "type": "user"}, + map[string]interface{}{"member_id": "ou_b", "member_type": "openid", "perm": "view", "type": "user"}, + }, + }, + }, + } + reg.Register(stub) + + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "bascnTok", + "--type", "bitable", + "--member-id", "ou_a,ou_b", + "--member-type", "openid", + "--perm", "view", + "--as", "user", + "--yes", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeDriveEnvelope(t, stdout) + if data["requested_count"] != float64(2) || data["succeeded_count"] != float64(2) || data["partial"] != false { + t.Fatalf("batch output counts = %#v", data) + } + missing, ok := data["missing_member_ids"].([]interface{}) + if !ok || len(missing) != 0 { + t.Fatalf("missing_member_ids = %#v, want empty array", data["missing_member_ids"]) + } + members, ok := data["members"].([]interface{}) + if !ok || len(members) != 2 { + t.Fatalf("members = %#v, want 2", data["members"]) + } + first, _ := members[0].(map[string]interface{}) + second, _ := members[1].(map[string]interface{}) + if first["member_id"] != "ou_a" || second["member_id"] != "ou_b" { + t.Fatalf("members = %#v, want request-order fallback IDs", members) + } + if !strings.Contains(stderr.String(), "Added 2 Drive member(s)") { + t.Fatalf("stderr = %q, want success log", stderr.String()) + } + if !strings.Contains(capturedQuery, "type=bitable") { + t.Fatalf("captured query = %q, want type=bitable for bascn token", capturedQuery) + } +} + +func TestDriveMemberAdd_ExecuteBatchSuccessWithOutOfOrderResponse(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/bascnTok/members/batch_create", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "members": []interface{}{ + map[string]interface{}{"member_id": "ou_b", "member_type": "openid", "perm": "view", "type": "user"}, + map[string]interface{}{"member_id": "ou_a", "member_type": "openid", "perm": "view", "type": "user"}, + }, + }, + }, + } + reg.Register(stub) + + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "bascnTok", + "--type", "bitable", + "--member-id", "ou_a,ou_b", + "--member-type", "openid", + "--perm", "view", + "--as", "user", + "--yes", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeDriveEnvelope(t, stdout) + if data["requested_count"] != float64(2) || data["succeeded_count"] != float64(2) || data["partial"] != false { + t.Fatalf("batch output counts = %#v", data) + } + missing, ok := data["missing_member_ids"].([]interface{}) + if !ok || len(missing) != 0 { + t.Fatalf("missing_member_ids = %#v, want empty array", data["missing_member_ids"]) + } + if _, ok := data["mismatched_member_ids"]; ok { + t.Fatalf("mismatched_member_ids should not be present on success: %#v", data["mismatched_member_ids"]) + } + members, ok := data["members"].([]interface{}) + if !ok || len(members) != 2 { + t.Fatalf("members = %#v, want 2", data["members"]) + } +} + +func TestDriveMemberAdd_ExecuteBatchPartialWhenResponseMissingMember(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/bascnTok/members/batch_create", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "members": []interface{}{ + map[string]interface{}{"member_id": "ou_a", "member_type": "openid", "perm": "view", "type": "user"}, + }, + }, + }, + } + reg.Register(stub) + + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "bascnTok", + "--type", "bitable", + "--member-id", "ou_a,ou_b", + "--member-type", "openid", + "--perm", "view", + "--as", "user", + "--yes", + }, f, stdout) + exitErr := assertDriveMemberAddPartialFailure(t, err) + if exitErr.Code != output.ExitAPI { + t.Fatalf("exit code = %d, want %d (ExitAPI)", exitErr.Code, output.ExitAPI) + } + + // stdout must carry ok:false envelope with structured partial result. + var env struct { + OK bool `json:"ok"` + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("unmarshal stdout envelope: %v\nstdout: %s", err, stdout.String()) + } + if env.OK { + t.Fatalf("ok must be false on partial failure, got ok:true\nstdout: %s", stdout.String()) + } + if v := common.GetInt(env.Data, "succeeded_count"); v != 1 { + t.Fatalf("succeeded_count = %d, want 1", v) + } + if v := common.GetInt(env.Data, "requested_count"); v != 2 { + t.Fatalf("requested_count = %d, want 2", v) + } + if v := common.GetBool(env.Data, "partial"); !v { + t.Fatal("partial must be true") + } +} + +func TestDriveMemberAdd_ExecuteBatchPartialDoesNotInferMissingMemberID(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/bascnTok/members/batch_create", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "members": []interface{}{ + map[string]interface{}{"member_type": "openid", "perm": "view", "type": "user"}, + }, + }, + }, + } + reg.Register(stub) + + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "bascnTok", + "--type", "bitable", + "--member-id", "ou_a,ou_b", + "--member-type", "openid", + "--perm", "view", + "--as", "user", + "--yes", + }, f, stdout) + exitErr := assertDriveMemberAddPartialFailure(t, err) + if exitErr.Code != output.ExitAPI { + t.Fatalf("exit code = %d, want %d (ExitAPI)", exitErr.Code, output.ExitAPI) + } + + var env struct { + OK bool `json:"ok"` + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("unmarshal stdout envelope: %v\nstdout: %s", err, stdout.String()) + } + if env.OK { + t.Fatalf("ok must be false on partial failure, got ok:true\nstdout: %s", stdout.String()) + } + if v := common.GetInt(env.Data, "succeeded_count"); v != 0 { + t.Fatalf("succeeded_count = %d, want 0", v) + } + if v := common.GetInt(env.Data, "requested_count"); v != 2 { + t.Fatalf("requested_count = %d, want 2", v) + } + if v := common.GetBool(env.Data, "partial"); !v { + t.Fatal("partial must be true") + } +} + +func TestDriveMemberAdd_ExecuteBatchPartialWhenMemberIDMismatches(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/bascnTok/members/batch_create", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "members": []interface{}{ + map[string]interface{}{"member_id": "ou_a", "member_type": "openid", "perm": "view", "type": "user"}, + map[string]interface{}{"member_id": "ou_other", "member_type": "openid", "perm": "view", "type": "user"}, + }, + }, + }, + } + reg.Register(stub) + + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "bascnTok", + "--type", "bitable", + "--member-id", "ou_a,ou_b", + "--member-type", "openid", + "--perm", "view", + "--as", "user", + "--yes", + }, f, stdout) + exitErr := assertDriveMemberAddPartialFailure(t, err) + if exitErr.Code != output.ExitAPI { + t.Fatalf("exit code = %d, want %d (ExitAPI)", exitErr.Code, output.ExitAPI) + } + + var env struct { + OK bool `json:"ok"` + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("unmarshal stdout envelope: %v\nstdout: %s", err, stdout.String()) + } + if env.OK { + t.Fatalf("ok must be false on partial failure, got ok:true\nstdout: %s", stdout.String()) + } + if v := common.GetBool(env.Data, "partial"); !v { + t.Fatal("partial must be true") + } + missing, _ := env.Data["missing_member_ids"].([]interface{}) + if len(missing) != 1 || missing[0] != "ou_b" { + t.Fatalf("missing_member_ids = %#v, want [ou_b]", env.Data["missing_member_ids"]) + } + mismatched, _ := env.Data["mismatched_member_ids"].([]interface{}) + if len(mismatched) != 1 { + t.Fatalf("mismatched_member_ids = %#v, want 1 entry", env.Data["mismatched_member_ids"]) + } + mismatchedEntry, _ := mismatched[0].(map[string]interface{}) + if mismatchedEntry["returned"] != "ou_other" { + t.Fatalf("mismatched_member_ids[0].returned = %#v, want ou_other", mismatchedEntry["returned"]) + } +} + +func TestDriveMemberAdd_ExecuteBatchInvalidOperationHasActionableHint(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/bascnTok/members/batch_create", + Body: map[string]interface{}{ + "code": 1063003, + "msg": "Invalid operation", + "data": map[string]interface{}{}, + }, + } + reg.Register(stub) + + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "bascnTok", + "--type", "bitable", + "--member-id", "ou_a,ou_b", + "--member-type", "openid", + "--perm", "view", + "--as", "user", + "--yes", + }, f, stdout) + if err == nil { + t.Fatal("expected API error, got nil") + } + var apiErr *errs.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *errs.APIError, got %T: %v", err, err) + } + if apiErr.Code != 1063003 { + t.Fatalf("code = %d, want 1063003", apiErr.Code) + } + if !strings.Contains(apiErr.Message, "requested members may already be collaborators") { + t.Fatalf("message = %q, want duplicate-permission guidance", apiErr.Message) + } + if !strings.Contains(apiErr.Hint, "retry only the missing collaborators") { + t.Fatalf("hint = %q, want retry guidance", apiErr.Hint) + } +} + +func TestDriveMemberAdd_ExecuteBatchInvalidParameterHasConservativeHint(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/bascnTok/members/batch_create", + Body: map[string]interface{}{ + "code": 1063001, + "msg": "Invalid parameter", + "data": map[string]interface{}{}, + }, + } + reg.Register(stub) + + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "bascnTok", + "--type", "bitable", + "--member-id", "ou_a,ou_missing", + "--member-type", "openid", + "--perm", "view", + "--as", "user", + "--yes", + }, f, stdout) + if err == nil { + t.Fatal("expected API error, got nil") + } + var apiErr *errs.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *errs.APIError, got %T: %v", err, err) + } + if apiErr.Code != 1063001 { + t.Fatalf("code = %d, want 1063001", apiErr.Code) + } + if !strings.Contains(apiErr.Message, "requested members may be invalid") { + t.Fatalf("message = %q, want invalid-member guidance", apiErr.Message) + } + if !strings.Contains(apiErr.Hint, "belongs to the same tenant") || !strings.Contains(apiErr.Hint, "visible to the current identity") { + t.Fatalf("hint = %q, want conservative validation guidance", apiErr.Hint) + } +} + +func TestDriveMemberAdd_ExecuteSuccessAsBot(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + var capturedQuery string + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/wikcnBotTok/members", + OnMatch: func(req *http.Request) { + capturedQuery = req.URL.RawQuery + }, + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "member": map[string]interface{}{ + "member_id": "ou_bot_target", + "member_type": "openid", + "perm": "edit", + "type": "user", + }, + }, + }, + } + reg.Register(stub) + + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "wikcnBotTok", + "--type", "wiki", + "--member-id", "ou_bot_target", + "--member-type", "openid", + "--perm", "edit", + "--as", "bot", + "--yes", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Bot identity should NOT send need_notification in query. + if strings.Contains(capturedQuery, "need_notification") { + t.Fatalf("captured query = %q, want need_notification absent for bot", capturedQuery) + } + + var captured map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil { + t.Fatalf("decode captured body: %v", err) + } + if captured["perm"] != "edit" { + t.Fatalf("captured body perm = %v, want edit", captured["perm"]) + } + + data := decodeDriveEnvelope(t, stdout) + if data["resource_type"] != "wiki" || data["member_kind"] != "user" || data["perm"] != "edit" { + t.Fatalf("flattened output = %#v", data) + } +} + +func assertDriveMemberAddPartialFailure(t *testing.T, err error) *output.PartialFailureError { + t.Helper() + if err == nil { + t.Fatal("expected partial_failure error, got nil") + } + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) { + t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err) + } + return pfErr +} + +func TestDriveMemberAdd_RequiresYesForHighRiskWrite(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "doxcnTok", + "--type", "docx", + "--member-id", "ou_x", + "--member-type", "openid", + "--perm", "view", + "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "requires confirmation") { + t.Fatalf("expected confirmation error, got: %v", err) + } +} diff --git a/shortcuts/drive/shortcuts.go b/shortcuts/drive/shortcuts.go index 3da5fdf5..281f8b9d 100644 --- a/shortcuts/drive/shortcuts.go +++ b/shortcuts/drive/shortcuts.go @@ -30,6 +30,7 @@ func Shortcuts() []common.Shortcut { DriveSync, DriveTaskResult, DriveApplyPermission, + DriveMemberAdd, DriveSecureLabelList, DriveSecureLabelUpdate, DriveSearch, diff --git a/shortcuts/drive/shortcuts_test.go b/shortcuts/drive/shortcuts_test.go index a38a5c0f..85e45ed4 100644 --- a/shortcuts/drive/shortcuts_test.go +++ b/shortcuts/drive/shortcuts_test.go @@ -33,6 +33,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) { "+sync", "+task_result", "+apply-permission", + "+member-add", "+secure-label-list", "+secure-label-update", "+search", diff --git a/shortcuts/minutes/minutes_detail.go b/shortcuts/minutes/minutes_detail.go new file mode 100644 index 00000000..8a7f4f1a --- /dev/null +++ b/shortcuts/minutes/minutes_detail.go @@ -0,0 +1,320 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT +// +// minutes +detail — query minute details with selective artifact flags + +package minutes + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const minutesDetailLogPrefix = "[minutes +detail]" + +// Error codes from the minutes API. +const minutesDetailNoReadPermissionCode = 2091005 + +var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`) + +var scopesDetailMinuteTokens = []string{ + "minutes:minutes.basic:read", + "minutes:minutes.artifacts:read", +} + +// minuteDetailItem represents a single minute detail result. +type minuteDetailItem struct { + MinuteToken string `json:"minute_token"` + Title string `json:"title"` + NoteID string `json:"note_id"` + Artifacts map[string]any `json:"artifacts,omitempty"` + Error string `json:"error,omitempty"` +} + +// fetchMinuteDetail queries a single minute's metadata and selected artifacts. +func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) *minuteDetailItem { + data, err := runtime.CallAPITyped(http.MethodGet, + fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil) + if err != nil { + result := &minuteDetailItem{MinuteToken: minuteToken} + if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode { + result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken) + } else { + result.Error = fmt.Sprintf("failed to query minute: %v", err) + } + return result + } + + minute, _ := data["minute"].(map[string]any) + if minute == nil { + return &minuteDetailItem{MinuteToken: minuteToken, Error: "minute not found"} + } + + result := &minuteDetailItem{MinuteToken: minuteToken} + if v, ok := minute["title"].(string); ok && v != "" { + result.Title = v + } + if v, ok := minute["note_id"].(string); ok && v != "" { + result.NoteID = v + } + + // Fetch artifacts selectively based on flags + needSummary := runtime.Bool("summary") + needTodo := runtime.Bool("todo") + needChapter := runtime.Bool("chapter") + needTranscript := runtime.Bool("transcript") + needKeyword := runtime.Bool("keyword") + + if needSummary || needTodo || needChapter || needTranscript || needKeyword { + artData, err := runtime.CallAPITyped(http.MethodGet, + fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil) + if err != nil { + fmt.Fprintf(runtime.IO().ErrOut, "%s failed to fetch artifacts for %s: %v\n", minutesDetailLogPrefix, minuteToken, err) + } else { + artifacts := make(map[string]any) + if needSummary { + if v, ok := artData["summary"].(string); ok && v != "" { + artifacts["summary"] = v + } else { + artifacts["summary"] = "" + } + } + if needTodo { + if v, ok := artData["minute_todos"].([]any); ok && len(v) > 0 { + artifacts["todos"] = v + } else { + artifacts["todos"] = []any{} + } + } + if needChapter { + if v, ok := artData["minute_chapters"].([]any); ok && len(v) > 0 { + artifacts["chapters"] = v + } else { + artifacts["chapters"] = []any{} + } + } + if needKeyword { + if v, ok := artData["keywords"].([]any); ok && len(v) > 0 { + artifacts["keywords"] = v + } else { + artifacts["keywords"] = []any{} + } + } + if needTranscript { + if v, ok := artData["transcript"].(string); ok && v != "" { + if path := saveDetailTranscript(runtime, minuteToken, result.Title, []byte(v)); path != "" { + artifacts["transcript_file"] = path + } else { + artifacts["transcript_file"] = "" + } + } else { + artifacts["transcript_file"] = "" + } + } + result.Artifacts = artifacts + } + } + + return result +} + +// saveDetailTranscript persists transcript bytes to the canonical artifact path. +// With --output-dir, transcripts land under /artifact--<token>/ +// to mirror the legacy `vc +notes` layout. Otherwise falls back to the default +// ./minutes/<token>/ shared with `minutes +download`. +func saveDetailTranscript(runtime *common.RuntimeContext, minuteToken, title string, content []byte) string { + errOut := runtime.IO().ErrOut + var dirName string + if outDir := runtime.Str("output-dir"); outDir != "" { + dirName = filepath.Join(outDir, sanitizeDetailDirName(title, minuteToken)) + } else { + dirName = common.DefaultMinuteArtifactDir(minuteToken) + } + transcriptPath := filepath.Join(dirName, common.DefaultTranscriptFileName) + + if !runtime.Bool("overwrite") { + if _, statErr := runtime.FileIO().Stat(transcriptPath); statErr == nil { + fmt.Fprintf(errOut, "%s transcript already exists: %s (use --overwrite to replace)\n", minutesDetailLogPrefix, transcriptPath) + return transcriptPath + } + } + + fmt.Fprintf(errOut, "%s writing transcript: %s\n", minutesDetailLogPrefix, transcriptPath) + if _, err := runtime.FileIO().Save(transcriptPath, fileio.SaveOptions{}, bytes.NewReader(content)); err != nil { + fmt.Fprintf(errOut, "%s failed to write transcript: %v\n", minutesDetailLogPrefix, err) + return "" + } + return transcriptPath +} + +// sanitizeDetailDirName generates a filesystem-safe directory name using title +// and minuteToken for uniqueness. Mirrors the layout produced by `vc +notes` +// so both shortcuts write artifacts to identical paths under --output-dir. +func sanitizeDetailDirName(title, minuteToken string) string { + const maxLen = 200 + replacer := strings.NewReplacer( + "/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", + "\"", "_", "<", "_", ">", "_", "|", "_", + "\n", "_", "\r", "_", "\t", "_", "\x00", "_", + ) + safe := replacer.Replace(strings.TrimSpace(title)) + safe = strings.Trim(safe, ".") + if len(safe) > maxLen { + safe = safe[:maxLen] + } + if safe == "" { + return fmt.Sprintf("artifact-%s", minuteToken) + } + return fmt.Sprintf("artifact-%s-%s", safe, minuteToken) +} + +// MinutesDetail queries minute details with selective artifact flags. +var MinutesDetail = common.Shortcut{ + Service: "minutes", + Command: "+detail", + Description: "Query minute details with selective artifact flags (summary, todo, chapter, transcript, keyword)", + Risk: "read", + Scopes: []string{"minutes:minutes.basic:read", "minutes:minutes.artifacts:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "minute-tokens", Desc: "minute tokens, comma-separated for batch", Required: true}, + {Name: "summary", Type: "bool", Desc: "include summary"}, + {Name: "todo", Type: "bool", Desc: "include todos"}, + {Name: "chapter", Type: "bool", Desc: "include chapters"}, + {Name: "transcript", Type: "bool", Desc: "include transcript (saved to file)"}, + {Name: "keyword", Type: "bool", Desc: "include keywords"}, + {Name: "output-dir", Desc: "output directory for transcript files (default: ./minutes/{minute_token}/)"}, + {Name: "overwrite", Type: "bool", Desc: "overwrite existing transcript files"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + tokens := common.SplitCSV(runtime.Str("minute-tokens")) + const maxBatchSize = 50 + if len(tokens) > maxBatchSize { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-tokens: too many tokens (%d), maximum is %d", len(tokens), maxBatchSize).WithParam("--minute-tokens") + } + for _, token := range tokens { + if !validMinuteTokenDetail.MatchString(token) { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid minute token %q: must contain only lowercase alphanumeric characters", token).WithParam("--minute-tokens") + } + } + if outDir := runtime.Str("output-dir"); outDir != "" { + if err := common.ValidateSafePathTyped(runtime.FileIO(), outDir); err != nil { + return err + } + } + // dynamic scope check + result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID)) + if err == nil && result != nil && result.Scopes != "" { + if missing := auth.MissingScopes(result.Scopes, scopesDetailMinuteTokens); len(missing) > 0 { + return errs.NewPermissionError(errs.SubtypeMissingScope, + "missing required scope(s): %s", strings.Join(missing, ", ")). + WithHint("run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")). + WithMissingScopes(missing...). + WithIdentity(string(runtime.As())) + } + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + tokens := runtime.Str("minute-tokens") + d := common.NewDryRunAPI(). + GET("/open-apis/minutes/v1/minutes/{minute_token}"). + Set("minute_tokens", common.SplitCSV(tokens)) + + if runtime.Bool("summary") || runtime.Bool("todo") || runtime.Bool("chapter") || runtime.Bool("transcript") || runtime.Bool("keyword") { + d.GET("/open-apis/minutes/v1/minutes/{minute_token}/artifacts") + } + return d + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + errOut := runtime.IO().ErrOut + minuteTokens := common.SplitCSV(runtime.Str("minute-tokens")) + results := make([]*minuteDetailItem, 0, len(minuteTokens)) + + const batchDelay = 100 * time.Millisecond + fmt.Fprintf(errOut, "%s querying %d minute_token(s)\n", minutesDetailLogPrefix, len(minuteTokens)) + for i, token := range minuteTokens { + if err := ctx.Err(); err != nil { + return err + } + if i > 0 { + time.Sleep(batchDelay) + } + fmt.Fprintf(errOut, "%s querying minute_token=%s ...\n", minutesDetailLogPrefix, token) + results = append(results, fetchMinuteDetail(ctx, runtime, token)) + } + + successCount := 0 + for _, r := range results { + if r.Error == "" { + successCount++ + } + } + fmt.Fprintf(errOut, "%s done: %d total, %d succeeded, %d failed\n", minutesDetailLogPrefix, len(results), successCount, len(results)-successCount) + + if successCount == 0 && len(results) > 0 { + return runtime.OutPartialFailure(map[string]any{"minutes": results}, &output.Meta{Count: len(results)}) + } + + outData := map[string]any{"minutes": results} + runtime.OutFormat(outData, &output.Meta{Count: len(results)}, func(w io.Writer) { + if len(results) == 0 { + fmt.Fprintln(w, "No minutes.") + return + } + var rows []map[string]interface{} + for _, r := range results { + row := map[string]interface{}{"minute_token": r.MinuteToken} + if r.Error != "" { + row["status"] = "FAIL" + row["error"] = r.Error + } else { + row["status"] = "OK" + row["title"] = r.Title + row["note_id"] = r.NoteID + if len(r.Artifacts) > 0 { + var parts []string + if _, ok := r.Artifacts["summary"]; ok { + parts = append(parts, "summary") + } + if _, ok := r.Artifacts["todos"]; ok { + parts = append(parts, "todo") + } + if _, ok := r.Artifacts["chapters"]; ok { + parts = append(parts, "chapter") + } + if _, ok := r.Artifacts["keywords"]; ok { + parts = append(parts, "keyword") + } + if _, ok := r.Artifacts["transcript_file"]; ok { + parts = append(parts, "transcript") + } + if len(parts) > 0 { + row["artifacts"] = strings.Join(parts, ", ") + } + } + } + rows = append(rows, row) + } + output.PrintTable(w, rows) + fmt.Fprintf(w, "\n%d minute(s), %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount) + }) + return nil + }, +} diff --git a/shortcuts/minutes/minutes_detail_test.go b/shortcuts/minutes/minutes_detail_test.go new file mode 100644 index 00000000..b5c8f46d --- /dev/null +++ b/shortcuts/minutes/minutes_detail_test.go @@ -0,0 +1,394 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + "sync" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +var detailWarmOnce sync.Once + +func detailWarmTokenCache(t *testing.T) { + t.Helper() + detailWarmOnce.Do(func() { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/test/v1/warm", + Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}}, + }) + s := common.Shortcut{ + Service: "test", + Command: "+warm", + AuthTypes: []string{"bot"}, + Execute: func(_ context.Context, rctx *common.RuntimeContext) error { + _, err := rctx.CallAPITyped("GET", "/open-apis/test/v1/warm", nil, nil) + return err + }, + } + parent := &cobra.Command{Use: "test"} + s.Mount(parent, f) + parent.SetArgs([]string{"+warm"}) + parent.SilenceErrors = true + parent.SilenceUsage = true + parent.Execute() + }) +} + +func detailMountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error { + t.Helper() + detailWarmTokenCache(t) + parent := &cobra.Command{Use: "minutes"} + s.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +// --------------------------------------------------------------------------- +// Validation tests +// --------------------------------------------------------------------------- + +func detailMinuteGetStub(token, noteID, title string) *httpmock.Stub { + minute := map[string]interface{}{"title": title} + if noteID != "" { + minute["note_id"] = noteID + } + return &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/minutes/v1/minutes/" + token, + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{"minute": minute}, + }, + } +} + +func detailArtifactsStub(token, transcript string) *httpmock.Stub { + data := map[string]interface{}{ + "summary": "Test summary content", + "minute_todos": []interface{}{map[string]interface{}{"content": "Buy milk"}}, + "minute_chapters": []interface{}{map[string]interface{}{"title": "Intro", "summary_content": "Opening"}}, + "keywords": []interface{}{"budget", "roadmap"}, + } + if transcript != "" { + data["transcript"] = transcript + } + return &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/minutes/v1/minutes/" + token + "/artifacts", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": data, + }, + } +} + +func TestDetail_Validation_MissingMinuteTokens(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--as", "user"}, f, nil) + if err == nil { + t.Fatal("expected validation error for missing --minute-tokens") + } +} + +func TestDetail_Validation_InvalidToken(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "INVALID!", "--as", "user"}, f, nil) + if err == nil { + t.Fatal("expected validation error for invalid token") + } + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) + } + if ve.Param != "--minute-tokens" { + t.Errorf("Param = %q, want --minute-tokens", ve.Param) + } +} + +func TestDetail_Validation_BatchLimit(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + tokens := make([]string, 51) + for i := range tokens { + tokens[i] = fmt.Sprintf("tok%d", i) + } + err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", strings.Join(tokens, ","), "--as", "user"}, f, nil) + if err == nil { + t.Fatal("expected batch limit error") + } + if !strings.Contains(err.Error(), "too many tokens") { + t.Errorf("expected 'too many tokens' error, got: %v", err) + } +} + +// --------------------------------------------------------------------------- +// DryRun tests +// --------------------------------------------------------------------------- + +func TestDetail_DryRun(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tok001", "--dry-run", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "/open-apis/minutes/v1/minutes/") { + t.Errorf("dry-run should show minutes API path, got: %s", stdout.String()) + } +} + +func TestDetail_DryRun_WithArtifactFlags(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tok001", "--summary", "--todo", "--dry-run", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "artifacts") { + t.Errorf("dry-run should show artifacts API path when artifact flags are set, got: %s", stdout.String()) + } +} + +// --------------------------------------------------------------------------- +// Execute tests with mocked HTTP +// --------------------------------------------------------------------------- + +func TestDetail_Execute_BasicInfo(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(detailMinuteGetStub("tokbasic", "", "Test Meeting")) + + err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokbasic", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var resp map[string]any + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + data, _ := resp["data"].(map[string]any) + minutes, _ := data["minutes"].([]any) + if len(minutes) != 1 { + t.Fatalf("expected 1 minute, got %d", len(minutes)) + } + m, _ := minutes[0].(map[string]any) + if m["minute_token"] != "tokbasic" { + t.Errorf("minute_token = %v, want tokbasic", m["minute_token"]) + } + if m["title"] != "Test Meeting" { + t.Errorf("title = %v, want Test Meeting", m["title"]) + } + noteID, hasNoteID := m["note_id"] + if !hasNoteID { + t.Error("note_id should always be present in output (even when empty)") + } + if noteID != "" { + t.Errorf("note_id should be empty string when minute has no note_id, got %v", noteID) + } +} + +func TestDetail_Execute_WithSummaryAndTodo(t *testing.T) { + chdirForDetailTest(t) + + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(detailMinuteGetStub("tokart", "note_art", "Artifact Meeting")) + reg.Register(detailArtifactsStub("tokart", "")) + + err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokart", "--summary", "--todo", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var resp map[string]any + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + data, _ := resp["data"].(map[string]any) + minutes, _ := data["minutes"].([]any) + if len(minutes) != 1 { + t.Fatalf("expected 1 minute, got %d", len(minutes)) + } + m, _ := minutes[0].(map[string]any) + if m["note_id"] != "note_art" { + t.Errorf("note_id = %v, want note_art", m["note_id"]) + } + arts, _ := m["artifacts"].(map[string]any) + if arts == nil { + t.Fatal("expected artifacts to be present") + } + if _, ok := arts["summary"]; !ok { + t.Error("expected summary in artifacts") + } + if _, ok := arts["todos"]; !ok { + t.Error("expected todos in artifacts") + } + // chapter and keywords should NOT be present since flags not set + if _, ok := arts["chapters"]; ok { + t.Error("chapters should not be present when --chapter not set") + } + if _, ok := arts["keywords"]; ok { + t.Error("keywords should not be present when --keyword not set") + } +} + +func TestDetail_Execute_NoArtifactFlags(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(detailMinuteGetStub("toknoart", "", "No Artifacts")) + + err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "toknoart", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var resp map[string]any + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + data, _ := resp["data"].(map[string]any) + minutes, _ := data["minutes"].([]any) + if len(minutes) != 1 { + t.Fatalf("expected 1 minute, got %d", len(minutes)) + } + m, _ := minutes[0].(map[string]any) + if _, ok := m["artifacts"]; ok { + t.Error("artifacts should not be present when no artifact flags set") + } +} + +func TestDetail_Execute_Transcript(t *testing.T) { + chdirForDetailTest(t) + + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(detailMinuteGetStub("toktrans", "", "Transcript Meeting")) + reg.Register(detailArtifactsStub("toktrans", "speaker1: hello world\n")) + + err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "toktrans", "--transcript", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Check transcript file was saved + wantPath := "minutes/toktrans/transcript.txt" + data, err := os.ReadFile(wantPath) + if err != nil { + t.Fatalf("expected file at %s: %v", wantPath, err) + } + if string(data) != "speaker1: hello world\n" { + t.Errorf("content mismatch: %q", string(data)) + } +} + +func TestDetail_Execute_Transcript_OutputDir(t *testing.T) { + chdirForDetailTest(t) + + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(detailMinuteGetStub("tokod", "", "Output Dir Meeting")) + reg.Register(detailArtifactsStub("tokod", "alice: hi\n")) + + err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokod", "--transcript", "--output-dir", "custom_out", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Mirrors `minutes +detail --output-dir` layout: artifact-<title>-<token>/transcript.txt + wantPath := "custom_out/artifact-Output Dir Meeting-tokod/transcript.txt" + data, err := os.ReadFile(wantPath) + if err != nil { + t.Fatalf("expected file at %s: %v", wantPath, err) + } + if string(data) != "alice: hi\n" { + t.Errorf("content mismatch: %q", string(data)) + } +} + +func TestDetail_Validation_OutputDirEscape(t *testing.T) { + chdirForDetailTest(t) + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tok001", "--output-dir", "../escape", "--as", "user"}, f, nil) + if err == nil { + t.Fatal("expected validation error for escaping output-dir") + } + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) + } +} + +func TestDetail_Execute_MinuteNotFound(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/minutes/v1/minutes/tokbad", + Body: map[string]interface{}{"code": 2091004, "msg": "not found"}, + }) + + err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokbad", "--as", "user"}, f, stdout) + if err == nil { + t.Fatal("expected partial failure error") + } + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) { + t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err) + } +} + +// --------------------------------------------------------------------------- +// Pure function tests +// --------------------------------------------------------------------------- + +func TestValidMinuteTokenDetail(t *testing.T) { + tests := []struct { + token string + valid bool + }{ + {"abc123", true}, + {"obcnmgn1429t5xt9j82i1p3h", true}, + {"INVALID!", false}, + {"has-space", false}, + {"", false}, + } + for _, tt := range tests { + got := validMinuteTokenDetail.MatchString(tt.token) + if got != tt.valid { + t.Errorf("validMinuteTokenDetail(%q) = %v, want %v", tt.token, got, tt.valid) + } + } +} + +// chdirForDetailTest switches cwd to a temp dir for the test. +func chdirForDetailTest(t *testing.T) string { + t.Helper() + orig, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + dir := t.TempDir() + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { os.Chdir(orig) }) + return dir +} diff --git a/shortcuts/minutes/minutes_search.go b/shortcuts/minutes/minutes_search.go index bdaa8c5e..46843e4c 100644 --- a/shortcuts/minutes/minutes_search.go +++ b/shortcuts/minutes/minutes_search.go @@ -184,12 +184,6 @@ func minuteSearchAppLink(item map[string]interface{}) string { return common.GetString(meta, "app_link") } -// minuteSearchAvatar extracts the avatar URL from a search result item. -func minuteSearchAvatar(item map[string]interface{}) string { - meta := common.GetMap(item, "meta_data") - return common.GetString(meta, "avatar") -} - // buildMinuteSearchRows converts API items into pretty output rows. func buildMinuteSearchRows(items []interface{}) []map[string]interface{} { rows := make([]map[string]interface{}, 0, len(items)) @@ -203,12 +197,27 @@ func buildMinuteSearchRows(items []interface{}) []map[string]interface{} { "display_info": common.TruncateStr(minuteSearchDisplayInfo(item), 40), "description": common.TruncateStr(minuteSearchDescription(item), 40), "app_link": common.TruncateStr(minuteSearchAppLink(item), 80), - "avatar": common.TruncateStr(minuteSearchAvatar(item), 80), }) } return rows } +// stripAvatarFromItems removes meta_data.avatar from each search item in place +// so the structured output does not surface avatars to AI agents. +func stripAvatarFromItems(items []interface{}) { + for _, raw := range items { + item, _ := raw.(map[string]interface{}) + if item == nil { + continue + } + meta, _ := item["meta_data"].(map[string]interface{}) + if meta == nil { + continue + } + delete(meta, "avatar") + } +} + // MinutesSearch searches minutes by keyword, owners, participants, and time range. var MinutesSearch = common.Shortcut{ Service: "minutes", @@ -298,13 +307,13 @@ var MinutesSearch = common.Shortcut{ } items := minuteSearchItems(data) + stripAvatarFromItems(items) hasMore, _ := data["has_more"].(bool) pageToken, _ := data["page_token"].(string) rows := buildMinuteSearchRows(items) outData := map[string]interface{}{ "items": items, - "total": data["total"], "has_more": data["has_more"], "page_token": data["page_token"], } diff --git a/shortcuts/minutes/minutes_search_test.go b/shortcuts/minutes/minutes_search_test.go index 47ae8772..63b33750 100644 --- a/shortcuts/minutes/minutes_search_test.go +++ b/shortcuts/minutes/minutes_search_test.go @@ -526,7 +526,7 @@ func TestMinutesSearchExecuteRendersRowsAndMoreHint(t *testing.T) { } out := stdout.String() - for _, want := range []string{"minute_1", "周会摘要", "周会纪要", "https://meetings.feishu.cn/minutes/obcn123", "https://p3-lark-file.byteimg.com/img/xxxx.jpg", "next_token", "more available"} { + for _, want := range []string{"minute_1", "周会摘要", "周会纪要", "https://meetings.feishu.cn/minutes/obcn123", "next_token", "more available"} { if !strings.Contains(out, want) { t.Fatalf("output missing %q, got: %s", want, out) } @@ -672,7 +672,6 @@ func TestMinuteSearchFieldExtractors(t *testing.T) { "meta_data": map[string]interface{}{ "description": "周会纪要", "app_link": "https://meetings.feishu.cn/minutes/obcn123", - "avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg", }, } @@ -688,9 +687,6 @@ func TestMinuteSearchFieldExtractors(t *testing.T) { if got := minuteSearchAppLink(item); got != "https://meetings.feishu.cn/minutes/obcn123" { t.Fatalf("minuteSearchAppLink() = %q", got) } - if got := minuteSearchAvatar(item); got != "https://p3-lark-file.byteimg.com/img/xxxx.jpg" { - t.Fatalf("minuteSearchAvatar() = %q", got) - } } // TestMinuteSearchFieldExtractorsFallbacks verifies extractors keep working for alternate sample data. @@ -703,7 +699,6 @@ func TestMinuteSearchFieldExtractorsFallbacks(t *testing.T) { "meta_data": map[string]interface{}{ "description": "回退纪要", "app_link": "https://meetings.feishu.cn/minutes/fallback", - "avatar": "https://p3-lark-file.byteimg.com/img/fallback.jpg", }, } @@ -716,9 +711,6 @@ func TestMinuteSearchFieldExtractorsFallbacks(t *testing.T) { if got := minuteSearchAppLink(item); got != "https://meetings.feishu.cn/minutes/fallback" { t.Fatalf("minuteSearchAppLink() = %q", got) } - if got := minuteSearchAvatar(item); got != "https://p3-lark-file.byteimg.com/img/fallback.jpg" { - t.Fatalf("minuteSearchAvatar() = %q", got) - } } // TestMinuteSearchFieldExtractorsMissingMetaData verifies extractors fall back to empty values without metadata. @@ -739,7 +731,32 @@ func TestMinuteSearchFieldExtractorsMissingMetaData(t *testing.T) { if got := minuteSearchAppLink(item); got != "" { t.Fatalf("minuteSearchAppLink() = %q, want empty", got) } - if got := minuteSearchAvatar(item); got != "" { - t.Fatalf("minuteSearchAvatar() = %q, want empty", got) +} + +// TestStripAvatarFromItems verifies the avatar field is removed from items in place. +func TestStripAvatarFromItems(t *testing.T) { + t.Parallel() + + items := []interface{}{ + map[string]interface{}{ + "token": "minute_1", + "meta_data": map[string]interface{}{ + "description": "周会纪要", + "avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg", + }, + }, + nil, + map[string]interface{}{"token": "minute_no_meta"}, + } + + stripAvatarFromItems(items) + + first, _ := items[0].(map[string]interface{}) + meta, _ := first["meta_data"].(map[string]interface{}) + if _, ok := meta["avatar"]; ok { + t.Fatalf("avatar should be stripped, got meta = %v", meta) + } + if meta["description"] != "周会纪要" { + t.Fatalf("description should be preserved, got %v", meta["description"]) } } diff --git a/shortcuts/minutes/minutes_speaker_replace.go b/shortcuts/minutes/minutes_speaker_replace.go index f0fa649f..716230d3 100644 --- a/shortcuts/minutes/minutes_speaker_replace.go +++ b/shortcuts/minutes/minutes_speaker_replace.go @@ -25,12 +25,13 @@ var MinutesSpeakerReplace = common.Shortcut{ Command: "+speaker-replace", Description: "Replace a speaker in a minute's transcript (rebind from one user to another)", Risk: "write", - Scopes: []string{"minutes:minutes:update"}, + Scopes: []string{"minutes:minutes:readonly", "minutes:minutes:update"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "minute-token", Desc: "minute token", Required: true}, - {Name: "from-user-id", Desc: "speaker to replace, must be an open_id starting with 'ou_'", Required: true}, + {Name: "from-speaker-id", Desc: "speaker to replace: opaque speaker_id from transcript speakerlist API (do not pass display names)"}, + {Name: "from-user-id", Desc: "deprecated: open_id of the speaker to replace; prefer --from-speaker-id", Hidden: true}, {Name: "to-user-id", Desc: "new speaker, must be an open_id starting with 'ou_'", Required: true}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { @@ -41,12 +42,10 @@ var MinutesSpeakerReplace = common.Shortcut{ if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--minute-token") } + fromSpeakerID := strings.TrimSpace(runtime.Str("from-speaker-id")) fromUserID := strings.TrimSpace(runtime.Str("from-user-id")) - if fromUserID == "" { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "--from-user-id is required").WithParam("--from-user-id") - } - if _, err := common.ValidateUserIDTyped("--from-user-id", fromUserID); err != nil { - return err + if fromSpeakerID == "" && fromUserID == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--from-speaker-id is required").WithParam("--from-speaker-id") } toUserID := strings.TrimSpace(runtime.Str("to-user-id")) if toUserID == "" { @@ -55,53 +54,93 @@ var MinutesSpeakerReplace = common.Shortcut{ if _, err := common.ValidateUserIDTyped("--to-user-id", toUserID); err != nil { return err } - if fromUserID == toUserID { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "--from-user-id and --to-user-id must be different").WithParam("--to-user-id") + if fromSpeakerID == "" { + if _, err := common.ValidateUserIDTyped("--from-user-id", fromUserID); err != nil { + return err + } + if fromUserID == toUserID { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--from-user-id and --to-user-id must be different").WithParam("--to-user-id") + } } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { minuteToken := strings.TrimSpace(runtime.Str("minute-token")) - fromUserID := strings.TrimSpace(runtime.Str("from-user-id")) - toUserID := strings.TrimSpace(runtime.Str("to-user-id")) - return common.NewDryRunAPI(). - PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))). - Body(map[string]interface{}{ - "minute_token": minuteToken, - "from_user_id": fromUserID, - "to_user_id": toUserID, - }) + dr := common.NewDryRunAPI() + if strings.TrimSpace(runtime.Str("from-speaker-id")) != "" && strings.TrimSpace(runtime.Str("from-user-id")) == "" { + dr.GET(minuteTranscriptSpeakerlistPath(minuteToken)).Desc("Resolve --from-speaker-id when it is a display name") + } + return dr.PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))). + Body(buildSpeakerReplaceRequestBody(runtime)) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { minuteToken := strings.TrimSpace(runtime.Str("minute-token")) - fromUserID := strings.TrimSpace(runtime.Str("from-user-id")) + fromSpeakerInput := strings.TrimSpace(runtime.Str("from-speaker-id")) toUserID := strings.TrimSpace(runtime.Str("to-user-id")) - body := map[string]interface{}{ - "minute_token": minuteToken, - "from_user_id": fromUserID, - "to_user_id": toUserID, - } - - _, err := runtime.CallAPITyped(http.MethodPut, - fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)), - nil, body) + fromSpeakerID, fromUserID, err := resolveSpeakerReplaceFrom(runtime, minuteToken) if err != nil { - return minutesSpeakerReplaceError(err, minuteToken, fromUserID) + return err } - outData := map[string]interface{}{ - "minute_token": minuteToken, - "from_user_id": fromUserID, - "to_user_id": toUserID, + _, err = runtime.CallAPITyped(http.MethodPut, + fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)), + map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID)) + if err != nil { + return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID)) } - runtime.OutFormat(outData, nil, nil) + runtime.OutFormat(buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil) return nil }, } -func minutesSpeakerReplaceError(err error, minuteToken, fromUserID string) error { +func buildSpeakerReplaceRequestBody(runtime *common.RuntimeContext) map[string]interface{} { + fromSpeakerID := strings.TrimSpace(runtime.Str("from-speaker-id")) + fromUserID := strings.TrimSpace(runtime.Str("from-user-id")) + toUserID := strings.TrimSpace(runtime.Str("to-user-id")) + return buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID) +} + +func buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID string) map[string]interface{} { + body := map[string]interface{}{ + "to_user_id": toUserID, + } + if fromSpeakerID != "" { + body["from_speaker_id"] = fromSpeakerID + } else { + body["from_user_id"] = fromUserID + } + return body +} + +func buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} { + out := map[string]interface{}{ + "minute_token": minuteToken, + "to_user_id": toUserID, + } + if fromSpeakerID != "" { + out["from_speaker_id"] = fromSpeakerID + if fromSpeakerInput != "" && fromSpeakerInput != fromSpeakerID { + out["from_speaker_input"] = fromSpeakerInput + } + } else { + out["from_user_id"] = fromUserID + } + return out +} + +func speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID string) string { + if fromSpeakerInput != "" { + return fromSpeakerInput + } + if fromSpeakerID != "" { + return fromSpeakerID + } + return fromUserID +} + +func minutesSpeakerReplaceError(err error, minuteToken, sourceSpeaker string) error { p, ok := errs.ProblemOf(err) if !ok { return err @@ -112,8 +151,8 @@ func minutesSpeakerReplaceError(err error, minuteToken, fromUserID string) error p.Hint = "Ask the minute owner for minute edit permission" case minutesSpeakerReplaceSpeakerNotFoundCode: p.Subtype = errs.SubtypeNotFound - p.Message = fmt.Sprintf("Speaker not found in minute %q: --from-user-id %q does not match an existing speaker in the transcript.", minuteToken, fromUserID) - p.Hint = "Check --minute-token and --from-user-id. Use an open_id for a speaker that appears in the minute transcript, then retry." + p.Message = fmt.Sprintf("Speaker not found in minute %q: source speaker %q does not match an existing speaker in the transcript.", minuteToken, sourceSpeaker) + p.Hint = "Verify --from-speaker-id is a valid speaker_id or display name from the transcript; if multiple speakers share the same name, pass the exact speaker_id after reviewing their utterances." } return err } diff --git a/shortcuts/minutes/minutes_speaker_replace_test.go b/shortcuts/minutes/minutes_speaker_replace_test.go index 78944b2d..5b028a24 100644 --- a/shortcuts/minutes/minutes_speaker_replace_test.go +++ b/shortcuts/minutes/minutes_speaker_replace_test.go @@ -34,7 +34,7 @@ func TestMinutesSpeakerReplace_Validate(t *testing.T) { { name: "missing from", args: []string{"+speaker-replace", "--minute-token", minutesSpeakerReplaceTestToken, "--to-user-id", "ou_b", "--as", "user"}, - wantErr: "required flag(s) \"from-user-id\" not set", + wantErr: "--from-speaker-id is required", }, { name: "missing to", @@ -153,6 +153,129 @@ func TestMinutesSpeakerReplace_DryRun(t *testing.T) { } } +func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesSpeakerReplace, []string{ + "+speaker-replace", + "--minute-token", minutesSpeakerReplaceTestToken, + "--from-speaker-id", "说话人1", + "--to-user-id", "ou_new_speaker", + "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "GET") { + t.Errorf("expected GET for internal speaker list, got:\n%s", out) + } + if !strings.Contains(out, "/transcript/speakerlist") { + t.Errorf("expected speakerlist path, got:\n%s", out) + } + if !strings.Contains(out, "PUT") { + t.Errorf("expected PUT for speaker replace, got:\n%s", out) + } + if !strings.Contains(out, "ou_new_speaker") { + t.Errorf("expected to_user_id in body, got:\n%s", out) + } +} + +func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + reg.Register(&httpmock.Stub{ + Method: http.MethodGet, + URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speakerlist", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "speakers": []interface{}{ + map[string]interface{}{ + "speaker_id": "ENCRYPTED_TOKEN_ABC", + "name": "说话人1", + }, + }, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: http.MethodPut, + URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{}, + }, + }) + + err := mountAndRun(t, MinutesSpeakerReplace, []string{ + "+speaker-replace", + "--minute-token", minutesSpeakerReplaceTestToken, + "--from-speaker-id", "说话人1", + "--to-user-id", "ou_new_speaker", + "--format", "json", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var envelope struct { + Data struct { + MinuteToken string `json:"minute_token"` + FromSpeakerInput string `json:"from_speaker_input"` + FromSpeakerID string `json:"from_speaker_id"` + ToUserID string `json:"to_user_id"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v", err) + } + if envelope.Data.FromSpeakerInput != "说话人1" { + t.Errorf("data.from_speaker_input = %q, want 说话人1", envelope.Data.FromSpeakerInput) + } + if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" { + t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID) + } +} + +func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + warmTokenCache(t) + + err := mountAndRun(t, MinutesSpeakerReplace, []string{ + "+speaker-replace", + "--minute-token", minutesSpeakerReplaceTestToken, + "--from-speaker-id", "ENCRYPTED_TOKEN_ABC", + "--to-user-id", "ou_new_speaker", + "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "GET") { + t.Errorf("expected GET for internal speaker list, got:\n%s", out) + } + if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") { + t.Errorf("expected from_speaker_id in body, got:\n%s", out) + } + if strings.Contains(out, "from_user_id") { + t.Errorf("from_speaker_id path should not send from_user_id, got:\n%s", out) + } + if !strings.Contains(out, "ou_new_speaker") { + t.Errorf("expected to_user_id in body, got:\n%s", out) + } +} + func TestMinutesSpeakerReplace_Execute(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) @@ -238,8 +361,8 @@ func TestMinutesSpeakerReplace_SpeakerNotFound(t *testing.T) { if !strings.Contains(p.Message, "ou_missing_speaker") { t.Errorf("message should include missing speaker id, got: %s", p.Message) } - if !strings.Contains(p.Hint, "--from-user-id") { - t.Errorf("hint should mention --from-user-id, got: %s", p.Hint) + if !strings.Contains(p.Hint, "--from-speaker-id") { + t.Errorf("hint should mention --from-speaker-id, got: %s", p.Hint) } } diff --git a/shortcuts/minutes/minutes_speakers.go b/shortcuts/minutes/minutes_speakers.go new file mode 100644 index 00000000..b9649f6d --- /dev/null +++ b/shortcuts/minutes/minutes_speakers.go @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "fmt" + "net/http" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +type minuteSpeaker struct { + SpeakerID string + Name string +} + +func minuteTranscriptSpeakerlistPath(minuteToken string) string { + return fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speakerlist", validate.EncodePathSegment(minuteToken)) +} + +func fetchMinuteSpeakers(runtime *common.RuntimeContext, minuteToken string) ([]minuteSpeaker, error) { + data, err := runtime.CallAPITyped(http.MethodGet, minuteTranscriptSpeakerlistPath(minuteToken), nil, nil) + if err != nil { + return nil, err + } + if data == nil { + return nil, nil + } + + items := common.GetSlice(data, "speakers") + speakers := make([]minuteSpeaker, 0, len(items)) + for _, raw := range items { + item, _ := raw.(map[string]interface{}) + if item == nil { + continue + } + id := strings.TrimSpace(common.GetString(item, "speaker_id")) + name := strings.TrimSpace(common.GetString(item, "name")) + if id == "" { + continue + } + speakers = append(speakers, minuteSpeaker{SpeakerID: id, Name: name}) + } + return speakers, nil +} + +func resolveSpeakerIDByName(speakers []minuteSpeaker, name string) (string, error) { + name = strings.TrimSpace(name) + var matches []minuteSpeaker + for _, s := range speakers { + if s.Name == name { + matches = append(matches, s) + } + } + switch len(matches) { + case 0: + return "", errs.NewValidationError(errs.SubtypeNotFound, + "no speaker named %q in minute transcript", name). + WithParam("--from-speaker-id"). + WithHint("Check the speaker name spelling or open the minute to see transcript speaker labels") + case 1: + return matches[0].SpeakerID, nil + default: + ids := make([]string, len(matches)) + for i, m := range matches { + ids[i] = m.SpeakerID + } + return "", errs.NewValidationError(errs.SubtypeFailedPrecondition, + "multiple speakers named %q (%d matches); pass the exact --from-speaker-id", name, len(matches)). + WithParam("--from-speaker-id"). + WithHint(fmt.Sprintf("Matching speaker_ids: %s. Review each speaker's utterances in the minute, then retry with the exact speaker_id", strings.Join(ids, ", "))) + } +} + +// resolveFromSpeakerID resolves --from-speaker-id to an API speaker_id. +// The input may already be an opaque speaker_id, or a display name that requires +// an internal speaker-list fetch. +func resolveFromSpeakerID(runtime *common.RuntimeContext, minuteToken, input string) (string, error) { + input = strings.TrimSpace(input) + speakers, err := fetchMinuteSpeakers(runtime, minuteToken) + if err != nil { + return "", err + } + for _, s := range speakers { + if s.SpeakerID == input { + return input, nil + } + } + return resolveSpeakerIDByName(speakers, input) +} + +func resolveSpeakerReplaceFrom(runtime *common.RuntimeContext, minuteToken string) (fromSpeakerID, fromUserID string, err error) { + fromUserID = strings.TrimSpace(runtime.Str("from-user-id")) + if fromUserID != "" { + return "", fromUserID, nil + } + + fromSpeakerID, err = resolveFromSpeakerID(runtime, minuteToken, runtime.Str("from-speaker-id")) + return fromSpeakerID, "", err +} diff --git a/shortcuts/minutes/minutes_speakers_test.go b/shortcuts/minutes/minutes_speakers_test.go new file mode 100644 index 00000000..015a362c --- /dev/null +++ b/shortcuts/minutes/minutes_speakers_test.go @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" +) + +func TestResolveSpeakerIDByName(t *testing.T) { + speakers := []minuteSpeaker{ + {SpeakerID: "id_a", Name: "Alice"}, + {SpeakerID: "id_b", Name: "Bob"}, + {SpeakerID: "id_c", Name: "Alice"}, + } + + id, err := resolveSpeakerIDByName(speakers, "Bob") + if err != nil || id != "id_b" { + t.Fatalf("resolve Bob: id=%q err=%v", id, err) + } + + _, err = resolveSpeakerIDByName(speakers, "Carol") + if err == nil { + t.Fatal("expected not found error") + } + var ve *errs.ValidationError + if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeNotFound { + t.Fatalf("want not-found validation error, got %T: %v", err, err) + } + + _, err = resolveSpeakerIDByName(speakers, "Alice") + if err == nil { + t.Fatal("expected duplicate name error") + } + if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeFailedPrecondition { + t.Fatalf("want failed-precondition validation error, got %T: %v", err, err) + } + if !strings.Contains(ve.Hint, "id_a") || !strings.Contains(ve.Hint, "id_c") { + t.Errorf("hint should list matching speaker_ids, got: %s", ve.Hint) + } +} diff --git a/shortcuts/minutes/minutes_summary.go b/shortcuts/minutes/minutes_summary.go index f8277eb2..a96f024d 100644 --- a/shortcuts/minutes/minutes_summary.go +++ b/shortcuts/minutes/minutes_summary.go @@ -31,7 +31,7 @@ var MinutesSummary = common.Shortcut{ }, Tips: []string{ minutesSummaryMarkdownTip, - "Use `lark-cli vc +notes --minute-tokens <token>` to read the current summary before replacing it.", + "Use `lark-cli minutes +detail --minute-tokens <token> --summary` to read the current summary before replacing it.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { minuteToken := runtime.Str("minute-token") diff --git a/shortcuts/minutes/minutes_todo.go b/shortcuts/minutes/minutes_todo.go index 05888c33..70730b43 100644 --- a/shortcuts/minutes/minutes_todo.go +++ b/shortcuts/minutes/minutes_todo.go @@ -59,7 +59,7 @@ var MinutesTodo = common.Shortcut{ "Update: `--operation update --todo-id <id> --todo \"...\" --is-done`.", "Delete: `--operation delete --todo-id <id>`.", "`content` is plain text only; markdown formatting is not supported.", - "Use `lark-cli vc +notes --minute-tokens <token>` to read current todos before writing.", + "Use `lark-cli minutes +detail --minute-tokens <token> --todo` to read current todos before writing.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { minuteToken := runtime.Str("minute-token") diff --git a/shortcuts/minutes/minutes_word_replace.go b/shortcuts/minutes/minutes_word_replace.go index fc079872..2cae38eb 100644 --- a/shortcuts/minutes/minutes_word_replace.go +++ b/shortcuts/minutes/minutes_word_replace.go @@ -148,7 +148,7 @@ func minutesWordReplaceError(err error, minuteToken string) error { if strings.Contains(strings.ToLower(p.Message), "not found in transcript") { p.Subtype = errs.SubtypeNotFound p.Message = fmt.Sprintf("None of the source words were found in minute %q transcript; nothing was replaced.", minuteToken) - p.Hint = "Verify each source_word's exact spelling and case against the current transcript (use vc +notes to read it), then retry" + p.Hint = "Verify each source_word's exact spelling and case against the current transcript (use `minutes +detail --minute-tokens <token> --transcript` to read it), then retry" } } diff --git a/shortcuts/minutes/shortcuts.go b/shortcuts/minutes/shortcuts.go index 283eca7f..c70c37c7 100644 --- a/shortcuts/minutes/shortcuts.go +++ b/shortcuts/minutes/shortcuts.go @@ -16,5 +16,6 @@ func Shortcuts() []common.Shortcut { MinutesTodo, MinutesSpeakerReplace, MinutesWordReplace, + MinutesDetail, } } diff --git a/shortcuts/note/note_transcript.go b/shortcuts/note/note_transcript.go index 6810afe6..fe84f057 100644 --- a/shortcuts/note/note_transcript.go +++ b/shortcuts/note/note_transcript.go @@ -153,7 +153,7 @@ func ensureUnifiedNote(ctx context.Context, runtime *common.RuntimeContext, note if detail.DisplayType != "unified" { if detail.VerbatimDocToken != "" { return errs.NewValidationError(errs.SubtypeFailedPrecondition, "note %s is not a unified note (note_display_type=%s, verbatim_doc_token=%s)", noteID, detail.DisplayType, detail.VerbatimDocToken). - WithHint("Use docs +fetch --api-version v2 --doc %s for normal note transcripts", detail.VerbatimDocToken) + WithHint("Use docs +fetch --doc %s for normal note transcripts", detail.VerbatimDocToken) } return errs.NewValidationError(errs.SubtypeFailedPrecondition, "note %s is not a unified note (note_display_type=%s, verbatim_doc_token=)", noteID, detail.DisplayType). WithHint("Use note +detail to inspect document tokens") diff --git a/shortcuts/note/note_transcript_test.go b/shortcuts/note/note_transcript_test.go index d6979414..f9914ec7 100644 --- a/shortcuts/note/note_transcript_test.go +++ b/shortcuts/note/note_transcript_test.go @@ -39,7 +39,7 @@ func TestNoteTranscriptRequiresUnifiedNote(t *testing.T) { if problem.Subtype != errs.SubtypeFailedPrecondition { t.Fatalf("subtype = %v, want FailedPrecondition", problem.Subtype) } - if !strings.Contains(problem.Hint, "docs +fetch --api-version v2 --doc doc_verbatim") { + if !strings.Contains(problem.Hint, "docs +fetch --doc doc_verbatim") { t.Fatalf("hint = %q, want docs +fetch guidance", problem.Hint) } if stdout.Len() != 0 { diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 04d2e40f..5137584b 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -246,8 +246,9 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) { shortcutHelp: "Create a Lark document", visibleFlag: "--content", skillCommand: "lark-cli skills read lark-doc references/lark-doc-create.md", - hiddenFlags: []string{"title", "markdown", "folder-token", "wiki-node", "wiki-space"}, + hiddenFlags: []string{"api-version", "markdown", "folder-token", "wiki-node", "wiki-space"}, contentHelp: []string{ + "--title", "AI agents MUST read", "lark-cli skills read lark-doc references/lark-doc-xml.md", "before writing any --content payload", @@ -257,7 +258,7 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) { "MUST NOT grep/open local SKILL.md files", "use --help for the latest command flags", }, - unwanted: []string{"--markdown", "--title", "--folder-token", "--wiki-node", "--wiki-space"}, + unwanted: []string{"--api-version", "--markdown", "--folder-token", "--wiki-node", "--wiki-space"}, }, { name: "fetch", @@ -265,8 +266,8 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) { shortcutHelp: "Fetch Lark document content", visibleFlag: "read scope", skillCommand: "lark-cli skills read lark-doc references/lark-doc-fetch.md", - hiddenFlags: []string{"offset", "limit"}, - unwanted: []string{"--offset", "--limit"}, + hiddenFlags: []string{"api-version", "offset", "limit"}, + unwanted: []string{"--api-version", "--offset", "--limit"}, }, { name: "update", @@ -274,7 +275,7 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) { shortcutHelp: "Update a Lark document", visibleFlag: "--command", skillCommand: "lark-cli skills read lark-doc references/lark-doc-update.md", - hiddenFlags: []string{"mode", "markdown", "selection-with-ellipsis", "selection-by-title", "new-title"}, + hiddenFlags: []string{"api-version", "mode", "markdown", "selection-with-ellipsis", "selection-by-title", "new-title"}, contentHelp: []string{ "AI agents MUST read", "lark-cli skills read lark-doc references/lark-doc-xml.md", @@ -285,7 +286,7 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) { "MUST NOT grep/open local SKILL.md files", "use --help for the latest command flags", }, - unwanted: []string{"--mode", "--markdown", "--selection-with-ellipsis", "--selection-by-title", "--new-title"}, + unwanted: []string{"--api-version", "--mode", "--markdown", "--selection-with-ellipsis", "--selection-by-title", "--new-title"}, }, } @@ -311,17 +312,6 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) { t.Fatalf("docs %s flag %q should be hidden", tt.shortcut, flagName) } } - apiVersionFlag := cmd.Flags().Lookup("api-version") - if apiVersionFlag == nil { - t.Fatalf("docs %s missing --api-version flag", tt.shortcut) - } - if apiVersionFlag.Hidden { - t.Fatalf("docs %s --api-version should be visible", tt.shortcut) - } - if apiVersionFlag.DefValue != "v2" { - t.Fatalf("docs %s --api-version default = %q, want v2", tt.shortcut, apiVersionFlag.DefValue) - } - var out bytes.Buffer cmd.SetOut(&out) if err := cmd.Help(); err != nil { @@ -331,10 +321,6 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) { for _, want := range []string{ tt.shortcutHelp, tt.visibleFlag, - "--api-version", - "deprecated compatibility flag; docs shortcuts always use v2", - "both v1/v2 are accepted", - "(default \"v2\")", "Start here (required for AI agents):", "AI agents MUST read the matching embedded skill", "Do not skip this step", diff --git a/shortcuts/sheets/backward/lark_sheets_float_images.go b/shortcuts/sheets/backward/lark_sheets_float_images.go index b1c62331..a117bbc2 100644 --- a/shortcuts/sheets/backward/lark_sheets_float_images.go +++ b/shortcuts/sheets/backward/lark_sheets_float_images.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "path/filepath" + "strings" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" @@ -14,7 +15,25 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) -const sheetImageParentType = "sheet_image" +// Drive media parent_type values for uploading an image into a spreadsheet. +// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a +// synthetic token prefixed with "fake_office_" and the backend requires +// "office_sheet_file" instead. +const ( + sheetImageParentType = "sheet_image" + officeSheetFileParentType = "office_sheet_file" + fakeOfficeTokenPrefix = "fake_office_" +) + +// sheetMediaParentType returns the drive media parent_type to use when +// uploading an image whose parent_node is spreadsheetToken, mapping the +// "fake_office_" imported-spreadsheet token prefix to "office_sheet_file". +func sheetMediaParentType(spreadsheetToken string) string { + if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) { + return officeSheetFileParentType + } + return sheetImageParentType +} var SheetMediaUpload = common.Shortcut{ Service: "sheets", @@ -49,7 +68,7 @@ var SheetMediaUpload = common.Shortcut{ POST("/open-apis/drive/v1/medias/upload_prepare"). Body(map[string]interface{}{ "file_name": fileName, - "parent_type": sheetImageParentType, + "parent_type": sheetMediaParentType(parentNode), "parent_node": parentNode, "size": "<file_size>", }). @@ -71,7 +90,7 @@ var SheetMediaUpload = common.Shortcut{ POST("/open-apis/drive/v1/medias/upload_all"). Body(map[string]interface{}{ "file_name": fileName, - "parent_type": sheetImageParentType, + "parent_type": sheetMediaParentType(parentNode), "parent_node": parentNode, "size": "<file_size>", "file": "@" + filePath, @@ -141,13 +160,14 @@ func resolveSheetMediaUploadParent(runtime *common.RuntimeContext) (string, erro } func uploadSheetMediaFile(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, parentNode string) (string, error) { + parentType := sheetMediaParentType(parentNode) if fileSize <= common.MaxDriveMediaUploadSinglePartSize { pn := parentNode return common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{ FilePath: filePath, FileName: fileName, FileSize: fileSize, - ParentType: sheetImageParentType, + ParentType: parentType, ParentNode: &pn, }) } @@ -155,7 +175,7 @@ func uploadSheetMediaFile(runtime *common.RuntimeContext, filePath, fileName str FilePath: filePath, FileName: fileName, FileSize: fileSize, - ParentType: sheetImageParentType, + ParentType: parentType, ParentNode: parentNode, }) } diff --git a/shortcuts/sheets/backward/lark_sheets_sheet_media_upload_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_media_upload_test.go index 03b10416..40b31d31 100644 --- a/shortcuts/sheets/backward/lark_sheets_sheet_media_upload_test.go +++ b/shortcuts/sheets/backward/lark_sheets_sheet_media_upload_test.go @@ -91,6 +91,39 @@ func TestSheetMediaUploadDryRunSmallFile(t *testing.T) { } } +// TestSheetMediaUploadDryRunSmallFileOfficeParentType pins the small-file +// upload_all dry-run preview to the token-derived parent_type so the preview +// agents/users will copy matches what Execute actually sends. Without this the +// multipart dry-run branch could drift back to a hard-coded "sheet_image". +func TestSheetMediaUploadDryRunSmallFileOfficeParentType(t *testing.T) { + dir := t.TempDir() + withSheetsTestWorkingDir(t, dir) + if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil { + t.Fatal(err) + } + + f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err := mountAndRunSheets(t, SheetMediaUpload, []string{ + "+media-upload", + "--spreadsheet-token", "fake_office_abc123", + "--file", "img.png", + "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "/open-apis/drive/v1/medias/upload_all") { + t.Fatalf("dry-run should use upload_all for small file, got: %s", out) + } + if !strings.Contains(out, `"office_sheet_file"`) { + t.Fatalf("dry-run should include parent_type=office_sheet_file for fake_office_ token, got: %s", out) + } + if strings.Contains(out, `"sheet_image"`) { + t.Fatalf("dry-run must not emit sheet_image for fake_office_ token, got: %s", out) + } +} + func TestSheetMediaUploadDryRunURLExtractsToken(t *testing.T) { dir := t.TempDir() withSheetsTestWorkingDir(t, dir) @@ -205,6 +238,47 @@ func TestSheetMediaUploadExecuteSuccess(t *testing.T) { } } +// TestSheetMediaUploadExecuteOfficeParentType confirms that an imported +// "office" spreadsheet (token prefixed with "fake_office_") uploads with +// parent_type=office_sheet_file instead of the native sheet_image. +func TestSheetMediaUploadExecuteOfficeParentType(t *testing.T) { + dir := t.TempDir() + withSheetsTestWorkingDir(t, dir) + if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil { + t.Fatal(err) + } + + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"file_token": "boxTOK123"}, + }, + } + reg.Register(stub) + + const officeToken = "fake_office_abc123" + err := mountAndRunSheets(t, SheetMediaUpload, []string{ + "+media-upload", + "--spreadsheet-token", officeToken, + "--file", "img.png", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body := decodeSheetsMultipartBody(t, stub) + if got := body.Fields["parent_type"]; got != officeSheetFileParentType { + t.Fatalf("parent_type = %q, want %q", got, officeSheetFileParentType) + } + if got := body.Fields["parent_node"]; got != officeToken { + t.Fatalf("parent_node = %q, want %q", got, officeToken) + } +} + func TestSheetMediaUploadFileNotFound(t *testing.T) { dir := t.TempDir() withSheetsTestWorkingDir(t, dir) diff --git a/shortcuts/sheets/helpers.go b/shortcuts/sheets/helpers.go index ad364312..5c2545c6 100644 --- a/shortcuts/sheets/helpers.go +++ b/shortcuts/sheets/helpers.go @@ -50,6 +50,42 @@ func sheetsInputStatError(flag string, err error) error { return wrapped } +// Drive media parent_type values for uploading an image into a spreadsheet. +// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a +// synthetic token prefixed with "fake_office_" and the backend requires +// "office_sheet_file" instead. +const ( + sheetImageParentType = "sheet_image" + officeSheetFileParentType = "office_sheet_file" + fakeOfficeTokenPrefix = "fake_office_" +) + +// sheetMediaParentType returns the drive media parent_type to use when +// uploading an image whose parent_node is spreadsheetToken. It is the single +// place that maps a spreadsheet token to its parent_type so every image-upload +// entry point (and its dry-run preview) stays consistent. +func sheetMediaParentType(spreadsheetToken string) string { + if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) { + return officeSheetFileParentType + } + return sheetImageParentType +} + +// uploadSheetImage uploads a local image file as a spreadsheet media asset and +// returns its file_token. It funnels every sheets image upload through one +// place so the parent_type selection (see sheetMediaParentType) is never +// duplicated or forgotten at a call site. Callers are expected to have already +// resolved spreadsheetToken (the upload's parent_node) and stat'd the file. +func uploadSheetImage(runtime *common.RuntimeContext, spreadsheetToken, filePath, fileName string, fileSize int64) (string, error) { + return common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{ + FilePath: filePath, + FileName: fileName, + FileSize: fileSize, + ParentType: sheetMediaParentType(spreadsheetToken), + ParentNode: &spreadsheetToken, + }) +} + // spreadsheetRef classification: a --url / --spreadsheet-token input names a // spreadsheet either directly (a /sheets/ URL or raw token) or indirectly via a // wiki node that must be resolved to its backing spreadsheet at Execute time. diff --git a/shortcuts/sheets/lark_sheet_object_crud.go b/shortcuts/sheets/lark_sheet_object_crud.go index 580e9daa..83f3b550 100644 --- a/shortcuts/sheets/lark_sheet_object_crud.go +++ b/shortcuts/sheets/lark_sheet_object_crud.go @@ -861,10 +861,10 @@ func newFloatImageWriteShortcut(command, description, op string, withIDFlag, isH manageBody, _ := buildToolBody("manage_float_image_object", input) return common.NewDryRunAPI(). POST("/open-apis/drive/v1/medias/upload_all"). - Desc("upload local image to drive (parent_type=sheet_image)"). + Desc("upload local image to drive (parent_type=" + sheetMediaParentType(token) + ")"). Body(map[string]interface{}{ "file_name": floatImageName(runtime), - "parent_type": "sheet_image", + "parent_type": sheetMediaParentType(token), "parent_node": token, "size": "<file_size>", "file": "@" + img, @@ -918,13 +918,7 @@ func uploadFloatImageIfLocal(runtime *common.RuntimeContext, spreadsheetToken st if err != nil { return "", sheetsInputStatError("image", err) } - return common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{ - FilePath: img, - FileName: floatImageName(runtime), - FileSize: info.Size(), - ParentType: "sheet_image", - ParentNode: &spreadsheetToken, - }) + return uploadSheetImage(runtime, spreadsheetToken, img, floatImageName(runtime), info.Size()) } func floatImageWriteInput(runtime flagView, token, sheetID, sheetName, op string, withIDFlag bool, uploadedImageToken string) (map[string]interface{}, error) { diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index 654c0634..57974bf6 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -791,10 +791,10 @@ var CellsSetImage = common.Shortcut{ }) return common.NewDryRunAPI(). POST("/open-apis/drive/v1/medias/upload_all"). - Desc("upload local image to drive (parent_type=sheet_image)"). + Desc("upload local image to drive (parent_type=" + sheetMediaParentType(token) + ")"). Body(map[string]interface{}{ "file_name": fileName, - "parent_type": "sheet_image", + "parent_type": sheetMediaParentType(token), "parent_node": token, "size": "<file_size>", "file": "@" + imgPath, @@ -832,13 +832,7 @@ var CellsSetImage = common.Shortcut{ WithParam("--image"). WithCause(err) } - fileToken, err := common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{ - FilePath: imgPath, - FileName: fileName, - FileSize: info.Size(), - ParentType: "sheet_image", - ParentNode: &token, - }) + fileToken, err := uploadSheetImage(runtime, token, imgPath, fileName, info.Size()) if err != nil { return err } diff --git a/shortcuts/sheets/lark_sheet_write_cells_test.go b/shortcuts/sheets/lark_sheet_write_cells_test.go index b450bb62..c36f59fd 100644 --- a/shortcuts/sheets/lark_sheet_write_cells_test.go +++ b/shortcuts/sheets/lark_sheet_write_cells_test.go @@ -496,6 +496,31 @@ func TestCellsSetImage_DryRun(t *testing.T) { } } +// TestCellsSetImage_DryRunOfficeParentType confirms that an imported "office" +// spreadsheet (token prefixed with "fake_office_") uploads with +// parent_type=office_sheet_file instead of the native sheet_image, and that the +// preview's parent_node carries the same token. +func TestCellsSetImage_DryRunOfficeParentType(t *testing.T) { + t.Parallel() + const officeToken = "fake_office_abc123" + calls := parseDryRunAPI(t, CellsSetImage, []string{ + "--spreadsheet-token", officeToken, "--sheet-id", testSheetID, + "--range", "A1", + "--image", "./README.md", // any existing-shaped path; dry-run skips stat + }) + if len(calls) != 2 { + t.Fatalf("api calls = %d, want 2 (upload + set_cell_range)", len(calls)) + } + upload := calls[0].(map[string]interface{}) + ubody, _ := upload["body"].(map[string]interface{}) + if ubody["parent_type"] != officeSheetFileParentType { + t.Errorf("parent_type = %v, want %s", ubody["parent_type"], officeSheetFileParentType) + } + if ubody["parent_node"] != officeToken { + t.Errorf("parent_node = %v, want %s", ubody["parent_node"], officeToken) + } +} + func TestCellsSetImage_RangeMustBeSingleCell(t *testing.T) { t.Parallel() _, _, err := runShortcutCapturingErr(t, CellsSetImage, []string{ diff --git a/shortcuts/sheets/sheet_media_parent_type_test.go b/shortcuts/sheets/sheet_media_parent_type_test.go new file mode 100644 index 00000000..ebf03dec --- /dev/null +++ b/shortcuts/sheets/sheet_media_parent_type_test.go @@ -0,0 +1,192 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "bytes" + "context" + "errors" + "io" + "io/fs" + "mime" + "mime/multipart" + "os" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +// TestSheetMediaParentType pins the token→parent_type mapping that every +// sheets image-upload entry point funnels through. Native spreadsheet tokens +// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_" +// synthetic token and must upload with "office_sheet_file". +func TestSheetMediaParentType(t *testing.T) { + t.Parallel() + cases := []struct { + name string + token string + want string + }{ + {"native spreadsheet token", "shtcnABC123", sheetImageParentType}, + {"empty token", "", sheetImageParentType}, + {"office imported token", "fake_office_abc123", officeSheetFileParentType}, + {"office token, only the prefix", fakeOfficeTokenPrefix, officeSheetFileParentType}, + {"prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := sheetMediaParentType(tc.token); got != tc.want { + t.Fatalf("sheetMediaParentType(%q) = %q, want %q", tc.token, got, tc.want) + } + }) + } +} + +// TestUploadSheetImage_ParentType exercises the uploadSheetImage collector end +// to end (the Execute path the dry-run tests don't reach), asserting the +// parent_type that actually goes out on the wire is derived from the token: a +// native spreadsheet uploads as sheet_image, an imported "office" spreadsheet +// (fake_office_-prefixed token) as office_sheet_file. +func TestUploadSheetImage_ParentType(t *testing.T) { + cases := []struct { + name string + token string + wantParentType string + }{ + {"native spreadsheet", "shtcnTOK123", sheetImageParentType}, + {"office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + runtime, reg := newSheetMediaTestRuntime(t) + // UploadDriveMediaAllTyped opens the file via the runtime's FileIO, + // which sandboxes paths to the current working directory; chdir to a + // temp dir and pass a relative name so the open is allowed. + cmdutil.TestChdir(t, t.TempDir()) + if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil { + t.Fatal(err) + } + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"file_token": "boxTOK123"}, + }, + } + reg.Register(stub) + + fileToken, err := uploadSheetImage(runtime, tc.token, "img.png", "img.png", 9) + if err != nil { + t.Fatalf("uploadSheetImage() error: %v", err) + } + if fileToken != "boxTOK123" { + t.Fatalf("file_token = %q, want boxTOK123", fileToken) + } + + body := decodeSheetMediaMultipartBody(t, stub) + if got := body.Fields["parent_type"]; got != tc.wantParentType { + t.Fatalf("parent_type = %q, want %q", got, tc.wantParentType) + } + if got := body.Fields["parent_node"]; got != tc.token { + t.Fatalf("parent_node = %q, want %q", got, tc.token) + } + if got := body.Fields["file_name"]; got != "img.png" { + t.Fatalf("file_name = %q, want img.png", got) + } + }) + } +} + +// TestUploadSheetImage_FileOpenError confirms a missing image surfaces as a +// typed validation error (category=validation, subtype=invalid_argument) with +// the original os-level cause preserved for errors.Is, and proves the upload +// endpoint is never hit. No httpmock stub is registered, so if uploadSheetImage +// ever tried to POST upload_all the RoundTrip would return a +// "no stub for POST ..." network failure — that would surface as a +// non-validation category and fail the metadata assertion below. The +// category=validation + fs.ErrNotExist cause therefore strictly implies the +// short-circuit happened before the wire. +func TestUploadSheetImage_FileOpenError(t *testing.T) { + runtime, _ := newSheetMediaTestRuntime(t) + cmdutil.TestChdir(t, t.TempDir()) + + _, err := uploadSheetImage(runtime, "shtcnTOK123", "missing.png", "missing.png", 1) + if err == nil { + t.Fatal("expected error for missing file, got nil") + } + + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("err = %v; want typed problem carrier", err) + } + if p.Category != errs.CategoryValidation { + t.Fatalf("category = %q, want %q (non-validation implies the upload endpoint was reached)", p.Category, errs.CategoryValidation) + } + if p.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument) + } + if !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("err = %v; want wrapped fs.ErrNotExist cause to be preserved", err) + } +} + +func newSheetMediaTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) { + t.Helper() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + cfg := &core.CliConfig{ + AppID: "test-sheets-media-" + t.Name(), + AppSecret: "test-secret", + Brand: core.BrandFeishu, + } + f, _, _, reg := cmdutil.TestFactory(t, cfg) + runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "sheets"}, cfg, f, core.AsBot) + return runtime, reg +} + +type sheetMediaCapturedMultipart struct { + Fields map[string]string + Files map[string][]byte +} + +func decodeSheetMediaMultipartBody(t *testing.T, stub *httpmock.Stub) sheetMediaCapturedMultipart { + t.Helper() + contentType := stub.CapturedHeaders.Get("Content-Type") + mediaType, params, err := mime.ParseMediaType(contentType) + if err != nil { + t.Fatalf("parse content-type %q: %v", contentType, err) + } + if mediaType != "multipart/form-data" { + t.Fatalf("content type = %q, want multipart/form-data", mediaType) + } + reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"]) + body := sheetMediaCapturedMultipart{Fields: map[string]string{}, Files: map[string][]byte{}} + for { + part, err := reader.NextPart() + if err != nil { + if err == io.EOF { + break + } + t.Fatalf("read multipart part: %v", err) + } + buf := new(bytes.Buffer) + if _, err := buf.ReadFrom(part); err != nil { + t.Fatalf("read multipart body for %q: %v", part.FormName(), err) + } + if part.FileName() != "" { + body.Files[part.FormName()] = buf.Bytes() + continue + } + body.Fields[part.FormName()] = buf.String() + } + return body +} diff --git a/shortcuts/slides/shortcuts.go b/shortcuts/slides/shortcuts.go index f665810c..011e27e0 100644 --- a/shortcuts/slides/shortcuts.go +++ b/shortcuts/slides/shortcuts.go @@ -11,6 +11,8 @@ func Shortcuts() []common.Shortcut { SlidesCreate, SlidesMediaUpload, SlidesReplaceSlide, + SlidesReplacePages, SlidesScreenshot, + SlidesXMLGet, } } diff --git a/shortcuts/slides/slides_create.go b/shortcuts/slides/slides_create.go index 115f04cd..b03fea49 100644 --- a/shortcuts/slides/slides_create.go +++ b/shortcuts/slides/slides_create.go @@ -204,13 +204,11 @@ var SlidesCreate = common.Shortcut{ } } - // Build the presentation URL locally from the token. The brand-standard - // host transparently redirects to the tenant domain (same fallback used by - // drive +upload / wiki +node-create). This avoids the prior best-effort - // drive metas/batch_query call, which needed an extra drive scope and 403'd - // for users who only authorized slides scopes — without ever blocking an - // otherwise-successful creation. - if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" { + // Prefer the URL returned by presentation.create. Fall back to a local + // brand-standard URL only when the API omits it. + if url := common.GetString(data, "url"); url != "" { + result["url"] = url + } else if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" { result["url"] = url } diff --git a/shortcuts/slides/slides_create_test.go b/shortcuts/slides/slides_create_test.go index 36b964a0..a5ba64cd 100644 --- a/shortcuts/slides/slides_create_test.go +++ b/shortcuts/slides/slides_create_test.go @@ -34,6 +34,7 @@ func TestSlidesCreateBasic(t *testing.T) { "data": map[string]interface{}{ "xml_presentation_id": "pres_abc123", "revision_id": 1, + "url": "https://tenant.example.com/slides/pres_abc123", }, }, }) @@ -54,10 +55,8 @@ func TestSlidesCreateBasic(t *testing.T) { if data["title"] != "项目汇报" { t.Fatalf("title = %v, want 项目汇报", data["title"]) } - // URL is built locally from the token (brand-standard host), not fetched from - // drive metas, so it is deterministic and needs no drive scope. - if data["url"] != "https://www.feishu.cn/slides/pres_abc123" { - t.Fatalf("url = %v, want https://www.feishu.cn/slides/pres_abc123", data["url"]) + if data["url"] != "https://tenant.example.com/slides/pres_abc123" { + t.Fatalf("url = %v, want https://tenant.example.com/slides/pres_abc123", data["url"]) } if _, ok := data["permission_grant"]; ok { t.Fatalf("did not expect permission_grant in user mode") @@ -647,12 +646,12 @@ func TestSlidesCreateWithoutSlidesUnchanged(t *testing.T) { } } -// TestSlidesCreateURLBuiltLocally verifies the presentation URL is constructed -// locally from the token — no drive metas/batch_query call is made, so creation -// works for users who only authorized slides scopes. The httpmock registry has no -// batch_query stub registered; if the shortcut tried to call it, the request would -// fail the test (unregistered stub), proving the URL is built without a drive call. -func TestSlidesCreateURLBuiltLocally(t *testing.T) { +// TestSlidesCreateURLFallsBackToLocalBuild verifies the presentation URL is +// constructed locally from the token when presentation.create omits url — no +// drive metas/batch_query call is made, so creation works for users who only +// authorized slides scopes. The httpmock registry has no batch_query stub +// registered; if the shortcut tried to call it, the request would fail the test. +func TestSlidesCreateURLFallsBackToLocalBuild(t *testing.T) { t.Parallel() f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) @@ -665,6 +664,7 @@ func TestSlidesCreateURLBuiltLocally(t *testing.T) { "data": map[string]interface{}{ "xml_presentation_id": "pres_local_url", "revision_id": 1, + "url": "", }, }, }) diff --git a/shortcuts/slides/slides_replace_pages.go b/shortcuts/slides/slides_replace_pages.go new file mode 100644 index 00000000..a2ee7716 --- /dev/null +++ b/shortcuts/slides/slides_replace_pages.go @@ -0,0 +1,426 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "context" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// SlidesReplacePages rebuilds multiple pages inside an existing presentation. +// It deliberately creates the new page before deleting the old one so a create +// failure cannot remove existing user content. The operation is not atomic. +const replacePagesInitialRevisionID = -1 + +var SlidesReplacePages = common.Shortcut{ + Service: "slides", + Command: "+replace-pages", + Description: "Batch rebuild pages inside an existing Slides presentation (create before old page, then delete old page; not atomic)", + Risk: "write", + Scopes: []string{"slides:presentation:update", "slides:presentation:write_only"}, + // wiki:node:read is required only when --presentation is a wiki URL. + ConditionalScopes: []string{"wiki:node:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true}, + {Name: "pages", Desc: "JSON array of page replacements (each: {slide_id, content}); supports @file or -", Required: true, Input: []string{common.File, common.Stdin}}, + {Name: "continue-on-error", Type: "bool", Desc: "continue with later pages after a create/delete failure; default false"}, + {Name: "validate-only", Type: "bool", Desc: "validate input and build the create/delete plan without write calls"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + ref, err := parsePresentationRef(runtime.Str("presentation")) + if err != nil { + return err + } + if ref.Kind == "wiki" { + if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil { + return err + } + } + pages, err := parseReplacePages(runtime.Str("pages")) + if err != nil { + return err + } + return validateReplacePagesInput(pages) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + dry := common.NewDryRunAPI() + resolved, err := prepareReplacePages(runtime) + if err != nil { + return dry.Set("error", err.Error()) + } + appendReplacePagesDryRunCalls(dry, resolved) + return dry. + Set("xml_presentation_id", resolved.PresentationID). + Set("pages_count", len(resolved.Plan)). + Set("plan", replacePagesPlanOutput(resolved.Plan)). + Set("note", "dry-run built a create/delete plan from slide_id inputs; no Slides presentation get/create/delete calls were executed") + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + resolved, err := prepareReplacePages(runtime) + if err != nil { + return err + } + if runtime.Bool("validate-only") { + runtime.Out(map[string]interface{}{ + "xml_presentation_id": resolved.PresentationID, + "pages_count": len(resolved.Plan), + "plan": replacePagesPlanOutput(resolved.Plan), + "status": "validated", + "note": "validate-only checked input and built the create/delete plan; no Slides presentation get/create/delete calls were executed", + }, nil) + return nil + } + + revisionID := replacePagesInitialRevisionID + results := make([]replacePageResult, 0, len(resolved.Plan)) + for i, item := range resolved.Plan { + result, err := replaceOnePage(runtime, resolved.PresentationID, item, revisionID) + results = append(results, result) + if result.RevisionID != nil { + revisionID = *result.RevisionID + } + if err != nil { + if runtime.Bool("continue-on-error") { + continue + } + return appendSlidesProgressHint(err, fmt.Sprintf("slides +replace-pages stopped at item %d/%d; %d page(s) completed before failure; old page is kept when create failed", i+1, len(resolved.Plan), countReplacedPages(results))) + } + } + + out := map[string]interface{}{ + "xml_presentation_id": resolved.PresentationID, + "pages_count": len(resolved.Plan), + "results": replacePageResultsOutput(results), + "status": "completed", + "summary": replacePagesSummaryOutput(results), + "note": "batch replace is not atomic; each page was created before its old page was deleted", + } + if revisionID != replacePagesInitialRevisionID { + out["revision_id"] = revisionID + } + if hasReplacePageFailures(results) { + out["status"] = "partial_failure" + return runtime.OutPartialFailure(out, nil) + } + runtime.Out(out, nil) + return nil + }, +} + +type replacePageInput struct { + SlideID string + Content string +} + +type replacePagePlanItem struct { + OldSlideID string + Content string + Locator string +} + +type replacePagesPrepared struct { + PresentationID string + Plan []replacePagePlanItem +} + +type replacePageResult struct { + OldSlideID string + NewSlideID string + Status string + Error string + RevisionID *int +} + +func prepareReplacePages(runtime *common.RuntimeContext) (*replacePagesPrepared, error) { + ref, err := parsePresentationRef(runtime.Str("presentation")) + if err != nil { + return nil, err + } + presentationID, err := resolvePresentationID(runtime, ref) + if err != nil { + return nil, err + } + pages, err := parseReplacePages(runtime.Str("pages")) + if err != nil { + return nil, err + } + if err := validateReplacePagesInput(pages); err != nil { + return nil, err + } + + plan, err := buildReplacePagesPlan(pages) + if err != nil { + return nil, err + } + return &replacePagesPrepared{PresentationID: presentationID, Plan: plan}, nil +} + +func parseReplacePages(raw string) ([]replacePageInput, error) { + s := strings.TrimSpace(raw) + if s == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages cannot be empty").WithParam("--pages") + } + var decoded []map[string]interface{} + if err := json.Unmarshal([]byte(s), &decoded); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages invalid JSON, must be an array of objects: %v", err).WithParam("--pages").WithCause(err) + } + out := make([]replacePageInput, 0, len(decoded)) + for i, m := range decoded { + p := replacePageInput{} + if v, ok := m["slide_number"]; ok { + _ = v + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_number is no longer supported; use slide_id", i).WithParam("--pages").WithHint("read current slide IDs first, then pass slide_id for each page replacement") + } + if v, ok := m["slide_id"]; ok { + s, ok := v.(string) + if !ok { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_id must be a string", i).WithParam("--pages") + } + p.SlideID = s + } + if v, ok := m["content"]; ok { + s, ok := v.(string) + if !ok { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content must be a string", i).WithParam("--pages") + } + p.Content = s + } + out = append(out, p) + } + return out, nil +} + +func validateReplacePagesInput(pages []replacePageInput) error { + if len(pages) == 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages must contain at least 1 item").WithParam("--pages") + } + seenIDs := map[string]bool{} + for i, p := range pages { + id := strings.TrimSpace(p.SlideID) + if id == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_id is required", i).WithParam("--pages") + } + if seenIDs[id] { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages contains duplicate slide_id %q", id).WithParam("--pages") + } + seenIDs[id] = true + if strings.TrimSpace(p.Content) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content cannot be empty", i).WithParam("--pages") + } + if err := validateCompleteSlideXML(p.Content); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content must be a complete <slide> XML element: %v", i, err).WithParam("--pages").WithCause(err) + } + } + return nil +} + +func validateCompleteSlideXML(content string) error { + dec := xml.NewDecoder(strings.NewReader(content)) + depth := 0 + seenRoot := false + for { + tok, err := dec.Token() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return err + } + switch t := tok.(type) { + case xml.StartElement: + if depth == 0 { + if seenRoot { + return invalidSlideXMLStructureError("multiple root elements") + } + if t.Name.Local != "slide" { + return invalidSlideXMLStructureError("root element is <%s>, want <slide>", t.Name.Local) + } + seenRoot = true + } + depth++ + case xml.EndElement: + depth-- + case xml.CharData: + if depth == 0 && strings.TrimSpace(string(t)) != "" { + return invalidSlideXMLStructureError("non-whitespace text outside root element") + } + } + } + if !seenRoot { + return invalidSlideXMLStructureError("missing root element") + } + if depth != 0 { + return invalidSlideXMLStructureError("unclosed XML element") + } + return nil +} + +func invalidSlideXMLStructureError(format string, args ...interface{}) error { + return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...) +} + +func buildReplacePagesPlan(pages []replacePageInput) ([]replacePagePlanItem, error) { + plan := make([]replacePagePlanItem, 0, len(pages)) + for _, page := range pages { + id := strings.TrimSpace(page.SlideID) + plan = append(plan, replacePagePlanItem{ + OldSlideID: id, + Content: page.Content, + Locator: "slide_id", + }) + } + return plan, nil +} + +func appendReplacePagesDryRunCalls(dry *common.DryRunAPI, resolved *replacePagesPrepared) { + dry.Desc("Batch replace pages in-place: create each new page before old page, then delete old page (not atomic)") + for i, item := range resolved.Plan { + dry.POST(fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(resolved.PresentationID))). + Desc(fmt.Sprintf("[%d/%d] Create replacement before old slide %s", i*2+1, len(resolved.Plan)*2, item.OldSlideID)). + Params(map[string]interface{}{"revision_id": "<latest_or_revision_returned_by_previous_step>"}). + Body(map[string]interface{}{ + "slide": map[string]interface{}{"content": item.Content}, + "before_slide_id": item.OldSlideID, + }) + dry.DELETE(fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(resolved.PresentationID))). + Desc(fmt.Sprintf("[%d/%d] Delete old slide %s after create succeeds", i*2+2, len(resolved.Plan)*2, item.OldSlideID)). + Params(map[string]interface{}{ + "slide_id": item.OldSlideID, + "revision_id": "<revision_returned_by_create>", + }) + } +} + +func replaceOnePage(runtime *common.RuntimeContext, presentationID string, item replacePagePlanItem, revisionID int) (replacePageResult, error) { + result := replacePageResult{ + OldSlideID: item.OldSlideID, + Status: "pending", + } + slideURL := fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(presentationID)) + createData, err := runtime.CallAPITyped( + "POST", + slideURL, + map[string]interface{}{"revision_id": revisionID}, + map[string]interface{}{ + "slide": map[string]interface{}{"content": item.Content}, + "before_slide_id": item.OldSlideID, + }, + ) + if err != nil { + result.Status = "create_failed" + result.Error = err.Error() + return result, err + } + newSlideID := common.GetString(createData, "slide_id") + if newSlideID == "" { + err := errs.NewInternalError(errs.SubtypeInvalidResponse, "slide.create returned no slide_id for replacement of slide_id %q", item.OldSlideID) + result.Status = "create_failed" + result.Error = err.Error() + return result, err + } + result.NewSlideID = newSlideID + if rev, ok := revisionFromData(createData); ok { + revisionID = rev + result.RevisionID = &rev + } + + deleteData, err := runtime.CallAPITyped( + "DELETE", + slideURL, + map[string]interface{}{ + "slide_id": item.OldSlideID, + "revision_id": revisionID, + }, + nil, + ) + if err != nil { + result.Status = "delete_failed" + result.Error = err.Error() + return result, err + } + if rev, ok := revisionFromData(deleteData); ok { + result.RevisionID = &rev + } + result.Status = "replaced" + return result, nil +} + +func revisionFromData(data map[string]interface{}) (int, bool) { + if _, ok := data["revision_id"]; !ok { + return 0, false + } + return int(common.GetFloat(data, "revision_id")), true +} + +func replacePagesPlanOutput(plan []replacePagePlanItem) []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(plan)) + for _, item := range plan { + out = append(out, map[string]interface{}{ + "old_slide_id": item.OldSlideID, + "insert_before_slide_id": item.OldSlideID, + "locator": item.Locator, + "action": "create_before_then_delete_old", + }) + } + return out +} + +func replacePageResultsOutput(results []replacePageResult) []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(results)) + for _, result := range results { + m := map[string]interface{}{ + "old_slide_id": result.OldSlideID, + "status": result.Status, + } + if result.NewSlideID != "" { + m["new_slide_id"] = result.NewSlideID + } + if result.Error != "" { + m["error"] = result.Error + } + if result.RevisionID != nil { + m["revision_id"] = *result.RevisionID + } + out = append(out, m) + } + return out +} + +func replacePagesSummaryOutput(results []replacePageResult) map[string]interface{} { + replaced := countReplacedPages(results) + return map[string]interface{}{ + "replaced": replaced, + "failed": len(results) - replaced, + "total": len(results), + } +} + +func countReplacedPages(results []replacePageResult) int { + n := 0 + for _, result := range results { + if result.Status == "replaced" { + n++ + } + } + return n +} + +func hasReplacePageFailures(results []replacePageResult) bool { + for _, result := range results { + if result.Status == "create_failed" || result.Status == "delete_failed" { + return true + } + } + return false +} diff --git a/shortcuts/slides/slides_replace_pages_test.go b/shortcuts/slides/slides_replace_pages_test.go new file mode 100644 index 00000000..fd1c5158 --- /dev/null +++ b/shortcuts/slides/slides_replace_pages_test.go @@ -0,0 +1,341 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "encoding/json" + "errors" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" +) + +func TestReplacePagesDeclaredScopes(t *testing.T) { + if got := SlidesReplacePages.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) { + t.Fatalf("user preflight scopes = %#v, want slides update/write_only only", got) + } + if got := SlidesReplacePages.ScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) { + t.Fatalf("bot preflight scopes = %#v, want slides update/write_only only", got) + } + + got := SlidesReplacePages.DeclaredScopesForIdentity("user") + want := []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("declared scopes = %#v, want %#v", got, want) + } +} + +func TestReplacePagesCreatesBeforeThenDeletesOld(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + var requestOrder []string + createStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"slide_id": "new2", "revision_id": 11}, + }, + OnMatch: func(req *http.Request) { + requestOrder = append(requestOrder, req.Method) + }, + } + reg.Register(createStub) + var deleteQuery map[string][]string + deleteStub := &httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"revision_id": 12}, + }, + OnMatch: func(req *http.Request) { + requestOrder = append(requestOrder, req.Method) + deleteQuery = req.URL.Query() + }, + } + reg.Register(deleteStub) + + pages := `[{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]` + err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{ + "+replace-pages", + "--presentation", "pres_abc", + "--pages", pages, + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var createBody struct { + Slide struct { + Content string `json:"content"` + } `json:"slide"` + BeforeSlideID string `json:"before_slide_id"` + } + if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil { + t.Fatalf("decode create body: %v\nraw=%s", err, createStub.CapturedBody) + } + if createBody.BeforeSlideID != "old2" { + t.Fatalf("before_slide_id = %q, want old2", createBody.BeforeSlideID) + } + if !strings.Contains(createBody.Slide.Content, "<slide") { + t.Fatalf("create content = %q", createBody.Slide.Content) + } + if !reflect.DeepEqual(requestOrder, []string{"POST", "DELETE"}) { + t.Fatalf("request order = %#v, want POST then DELETE", requestOrder) + } + deleteURL := string(deleteStub.CapturedBody) + if deleteURL != "" { + t.Fatalf("delete body = %q, want empty", deleteURL) + } + if got := deleteQuery["slide_id"]; !reflect.DeepEqual(got, []string{"old2"}) { + t.Fatalf("delete slide_id = %#v, want old2", got) + } + if got := deleteQuery["revision_id"]; !reflect.DeepEqual(got, []string{"11"}) { + t.Fatalf("delete revision_id = %#v, want 11 from create response", got) + } + + data := decodeShortcutData(t, stdout) + if data["xml_presentation_id"] != "pres_abc" { + t.Fatalf("xml_presentation_id = %v", data["xml_presentation_id"]) + } + if data["revision_id"] != float64(12) { + t.Fatalf("revision_id = %v, want 12", data["revision_id"]) + } + summary, _ := data["summary"].(map[string]interface{}) + if summary["failed"] != float64(0) { + t.Fatalf("summary.failed = %v, want 0", summary["failed"]) + } + results, _ := data["results"].([]interface{}) + if len(results) != 1 { + t.Fatalf("results len = %d, want 1", len(results)) + } + first, _ := results[0].(map[string]interface{}) + if first["old_slide_id"] != "old2" || first["new_slide_id"] != "new2" || first["status"] != "replaced" { + t.Fatalf("result = %#v", first) + } +} + +func TestReplacePagesContinueOnErrorReturnsPartialFailure(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide", + Body: map[string]interface{}{ + "code": 3350001, + "msg": "invalid param", + "data": map[string]interface{}{}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"slide_id": "new2", "revision_id": 11}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"revision_id": 12}, + }, + }) + + pages := `[ + {"slide_id":"old1","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}, + {"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"} + ]` + err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{ + "+replace-pages", + "--presentation", "pres_abc", + "--pages", pages, + "--continue-on-error", + "--as", "user", + }) + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) { + t.Fatalf("err = %T %v, want *output.PartialFailureError", err, err) + } + + env := decodeReplacePagesEnvelope(t, stdout) + if env.OK { + t.Fatalf("stdout ok = true, want false for partial failure") + } + data := env.Data + if data["status"] != "partial_failure" { + t.Fatalf("status = %v, want partial_failure", data["status"]) + } + summary, _ := data["summary"].(map[string]interface{}) + if summary["replaced"] != float64(1) || summary["failed"] != float64(1) || summary["total"] != float64(2) { + t.Fatalf("summary = %#v, want replaced=1 failed=1 total=2", summary) + } + results, _ := data["results"].([]interface{}) + if len(results) != 2 { + t.Fatalf("results len = %d, want 2", len(results)) + } + first, _ := results[0].(map[string]interface{}) + second, _ := results[1].(map[string]interface{}) + if first["status"] != "create_failed" { + t.Fatalf("first status = %v, want create_failed", first["status"]) + } + if second["status"] != "replaced" || second["new_slide_id"] != "new2" { + t.Fatalf("second result = %#v, want replaced with new2", second) + } +} + +func TestReplacePagesContinueOnErrorDeleteFailureIncludesNewSlideID(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"slide_id": "new1", "revision_id": 11}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide", + Body: map[string]interface{}{ + "code": 3350001, + "msg": "invalid param", + "data": map[string]interface{}{}, + }, + }) + + pages := `[{"slide_id":"old1","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]` + err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{ + "+replace-pages", + "--presentation", "pres_abc", + "--pages", pages, + "--continue-on-error", + "--as", "user", + }) + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) { + t.Fatalf("err = %T %v, want *output.PartialFailureError", err, err) + } + + env := decodeReplacePagesEnvelope(t, stdout) + if env.OK { + t.Fatalf("stdout ok = true, want false for partial failure") + } + results, _ := env.Data["results"].([]interface{}) + if len(results) != 1 { + t.Fatalf("results len = %d, want 1", len(results)) + } + first, _ := results[0].(map[string]interface{}) + if first["status"] != "delete_failed" { + t.Fatalf("status = %v, want delete_failed", first["status"]) + } + if first["new_slide_id"] != "new1" { + t.Fatalf("new_slide_id = %v, want new1", first["new_slide_id"]) + } +} + +func TestReplacePagesDryRunPlansOnly(t *testing.T) { + t.Parallel() + + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + + pages := `[{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]` + err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{ + "+replace-pages", + "--presentation", "pres_abc", + "--pages", pages, + "--dry-run", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var out map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &out); err != nil { + t.Fatalf("decode dry-run: %v\nraw=%s", err, stdout.String()) + } + if out["xml_presentation_id"] != "pres_abc" { + t.Fatalf("xml_presentation_id = %v", out["xml_presentation_id"]) + } + plan, _ := out["plan"].([]interface{}) + if len(plan) != 1 { + t.Fatalf("plan len = %d, want 1", len(plan)) + } + item, _ := plan[0].(map[string]interface{}) + if item["old_slide_id"] != "old2" || item["action"] != "create_before_then_delete_old" { + t.Fatalf("plan item = %#v", item) + } + api, _ := out["api"].([]interface{}) + if len(api) != 2 { + t.Fatalf("api len = %d, want create/delete plan", len(api)) + } +} + +func TestReplacePagesValidationParam(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pages string + }{ + {"empty pages", `[]`}, + {"slide number no longer supported", `[{"slide_number":1,"content":"<slide/>"}]`}, + {"no locator", `[{"content":"<slide/>"}]`}, + {"empty content", `[{"slide_id":"s1","content":" "}]`}, + {"not slide XML", `[{"slide_id":"s1","content":"<shape/>"}]`}, + {"duplicate id", `[{"slide_id":"s1","content":"<slide/>"},{"slide_id":"s1","content":"<slide/>"}]`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{ + "+replace-pages", + "--presentation", "pres_abc", + "--pages", tt.pages, + "--as", "user", + }) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %v, want *errs.ValidationError", err) + } + if ve.Param != "--pages" { + t.Fatalf("Param = %q, want --pages", ve.Param) + } + }) + } +} + +type replacePagesEnvelope struct { + OK bool `json:"ok"` + Data map[string]interface{} `json:"data"` +} + +func decodeReplacePagesEnvelope(t *testing.T, stdout interface{ Bytes() []byte }) replacePagesEnvelope { + t.Helper() + var env replacePagesEnvelope + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\nraw=%s", err, string(stdout.Bytes())) + } + if env.Data == nil { + t.Fatalf("missing data: %#v", env) + } + return env +} diff --git a/shortcuts/slides/slides_replace_slide.go b/shortcuts/slides/slides_replace_slide.go index a6910728..b518b2c8 100644 --- a/shortcuts/slides/slides_replace_slide.go +++ b/shortcuts/slides/slides_replace_slide.go @@ -43,8 +43,10 @@ var SlidesReplaceSlide = common.Shortcut{ Command: "+replace-slide", Description: "Replace elements on a slide via block_replace / block_insert parts (auto-injects id + <content/> on shape elements)", Risk: "write", - Scopes: []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"}, - AuthTypes: []string{"user", "bot"}, + Scopes: []string{"slides:presentation:update", "slides:presentation:write_only"}, + // wiki:node:read is required only when --presentation is a wiki URL. + ConditionalScopes: []string{"wiki:node:read"}, + AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true}, {Name: "slide-id", Desc: "slide page identifier (slide_id)", Required: true}, @@ -53,9 +55,15 @@ var SlidesReplaceSlide = common.Shortcut{ {Name: "tid", Desc: "transaction id for concurrent-edit locking (usually empty)"}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - if _, err := parsePresentationRef(runtime.Str("presentation")); err != nil { + ref, err := parsePresentationRef(runtime.Str("presentation")) + if err != nil { return err } + if ref.Kind == "wiki" { + if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil { + return err + } + } if strings.TrimSpace(runtime.Str("slide-id")) == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide-id cannot be empty").WithParam("--slide-id") } diff --git a/shortcuts/slides/slides_replace_slide_test.go b/shortcuts/slides/slides_replace_slide_test.go index a4c37db3..97a531b2 100644 --- a/shortcuts/slides/slides_replace_slide_test.go +++ b/shortcuts/slides/slides_replace_slide_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "reflect" "strings" "testing" @@ -15,6 +16,21 @@ import ( "github.com/larksuite/cli/internal/httpmock" ) +func TestReplaceSlideDeclaredScopes(t *testing.T) { + if got := SlidesReplaceSlide.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) { + t.Fatalf("user preflight scopes = %#v, want slides update/write_only only", got) + } + if got := SlidesReplaceSlide.ScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) { + t.Fatalf("bot preflight scopes = %#v, want slides update/write_only only", got) + } + + got := SlidesReplaceSlide.DeclaredScopesForIdentity("user") + want := []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("declared scopes = %#v, want %#v", got, want) + } +} + // TestReplaceSlideBlockReplaceInjectsID is the core regression: users write // <shape>…</shape> as replacement and the CLI must stitch id="<block_id>" // onto the root before sending. The backend returns 3350001 otherwise. diff --git a/shortcuts/slides/slides_screenshot.go b/shortcuts/slides/slides_screenshot.go index a265facd..ba09549b 100644 --- a/shortcuts/slides/slides_screenshot.go +++ b/shortcuts/slides/slides_screenshot.go @@ -34,7 +34,9 @@ var SlidesScreenshot = common.Shortcut{ Command: "+screenshot", Description: "Save slide screenshots to local files without printing Base64 image data", Risk: "read", - Scopes: []string{"slides:presentation:screenshot"}, + Scopes: []string{}, + // The screenshot API is allowlist-gated for only a few apps, so do not + // advertise/preflight its scope. Let the API fail and let callers degrade. // wiki:node:read is required only when --presentation is a wiki URL. ConditionalScopes: []string{"wiki:node:read"}, AuthTypes: []string{"user", "bot"}, diff --git a/shortcuts/slides/slides_screenshot_test.go b/shortcuts/slides/slides_screenshot_test.go index 8c477b68..3efae372 100644 --- a/shortcuts/slides/slides_screenshot_test.go +++ b/shortcuts/slides/slides_screenshot_test.go @@ -17,11 +17,23 @@ import ( ) func TestSlidesScreenshotDeclaredScopes(t *testing.T) { + if got := SlidesScreenshot.ScopesForIdentity("user"); len(got) != 0 { + t.Fatalf("user preflight scopes = %#v, want empty", got) + } + if got := SlidesScreenshot.ScopesForIdentity("bot"); len(got) != 0 { + t.Fatalf("bot preflight scopes = %#v, want empty", got) + } + got := SlidesScreenshot.DeclaredScopesForIdentity("user") - want := []string{"slides:presentation:screenshot", "wiki:node:read"} - if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + want := []string{"wiki:node:read"} + if len(got) != len(want) || got[0] != want[0] { t.Fatalf("declared scopes = %#v, want %#v", got, want) } + for _, scope := range got { + if scope == "slides:presentation:screenshot" { + t.Fatalf("declared scopes must not advertise screenshot scope: %#v", got) + } + } } func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) { diff --git a/shortcuts/slides/slides_xml_get.go b/shortcuts/slides/slides_xml_get.go new file mode 100644 index 00000000..cc210148 --- /dev/null +++ b/shortcuts/slides/slides_xml_get.go @@ -0,0 +1,144 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "bytes" + "context" + "fmt" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// SlidesXMLGet fetches the full XML presentation content and writes it to a +// local file, keeping the terminal output small for large decks. +var SlidesXMLGet = common.Shortcut{ + Service: "slides", + Command: "+xml-get", + Description: "Fetch full presentation XML and save it to a local file", + Risk: "read", + Scopes: []string{"slides:presentation:read"}, + // wiki:node:read is required only when --presentation is a wiki URL. + ConditionalScopes: []string{"wiki:node:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true}, + {Name: "output", Desc: "local XML output path; existing file is overwritten", Required: true}, + {Name: "revision-id", Type: "int", Default: "-1", Desc: "presentation revision_id; -1 means latest"}, + {Name: "remove-attr-id", Type: "bool", Desc: "remove XML id attributes in the returned content; useful for read-only inspection, not precise block editing"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + ref, err := parsePresentationRef(runtime.Str("presentation")) + if err != nil { + return err + } + if ref.Kind == "wiki" { + if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil { + return err + } + } + if strings.TrimSpace(runtime.Str("output")) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output cannot be empty").WithParam("--output") + } + if _, err := runtime.ResolveSavePath(runtime.Str("output")); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output invalid: %v", err).WithParam("--output").WithCause(err) + } + if runtime.Int("revision-id") < -1 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--revision-id must be -1 or a non-negative integer").WithParam("--revision-id") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + ref, err := parsePresentationRef(runtime.Str("presentation")) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + presentationID := ref.Token + dry := common.NewDryRunAPI() + if ref.Kind == "wiki" { + presentationID = "<resolved_slides_token>" + dry.Desc("2-step orchestration: resolve wiki → fetch full presentation XML"). + GET("/open-apis/wiki/v2/spaces/get_node"). + Desc("[1] Resolve wiki node to slides presentation"). + Params(map[string]interface{}{"token": ref.Token}) + } else { + dry.Desc("Fetch full presentation XML and save it to a local file") + } + params := map[string]interface{}{ + "revision_id": runtime.Int("revision-id"), + } + if runtime.Bool("remove-attr-id") { + params["remove_attr_id"] = true + } + dry.GET(fmt.Sprintf( + "/open-apis/slides_ai/v1/xml_presentations/%s", + validate.EncodePathSegment(presentationID), + )). + Params(params) + return dry.Set("output", runtime.Str("output")).Set("stdout_content", "suppressed; XML content is saved to --output during execution") + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + ref, err := parsePresentationRef(runtime.Str("presentation")) + if err != nil { + return err + } + presentationID, err := resolvePresentationID(runtime, ref) + if err != nil { + return err + } + + params := map[string]interface{}{ + "revision_id": runtime.Int("revision-id"), + } + if runtime.Bool("remove-attr-id") { + params["remove_attr_id"] = true + } + data, err := runtime.CallAPITyped( + "GET", + fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s", validate.EncodePathSegment(presentationID)), + params, + nil, + ) + if err != nil { + return err + } + + presentation := common.GetMap(data, "xml_presentation") + content := common.GetString(presentation, "content") + if content == "" { + return errs.NewInternalError(errs.SubtypeInvalidResponse, "slides xml get returned empty xml_presentation.content") + } + outputPath := runtime.Str("output") + result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{ + ContentType: "application/xml", + ContentLength: int64(len(content)), + }, bytes.NewReader([]byte(content))) + if err != nil { + return common.WrapSaveErrorTyped(err) + } + resolvedPath, err := runtime.ResolveSavePath(outputPath) + if err != nil { + return errs.NewInternalError(errs.SubtypeFileIO, "resolve saved XML path %s: %v", outputPath, err).WithCause(err) + } + + out := map[string]interface{}{ + "xml_presentation_id": presentationID, + "path": resolvedPath, + "size": result.Size(), + "content_saved": true, + } + if revisionID := common.GetFloat(presentation, "revision_id"); revisionID > 0 { + out["revision_id"] = int(revisionID) + } + if runtime.Bool("remove-attr-id") { + out["remove_attr_id"] = true + } + runtime.Out(out, nil) + return nil + }, +} diff --git a/shortcuts/slides/slides_xml_get_test.go b/shortcuts/slides/slides_xml_get_test.go new file mode 100644 index 00000000..f0f3a2ab --- /dev/null +++ b/shortcuts/slides/slides_xml_get_test.go @@ -0,0 +1,165 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "errors" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + + xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>` + var capturedQuery url.Values + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "xml_presentation": map[string]interface{}{ + "presentation_id": "pres_abc", + "revision_id": 7, + "content": xml, + }, + }, + }, + OnMatch: func(req *http.Request) { + capturedQuery = req.URL.Query() + }, + }) + + err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{ + "+xml-get", + "--presentation", "pres_abc", + "--output", "readback.xml", + "--revision-id", "7", + "--remove-attr-id", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + path := filepath.Join(dir, "readback.xml") + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read saved XML: %v", err) + } + if string(got) != xml { + t.Fatalf("saved XML = %q, want %q", got, xml) + } + if strings.Contains(stdout.String(), xml) { + t.Fatalf("stdout leaked full XML content: %s", stdout.String()) + } + if got := capturedQuery.Get("revision_id"); got != "7" { + t.Fatalf("revision_id query = %q, want 7", got) + } + if got := capturedQuery.Get("remove_attr_id"); got != "true" { + t.Fatalf("remove_attr_id query = %q, want true", got) + } + + data := decodeShortcutData(t, stdout) + if data["xml_presentation_id"] != "pres_abc" { + t.Fatalf("xml_presentation_id = %v, want pres_abc", data["xml_presentation_id"]) + } + if data["revision_id"] != float64(7) { + t.Fatalf("revision_id = %v, want 7", data["revision_id"]) + } + if data["size"] != float64(len(xml)) { + t.Fatalf("size = %v, want %d", data["size"], len(xml)) + } + gotPath, _ := data["path"].(string) + if !filepath.IsAbs(gotPath) { + t.Fatalf("path = %v, want absolute path", gotPath) + } + if !strings.HasSuffix(gotPath, "readback.xml") { + t.Fatalf("path = %v, want readback.xml suffix", gotPath) + } +} + +func TestSlidesXMLGetResolvesWikiPresentation(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/get_node", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "obj_type": "slides", + "obj_token": "pres_real", + }, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_real", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "xml_presentation": map[string]interface{}{ + "content": `<presentation/>`, + }, + }, + }, + }) + + err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{ + "+xml-get", + "--presentation", "https://example.feishu.cn/wiki/wikcn123", + "--output", "wiki.xml", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeShortcutData(t, stdout) + if data["xml_presentation_id"] != "pres_real" { + t.Fatalf("xml_presentation_id = %v, want pres_real", data["xml_presentation_id"]) + } +} + +func TestSlidesXMLGetRejectsUnsafeOutputPath(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{ + "+xml-get", + "--presentation", "pres_abc", + "--output", "../readback.xml", + "--as", "user", + }) + if err == nil { + t.Fatal("expected unsafe output path error, got nil") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T %v", err, err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryValidation) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got %T %v", err, err) + } + if validationErr.Param != "--output" { + t.Fatalf("param = %q, want --output", validationErr.Param) + } +} diff --git a/shortcuts/vc/shortcuts.go b/shortcuts/vc/shortcuts.go index 44464447..6b4ab523 100644 --- a/shortcuts/vc/shortcuts.go +++ b/shortcuts/vc/shortcuts.go @@ -11,6 +11,7 @@ func Shortcuts() []common.Shortcut { VCSearch, VCNotes, VCRecording, + VCDetail, VCMeetingJoin, VCMeetingLeave, VCMeetingListActive, diff --git a/shortcuts/vc/vc_detail.go b/shortcuts/vc/vc_detail.go new file mode 100644 index 00000000..05f1055f --- /dev/null +++ b/shortcuts/vc/vc_detail.go @@ -0,0 +1,216 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT +// +// vc +detail — get meeting details including note_id and minute_token + +package vc + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const detailLogPrefix = "[vc +detail]" + +var scopesDetailMeetingIDs = []string{ + "vc:meeting.meetingevent:read", + "vc:record:readonly", +} + +// meetingDetailItem represents a single meeting detail result. +type meetingDetailItem struct { + MeetingID string `json:"meeting_id"` + MeetingNo string `json:"meeting_no,omitempty"` + Topic string `json:"topic"` + StartTime string `json:"start_time,omitempty"` + EndTime string `json:"end_time,omitempty"` + NoteID string `json:"note_id,omitempty"` + MinuteToken string `json:"minute_token,omitempty"` + Error string `json:"error,omitempty"` + Hint string `json:"hint,omitempty"` +} + +// fetchMeetingDetail queries meeting.get and recording API to return a +// consolidated view of meeting metadata, note_id, and minute_token. +// Error is only set when an API call actually fails; note_id and minute_token +// are always present (empty string when not available). +func fetchMeetingDetail(ctx context.Context, runtime *common.RuntimeContext, meetingID string) *meetingDetailItem { + result := &meetingDetailItem{MeetingID: meetingID} + + // Step 1: query meeting detail + data, err := runtime.CallAPITyped(http.MethodGet, + fmt.Sprintf("/open-apis/vc/v1/meetings/%s", validate.EncodePathSegment(meetingID)), + map[string]interface{}{"with_participants": "false", "query_mode": "0"}, nil) + if err != nil { + result.Error = fmt.Sprintf("failed to query meeting detail: %v", err) + return result + } + + meeting, _ := data["meeting"].(map[string]any) + if meeting == nil { + result.Error = "meeting not found in response" + return result + } + + if v, ok := meeting["meeting_no"].(string); ok { + result.MeetingNo = v + } + if v, ok := meeting["topic"].(string); ok { + result.Topic = v + } + if v := common.FormatTime(meeting["start_time"]); v != "" { + result.StartTime = v + } + if v := common.FormatTime(meeting["end_time"]); v != "" { + result.EndTime = v + } + if v, ok := meeting["note_id"].(string); ok && v != "" { + result.NoteID = v + } + + // Step 2: query minute_token via recording API + minuteToken, minuteHint, minuteErr := fetchMeetingMinuteToken(runtime, meetingID) + if minuteErr != nil { + // Recording API failed — surface the error but keep data from step 1 + result.Error = fmt.Sprintf("failed to query minutes: %v", minuteErr) + minuteHint = "" + } + if minuteToken != "" { + result.MinuteToken = minuteToken + } + + // Add hints for empty resources (not errors, just informational) + var emptyFields []string + if result.NoteID == "" { + emptyFields = append(emptyFields, "note_id") + } + if result.MinuteToken == "" && minuteErr == nil && minuteHint == "" { + emptyFields = append(emptyFields, "minute_token") + } + if len(emptyFields) > 0 { + result.Hint = fmt.Sprintf("%s not found for this meeting", strings.Join(emptyFields, ", ")) + } + if minuteHint != "" { + if result.Hint != "" { + result.Hint += "; " + minuteHint + } else { + result.Hint = minuteHint + } + } + + return result +} + +// VCDetail gets meeting details including note_id and minute_token. +var VCDetail = common.Shortcut{ + Service: "vc", + Command: "+detail", + Description: "Get meeting details including note_id and minute_token by meeting IDs", + Risk: "read", + Scopes: []string{"vc:meeting.meetingevent:read", "vc:record:readonly"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "meeting-ids", Desc: "meeting IDs, comma-separated for batch", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + ids := common.SplitCSV(runtime.Str("meeting-ids")) + const maxBatchSize = 50 + if len(ids) > maxBatchSize { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--meeting-ids: too many IDs (%d), maximum is %d", len(ids), maxBatchSize).WithParam("--meeting-ids") + } + // dynamic scope check + result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID)) + if err == nil && result != nil && result.Scopes != "" { + if missing := auth.MissingScopes(result.Scopes, scopesDetailMeetingIDs); len(missing) > 0 { + return errs.NewPermissionError(errs.SubtypeMissingScope, + "missing required scope(s): %s", strings.Join(missing, ", ")). + WithHint("run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")). + WithMissingScopes(missing...). + WithIdentity(string(runtime.As())) + } + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + ids := runtime.Str("meeting-ids") + return common.NewDryRunAPI(). + GET("/open-apis/vc/v1/meetings/{meeting_id}"). + GET("/open-apis/vc/v1/meetings/{meeting_id}/recording"). + Set("meeting_ids", common.SplitCSV(ids)). + Set("steps", "meeting.get → note_id + recording API → minute_token") + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + errOut := runtime.IO().ErrOut + meetingIDs := common.SplitCSV(runtime.Str("meeting-ids")) + results := make([]*meetingDetailItem, 0, len(meetingIDs)) + + const batchDelay = 100 * time.Millisecond + fmt.Fprintf(errOut, "%s querying %d meeting_id(s)\n", detailLogPrefix, len(meetingIDs)) + for i, id := range meetingIDs { + if err := ctx.Err(); err != nil { + return err + } + if i > 0 { + time.Sleep(batchDelay) + } + fmt.Fprintf(errOut, "%s querying meeting_id=%s ...\n", detailLogPrefix, sanitizeLogValue(id)) + results = append(results, fetchMeetingDetail(ctx, runtime, id)) + } + + successCount := 0 + for _, r := range results { + if r.Error == "" { + successCount++ + } + } + fmt.Fprintf(errOut, "%s done: %d total, %d succeeded, %d failed\n", detailLogPrefix, len(results), successCount, len(results)-successCount) + + if successCount == 0 && len(results) > 0 { + return runtime.OutPartialFailure(map[string]any{"meetings": results}, &output.Meta{Count: len(results)}) + } + + outData := map[string]any{"meetings": results} + runtime.OutFormat(outData, &output.Meta{Count: len(results)}, func(w io.Writer) { + if len(results) == 0 { + fmt.Fprintln(w, "No meetings.") + return + } + var rows []map[string]interface{} + for _, r := range results { + row := map[string]interface{}{"meeting_id": r.MeetingID} + if r.Error != "" { + row["status"] = "FAIL" + row["error"] = r.Error + } else { + row["status"] = "OK" + } + if r.NoteID != "" { + row["note_id"] = r.NoteID + } + if r.MinuteToken != "" { + row["minute_token"] = r.MinuteToken + } + row["topic"] = r.Topic + if r.Hint != "" { + row["hint"] = r.Hint + } + rows = append(rows, row) + } + output.PrintTable(w, rows) + fmt.Fprintf(w, "\n%d meeting(s), %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount) + }) + return nil + }, +} diff --git a/shortcuts/vc/vc_detail_test.go b/shortcuts/vc/vc_detail_test.go new file mode 100644 index 00000000..4440a78c --- /dev/null +++ b/shortcuts/vc/vc_detail_test.go @@ -0,0 +1,282 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +// --------------------------------------------------------------------------- +// Validation tests +// --------------------------------------------------------------------------- + +func TestDetail_Validation_MissingMeetingIDs(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := mountAndRun(t, VCDetail, []string{"+detail", "--as", "user"}, f, nil) + if err == nil { + t.Fatal("expected validation error for missing --meeting-ids") + } + if !strings.Contains(err.Error(), "meeting-ids") { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestDetail_Validation_BatchLimit(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + ids := make([]string, 51) + for i := range ids { + ids[i] = fmt.Sprintf("m%d", i) + } + err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", strings.Join(ids, ","), "--as", "user"}, f, nil) + if err == nil { + t.Fatal("expected batch limit error") + } + if !strings.Contains(err.Error(), "too many IDs") { + t.Errorf("expected 'too many IDs' error, got: %v", err) + } + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) + } + if ve.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype) + } +} + +// --------------------------------------------------------------------------- +// DryRun tests +// --------------------------------------------------------------------------- + +func TestDetail_DryRun(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m001", "--dry-run", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "/open-apis/vc/v1/meetings/") { + t.Errorf("dry-run should show meeting API path, got: %s", stdout.String()) + } + if !strings.Contains(stdout.String(), "recording") { + t.Errorf("dry-run should show recording API path, got: %s", stdout.String()) + } +} + +// --------------------------------------------------------------------------- +// Execute tests with mocked HTTP +// --------------------------------------------------------------------------- + +func TestDetail_Execute_Success(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(meetingGetStub("m_detail1", "note_001")) + reg.Register(recordingOKStub("m_detail1", "https://meetings.feishu.cn/minutes/obc_detail1")) + + err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m_detail1", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var resp map[string]any + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + data, _ := resp["data"].(map[string]any) + meetings, _ := data["meetings"].([]any) + if len(meetings) != 1 { + t.Fatalf("expected 1 meeting, got %d", len(meetings)) + } + m, _ := meetings[0].(map[string]any) + if m["meeting_id"] != "m_detail1" { + t.Errorf("meeting_id = %v, want m_detail1", m["meeting_id"]) + } + if m["note_id"] != "note_001" { + t.Errorf("note_id = %v, want note_001", m["note_id"]) + } + if m["minute_token"] != "obc_detail1" { + t.Errorf("minute_token = %v, want obc_detail1", m["minute_token"]) + } +} + +func TestDetail_Execute_NoNoteNoMinute(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(meetingGetStub("m_nonote", "")) + reg.Register(recordingErrStub("m_nonote", 121004, "not found")) + + err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m_nonote", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify hint is present for empty note_id and missing recording + var resp map[string]any + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + data, _ := resp["data"].(map[string]any) + meetings, _ := data["meetings"].([]any) + m, _ := meetings[0].(map[string]any) + if hint, _ := m["hint"].(string); !strings.Contains(hint, "note_id") || !strings.Contains(hint, "no minute file for this meeting") { + t.Errorf("hint should mention note_id and minute file missing, got: %v", hint) + } + if errMsg, _ := m["error"].(string); errMsg != "" { + t.Errorf("error should be empty, got: %v", errMsg) + } +} + +func TestDetail_Execute_MeetingNotFound(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/vc/v1/meetings/m_bad", + Body: map[string]interface{}{"code": 121004, "msg": "data not found"}, + }) + + err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m_bad", "--as", "user"}, f, stdout) + if err == nil { + t.Fatal("expected partial failure error") + } +} + +func TestDetail_Execute_Batch(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + // m1 succeeds with note and minute + reg.Register(meetingGetStub("m_batch1", "note_b1")) + reg.Register(recordingOKStub("m_batch1", "https://meetings.feishu.cn/minutes/obc_b1")) + // m2 has no note_id but has minute + reg.Register(meetingGetStub("m_batch2", "")) + reg.Register(recordingOKStub("m_batch2", "https://meetings.feishu.cn/minutes/obc_b2")) + + err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m_batch1,m_batch2", "--as", "user"}, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var resp map[string]any + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + data, _ := resp["data"].(map[string]any) + meetings, _ := data["meetings"].([]any) + if len(meetings) != 2 { + t.Fatalf("expected 2 meetings, got %d", len(meetings)) + } +} + +// --------------------------------------------------------------------------- +// Pure function tests +// --------------------------------------------------------------------------- + +func TestFetchMeetingDetail_MeetingWithNoteAndMinute(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(meetingGetStub("m_fn", "note_fn")) + reg.Register(recordingOKStub("m_fn", "https://meetings.feishu.cn/minutes/obc_fn")) + + if err := botExec(t, "detail-fn", f, func(_ context.Context, rctx *common.RuntimeContext) error { + result := fetchMeetingDetail(context.Background(), rctx, "m_fn") + if result.MeetingID != "m_fn" { + t.Errorf("meeting_id = %v, want m_fn", result.MeetingID) + } + if result.NoteID != "note_fn" { + t.Errorf("note_id = %v, want note_fn", result.NoteID) + } + if result.MinuteToken != "obc_fn" { + t.Errorf("minute_token = %v, want obc_fn", result.MinuteToken) + } + if result.Error != "" { + t.Errorf("unexpected error: %v", result.Error) + } + return nil + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestFetchMeetingDetail_MeetingNotFound(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/vc/v1/meetings/m_nf", + Body: map[string]interface{}{"code": 121004, "msg": "data not found"}, + }) + + if err := botExec(t, "detail-nf", f, func(_ context.Context, rctx *common.RuntimeContext) error { + result := fetchMeetingDetail(context.Background(), rctx, "m_nf") + if result.Error == "" { + t.Error("expected error for meeting not found") + } + // note_id and minute_token should still be present (empty) + if result.NoteID != "" { + t.Errorf("note_id = %q, want empty", result.NoteID) + } + if result.MinuteToken != "" { + t.Errorf("minute_token = %q, want empty", result.MinuteToken) + } + return nil + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestFetchMeetingDetail_RecordingFailsButNoteOK(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(meetingGetStub("m_partial", "note_partial")) + reg.Register(recordingErrStub("m_partial", 121004, "not found")) + + if err := botExec(t, "detail-partial", f, func(_ context.Context, rctx *common.RuntimeContext) error { + result := fetchMeetingDetail(context.Background(), rctx, "m_partial") + if result.NoteID != "note_partial" { + t.Errorf("note_id = %v, want note_partial", result.NoteID) + } + if result.MinuteToken != "" { + t.Errorf("minute_token = %q, want empty", result.MinuteToken) + } + if result.Error != "" { + t.Errorf("error = %q, want empty", result.Error) + } + if !strings.Contains(result.Hint, "no minute file for this meeting") { + t.Errorf("hint = %q, want contains 'no minute file for this meeting'", result.Hint) + } + return nil + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestFetchMeetingDetail_RecordingAPIErrorButNoteOK(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(meetingGetStub("m_api_err", "note_apierr")) + reg.Register(recordingErrStub("m_api_err", 99999, "weird API error")) + + if err := botExec(t, "detail-apierr", f, func(_ context.Context, rctx *common.RuntimeContext) error { + result := fetchMeetingDetail(context.Background(), rctx, "m_api_err") + if result.NoteID != "note_apierr" { + t.Errorf("note_id = %v, want note_apierr", result.NoteID) + } + if result.MinuteToken != "" { + t.Errorf("minute_token = %q, want empty", result.MinuteToken) + } + if !strings.Contains(result.Error, "failed to query minutes") || !strings.Contains(result.Error, "weird API error") { + t.Errorf("error = %q, want contains 'failed to query minutes' and 'weird API error'", result.Error) + } + if strings.Contains(result.Hint, "minute_token") { + t.Errorf("hint = %q, should not mention minute_token when there is an error", result.Hint) + } + return nil + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/shortcuts/vc/vc_meeting_events_test.go b/shortcuts/vc/vc_meeting_events_test.go index 2e9e7354..19849873 100644 --- a/shortcuts/vc/vc_meeting_events_test.go +++ b/shortcuts/vc/vc_meeting_events_test.go @@ -838,7 +838,7 @@ func TestVCShortcuts_RegistersMeetingAgentCommands(t *testing.T) { for _, shortcut := range got { commands = append(commands, shortcut.Command) } - want := []string{"+search", "+notes", "+recording", "+meeting-join", "+meeting-leave", "+meeting-list-active", "+meeting-events"} + want := []string{"+search", "+notes", "+recording", "+detail", "+meeting-join", "+meeting-leave", "+meeting-list-active", "+meeting-events"} if !reflect.DeepEqual(commands, want) { t.Fatalf("shortcut commands = %#v, want %#v", commands, want) } diff --git a/shortcuts/vc/vc_notes.go b/shortcuts/vc/vc_notes.go index 30b66856..555acc70 100644 --- a/shortcuts/vc/vc_notes.go +++ b/shortcuts/vc/vc_notes.go @@ -263,42 +263,35 @@ func asStringSlice(v any) []string { } // fetchMeetingMinuteToken queries the recording API of a meeting and returns -// the associated minute_token (parsed from the recording URL) and an -// optional human-friendly error message. On success token is non-empty and -// errMsg is empty; on failure token is empty and errMsg describes the cause: -// - 121004: meeting has no minute file -// - 121005: caller has no permission for the meeting recording -// - 124002: recording / minute file is still being generated -// -// Other failures fall back to the raw API error description so Agents can -// still parse the underlying cause. -func fetchMeetingMinuteToken(runtime *common.RuntimeContext, meetingID string) (token, errMsg string) { - data, err := runtime.CallAPITyped(http.MethodGet, +// the associated minute_token (parsed from the recording URL), an optional +// hint for expected missing states, and an error for unexpected failures. +func fetchMeetingMinuteToken(runtime *common.RuntimeContext, meetingID string) (token, hint string, err error) { + data, apiErr := runtime.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/meetings/%s/recording", validate.EncodePathSegment(meetingID)), nil, nil) - if err != nil { - if p, ok := errs.ProblemOf(err); ok { + if apiErr != nil { + if p, ok := errs.ProblemOf(apiErr); ok { switch p.Code { case recordingNotFoundCode: - return "", "no minute file for this meeting" + return "", "no minute file for this meeting", nil case recordingNoPermissionCode: - return "", "no permission to access this meeting's minute; ask the meeting owner to share the minute" + return "", "no permission to access this meeting's minute; ask the meeting owner to share the minute", nil case recordingGeneratingCode: - return "", "minute file is still being generated; please retry later" + return "", "minute file is still being generated; please retry later", nil } } - return "", fmt.Sprintf("failed to query recording: %v", err) + return "", "", apiErr } recording, _ := data["recording"].(map[string]any) if recording == nil { - return "", "no recording available for this meeting" + return "", "no recording available for this meeting", nil } recordingURL, _ := recording["url"].(string) if t := extractMinuteToken(recordingURL); t != "" { - return t, "" + return t, "", nil } - return "", "no minute_token found in recording URL" + return "", "no minute_token found in recording URL", nil } // fetchNoteByMeetingID queries notes via meeting_id and additionally fetches @@ -321,7 +314,7 @@ func fetchNoteByMeetingID(ctx context.Context, runtime *common.RuntimeContext, m // Always attempt to query the meeting's minute_token via the recording API, // regardless of whether the meeting has a note_id, so callers always see // minute state for follow-up calls (e.g. `vc +notes --minute-tokens=...`). - minuteToken, minuteErr := fetchMeetingMinuteToken(runtime, meetingID) + minuteToken, minuteHint, minuteErr := fetchMeetingMinuteToken(runtime, meetingID) var result map[string]any var noteErr string @@ -340,7 +333,13 @@ func fetchNoteByMeetingID(ctx context.Context, runtime *common.RuntimeContext, m if minuteToken != "" { result["minute_token"] = minuteToken } - if combined := joinErrors(noteErr, minuteErr); combined != "" { + var minuteErrMsg string + if minuteHint != "" { + minuteErrMsg = minuteHint + } else if minuteErr != nil { + minuteErrMsg = minuteErr.Error() + } + if combined := joinErrors(noteErr, minuteErrMsg); combined != "" { result["error"] = combined } return result @@ -538,6 +537,7 @@ var VCNotes = common.Shortcut{ Risk: "read", Scopes: []string{"vc:note:read"}, // minimum scope; additional per-flag scopes checked in Validate AuthTypes: []string{"user"}, + Hidden: true, // hidden from --help; prefer vc +detail, minutes +detail, or note +detail HasFormat: true, Flags: []common.Flag{ {Name: "meeting-ids", Desc: "meeting IDs, comma-separated for batch"}, diff --git a/shortcuts/vc/vc_notes_test.go b/shortcuts/vc/vc_notes_test.go index a5b55ca6..992c5f2d 100644 --- a/shortcuts/vc/vc_notes_test.go +++ b/shortcuts/vc/vc_notes_test.go @@ -792,12 +792,15 @@ func TestFetchMeetingMinuteToken_Success(t *testing.T) { reg.Register(recordingOKStub("m_ok", "https://meetings.feishu.cn/minutes/obctoken_ok")) if err := botExec(t, "fmmt-ok", f, func(_ context.Context, rctx *common.RuntimeContext) error { - token, msg := fetchMeetingMinuteToken(rctx, "m_ok") + token, hint, err := fetchMeetingMinuteToken(rctx, "m_ok") if token != "obctoken_ok" { t.Errorf("token = %q, want obctoken_ok", token) } - if msg != "" { - t.Errorf("errMsg = %q, want empty", msg) + if hint != "" { + t.Errorf("hint = %q, want empty", hint) + } + if err != nil { + t.Errorf("err = %v, want nil", err) } return nil }); err != nil { @@ -823,12 +826,15 @@ func TestFetchMeetingMinuteToken_KnownErrorCodes(t *testing.T) { reg.Register(recordingErrStub(tt.meetingID, tt.code, "err")) if err := botExec(t, "fmmt-"+tt.meetingID, f, func(_ context.Context, rctx *common.RuntimeContext) error { - token, msg := fetchMeetingMinuteToken(rctx, tt.meetingID) + token, hint, err := fetchMeetingMinuteToken(rctx, tt.meetingID) if token != "" { t.Errorf("token = %q, want empty on error", token) } - if !strings.Contains(msg, tt.wantMsg) { - t.Errorf("errMsg = %q, want contains %q", msg, tt.wantMsg) + if !strings.Contains(hint, tt.wantMsg) { + t.Errorf("hint = %q, want contains %q", hint, tt.wantMsg) + } + if err != nil { + t.Errorf("err = %v, want nil", err) } return nil }); err != nil { @@ -844,12 +850,15 @@ func TestFetchMeetingMinuteToken_GenericAPIError(t *testing.T) { reg.Register(recordingErrStub("m_other", 99999, "weird")) if err := botExec(t, "fmmt-generic", f, func(_ context.Context, rctx *common.RuntimeContext) error { - token, msg := fetchMeetingMinuteToken(rctx, "m_other") + token, hint, err := fetchMeetingMinuteToken(rctx, "m_other") if token != "" { t.Errorf("token = %q, want empty", token) } - if !strings.Contains(msg, "failed to query recording") { - t.Errorf("errMsg = %q, want contains 'failed to query recording'", msg) + if hint != "" { + t.Errorf("hint = %q, want empty", hint) + } + if err == nil || !strings.Contains(err.Error(), "weird") { + t.Errorf("err = %v, want contains 'weird'", err) } return nil }); err != nil { @@ -866,12 +875,15 @@ func TestFetchMeetingMinuteToken_NoRecording(t *testing.T) { })) if err := botExec(t, "fmmt-norec", f, func(_ context.Context, rctx *common.RuntimeContext) error { - token, msg := fetchMeetingMinuteToken(rctx, "m_norec") + token, hint, err := fetchMeetingMinuteToken(rctx, "m_norec") if token != "" { t.Errorf("token = %q, want empty", token) } - if !strings.Contains(msg, "no recording available") { - t.Errorf("errMsg = %q, want contains 'no recording available'", msg) + if err != nil { + t.Errorf("err = %v, want nil", err) + } + if !strings.Contains(hint, "no recording available") { + t.Errorf("hint = %q, want contains 'no recording available'", hint) } return nil }); err != nil { @@ -885,12 +897,15 @@ func TestFetchMeetingMinuteToken_URLWithoutToken(t *testing.T) { reg.Register(recordingOKStub("m_notok", "https://example.com/no/minute/path")) if err := botExec(t, "fmmt-notok", f, func(_ context.Context, rctx *common.RuntimeContext) error { - token, msg := fetchMeetingMinuteToken(rctx, "m_notok") + token, hint, err := fetchMeetingMinuteToken(rctx, "m_notok") if token != "" { t.Errorf("token = %q, want empty", token) } - if !strings.Contains(msg, "no minute_token found") { - t.Errorf("errMsg = %q, want contains 'no minute_token found'", msg) + if err != nil { + t.Errorf("err = %v, want nil", err) + } + if !strings.Contains(hint, "no minute_token found") { + t.Errorf("hint = %q, want contains 'no minute_token found'", hint) } return nil }); err != nil { @@ -983,7 +998,7 @@ func TestNotes_MeetingPath_OnlyMinuteFails_PartialSuccess(t *testing.T) { t.Errorf("note_doc_token = %v, want doc_main", got) } assertNoteFieldAbsent(t, note, "minute_token") - assertNoteError(t, note, "no permission to access this meeting's minute") + assertNoteError(t, note, "no permission to access this meeting's minute; ask the meeting owner to share the minute") } func TestNotes_MeetingPath_NoNote_ButMinuteOK(t *testing.T) { @@ -1068,6 +1083,7 @@ func TestNotes_MeetingPath_NoteNoPermission_FriendlyHint(t *testing.T) { assertNoteError(t, note, "[121005]", "no read permission for this meeting note", + "no permission to access this meeting's minute", "; ", // note + minute causes joined with semicolon ) } diff --git a/shortcuts/vc/vc_search.go b/shortcuts/vc/vc_search.go index 37d3685a..f0bac1ab 100644 --- a/shortcuts/vc/vc_search.go +++ b/shortcuts/vc/vc_search.go @@ -230,9 +230,16 @@ var VCSearch = common.Shortcut{ data = map[string]interface{}{} } items := common.GetSlice(data, "items") + // Strip avatar from meta_data — not useful for AI agents. + for _, raw := range items { + if m, ok := raw.(map[string]interface{}); ok { + if meta, ok := m["meta_data"].(map[string]interface{}); ok { + delete(meta, "avatar") + } + } + } outData := map[string]interface{}{ "items": items, - "total": data["total"], "has_more": data["has_more"], "page_token": data["page_token"], } diff --git a/skill-template/domains/approval.md b/skill-template/domains/approval.md new file mode 100644 index 00000000..c79be9e4 --- /dev/null +++ b/skill-template/domains/approval.md @@ -0,0 +1,40 @@ +所有命令默认 `--as user`(审批是人的动作)。调用前先 `lark-cli schema approval.<resource>.<method>` 查参数结构,不要猜字段。 + +## 选哪个命令 + +| 想做什么 | 命令 | +|---|---| +| 搜可发起定义 | `approvals search` | +| 看审批定义详情/提单前确认表单与流程 | `approvals get` | +| 发起原生审批实例 | `instances create` | +| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读)| +| 看表单/进度/当前节点 | `instances get` | +| 同意/拒绝 | `tasks approve` / `tasks reject` | +| 转交/加签/退回 | `tasks transfer` / `tasks add_sign` / `tasks rollback` | +| 催办 | `tasks remind` | +| 撤回/抄送/按定义查已发起 | `instances cancel` / `instances cc` / `instances initiated` | + +处理链: + +- 发起审批:`approvals search` -> `approvals get` -> `instances.create` +- 处理审批:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 需要细节再 `instances get` → 执行操作 + +```bash +lark-cli approval approvals search --data '{"keyword":"请假"}' --as user +lark-cli approval approvals get --params '{"approval_code":"<code>"}' --as user +lark-cli approval instances create --data '{"approval_code":"<code>","form":"[...]"}' --yes --as user +lark-cli approval tasks query --params '{"topic":"1"}' --as user +lark-cli approval tasks approve --data '{"instance_code":"<ic>","task_id":"<tid>","comment":"同意"}' --as user +``` + +## 发起原生审批 + +发起审批属于高风险写操作,按下表处理: + +| 规则 | 处理 | +|---|---| +| 用户意图是发起审批 / 提单 / 提交请假审批 / 提交报销审批 / 创建审批实例 | 先读 [`references/lark-approval-initiate.md`](references/lark-approval-initiate.md)、[`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md) 和 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create` | +| 编排顺序 | 固定走 `approvals.search` -> `approvals.get` -> `instances.create`;未拿到定义详情前不要猜 `form`、`node_approver_list` 或 `node_cc_list` | +| 三方定义 | `is_external=true` 时不要调用 `approval instances create`,返回 `create_link` 并说明需通过链接发起 | +| 表单与节点参数 | 控件 `value` 结构看 [`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md);值来源看 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md) | +| 真正执行前 | 让用户确认最终定义、表单值和节点参数;执行时显式传 `--yes`,成功后回报 `instance_code` 与 `instance_link` | diff --git a/skill-template/domains/doc.md b/skill-template/domains/doc.md index 9d8b42af..17cf03d0 100644 --- a/skill-template/domains/doc.md +++ b/skill-template/domains/doc.md @@ -86,8 +86,8 @@ Drive Folder (云空间文件夹) ## 重要说明:画板编辑 > **⚠️ lark-doc skill 不能直接编辑已有画板内容,但 `docs +update` 可以新建空白画板** -### 场景 1:已通过 docs +fetch --api-version v2 获取到文档内容和画板 token -如果用户已经通过 `docs +fetch --api-version v2` 拉取了文档内容,并且文档中已有画板(返回的 markdown 中包含 `<whiteboard token="xxx"/>` 标签),请引导用户: +### 场景 1:已通过 docs +fetch 获取到文档内容和画板 token +如果用户已经通过 `docs +fetch` 拉取了文档内容,并且文档中已有画板(返回的 markdown 中包含 `<whiteboard token="xxx"/>` 标签),请引导用户: 1. 记录画板的 token 2. 查看 [`../lark-whiteboard/SKILL.md`](../lark-whiteboard/SKILL.md) 了解如何编辑画板内容 ### 场景 2:刚创建画板,需要编辑 diff --git a/skill-template/domains/drive.md b/skill-template/domains/drive.md index fbaf4e8b..99fbabce 100644 --- a/skill-template/domains/drive.md +++ b/skill-template/domains/drive.md @@ -111,7 +111,7 @@ Drive Folder (云空间文件夹) | 操作 | 需要的 Token | 说明 | |------|-------------|------| -| 读取文档内容 | `file_token` / 通过 `docs +fetch --api-version v2` 自动处理 | `docs +fetch --api-version v2` 支持直接传入 URL | +| 读取文档内容 | `file_token` / 通过 `docs +fetch` 自动处理 | `docs +fetch` 支持直接传入 URL | | 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;`docx` 支持文本定位或 block_id,`sheet` 使用 `<sheetId>!<cell>`,`slides` 使用 `<slide-block-type>!<xml-id>`;Base / bitable 只有记录局部评论,定位为 file_token(base token) + `--block-id <table-id>!<record-id>!<view-id>` | | 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终解析为 `doc`/`docx`/`file` 的 wiki URL | | 下载文件 | `file_token` | 从文件 URL 中直接提取 | diff --git a/skills/lark-approval/SKILL.md b/skills/lark-approval/SKILL.md index 8bf2e7d6..04b9403c 100644 --- a/skills/lark-approval/SKILL.md +++ b/skills/lark-approval/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-approval -version: 1.1.0 -description: "飞书审批:当前用户审批的查询与全部处理操作,覆盖待本人审批的任务与本人发起的实例。审批待办不是飞书任务(任务类待办走 lark-task);不负责创建审批定义和发起新审批。" +version: 1.2.0 +description: "飞书审批:查询和处理审批待办/已办/实例,搜索可发起审批定义、查看定义详情并发起原生审批实例。当用户要处理审批任务、查看审批实例、搜索或发起审批时使用。审批待办不是飞书任务;非审批类待办走 lark-task。不负责创建审批定义;三方审批定义不走原生提单。" metadata: requires: bins: ["lark-cli"] @@ -16,6 +16,9 @@ metadata: | 想做什么 | 命令 | |---|---| +| 搜可发起定义 | `approvals search` | +| 看审批定义详情/提单前确认表单与流程 | `approvals get` | +| 发起原生审批实例 | `instances create` | | 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读)| | 看表单/进度/当前节点 | `instances get` | | 同意/拒绝 | `tasks approve` / `tasks reject` | @@ -23,13 +26,31 @@ metadata: | 催办 | `tasks remind` | | 撤回/抄送/按定义查已发起 | `instances cancel` / `instances cc` / `instances initiated` | -处理链:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 需要细节再 `instances get` → 执行操作。 +处理链: + +- 发起审批:`approvals search` -> `approvals get` -> `instances.create` +- 处理审批:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 需要细节再 `instances get` → 执行操作 ```bash +lark-cli approval approvals search --data '{"keyword":"请假"}' --as user +lark-cli approval approvals get --params '{"approval_code":"<code>"}' --as user +lark-cli approval instances create --data '{"approval_code":"<code>","form":"[...]"}' --yes --as user lark-cli approval tasks query --params '{"topic":"1"}' --as user lark-cli approval tasks approve --data '{"instance_code":"<ic>","task_id":"<tid>","comment":"同意"}' --as user ``` +## 发起原生审批 + +发起审批属于高风险写操作,按下表处理: + +| 规则 | 处理 | +|---|---| +| 用户意图是发起审批 / 提单 / 提交请假审批 / 提交报销审批 / 创建审批实例 | 先读 [`references/lark-approval-initiate.md`](references/lark-approval-initiate.md)、[`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md) 和 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create` | +| 编排顺序 | 固定走 `approvals.search` -> `approvals.get` -> `instances.create`;未拿到定义详情前不要猜 `form`、`node_approver_list` 或 `node_cc_list` | +| 三方定义 | `is_external=true` 时不要调用 `approval instances create`,返回 `create_link` 并说明需通过链接发起 | +| 表单与节点参数 | 控件 `value` 结构看 [`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md);值来源看 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md) | +| 真正执行前 | 让用户确认最终定义、表单值和节点参数;执行时显式传 `--yes`,成功后回报 `instance_code` 与 `instance_link` | + ## 不在本 skill 范围 -创建审批定义/发起新审批(走飞书客户端或审批管理后台);非审批类待办 → [`lark-task`](../lark-task/SKILL.md) +创建审批定义(走飞书客户端或审批管理后台);三方定义发起(返回 `create_link`,引导用户通过链接发起);非审批类待办 → [`lark-task`](../lark-task/SKILL.md) diff --git a/skills/lark-approval/references/lark-approval-initiate.md b/skills/lark-approval/references/lark-approval-initiate.md new file mode 100644 index 00000000..d5621a2c --- /dev/null +++ b/skills/lark-approval/references/lark-approval-initiate.md @@ -0,0 +1,196 @@ +# 审批提单工作流 + +## 执行摘要 + +- **原生审批提单必须固定走 `approvals.search` -> `approvals.get` -> `instances.create`。** 不要跳过 `get` 直接拼请求。 +- **`is_external=true` 的定义是三方定义。** 这类定义不要调用 `instances.create`,应优先使用 `create_link`。 +- **所有人员类参数默认使用 `open_id`。** 若用户给的是姓名、邮箱或其他身份,先用 [`../../lark-contact/SKILL.md`](../../lark-contact/SKILL.md) 解析。 +- **先读控件参数 reference 和值来源 reference,再看 `schema`。** 提单前必须先阅读 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 和 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create`。 +- **`approvals.get.form` 不是创建 payload 的原样模板。** 它主要用于识别控件 `id`、`type`、选项值范围和明细子控件结构;真正的 `instances.create.data.form` 中,请求字段与节点字段以 `schema` / `meta` 为准,控件 `value` 结构以 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 为准。 +- **节点参数只从 `node_list` 和 `schema` / `meta` 里取。** 节点 key 必须来自定义详情返回的节点标识;审批人/抄送人列表传用户 ID 时,要先与当前 `schema` 字段名和 ID 口径对齐,不要混用姓名或其他身份标识。 +- **看到 `need_approver=true` 就说明该节点需要发起人补充审批人。** 如果 `approver_chosen_multi=false`,该节点只允许一个 `open_id`。 +- **创建实例前先确认。** `approval instances create` 是写操作,真正执行时显式传 `--yes`。 + +## 适用场景 + +- “帮我提交一个请假审批” +- “帮我发起报销审批” +- “我想提一个出差审批” +- “先搜可发起的审批,再帮我提单” + +## 严禁行为 + +- **严禁在未先查看 `schema` 的情况下猜测 `--data` 结构。** +- **严禁在未先阅读 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md)、[`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md) 且未先查看 `schema` 的情况下直接提单。** +- **严禁跳过 `approvals.get`。** 未拿到 `form` 和 `node_list` 前,不得调用 `instances.create`。 +- **严禁把姓名直接写进 `node_approver_list`、`node_cc_list` 或表单人员控件。** 必须先转成 `open_id`。 +- **严禁对三方定义调用 `instances.create`。** +- **严禁对 API 不支持的控件硬提单。** 如果目标定义包含创建实例 API 不支持的控件,应明确告诉用户该定义不能仅通过 API 完整发起。 +- **严禁把 `approvals.get.form` 当成可直接提交的原样模板。** +- **严禁在未得到用户确认前直接执行真实提单。** + +## 工作流 + +### 1. 搜索可发起审批定义 + +先用 `schema` 看参数,再搜索定义: + +```bash +lark-cli schema approval.approvals.search +lark-cli approval approvals search --data '{"keyword":"请假"}' +``` + +处理规则: + +- 若结果为空,告诉用户当前关键词下没有可发起定义。 +- 若命中多个定义,必须把候选项列给用户选择,不要自行猜测。 +- 若目标定义 `is_external=true`,优先返回 `create_link`,说明这是三方定义,不能走原生 `instances.create`。 +- 只有 `is_external=false` 的原生定义才继续下一步。 + +### 2. 获取审批定义详情 + +拿到 `approval_code` 后,读取定义详情: + +```bash +lark-cli schema approval.approvals.get +lark-cli approval approvals get \ + --params '{"approval_code":"7C468A54-8745-2245-9675-08B7C63E7A85"}' +``` + +重点关注返回: + +- `approval_name`: 当前发起的是哪个审批定义。 +- `form`: 表单定义快照,用于识别控件 `id`、`type`、选项值范围以及明细子控件结构;不是创建实例时可直接原样提交的 payload 模板。 +- `node_list`: 流程节点信息,是后续 `node_approver_list` / `node_cc_list` 的唯一可靠来源。 + +### 3. 组装 `form` + +`instances.create.data.form` 是一个 JSON 数组字符串。组装原则: + +- 先用 `approvals.get.form` 识别有哪些控件、每个控件的 `id` / `type` / 可选值范围,再按 `schema` / `meta` 与 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 重新组装创建 payload。 +- 提交时必须至少保证每个控件的 `id`、`type` 与 `value` 符合当前 `schema` 要求;不要假设定义快照里出现的其他字段都能直接照搬。 +- 如果用户提供的是人员信息,优先转换成 `open_id` 后再写入对应控件。 +- 单选/多选控件提交的是选项 `value`,该值可从 `approvals.get.form` 的选项定义中取得。 +- `contact`、`department`、`fieldList`、`dateInterval`、`amount`、`telephone`、`document` 等控件的 `value` 结构各不相同,必须按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 单独组装,不要套用文本控件的写法。 +- 值本身从哪里拿,优先按 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md) 处理;不要把“知道结构”误当成“已经拿到可提交值”。 +- 若 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 标明某控件不支持通过创建实例 API 提交,则不要硬猜绕过;应明确告诉用户该定义当前无法仅通过 API 提单。 +- 若遇到当前 skill 未明确覆盖的复杂控件,不要硬猜;先依据 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 判断支持性与传值结构,再向用户确认。 + +## API 不支持的控件 + +根据 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md),创建审批实例 API 不支持的控件至少包括: + +- `text` +- `mutableGroup` +- `account` +- `serialNumber` +- `tripGroup` +- `apaascorehrOnboardingGroup` +- `apaascorehrRegularateGroup` +- `remedyGroupV2` +- `apaascorehrJobAdjustGroup` +- `apaascorehrOffboardingGroup` + +如果目标审批定义包含上述控件,不要继续硬拼 `form`;应直接告诉用户该定义不能仅通过当前 API 完整提单。 + +## 高频控件速查 + +优先按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 组装,下面只保留最常用、最容易出错的格式: + +- `input` / `textarea`: `value` 是字符串 +- `date`: `value` 是 RFC3339 时间字符串 +- `dateInterval`: `value` 是对象,包含 `start` / `end` / `interval` +- `radio` / `radioV2`: `value` 是单个选项值,取自定义详情里的 option.value;关联外部选项时传 `options.id` +- `checkbox` / `checkboxV2`: `value` 是选项值数组 +- `number`: `value` 是数字 +- `amount`: `value` 是数字,还要带 `currency` +- `formula`: `value` 必须与定义中的公式结果匹配,否则会报错 +- `contact`: 只推荐写 `open_ids`,由人员信息先转换成 `open_id` +- `connect`: `value` 是关联审批实例 `instance_code` 数组,当前默认要求用户直接提供 `instance_code` +- `document`: `value` 是对象,至少含 `token` 和 `type=docx` +- `attachmentV2` / `image` / `imageV2`: `value` 是 file code 数组,当前默认要求用户直接提供 +- `fieldList`: `value` 是二维数组,子项继续按各自控件类型组装 +- `department`: `value` 是对象数组,元素字段名为 `open_id`,其值填写部门的 `open_department_id` +- `telephone`: `value` 是对象,包含 `countryCode` 和 `nationalNumber` +- `address`: `value` 是对象数组,至少包含地理库 `id`,可选 `detailAddress`;当前默认要求用户直接提供该 `id` + +## 特殊控件组 + +[`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 还明确给出了若干特殊控件组的提单格式,至少包括: + +- `leaveGroupV2` +- `workGroup` +- `outGroup` +- `shiftGroup` + +这类控件组不是简单文本控件,通常内部还嵌套 `radioV2`、`date`、`fieldList`、`image`、`contact` 等子控件。遇到这些控件组时: + +- 先从 `approvals.get.form` 找到控件组及其子控件 ID +- 再严格按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 的示例组装 `value` +- 不要把控件组整体当成普通字符串或扁平对象提交 + +### 4. 组装节点参数 + +从 `node_list` 推导节点参数: + +- 若某节点 `need_approver=true`,则必须在 `node_approver_list` 中补该节点的审批人。 +- `key` 优先取 `custom_node_id`;若不存在,再用 `node_id`。 +- `value` 是审批人 `open_id` 列表。 +- 若 `approver_chosen_multi=false`,该节点只允许一个审批人 `open_id`。 +- `node_cc_list` 仅在用户明确需要补充节点抄送人时才填写;其 `key/value` 规则与 `node_approver_list` 相同。 + +### 5. 创建审批实例 + +先看 `schema`,确认最终结构后再执行: + +```bash +lark-cli schema approval.instances.create + +lark-cli approval instances create \ + --data '{ + "approval_code":"7C468A54-8745-2245-9675-08B7C63E7A85", + "form":"[{\"id\":\"widget1\",\"type\":\"input\",\"value\":\"请假半天\"}]", + "node_approver_list":[ + { + "key":"manager_node_id", + "value":["ou_xxx"] + } + ] + }' \ + --yes +``` + +执行规则: + +- 执行前先向用户确认:目标审批定义、核心表单值、节点审批人/抄送人。 +- 若需要幂等,可补 `uuid`。 +- 成功后回报 `instance_code` 与 `instance_link`。 + +## 组装时优先依据的资料 + +优先级固定如下: + +1. `lark-cli schema approval.instances.create` 与对应 `meta`:决定创建请求体有哪些字段、节点参数怎么传。 +2. [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md):决定每种控件的 `value` 结构与支持范围。 +3. [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md):决定每类值应该从哪里拿,以及当前哪些值必须由用户直接提供。 +4. `approvals.get.form`:提供当前审批定义里实际有哪些控件、控件 `id`、控件 `type`、选项值范围、明细子控件结构。 +5. `approvals.get.node_list`:提供节点 key 与是否需要补充审批人/抄送人的线索。 + +不要反过来把 `approvals.get.form` 当成第一优先级,更不要把它当成可直接提交的 JSON 模板。 + +## 最小判断表 + +| 你手上有什么 | 下一步 | +|---|---| +| 只有口语需求,比如“帮我提个请假审批” | 先 `approvals.search` | +| 已经拿到 `approval_code` | 直接 `approvals.get` | +| 已拿到 `form` / `node_list`,且用户已给出表单值和审批人 | 组装 `instances.create` | +| `is_external=true` | 返回 `create_link`,不要调 `instances.create` | + +## 返回结果 + +完成创建后,至少向用户返回: + +- `approval_name` +- `instance_code` +- `instance_link` diff --git a/skills/lark-approval/references/lark-approval-instance-form-control-parameters.md b/skills/lark-approval/references/lark-approval-instance-form-control-parameters.md new file mode 100644 index 00000000..9ea936f3 --- /dev/null +++ b/skills/lark-approval/references/lark-approval-instance-form-control-parameters.md @@ -0,0 +1,606 @@ +# 审批实例表单控件参数 + +> 说明:本文尽量保留上游参数文档的原始结构与示例,用于回答“控件 `value` 长什么样”。 +> 当前 `lark-cli` 的推荐取值口径以 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md) 为准;如果两份文档在“值从哪里拿”上存在差异,以后者为准。 + +在调用创建审批实例接口时需要使用表单控件参数,你可以通过本文了解审批实例内各表单控件的参数说明。 + +## 准备工作 + +审批实例的表单控件参数依据审批定义表单来配置,例如,审批定义的表单设计包括了 **单行文本** 和 **日期区间** 控件,则审批实例的表单控件参数就需要为 **单行文本** 和 **日期区间** 控件进行赋值。因此,在操作审批实例表单的控件参数前,应先通过审批定义详情确认表单控件结构。 + +## 审批实例 API 不支持的控件 + +创建审批实例 API 未完全支持所有的审批表单控件,不支持的控件如下表所示。如果你必须使用 API 不支持的控件,则不能仅通过当前 API 完成提单。 + +**控件/控件组** | **Type** | +| ---------- | --------------------------- | +| 说明 | text | +| 引用多维表格 | mutableGroup | +| 收款账户 | account | +| 流水号 | serialNumber | +| 出差控件组 | tripGroup | +| 录用控件组 | apaascorehrOnboardingGroup | +| 转正控件组 | apaascorehrRegularateGroup | +| 补卡控件组 | remedyGroupV2 | +| 调岗控件组 | apaascorehrJobAdjustGroup | +| 离职控件组 | apaascorehrOffboardingGroup + +## 通用参数 + +审批实例的表单控件均包含的参数如下表所示。 + +参数 | 类型 | 是否必填 | 描述 +---|---|---|--- +id | string | 是 | 控件的 ID,需要与审批定义中的控件 ID 保持一致。 +type | string | 是 | 控件类型。各控件类型取值参见下文 **不同控件的参数** 章节。 +value | 不同控件的类型不同 | 是 | 控件的取值。不同控件 value 数据类型也不同,例如单行文本控件的 value 为字符串、联系人的 value 为数组。详情参见下文 **不同控件的参数** 章节。 + +## 不同控件的参数 + +本章节提供不同控件的 type 参数值、JSON 示例以及非通用参数说明。 + +### 单行文本 + +控件 type 为 input,JSON 数据示例: + +```json +{ + "id": "widget1", + "type": "input", + "value": "data" // string 类型 +} +``` + +### 多行文本 + +控件 type 为 textarea,JSON 数据示例: + +```json +{ + "id": "widget1", + "type": "textarea", + "value": "data" // string 类型 +} +``` + +### 日期 + +控件 type 为 date,JSON 数据示例: + +```json +{ + "id": "widget1", + "type": "date", + "value": "2019-10-01T08:12:01+08:00" // 需满足 RFC3339 格式的 string 类型 +} +``` + +### 日期区间 + +控件 type 为 dateInterval,JSON 数据示例: + +```json +{ + "id": "widget1", + "type": "dateInterval", + "value": { + "start":"2019-10-01T08:12:01+08:00", + "end":"2019-10-02T08:12:01+08:00", + "interval": 1.0 + } +} +``` + +value 参数为 object 类型,包含参数说明: + +参数 | 类型 | 是否必填 | 描述 +---|---|---|--- +start | string | 是 | 开始时间,需满足 RFC3339 格式。 +end | string | 是 | 结束时间,需满足 RFC3339 格式。 +interval | float | 是 | 时长(天)。 + +### 单选 + +控件 type 为 radio/radioV2,JSON 数据示例: + +```json +{ + "id": "widget1", + "type": "radioV2", + "value": "k2b8mkx0-h71x5gl1234-1" // string 类型 +} +``` + +其中, value 表示选项值,取值范围需要参考相应审批定义中 **单选** 控件 option 的 value 参数。你可以通过审批定义详情返回的 `form` 参数,获取单选控件 option 的 value 取值。如果控件关联了外部选项,则 value 需要传入外部选项的 `options.id`。 + +### 多选 + +控件 type 为 checkbox/checkboxV2,JSON数据示例: + +```json +{ + "id":"widget1", + "type":"checkboxV2", + "value": ["k2b8mkx0-h71x5gl4321-1"] // string 类型的数组 +} +``` +其中, value 表示选项值,取值范围需要参考相应审批定义中 **多选** 控件 option 的 value 参数。你可以通过审批定义详情返回的 `form` 参数,获取多选控件 option 的 value 取值。如果控件关联了外部选项,则 value 需要传入外部选项的 `options.id`。 + +### 数字 + +控件 type 为 number,JSON 数据示例: + +```json +{ + "id": "widget1", + "type": "number", + "value": 1234.5678 // float 类型 +} +``` + +### 金额 + +控件 type 为 amount,JSON 数据示例: + +```json +{ + "id": "widget1", + "type": "amount", + "value": 1234.5678, // float 类型 + "currency":"USD" +} +``` + +其中,currency 表示货币种类,取值范围需要参考相应审批定义中 **金额** 控件的 value 参数。你可以通过审批定义详情返回的 `form` 参数,获取金额控件可设置的货币种类。 + +### 计算公式 + +控件 type 为 formula,JSON 数据示例: + +```json +{ + "id": "widget1", + "type": "formula", + "value": 1234.5678 // 该值由审批定义内配置的公式计算出取值,若不匹配则返回报错。 +} +``` + +### 联系人 + +控件 type 为 contact,JSON 数据示例: + +```json +{ + "id":"widget1", + "type":"contact", + "value": ["f8ca557e"], // string 类型的数组 + "open_ids": ["ou_12345"] // string 类型的数组 +} +``` +其中,value 包含的是用户 `user_id`;open_ids 包含的是用户 `open_id`。 + +### 关联审批 + +控件 type 为 connect,JSON 数据示例: + +```json +{ + "id":"widget1", + "type":"connect", + "value": ["19EAC829-F1CB-527F-BE2A-1330422E60C0"] // string 类型的数组 +} +``` +其中,value 包含的是被关联的审批实例 Code,你可以通过审批实例详情能力根据实例 Code 获取实例详情。 + +### 文档控件 + +控件 type 为 document,JSON 数据示例: + +```json +{ + "id": "widget1", + "type": "document", + "value": { + "token":"TLLKdcpDro9ijQxA33ycNMabcef", + "type":"docx", + } +} +``` + +value 参数为 object 类型,包含参数说明: + +参数 | 类型 | 是否必填 | 描述 +---|---|---|--- +token | string | 是 | 文档的 document_id。 +type | string | 是 | 文档类型,支持 `docx`。 + +### 附件 + +控件 type 为 attachmentV2,JSON 数据示例: + +```json +{ + "id":"widget1", + "type":"attachmentV2", + "value": ["D93653C3-2609-4EE0-8041-61DC1D84F0B5"] // string 类型的数组 +} +``` +其中,value 包含的是上传文件后返回的文件 code。 + +### 图片 + +控件 type 为 image/imageV2,JSON 数据示例: + +```json +{ + "id":"widget1", + "type":"image", + "value": ["D93653C3-2609-4EE0-8041-61DC1D84F0B5"] // string 类型的数组 +} +``` + +其中,value 包含的是上传文件后返回的文件 code。 + +### 明细/表格 + +控件 type 为 fieldList,JSON 格式示例: + +```json +{ + "id": "widget1", + "type": "fieldList", + "value": [ + [ + { + "id": "widget1", + "type": "checkbox", + "value": ["jxpsebqp-0"] + } + ] + ] +} +``` + +其中 value 是二维数组,根据审批定义内 **明细/表格** 控件所包含的控件,依次设置控件 JSON 值。 + +### 部门 + +控件 type 为 department,JSON 数据示例: + +```json +{ + "id":"widget1", + "type":"department", + "value":[ + { + "open_id": "od-xxx" + } + ] +} +``` + +其中 value 为对象数组,通过 open_id 设置部门的 open_department_id。 + +### 电话 + +控件 type 为 telephone,JSON 数据示例: + +```json +{ + "id":"widget1", + "type":"telephone", + "value": { + "countryCode":"+86", + "nationalNumber":"13122222222" + } +} +``` + +value 参数为 object 类型,包含参数说明: + +参数 | 类型 | 是否必填 | 描述 +---|---|---|--- +countryCode | string | 是 | 区号。 +nationalNumber | string | 是 | 电话号。 + +### 地址 +控件 type 为 address,JSON 数据示例: + +```json +{ + "id": "widget1", + "type": "address", + "value": [{ + "id": "290557", + "detailAddress": "详细的地址" + }] +} +``` + +value 参数为 []object 类型,参数说明如下: + +参数 | 类型 | 是否必填 | 描述 +---|---|---|--- +value | []object | 是 | 非出差控件组场景地址控件仅支持单个地址,传入多个时默认只取第一个 +└ id | string | 是 | 区域ID, 可通过审批的地理库接口获取 +└ detailAddress | string | 否 | 详细的地址,若表单配置中未开启填写详细地址,则会忽略该参数,即使传入也不会生效 + +### 换班控件组 + +控件 type 为 shiftGroup,JSON 数据示例: + +```json +{ + "id": "widget1", + "type": "shiftGroup", + "value": { + "shiftTime": "2019-10-01T08:12:01+08:00", + "returnTime": "2019-10-02T08:12:01+08:00", + "reason": "ask for leave" + } +} +``` + +value 参数为 object 类型,包含参数说明: + +参数 | 类型 | 是否必填 | 描述 +---|---|---|--- +shiftTime | string | 是 | 换班时间,需满足 RFC3339 格式。 +returnTime | string | 是 | 对调日期,需满足 RFC3339 格式。 +reason | string | 是 | 换班原因。 + +### 请假控件组 + +**请假控件组请求示例** +```json +{ + "id": "widgetLeaveGroupV2", + "type": "leaveGroupV2", + "value": [ + { + "id": "widgetLeaveGroupType", + "type": "radioV2", + "value": "7488925543484620819" + }, + { + "id": "widgetLeaveGroupStartTime", + "type": "date", + "value": "2025-08-25T11:30:00+08:00" + }, + { + "id": "widgetLeaveGroupEndTime", + "type": "date", + "value": "2025-08-26T11:35:00+08:00" + }, + { + "id": "widgetLeaveGroupReason", + "type": "textarea", + "value": "123123" + }, + { + "id": "widgetLeaveCertification", + "type": "image", + "value": [ + "B69F8E26-0EAA-4A92-9B80-DA613CD36136" + ] + }, + { + "id":"widgetLeaveCertification", + "type":"image", + "value": ["D93653C3-2609-4EE0-8041-61DC1D84F0B5"] + }, + { + "id": "widgetLeaveGroupFeedingArrivingLate", + "type": "radioV2", + "value": "30" + }, + { + "id": "widgetLeaveGroupFeedingOffLeaveEarly", + "type": "radioV2", + "value": "30" + } + ] +} +``` + +**请假控件组包含参数说明:** + +id | 类型 | JSON示例 | 描述 +---|---|---|--- +id | string | 是 | 控件组ID,固定为widgetLeaveGroupV2 +type | string | 是 | 控件组类型,固定为leaveGroupV2 +value | object[] | 是 | 控件组的值,值为多个子控件值的列表 + +value中包含的子控件值说明: + +id | 类型 | JSON示例 | 描述 +---|---|---|--- +widgetLeaveGroupType | radioV2 | ```<br>{<br>"id": "widgetLeaveGroupType",<br>"type": "radioV2",<br>"value": "7488925543484620819"<br>}<br>``` | 假期类型,具体格式可参考单选控件,选项由假勤接口获取,提单时必须包含该控件 +widgetLeaveGroupStartTime | date | ```<br>{<br>"id": "widgetLeaveGroupStartTime",<br>"type": "date",<br>"value": "2019-10-01T08:12:01+08:00", // 需满足 RFC3339 格式的 string 类型<br>} <br>``` | 请假开始时间,具体格式可参考日期控件,会根据假期类型自动取整,其中半天假小于12点则认为是上午,小时假则以半小时为粒度向前取整, 提单时必须包含该控件 +widgetLeaveGroupEndTime | date | ```<br>{<br>"id": "widgetLeaveGroupEndTime",<br>"type": "date",<br>"value": "2019-10-01T08:12:01+08:00", // 需满足 RFC3339 格式的 string 类型<br>}<br>``` | 请假结束时间,具体格式可参考日期控件,会根据假期类型自动取整,其中半天假小于12点则认为是上午,小时假则以半小时为粒度向后取整 +widgetLeaveGroupReason | textarea | ```<br>{<br>"id": "widgetLeaveGroupReason",<br>"type": "textarea",<br>"value": "123123"<br>}<br>``` | 请假事由,具体格式可参考多行文本控件,哺乳假无需填写,其他情况则根据控件组配置中该控件是否可见以及必填判断 +widgetLeaveCertification | image | ```<br>{<br>"id":"widgetLeaveCertification",<br>"type":"image",<br>"value": ["D93653C3-2609-4EE0-8041-61DC1D84F0B5"]<br>}<br>``` | 请假证明,具体格式可参考图片控件,如果所选假期类型配置要求补充证明则必须传递该值,缺失会报错 +widgetLeaveGroupFeedingArrivingLate | radioV2 | ```<br>{ <br>"id": "widgetLeaveGroupFeedingArrivingLate",<br>"type": "radioV2",<br>"value": "30"<br>}<br>``` | 上班晚到的分钟数,具体格式可参考单选控件,仅哺乳假需要填写,取值范围是0-120分钟,粒度是15分钟,选项从审批定义中该控件的option中获取 +widgetLeaveGroupFeedingOffLeaveEarly | radioV2 | ```<br>{ <br>"id": "widgetLeaveGroupFeedingOffLeaveEarly",<br>"type": "radioV2",<br>"value": "30"<br>} <br>``` | 下班早走的分钟数,具体格式可参考单选控件,仅哺乳假需要填写,取值范围是0-120分钟,粒度是15分钟,选项即是分钟对应的字符串 + +**特殊的参数校验报错信息** +message | 说明 | +| -------------------------------------------------- | ---------------------------- | +| leave type id parse error | 请假类型不是int64 | +| group value is invalid | 当前控件组的值无效,请校验是否为空或者校验类型是否为数组 | +| start time format is not RFC3339 | 开始时间日期格式非*RFC3339格式* | +| end time format is not RFC3339 | 结束时间日期格式非*RFC3339格式* | +| start time is after end time | 开始时间晚于结束时间 | +| user not in gray | 申请用户不在假勤灰度内 | +| leave type not found | 请假类型不存在 | +| reason is required | 请假原因未填写 | +| leave quote should be bigger than 0 | 请假时长需要大于0 | +| leave is conflict | 所选时间内已有请假记录,请选择其他时间 | +| balance is not enough | 当前假期类型下假期余额不足 | +| certification is required | 需要上传请假证明 | +| arriving late is required | 哺乳假需要填写上班晚到时长 | +| arriving late value is not in the optional items | 晚到时间不在可选范围内 | +| leaving early is required | 哺乳假需要填写下班提前时长 | +| leaving early value is not in the optional items | 下班提前时间不在可选范围内 | +| feeding rest daily is 0 | 哺乳假每日休息时长为0,请重新选择 | +| the operation is prohibited by the workforce rules | 当前账户已在假勤侧封账,无法提交 + +### 加班控件组 + +**加班控件组请求示例** +```json +{ + "id": "widgetWorkGroup", + "type": "workGroup", + "value":[ + { + "id":"widgetWorkGroupOvertimeWorkers", + "type":"contact", + "value": ["f8ca557e"], + "open_ids": ["ou_12345"] + }, + { + "id": "widgetWorkGroupType", + "type": "radioV2", + "value": "7259635026038505475" + }, + { + "id":"widgetWorkGroupTimeRangeFieldList", + "type":"fieldList", + "value":[ + [ + { + "id":"widgetWorkGroupStartTime", + "type":"date", + "value":"2019-10-01T08:12:01+08:00" + }, + { + "id":"widgetWorkGroupEndTime", + "type":"date", + "value":"2019-10-01T08:12:01+08:00" + } + ] + ] + }, + { + "id": "widgetWorkGroupReason", + "type": "textarea", + "value": "111" + } + ] +} + +``` + +**加班控件组参数说明:** + +参数 | 类型 | 是否必填 | 描述 +---|---|---|--- +id | string | 是 | 控件组ID,固定为widgetWorkGroup +type | string | 是 | 控件组类型,固定为workGroup +value | object[] | 是 | 控件组的值,值为多个子控件值的列表 + +value中包含的子控件值说明: + +id | 类型 | JSON示例 | 描述 +---|---|---|--- +widgetWorkGroupOvertimeWorkers | contact | ```<br>{<br>"id":"widgetWorkGroupOvertimeWorkers",<br>"type":"contact",<br>"value": ["f8ca557e"], <br>"open_ids": ["ou_12345"]<br>}<br>``` | 加班人员列表,具体格式可参考联系人控件,如果定义中配置「允许代多人提交」则该字段必填,如果是提交人给自己提交需填写提交人的ID +widgetWorkGroupType | radioV2 | ```<br>{<br>"id": "widgetWorkGroupType",<br>"type": "radioV2",<br>"value": "7259635026038505475" // 对应的类型选项ID<br>}<br>``` | 加班类型,具体格式可参考单选控件,如果定义中关闭「关联加班规则」则需要填写该字段 +widgetWorkGroupTimeRangeFieldList | fieldList | ```<br>{<br>"id":"widgetWorkGroupTimeRangeFieldList",<br>"type":"fieldList",<br>"value":[<br>[<br>{<br>"id":"widgetWorkGroupStartTime",<br>"type":"date",<br>"value":"2019-10-01T08:12:01+08:00"<br>},<br>{<br>"id":"widgetWorkGroupEndTime",<br>"type":"date",<br>"value":"2019-10-01T08:12:01+08:00"<br>}<br>]<br>]<br>}<br>``` | 加班时段,具体格式可参考明细控件,如果定义中打开「允许提交多个加班时段」则可以传多个,最多支持30个,否则只会取第一个,单次加班时长不可超过两天 +widgetWorkGroupReason | textarea | ```<br>{<br>"id": "widgetWorkGroupReason",<br>"type": "textarea",<br>"value": "111"<br>}<br>``` | 加班事由,如果定义中配置了「加班事由」必填,则必须填写该字段 + +**特殊的参数校验报错信息** +message | 说明 | +| ---------------------------------------------------------------------------------- | ---------------------------- | +| the time range list has more than 30 items | 加班时段数量超过30 | +| group value is invalid | 当前控件组的值无效,请校验是否为空或者校验类型是否为数组 | +| overtime type is required | 未关联加班规则时,加班类型必填 | +| work time range is required | 至少需要一个加班时段 | +| start time is after end time | 开始时间晚于结束时间 | +| start time or end time of range is required | 加班时间段的开始时间和结束时间必填 | +| overtime duration is over 2 days | 单次加班时长不可超过两天 | +| overtime date time zone not support | 加班时段的日期时区信息无法识别 | +| {date} can not apply overtime | 所选时间不可申请加班 | +| {date} already apply overtime | 所选时间已经有加班记录 | +| {date} no need approval | 所选日期加班无需申请 | +| apply reason is required | 定义中设置了加班事由为必填,不可为空 | +| {users} user follow different overtime rules, cannot be submitted in the same form | 所选加班人不在同一个考勤组内,无法同时提交加班 | +| invalid overtime work application | 没有有效的加班申请,请重新选择加班日期 | +| the overtime duration cannot be 0 | 加班时长不能是0 | +| the number of apply workers cannot exceed 50 | 单次申请加班人数量不可大于50 | +| apply worker is required | 必须有加班人,配置置可代多人提交时必须指定加班人 | +| resigned worker can not apply | 离职人员不可申请加班 | +| overtime duration is over limit | 加班时长超过限制 + +### 外出控件组 + +**外出控件组请求体示例** +```json +{ + "id": "widgetOutGroup", + "type": "outGroup", + "value":[ + { + "id": "widgetOutGroupType", + "type": "radioV2", + "value": "me15yqrf-gmjgbml2vhp-0" + }, + { + "id": "widgetOutGroupStartTime", + "type": "date", + "value":"2019-10-01T08:12:01+08:00" + }, + { + "id": "widgetOutGroupEndTime", + "type": "date", + "value":"2019-10-01T08:12:01+08:00" + }, + { + "id": "widgetOutGroupReason", + "type": "textarea", + "value":"123213" + }, + { + "id":"widgetOutGroupImage", + "type":"image", + "value": ["D93653C3-2609-4EE0-8041-61DC1D84F0B5"] + } + ] +} + +``` + +**外出控件参数说明** + +参数 | 类型 | 是否必填 | 描述 +---|---|---|--- +id | string | 是 | 控件组ID,固定为widgetOutGroup +type | string | 是 | 控件组Type,固定为outGroup +value | object[] | 是 | 控件组的值,值为多个子控件值的列表 + +value中包含的子控件值说明: + +id | 类型 | JSON示例 | 描述 +---|---|---|--- +widgetOutGroupType | radioV2 | ```<br>{<br>"id": "widgetOutGroupType",<br>"type": "radioV2",<br>"value": "me15yqrf-gmjgbml2vhp-0" <br>}<br>``` | 外出类型,具体格式可参考单选控件,如果配置了「外出类型」则必填,外出时长单位会选取所选外出类型关联的单位,如果没有配置「外出类型」,则该字段无需填写,计算外出时长时会选取「外出时长」配置的单位 +widgetOutGroupStartTime | date | ```<br>{<br>"id": "widgetOutGroupStartTime",<br>"type": "date",<br>"value":"2019-10-01T08:12:01+08:00"<br>}<br>``` | 外出开始时间,具体格式可参考日期控件,如果外出时长单位是半天假,则小于12点则认为是上午,否则认为是下午;如果单位是小时,则会按半小时的粒度向前取整 +widgetOutGroupEndTime | date | ```<br>{<br>"id": "widgetOutGroupEndTime",<br>"type": "date",<br>"value":"2019-10-01T08:12:01+08:00"<br>}<br>``` | 外出结束时间,具体格式可参考日期控件,如果外出时长单位是半天假,则小于12点则认为是上午,否则认为是下午;如果单位是小时,则会按半小时的粒度向后取整 +widgetOutGroupReason | textarea | ```<br>{<br>"id": "widgetOutGroupReason",<br>"type": "textarea",<br>"value":"123213"<br>}<br>``` | 外出事由,具体格式可参考多行文本控件,如果定义中「外出事由」必填,则必须填写该控件,如果定义配置无需填写,则无需填写该控件 +widgetOutGroupImage | image | ```<br>{<br>"id":"widgetOutGroupImage",<br>"type":"image",<br>"value": ["D93653C3-2609-4EE0-8041-61DC1D84F0B5"]<br>} <br>``` | 外出证明,具体格式可参考图片控件,如果定义中「外出拍照」必填,则必须填写该控件,如果定义配置无需填写,则无需填写该控件 + +**特殊的参数校验报错信息** + +message | 说明 | +| ----------------------------------------------------- | ---------------------------- | +| group value is invalid | 当前控件组的值无效,请校验是否为空或者校验类型是否为数组 | +| start time format is not RFC3339 | 开始时间日期格式非*RFC3339格式* | +| end time format is not RFC3339 | 结束时间日期格式非*RFC3339格式* | +| start time and end time must be in the same time zone | 开始时间与结束时间必须是同一时区 | +| out type is required | 如果定义中设定了「外出类型」,则外出类型必填 | +| out start time is required | 外出开始时间必填 | +| out end time is required | 外出结束时间必填 | +| out duration must be greater than 0 | 外出间隔不能为0,请检查起止时间并重新选择 | +| out reason is empty | 如果定义中勾选「外出事由」同时设定必填,则该字段必填 | +| photo is required | 如果定义中勾选「外出拍照」同时设定必填,则该字段必填 | +| out time is conflict | 外出时间有冲突,请确认是否已在该时段申请外出 diff --git a/skills/lark-approval/references/lark-approval-instance-value-sourcing.md b/skills/lark-approval/references/lark-approval-instance-value-sourcing.md new file mode 100644 index 00000000..30d7d9c3 --- /dev/null +++ b/skills/lark-approval/references/lark-approval-instance-value-sourcing.md @@ -0,0 +1,108 @@ +# 审批提单值来源 + +## 目的 + +本文用于回答一个固定问题:在调用 `approval instances create` 发起原生审批实例时,**每个要填写的值从哪里拿**。 + +阅读顺序固定如下: + +1. `lark-cli schema approval.instances.create` +2. `approval approvals get` 返回的 `form` / `node_list` +3. [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) +4. 本文 + +## 总原则 + +- `schema` / `meta` 决定请求字段名、字段层级、节点参数结构。 +- `approvals.get.form` 决定控件 `id`、`type`、选项值范围、子控件结构。 +- `approvals.get.node_list` 决定节点 key、是否必须补审批人、是否允许多人。 +- [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 决定各控件 `value` 的最终结构。 +- 除非本文明确允许,否则不要猜值来源,不要把展示文案直接当成可提交值。 + +## 默认来源 + +- 审批定义、`approval_code`、`is_external`、`create_link` 等基础信息,默认从 `approval approvals search` 获取。 +- 控件 `id`、`type`、选项值、子控件结构,默认从 `approval approvals get.form` 获取。 +- 节点 key、`need_approver`、`approver_chosen_multi` 等节点信息,默认从 `approval approvals get.node_list` 获取。 +- 本文只补充 **这些默认来源之外** 的取值规则,以及当前必须由用户直接提供的值。 + +## 控件值来源规则 + +### 联系人 `contact` + +- 只推荐写 `open_ids`。 +- 不再推荐双写 `value(user_id)` + `open_ids`,避免复杂度继续上升。 +- 如果用户给的是姓名、邮箱或账号,先用 `lark-contact` 解析成 `open_id`。 + +### 部门 `department` + +- 最优先:用户直接提供 `open_department_id`。 +- 若用户说“我的部门”或“张三的部门”,先用 `lark-contact` 查询对应人员信息,再取其所属部门里的 `open_department_id`。 +- 如果查到该人员只有一个部门,可直接使用。 +- 如果查到多个部门,不自动猜,必须让用户明确选一个,或直接输入 `open_department_id`。 +- 如果仍无法确定,则明确告知当前不支持自动决定部门值。 + +### 附件 `attachmentV2` + +- 当前 `lark-approval` 不负责上传文件。 +- 用户必须直接提供 file code。 +- 如果用户无法提供 file code,应明确告知当前无法仅通过 `lark-approval` 完成该控件提单。 + +### 图片 `image` / `imageV2` + +- 当前 `lark-approval` 不负责上传图片。 +- 用户必须直接提供 file code。 +- 如果用户无法提供 file code,应明确告知当前无法仅通过 `lark-approval` 完成该控件提单。 + +### 文档 `document` + +- 用户可直接提供 `token` / `document_id`。 +- 如果用户给的是飞书文档链接,应先尝试从链接中提取 token。 +- 若链接提取失败,再要求用户手动输入 token。 + +### 关联审批 `connect` + +- 用户直接提供目标审批实例的 `instance_code`。 +- 当前不默认做“搜索关联实例再反查 code”的自动流程。 + +### 地址 `address` + +- 用户直接提供地理库 `id`。 +- 若用户无法提供该 `id`,当前不支持自动取值。 + +## 特殊控件组 + +以下控件组的结构仍按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 组装: + +- `leaveGroupV2` +- `workGroup` +- `outGroup` +- `shiftGroup` + +补充规则: + +- 控件组自身和子控件的 `id` / `type` 从 `approval approvals get.form` 中识别。 +- 组内单选/多选或业务枚举值,优先从 `approval approvals get.form` 返回的选项结构中取。 +- 不要把控件组整体当成普通字符串或扁平对象提交。 + +## 不支持自动准备的值 + +以下值当前不建议由 `lark-approval` 自动准备: + +- 文件上传后的 file code +- 图片上传后的 file code +- 地址控件的地理库 `id` +- 无法唯一确定的部门 `open_department_id` + +遇到这类值时,应明确告诉用户需要提供什么,而不是继续猜测。 + +## 最小决策表 + +| 场景 | 处理 | +|---|---| +| 用户说“找张三当审批人” | 用 `lark-contact` 解析张三,取 `open_id` | +| 用户说“我的部门” | 先查当前用户部门;若多个部门,让用户选 | +| 用户给了文档链接 | 先尝试提取 token | +| 用户要填图片/附件 | 要求直接提供 file code | +| 用户要填关联审批 | 要求直接提供 `instance_code` | +| 用户要填地址 | 要求直接提供地理库 `id` | diff --git a/skills/lark-calendar/SKILL.md b/skills/lark-calendar/SKILL.md index 08e2c5f5..c5abe011 100644 --- a/skills/lark-calendar/SKILL.md +++ b/skills/lark-calendar/SKILL.md @@ -31,6 +31,8 @@ lark-cli calendar +agenda --as user | Shortcut | 说明 | |----------|------| | [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) | +| [`+search-event`](references/lark-calendar-search-event.md) | 按关键词、时间范围和参会人搜索日程, 仅返回 日程ID/主题/时间等信息,详情需走 `events get` | +| [`+meeting`](references/lark-calendar-meeting.md) | 通过日程事件 ID 获取关联的视频会议信息(meeting_id、meeting_note),日程开过视频会议才会有meeting_id | | [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人(ISO 8601 时间) | | [`+update`](references/lark-calendar-update.md) | 更新既有日程字段,或独立增量添加/移除参会人和会议室 | | [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和 RSVP 状态 | @@ -53,6 +55,7 @@ lark-cli calendar +agenda --as user - **全天日程(All-day Event)**:只按日期占用、没有具体起止时刻的日程,结束日期是包含在日程时间内的。 - **时间块 vs 时间范围**:时间块是具体确定的连续时间段(如 `14:00~15:00`),时间范围是泛指(如"今天下午")。`+room-find` 必须基于确定时间块,不能基于模糊范围。 - **会议室(Room)**:"room"不是"房间",是"会议室"。会议室是日程的一种参与人(resource attendee),不能脱离日程单独预定。 +- **日程会议 ID(Meeting ID)**:日程的历史视频会议 ID,在日程上开过视频会议才会有。 ## 术语映射 @@ -64,6 +67,9 @@ lark-cli calendar +agenda --as user |----------|--------| | 查询过去的会议("昨天的会议""上周的会") | [`../lark-vc/SKILL.md`](../lark-vc/SKILL.md)(会议数据含即时会议,仅查日程会遗漏) | | 查询日历/日程或未来时间的会议 | 本 skill | +| 按关键词搜索日程 | 本 skill(`+search-event`) | +| 从日程获取关联的视频会议 ID 或用户绑定的会议纪要文档 | 本 skill(`+meeting`) | +| 从日程进一步拿 AI 智能纪要 / 逐字稿 / 妙记产物 | 先 `+meeting` 取 `meeting_id`,再 [`vc +detail`](../lark-vc/references/lark-vc-detail.md) → [`note +detail`](../lark-note/references/lark-note-detail.md) / [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md) | | 预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) | ## 任务类型分流 @@ -115,7 +121,6 @@ lark-cli calendar <resource> <method> [flags] - `get` — 获取日程 - `instance_view` — 查询日程视图 - `patch` — 更新日程 - - `search_event` — 搜索日程(仅返回 日程ID/主题/时间,详情需走 `events get`) - `share_info` — 获取日程分享链接 ### freebusys diff --git a/skills/lark-calendar/references/lark-calendar-meeting.md b/skills/lark-calendar/references/lark-calendar-meeting.md new file mode 100644 index 00000000..9dfac260 --- /dev/null +++ b/skills/lark-calendar/references/lark-calendar-meeting.md @@ -0,0 +1,40 @@ + +# calendar +meeting + +通过日程 ID(`event_id`) 获取关联的视频会议信息(`meeting_id`、`meeting_note`)。只读。 + +## 命令 + +```bash +# 单个 / 批量(逗号分隔,最多 50 个) +lark-cli calendar +meeting --event-ids <event_id1>,<event_id2> + +# 默认使用主日历,需要时显式传 --calendar-id +lark-cli calendar +meeting --event-ids <event_id> --calendar-id <calendar_id> +``` + +## 输出字段 + +| 字段 | 说明 | +|------|------| +| `event_id` | 日程 ID | +| `meeting_id` | 关联的视频会议 ID | +| `meeting_note` | 用户主动绑定到日程的纪要文档 Token(`MeetingNotes`,由用户在日程页手动添加;)。**与会中产生的 AI 智能纪要 `note_doc_token` 是两份不同文档**,要拿 AI 纪要请继续走 `vc +detail` → `note +detail`。 | + +## 下游链路 + +`calendar +meeting` 只把日程 ID 翻译为 `meeting_id` / `meeting_note`,要拿会中产生的产物(AI 智能纪要、逐字稿、妙记)需继续调用: + +```bash +# 1. meeting_id → note_id + minute_token(同一会议两份产物,可能各自为空) +lark-cli vc +detail --meeting-ids <meeting_id> + +# 2a. note_id → 纪要文档 token(note_doc_token / verbatim_doc_token / shared_doc_tokens) +lark-cli note +detail --note-id <note_id> + +# 2b. minute_token → 妙记 AI 产物(按需获取,不传不返回任何 AI 内容) +lark-cli minutes +detail --minute-tokens <minute_token> --summary --todo --chapter --keyword --transcript + +# 3. 任意文档 token(meeting_note / note_doc_token / verbatim_doc_token / shared_doc_token)→ 正文 +lark-cli docs +fetch --api-version v2 --doc <doc_token> --doc-format markdown +``` \ No newline at end of file diff --git a/skills/lark-calendar/references/lark-calendar-schedule-meeting.md b/skills/lark-calendar/references/lark-calendar-schedule-meeting.md index 06eb4a66..57187ce9 100644 --- a/skills/lark-calendar/references/lark-calendar-schedule-meeting.md +++ b/skills/lark-calendar/references/lark-calendar-schedule-meeting.md @@ -75,7 +75,7 @@ 定位规则: -- 优先利用用户给出的标题、日期、时间范围、`这个日程/这场会` 等锚点,通过 `+agenda`、`events search_event` 或实例视图缩小范围。 +- 优先利用用户给出的标题、日期、时间范围、`这个日程/这场会` 等锚点,通过 `+agenda`、`+search-event` 或实例视图缩小范围。 - 如果命中多个候选日程,必须向用户展示候选项并要求确认,禁止自行猜测。 - 如果是重复性日程的某一次实例,必须继续定位到该次实例的 `event_id`。 diff --git a/skills/lark-calendar/references/lark-calendar-search-event.md b/skills/lark-calendar/references/lark-calendar-search-event.md new file mode 100644 index 00000000..e2addb5c --- /dev/null +++ b/skills/lark-calendar/references/lark-calendar-search-event.md @@ -0,0 +1,29 @@ + +# calendar +search-event + +按关键词、时间范围和参会人搜索日历日程。只读。 + +## 命令 + +```bash +# 按关键词 +lark-cli calendar +search-event --query "周会" + +# 按时间范围(ISO 8601 或 YYYY-MM-DD) +lark-cli calendar +search-event --start "2026-04-20T00:00:00+08:00" --end "2026-04-27T23:59:59+08:00" + +# 按参会人(自动识别 ou_ 用户 / oc_ 群聊 / omm_ 会议室前缀) +lark-cli calendar +search-event --attendee-ids "ou_user1,oc_chat1,omm_room1" + +# 组合 +lark-cli calendar +search-event --query "周会" --start 2026-04-20 --end 2026-04-27 --attendee-ids "ou_user1" +``` + +## 输出字段 + +`items` 列表每条返回 `event_id` / `summary` / `start` / `end` / `is_all_day` / `app_link`;外层有 `has_more`、`page_token`。**仅返回基础字段,要拿日程详情用 `calendar events get`。** + +## 注意事项 + +- 分页:`has_more=true` 时持续用 `page_token` 翻页直到 false,不要遗漏;`page-size` 最大 30。 +- 已结束的会议优先用 `vc +search`——日历不收录"即时会议",只查日程会漏。 diff --git a/skills/lark-calendar/references/lark-calendar-update.md b/skills/lark-calendar/references/lark-calendar-update.md index 4fdd8db1..834ed9ac 100644 --- a/skills/lark-calendar/references/lark-calendar-update.md +++ b/skills/lark-calendar/references/lark-calendar-update.md @@ -65,7 +65,7 @@ lark-cli calendar +update \ - 只想修改标题、描述、时间或重复规则时,不需要同时传 `--add-attendee-ids` 或 `--remove-attendee-ids`。 - 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`。 - 会议室是 resource attendee,必须使用 `omm_` ID 添加到参会人列表,不能脱离日程单独预定。 -- 更新重复性日程的某一次实例时,必须先通过 `+agenda`、`events search_event` 或实例视图定位该实例的 `event_id`。 +- 更新重复性日程的某一次实例时,必须先通过 `+agenda`、`+search-event` 或实例视图定位该实例的 `event_id`。 - 如果需要验证更新结果,等待至少 2 秒后再查询,避免同步延迟导致读到旧数据。 - 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。 diff --git a/skills/lark-doc/SKILL.md b/skills/lark-doc/SKILL.md index 8d726e98..de803512 100644 --- a/skills/lark-doc/SKILL.md +++ b/skills/lark-doc/SKILL.md @@ -1,33 +1,30 @@ --- name: lark-doc version: 2.0.0 -description: "飞书云文档(Docx / Wiki 文档,v2 API):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Base 的数据操作。" +description: "飞书云文档(Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Base 的数据操作。" metadata: requires: bins: ["lark-cli"] - cliHelp: "lark-cli docs --api-version v2 --help; lark-cli docs +create --api-version v2 --help; lark-cli docs +fetch --api-version v2 --help; lark-cli docs +update --api-version v2 --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help" + cliHelp: "lark-cli docs --help; lark-cli docs +create --help; lark-cli docs +fetch --help; lark-cli docs +update --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help" --- -# docs (v2) +# docs **身份:文档操作默认使用 `--as user`。首次使用前执行 `lark-cli auth login`。** -> **CRITICAL — API 版本:本 skill 使用 v2 API。执行 `docs +create`、`docs +fetch`、`docs +update` 时必须显式传入 `--api-version v2`。** - ```bash # 常用示例 -lark-cli docs +fetch --api-version v2 --doc "文档URL或token" -lark-cli docs +create --api-version v2 --content '<title>标题

    内容

    ' -lark-cli docs +update --api-version v2 --doc "文档URL或token" --command append --content '

    内容

    ' +lark-cli docs +fetch --doc "文档URL或token" +lark-cli docs +create --content '标题

    内容

    ' +lark-cli docs +update --doc "文档URL或token" --command append --content '

    内容

    ' ``` ## 前置条件 — 执行操作前必读 **CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下文件,缺一不可:** 1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用) -2. **读取文档(`docs +fetch --api-version v2`)** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)(`--scope` / `--detail` 选择、局部读取策略、`` / `` 输出结构) -3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md));从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md) -4. **需要使用 callout、grid、table、whiteboard 等富 block 时** → 参考 [`lark-doc-style.md`](references/style/lark-doc-style.md) 的元素能力说明。该文件不是固定模板或强制排版规范;除非用户明确要求美化、重排版或特定风格,不要为了“达标”主动套用固定结构。 +2. **读取文档(`docs +fetch`)** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)(`--scope` / `--detail` 选择、局部读取策略、`` / `` 输出结构) +3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和 [`lark-doc-style.md`](references/style/lark-doc-style.md)(元素选择、丰富度规则、颜色语义);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md) **未读完以上文件就执行相应操作会导致参数选择错误或格式错误。** @@ -58,7 +55,7 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen | `` | 同 `` | [`lark-sheets`](../lark-sheets/SKILL.md) | | `` | 同 `` | [`lark-base`](../lark-base/SKILL.md) | | `` | `vc-node-id` -> note_id | [`lark-note`](../lark-note/SKILL.md):先 `note +detail --note-id ` | -| `` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch --api-version v2` 读取 src-token 文档,定位 block | +| `` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch` 读取 src-token 文档,定位 block | ## Shortcuts(推荐优先使用) @@ -67,7 +64,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs + [flags]`) | Shortcut | 说明 | |----------|------| | [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) | -| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown) | +| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown / im-markdown; `im-markdown` only after fetch for `lark-im`) | | [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) | | [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. | | [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) | diff --git a/skills/lark-doc/references/lark-doc-create.md b/skills/lark-doc/references/lark-doc-create.md index f0f6e548..17fa6bc2 100644 --- a/skills/lark-doc/references/lark-doc-create.md +++ b/skills/lark-doc/references/lark-doc-create.md @@ -2,9 +2,8 @@ > **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可: > 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md)) -> 2. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、并行执行策略) -> -> **需要富 block 或用户明确要求美化/重排版时,再参考 [`lark-doc-style.md`](style/lark-doc-style.md)。** +> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义) +> 3. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、并行执行策略) > > **未读完以上文件就生成内容会导致格式错误。** @@ -16,16 +15,10 @@ ```bash # 创建 XML 文档(默认格式,推荐) -lark-cli docs +create --api-version v2 --content '项目计划

    目标

    • 目标 1
    • 目标 2
    ' +lark-cli docs +create --content '项目计划

    目标

    记录本周重点。

    ' -# 创建到指定文件夹(XML) -lark-cli docs +create --api-version v2 --parent-token fldcnXXXX --content '标题

    首段内容

    ' - -# 创建到个人知识库(XML) -lark-cli docs +create --api-version v2 --parent-position my_library --content '标题

    内容

    ' - -# 仅当用户明确要求时才使用 Markdown;文档标题必须是开头唯一的一级标题,正文从二级标题开始 -lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项目计划\n\n## 目标\n\n- 目标 1\n- 目标 2' +# 仅当用户明确要求导入 Markdown 时才使用;文档标题用 --title,正文标题按内容自然组织 +lark-cli docs +create --doc-format markdown --title "项目计划" --content $'## 目标\n\n- 明确重点\n- 记录待办' ``` ## 返回值 @@ -36,9 +29,9 @@ lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项 "identity": "user", "data": { "document": { - "document_id": "doxcnXXXXXXXXXXXXXXXXXXX", + "document_id": "docx_token", "revision_id": 1, - "url": "https://xxx.feishu.cn/docx/doxcnXXXXXXXXXXXXXXXXXXX", + "url": "https://xxx.feishu.cn/docx/docx_token", "new_blocks": [ { "block_id": "blkcnXXXX", "block_type": "whiteboard", "block_token": "boardXXXX" } ] @@ -65,16 +58,15 @@ lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项 | 参数 | 必填 | 说明 | | ------------------- | -- |---------------------------------------------| -| `--api-version` | 是 | 固定传 `v2` | -| `--content` | 是 | 文档内容(XML 或 Markdown 格式) | +| `--title` | 否 | 文档标题,Markdown 导入时使用;XML 创建推荐在 `--content` 开头写 `...`;多个标题仅保留第一个并在 `warnings` / `degrade_details` 提示 | +| `--content` | 视情况 | 文档内容(XML 或 Markdown 格式);不传 `--content` 时必须传 `--title` | | `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) | | `--parent-token` | 否 | 父文件夹或知识库节点 token(与 `--parent-position` 互斥) | | `--parent-position` | 否 | 父节点位置,如 `my_library`(与 `--parent-token` 互斥) | ## 最佳实践 -- 文档标题从内容中自动提取:XML 使用 ``;Markdown 使用文档开头唯一的一级标题(`# 标题`),正文从 `##` 开始。不要在内容开头重复写标题,也不要在 Markdown 正文中使用多个一级标题。 -- **较长文档**:参考 [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) 先建骨架再分段写入;短文档可一次写完整内容。 +- **较长文档**:参考 [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) 先建骨架再分段写入;短文档可一次写完整内容 - **表达形式**:由用户目标和内容决定。需要结构化表达时可参考 [`lark-doc-style.md`](style/lark-doc-style.md),但不要默认套用固定开头、固定富 block 比例或固定图表 ## 参考 diff --git a/skills/lark-doc/references/lark-doc-fetch.md b/skills/lark-doc/references/lark-doc-fetch.md index 911a390a..b0ffc87e 100644 --- a/skills/lark-doc/references/lark-doc-fetch.md +++ b/skills/lark-doc/references/lark-doc-fetch.md @@ -5,27 +5,27 @@ ```bash # 获取文档(默认 XML,simple) -lark-cli docs +fetch --api-version v2 --doc "https://xxx.feishu.cn/docx/Z1Fj...tnAc" +lark-cli docs +fetch --doc "https://xxx.feishu.cn/docx/Z1Fj...tnAc" # Markdown 格式 -lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc --doc-format markdown +lark-cli docs +fetch --doc Z1Fj...tnAc --doc-format markdown # 带 block ID(用于后续 block 级更新) -lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc --detail with-ids +lark-cli docs +fetch --doc Z1Fj...tnAc --detail with-ids # 只拿目录 -lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc --scope outline --max-depth 3 +lark-cli docs +fetch --doc Z1Fj...tnAc --scope outline --max-depth 3 # 按 block id 区间精读 -lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \ +lark-cli docs +fetch --doc Z1Fj...tnAc \ --scope range --start-block-id blkA --end-block-id blkB --detail with-ids # 读整个章节(以标题 id 为锚点,自动展开到下一个同级/更高级标题前) -lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \ +lark-cli docs +fetch --doc Z1Fj...tnAc \ --scope section --start-block-id <标题id> --detail with-ids # 按关键词定位(多关键词用 | 分隔,任一命中即返回) -lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \ +lark-cli docs +fetch --doc Z1Fj...tnAc \ --scope keyword --keyword "部署|发布|上线" ``` @@ -91,15 +91,14 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \ } ``` -`content` 的格式由 `--doc-format` 决定。设置 `--scope` 时会被 `<fragment>` 包裹,详见上文"局部读取的输出结构"。 +`content` 的格式由 `--doc-format` 决定;`im-markdown` 仅用于获取内容后在 `lark-im` 场景下使用。设置 `--scope` 时会被 `<fragment>` 包裹,详见上文"局部读取的输出结构"。 ## 参数 | 参数 | 必填 | 说明 | |------|------|------| -| `--api-version` | 是 | 固定传 `v2` | | `--doc` | 是 | 文档 URL 或 token(支持 `/docx/` 和 `/wiki/`) | -| `--doc-format` | 否 | `xml`(默认)\| `markdown` \| `text` | +| `--doc-format` | 否 | `xml`(默认)\| `markdown` \| `im-markdown`(仅用于获取内容后在 `lark-im` 场景下使用) | | `--detail` | 否 | `simple`(默认)\| `with-ids` \| `full` | | `--revision-id` | 否 | 文档版本号,`-1` = 最新(默认) | | `--scope` | 否 | `outline` \| `range` \| `keyword` \| `section`(省略 = 读整篇) | @@ -128,7 +127,7 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \ ## 嵌入电子表格 / 多维表格 -返回中可能含 `<sheet>`、`<bitable>`、`<cite file-type="sheets|bitable">`。内部数据无法通过 `docs +fetch --api-version v2` 获取,提取 `token` 等属性后切到 [`lark-sheets`](../../lark-sheets/SKILL.md) / [`lark-base`](../../lark-base/SKILL.md) 下钻,详见 [SKILL.md 快速决策](../SKILL.md) 路由表。 +返回中可能含 `<sheet>`、`<bitable>`、`<cite file-type="sheets|bitable">`。内部数据无法通过 `docs +fetch` 获取,提取 `token` 等属性后切到 [`lark-sheets`](../../lark-sheets/SKILL.md) / [`lark-base`](../../lark-base/SKILL.md) 下钻,详见 [SKILL.md 快速决策](../SKILL.md) 路由表。 ## 参考 diff --git a/skills/lark-doc/references/lark-doc-md.md b/skills/lark-doc/references/lark-doc-md.md index 4d267e7b..b8ae2d0a 100644 --- a/skills/lark-doc/references/lark-doc-md.md +++ b/skills/lark-doc/references/lark-doc-md.md @@ -1,10 +1,6 @@ # Markdown 格式参考 -`docs +fetch --api-version v2` / `docs +create --api-version v2` / `docs +update --api-version v2` 使用 `--doc-format markdown` 时适用。 - -## 创建文档标题 - -使用 `docs +create --doc-format markdown` 创建文档时,文档标题必须写成内容开头唯一的一级标题:`# 标题`。正文标题从 `##` 开始,不要使用多个一级标题;否则标题可能无法被提取并显示为 `Untitled`。 +`docs +fetch` / `docs +create` / `docs +update` 使用 `--doc-format markdown` 时适用;fetch 的 `--doc-format im-markdown` 仅用于获取内容后在 `lark-im` 场景下使用,不作为 create/update 写入格式。 ## 转义规则 @@ -38,14 +34,14 @@ - `$...$` 数学公式内部,符号为 LaTeX 语法,不受 Markdown 转义影响 **导出已转义,不要反转义:** -`docs +fetch --api-version v2 --doc-format markdown` 导出的内容中,特殊字符**已经被转义过了**(例如 `\[`、`\|`、`\\` 等)。这些 `\` 是有意义的——去掉会导致后续写入时字符被 Markdown 语法吞掉。**不要反转义或去掉 `\`。** +`docs +fetch --doc-format markdown` 导出的内容中,特殊字符**已经被转义过了**(例如 `\[`、`\|`、`\\` 等)。这些 `\` 是有意义的——去掉会导致后续写入时字符被 Markdown 语法吞掉。**不要反转义或去掉 `\`。** **写入时必须转义:** 使用 `docs +create` 或 `docs +update` 的 `--doc-format markdown` 写入内容时,字面文本中的特殊字符同样必须转义。`--pattern` 参数中也必须使用转义形式才能正确匹配。 **导出 → 更新 工作流示例:** -1. `docs +fetch --api-version v2` 导出得到 `C:\\Users\\test\[1\]` +1. `docs +fetch` 导出得到 `C:\\Users\\test\[1\]` 2. 用 `str_replace --pattern 'C:\\Users\\test\[1\]'` 匹配(直接使用导出的转义形式) 3. `--content` 中的替换内容也要保持转义:`C:\\Users\\prod\[2\]` diff --git a/skills/lark-doc/references/lark-doc-media-insert.md b/skills/lark-doc/references/lark-doc-media-insert.md index 9b557b70..ca541300 100644 --- a/skills/lark-doc/references/lark-doc-media-insert.md +++ b/skills/lark-doc/references/lark-doc-media-insert.md @@ -48,7 +48,7 @@ lark-cli docs +media-insert --doc doxcnXXX --from-clipboard # 从本地文件插入 # 除了上传本地文件,还可以在 `docs +update` 时直接通过网络 URL 插入图片,无需先下载到本地: -lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_insert_after \ +lark-cli docs +update --doc "<doc_id>" --command block_insert_after \ --block-id "目标 block_id" \ --content '<img href="https://example.com/photo.png"/>' diff --git a/skills/lark-doc/references/lark-doc-update.md b/skills/lark-doc/references/lark-doc-update.md index 537264de..62fc467a 100644 --- a/skills/lark-doc/references/lark-doc-update.md +++ b/skills/lark-doc/references/lark-doc-update.md @@ -3,9 +3,8 @@ > **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可: > 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md)) -> 2. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、并行执行策略) -> -> **需要富 block 或用户明确要求美化/重排版时,再参考 [`lark-doc-style.md`](style/lark-doc-style.md)。** +> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义) +> 3. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、并行执行策略) > > **未读完以上文件就生成内容会导致格式错误。** @@ -15,13 +14,12 @@ > - **局部精修**(`str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after`):优先使用 XML(默认)。XML 能稳定表达 block 结构和样式,精准编辑更可控;不要因为 Markdown 写起来更简单就自行切换。 > - **整段写入**(`append` / `overwrite`):XML 和 Markdown 都可以。用户提供 `.md` 本地文件或明确要求 Markdown 时直接用 Markdown;否则默认 XML。 > -> **Markdown 局限 & block ID 前提:** Markdown 不携带 block ID,也无样式(颜色、对齐、callout 等)。需要按 block ID 定位(`block_*` 指令的 `--block-id`)时,先 `docs +fetch --api-version v2 --detail with-ids` **配合 `--scope`(`outline` / `range` / `keyword` / `section`)局部获取**目标段落,不要全量 fetch。拿到 block ID 后 `--content` 仍可用 Markdown,只是写入内容不带样式。 +> **Markdown 局限 & block ID 前提:** Markdown 不携带 block ID,也无样式(颜色、对齐、callout 等)。需要按 block ID 定位(`block_*` 指令的 `--block-id`)时,先 `docs +fetch --detail with-ids` **配合 `--scope`(`outline` / `range` / `keyword` / `section`)局部获取**目标段落,不要全量 fetch。拿到 block ID 后 `--content` 仍可用 Markdown,只是写入内容不带样式。 ## 参数 | 参数 | 必填 | 说明 | |------|------|------| -| `--api-version` | 是 | 固定传 `v2` | | `--doc` | 是 | 文档 URL 或 token | | `--command` | 是 | 操作指令(见下方指令速查表) | | `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) | @@ -65,20 +63,20 @@ ```bash # 简单文本替换 -lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \ +lark-cli docs +update --doc "<doc_id>" --command str_replace \ --pattern "张三" --content "李四" # 替换为富文本(加粗 + 链接) -lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \ +lark-cli docs +update --doc "<doc_id>" --command str_replace \ --pattern "旧链接" --content '<b>新链接</b> <a href="https://example.com">点击查看</a>' # 仅当用户明确要求时才使用 Markdown -lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \ +lark-cli docs +update --doc "<doc_id>" --command str_replace \ --doc-format markdown --pattern "旧内容" --content "新内容" # Markdown 模式下支持跨行匹配(--pattern 与 --content 都需要真实换行;"..."/'...' 里的 \n 是字面量) # 多行内容推荐 heredoc 或 --content @file.md,避免 shell 转义踩坑 -lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \ +lark-cli docs +update --doc "<doc_id>" --command str_replace \ --doc-format markdown \ --pattern "$(printf '## 旧标题\n\n第一段原文\n\n第二段原文')" \ --content - <<'EOF' @@ -91,7 +89,7 @@ EOF # Markdown 模式下使用 `前缀...后缀` 省略号匹配首尾特征明显的大段内容 # 下例会把「## 旧标题」到「结束语。」之间的所有内容整体替换 -lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \ +lark-cli docs +update --doc "<doc_id>" --command str_replace \ --doc-format markdown \ --pattern "## 旧标题...结束语。" \ --content - <<'EOF' @@ -103,14 +101,14 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \ EOF # 删除文本:--content 传空字符串即可 -lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \ +lark-cli docs +update --doc "<doc_id>" --command str_replace \ --pattern "废弃的内容" --content "" ``` ### block_insert_after — 在指定 block 之后插入 ```bash -lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_insert_after \ +lark-cli docs +update --doc "<doc_id>" --command block_insert_after \ --block-id "目标 block_id" \ --content '<h2>新章节</h2><ul><li>要点 1</li><li>要点 2</li></ul>' ``` @@ -118,7 +116,7 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_insert_a ### block_replace — 替换指定 block ```bash -lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_replace \ +lark-cli docs +update --doc "<doc_id>" --command block_replace \ --block-id "目标 block_id" \ --content '<p>替换后的段落内容</p>' ``` @@ -127,14 +125,14 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_replace ```bash # 删除多个块时用逗号 "," 分隔 -lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_delete \ +lark-cli docs +update --doc "<doc_id>" --command block_delete \ --block-id "block_id_1,block_id_2,block_id_3" ``` ### overwrite — 全文覆盖 ```bash -lark-cli docs +update --api-version v2 --doc "<doc_id>" --command overwrite \ +lark-cli docs +update --doc "<doc_id>" --command overwrite \ --content '<title>全新文档

    概述

    新的内容

    ' ``` @@ -143,7 +141,7 @@ lark-cli docs +update --api-version v2 --doc "" --command overwrite \ ### append — 在文档末尾追加 ```bash -lark-cli docs +update --api-version v2 --doc "" --command append \ +lark-cli docs +update --doc "" --command append \ --content '

    新增章节

    追加的内容

    ' ``` @@ -155,7 +153,7 @@ lark-cli docs +update --api-version v2 --doc "" --command append \ ```bash # 复制多个块(按顺序插入:anchor → a → b → c) -lark-cli docs +update --api-version v2 --doc "" --command block_copy_insert_after \ +lark-cli docs +update --doc "" --command block_copy_insert_after \ --block-id "锚点 block_id" \ --src-block-ids "block_a,block_b,block_c" ``` @@ -166,7 +164,7 @@ lark-cli docs +update --api-version v2 --doc "" --command block_copy_ins ```bash # 移动到页面末尾 -lark-cli docs +update --api-version v2 --doc "" --command block_move_after \ +lark-cli docs +update --doc "" --command block_move_after \ --block-id "-1表示末尾,page_id表示开头,blk" \ --src-block-ids "block_a,block_b" ``` @@ -204,7 +202,7 @@ lark-cli docs +update --api-version v2 --doc "" --command block_move_aft 1. **获取文档内容和 block ID**: ```bash - lark-cli docs +fetch --api-version v2 --doc "" --detail with-ids + lark-cli docs +fetch --doc "" --detail with-ids ``` 2. **定位目标 block**:从返回的 XML 中找到要修改的 block 及其 `id` 属性 @@ -212,11 +210,11 @@ lark-cli docs +update --api-version v2 --doc "" --command block_move_aft 3. **执行更新**: ```bash # 替换特定 block - lark-cli docs +update --api-version v2 --doc "" --command block_replace \ + lark-cli docs +update --doc "" --command block_replace \ --block-id "blkcnXXXX" --content "

    新内容

    " # 在某 block 后插入 - lark-cli docs +update --api-version v2 --doc "" --command block_insert_after \ + lark-cli docs +update --doc "" --command block_insert_after \ --block-id "blkcnXXXX" --content "

    追加的章节

    " ``` @@ -225,13 +223,13 @@ lark-cli docs +update --api-version v2 --doc "" --command block_move_aft 不需要 block ID,直接匹配替换: ```bash -lark-cli docs +update --api-version v2 --doc "" --command str_replace \ +lark-cli docs +update --doc "" --command str_replace \ --pattern "v1.0" --content "v2.0" ``` ## 画板处理 -> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch --api-version v2` 取到 ``,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 读取 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 并写入。 +> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch` 取到 ``,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 读取 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 并写入。 画板的语法选型与插入示例见 [`lark-doc-style.md`](style/lark-doc-style.md) 的「画板语法与插入」章节。 diff --git a/skills/lark-doc/references/lark-doc-whiteboard.md b/skills/lark-doc/references/lark-doc-whiteboard.md index 1048cd95..6d16b74a 100644 --- a/skills/lark-doc/references/lark-doc-whiteboard.md +++ b/skills/lark-doc/references/lark-doc-whiteboard.md @@ -23,7 +23,7 @@ |-------------------------|-----------------------------------------------------------| | 文档中需要思维导图、时序图、类图、饼图、甘特图 | 步骤 2A:使用 mermaid 插入图表 | | 文档中需要插入其他图表/自定义图形 | 步骤 2B: 使用 SVG 插入图表 | -| 已有画板需要更新内容 | 先 `docs +fetch --api-version v2` 获取 `board_token`,跳至步骤 3B | +| 已有画板需要更新内容 | 先 `docs +fetch` 获取 `board_token`,跳至步骤 3B | | 只查看 / 下载已有画板 | 切换至 `lark-whiteboard`,不走本流程 | > [!IMPORTANT] @@ -46,7 +46,7 @@ SubAgent 插入 SVG。 ### 步骤 2B: SubAgent 使用 SVG 插入图表 -主 Agent 启动 SubAgent,让它用 `docs +create --api-version v2` / `docs +update --api-version v2` 插入: +主 Agent 启动 SubAgent,让它用 `docs +create` / `docs +update` 插入: ```xml diff --git a/skills/lark-doc/references/lark-doc-xml.md b/skills/lark-doc/references/lark-doc-xml.md index 352619ec..7484f428 100644 --- a/skills/lark-doc/references/lark-doc-xml.md +++ b/skills/lark-doc/references/lark-doc-xml.md @@ -10,10 +10,6 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr | `` | 文档标题(每篇唯一)| `align` | | `<checkbox>` | 待办项| `done="true"\|"false"` | -## 创建文档标题 - -使用 `docs +create` 创建 XML 文档时,文档标题必须写成 `<title>标题`,且每篇文档只写一个 ``。 - ## 容器标签 |标签|说明|关键属性| |-|-|-| diff --git a/skills/lark-doc/references/style/lark-doc-create-workflow.md b/skills/lark-doc/references/style/lark-doc-create-workflow.md index 39a4d249..d845e91c 100644 --- a/skills/lark-doc/references/style/lark-doc-create-workflow.md +++ b/skills/lark-doc/references/style/lark-doc-create-workflow.md @@ -20,7 +20,7 @@ 1. 分析用户需求:受众、目的、范围 2. 设计大纲:根据任务自然选择结构。可以是短文、纪要、FAQ、方案、报告、清单或其他形式;不要默认套固定章节、固定开头或固定富 block 配比 -3. `docs +create --api-version v2` 创建文档。长文档可**只建骨架**:标题 + 各级标题 + 每节一句占位摘要;短文档可以一次写入完整内容 +3. `docs +create` 创建文档。长文档可**只建骨架**:标题 + 各级标题 + 每节一句占位摘要;短文档可以一次写入完整内容 - ⚠️ 创建较长文档时,**不要**一次性把完整章节内容塞进 `--content`。超长 `--content` 容易触发字符/参数限制。 - 完整内容留到步骤二,由各 Agent 用 `block_insert_after --block-id <章节标题 block_id>` 分段写入。 - ⚠️ **`@file` 路径限制**:`--content @file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下,用完自行清理。 @@ -29,12 +29,12 @@ 4. Spawn Agent 并行撰写各章节。每个 Agent 需收到: - 文档 token、负责的章节范围、用户目标、目标读者和已有风格线索 - - `lark-doc-xml.md` 的完整路径(Agent 须先读取);仅在需要使用富 block 或用户要求美化时提供 `lark-doc-style.md` + - `lark-doc-xml.md` 和 `lark-doc-style.md` 的完整路径(Agent 须先读取) - 使用 `block_insert_after --block-id <章节标题 block_id>` 写入对应章节内容 ### 步骤三:整合审查与画板识别(串行) -5. `docs +fetch --api-version v2 --detail with-ids` 获取文档,审查整体效果 +5. `docs +fetch --detail with-ids` 获取文档,审查整体效果 6. 评估内容是否满足用户目标:事实是否完整、结构是否清楚、语气是否匹配、是否保留必要素材 7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。重要信息优先画板化,记录需要插图的章节、推荐画板类型、mermaid/SVG 路径和用于画图的源内容 @@ -50,7 +50,7 @@ ## Agent 子任务要求 -内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`。只有在需要使用富 block 或用户要求美化时,才提供 `lark-doc-style.md` 路径。 +内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`。 Mermaid 图由主 Agent 直接插入 `<whiteboard type="mermaid">...</whiteboard>`,无需 SubAgent。 diff --git a/skills/lark-doc/references/style/lark-doc-update-workflow.md b/skills/lark-doc/references/style/lark-doc-update-workflow.md index 6ae8972a..9ff07c4d 100644 --- a/skills/lark-doc/references/style/lark-doc-update-workflow.md +++ b/skills/lark-doc/references/style/lark-doc-update-workflow.md @@ -19,10 +19,10 @@ ### 步骤一:分析与画板识别(串行) 1. **选择读取范围**(节省上下文的关键): - - 用户只改某一节 / 文档较大 → 先 `docs +fetch --api-version v2 --scope outline --max-depth 2` 拿目录,再 `docs +fetch --api-version v2 --scope section --start-block-id <目标标题id> --detail with-ids` 精读该节(`section` 会自动展开到下一个同级/更高级标题前,不用手动算结束 block id) - - 需要精确跨节区间 → `docs +fetch --api-version v2 --scope range --start-block-id xxx --end-block-id yyy`(或 `--end-block-id -1` 读到末尾) - - 用户只给了模糊关键词 → `docs +fetch --api-version v2 --scope keyword --keyword xxx --context-before 1 --context-after 1 --detail with-ids` - - 用户明确要改整篇 → `docs +fetch --api-version v2 --detail with-ids` + - 用户只改某一节 / 文档较大 → 先 `docs +fetch --scope outline --max-depth 2` 拿目录,再 `docs +fetch --scope section --start-block-id <目标标题id> --detail with-ids` 精读该节(`section` 会自动展开到下一个同级/更高级标题前,不用手动算结束 block id) + - 需要精确跨节区间 → `docs +fetch --scope range --start-block-id xxx --end-block-id yyy`(或 `--end-block-id -1` 读到末尾) + - 用户只给了模糊关键词 → `docs +fetch --scope keyword --keyword xxx --context-before 1 --context-after 1 --detail with-ids` + - 用户明确要改整篇 → `docs +fetch --detail with-ids` - 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) "意图引导:选择正确的 --scope" 2. 系统性评估:用户想改什么、现有文档风格是什么、哪些内容需要保留、哪些问题影响理解 3. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断哪些段落的信息适合用图表达。重要信息优先画板化,记录需要插图的章节(block ID)、推荐画板类型、mermaid/SVG路径和源内容片段 @@ -44,7 +44,7 @@ ## Agent 子任务要求 -内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`。只有在需要使用富 block 或用户要求美化时,才提供 `lark-doc-style.md` 路径。 +内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`。 Mermaid 图由主 Agent 直接插入 `<whiteboard type="mermaid">...</whiteboard>`,无需 SubAgent。 @@ -52,4 +52,4 @@ SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、 已有画板更新 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。 -**上下文节省提示**:Agent 如需在自己负责的章节内重新读取内容,优先用 `docs +fetch --api-version v2 --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉自己的章节,不要重复拉全文。 +**上下文节省提示**:Agent 如需在自己负责的章节内重新读取内容,优先用 `docs +fetch --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉自己的章节,不要重复拉全文。 diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index 0afe985c..393e1381 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -69,7 +69,7 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX' | 操作 | 需要的 Token | 说明 | |------|-------------|------| -| 读取文档内容 | `file_token` / 通过 `docs +fetch --api-version v2` 自动处理 | `docs +fetch --api-version v2` 支持直接传入 URL | +| 读取文档内容 | `file_token` / 通过 `docs +fetch` 自动处理 | `docs +fetch` 支持直接传入 URL | | 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;`docx` 支持文本定位或 block_id,`sheet` 使用 `<sheetId>!<cell>`,`slides` 使用 `<slide-block-type>!<xml-id>`;Base 只有记录局部评论,定位为 file_token(base_token) + `--block-id <table-id>!<record-id>!<view-id>` | | 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终解析为 `doc`/`docx`/`file` 的 wiki URL | | 下载文件 | `file_token` | 从文件 URL 中直接提取 | @@ -144,6 +144,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`) | [`+task_result`](references/lark-drive-task-result.md) | 查询 import/export/move/delete 等异步任务结果。 | | [`+inspect`](references/lark-drive-inspect.md) | 检视 URL 的类型、标题和 canonical token;wiki URL 会自动解包到底层文档。 | | [`+apply-permission`](references/lark-drive-apply-permission.md) | 以 user 身份向文档 owner 申请访问权限。 | +| [`+member-add`](references/lark-drive-member-add.md) | 添加一个或最多 10 个 Drive 文档、文件、文件夹或 wiki 节点协作者/授权成员;封装 Drive permission member create/batch_create,真实写入需要 `--yes`。 | | [`+secure-label-list`](references/lark-drive-secure-label.md) | 列出当前用户可用的密级标签。 | | [`+secure-label-update`](references/lark-drive-secure-label.md) | 更新 Drive 文件或文档的密级标签。 | diff --git a/skills/lark-drive/references/lark-drive-add-comment.md b/skills/lark-drive/references/lark-drive-add-comment.md index 8de1982c..17dc2d9f 100644 --- a/skills/lark-drive/references/lark-drive-add-comment.md +++ b/skills/lark-drive/references/lark-drive-add-comment.md @@ -35,7 +35,7 @@ lark-cli drive +add-comment \ --doc "<FILE_TOKEN>" --type file \ --content '[{"type":"text","text":"请补充目录说明"}]' -# 给 docx 文档的指定 block 添加局部评论(block_id 可通过 docs +fetch --api-version v2 --detail with-ids 获取) +# 给 docx 文档的指定 block 添加局部评论(block_id 可通过 docs +fetch --detail with-ids 获取) lark-cli drive +add-comment \ --doc "https://example.larksuite.com/docx/<DOC_ID>" \ --block-id "<BLOCK_ID>" \ @@ -155,11 +155,11 @@ lark-cli drive +add-comment \ | `--type` | 裸 token 时必填 | 文档类型:`doc`、`docx`、`file`、`sheet`、`slides`、`bitable`、`base`;评论 Base 文档推荐传 `bitable`,`base` 仅作为兼容别名兜底。URL 输入时自动识别,无需传 | | `--content` | 是 | `reply_elements` JSON 数组字符串。示例:`'[{"type":"text","text":"文本"},{"type":"mention_user","text":"ou_xxx"},{"type":"link","text":"https://example.com"}]'` | | `--full-comment` | 否 | 显式指定创建全文评论;未传 `--block-id` 时也会默认走全文评论(仅适用于 doc/docx、白名单 Drive file,以及解析为这些类型的 wiki;不适用于 sheet、slides、Base / bitable) | -| `--block-id` | 局部评论时必填 | 目标块 ID,可通过 `docs +fetch --api-version v2 --detail with-ids` 获取;sheet 用 `<sheetId>!<cell>`,slides 用 `<slide-block-type>!<xml-id>`,Base 用 `<table-id>!<record-id>!<view-id>` | +| `--block-id` | 局部评论时必填 | 目标块 ID,可通过 `docs +fetch --detail with-ids` 获取;sheet 用 `<sheetId>!<cell>`,slides 用 `<slide-block-type>!<xml-id>`,Base 用 `<table-id>!<record-id>!<view-id>` | ## 行为说明 -- **局部评论需要先获取 block ID**:先调用 `docs +fetch --api-version v2 --doc <TOKEN> --detail with-ids` 获取带有 block ID 的文档内容,然后使用 `--block-id` 指定目标块。 +- **局部评论需要先获取 block ID**:先调用 `docs +fetch --doc <TOKEN> --detail with-ids` 获取带有 block ID 的文档内容,然后使用 `--block-id` 指定目标块。 - **Review 场景优先局部评论**:审阅、校对、逐条指出问题时,必须先尝试定位到具体 block / 单元格 / slide 元素,并逐问题创建局部评论;不要把所有问题合并成一条全文评论。 - 未传 `--block-id` 时,shortcut 默认创建**全文评论**;也可以显式传 `--full-comment`。全文评论支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终可解析为 `doc`/`docx`/`file` 的 wiki URL。 - **Drive file 评论**:仅支持白名单扩展名的普通文件。当前支持:`.md`、`.txt`、`.json`、`.csv`、`.go`、`.js`、`.py`、`.pptx`、`.png`、`.jpg`、`.jpeg`、`.zip`、`.mp3`、`.mp4`。 diff --git a/skills/lark-drive/references/lark-drive-comment-location.md b/skills/lark-drive/references/lark-drive-comment-location.md index bd6d1353..cbb5a6b6 100644 --- a/skills/lark-drive/references/lark-drive-comment-location.md +++ b/skills/lark-drive/references/lark-drive-comment-location.md @@ -27,7 +27,7 @@ lark-cli drive file.comments batch_query \ 同时获取文档内容,并要求返回 block id: ```bash -lark-cli docs +fetch --api-version v2 --doc '<doc_token_or_url>' --detail with-ids +lark-cli docs +fetch --doc '<doc_token_or_url>' --detail with-ids ``` ## 字段含义 @@ -127,7 +127,7 @@ lark-cli docs +fetch --api-version v2 --doc '<doc_token_or_url>' --detail with-i 1. 确认目标是 `file_type=docx`;只有 docx 文档支持通过 `need_relation` 查询评论位置。 2. 用 `drive file.comments list` 或 `drive file.comments batch_query` 获取评论,并带 `need_relation=true`。 -3. 用 `docs +fetch --api-version v2 --detail with-ids` 获取文档内容。 +3. 用 `docs +fetch --detail with-ids` 获取文档内容。 4. 对每条评论先看 `relation`: - 如果存在 `relation.relation`,解析这个 JSON 字符串。 - 从解析结果里取 `positionInfo.blockID`。 diff --git a/skills/lark-drive/references/lark-drive-files-list.md b/skills/lark-drive/references/lark-drive-files-list.md index 4af0c3f4..e3f5ec8f 100644 --- a/skills/lark-drive/references/lark-drive-files-list.md +++ b/skills/lark-drive/references/lark-drive-files-list.md @@ -10,7 +10,7 @@ | 盘点用户明确确认的 Drive 根目录 | 使用 | 第一层用空 `folder_token`,子文件夹继续按普通文件夹递归 | | 验证移动 / 创建后的实际位置 | 使用 | 读取目标目录直接子项,再按需递归验证 | | 根据关键词、标题、时间、owner 找资源 | 不使用 | 优先用 `drive +search` | -| 读取 Docx 正文内容 | 不使用 | 用 `docs +fetch --api-version v2` | +| 读取 Docx 正文内容 | 不使用 | 用 `docs +fetch` | | 读取 Sheet / Base 内部数据 | 不使用 | 切到 `lark-sheets` / `lark-base` | ## 标准命令模板 diff --git a/skills/lark-drive/references/lark-drive-member-add.md b/skills/lark-drive/references/lark-drive-member-add.md new file mode 100644 index 00000000..8b36d27e --- /dev/null +++ b/skills/lark-drive/references/lark-drive-member-add.md @@ -0,0 +1,66 @@ +# drive +member-add(添加协作者/授权成员权限) + +> 这是高风险写操作。真实执行会修改文档权限,需要显式加 `--yes` + +## 命令 + +```bash + +# 批量添加(同一 member-type 和 perm,最多 10 人) +lark-cli drive +member-add \ + --token "<bare_token_or_url>" \ + --type bitable \ + --member-id "ou_a,ou_b" \ + --member-type openid \ + --perm view \ + --yes +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|----|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `--token` | 是 | 裸 token 或完整 URL。路径支持 `/drive/folder/`、`/docx/`、`/doc/`、`/sheets/`、`/base/`、`/bitable/`、`/wiki/`、`/file/`、`/mindnotes/`、`/slides/`、`/minutes/`;URL 输入可从路径推断 `--type`,裸 token 不做前缀推断 | +| `--type` | 必填 | 目标资源类型:`docx` / `doc` / `sheet` / `bitable` / `file` / `folder` / `wiki` / `mindnote` / `slides` / `minutes`。传 URL 时可省略;裸 token 必须显式传;若同时传 URL 和 `--type`,显式 `--type` 覆盖 URL 推断 | +| `--member-id` | 是 | 协作者 ID;逗号分隔可批量添加,最多 10 个 | +| `--member-type` | 是 | member-id 的类型;支持 `email` / `openid` / `unionid` / `openchat` / `opendepartmentid` / `groupid` / `appid` / `wikispaceid`。在实际使用里,给当前应用授权仍优先推荐 bot `open_id` + `openid`。 | +| `--member-kind` | 条件必填 | 仅当 `--member-type=wikispaceid` 时填写,映射到请求 body 的 `type` 字段。取值:`wiki_space_member` / `wiki_space_viewer` / `wiki_space_editor`。其他 member-type 禁止传此参数。 | +| `--perm` | 否 | 授权角色:`view`(默认)/ `edit` / `full_access` | +| `--perm-type` | 否 | 只作用 wiki 节点权限范围:`container`(默认,当前页面+子页面)/ `single_page`(仅当前页面) | +| `--need-notification` | 否 | 是否通知对方。仅 `--as user` 可用;未传时不会写入 query,`--need-notification=false` 表示显式不通知 | +| `--dry-run` | 否 | 仅打印请求,不实际授权 | +| `--yes` | 真实执行时是 | 确认高风险写操作 | + +## 输出 + +批量成功: + +```json +{ + "ok": true, + "identity": "user", + "data": { + "resource_token": "doc_token_or_url", + "resource_type": "docx", + "requested_count": 2, + "succeeded_count": 2, + "partial": false, + "members": [ + {"resource_token": "doc_token_or_url", "resource_type": "docx", "member_id": "ou_a", "member_type": "openid", "member_kind": "user", "perm": "view"}, + {"resource_token": "doc_token_or_url", "resource_type": "docx", "member_id": "ou_b", "member_type": "openid", "member_kind": "user", "perm": "view"} + ], + "missing_member_ids": [] + } +} +``` + +批量部分失败时,`partial` 为 `true`,CLI 以非零退出码返回 `error.type=partial_failure`。检查 `error.detail` 中的 `requested_count`、`succeeded_count`、`members`、`missing_member_ids` 和可选的 `mismatched_member_ids`。响应顺序不影响匹配结果。 + +## 行为说明 + +- **身份支持**:`--as user` 和 `--as bot` 均可使用。 +- **部门协作者**:`--member-type=opendepartmentid` 必须配合 `--as user`;bot 身份不支持添加部门协作者。 +- **通知**:`--need-notification` 仅 `--as user` 时有效;`--as bot` 时传此参数会被拒绝。 +- **批量约束**:批量请求共享同一 `--member-type`、`--perm` 和 `--perm-type`;混合用户/群组/部门的场景需拆分为多次调用。 +- **Wiki 空间 ID**:`--member-type=wikispaceid` 时必须同时传 `--member-kind`,否则 API 会缺少必填的 body `type` 字段。`wiki_space_member` 对应知识库成员角色;若知识库已将成员拆分为可阅读/可编辑成员组,改用 `wiki_space_viewer` 或 `wiki_space_editor`。 +- **ID 解析**:优先用 `open_id` + `--member-type openid`;仅在无法解析 `open_id` 时使用 `email`。群组优先用 `openchat`,部门用 `opendepartmentid`。 diff --git a/skills/lark-im/SKILL.md b/skills/lark-im/SKILL.md index 983c66d2..76eec025 100644 --- a/skills/lark-im/SKILL.md +++ b/skills/lark-im/SKILL.md @@ -67,6 +67,10 @@ Card messages (`interactive` type) are not yet supported for compact conversion `--audio` sends a voice message and supports only Opus audio files, for example `.opus` files or Ogg Opus (`.ogg`) files. For `mp3`, `wav`, or other non-Opus audio, either convert to `.opus` first and keep using `--audio`, or send the original file as an attachment with `--file`. +### Sending Doc Content as a Message + +When sending content fetched from a Lark doc as a message, fetch the doc with --doc-format im-markdown, then send it as a message using the --markdown format. The fetched content is already in markdown; in any content-forwarding scenario, keep the fetched original text and send it in the --markdown format. Note: if the doc contains a cite tag with type="user", keep it as-is and do not strip the tag. + ### Flag Types Flags support two layers: @@ -145,6 +149,12 @@ lark-cli im <resource> <method> [flags] # 调用 API - `batch_query` — 批量查询当前用户在群内的个人偏好设置 (e.g. `is_muted` mutes normal messages, `is_mute_at_all` mutes @all messages); up to 10 chats per request. Identity: `user` only (`user_access_token`); the caller must be in each target chat. - `batch_update` — 批量更新当前用户在群内的个人偏好设置 (e.g. `is_muted` mutes normal messages, `is_mute_at_all` mutes @all messages); up to 10 chats per request. Identity: `user` only (`user_access_token`); the caller must be in each target chat. +### chat.nickname + + - `get` — 获取自己的群昵称。Get your own nickname in the chat (self-only). Identity: `user` only (`user_access_token`); returns an empty string when no nickname is set. + - `update` — 设置自己的群昵称。Set or update your own nickname in the chat (self-only). Identity: `user` only (`user_access_token`); `nickname` must be a non-empty string (max 300 bytes). Use DELETE to clear it. + - `delete` — 清空自己的群昵称。Clear your own nickname in the chat (self-only). Identity: `user` only (`user_access_token`). + ### chat.managers - `add_managers` — 指定群管理员。Identity: supports `user` and `bot`; only the group owner can add managers; max 10 managers per chat (20 for super-large chats), and at most 5 bots per request. diff --git a/skills/lark-minutes/SKILL.md b/skills/lark-minutes/SKILL.md index ab877980..c04ecd83 100644 --- a/skills/lark-minutes/SKILL.md +++ b/skills/lark-minutes/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-minutes version: 1.0.0 -description: "飞书妙记:搜索妙记列表、查看妙记基础信息、下载妙记音视频文件、上传音视频生成妙记、更新妙记标题、替换说话人。当需要获取、操作或者生成妙记时使用。也支持将本地音视频文件转成纪要和逐字稿(优先使用本 skill,不要用 ffmpeg/whisper 本地转写)。不负责:获取会议关联妙记,或仅按自然语言标题定位纪要" +description: "飞书妙记:搜索妙记、查看妙记基础信息、下载/上传音视频、读取或编辑妙记的产物内容、改标题、替换说话人/关键词。当给出minute_token、本地音视频文件,要查/改/转妙记产物时使用;本地音视频转纪要/逐字稿优先走本 skill,不要用 ffmpeg/whisper 本地转写。不负责:获取会议关联妙记,或仅按自然语言标题定位纪要" metadata: requires: bins: ["lark-cli"] @@ -27,27 +27,34 @@ metadata: | Shortcut | 说明 | |----------|------| | [`+search`](references/lark-minutes-search.md) | 按关键词、所有者、参与者、时间范围搜索妙记 | +| [`+detail`](references/lark-minutes-detail.md) | 查询妙记详情(标题和关联的纪要note_id),按需获取 AI 产物(总结、待办、章节、逐字稿、关键词) | | [`+download`](references/lark-minutes-download.md) | 下载妙记音视频媒体文件 | | [`+upload`](references/lark-minutes-upload.md) | 上传 file_token 生成妙记 | | [`+update`](references/lark-minutes-update.md) | 更新妙记标题 | -| [`+speaker-replace`](references/lark-minutes-speaker-replace.md) | 替换妙记逐字稿中的说话人(仅支持用户 ID,不支持姓名) | +| [`+speaker-replace`](references/lark-minutes-speaker-replace.md) | 替换妙记逐字稿中的说话人(须先 `lark-cli api GET .../speakerlist` 取 `speaker_id`) | +| `+word-replace` | 批量替换逐字稿关键词(详见 `lark-cli minutes +word-replace --help`) | +| [`+summary`](references/lark-minutes-summary.md) | 替换妙记 AI 总结全文 | +| [`+todo`](references/lark-minutes-todo.md) | 新建/更新/删除妙记 AI 待办(单条或 `--todos` 批量;不是 lark-task) | - 使用任何 Shortcut 前,必须先读其对应 reference 文档。 ## 意图路由 -| 用户意图 | 路由到 | -|----------|--------| -| "我的妙记""搜索妙记""妙记列表" | 本 skill(`+search`) | -| "这个妙记的标题/时长/封面/链接" | 本 skill(`minutes get`) | -| "下载妙记的视频/音频" | 本 skill(`+download`) | -| "把音视频转妙记/上传文件生成妙记" | 本 skill(`+upload`) | -| "重命名妙记/改妙记标题" | 本 skill(`+update`) | -| "替换说话人/把 A 的发言改成 B" | 本 skill(`+speaker-replace`) | -| "这个妙记的逐字稿/总结/待办/章节" | [lark-vc](../lark-vc/SKILL.md)(`vc +notes --minute-tokens`) | -| "xx 纪要的逐字稿/原始记录/谁说了什么" 且没有 `minute_token` / 妙记 URL / 本地音视频文件 | 不走本 skill;路由到 [lark-drive](../lark-drive/SKILL.md) / [lark-doc](../lark-doc/SKILL.md),必要时再到 [lark-note](../lark-note/SKILL.md) | -| "把音视频文件转成纪要/逐字稿/文字稿" | 先本 skill(`+upload`),再 [lark-vc](../lark-vc/SKILL.md)(`vc +notes --minute-tokens`) | -| 用户同时提到"会议/开会"和"妙记" | 先 [lark-vc](../lark-vc/SKILL.md)(`+search` → `+recording`),再本 skill | +| 用户意图 | 命令 | +|---------|------| +| 我的妙记 / 搜索妙记 / 某段时间的妙记 | `+search` | +| 妙记基础信息:标题 / 时长 / 封面 / 链接 | `minutes get` | +| 下载妙记音视频文件、获取媒体下载链接 | `+download`(仅媒体;要妙记内容用 `+detail`) | +| 妙记总结 / 章节 / 待办 / 关键词 / 逐字稿 | `+detail --minute-tokens <token>` + 显式产物 flag | +| 基于妙记**提炼/总结/分析/回顾**会议 | `+detail --minute-tokens <token> --transcript`,再独立分析(**禁止照搬 AI 总结**) | +| 拿这条妙记关联的纪要文档(`note_doc_token` / `verbatim_doc_token` / `shared_doc_tokens`) | `+detail` 取顶层 `note_id` → [`note +detail --note-id`](../lark-note/SKILL.md) | +| 把本地音视频转纪要 / 逐字稿 / 文字稿 | `drive +upload` 取 `file_token` → `+upload` 生成 `minute_url` → `+detail` 拿产物 | +| 在妙记里增加 / 更改 / 删除 AI 待办 | `+todo`(**禁止走 lark-task**) | +| 替换妙记的AI 总结 | `+summary` | +| 重命名妙记/改妙记标题 | `+update` | +| 替换说话人/把 A 的发言改成 B/重新归属发言人/把外部(非飞书)说话人改成飞书用户" | 先 `lark-cli api GET .../transcript/speakerlist` 取 `speaker_id`,再 [`minutes +speaker-replace`](references/lark-minutes-speaker-replace.md);`--from-speaker-id` 只传 id,不传展示名 | +| 批量替换逐字稿关键词 | `+word-replace` | +| 用户同时提到"会议/开会"和"妙记" | 先 [lark-vc](../lark-vc/SKILL.md)(`+search` → `+recording`)获取 `minute_token`,再本 skill | ## 核心概念 @@ -58,60 +65,30 @@ metadata: ### 1. 搜索妙记 -1. 当用户描述的是"我的妙记""包含某个关键词的妙记""某段时间内的妙记",优先使用 `minutes +search`。 -2. 仅支持使用关键词、时间段、参与者、所有者等筛选条件搜索妙记记录,对于不支持的筛选条件,需要提示用户。 -3. 搜索结果存在多条数据时,务必注意分页数据获取,不要遗漏任何妙记记录。 -4. 如果是会议的妙记,应优先通过 [lark-vc](../lark-vc/SKILL.md) 定位会议并获取 `minute_token`。 -5. 会议场景的妙记路由,以及"参与的妙记"如何解释,统一以 [minutes +search](references/lark-minutes-search.md) 为准。 +1. 如果是会议的妙记,应优先通过 [lark-vc](../lark-vc/SKILL.md) 定位会议并获取 `minute_token`。 +2. 会议场景的妙记路由,以及"参与的妙记"如何解释,统一以 [minutes +search](references/lark-minutes-search.md) 为准。 ### 2. 查看妙记基础信息 1. 当用户只需要确认某条妙记的标题、封面、时长、所有者、URL 等基础信息时,使用 `minutes minutes get`。 -2. 如果用户给的是妙记 URL,应先从 URL 末尾提取 `minute_token`,再调用 `minutes minutes get`。 -3. 如果是会议 / 日程上下文中的妙记基础信息,先通过 VC 链路拿到 `minute_token`,再调用 `minutes minutes get`。 -4. 用户意图不明确时,默认先给基础元信息,帮助确认是否命中目标妙记。 +2. 如果是会议 / 日程上下文中的妙记基础信息,先通过 VC/Calendar 链路拿到 `minute_token`,再调用 `minutes minutes get`。 +3. 用户意图不明确时,默认先给基础元信息,帮助确认是否命中目标妙记。 -> 使用 `lark-cli schema minutes.minutes.get` 可查看完整返回值结构。核心字段包含:`title`(标题)、`cover`(封面 URL)、`duration`(时长,毫秒)、`owner_id`(所有者 ID)、`url`(妙记链接)。 -### 3. 下载妙记音视频文件 +### 3. 上传音视频文件生成妙记(并可继续获取纪要 / 逐字稿) -1. 下载妙记音视频文件到本地,或获取有效期 1 天的下载链接。详见 [minutes +download](references/lark-minutes-download.md)。 -2. `+download` 只负责音视频媒体文件。用户需要逐字稿、总结、待办、章节等纪要内容时,请使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)。 -3. 用户只想拿可分享的下载地址时,使用 `--url-only`;用户要落地到本地文件时,直接下载。 -4. 未显式指定路径时,文件默认落到 `./minutes/{minute_token}/<server-filename>`,与 `vc +notes` 的逐字稿共享同一目录便于聚合。 - -> **注意**:`+download` 只负责音视频媒体文件。如果用户需要的是逐字稿、总结、待办、章节等纪要内容,请使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)。 - -### 4. 读取妙记的逐字稿、总结、待办、章节(只读) - -1. 当用户要**查看 / 读取**"这个妙记的逐字稿""总结""待办""章节"时,使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)。 -2. 如果当前上下文中已有 `minute_token`,可直接传给 `vc +notes`;如果只有妙记 URL,先提取 `minute_token`。 -3. 如果用户给的是**本地音视频文件**,但目标是"转成纪要""转成逐字稿""转成文字稿""转成撰写文字",应先按下文第 5 节上传文件生成妙记,再把返回的 `minute_url` 提取成 `minute_token`,继续调用 `vc +notes --minute-tokens`。 -4. 用户如果直接给出本地文件名或路径,并要求"转逐字稿""转文字稿""整理成撰写文字",这也是本 skill 的明确触发信号。 - -```bash -# 通过 minute_token 获取纪要产物(逐字稿、总结、待办、章节) -lark-cli vc +notes --minute-tokens <minute_token> -``` - -> **跨 skill 路由**:逐字稿、AI 总结、待办、章节等纪要内容由 [lark-vc](../lark-vc/SKILL.md) 的 `+notes` 命令提供 -> **读 vs 写**:`vc +notes` 只负责**读取** AI 产物。用户要**新建 / 修改 / 删除**妙记内的 AI 待办或替换 AI 总结,见下文第 6 节,**不要**走 [lark-task](../lark-task/SKILL.md)。 - -### 5. 上传音视频文件生成妙记(并可继续获取纪要 / 逐字稿) - -1. 当用户需要通过上传本地音视频文件来生成妙记时使用。 -2. 当用户说"把音视频文件转成纪要""把录音转成逐字稿/文字稿/撰写文字""把 mp4/mp3 转成总结/待办/章节"时,也先走这个入口。 -3. **处理流程**: +1. 当用户说"把音视频文件转成纪要""把录音转成逐字稿/文字稿/撰写文字""把 mp4/mp3 转成总结/待办/章节"时,也先走这个入口。 +2. **处理流程**: - **上传音视频获取 `file_token`**:使用 [`lark-cli drive +upload`](../lark-drive/references/lark-drive-upload.md) 上传本地文件到云空间(云盘/云存储)并获取 `file_token`。 - **生成妙记**:获取到 `file_token` 后,调用 [`lark-cli minutes +upload`](references/lark-minutes-upload.md) 将文件转换为妙记并获取 `minute_url` 链接。 - - **继续获取纪要 / 逐字稿(按需)**:如果用户目标不是只要妙记链接,而是要纪要、逐字稿、总结、待办或章节,则从 `minute_url` 中提取 `minute_token`,再调用 [`lark-cli vc +notes --minute-tokens`](../lark-vc/references/lark-vc-notes.md) 获取对应产物。 + - **继续获取纪要 / 逐字稿(按需)**:如果用户目标不是只要妙记链接,而是要纪要、逐字稿、总结、待办或章节,则从 `minute_url` 中提取 `minute_token`,再调用 [`lark-cli minutes +detail --minute-tokens`](references/lark-minutes-detail.md) 获取对应产物。 > **注意**:必须先获取飞书云空间(云盘/云存储)的 `file_token` 才能进行转换。 > -> **不要误走本地转写工具**:当用户目标是把本地音视频文件转成纪要、逐字稿、文字稿、撰写文字时,不要改用 `ffmpeg`、`whisper` 或其他本地 ASR/转码命令;标准路径就是 `drive +upload -> minutes +upload -> vc +notes --minute-tokens`。 +> **不要误走本地转写工具**:当用户目标是把本地音视频文件转成纪要、逐字稿、文字稿、撰写文字时,不要改用 `ffmpeg`、`whisper` 或其他本地 ASR/转码命令;标准路径就是 `drive +upload -> minutes +upload -> minutes +detail --minute-tokens`。 -### 6. 编辑妙记的 AI 待办与 AI 总结(写入) +### 5. 编辑妙记的 AI 待办与 AI 总结(写入) 当用户要在**某条妙记内**操作 AI 待办或 AI 总结时使用本节。**不是**飞书任务(Task)清单里的待办。 @@ -141,73 +118,58 @@ lark-cli minutes +todo --minute-token <token> --as user --todos '[ ]' ``` -**更新 / 删除前**:先用 `vc +notes --minute-tokens <token>` 读取 `todos[].todo_id`(按 `content` 匹配目标条目;列表顺序不保证稳定,**不要**用"第 2 条"代替 `todo_id`)。 +**更新 / 删除前**:先用 `minutes +detail --minute-tokens <token> --todo` 读取 `todos[].todo_id`(按 `content` 匹配目标条目;列表顺序不保证稳定,**不要**用"第 2 条"代替 `todo_id`)。 **无编辑权限**:若 CLI 返回 `error.type=no_edit_permission`,表示对**这条妙记**没有编辑权,应请所有者授权;**不要**误走 `auth login --scope`。 -**逐字稿关键词替换无命中**:`minutes +word-replace` 时,若 CLI 返回 `error.type=words_not_found`,表示传入的 `source_word` 在该妙记逐字稿中**一个都没匹配到**,未做任何替换。这是**参数问题不是权限问题**:先用 `vc +notes --minute-tokens <token>` 读取当前逐字稿,核对 `source_word` 的精确写法与大小写后重试。 +**逐字稿关键词替换无命中**:`minutes +word-replace` 时,若 CLI 返回 `error.type=words_not_found`,表示传入的 `source_word` 在该妙记逐字稿中**一个都没匹配到**,未做任何替换。这是**参数问题不是权限问题**:先用 `minutes +detail --minute-tokens <token> --transcript` 读取当前逐字稿,核对 `source_word` 的精确写法与大小写后重试。 **替换 AI 总结全文**:见 [minutes +summary](references/lark-minutes-summary.md)。 > 使用 `+todo` 前必须阅读 [references/lark-minutes-todo.md](references/lark-minutes-todo.md);使用 `+summary` 前必须阅读 [references/lark-minutes-summary.md](references/lark-minutes-summary.md)。 -## 资源关系 +### 7. 替换妙记逐字稿说话人 -```text -Minutes (妙记) ← minute_token 标识 -├── Metadata (标题、封面、时长、owner、url) → minutes minutes get -└── MediaFile (音频/视频文件) → minutes +download +当用户要把妙记里某说话人的发言改绑到另一位飞书用户时使用。 + +**触发信号**:「替换说话人」「把 A 的发言改成 B」「说话人识别错了」「把外部说话人改成飞书用户」等。 + +**Agent 必读流程**(详见 [minutes +speaker-replace](references/lark-minutes-speaker-replace.md)): + +1. 确认 `minute_token`。 +2. **先**用 `lark-cli api GET "/open-apis/minutes/v1/minutes/<token>/transcript/speakerlist"` 查说话人列表(内部 HTTP,无 shortcut、无公开 OpenAPI 文档页)。 +3. 根据用户描述的原说话人展示名,在返回的 `data.speakers[]` 中匹配 `name` → 得到 `speaker_id`;同名多人时结合 `vc +notes` 逐字稿请用户确认,**不要擅自挑选**。 +4. 新说话人姓名用 [lark-contact](../lark-contact/SKILL.md) 解析为 `ou_` open_id。 +5. 调用 `minutes +speaker-replace`,**`--from-speaker-id` 只传步骤 3 的 `speaker_id`,禁止传展示名**。 + +## 行为规则 + +### 1. `+detail` 必须显式声明产物 flag + +不传 `--summary` / `--todo` / `--chapter` / `--keyword` / `--transcript` 时只返回基础信息(含顶层 `note_id`),AI 产物字段一律不返回。即使产物为空也会返回空值字段,便于程序化处理。 + +```bash +# 拿全产物 +lark-cli minutes +detail --minute-tokens <token> --summary --todo --chapter --keyword --transcript ``` -> **能力边界**:`minutes` 负责 **搜索妙记、查看基础元信息、下载/上传音视频、编辑妙记 AI 待办与 AI 总结、重命名、逐字稿说话人/关键词替换**。 -> -> **路由规则**: -> -> - 用户说"妙记列表 / 搜索妙记 / 某个关键词的妙记" → `minutes +search` -> - 用户只是想看"我的妙记 / 某段时间内的妙记 / 妙记列表",不要先走 [lark-vc](../lark-vc/SKILL.md),而应直接使用本 skill -> - 用户如果同时提到"会议 / 会 / 开会 / 某场会",即使也提到了"妙记",也应优先走 [lark-vc](../lark-vc/SKILL.md) 先定位会议,再通过 [vc +recording](../lark-vc/references/lark-vc-recording.md) 获取 `minute_token` -> - 用户如果要的是妙记基础信息,拿到 `minute_token` 后用 `minutes minutes get`;用户如果要**读取**逐字稿、文字稿、撰写文字、总结、待办、章节,再走 `vc +notes --minute-tokens` -> - “我的妙记”“参与的妙记”等自然语言映射细则,以 [minutes +search](references/lark-minutes-search.md) 为准 -> - 结果有多页时,使用 `page_token` 持续翻页,直到确认没有更多结果 -> - `minutes +search` 单次最多返回 `200` 条;结果总数没有固定上限 -> - 用户说"这个妙记的标题 / 时长 / 封面 / 链接" → `minutes minutes get` -> - 用户说"下载这个妙记的视频 / 音频 / 媒体文件" → `minutes +download` -> - 用户要**读取**"这个妙记的逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md) -> - 用户要在**妙记内新建 / 修改 / 删除 AI 待办**(含「妙记里加待办」「任务1 已完成」等)→ [`minutes +todo`](references/lark-minutes-todo.md),**禁止**走 lark-task -> - 用户要**替换妙记 AI 总结全文** → [`minutes +summary`](references/lark-minutes-summary.md) -> - 用户说"通过文件生成妙记 / 把音视频转妙记" → 先上传获取 `file_token`,然后使用 `minutes +upload` -> - 用户说"把音视频文件转成纪要 / 逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → 先上传获取 `file_token`,调用 `minutes +upload` 生成 `minute_url`,再提取 `minute_token` 走 `vc +notes --minute-tokens` -> - 用户说"重命名妙记 / 改妙记标题 / 修改妙记名字" → `minutes +update` -> - 用户说"替换说话人 / 把 A 的发言改成 B / 重新归属发言人" → `minutes +speaker-replace` -> - 用户说"批量替换逐字稿关键词" → `minutes +word-replace` -> -> **Note 域边界(禁止规则)**:`minute_token` 是妙记文件标识,**不是** `note_id`。 -> - 不要把 `minute_token` 传给 `note +detail` 或 `note +transcript`。 -> - 已有 `minute_token` 且要读取纪要产物时,先走 [lark-vc](../lark-vc/SKILL.md);只有自然语言纪要标题时不要从 Minutes 反查。 +### 2. "提炼 / 总结"必须基于 Transcript,不要照搬 AI 总结 -## Shortcuts(推荐优先使用) +AI 总结是模型对会议的二次压缩,可能遗漏争论过程和隐含决策。用户要求"提炼"或"重新总结"时,期望基于原始发言独立分析,而非搬运 AI 产物。**优先 `--transcript`,再独立写结论**。 -Shortcut 是对常用操作的高级封装(`lark-cli minutes +<verb> [flags]`)。有 Shortcut 的操作优先使用。 +### 3. 从妙记反查纪要:不绕 lark-vc -| Shortcut | 说明 | -| -------------------------------------------------- | --------------------------------------------------------------- | -| [`+search`](references/lark-minutes-search.md) | Search minutes by keyword, owners, participants, and time range | -| [`+download`](references/lark-minutes-download.md) | Download audio/video media file of a minute | -| [`+upload`](references/lark-minutes-upload.md) | Upload a media file token to generate a minute | -| [`+update`](references/lark-minutes-update.md) | Update a minute's title | -| [`+speaker-replace`](references/lark-minutes-speaker-replace.md) | Replace a speaker in a minute's transcript (rebind from one user to another) | -| [`+summary`](references/lark-minutes-summary.md) | Replace the full AI summary text of a minute | -| [`+todo`](references/lark-minutes-todo.md) | Add, update, or delete **AI todo(s) inside a minute** (single or batch via `--todos`; not Feishu Task) | +`minutes +detail` 顶层直接返回 `note_id`(仅在该妙记关联纪要时存在)。不需要绕回 [lark-vc](../lark-vc/SKILL.md),直接: -- 使用 `+search` 命令时,必须阅读 [references/lark-minutes-search.md](references/lark-minutes-search.md),了解搜索参数和返回值结构。 -- 使用 `+download` 命令时,必须阅读 [references/lark-minutes-download.md](references/lark-minutes-download.md),了解下载参数和返回值结构。 -- 使用 `+upload` 命令时,必须阅读 [references/lark-minutes-upload.md](references/lark-minutes-upload.md),了解生成参数和返回值结构。 -- 使用 `+update` 命令时,必须阅读 [references/lark-minutes-update.md](references/lark-minutes-update.md),了解修改参数和返回值结构。 -- 使用 `+speaker-replace` 命令时,必须阅读 [references/lark-minutes-speaker-replace.md](references/lark-minutes-speaker-replace.md),了解参数和限制(仅支持用户 ID,不支持姓名)。 -- 使用 `+summary` 命令时,必须阅读 [references/lark-minutes-summary.md](references/lark-minutes-summary.md),了解全文替换参数。 -- 使用 `+todo` 命令时,必须阅读 [references/lark-minutes-todo.md](references/lark-minutes-todo.md),了解单条与 `--todos` 批量模式;**不要**用 lark-task。 +```bash +# 1) 取 note_id(顶层 .minutes[0].note_id) +lark-cli minutes +detail --minute-tokens <minute_token> --format json +# 2) 用上一步拿到的 note_id 读纪要 token +lark-cli note +detail --note-id <note_id> # 拿 note_doc_token / verbatim_doc_token / shared_doc_tokens +``` + +顶层无 `note_id` 字段即代表无关联纪要,到此为止——不要继续尝试用 `minute_token` 当 `note_id`。 -<!-- AUTO-GENERATED-START — gen-skills.py 管理,勿手动编辑 --> ## API Resources @@ -223,7 +185,8 @@ lark-cli minutes <resource> <method> [flags] ## 不在本 skill 范围 -- 已有 `minute_token` 的纪要/逐字稿/总结/待办/章节内容获取 → [lark-vc](../lark-vc/SKILL.md)(`vc +notes --minute-tokens`) -- 只有自然语言纪要标题的逐字稿查询 → 文档搜索 / Docx 正文读取;有显式 `vc-node-id` 才进入 [lark-note](../lark-note/SKILL.md) -- 搜索历史会议记录 → [lark-vc](../lark-vc/SKILL.md) -- 查询未来的会议日程 → [lark-calendar](../lark-calendar/SKILL.md) +- 搜索历史会议记录、查参会人快照 → [lark-vc](../lark-vc/SKILL.md) +- 未来日程 / 日历查询 → [lark-calendar](../lark-calendar/SKILL.md) +- 已知 `note_id` 直接读纪要详情 → [lark-note](../lark-note/SKILL.md) +- 飞书任务清单(个人 Todo / 共享清单) → [lark-task](../lark-task/SKILL.md) +- 只有自然语言纪要标题、没有 `minute_token` / 妙记 URL / 本地音视频时定位逐字稿 → 文档搜索([lark-drive](../lark-drive/SKILL.md) / [lark-doc](../lark-doc/SKILL.md)) diff --git a/skills/lark-minutes/references/lark-minutes-detail.md b/skills/lark-minutes/references/lark-minutes-detail.md new file mode 100644 index 00000000..16b6e439 --- /dev/null +++ b/skills/lark-minutes/references/lark-minutes-detail.md @@ -0,0 +1,62 @@ + +# minutes +detail + +通过 `minute_token` 查询妙记详情,按需获取 AI 产物(总结/待办/章节/逐字稿/关键词)。只读。 + +> `--summary` / `--todo` / `--chapter` / `--keyword` / `--transcript` 至少一个;不传任何产物 flag 时只返回基础信息(如 `title`),AI 产物字段都不会出现。一次性获取所有产物:`--summary --todo --chapter --keyword --transcript`。 + +## 命令 + +```bash +# 仅基础信息 +lark-cli minutes +detail --minute-tokens obcxxxxxxxxxx + +# 批量(逗号分隔,最多 50 个) +lark-cli minutes +detail --minute-tokens obcxxx,obcyyy --summary --todo + +# 全产物 +lark-cli minutes +detail --minute-tokens obcxxx --summary --todo --chapter --keyword --transcript + +# 仅逐字稿,覆盖已有文件,指定输出目录 +lark-cli minutes +detail --minute-tokens obcxxx --transcript --overwrite --output-dir ./out +``` + +## 输出 + +`minutes` 数组每条含 `minute_token`、`title`、`note_id`、`artifacts`。`note_id` 仅在该妙记关联了会议纪要时返回,可直接传给 [`note +detail`](../../lark-note/references/lark-note-detail.md) 拿纪要文档 token,无需再绕回 `vc +detail`。`artifacts` 中**只包含本次请求的产物**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `artifacts.summary` | string | AI 总结。 | +| `artifacts.todos` | array | 待办事项列表。 | +| `artifacts.chapters` | array | 章节列表。 | +| `artifacts.keywords` | array | 关键词列表。 | +| `artifacts.transcript_file` | string | 逐字稿本地文件路径。 | + +逐字稿默认落地 `./minutes/{minute_token}/transcript.txt`,与 `minutes +download` 同目录便于聚合。指定 `--output-dir <dir>` 时改写到 `<dir>/artifact-{title}-{minute_token}/transcript.txt`。 + +## minute_token 来源 + +| 来源 | 取值字段 | +|------|---------| +| 妙记 URL `https://*.feishu.cn/minutes/obcxxx` | 截路径最后一段 `obcxxx` | +| `vc +detail --meeting-ids` | `minute_token` | +| `vc +recording --meeting-ids` | `minute_token` | +| `minutes +search` | `minute_token` | + +## 典型链路:从 minute_token 拿纪要文档 token + +只持有 `minute_token`(如妙记 URL 入口),又想拿 AI 智能纪要 / 逐字稿文档时: + +```bash +# 1. 取妙记关联的 note_id,没有关联会议纪要则为空 +lark-cli minutes +detail --minute-tokens <minute_token> + +# 2. 用 note_id 拿 note_doc_token / verbatim_doc_token / shared_doc_tokens +lark-cli note +detail --note-id <note_id> + +# 3. 读纪要 / 逐字稿正文 +lark-cli docs +fetch --api-version v2 --doc <note_doc_token> --doc-format markdown +``` + +> `minute_token` 不要直接传给 `note +detail`:必须先用本命令拿到 `note_id` 再调用 `note +detail`。 diff --git a/skills/lark-minutes/references/lark-minutes-download.md b/skills/lark-minutes/references/lark-minutes-download.md index fd70bc46..b2bac61f 100644 --- a/skills/lark-minutes/references/lark-minutes-download.md +++ b/skills/lark-minutes/references/lark-minutes-download.md @@ -43,7 +43,7 @@ lark-cli minutes +download --minute-tokens obcnxxxxxxxxxxxxxxxxxxxx --dry-run | `--url-only` | 否 | 仅返回下载链接,不下载文件 | | `--dry-run` | 否 | 预览 API 调用,不执行 | -> **默认落点**:未指定 `--output` / `--output-dir` 时,文件落到 `./minutes/{minute_token}/<server-filename>`。文件名沿用服务端 Content-Disposition / Content-Type 推断,Agent 可从 `saved_path` 字段读取实际路径。同一 minute_token 的录像和 `vc +notes` 的逐字稿默认会落在**同一目录**下,方便聚合。 +> **默认落点**:未指定 `--output` / `--output-dir` 时,文件落到 `./minutes/{minute_token}/<server-filename>`。文件名沿用服务端 Content-Disposition / Content-Type 推断,Agent 可从 `saved_path` 字段读取实际路径。同一 minute_token 的录像和 `minutes +detail` 的逐字稿默认会落在**同一目录**下,方便聚合。 ## 核心约束 @@ -85,7 +85,7 @@ API 限流 5 次/秒,批量下载时需注意控制频率。 | 字段 | 说明 | |------|------| | `minute_token` | 妙记 Token(用于 Agent 索引) | -| `artifact_type` | 固定为 `"recording"`(与 `vc +notes` 的 `"transcript"` 区分) | +| `artifact_type` | 固定为 `"recording"`(与 `minutes +detail` 的 `"transcript"` 区分) | | `saved_path` | 文件保存的本地路径(绝对路径) | | `size_bytes` | 文件大小(字节) | @@ -125,13 +125,13 @@ API 限流 5 次/秒,批量下载时需注意控制频率。 ## 提示 - 音视频文件可能较大,下载无固定超时限制(由用户 Ctrl+C 控制取消)。 -- 默认落点 `./minutes/{minute_token}/` 与 `vc +notes` 的逐字稿共享同一目录,方便 Agent 聚合同一会议的所有产物。 +- 默认落点 `./minutes/{minute_token}/` 与 `minutes +detail` 的逐字稿共享同一目录,方便 Agent 聚合同一会议的所有产物。 - 单 token 模式下 `--output` 若传入已存在目录(如 `--output ./existing-dir`),等价于 `--output-dir`,文件落入该目录(cp 语义)。 - 批量模式下 `--output` 不接受已存在的文件路径(会报错),应改用 `--output-dir`。 -- 如需获取妙记的纪要内容(逐字稿、AI 总结等),请使用 [vc +notes](../../lark-vc/references/lark-vc-notes.md)。 +- 如需获取妙记的纪要内容(逐字稿、AI 总结等),请使用 [minutes +detail](lark-minutes-detail.md)。 ## 参考 - [lark-minutes](../SKILL.md) — 妙记全部命令 -- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 会议纪要查询 +- [lark-minutes-detail](lark-minutes-detail.md) — 妙记详情与 AI 产物查询 - [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-minutes/references/lark-minutes-search.md b/skills/lark-minutes/references/lark-minutes-search.md index cec6099e..ecbd4ce0 100644 --- a/skills/lark-minutes/references/lark-minutes-search.md +++ b/skills/lark-minutes/references/lark-minutes-search.md @@ -129,8 +129,6 @@ CLI 会先按输入的本地日历日语义解析,再标准化为 RFC3339 时 2. `vc +recording` 获取 `minute_token` 3. `minutes minutes get` 查询妙记基础信息 -不要为了查"妙记信息"直接走 `vc +notes --meeting-ids`。`vc +notes` 只适用于逐字稿、总结、待办、章节等纪要内容。 - <br /> ## 时间格式 @@ -145,14 +143,14 @@ CLI 会先按输入的本地日历日语义解析,再标准化为 RFC3339 时 ## 输出结果 -- 默认输出包含 `items`、`total`、`has_more` 和 `page_token`。 +- 默认输出包含 `items`、`has_more` 和 `page_token`。 ## Pagination (`has_more` / `page_token`) - 当结果中返回 `has_more=true` 时,说明还有更多页可继续获取。 - 继续翻页时,使用响应中的 `page_token` 搭配 `--page-token` 发起下一次查询。 - 不要假设调大 `--page-size` 就能拿全结果;分页遍历时应以 `has_more` 和 `page_token` 为准。 -- `total` 数量小于 50 时,自动分页获取所有结果;`total` 数量大于 50 时,向用户确认是否获取全部结果。 +- 当 `has_more=true` 时,逐页累计已读取的 `items` 数:累计不到 50 条之前可自动继续翻页;超过 50 条后应停下来向用户确认是否获取全部结果。 ```bash # First page @@ -173,8 +171,8 @@ lark-cli minutes +search --query "预算复盘" --page-size 20 --page-token '<PA # 首先查询妙记元信息(标题、时长、封面) → 用本 skill lark-cli minutes minutes get --params '{"minute_token": "obcn***************"}' -# 查妙记关联的纪要产物:逐字稿、总结、待办、章节等 → 用 lark-cli vc +notes -lark-cli vc +notes --minute-tokens obcn_EXAMPLE_TOKEN +# 查妙记关联的产物(--summary --todo --chapter --keyword --transcript 按需返回) +lark-cli minutes +detail --minute-tokens <minute_token> --summary ``` ## 常见错误与排查 @@ -192,7 +190,7 @@ lark-cli vc +notes --minute-tokens obcn_EXAMPLE_TOKEN - 当用户说“我的妙记”时,优先理解为 `--owner-ids me`。 - 当用户说“我参与的妙记”“我参加过的妙记”时,默认理解为 `--owner-ids me` 与 `--participant-ids me` 两次查询后的并集。 - 当用户明确说“仅我参与但不是我拥有”时,才优先理解为 `--participant-ids me`。 -- 当用户同时提到“会议 / 会 / 开会 / 某场会”和“妙记”时,优先先定位会议;如果要的是妙记信息,走 `vc +recording` → `minutes minutes get`,只有要纪要内容时才走 `vc +notes --minute-tokens`。 +- 当用户同时提到“会议 / 会 / 开会 / 某场会”和“妙记”时,优先先定位会议;如果要的是妙记信息,走 `vc +recording` 获取 `minute_token` → `minutes minutes get`,只有要妙记产物内容时才走 `minutes +detail --minute-tokens`。 - 必须使用 `--format json` 输出,你更加擅长解析 JSON 数据。 - 排查参数与请求结构时优先使用 `--dry-run`。 - 搜索的时间范围最大为 1 个月,如果需要搜索更长时间范围的妙记,需要拆分为多次时间范围为一个月查询。 @@ -200,7 +198,7 @@ lark-cli vc +notes --minute-tokens obcn_EXAMPLE_TOKEN ## 参考 - [lark-minutes](../SKILL.md) -- 妙记相关命令 -- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) -- 基于 `minute_token` 获取逐字稿、总结、待办、章节等产物 +- [lark-minutes-detail](lark-minutes-detail.md) -- 基于 `minute_token` 获取逐字稿、总结、待办、章节等产物 - [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 - [lark-vc](../../lark-vc/SKILL.md) -- 视频会议全部命令 diff --git a/skills/lark-minutes/references/lark-minutes-speaker-replace.md b/skills/lark-minutes/references/lark-minutes-speaker-replace.md index 12b82c63..a3e94ec7 100644 --- a/skills/lark-minutes/references/lark-minutes-speaker-replace.md +++ b/skills/lark-minutes/references/lark-minutes-speaker-replace.md @@ -2,7 +2,7 @@ > **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 -替换妙记逐字稿中的说话人身份:把妙记逐字稿里"原说话人"对应的所有发言段,重新归属到"新说话人"。常用于解决妙记自动识别错说话人,或需要手工把某段语音绑定到正确用户的场景。 +替换妙记逐字稿中的说话人身份:把妙记逐字稿里"原说话人"对应的所有发言段,重新归属到"新说话人"。常用于解决妙记自动识别错说话人,或需要把外部/非飞书说话人改绑到正确飞书用户的场景。 本 skill 对应 shortcut:`lark-cli minutes +speaker-replace`。 @@ -10,15 +10,60 @@ - "把这条妙记里 A 的发言改成 B" - "妙记说话人识别错了,帮我把张三的部分换成李四" +- "把妙记里外部说话人 / 非飞书说话人的发言改成某个飞书用户" - "妙记说话人修改 / 替换 / 重新归属" -- "改一下妙记的说话人" + +## 完整工作流 + +识别到「修改妙记说话人」需求后,**必须**按以下顺序执行;**禁止**把展示名直接传给 `--from-speaker-id`。 + +1. **确认 `minute_token`** + - 从妙记 URL、搜索或 VC 链路取得 `minute_token`。 + +2. **查说话人列表(必须先做)** + - 用 **`lark-cli api`** 直接调用内部 HTTP 接口: + ```bash + lark-cli api GET "/open-apis/minutes/v1/minutes/<minute_token>/transcript/speakerlist" --as user + ``` + - 返回 `data.speakers[]`,每项含 `speaker_id`(不透明 id)与 `name`(逐字稿展示名)。示例: + ```json + { + "data": { + "speakers": [ + {"speaker_id": "ENCRYPTED_TOKEN_ABC", "name": "说话人1"}, + {"speaker_id": "ENCRYPTED_TOKEN_DEF", "name": "说话人2"} + ] + } + } + ``` + +3. **解析 `--from-speaker-id`** + - 根据用户描述的原说话人(展示名,如「说话人1」「张三」),在 `speakers[]` 里按 `name` **精确匹配**,取对应的 **`speaker_id`** 作为 `--from-speaker-id` 的值。 + - **`--from-speaker-id` 只传 `speaker_id`,不传展示名。** + - 若同名有多条(`name` 相同、`speaker_id` 不同):**不要擅自挑选**。可结合 [`vc +notes --minute-tokens`](../../lark-vc/references/lark-vc-notes.md) 对照各人发言内容,请用户确认后再用精确的 `speaker_id`。 + - 若列表中无匹配展示名:告知用户并核对拼写,或请用户在妙记页面确认标签。 + +4. **解析 `--to-user-id`** + - 新说话人必须是 `ou_` 开头的 open_id。用户只给姓名时,先用 [lark-contact](../../lark-contact/SKILL.md) 解析。 + +5. **执行替换** + ```bash + lark-cli minutes +speaker-replace \ + --minute-token obcnxxxxxxxxxxxxxxxxxxxx \ + --from-speaker-id ENCRYPTED_TOKEN_ABC \ + --to-user-id ou_new_speaker_open_id + ``` ## 命令示例 ```bash +# 1. 先查列表(裸调 HTTP) +lark-cli api GET "/open-apis/minutes/v1/minutes/obcnxxxxxxxxxxxxxxxxxxxx/transcript/speakerlist" --as user + +# 2. 再替换(from-speaker-id 来自上一步的 speaker_id) lark-cli minutes +speaker-replace \ --minute-token obcnxxxxxxxxxxxxxxxxxxxx \ - --from-user-id ou_old_speaker_open_id \ + --from-speaker-id ENCRYPTED_TOKEN_ABC \ --to-user-id ou_new_speaker_open_id ``` @@ -27,21 +72,33 @@ lark-cli minutes +speaker-replace \ | 参数 | 必填 | 说明 | |------|------|------| | `--minute-token <token>` | 是 | 妙记的唯一标识,可从妙记 URL 末尾路径提取 | -| `--from-user-id <ou_xxx>` | 是 | 被替换的原说话人,**必须是 `ou_` 开头的 open_id**,不支持用户名 | +| `--from-speaker-id <id>` | 是 | 被替换的原说话人 **`speaker_id`**(来自 speakerlist API 的 `data.speakers[].speaker_id`) | | `--to-user-id <ou_xxx>` | 是 | 新的说话人,**必须是 `ou_` 开头的 open_id**,不支持用户名 | -> **重要**:`--from-user-id` 和 `--to-user-id` 仅支持 `ou_` 开头的用户 ID,**不支持直接传姓名**。如果用户只给了姓名,请先用 [lark-contact](../../lark-contact/SKILL.md) 把姓名解析成 `open_id`,再调用本命令。 +## 核心约束 + +### 1. 必须先查 speakerlist,再替换 + +Agent 必须先 `lark-cli api GET .../speakerlist`,再 `+speaker-replace`;`--from-speaker-id` 只接受 `speaker_id`。 + +### 2. 新说话人必须是 open_id + +`--to-user-id` 仅支持 `ou_` 开头的 open_id,**不支持直接传姓名**;如果用户只给了姓名,请先用 [lark-contact](../../lark-contact/SKILL.md) 把姓名解析成 `open_id`。 + +### 3. 历史参数 + +存在一个隐藏的历史参数 `--from-user-id`(飞书说话人的 open_id),仅为向后兼容保留;新流程请一律使用 `--from-speaker-id` + `speaker_id`。 ## 认证与权限 -- 所需 scope:`minutes:minutes:update`。 +- 所需 scope:`minutes:minutes:readonly`(内部解析说话人)、`minutes:minutes:update`(执行替换)。 ## 输出结果 | 字段 | 说明 | |------|------| | `minute_token` | 被修改的妙记 Token,与输入的 `--minute-token` 一致 | -| `from_user_id` | 被替换的原说话人 open_id,与输入的 `--from-user-id` 一致;必须是妙记逐字稿中已存在的说话人 | +| `from_speaker_id` | 实际用于替换的不透明说话人标识 | | `to_user_id` | 替换后的新说话人 open_id,与输入的 `--to-user-id` 一致 | ## 参考 diff --git a/skills/lark-minutes/references/lark-minutes-summary.md b/skills/lark-minutes/references/lark-minutes-summary.md index e7bc7134..a1fd8e8d 100644 --- a/skills/lark-minutes/references/lark-minutes-summary.md +++ b/skills/lark-minutes/references/lark-minutes-summary.md @@ -40,7 +40,7 @@ lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary @sum ### 1. 先读后写 -替换前建议先用 `lark-cli vc +notes --minute-tokens <token>` 读取当前总结,确认 `minute_token` 与待替换内容无误。 +替换前建议先用 `lark-cli minutes +detail --minute-tokens <token> --summary` 读取当前总结,确认 `minute_token` 与待替换内容无误。 ### 2. Markdown 展示说明 @@ -104,7 +104,7 @@ lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary @sum |------|---------| | 妙记 URL | 从 URL 末尾提取,如 `https://sample.feishu.cn/minutes/obcnxxxxxxxxxxxxxxxxxxxx` | | 妙记搜索 | `lark-cli minutes +search --query "关键词"` | -| 会议产物查询 | `lark-cli vc +notes --minute-tokens <token>` | +| 会议产物查询 | `lark-cli vc +detail --meeting-ids <id>` 或 `vc +recording`, 拿到 `minute_token`, 然后走 `minutes +detail` | ## 常见错误与排查 @@ -118,5 +118,5 @@ lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary @sum - [lark-minutes](../SKILL.md) — 妙记全部命令 - [minutes +todo](lark-minutes-todo.md) — 替换待办项 -- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 读取总结、待办等 AI 产物 +- [minutes +detail](lark-minutes-detail.md) — 读取总结、待办等 AI 产物 - [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-minutes/references/lark-minutes-todo.md b/skills/lark-minutes/references/lark-minutes-todo.md index 7a50656b..de98c75d 100644 --- a/skills/lark-minutes/references/lark-minutes-todo.md +++ b/skills/lark-minutes/references/lark-minutes-todo.md @@ -94,7 +94,7 @@ lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --operation add - ### 1. 先读后写,待办 id 如何获取 -更新 / 删除前先用 `lark-cli vc +notes --minute-tokens <token>` 读取当前待办。返回的每条待办带 `todo_id` 字段。 +更新 / 删除前先用 `lark-cli minutes +detail --minute-tokens <token> --todo` 读取当前待办。返回的每条待办带 `todo_id` 字段。 > 待办 id 仅用于程序内部定位,不必展示给用户。 @@ -134,5 +134,5 @@ lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --operation add - - [lark-minutes](../SKILL.md) - [minutes +summary](lark-minutes-summary.md) -- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) +- [minutes +detail](lark-minutes-detail.md) - [lark-shared](../../lark-shared/SKILL.md) diff --git a/skills/lark-minutes/references/lark-minutes-upload.md b/skills/lark-minutes/references/lark-minutes-upload.md index 75f5e2fe..0142df6d 100644 --- a/skills/lark-minutes/references/lark-minutes-upload.md +++ b/skills/lark-minutes/references/lark-minutes-upload.md @@ -31,13 +31,13 @@ ``` - 命令执行成功后,将返回生成的妙记链接 `minute_url`。 -3. **如需纪要 / 逐字稿 / 文字稿 / 撰写文字,继续提取 `minute_token` 调用 `vc +notes`** +3. **如需纪要 / 逐字稿 / 文字稿 / 撰写文字,继续提取 `minute_token` 调用 `minutes +detail`** - 从返回的 `minute_url` 中提取路径最后一段,得到 `minute_token`。 - 如果用户要的是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,继续调用: ```bash - lark-cli vc +notes --minute-tokens <minute_token> + lark-cli minutes +detail --minute-tokens <minute_token> --summary --todo --chapter --keyword --transcript ``` - - `vc +notes --minute-tokens` 会返回纪要文档、逐字稿文档,以及 AI 内置产物(总结、待办、章节);必要时还会把逐字稿落地到本地文件。 + - `minutes +detail --minute-tokens` 会返回妙记产物(总结、待办、章节、关键词、逐字稿);必要时还会把逐字稿落地到本地文件。 > **异步生成提示**:API 会立即返回 `minute_url`,但妙记可能仍在异步生成中,您可以直接通过该妙记链接查看当前的处理状态和转写结果。 @@ -47,8 +47,8 @@ # 通过已上传到云空间(云盘/云存储)的 file_token 生成妙记 lark-cli minutes +upload --file-token boxcnxxxxxxxxxxxxxxxx -# 通过 minute_token 继续获取纪要 / 逐字稿 / 文字稿 / AI 产物 -lark-cli vc +notes --minute-tokens obcnxxxxxxxxxxxxxxxx +# 通过 minute_token 继续获取妙记产物(--summary --todo --chapter --keyword --transcript 按需传入) +lark-cli minutes +detail --minute-tokens obcnxxxxxxxxxxxxxxxx --summary ``` ## 参数 @@ -81,9 +81,9 @@ lark-cli vc +notes --minute-tokens obcnxxxxxxxxxxxxxxxx 1. 使用 `lark-cli drive +upload --file <path>` 上传本地音视频文件到云空间(云盘/云存储) 2. 从返回结果中取出 `file_token` 3. 调用 `lark-cli minutes +upload --file-token <file_token>` 生成妙记 -4. 如果目标是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,再从 `minute_url` 提取 `minute_token`,继续调用 `lark-cli vc +notes --minute-tokens <minute_token>` +4. 如果目标是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,再从 `minute_url` 提取 `minute_token`,继续调用 `lark-cli minutes +detail --minute-tokens <minute_token>` -> **边界说明**:`minutes +upload` 本身只负责把文件转成妙记并返回 `minute_url`。纪要内容、逐字稿、文字稿、撰写文字、总结、待办、章节属于后续产物获取,应由 [vc +notes](../../lark-vc/references/lark-vc-notes.md) 承接。 +> **边界说明**:`minutes +upload` 本身只负责把文件转成妙记并返回 `minute_url`。纪要内容、逐字稿、文字稿、撰写文字、总结、待办、章节属于后续产物获取,应由 [minutes +detail](lark-minutes-detail.md) 承接。 ## 输出结果示例 diff --git a/skills/lark-note/SKILL.md b/skills/lark-note/SKILL.md index b95a3ad4..dd9ac331 100644 --- a/skills/lark-note/SKILL.md +++ b/skills/lark-note/SKILL.md @@ -12,15 +12,23 @@ metadata: 身份:仅使用 `--as user`。使用前阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)。 -Note 域只接受显式 `note_id`:用户直接提供,或 `docs +fetch --api-version v2` 返回的 `<vc-transcribe-tab vc-node-id="...">` 中的 `vc-node-id`。不要从 `doc_token`、标题、正文或 backlink 反推 `note_id`。 +**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-vc/references/vc-domain-boundaries.md`](../lark-vc/references/vc-domain-boundaries.md)**,不读将导致命令使用、会议产物决策、领域边界职责判断错误: +> 1. 了解日历 & VC、会议产物 & 文档的关联关系和职责划分 +> 2. 了解会议产物(妙记和纪要)之间的关联关系,例如:**妙记和纪要产生条件相互独立** +> 3. 了解不同会议产物的组成部分,以便根据需求决策使用哪种产物的数据 + +Note 域只接受显式 `note_id`:用户直接提供,或 `docs +fetch` 返回的 `<vc-transcribe-tab vc-node-id="...">` 中的 `vc-node-id`。不要从 `doc_token`、标题、正文或 backlink 反推 `note_id`。 ## 命令路由 | 用户表达 / 上下文 | 路由 | |---------|------| | 已知 `note_id`,查纪要类型 / 文档 token | `note +detail --note-id NOTE_ID` | -| `docs +fetch --api-version v2` 返回 `<vc-transcribe-tab vc-node-id="...">` | 取 `vc-node-id` 作为 `NOTE_ID`,先 `note +detail --note-id NOTE_ID` | -| 已知 `note_id`,读纪要正文 | `note +detail` → `docs +fetch --api-version v2 --doc <note_doc_token>` | +| `docs +fetch` 返回 `<vc-transcribe-tab vc-node-id="...">` | 取 `vc-node-id` 作为 `NOTE_ID`,先 `note +detail --note-id NOTE_ID` | +| 只持有 `meeting_id` | 先 `vc +detail --meeting-ids <id>` 拿 `note_id`,再 `note +detail --note-id NOTE_ID` | +| 只持有 `minute_token`(妙记 URL) | 先 `minutes +detail --minute-tokens <token>` 顶层取 `note_id`,再 `note +detail --note-id NOTE_ID`(不要把 `minute_token` 当 `note_id`) | +| 只持有日程 `event_id` | 先 `calendar +meeting --event-ids <id>` 拿 `meeting_id`,再按上一行继续 | +| 已知 `note_id`,读纪要正文 | `note +detail` → `docs +fetch --doc <note_doc_token>` | | 已知 `note_id`,查 unified 原始记录 / 逐字稿 | `note +transcript --note-id NOTE_ID` | | 只有自然语言纪要标题,用户要逐字稿 / 原始记录 / 谁说了什么 | 不进本 skill;先走文档搜索与 `docs +fetch`,拿到 `vc-node-id` 后再回来 | @@ -28,7 +36,7 @@ Note 域只接受显式 `note_id`:用户直接提供,或 `docs +fetch --api- | `note +detail` 结果 | 用户要逐字稿 / 原始记录时 | |------|---------------| -| `normal` + `verbatim_doc_token` 非空 | `docs +fetch --api-version v2 --doc <verbatim_doc_token>` | +| `normal` + `verbatim_doc_token` 非空 | `docs +fetch --doc <verbatim_doc_token>` | | `unknown` + `verbatim_doc_token` 非空 | 先按独立文档处理;不要猜成 unified | | `unknown` + 无逐字稿 token | 停止重试并说明无法确定逐字稿入口 | | `unified` | `note +transcript --note-id <note_id>` | @@ -44,7 +52,9 @@ Note 域只接受显式 `note_id`:用户直接提供,或 `docs +fetch --api- ## 不在本 Skill 范围 -- 通过 `meeting_id` / `calendar_event_id` / `minute_token` 定位纪要 → [lark-vc](../lark-vc/SKILL.md)。 +- 通过 `meeting_id` 定位纪要(`note_id`)→ [lark-vc](../lark-vc/SKILL.md)(`vc +detail`)。 +- 通过 `minute_token` 定位纪要(`note_id`)→ [lark-minutes](../lark-minutes/SKILL.md)(`minutes +detail` 顶层返回 `note_id`)。 +- 通过日程 `event_id` 定位会议(`meeting_id`) / 用户绑定纪要(`meeting_note`) → [lark-calendar](../lark-calendar/SKILL.md)(`calendar +meeting`)。 - 自然语言纪要标题搜索 → [lark-drive](../lark-drive/SKILL.md) / [lark-doc](../lark-doc/SKILL.md)。 - Docx 正文读取 → [lark-doc](../lark-doc/SKILL.md)。 - 妙记基础信息与媒体文件 → [lark-minutes](../lark-minutes/SKILL.md)。 @@ -55,3 +65,30 @@ Note 域只接受显式 `note_id`:用户直接提供,或 `docs +fetch --api- |----------|------| | [`+detail`](references/lark-note-detail.md) | 需要解释输出字段或根据展示类型继续路由 | | [`+transcript`](references/lark-note-transcript.md) | 需要拉取 unified 原始记录或处理本地输出文件 | + +## 核心概念 + +- **会议纪要(Note)**:视频会议结束后生成的结构化文档,通过 `note_id` 标识。一个 Note 包含 AI 智能纪要文档、逐字稿文档和会中共享文档。 +- **note_id**:纪要的唯一标识符,可通过 `vc +detail --meeting-ids` 获取。 +- **AI 智能纪要(MainDoc)**:AI 生成的会议总结与待办,对应 `note_doc_token`。 +- **逐字稿(VerbatimDoc)**:会议的逐句发言记录,含说话人和时间戳,对应 `verbatim_doc_token`。 +- **共享文档(SharedDoc)**:会中投屏共享的文档,对应 `shared_doc_tokens`。 + +## 核心场景 + +### 1. 通过 note_id 获取纪要文档 Token + +1. 当用户已有 `note_id`,需要获取对应的 `note_doc_token`、`verbatim_doc_token` 或 `shared_doc_tokens` 时,使用 `note +detail`。 +2. `note_id` 通常来自 `vc +detail` 的返回结果。 +3. 获取到文档 Token 后,可使用 `docs +fetch` 读取文档内容,或使用 `drive metas batch_query` 获取文档元信息。 + +```bash +# 1. 从会议获取 note_id +lark-cli vc +detail --meeting-ids <meeting_id> + +# 2. 用 note_id 拿文档 Token +lark-cli note +detail --note-id <note_id> + +# 3. 读取纪要文档内容 +lark-cli docs +fetch --doc <note_doc_token> --doc-format markdown +``` diff --git a/skills/lark-note/references/lark-note-detail.md b/skills/lark-note/references/lark-note-detail.md index 6eac7621..b0d352d1 100644 --- a/skills/lark-note/references/lark-note-detail.md +++ b/skills/lark-note/references/lark-note-detail.md @@ -1,9 +1,11 @@ # note +detail -`note +detail` 只做一件事:按显式 `note_id` 返回纪要展示类型和相关文档 token。 +通过 `note_id` 查询会议纪要详情,获取下挂文档 Token(AI 智能纪要、逐字稿、会中共享文档)。只读,仅支持 `--as user`。 + +## 命令 ```bash -lark-cli note +detail --note-id NOTE_ID --format json +lark-cli note +detail --note-id <note_id> ``` ## `note_id` 来源 @@ -16,8 +18,8 @@ lark-cli note +detail --note-id NOTE_ID --format json | detail 字段 | 后续动作 | |---------|---------| -| `note_doc_token` | 读纪要正文 / 总结 / 待办 / 章节:`docs +fetch --api-version v2 --doc <note_doc_token>` | -| `note_display_type=normal` + `verbatim_doc_token` | 读逐字稿:`docs +fetch --api-version v2 --doc <verbatim_doc_token>` | +| `note_doc_token` | 读纪要正文 / 总结 / 待办 / 章节:`docs +fetch --doc <note_doc_token>` | +| `note_display_type=normal` + `verbatim_doc_token` | 读逐字稿:`docs +fetch --doc <verbatim_doc_token>` | | `note_display_type=unknown` + `verbatim_doc_token` | 先按普通独立逐字稿文档读取;不要猜成 unified | | `note_display_type=unified` | 读逐字稿 / 原始记录:转 [`note +transcript`](lark-note-transcript.md) | diff --git a/skills/lark-note/references/lark-note-transcript.md b/skills/lark-note/references/lark-note-transcript.md index 20889dca..5d334acf 100644 --- a/skills/lark-note/references/lark-note-transcript.md +++ b/skills/lark-note/references/lark-note-transcript.md @@ -1,6 +1,6 @@ # note +transcript -只在 `note +detail` 或 `vc +notes` 已确认 `note_display_type=unified` 时使用。普通纪要逐字稿是独立 Docx 文档,应回到 [lark-doc](../../lark-doc/SKILL.md) 读取 `verbatim_doc_token`。 +只在 `note +detail` 已确认 `note_display_type=unified` 时使用。普通纪要逐字稿是独立 Docx 文档,应回到 [lark-doc](../../lark-doc/SKILL.md) 读取 `verbatim_doc_token`。 ```bash lark-cli note +transcript --note-id NOTE_ID @@ -17,7 +17,7 @@ lark-cli note +transcript --note-id NOTE_ID | 场景 | 正确路由 | |------|---------| -| 只有纪要文档标题 | 先文档搜索,再 `docs +fetch --api-version v2`;有 `vc-node-id` 才回 Note 域 | -| 只有 Docx URL / `doc_token` | 先 `docs +fetch --api-version v2`;不要从 `doc_token` 反推 `note_id` | -| `note_display_type=normal` | `docs +fetch --api-version v2 --doc <verbatim_doc_token>` | +| 只有纪要文档标题 | 先文档搜索,再 `docs +fetch`;有 `vc-node-id` 才回 Note 域 | +| 只有 Docx URL / `doc_token` | 先 `docs +fetch`;不要从 `doc_token` 反推 `note_id` | +| `note_display_type=normal` | `docs +fetch --doc <verbatim_doc_token>` | | `note_display_type=unknown` 且 `verbatim_doc_token` 非空 | 先按独立逐字稿文档读取 | diff --git a/skills/lark-slides/SKILL.md b/skills/lark-slides/SKILL.md index c0b6dac2..4630b663 100644 --- a/skills/lark-slides/SKILL.md +++ b/skills/lark-slides/SKILL.md @@ -15,7 +15,7 @@ metadata: | 用户需求 | 优先动作 | 关键文档 / 命令 | |----------|----------|-----------------| | 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` | -| 大幅改写页面 | 先回读现有 XML,写入新 plan,再替换或重建相关页面 | `xml_presentations.get`、`+replace-slide`、`lark-slides-edit-workflows.md` | +| 已有 PPT 大幅改写 | 多页整页重建用 `+replace-pages`,单页局部编辑用 `+replace-slide` | `xml_presentations.get`、`lark-slides-replace-pages.md`、`lark-slides-edit-workflows.md` | | 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide`、`lark-slides-replace-slide.md` | | 读取或分析已有 PPT | 解析 slides/wiki token,回读全文或单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `xml_presentations.get`、`xml_presentation.slide.get` | | 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面 | `slides +screenshot`、`lark-slides-screenshot.md` | @@ -47,7 +47,7 @@ metadata: **CRITICAL — 使用模板生成或改写页面时,MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。** -**编辑已有幻灯片页面**:优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。 +**编辑已有幻灯片页面**:单个标题、文本块、图片或局部元素优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);已有 Slides 的多页大改优先用 [`+replace-pages`](references/lark-slides-replace-pages.md) 在原 presentation 内批量重建页面,避免 `slides +create` 生成新链接。选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。 ## 身份选择 @@ -82,7 +82,7 @@ lark-cli auth login --domain slides 按需再读: - 创建:[`lark-slides-create.md`](references/lark-slides-create.md) -- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md) +- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)、[`lark-slides-replace-pages.md`](references/lark-slides-replace-pages.md) - 截图:[`lark-slides-screenshot.md`](references/lark-slides-screenshot.md) - 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md) - 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) @@ -268,6 +268,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]` | [`+create`](references/lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) | | [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB | | [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 | +| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 | 没有 Shortcut 覆盖时使用原生 API。高频资源:`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。 @@ -286,7 +287,7 @@ lark-cli slides <resource> <method> [flags] # 调用 API 4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内 5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id` 6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片 -7. **编辑已有页面优先块级替换**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;只有需要替换整页结构时才用 `slide.delete` + `slide.create` +7. **编辑已有页面优先原链接更新**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;已有 Slides 的多页整页重建用 `+replace-pages`,不要用 `slides +create` 新建整份 PPT;只有没有 shortcut 覆盖的特殊单页整页操作才手动 `slide.create` + `slide.delete` 8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。 > **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。 diff --git a/skills/lark-slides/references/lark-slides-edit-workflows.md b/skills/lark-slides/references/lark-slides-edit-workflows.md index 4b7fa3c7..63f6ce5e 100644 --- a/skills/lark-slides/references/lark-slides-edit-workflows.md +++ b/skills/lark-slides/references/lark-slides-edit-workflows.md @@ -1,6 +1,6 @@ # 编辑已有 PPT:读-改-写闭环 -编辑走 **shortcut [`+replace-slide`](lark-slides-replace-slide.md)**(块级替换 / 插入),配合 `xml_presentation.slide.get` 读原页拿 `block_id`。 +局部编辑走 **shortcut [`+replace-slide`](lark-slides-replace-slide.md)**(块级替换 / 插入),配合 `xml_presentation.slide.get` 读原页拿 `block_id`。已有 Slides 的多页整页重建走 **[`+replace-pages`](lark-slides-replace-pages.md)**,保持原 presentation 链接不变。 > 生成 XML 前**必读** [xml-schema-quick-ref.md](xml-schema-quick-ref.md)。 @@ -11,6 +11,7 @@ | 已知某块的 `block_id`,要换这块内容(改标题、换图、挪坐标) | `block_replace` | 精准替换,原子性好;`replacement` 根 `id` 由 CLI 自动注入为 `block_id` | | 只加 1~N 个元素、不动现有布局 | `block_insert` | 新增不覆盖,可选 `insert_before_block_id` 指定位置 | | 一次动多个元素(如:换标题 + 加图) | 单次 `--parts` 里拼多条 | 整批作为原子事务,任一失败整批不生效;`block_replace` 和 `block_insert` 可混用 | +| 多页版式重建、整页坐标重排 | `+replace-pages` | 原 presentation 内批量 create-before/delete-old,不生成新 Slides 链接 | > **没有字段级 patch**:即便只想改一个 `shape` 的 `topLeftX`,也得把整个块的新 XML 写出来用 `block_replace`。这不是"微调",是块级重写。 @@ -136,6 +137,7 @@ cat parts.json | lark-cli slides +replace-slide --as user --presentation "$PID" ## 相关文档 - [lark-slides-replace-slide.md](lark-slides-replace-slide.md) — +replace-slide shortcut 参数详情 +- [lark-slides-replace-pages.md](lark-slides-replace-pages.md) — 多页整页重建 shortcut - [lark-slides-xml-presentation-slide-get.md](lark-slides-xml-presentation-slide-get.md) — slide.get 参考(拿 `block_id` / `revision_id`) - [lark-slides-xml-presentation-slide-replace.md](lark-slides-xml-presentation-slide-replace.md) — 底层 replace API 参考(一般直接用 shortcut 即可) - [lark-slides-media-upload.md](lark-slides-media-upload.md) — 上传图片拿 file_token diff --git a/skills/lark-slides/references/lark-slides-replace-pages.md b/skills/lark-slides/references/lark-slides-replace-pages.md new file mode 100644 index 00000000..df503dcf --- /dev/null +++ b/skills/lark-slides/references/lark-slides-replace-pages.md @@ -0,0 +1,95 @@ +# slides +replace-pages(多页整页重建) + +批量替换已有演示文稿里的多个页面,保持原 `xml_presentation_id` 和原 Slides 链接不变。适合多页版式大改、坐标重排、整页视觉重建;单个文本框、图片或 shape 的局部编辑仍优先用 [`+replace-slide`](lark-slides-replace-slide.md)。 + +> 重要:这是多步编排,不是后端原子事务。CLI 对每页执行“先创建新页到旧页前,再删除旧页”;创建失败时旧页会保留。删除失败时可能出现新旧页同时存在,需要按返回结果继续处理。 + +## 命令 + +```bash +lark-cli slides +replace-pages \ + --as user \ + --presentation <slides_url_or_xml_presentation_id> \ + --pages @pages.json +``` + +## 参数 + +| 参数 | 必需 | 说明 | +|------|------|------| +| `--presentation` | 是 | `xml_presentation_id`、`/slides/` URL 或 `/wiki/` URL | +| `--pages` | 是 | JSON 数组,每项包含 `slide_id` 和 `content`;支持 literal、`@file`、stdin `-` | +| `--dry-run` | 否 | 基于 `slide_id` 输入输出替换计划,不执行 create/delete | +| `--continue-on-error` | 否 | 默认失败即停;开启后继续处理后续页,并在结果中标记失败项 | +| `--validate-only` | 否 | 只校验输入并生成替换计划,不执行 Slides get/create/delete | + +## pages.json + +```json +[ + { + "slide_id": "slide_short_id_1", + "content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>" + }, + { + "slide_id": "slide_short_id_2", + "content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>" + } +] +``` + +规则: + +- 每项必须提供 `slide_id`;不支持 `slide_number`。 +- `content` 必须是完整 `<slide>...</slide>` XML。 +- 同一批次不能重复 `slide_id`。 +- CLI 不会回读整份 presentation;如果 `slide_id` 已失效,create/delete 阶段会返回对应错误。 + +## Dry Run + +```bash +lark-cli slides +replace-pages --as user \ + --presentation "$PID" \ + --pages @pages.json \ + --dry-run +``` + +输出包含 `xml_presentation_id`、`pages_count`、`plan`,以及每页的 `old_slide_id`、`insert_before_slide_id` 和动作 `create_before_then_delete_old`。Dry-run 只基于输入的 `slide_id` 构造计划,不会调用 `xml_presentations.get`,也不会执行 create/delete。 + +## 成功输出 + +```json +{ + "xml_presentation_id": "xxx", + "pages_count": 2, + "status": "completed", + "summary": { + "replaced": 2, + "failed": 0, + "total": 2 + }, + "results": [ + { + "old_slide_id": "old3", + "new_slide_id": "new3", + "status": "replaced" + } + ], + "revision_id": 123 +} +``` + +如果使用 `--continue-on-error` 且任一页面失败,CLI 会继续处理后续页,但最终以 partial failure 非零退出;stdout 仍保留完整 `results`,顶层 `ok` 为 `false`,`status` 为 `partial_failure`。 + +`status` 可能为: + +- `replaced`:新页创建成功,旧页删除成功。 +- `create_failed`:新页创建失败,旧页保留。 +- `delete_failed`:新页已创建,但旧页删除失败。 + +## 使用建议 + +1. 大幅改写前先 `xml_presentations.get` 保存当前 XML,并记录要替换页面的 `slide_id`。 +2. 生成只含 `slide_id` 的 `pages.json` 后先跑 `--dry-run` 或 `--validate-only`。 +3. 默认不要开 `--continue-on-error`,除非能接受部分页面已替换。 +4. 替换后再回读全文 XML 并截图检查,确认页序、视觉和文本没有破损。 diff --git a/skills/lark-slides/references/lark-slides-screenshot.md b/skills/lark-slides/references/lark-slides-screenshot.md index ec4f29c4..23557fcb 100644 --- a/skills/lark-slides/references/lark-slides-screenshot.md +++ b/skills/lark-slides/references/lark-slides-screenshot.md @@ -4,7 +4,7 @@ 获取幻灯片页面截图并保存为本地图片文件。默认用于已存在 PPT 页面截图;传入 `--content` 时用于直接渲染单个 `<slide>` XML 片段预览。本 shortcut 会在 CLI 进程内解码并写入文件,stdout 只返回文件路径、大小、页面 ID 等元信息,避免把图片 Base64 输出给模型。 -注意:该截图能力对应的权限受白名单控制。只有在白名单内的应用才能申请该权限;不在白名单内的应用即使命令和参数正确,服务端仍可能返回权限或能力不可用相关错误。 +注意:该截图能力受应用白名单限制,绝大多数应用不可用。截图失败时不要引导用户申请 `slides:presentation:screenshot` 权限;记录错误后降级到 XML 读回、结构 lint、文本重叠检查等非截图检查路径。 ## 命令 diff --git a/skills/lark-vc-agent/SKILL.md b/skills/lark-vc-agent/SKILL.md index e63fdbd1..657c4095 100644 --- a/skills/lark-vc-agent/SKILL.md +++ b/skills/lark-vc-agent/SKILL.md @@ -68,8 +68,8 @@ metadata: 2. 输入是 **`meeting_id`**(长数字 ID),不是 9 位会议号。 3. 不依赖默认身份。`meeting_id` 来自用户身份发现时,继续用 `--as user`;来自应用身份发现或 `+meeting-join` 时,继续用 `--as bot`。身份不一致会导致空结果或权限错误。 4. **不能做会后复盘**,**不能替代参会人快照查询**。如果会议已结束: - - 先用 `lark-cli vc +notes --meeting-ids <meeting.id>` 获取会议产物信息。 - - 再根据 `note_display_type`、`note_id`、`minute_token` 和用户意图,按 [`lark-vc`](../lark-vc/SKILL.md) 的产物决策读取正文、逐字稿或妙记。 + - 先用 `lark-cli vc +detail --meeting-ids <meeting.id>` 获取会议产物信息。 + - 再根据 `note_id`、`minute_token` 和用户意图,按 [`lark-vc`](../lark-vc/SKILL.md) 的产物决策读取正文、逐字稿或妙记。 - 想看参会人快照:用 `vc meeting get --with-participants`(见 [`lark-vc`](../lark-vc/SKILL.md)) 5. **默认必须使用** **`--page-all`**,除非用户明确要求“只查一页”,或确实需要控制返回体大小。 6. 输出格式默认优先 `--format pretty`(时间线更易读);只有在需要完整保留原始消息流与结构化字段时,才使用 `--format json`。 @@ -107,8 +107,8 @@ MID=$(echo "$JOIN" | jq -r '.data.meeting.id') # 典型间隔 10-30 秒 lark-cli vc +meeting-events --as bot --meeting-id "$MID" --page-all --format pretty -# 3. 会后可选:进入 lark-vc 获取会议产物信息,再按 note_display_type / minute_token 决策读取 -lark-cli vc +notes --meeting-ids "$MID" +# 3. 会后可选:进入 lark-vc 获取会议产物信息,再按 note_id / minute_token 决策读取 +lark-cli vc +detail --meeting-ids "$MID" ``` 如果用户随后明确要求退出 / 离开 / 结束参会,再单独调用 `lark-cli vc +meeting-leave --as bot --meeting-id "$MID"`。 @@ -163,7 +163,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。 ## 延伸 - 查已结束会议、参会人快照、搜索历史会议 → [`lark-vc`](../lark-vc/SKILL.md) -- 会议纪要、逐字稿 → [`lark-vc`](../lark-vc/SKILL.md) 的 `+notes` +- 会议纪要、逐字稿 → [`lark-vc`](../lark-vc/SKILL.md) 的 `+detail` - 妙记产物(AI 总结 / 转写 / 章节)→ [`lark-minutes`](../lark-minutes/SKILL.md) - 会后把产物发到群 / 私聊 → [`lark-im`](../lark-im/SKILL.md) - 认证、身份切换、scope 管理 → [`lark-shared`](../lark-shared/SKILL.md) diff --git a/skills/lark-vc-agent/references/lark-vc-agent-meeting-events.md b/skills/lark-vc-agent/references/lark-vc-agent-meeting-events.md index 051be4b1..a01e5600 100644 --- a/skills/lark-vc-agent/references/lark-vc-agent-meeting-events.md +++ b/skills/lark-vc-agent/references/lark-vc-agent-meeting-events.md @@ -260,7 +260,7 @@ lark-cli vc +meeting-events \ | `not a 9-digit meeting number` | 把 9 位会议号误传给 `--meeting-id` | 如果只是查询会中内容,先用 `+meeting-list-active` 按 `meeting_no` 匹配拿长数字 `meeting_id`;只有用户明确要求入会时才用 `+meeting-join --as bot --meeting-number <9位号>` | | `10005 bot is not in meeting` | 使用应用身份读取,但应用机器人从未真实入会该会议;或会议已结束但应用机器人从未在会中出现过 | 如果本来是用户身份发现的 `meeting_id`,改回 `--as user`;如果确实要应用身份读取,先 `+meeting-join --as bot --meeting-number <9位号>` 真实入会再查。**如果只是想看参会人快照,改用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants`** | | 用户身份不支持 | 当前事件读取接口不支持用用户身份访问 | 不要反复执行 `auth login`。改用应用身份流程:先通过 `+meeting-list-active --as bot --user-id <user_open_id>` 获取应用身份可读的 `meeting_id`,或在用户明确同意后让应用机器人入会,再用 `+meeting-events --as bot` 读取 | -| `20001 meeting_status_MEETING_END` | 会议已结束且已超出后端允许的 5 分钟宽限窗口 | 本接口不再适合继续拉取事件。先用 `lark-cli vc +notes --meeting-ids <meeting.id>` 获取会议产物信息,再根据 `note_display_type` / `note_id` / `minute_token` 和用户意图选择纪要正文、逐字稿或妙记;参会人请用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants` | +| `20001 meeting_status_MEETING_END` | 会议已结束且已超出后端允许的 5 分钟宽限窗口 | 本接口不再适合继续拉取事件。先用 `lark-cli vc +detail --meeting-ids <meeting.id>` 获取会议产物信息,再根据 `note_id` / `minute_token` 和用户意图选择纪要正文、逐字稿或妙记;参会人请用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants` | | `20002 meeting not exist` | `meeting_id` 错误,或会议实例当前已不可获取(常见于把 9 位会议号当 meeting_id 传) | 确认传入的是长数字 `meeting_id`,不是 9 位会议号 | | 应用身份权限不足 | 应用权限、租户安装、权限可访问的数据范围或 VC Agent privilege 未配置完整 | 不要执行 `auth login`。以 CLI 返回的 metadata / error envelope 为准确认缺失权限;检查应用发布/安装,以及开放平台“权限可访问的数据范围”:选择“按条件筛选”,条件为“会议的归属者 包含 与应用的可用范围一致”;仍失败再排查内测 privilege / 灰度 | | `HTTP 404` / `HTTP 500` | 服务端当前无法找到或处理该会议实例 | 换一个正在进行且 bot 可见的 meeting_id,或排查后端问题 | @@ -269,7 +269,7 @@ lark-cli vc +meeting-events \ - 这是**会中事件流**查询,不适合拿来搜历史会议记录;搜历史会议请用 `+search`。 - 如果会议已经结束,不要卡在 `+meeting-events`: - - 先用 `lark-cli vc +notes --meeting-ids <meeting.id>` 获取会议产物信息。 + - 先用 `lark-cli vc +detail --meeting-ids <meeting.id>` 获取会议产物信息。 - 再根据 `note_display_type`、`note_id`、`minute_token` 和用户意图,按 `lark-vc` 的产物决策读取纪要正文、逐字稿或妙记。 - 事件列表是否完整,取决于应用机器人何时入会、何时离会,以及后端当前可见的会中事件范围。对于已结束会议,通常只在**结束后 5 分钟内**、且应用机器人**曾经在会中**时还能继续拉到事件。 - 查询"谁参加过某会议"请用 `vc meeting get --params '{"meeting_id":"<id>","with_participants":true}'`——这是参会人**快照** API,不依赖 bot 是否参会,对已结束会议也可查;**不要** 用 `+meeting-events` 做参会人查询。 @@ -281,7 +281,7 @@ lark-cli vc +meeting-events \ - [lark-vc-agent-meeting-leave](lark-vc-agent-meeting-leave.md) — 用户明确要求时离会 - [lark-vc-search](../../lark-vc/references/lark-vc-search.md) — 搜索历史会议(获取 meeting_id) - [lark-vc-recording](../../lark-vc/references/lark-vc-recording.md) — 查询 minute_token -- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 获取会议纪要 +- [lark-vc-detail](../../lark-vc/references/lark-vc-detail.md) — 获取会议详情 - [lark-vc-agent](../SKILL.md) — Agent 参会能力(本 skill) - [lark-vc](../../lark-vc/SKILL.md) — 视频会议原子域(Meeting / Note 等核心概念) - [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-vc-agent/references/lark-vc-agent-meeting-join.md b/skills/lark-vc-agent/references/lark-vc-agent-meeting-join.md index 77063bf8..44b57ba2 100644 --- a/skills/lark-vc-agent/references/lark-vc-agent-meeting-join.md +++ b/skills/lark-vc-agent/references/lark-vc-agent-meeting-join.md @@ -106,7 +106,7 @@ lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json lark-cli vc +meeting-join --as bot --meeting-number 123456789 # 第 2 步:会议结束后,先查询会议产物 -lark-cli vc +notes --meeting-ids <meeting.id> +lark-cli vc +detail --meeting-ids <meeting.id> ``` 后续按 `lark-vc` 的产物决策处理:根据 `note_display_type`、`note_id`、`minute_token` 和用户意图选择纪要正文、逐字稿或妙记。 @@ -135,7 +135,7 @@ lark-cli vc +notes --meeting-ids <meeting.id> - [lark-vc-agent-meeting-events](lark-vc-agent-meeting-events.md) — 会中事件流 - [lark-vc-search](../../lark-vc/references/lark-vc-search.md) — 搜索历史会议记录 - [lark-vc-recording](../../lark-vc/references/lark-vc-recording.md) — 查询 minute_token -- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 获取会议纪要 +- [lark-vc-detail](../../lark-vc/references/lark-vc-detail.md) — 获取会议详情 - [lark-vc-agent](../SKILL.md) — Agent 参会能力(本 skill) - [lark-vc](../../lark-vc/SKILL.md) — 视频会议原子域(Meeting / Note 等核心概念) - [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-vc-agent/references/lark-vc-agent-meeting-leave.md b/skills/lark-vc-agent/references/lark-vc-agent-meeting-leave.md index 62da0e74..6ede2b0f 100644 --- a/skills/lark-vc-agent/references/lark-vc-agent-meeting-leave.md +++ b/skills/lark-vc-agent/references/lark-vc-agent-meeting-leave.md @@ -75,7 +75,7 @@ lark-cli vc +meeting-leave --as bot --meeting-id <meeting.id> ```bash # 第 1 步:会议结束后进入 lark-vc 获取会议产物信息 -lark-cli vc +notes --meeting-ids <meeting.id> +lark-cli vc +detail --meeting-ids <meeting.id> ``` ## 常见错误与排查 @@ -99,7 +99,7 @@ lark-cli vc +notes --meeting-ids <meeting.id> - [lark-vc-agent-meeting-events](lark-vc-agent-meeting-events.md) — 会中事件流 - [lark-vc-search](../../lark-vc/references/lark-vc-search.md) — 搜索历史会议(获取 meeting_id) - [lark-vc-recording](../../lark-vc/references/lark-vc-recording.md) — 查询 minute_token -- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 获取会议纪要 +- [lark-vc-detail](../../lark-vc/references/lark-vc-detail.md) — 获取会议详情 - [lark-vc-agent](../SKILL.md) — Agent 参会能力(本 skill) - [lark-vc](../../lark-vc/SKILL.md) — 视频会议原子域(Meeting / Note 等核心概念) - [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-vc/SKILL.md b/skills/lark-vc/SKILL.md index 8125a659..9b1230d9 100644 --- a/skills/lark-vc/SKILL.md +++ b/skills/lark-vc/SKILL.md @@ -24,18 +24,18 @@ metadata: ```bash # BAD — 查昨天的会议用 calendar,会漏掉即时会议 -lark-cli calendar events search_event --query "站会" --start-time ... +lark-cli calendar +search-event --query "站会" --start <start_time> --end <end_time> # GOOD — 查已结束的会议用 vc +search -lark-cli vc +search --query "站会" --start-time ... +lark-cli vc +search --query "站会" --start <start_time> --end <end_time> ``` ## Shortcuts (推荐优先使用) | Shortcut | 说明 | |----------|------| -| [`+search`](references/lark-vc-search.md) | 搜索历史会议记录(需至少一个筛选条件) | -| [`+notes`](references/lark-vc-notes.md) | 查询会议纪要和妙记产物(通过 meeting-ids、minute-tokens 或 calendar-event-ids) | +| [`+search`](references/lark-vc-search.md) | 搜索历史会议记录(需至关键词、时间范围、组织者、参与者、会议室少一个筛选条件) | +| [`+detail`](references/lark-vc-detail.md) | 通过 meeting-ids 获取会议详情,包括 note_id 和 minute_token | | [`+recording`](references/lark-vc-recording.md) | 通过 meeting-ids 或 calendar-event-ids 查询 minute_token | - 使用任何 Shortcut 前,必须先读其对应 reference 文档。 @@ -49,7 +49,8 @@ lark-cli vc +search --query "站会" --start-time ... | 查"今天有哪些会议" | `vc +search`(已结束)+ lark-calendar(未开始),合并展示 | | 只按自然语言标题查"xx 纪要的逐字稿 / 原始记录 / 谁说了什么" | 先到 [lark-drive](../lark-drive/SKILL.md) / [lark-doc](../lark-doc/SKILL.md);仅在已拿到 `note_id` / `vc-node-id` 后再到 [lark-note](../lark-note/SKILL.md) | | Agent 真实入会/离会、会中实时事件 | [lark-vc-agent](../lark-vc-agent/SKILL.md) | -| 本地音视频文件转纪要/逐字稿 | 先走 [lark-minutes](../lark-minutes/SKILL.md) 上传,再回 `vc +notes --minute-tokens` | +| 妙记信息/时长/封面/链接 | 先走 `vc +detail` 或 `vc +recording` 获取 `minute_token`,再用 [lark-minutes](../lark-minutes/SKILL.md) 的 `minutes get` | +| 本地音视频文件转纪要/逐字稿 | 先走 [lark-minutes](../lark-minutes/SKILL.md) 上传,再用 `minutes +detail --minute-tokens` | ## 核心概念 @@ -57,20 +58,20 @@ lark-cli vc +search --query "站会" --start-time ... - **会议纪要(Note)**:视频会议结束后生成的结构化文档,通过 `note_id` 标识,包含纪要文档(总结、待办)和逐字稿文档。`note_display_type` 区分**普通纪要(`normal`)**和 **unified 纪要**;已知 `note_id` 的直查与 unified 原始记录请用 [lark-note](../lark-note/SKILL.md)。 - **妙记(Minutes)**:来源于飞书视频会议的录制产物或用户上传的音视频文件,支持视频/音频的转写,包含总结、待办、章节和文字记录,通过 minute_token 标识。 - **纪要文档(MainDoc)**:AI 智能纪要的主文档,包含 AI 生成的总结和待办,对应 `note_doc_token`。 -- **用户会议纪要(MeetingNotes)**:用户主动绑定到会议的纪要文档,对应 `meeting_notes`。仅通过 `--calendar-event-ids` 路径返回。 +- **用户会议纪要(MeetingNotes)**:用户主动绑定到日程的纪要文档,对应 `meeting_note`。需先通过 [`calendar +meeting`](../lark-calendar/references/lark-calendar-meeting.md) 由 `event_id` 获取。 - **逐字稿(VerbatimDoc)**:会议的逐句文字记录,包含说话人和时间戳。 ## 产物选择决策 | 用户意图 | 必须读取的产物 | 禁止 | |---------|-------------|------| -| 提炼/总结/重新总结/整理会议内容/回顾会议 | 原始对话记录(按下方逐字稿路由取得)或妙记文字记录(Transcript),基于原始对话独立分析 | 禁止直接搬运 AI 纪要(`note_doc_token`)的总结作为最终输出 | +| 提炼/总结/重新总结/整理会议内容/回顾会议 | 为降低 token 消耗,非必须不得获取 AI 纪要。必须使用原始对话记录(按下方逐字稿路由取得)或妙记文字记录(Transcript),基于原始对话独立分析 | 禁止直接搬运 AI 纪要(`note_doc_token`)的总结作为最终输出 | | 查看待办/章节 | AI 纪要(`note_doc_token`)或妙记产物 — AI 待办更友好(含提出人和负责人),章节按话题划分更结构化 | — | | 查看纪要链接/文档地址 | 仅返回文档链接,无需读取内容 | — | | 直接看 AI 总结结果 | AI 纪要(`note_doc_token`) | — | | 谁说了什么/完整发言记录 | 原始对话记录(按下方逐字稿路由取得) | — | -> **逐字稿路由**:先看 `vc +notes` 返回的 `note_display_type`,不要只看 `verbatim_doc_token` 是否为空。具体路由以 [`+notes`](references/lark-vc-notes.md) 和 [lark-note](../lark-note/SKILL.md) 为准。 +> **逐字稿路由**:先用 `vc +detail` 拿到 `note_id`,再 [`note +detail`](../lark-note/SKILL.md) 看 `note_display_type`,**不要只看 `verbatim_doc_token` 是否为空**。具体路由以 [lark-note](../lark-note/SKILL.md) 的 `note_display_type` 规则为准。 > > **为什么"提炼/总结"必须从原始对话记录出发?** AI 纪要是模型对会议的二次压缩,可能遗漏讨论细节、争论过程和隐含决策。用户要求"提炼"或"重新总结"时,期望的是基于原始对话的独立分析,而非对 AI 产物的重新排版。 @@ -92,21 +93,21 @@ lark-cli vc +search --query "站会" --start-time ... ```bash # 1. 读取纪要内容 -lark-cli docs +fetch --api-version v2 --doc <note_doc_token> --doc-format markdown +lark-cli docs +fetch --doc <note_doc_token> --doc-format markdown # 2. 从返回的 markdown 中提取第一个 <whiteboard token="xxx"/> 的 token # 3. 下载封面图到聚合目录(和逐字稿、录像同目录,保持产物归拢) # 并非所有纪要都有封面画板,没有 <whiteboard> 标签时跳过即可 lark-cli docs +media-download --type whiteboard --token <whiteboard_token> --output ./minutes/<minute_token>/cover ``` -> **产物目录规范**:同一会议的所有下载产物(录像、逐字稿、封面图等)统一放到 `./minutes/{minute_token}/` 目录下。这与 `minutes +download` 和 `vc +notes --minute-tokens` 的默认落点保持一致,便于 Agent 聚合。显式路径(如封面图)需手动对齐到同一目录。 +> **产物目录规范**:同一会议的所有下载产物(录像、逐字稿、封面图等)统一放到 `./minutes/{minute_token}/` 目录下。这与 `minutes +download` 和 `minutes +detail --minute-tokens` 的默认落点保持一致,便于 Agent 聚合。显式路径(如封面图)需手动对齐到同一目录。 > **纪要相关文档 — 根据用户意图选择:** -> - `note_doc_token` → **AI 智能纪要**(AI 总结 + 待办) -> - `meeting_notes` → **用户绑定的会议纪要**(用户主动关联到会议的文档,仅 `--calendar-event-ids` 路径返回) -> - 用户说"逐字稿""完整记录""谁说了什么"时 → 按 `note_display_type` 路由,详见 [`+notes`](references/lark-vc-notes.md) -> - 用户说"纪要""总结""纪要内容"时,应同时返回 `note_doc_token` 和 `meeting_notes`(如有) +> - `note_doc_token` → **AI 智能纪要**(AI 总结 + 待办),由 `note +detail --note-id <note_id>` 返回 +> - `meeting_note` → **用户绑定到日程的会议纪要**,由 [`calendar +meeting --event-ids <event_id>`](../lark-calendar/references/lark-calendar-meeting.md) 返回 +> - 用户说"逐字稿""完整记录""谁说了什么"时 → 按 `note_display_type` 路由,详见 [lark-note](../lark-note/SKILL.md) +> - 用户说"纪要""总结""纪要内容"时,应同时返回 `note_doc_token` 和 `meeting_note`(如有) > - 用户意图不明确时,应展示所有文档链接让用户选择,而不是替用户决定 -> - 如果用户提供的是**本地音视频文件**并说"转纪要""转逐字稿",不要直接从 `vc +notes` 开始;应先用 [minutes +upload](../lark-minutes/references/lark-minutes-upload.md) 生成 `minute_url`,再提取 `minute_token` 调用 `vc +notes --minute-tokens` +> - 如果用户提供的是**本地音视频文件**并说"转纪要""转逐字稿",不要直接从 `vc +detail` 开始;应先用 [minutes +upload](../lark-minutes/references/lark-minutes-upload.md) 生成 `minute_url`,再提取 `minute_token` 调用 `minutes +detail --minute-tokens` ### 3. 纪要文档与逐字稿链接 1. 纪要文档、逐字稿文档与关联的共享文档默认使用文档 Token 返回。 @@ -118,10 +119,10 @@ lark-cli schema drive.metas.batch_query # 批量获取文档基本信息: 一次最多查询 10 个文档 lark-cli drive metas batch_query --data '{"request_docs": [{"doc_type": "docx", "doc_token": "<doc_token>"}], "with_url": true}' ``` -3. 需要获取文档内容时,使用 `lark-cli docs +fetch --api-version v2`。 +3. 需要获取文档内容时,使用 `lark-cli docs +fetch`。 ```bash # 获取文档内容 -lark-cli docs +fetch --api-version v2 --doc <doc_token> --doc-format markdown +lark-cli docs +fetch --doc <doc_token> --doc-format markdown ``` ### 4. 查询参会人快照(读操作) @@ -137,7 +138,7 @@ lark-cli vc meeting get --params '{"meeting_id":"<meeting_id>","with_participant | 用户意图 | 推荐命令 | 所在 skill | |---------|---------|--------| | 参会人快照(谁参加过、何时入/离会,任意时点)| `vc meeting get --with-participants` | 本 skill | -| 已结束会议的发言内容 | 先 `vc +notes`,再按 `note_display_type` 路由 | 本 skill / [`lark-note`](../lark-note/SKILL.md) | +| 已结束会议的发言内容 | 优先:`vc +detail` 取 `note_id` 再 `note +detail` 取 `verbatim_doc_token` 后 `docs +fetch`;备选:`vc +detail` 取 `minute_token` 再 `minutes +detail --transcript` | [lark-note](../lark-note/SKILL.md) / [lark-minutes](../lark-minutes/SKILL.md) | | **进行中会议**的实时事件流(转写、聊天、共享、会中加入/离开)| `vc +meeting-events` | [`lark-vc-agent`](../lark-vc-agent/SKILL.md) | | **Agent 真实入会 / 离会** | `vc +meeting-join` / `vc +meeting-leave` | [`lark-vc-agent`](../lark-vc-agent/SKILL.md) | @@ -151,7 +152,7 @@ Meeting (视频会议) │ ├── VerbatimDoc (逐字稿, verbatim_doc_token) ← normal 路径 │ ├── UnifiedTranscript (unified 原始记录) ← unified 路径,note +transcript(lark-note) │ └── SharedDoc (会中共享文档) -└── Minutes (妙记) ← minute_token 标识,+recording 从 meeting_id 获取 +└── Minutes (妙记) ← minute_token 标识,由 `vc +detail` 或 `vc +recording` 桥接获取,产物详情走 [lark-minutes](../lark-minutes/SKILL.md) ├── Transcript (文字记录) ├── Summary (总结) ├── Todos (待办) @@ -159,12 +160,16 @@ Meeting (视频会议) └── Keywords (推荐关键词) ``` -> **妙记边界**:`+notes` 负责纪要内容、逐字稿和 AI 产物;妙记基础信息请优先看 [`+recording`](references/lark-vc-recording.md) 与 [lark-minutes](../lark-minutes/SKILL.md)。 +> **MeetingNotes 边界**:用户绑定到日程的会议纪要文档(`meeting_note`)属于日程域,不在 VC 资源关系内;从 `event_id` 用 [`calendar +meeting`](../lark-calendar/references/lark-calendar-meeting.md) 获取。 > -> **Note 域边界**:`vc +notes` 是从**会议线索**(`meeting_id` / `calendar_event_id` / `minute_token`)定位纪要的入口,返回 `note_id` 和 `note_display_type`。 -> - 已有 `note_id` → [lark-note](../lark-note/SKILL.md)。 +> **妙记边界**:`+recording` 仅负责把 `meeting_id` / `calendar_event_id` 桥接到 `minute_token`;妙记的总结/待办/章节/逐字稿等产物归 [lark-minutes](../lark-minutes/SKILL.md)(`minutes +detail`)。 +> +> **Note 域边界**:VC 域只负责把 `meeting_id` 转成 `note_id` / `minute_token`,纪要详情归 [lark-note](../lark-note/SKILL.md)。 +> - 入口选择:从 `meeting_id` 出发用 `vc +detail` 拿 `note_id` 和 `minute_token`;从 `minute_token` 出发用 [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md) 也会返回关联的 `note_id`,可继续走 `note +detail` 拿纪要文档 token。 +> - 已有 `note_id` → 直接走 [`note +detail`](../lark-note/SKILL.md) / [`note +transcript`](../lark-note/SKILL.md),不要绕回 VC。 > - 已有 `doc_token` 且目标是读正文 → [lark-doc](../lark-doc/SKILL.md)。 > - 只有自然语言纪要标题 → 文档搜索 / Docx 正文读取;有显式 `vc-node-id` 才进入 [lark-note](../lark-note/SKILL.md)。 +> - 从日程出发(只有 `event_id`)→ 先走 [`calendar +meeting`](../lark-calendar/references/lark-calendar-meeting.md) 拿到 `meeting_id` 或 `meeting_note`,再按上述路径继续。 ## API Resources @@ -186,12 +191,12 @@ lark-cli vc meeting get --params '{"meeting_id": "<meeting_id>", "with_participa ### minutes(跨域,详见 [lark-minutes](../lark-minutes/SKILL.md)) - - `get` — 获取妙记基础信息(标题、时长、封面);查询妙记**内容**请用 `+notes --minute-tokens <minute-token>` + - `get` — 获取妙记基础信息(标题、时长、封面);查询妙记**内容**(总结/待办/章节/逐字稿)请用 [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md) ## 不在本 skill 范围 - 查询未来的会议日程 → [lark-calendar](../lark-calendar/SKILL.md) - Agent 真实入会/离会、会中实时事件 → [lark-vc-agent](../lark-vc-agent/SKILL.md) - 只有纪要文档标题的逐字稿查询 → 文档搜索 / Docx 正文读取;有显式 `vc-node-id` 才进入 [lark-note](../lark-note/SKILL.md) -- 本地音视频文件转纪要/逐字稿 → [lark-minutes](../lark-minutes/SKILL.md)(上传后回 `vc +notes`) -- 妙记搜索/下载/上传/重命名/替换说话人 → [lark-minutes](../lark-minutes/SKILL.md) +- 本地音视频文件转纪要/逐字稿、妙记搜索/下载/上传/重命名/替换说话人 → [lark-minutes](../lark-minutes/SKILL.md) +- 通过 `note_id` 取纪要文档 Token → [lark-note](../lark-note/SKILL.md) diff --git a/skills/lark-vc/references/lark-vc-detail.md b/skills/lark-vc/references/lark-vc-detail.md new file mode 100644 index 00000000..69838aaa --- /dev/null +++ b/skills/lark-vc/references/lark-vc-detail.md @@ -0,0 +1,44 @@ + +# vc +detail + +通过会议 ID 获取会议详情,包括基本信息、关联的纪要 ID(`note_id`)和妙记 Token(`minute_token`)。只读。 + +## 命令 + +```bash +# 单个 / 批量(逗号分隔,最多 50 个) +lark-cli vc +detail --meeting-ids <meeting_id1>,<meeting_id2> +``` + +## 输出字段 + +| 字段 | 说明 | +|------|------| +| `meeting_id` | 会议 ID | +| `meeting_no` | 会议 9 位号码 | +| `topic` | 会议主题 | +| `start_time` | 开始时间 | +| `end_time` | 结束时间 | +| `note_id` | 关联的纪要 ID。 | +| `minute_token` | 关联的妙记 Token。 | + +## 典型场景 + +### 场景 1:获取会议的纪要和妙记关联 + +`vc +detail` 只能拿到 `note_id` 和 `minute_token`,不直接返回纪要文档 token 与妙记产物内容。要获取实际产物,需根据用户诉求继续调用 `note +detail` 或 `minutes +detail`: + +```bash +# 1. 获取会议详情,拿到 note_id 和 minute_token +lark-cli vc +detail --meeting-ids <meeting_id> + +# 2. 用 note_id 获取纪要文档 Token(note_doc_token / verbatim_doc_token / shared_doc_tokens) +lark-cli note +detail --note-id <note_id> + +# 3. 用 minute_token 获取妙记产物 +# ⚠️ 必须显式指定 --summary / --todo / --chapter / --keyword / --transcript 中至少一个 flag, +# 不传任何 flag 则不会返回任何产物内容。 +lark-cli minutes +detail --minute-tokens <minute_token> --todo --transcript +``` + +> **路由建议**:当用户未明确指定使用妙记时,**优先**走 `note +detail` 链路(纪要文档信息更完整、含逐字稿原文),仅在 `note_id` 为空或用户要求妙记产物时才走 `minutes +detail`。 diff --git a/skills/lark-vc/references/lark-vc-notes.md b/skills/lark-vc/references/lark-vc-notes.md deleted file mode 100644 index 15c85be2..00000000 --- a/skills/lark-vc/references/lark-vc-notes.md +++ /dev/null @@ -1,148 +0,0 @@ - -# vc +notes - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -查询会议纪要,支持通过会议 ID、妙记 Token 或日程事件 ID 获取纪要文档、逐字稿、AI 总结、待办和章节。只读操作。 - -本 skill 对应 shortcut:`lark-cli vc +notes`。 - -## 命令 - -```bash -# 通过会议 ID 查询(逗号分隔支持批量,最多 50 个) -lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 -lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28,69xxxxxxxxxxxxx29 - -# 通过妙记 Token 查询(从妙记 URL 中提取) -lark-cli vc +notes --minute-tokens obbxxxxxxxxxxxxxxxxxx -lark-cli vc +notes --minute-tokens obbxxxxxxxxxxxxxxxxxx,obbyyyyyyyyyyyyyyyyyy - -# 指定逐字稿输出目录(仅 --minute-tokens 路径有效) -lark-cli vc +notes --minute-tokens obbxxxxxxxxxxxxxxxxxx --output-dir ./output -lark-cli vc +notes --minute-tokens obbxxxxxxxxxxxxxxxxxx --overwrite - -# 通过日程事件 ID 查询(从 calendar +agenda 获取 event_id) -lark-cli vc +notes --calendar-event-ids xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx_0 - -# 输出格式 -lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --format json - -# 预览 API 调用 -lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --dry-run -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--meeting-ids <ids>` | 三选一 | 会议 ID,逗号分隔支持批量 | -| `--minute-tokens <tokens>` | 三选一 | 妙记 Token,逗号分隔支持批量 | -| `--calendar-event-ids <ids>` | 三选一 | 日程事件 ID,逗号分隔支持批量 | -| `--output-dir <dir>` | 否 | 逐字稿输出目录。未指定时默认落到 `./minutes/{minute_token}/transcript.txt`(与 `minutes +download` 共享目录);显式指定时沿用旧布局 `./{output-dir}/artifact-{title}-{token}/transcript.txt`。仅 `--minute-tokens` 路径有效 | -| `--overwrite` | 否 | 覆盖已存在的逐字稿文件,仅 `--minute-tokens` 路径有效 | -| `--dry-run` | 否 | 预览 API 调用,不执行 | - -## 核心约束 - -### 1. 三种参数互斥 - -每次只能指定一种输入方式。同时传入多种会报错。 - -### 2. 仅支持 user 身份 - -该命令仅支持 `user` 身份,使用前需完成 `lark-cli auth login`。 - -### 3. 批量上限 - -每次最多传入 50 个 ID/Token。 - -### 4. 按路径检查权限 - -不同输入方式需要不同权限,命令会自动检查对应路径所需的 scope: - -| 输入 | 所需权限 | -|------|---------| -| `--meeting-ids` | `vc:meeting.meetingevent:read`、`vc:note:read`、`vc:record:readonly` | -| `--minute-tokens` | `vc:note:read`、`minutes:minutes:readonly`、`minutes:minutes.artifacts:read`、`minutes:minutes.transcript:export` | -| `--calendar-event-ids` | `calendar:calendar:read`、`calendar:calendar.event:read`、`vc:meeting.meetingevent:read`、`vc:note:read`、`vc:record:readonly` | - -## 输出结果 - -### 有纪要文档时 - -返回 `notes` 数组,每条记录包含: - -| 字段 | 说明 | -|------|------| -| `meeting_id` | 会议 ID(`--meeting-ids` / `--calendar-event-ids` 路径) | -| `minute_token` | **会议对应的妙记 Token**(`--meeting-ids` / `--calendar-event-ids` 路径自动通过录制 API 反查并附加)| -| `note_id` | **纪要 ID** — 用于继续进入 Note 域(`note +detail` / `note +transcript`) | -| `note_display_type` | **纪要展示类型**:`unknown` / `normal` / `unified`,区分普通纪要和 unified 纪要 | -| `note_doc_token` | **AI 智能纪要**文档 Token — AI 生成的总结、待办、章节 | -| `meeting_notes` | **用户绑定的会议纪要**文档 Token 列表 — 用户主动关联到会议的文档(仅 `--calendar-event-ids` 路径返回) | -| `verbatim_doc_token` | **逐字稿**文档 Token — 完整的逐句文字记录,含说话人和时间戳;unified 纪要的逐字稿请改用 `note +transcript` | -| `shared_doc_tokens` | 会中共享文档 Token 列表 | -| `creator_id` | 创建者 ID | -| `create_time` | 创建时间(格式化) | - -> **选择哪个 token?** 用户说"会议纪要""总结""待办""纪要内容" → 返回 `note_doc_token` 和 `meeting_notes`(如有)。用户说"逐字稿""完整记录""谁说了什么" → 见下方「按 `note_display_type` 路由逐字稿」。意图不明确时,展示所有文档链接让用户选择。 -> -> 📌 不确定该返回哪个 token?参见 [`vc-domain-boundaries.md`](vc-domain-boundaries.md) 的产物链路对比表,了解 AI 总结链路 vs 录制链路的区别。 - -### 按 `note_display_type` 路由逐字稿 / 原始记录 - -逐字稿走哪条路由由 `note_display_type` 决定,**不要只看 `verbatim_doc_token` 是否为空**: - -| 字段 / 条件 | Agent 动作 | -|------------|-----------| -| 用户要纪要正文 / 总结 / 待办 / 章节 | `docs +fetch --api-version v2 --doc <note_doc_token>` | -| `note_display_type=normal` + 用户要逐字稿 | `docs +fetch --api-version v2 --doc <verbatim_doc_token>` | -| `note_display_type=unknown` + `verbatim_doc_token` 非空 + 用户要逐字稿 | `docs +fetch --api-version v2 --doc <verbatim_doc_token>`;不要猜成 unified | -| `note_display_type=unknown` + 无可用逐字稿 token | 先 `note +detail --note-id <note_id>` 复核,再按返回的展示类型路由 | -| `note_display_type=unified` + 用户要逐字稿 / 原始记录 | `note +transcript --note-id <note_id>` → 切到 [lark-note](../../lark-note/SKILL.md) | -| `minute_token` 存在 + 用户要音视频媒体 | `minutes +download --minute-tokens <minute_token>` | - -> **`unified` 纪要的逐字稿不是独立文档**,必须用 `note +transcript` 按 `note_id` 拉取,输出更结构化。即使 unified 也返回了非空 `verbatim_doc_token`,仍以 `note_display_type` 为准。 - -### minute-tokens 路径的 AI 产物 - -通过 `--minute-tokens` 查询时,返回的 `artifacts` 字段包含 AI 内置产物: - -| 字段 | 说明 | -|------|------| -| `artifacts.summary` | AI 总结(JSON 内联) | -| `artifacts.todos` | 待办事项(JSON 内联,**只读**);每条含 `content`、`is_done` 及 `todo_id`。`todo_id` 仅供 [`minutes +todo`](../../lark-minutes/references/lark-minutes-todo.md) 更新/删除待办时使用,不必展示给用户。**新建**妙记内待办请用 `minutes +todo`,不要用 lark-task | -| `artifacts.chapters` | 章节纪要(JSON 内联) | -| `artifacts.keywords` | 妙记推荐关键词(JSON 内联) | -| `artifacts.transcript_file` | 逐字稿本地文件路径。默认落到 `./minutes/{minute_token}/transcript.txt`(与 `minutes +download` 聚合);显式 `--output-dir` 时走旧布局 `./{output-dir}/artifact-{title}-{token}/transcript.txt` | - -## 如何获取输入参数 - -| 输入参数 | 获取方式 | -|---------|---------| -| `meeting_id` | `vc +search` 搜索历史会议 → 结果中的 `id` 字段 | -| `minute_token` | 从妙记 URL 中提取,如 `https://sample.feishu.cn/minutes/obbyyyyyyyyyyyyyyyyyy` → `obbyyyyyyyyyyyyyyyyyy` | -| `calendar_event_id` | `calendar +agenda` 查看日程 → 结果中的 `event_id` 字段 | - -## 常见错误与排查 - -| 错误现象 | 根本原因 | 解决方案 | -|---------|---------|---------| -| `exactly one of ... is required` | 未传入参数或同时传了多种 | 只指定一种输入方式 | -| `no notes available for this meeting` | 该会议未生成纪要 | 尝试用 `--minute-tokens` 路径 | -| `121005 no permission` | 非会议参与者无权查看 | 使用 `--minute-tokens` 降级到内置产物 | -| `missing required scope(s)` | 权限不足 | 按提示运行 `auth login --scope` | -| `too many IDs` | 超过批量上限 | 分批查询,每批最多 50 个 | - -## 提示 -- 默认使用 `--format json` 输出,你更佳擅长解析 JSON 数据。 -- 排查参数与请求结构时优先使用 `--dry-run`。 -- `--meeting-ids` 和 `--calendar-event-ids` 路径最终都走纪要详情 API,需要 `vc:note:read` 权限。 -- `--minute-tokens` 路径无纪要权限时会自动降级,**不会报错**,而是下载内置产物到本地。 - -## 参考 - -- [lark-vc](../SKILL.md) — 视频会议全部命令 -- [lark-vc-search](lark-vc-search.md) — 搜索历史会议(获取 meeting_id) -- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-vc/references/lark-vc-recording.md b/skills/lark-vc/references/lark-vc-recording.md index b3c42b8f..5f52e248 100644 --- a/skills/lark-vc/references/lark-vc-recording.md +++ b/skills/lark-vc/references/lark-vc-recording.md @@ -5,7 +5,7 @@ 通过 meeting_id 或 calendar_event_id 查询对应的 minute_token。这是 VC 域和 Minutes 域之间的桥梁命令。只读操作。 -> **边界提醒:** 如果用户明确要的是"妙记信息""妙记详情""妙记链接""minute_token""标题""时长""owner"这类妙记元信息,先用本命令拿到 `minute_token`,再调用 `minutes minutes get`。不要直接切到 `vc +notes`;`vc +notes` 只用于纪要内容和逐字稿。 +> **边界提醒:** 如果用户明确要的是"妙记信息""妙记详情""妙记链接""minute_token""标题""时长""owner"这类妙记元信息,先用本命令拿到 `minute_token`,再调用 `minutes minutes get`。不要直接切到 `minutes +detail`;`minutes +detail` 只用于纪要内容和逐字稿。 本 skill 对应 shortcut:`lark-cli vc +recording`。 @@ -102,7 +102,8 @@ lark-cli minutes minutes get --params '{"minute_token":"<minute_token>"}' lark-cli vc +recording --meeting-ids xxx # 第 2 步:使用上一步返回的 minute_token 获取完整纪要 -lark-cli vc +notes --minute-tokens <minute_token> +# ⚠️ 必须显式指定要获取的产物 flag(--summary, --keyword, --todo, --chapter, --transcript) +lark-cli minutes +detail --minute-tokens <minute_token> --summary --todo --chapter --transcript ``` ### 场景 4:先搜索会议,再获取录制并下载 @@ -143,11 +144,11 @@ lark-cli minutes +download --minute-tokens <minute_token> - 默认使用 `--format json` 输出,Agent 更擅长解析 JSON 数据。 - 排查参数与请求结构时优先使用 `--dry-run`。 - `minute_token` 从录制 URL 尾段解析(`https://meetings.feishu.cn/minutes/{minute_token}`)。 -- 拿到 `minute_token` 后,如果要妙记基础信息,优先传给 `minutes minutes get`;如果要下载媒体文件,传给 `minutes +download`;如果要逐字稿、总结、待办、章节,再传给 `vc +notes --minute-tokens`。 +- 拿到 `minute_token` 后,如果要妙记基础信息,优先传给 `minutes minutes get`;如果要下载媒体文件,传给 `minutes +download`;如果要逐字稿、总结、待办、章节,再传给 `minutes +detail --minute-tokens`。 ## 参考 - [lark-vc](../SKILL.md) — 视频会议全部命令 - [lark-vc-search](lark-vc-search.md) — 搜索历史会议(获取 meeting_id) -- [lark-vc-notes](lark-vc-notes.md) — 获取会议纪要 +- [lark-minutes-detail](../../lark-minutes/references/lark-minutes-detail.md) — 获取会议纪要 - [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-vc/references/lark-vc-search.md b/skills/lark-vc/references/lark-vc-search.md index 91361134..7aaa3160 100644 --- a/skills/lark-vc/references/lark-vc-search.md +++ b/skills/lark-vc/references/lark-vc-search.md @@ -1,17 +1,13 @@ # vc +search -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 - -搜索已结束的历史会议记录,支持关键词、时间范围、组织者、参与者以及会议室等多条件过滤。只读操作,不修改任何会议数据。 - -本 skill 对应 shortcut:`lark-cli vc +search`(调用 `POST /open-apis/vc/v1/meetings/search`)。 +搜索已结束的历史会议记录,支持关键词、时间范围、组织者、参与者、会议室多条件过滤。只读,仅 `--as user`。 ## 关键词使用边界 `--query` 只用于真实会议关键词,例如会议主题、项目名、评审名、客户名。用户只是说"我这月参加的所有视频会议"、"最近两周我组织的所有视频会议"、"总结主要议题 / 看看参会情况"时,本质是历史会议列表和后续总结,不要把"回顾"、"所有视频会议"、"总结主要议题"等动作词放进 `--query`。这类请求应先用时间范围 + `--participant-ids` / `--organizer-ids` 搜全量候选,再按结果继续取纪要或录制信息。 -列表阶段只负责找会议记录;总结阶段必须继续取证。若用户要求"主要议题"、"主要决策"、"参会情况",先确认搜索结果的 `meeting_id`、时间、组织者/参与者符合过滤条件,然后用 `vc +notes` 或 `vc +recording` / `minutes` 读取纪要、妙记或录制信息。没有纪要或妙记时,如实说明只能基于会议标题/参会数据汇总,不要编造议题。 +列表阶段只负责找会议记录;总结阶段必须继续取证。若用户要求"主要议题"、"主要决策"、"参会情况",先确认搜索结果的 `meeting_id`、时间、组织者/参与者符合过滤条件,然后用 `vc +detail` 或 `minutes` 读取纪要、妙记或录制信息。没有纪要或妙记时,如实说明只能基于会议标题/参会数据汇总,不要编造议题。 ## 典型触发表达 @@ -35,37 +31,17 @@ lark-cli vc +search --start 2026-03-10 --end 2026-03-10 # 按时间范围搜索 lark-cli vc +search --start "2026-03-10T00:00+08:00" --end "2026-03-17T00:00+08:00" -lark-cli vc +search --start 2026-03-10 --end 2026-03-17 -# 关键词 + 时间范围 -lark-cli vc +search --query "周会" --start "2026-03-10T00:00+08:00" --end "2026-03-17T00:00+08:00" -lark-cli vc +search --query "周会" --start "2026-03-10T00:00+08:00" -lark-cli vc +search --query "周会" --end "2026-03-17T00:00+08:00" - -# 按组织者过滤(open_id,逗号分隔) -lark-cli vc +search --organizer-ids "ou_a,ou_b" - -# 按参与者过滤(open_id,逗号分隔) -lark-cli vc +search --participant-ids "ou_x,ou_y" - -# 查询我这个月参加过的历史会议,不带关键词 -lark-cli vc +search --start "<YYYY-MM-DD>" --end "<YYYY-MM-DD>" --participant-ids "ou_me" - -# 查询最近两周我组织的历史会议,不带关键词 -lark-cli vc +search --start "<YYYY-MM-DD>" --end "<YYYY-MM-DD>" --organizer-ids "ou_me" - -# 按会议室过滤 +# 按组织者 / 参与者 / 会议室(逗号分隔) +lark-cli vc +search --organizer-ids "ou_user1,ou_user2" +lark-cli vc +search --participant-ids "ou_user1,ou_user2" lark-cli vc +search --room-ids "123,456" -# 多条件组合查询 -lark-cli vc +search --organizer-ids "ou_a" --room-ids "123" --start "2026-03-10T00:00+08:00" +# 多条件组合 +lark-cli vc +search --organizer-ids "ou_user1" --room-ids "123" --start "2026-03-10T00:00+08:00" -# 分页查询 -lark-cli vc +search --query "周会" --page-size 15 -lark-cli vc +search --query "周会" --page-token "next_page_token" - -# 输出为表格/可读格式 -lark-cli vc +search --query "周会" --format json +# 翻页 +lark-cli vc +search --query "周会" --page-token "<PAGE_TOKEN>" ``` ## 参数 @@ -137,15 +113,15 @@ lark-cli vc +search --query "周会" --format json ## 输出结果 -- 默认输出 JSON,包含 `items`、`total`、`has_more` 和 `page_token`。 +- 默认输出 JSON,包含 `items`、`has_more` 和 `page_token`。 ## Pagination (`has_more` / `page_token`) - 当结果中返回 `has_more=true` 时,说明还有更多页可继续获取。 - 继续翻页时,使用响应中的 `page_token` 搭配 `--page-token` 发起下一次查询。 - 不要假设调大 `--page-size` 就能拿全结果;分页遍历时应以 `has_more` 和 `page_token` 为准。 -- 未明确要求全量时,`total` 数量小于 50 可自动分页获取所有结果;`total` 数量大于 50 时,先向用户确认是否继续获取全部结果。 -- 用户明确说"所有 / 全部 / 统计 / 按时间排序"时,该全量意图优先于 `total > 50` 的确认门槛;直接完成分页和去重,再排序或统计,不要只用第一页回答。 +- 未明确要求全量时,逐页累计已读取的 `items` 数:累计不到 50 条之前可自动继续翻页(`has_more=true` 即继续);超过 50 条且仍 `has_more=true` 时,先向用户确认是否继续获取全部结果。 +- 用户明确说"所有 / 全部 / 统计 / 按时间排序"时,该全量意图优先于 50 条的确认门槛;直接按 `has_more` 翻完所有页并去重,再排序或统计,不要只用第一页回答。 ```bash # First page @@ -161,7 +137,7 @@ lark-cli vc +search --query "周会" --page-size 15 --page-token "<PAGE_TOKEN>" ```bash # 如果要会议纪要 / 逐字稿 / AI 总结 / 待办 / 章节 -lark-cli vc +notes --meeting-ids <MEETING_ID> +lark-cli vc +detail --meeting-ids <MEETING_ID> # 如果要会议对应的妙记信息 / minute_token / 妙记链接 lark-cli vc +recording --meeting-ids <MEETING_ID> @@ -183,11 +159,5 @@ lark-cli minutes minutes get --params '{"minute_token":"<MINUTE_TOKEN>"}' - 排查参数与请求结构时优先使用 `--dry-run`。 - 搜索的时间范围最大为 1 个月,如果需要搜索更长时间范围的会议,需要拆分为多次时间范围为一个月查询。 - 不要使用 `yesterday`、`today` 这类相对时间字面量;请先转换成明确日期,例如 `2026-03-10`。 -- 用户如果明确问的是“妙记信息”而不是“纪要内容”,不要默认走 `vc +notes`;应先用 `vc +recording`。 +- 用户如果明确问的是“妙记信息”而不是“纪要内容”,不要默认走 `vc +detail`;应先用 `vc +recording`。 -## 参考 - -- [lark-vc](../SKILL.md) -- 视频会议全部命令 -- [lark-vc-recording](lark-vc-recording.md) -- 查询会议对应的 minute_token -- [lark-vc-notes](lark-vc-notes.md) -- 获取会议纪要 -- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/skills/lark-vc/references/vc-domain-boundaries.md b/skills/lark-vc/references/vc-domain-boundaries.md index 01014a83..380c10ca 100644 --- a/skills/lark-vc/references/vc-domain-boundaries.md +++ b/skills/lark-vc/references/vc-domain-boundaries.md @@ -30,7 +30,7 @@ | 逐字稿 | `verbatim_doc_token` | 飞书文档 | 完整的逐句发言记录(含说话人、时间戳)— **仅 `note_display_type=normal` 时是可读的独立文档**;`unified` 纪要的逐字稿用 `note +transcript --note-id <note_id>` 拉取(见下方 [Note 域](#note-域)) | | 共享文档 | `shared_doc_token` | 飞书文档 | 会中投屏共享的文档信息 | -此外,还存在**用户会议纪要(MeetingNotes)**,对应 `meeting_notes` 字段。这是用户主动绑定到会议的纪要文档,通常用于会前记录会议相关内容,与智能纪要文档相互独立。仅通过 `+notes --calendar-event-ids` 路径返回。 +此外,还存在**用户会议纪要(MeetingNotes)**,对应 `meeting_note` 字段。这是用户主动绑定到日程的纪要文档,通常用于会前记录会议相关内容,与智能纪要文档相互独立。仅通过 [`calendar +meeting --event-ids`](../../lark-calendar/references/lark-calendar-meeting.md) 路径返回。 #### 链路二:开启「录制」 @@ -51,7 +51,7 @@ > **产物选择决策**: > - **AI 产物 vs 原始记录**:智能总结、待办、章节都属于 AI 分析产物,可能只包含最终结论和关键信息。 -> - **用户要求"提炼/总结/重新总结/整理/回顾"会议内容时** → **内容总结必须从逐字稿/文字记录出发,基于原始对话独立分析**。禁止直接搬运 AI 纪要的总结作为最终输出——那只是对 AI 产物的重新排版,不是独立提炼。AI 纪要可作为补充参考,但不能作为内容总结的唯一信息源。 +> - **用户要求"提炼/总结/重新总结/整理/回顾"会议内容时** → **内容总结必须从逐字稿/文字记录出发,基于原始对话独立分析**。禁止直接搬运 AI 纪要的总结作为最终输出——那只是对 AI 产物的重新排版,不是独立提炼。 > - **用户要求查看待办或章节时** → **应参考 AI 产物的待办和章节**,因为 AI 产物的待办更友好(包含提出人和负责人),章节按话题划分更结构化。 > - **用户只想直接看 AI 总结结果** → 使用 AI 产物的总结。 > - **链路优先级**:如果用户没有明确偏好,对于重复的内容(如智能总结、待办),**优先查询智能纪要(Note),不存在时再降级到妙记(Minutes)**。 @@ -81,7 +81,7 @@ 根据关键字、组织者、参与人、会议室等条件搜索会议,获取会议列表。 -> **不要把纪要标题当会议线索:** 如果用户说“查询 xx 纪要的逐字稿 / 原始记录 / 谁说了什么”,且没有 `meeting_id`、`calendar_event_id`、会议号、参会人或时间范围,先用 `drive +search --query <标题>` 搜索纪要文档,拿到 Docx URL/token 后再 `docs +fetch --api-version v2`。若返回 `<vc-transcribe-tab vc-node-id="...">`,提取 `note_id` 后进入 Note 域判断 `normal` / `unified`;若没有该 block,但有“文字记录/逐字稿” Docx 链接,直接用 `docs +fetch --api-version v2` 读取该链接。 +> **不要把纪要标题当会议线索:** 如果用户说“查询 xx 纪要的逐字稿 / 原始记录 / 谁说了什么”,且没有 `meeting_id`、`calendar_event_id`、会议号、参会人或时间范围,先用 `drive +search --query <标题>` 搜索纪要文档,拿到 Docx URL/token 后再 `docs +fetch`。若返回 `<vc-transcribe-tab vc-node-id="...">`,提取 `note_id` 后进入 Note 域判断 `normal` / `unified`;若没有该 block,但有“文字记录/逐字稿” Docx 链接,直接用 `docs +fetch` 读取该链接。 ```bash lark-cli vc +search --start "<YYYY-MM-DD>" --end "<YYYY-MM-DD>" --format json @@ -93,8 +93,20 @@ lark-cli vc +search --start "<YYYY-MM-DD>" --end "<YYYY-MM-DD>" --format json ##### 获取会议产物 +当用户提供 `meeting_id` 并需要会议产物时,先用 `vc +detail` 拿到 `note_id` 和 `minute_token`: + ```bash -lark-cli vc +notes --meeting-ids '<meeting_id1>,<meeting_id2>' +lark-cli vc +detail --meeting-ids '<meeting_id1>,<meeting_id2>' +``` + +详细用法请阅读 [`lark-vc-detail.md`](lark-vc-detail.md)。 + +**优先路径:通过 `note_id` 获取纪要产物** + +如果用户未明确要求使用妙记,且返回了 `note_id`,**优先**使用 `note +detail` 获取纪要文档的 token 信息: + +```bash +lark-cli note +detail --note-id <note_id> ``` 可获取会议的所有产物信息,包括: @@ -102,28 +114,31 @@ lark-cli vc +notes --meeting-ids '<meeting_id1>,<meeting_id2>' - 智能纪要(`note_doc_token`)— AI 生成的总结和待办信息 - 逐字稿(`verbatim_doc_token`)— 完整的会中发言记录(仅 `normal` 纪要可直接读取该文档) - 共享文档(`shared_doc_token`)— 会中投屏共享的文档 -- 妙记 Token(`minute_token`)— 如存在录制产物则返回 -详细用法请阅读 [`lark-vc-notes.md`](lark-vc-notes.md)。 +拿到文档 token 后,再通过 Doc 域 `docs +fetch` 拉取文档正文内容(见 Step 3)。详细用法请阅读 [`lark-note-detail.md`](../../lark-note/references/lark-note-detail.md)。 -如果返回了 `minute_token`,可通过以下命令获取妙记的详细信息(总结、待办、章节、文字记录): +**备选路径:通过 `minute_token` 获取妙记产物** + +如果 `note_id` 为空,或用户明确要求使用妙记产物,则使用 `minutes +detail` 获取妙记的具体产物: ```bash -lark-cli vc +notes --minute-tokens '<minute_token1>,<minute_token2>' +# 必须显式指定要获取的产物 flag,至少传一个;不传则不会返回任何产物内容 +lark-cli minutes +detail --minute-tokens '<minute_token1>,<minute_token2>' \ + --summary --todo --chapter --keyword --transcript ``` -可获取妙记的总结、待办、章节、文字记录等信息。详细用法请阅读 [`lark-vc-notes.md`](lark-vc-notes.md)。 +> **注意**:`minutes +detail` 需要**手动指定**要获取的产物 flag,可选 `--summary`(总结)、`--todo`(待办)、`--chapter`(章节)、`--keyword`(关键词)、`--transcript`(文字记录)。**未传任何产物 flag 时不会返回产物内容**,请按用户诉求按需指定。详细用法请阅读 [`lark-minutes-detail.md`](../../lark-minutes/references/lark-minutes-detail.md)。 #### Step 3: 按 `note_display_type` 拉取正文 / 逐字稿 -智能纪要(`note_doc_token`)是飞书文档,使用 `docs +fetch --api-version v2` 读取正文内容;**逐字稿的读取方式由 `note_display_type` 决定**: +智能纪要(`note_doc_token`)是飞书文档,使用 `docs +fetch` 读取正文内容;**逐字稿的读取方式由 `note_display_type` 决定**: ```bash # 纪要正文(两种展示类型都适用) -lark-cli docs +fetch --api-version v2 --doc <note_doc_token> --doc-format markdown +lark-cli docs +fetch --doc <note_doc_token> --doc-format markdown # note_display_type=normal:逐字稿是独立文档 -lark-cli docs +fetch --api-version v2 --doc <verbatim_doc_token> --doc-format markdown +lark-cli docs +fetch --doc <verbatim_doc_token> --doc-format markdown # note_display_type=unified:逐字稿不是独立文档,按 note_id 拉取 lark-cli note +transcript --note-id <note_id> @@ -138,16 +153,18 @@ lark-cli note +transcript --note-id <note_id> ## Note 域 -- VC 只负责从 `meeting_id` / `calendar_event_id` / `minute_token` 定位会议产物和 `note_id`。 +- VC 只负责从 `meeting_id` 定位会议产物和 `note_id` / `minute_token`([`vc +detail`](lark-vc-detail.md))。 - 已知 `note_id` 后切到 [lark-note](../../lark-note/SKILL.md);逐字稿路由以 `lark-note` 的 `note_display_type` 规则为准。 -- 只有自然语言纪要标题时,先走文档搜索与 `docs +fetch --api-version v2`;只有 `<vc-transcribe-tab vc-node-id="...">` 的 `vc-node-id` 可以进入 Note 域。 +- 已知 `minute_token` 时,[`minutes +detail`](../../lark-minutes/references/lark-minutes-detail.md) 顶层会一并返回该妙记关联的 `note_id`(如有);可直接传给 `note +detail` 取纪要文档 token,无需绕回 VC。 +- 仅有日程 `event_id` 时,先走 [`calendar +meeting`](../../lark-calendar/references/lark-calendar-meeting.md) 拿到 `meeting_id` 或用户绑定的 `meeting_note`,再按上述路径继续。 +- 只有自然语言纪要标题时,先走文档搜索与 `docs +fetch`;只有 `<vc-transcribe-tab vc-node-id="...">` 的 `vc-node-id` 可以进入 Note 域。 - `doc_token` / Docx URL 不是 `note_id`。没有 `vc-node-id` 时不要反推 Note,继续按 Doc 域读取正文或正文中明确给出的逐字稿文档。 ## Doc 域 - **lark-doc skill** 负责飞书云文档管理,包括获取文档元信息、读取文档内容、创建和编辑文档等操作。 -- **会议产物的文档本质**:智能纪要(`note_doc_token`)和 `normal` 纪要的逐字稿(`verbatim_doc_token`)都是飞书文档,需要通过 `lark-doc` 的 API(如 `docs +fetch --api-version v2`)查询其内容和元信息;`unified` 纪要的逐字稿不是独立文档,用 `note +transcript` 拉取([lark-note](../../lark-note/SKILL.md))。 -- **文档元信息查询**:获取文档名称、URL 等基本信息时,使用 `drive metas batch_query`;获取文档正文内容时,使用 `docs +fetch --api-version v2`。 +- **会议产物的文档本质**:智能纪要(`note_doc_token`)和 `normal` 纪要的逐字稿(`verbatim_doc_token`)都是飞书文档,需要通过 `lark-doc` 的 API(如 `docs +fetch`)查询其内容和元信息;`unified` 纪要的逐字稿不是独立文档,用 `note +transcript` 拉取([lark-note](../../lark-note/SKILL.md))。 +- **文档元信息查询**:获取文档名称、URL 等基本信息时,使用 `drive metas batch_query`;获取文档正文内容时,使用 `docs +fetch`。 ## 三域关联总览 diff --git a/skills/lark-whiteboard/references/lark-whiteboard-workflow.md b/skills/lark-whiteboard/references/lark-whiteboard-workflow.md index a3665e2d..c0ce702b 100644 --- a/skills/lark-whiteboard/references/lark-whiteboard-workflow.md +++ b/skills/lark-whiteboard/references/lark-whiteboard-workflow.md @@ -10,8 +10,8 @@ | 用户给了什么 | 怎么获取 | |---|---| | 直接给了 whiteboard token(`wbcnXXX`)| 直接使用 | -| 文档 URL 或 doc_id,文档中已有画板 | `lark-cli docs +fetch --api-version v2 --doc <URL> --as user`,从返回的 `<whiteboard token="xxx"/>` 提取 | -| 文档 URL 或 doc_id,需要新建画板 | `lark-cli docs +update --api-version v2 --doc <doc_id> --command append --content '<whiteboard type="blank"></whiteboard>' --as user`,从响应 `data.new_blocks[0].block_token` 取得(`block_type == "whiteboard"` 的那条;参数详见 lark-doc SKILL.md)| +| 文档 URL 或 doc_id,文档中已有画板 | `lark-cli docs +fetch --doc <URL> --as user`,从返回的 `<whiteboard token="xxx"/>` 提取 | +| 文档 URL 或 doc_id,需要新建画板 | `lark-cli docs +update --doc <doc_id> --command append --content '<whiteboard type="blank"></whiteboard>' --as user`,从响应 `data.new_blocks[0].block_token` 取得(`block_type == "whiteboard"` 的那条;参数详见 lark-doc SKILL.md)| **Step 2:渲染 & 写入** diff --git a/skills/lark-workflow-meeting-summary/SKILL.md b/skills/lark-workflow-meeting-summary/SKILL.md index d5c5a163..7f377ea2 100644 --- a/skills/lark-workflow-meeting-summary/SKILL.md +++ b/skills/lark-workflow-meeting-summary/SKILL.md @@ -37,7 +37,10 @@ lark-cli auth login --domain vc,drive # 含读取纪要文档正文、生成 {时间范围} ─► vc +search ──► 会议列表 (meeting_ids) │ ▼ - vc +notes ──► 纪要文档 tokens + vc +detail ──► 获取 note_id + │ + ▼ + note +detail ──► 纪要文档 tokens │ ▼ drive metas batch_query 纪要元数据 @@ -69,12 +72,16 @@ lark-cli vc +search --start "<YYYY-MM-DD>" --end "<YYYY-MM-DD>" --format json -- 1. 查询会议关联的纪要信息 ```bash -lark-cli vc +notes --meeting-ids "id1,id2,...,idN" +# 首先获取 note_id 和 minute_token +lark-cli vc +detail --meeting-ids "id1,id2,...,idN" + +# 然后用 note_id 获取文档 tokens(如有多个需分别获取) +lark-cli note +detail --note-id "note_id" ``` -- 根据上一步搜集到的 `meeting-id` 查询会议纪要。 -- 单次最多查询 50 个纪要信息,超过 50 个需分批调用。 -- 部分会议返回 `no notes available`,在最终输出中标注"无纪要" -- 记录每个会议的 `note_id`(纪要 ID)、`note_display_type`(展示类型:`unknown` / `normal` / `unified`)、`note_doc_token`(纪要文档 Token)和 `verbatim_doc_token`(逐字稿文档 Token) +- 根据上一步搜集到的 `meeting-id` 查询。 +- 单次最多查询 50 个,超过 50 个需分批调用。 +- 部分会议没有 `note_id` 或报错 `no notes available`,在最终输出中标注"无纪要"。 +- 记录每个纪要的 `note_id`(纪要 ID)、`note_display_type`(展示类型:`unknown` / `normal` / `unified`)、`note_doc_token`(纪要文档 Token)和 `verbatim_doc_token`(逐字稿文档 Token)。 > **逐字稿路由按 `note_display_type` 决定**(详见 [vc-domain-boundaries.md](../lark-vc/references/vc-domain-boundaries.md) 的 Note 域): > - `normal`:逐字稿是独立文档,链接/正文走 `verbatim_doc_token`。 @@ -102,14 +109,14 @@ lark-cli drive metas batch_query --data '{"request_docs": [{"doc_type": "docx", 阅读 [`../lark-doc/SKILL.md`](../lark-doc/SKILL.md) 学习云文档技能。 ```bash -lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>会议纪要汇总 (<start> - <end>)\n<内容>' +lark-cli docs +create --doc-format markdown --content $'会议纪要汇总 (<start> - <end>)\n<内容>' # 或追加到已有文档 -lark-cli docs +update --api-version v2 --doc "" --command append --doc-format markdown --content $'<内容>' +lark-cli docs +update --doc "" --command append --doc-format markdown --content $'<内容>' ``` ## 参考 - [lark-shared](../lark-shared/SKILL.md) — 认证、权限(必读) -- [lark-vc](../lark-vc/SKILL.md) — `+search`、`+notes` 详细用法 +- [lark-vc](../lark-vc/SKILL.md) — `+search`、`+detail` 详细用法 - [lark-note](../lark-note/SKILL.md) — `note +detail`、`note +transcript`(unified 纪要逐字稿) - [lark-doc](../lark-doc/SKILL.md) — `+fetch`、`+create`、`+update` 详细用法 \ No newline at end of file diff --git a/tests/cli_e2e/doc/docs_dryrun_test.go b/tests/cli_e2e/doc/docs_dryrun_test.go new file mode 100644 index 00000000..02fb0ac2 --- /dev/null +++ b/tests/cli_e2e/doc/docs_dryrun_test.go @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestDocsFetchDryRunIgnoresAPIVersionCompatFlag(t *testing.T) { + setDocsDryRunEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+fetch", + "--doc", "doxcnDryRunCompat", + "--api-version", "legacy", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if got := gjson.Get(out, "api.0.method").String(); got != "POST" { + t.Fatalf("method=%q, want POST\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/docs_ai/v1/documents/doxcnDryRunCompat/fetch" { + t.Fatalf("url=%q, want docs fetch endpoint\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.body.format").String(); got != "xml" { + t.Fatalf("format=%q, want xml\nstdout:\n%s", got, out) + } +} + +func setDocsDryRunEnv(t *testing.T) { + t.Helper() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "docs_dryrun_test") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "docs_dryrun_secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") +} diff --git a/tests/cli_e2e/docs/coverage.md b/tests/cli_e2e/docs/coverage.md index b19b7b85..a35946f9 100644 --- a/tests/cli_e2e/docs/coverage.md +++ b/tests/cli_e2e/docs/coverage.md @@ -10,6 +10,7 @@ - TestDocs_CreateAndFetchWorkflowAsUser: proves the same shortcut pair with UAT injection via `create as user` and `fetch as user`; creates its own Drive folder fixture first, then reads back the created doc by token. - TestDocs_UpdateWorkflow: proves `docs +update` via `update-title-and-content as bot`, then re-fetches the same doc in `verify as bot` to assert persisted title/content changes. - TestDocs_DryRunDefaultsToV2OpenAPI: proves `docs +create`, `docs +fetch`, and `docs +update` dry-run all emit `/open-apis/docs_ai/v1/...` requests without MCP or `--api-version` guidance. +- TestDocs_CreateTitleDryRunPrependsContent: proves `docs +create --title` dry-run prepends an escaped `...` tag to request body `content`. - Setup note: docs workflows create a Drive folder through `drive files create_folder` in `helpers_test.go`; that helper is external to the docs domain and is not counted here. - Blocked area: media and search shortcuts still need deterministic fixtures and local file orchestration. @@ -17,7 +18,7 @@ | Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason | | --- | --- | --- | --- | --- | --- | -| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create | `--parent-token`; `--doc-format markdown`; `--content` | helper asserts returned doc id from `data.document.document_id` | +| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create; docs_update_dryrun_test.go::TestDocs_CreateTitleDryRunPrependsContent | `--parent-token`; `--doc-format markdown`; `--content`; `--title` | helper asserts returned doc id from `data.document.document_id`; dry-run asserts title is prepended into request body content | | ✓ | docs +fetch | shortcut | docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc `; `--doc-format markdown` | | | ✕ | docs +media-download | shortcut | | none | no media fixture workflow yet | | ✕ | docs +media-insert | shortcut | | none | requires deterministic upload fixture and rollback assertions | diff --git a/tests/cli_e2e/docs/docs_update_dryrun_test.go b/tests/cli_e2e/docs/docs_update_dryrun_test.go index 65c9b06a..c0bd0586 100644 --- a/tests/cli_e2e/docs/docs_update_dryrun_test.go +++ b/tests/cli_e2e/docs/docs_update_dryrun_test.go @@ -11,6 +11,7 @@ import ( clie2e "github.com/larksuite/cli/tests/cli_e2e" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) { @@ -106,3 +107,31 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) { }) } } + +func TestDocs_CreateTitleDryRunPrependsContent(t *testing.T) { + // Fake creds are enough — dry-run short-circuits before any real API call. + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+create", + "--title", "Dry Run & Title", + "--doc-format", "markdown", + "--content", "## Body", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + require.Equal(t, "/open-apis/docs_ai/v1/documents", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out) + require.Equal(t, "markdown", gjson.Get(out, "api.0.body.format").String(), "stdout:\n%s", out) + require.Equal(t, "Dry Run & Title\n## Body", gjson.Get(out, "api.0.body.content").String(), "stdout:\n%s", out) +} diff --git a/tests/cli_e2e/drive/drive_member_add_dryrun_test.go b/tests/cli_e2e/drive/drive_member_add_dryrun_test.go new file mode 100644 index 00000000..3d31d18b --- /dev/null +++ b/tests/cli_e2e/drive/drive_member_add_dryrun_test.go @@ -0,0 +1,499 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestDrive_MemberAddDryRun locks in the request shape the shortcut emits +// under --dry-run: the real CLI binary is invoked end-to-end (so flag parsing, +// validation, and dry-run rendering all execute), and the printed request is +// inspected to confirm +// - HTTP method, URL template, and token path segment, +// - type query parameter (auto-inferred from a URL input, explicit for a +// bare token input), +// - explicit --type overriding URL inference, +// - member_type / member kind / perm / perm_type body fields, +// - single-member vs batch endpoint selection. +// +// Fake credentials are sufficient because --dry-run short-circuits before any +// network call. +func TestDrive_MemberAddDryRun(t *testing.T) { + // Isolate from any local CLI state: the subprocess inherits the parent + // test environment, and without an explicit config dir it could read a + // developer's real credentials/profile instead of the fake ones below. + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + tests := []struct { + name string + args []string + wantURL string + wantResourceType string + wantNeedNotification string + wantMemberID string + wantMemberType string + wantPerm string + wantMemberKind string + wantPermType string + wantBatch bool + }{ + { + name: "URL input auto-infers docx type", + args: []string{ + "drive", "+member-add", + "--token", "https://example.feishu.cn/docx/doxcnE2E001?from=share", + "--member-id", "ou_e2e_user", + "--member-type", "openid", + "--perm", "view", + "--need-notification=false", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/doxcnE2E001/members", + wantResourceType: "docx", + wantNeedNotification: "false", + wantMemberID: "ou_e2e_user", + wantMemberType: "openid", + wantPerm: "view", + wantMemberKind: "user", + }, + { + name: "URL input auto-infers mindnote type from mindnotes path", + args: []string{ + "drive", "+member-add", + "--token", "https://example.feishu.cn/mindnotes/mndE2E011?from=share", + "--member-id", "ou_e2e_user", + "--member-type", "openid", + "--perm", "view", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/mndE2E011/members", + wantResourceType: "mindnote", + wantMemberID: "ou_e2e_user", + wantMemberType: "openid", + wantPerm: "view", + wantMemberKind: "user", + }, + { + name: "bare token with explicit wiki type defaults perm_type container", + args: []string{ + "drive", "+member-add", + "--token", "wikcnE2E002", + "--type", "wiki", + "--member-id", "ou_e2e_admin", + "--member-type", "openid", + "--perm", "full_access", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/wikcnE2E002/members", + wantResourceType: "wiki", + wantMemberID: "ou_e2e_admin", + wantMemberType: "openid", + wantPerm: "full_access", + wantMemberKind: "user", + wantPermType: "container", + }, + { + // Explicit --type must override URL inference: the /docx/ marker + // would infer type=docx, but the caller asked to grant permission + // against a wiki node. The URL token itself is still used as the + // path token. + name: "explicit --type overrides URL inference", + args: []string{ + "drive", "+member-add", + "--token", "https://example.feishu.cn/docx/doxcnE2E009", + "--type", "wiki", + "--member-id", "ou_e2e_override", + "--member-type", "openid", + "--perm", "view", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/doxcnE2E009/members", + wantResourceType: "wiki", + wantMemberID: "ou_e2e_override", + wantMemberType: "openid", + wantPerm: "view", + wantMemberKind: "user", + wantPermType: "container", + }, + { + name: "bare token with explicit folder type", + args: []string{ + "drive", "+member-add", + "--token", "fldE2E010", + "--type", "folder", + "--member-id", "ou_e2e_folder", + "--member-type", "openid", + "--perm", "view", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/fldE2E010/members", + wantResourceType: "folder", + wantMemberID: "ou_e2e_folder", + wantMemberType: "openid", + wantPerm: "view", + wantMemberKind: "user", + }, + { + name: "email member-type", + args: []string{ + "drive", "+member-add", + "--token", "shtcnE2E003", + "--type", "sheet", + "--member-id", "user@example.com", + "--member-type", "email", + "--perm", "edit", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/shtcnE2E003/members", + wantResourceType: "sheet", + wantMemberID: "user@example.com", + wantMemberType: "email", + wantPerm: "edit", + wantMemberKind: "user", + }, + { + name: "unionid member-type", + args: []string{ + "drive", "+member-add", + "--token", "doxcnE2E006", + "--type", "docx", + "--member-id", "on_e2e_union", + "--member-type", "unionid", + "--perm", "view", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/doxcnE2E006/members", + wantResourceType: "docx", + wantMemberID: "on_e2e_union", + wantMemberType: "unionid", + wantPerm: "view", + wantMemberKind: "user", + }, + { + name: "explicit-only groupid member-type", + args: []string{ + "drive", "+member-add", + "--token", "doxcnE2E007", + "--type", "docx", + "--member-id", "group_e2e", + "--member-type", "groupid", + "--perm", "view", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/doxcnE2E007/members", + wantResourceType: "docx", + wantMemberID: "group_e2e", + wantMemberType: "groupid", + wantPerm: "view", + wantMemberKind: "group", + }, + { + name: "batch members use batch_create endpoint", + args: []string{ + "drive", "+member-add", + "--token", "bascnE2E004", + "--type", "bitable", + "--member-id", "ou_a,ou_b", + "--member-type", "openid", + "--perm", "view", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/bascnE2E004/members/batch_create", + wantResourceType: "bitable", + wantMemberID: "ou_a", + wantMemberType: "openid", + wantPerm: "view", + wantMemberKind: "user", + wantBatch: true, + }, + { + name: "explicit groupid member-type", + args: []string{ + "drive", "+member-add", + "--token", "doxcnE2E005", + "--type", "docx", + "--member-id", "grp_abc", + "--member-type", "groupid", + "--perm", "edit", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/doxcnE2E005/members", + wantResourceType: "docx", + wantMemberID: "grp_abc", + wantMemberType: "groupid", + wantPerm: "edit", + wantMemberKind: "group", + }, + { + name: "appid member-type is passed through", + args: []string{ + "drive", "+member-add", + "--token", "doxcnE2E008", + "--type", "docx", + "--member-id", "cli_app_123", + "--member-type", "appid", + "--perm", "view", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/doxcnE2E008/members", + wantResourceType: "docx", + wantMemberID: "cli_app_123", + wantMemberType: "appid", + wantPerm: "view", + }, + { + name: "wikispaceid member-type requires wiki-space body type", + args: []string{ + "drive", "+member-add", + "--token", "doxcnE2E012", + "--type", "docx", + "--member-id", "spc_e2e_wiki", + "--member-type", "wikispaceid", + "--member-kind", "wiki_space_editor", + "--perm", "view", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/doxcnE2E012/members", + wantResourceType: "docx", + wantMemberID: "spc_e2e_wiki", + wantMemberType: "wikispaceid", + wantPerm: "view", + wantMemberKind: "wiki_space_editor", + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: tt.args, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if got := gjson.Get(out, "api.0.method").String(); got != "POST" { + t.Fatalf("method = %q, want POST\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.url").String(); got != tt.wantURL { + t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out) + } + if got := gjson.Get(out, "api.0.params.type").String(); got != tt.wantResourceType { + t.Fatalf("params.type = %q, want %q\nstdout:\n%s", got, tt.wantResourceType, out) + } + notification := gjson.Get(out, "api.0.params.need_notification") + if tt.wantNeedNotification == "" { + if notification.Exists() { + t.Fatalf("need_notification should be omitted\nstdout:\n%s", out) + } + } else if got := notification.String(); got != tt.wantNeedNotification { + t.Fatalf("need_notification = %q, want %q\nstdout:\n%s", got, tt.wantNeedNotification, out) + } + bodyPath := "api.0.body" + if tt.wantBatch { + bodyPath = "api.0.body.members.0" + if count := len(gjson.Get(out, "api.0.body.members").Array()); count != 2 { + t.Fatalf("body.members count = %d, want 2\nstdout:\n%s", count, out) + } + } + if got := gjson.Get(out, bodyPath+".member_id").String(); got != tt.wantMemberID { + t.Fatalf("body.member_id = %q, want %q\nstdout:\n%s", got, tt.wantMemberID, out) + } + if got := gjson.Get(out, bodyPath+".member_type").String(); got != tt.wantMemberType { + t.Fatalf("body.member_type = %q, want %q\nstdout:\n%s", got, tt.wantMemberType, out) + } + if got := gjson.Get(out, bodyPath+".perm").String(); got != tt.wantPerm { + t.Fatalf("body.perm = %q, want %q\nstdout:\n%s", got, tt.wantPerm, out) + } + if got := gjson.Get(out, bodyPath+".type").String(); got != tt.wantMemberKind { + t.Fatalf("body.type = %q, want %q\nstdout:\n%s", got, tt.wantMemberKind, out) + } + permType := gjson.Get(out, bodyPath+".perm_type") + if tt.wantPermType == "" { + if permType.Exists() { + t.Fatalf("perm_type should be omitted\nstdout:\n%s", out) + } + } else if got := permType.String(); got != tt.wantPermType { + t.Fatalf("body.perm_type = %q, want %q\nstdout:\n%s", got, tt.wantPermType, out) + } + }) + } +} + +func TestDrive_MemberAddDryRunRejectsInvalidInputs(t *testing.T) { + // Isolate from any local CLI state: the subprocess inherits the parent + // test environment, and without an explicit config dir it could read a + // developer's real credentials/profile instead of the fake ones below. + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + tests := []struct { + name string + args []string + defaultAs string + wantErr string + }{ + { + name: "any host accepted", + args: []string{ + "drive", "+member-add", + "--token", "https://google.com/docx/doxcnE2E001", + "--member-id", "ou_e2e_user", + "--member-type", "openid", + "--dry-run", + }, + }, + { + name: "unsupported URL path", + args: []string{ + "drive", "+member-add", + "--token", "https://example.feishu.cn/calendar/calE2E001", + "--member-id", "ou_e2e_user", + "--member-type", "openid", + "--dry-run", + }, + wantErr: "unsupported URL path", + }, + { + name: "bare token requires explicit type", + args: []string{ + "drive", "+member-add", + "--token", "unknownE2E001", + "--member-id", "ou_e2e_user", + "--member-type", "openid", + "--dry-run", + }, + wantErr: "--type is required when --token is a bare token", + }, + { + name: "duplicate member IDs are rejected", + args: []string{ + "drive", "+member-add", + "--token", "doxcnE2E001", + "--type", "docx", + "--member-id", "ou_a,ou_b,ou_a", + "--member-type", "openid", + "--dry-run", + }, + wantErr: "duplicate collaborator ID", + }, + { + name: "invalid explicit type is rejected", + args: []string{ + "drive", "+member-add", + "--token", "mincnE2E001", + "--type", "invalidtype", + "--member-id", "ou_e2e_user", + "--member-type", "openid", + "--dry-run", + }, + wantErr: "--type must be one of: docx, doc, sheet, bitable, file, folder, wiki, mindnote, slides, minutes", + }, + { + name: "member-id prefix conflicts with explicit member-type", + args: []string{ + "drive", "+member-add", + "--token", "doxcnE2E001", + "--type", "docx", + "--member-id", "ou_e2e_user,oc_e2e_chat", + "--member-type", "openid", + "--dry-run", + }, + wantErr: "implies --member-type openchat", + }, + { + name: "explicit member-type conflicts with member-id prefix", + args: []string{ + "drive", "+member-add", + "--token", "doxcnE2E001", + "--type", "docx", + "--member-id", "oc_e2e_chat", + "--member-type", "openid", + "--dry-run", + }, + wantErr: "implies --member-type openchat", + }, + { + name: "department collaborator requires user identity", + args: []string{ + "drive", "+member-add", + "--token", "doxcnE2E001", + "--type", "docx", + "--member-id", "od_e2e_dept", + "--member-type", "opendepartmentid", + "--dry-run", + }, + defaultAs: "bot", + wantErr: "--member-type=opendepartmentid requires --as user", + }, + { + name: "wikispaceid requires member-kind", + args: []string{ + "drive", "+member-add", + "--token", "doxcnE2E001", + "--type", "docx", + "--member-id", "spc_e2e_wiki", + "--member-type", "wikispaceid", + "--dry-run", + }, + wantErr: "--member-kind is required when --member-type=wikispaceid", + }, + { + name: "member-kind only applies to wikispaceid", + args: []string{ + "drive", "+member-add", + "--token", "doxcnE2E001", + "--type", "docx", + "--member-id", "ou_e2e_user", + "--member-type", "openid", + "--member-kind", "wiki_space_member", + "--dry-run", + }, + wantErr: "--member-kind only applies when --member-type=wikispaceid", + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + defaultAs := tt.defaultAs + if defaultAs == "" { + defaultAs = "user" + } + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: tt.args, + DefaultAs: defaultAs, + }) + require.NoError(t, err) + + if tt.wantErr == "" { + result.AssertExitCode(t, 0) + return + } + + result.AssertExitCode(t, 2) + output := result.Stdout + result.Stderr + require.Contains(t, output, tt.wantErr, "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + }) + } +} diff --git a/tests/cli_e2e/minutes/minutes_speaker_replace_test.go b/tests/cli_e2e/minutes/minutes_speaker_replace_test.go index 70cf3cce..95944680 100644 --- a/tests/cli_e2e/minutes/minutes_speaker_replace_test.go +++ b/tests/cli_e2e/minutes/minutes_speaker_replace_test.go @@ -38,3 +38,54 @@ func TestMinutesSpeakerReplace_DryRun(t *testing.T) { assert.True(t, strings.Contains(output, "ou_old_speaker"), "dry-run should contain from_user_id, got: %s", output) assert.True(t, strings.Contains(output, "ou_new_speaker"), "dry-run should contain to_user_id, got: %s", output) } + +func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "minutes", "+speaker-replace", + "--minute-token", "obcnexampleminute", + "--from-speaker-id", "ENCRYPTED_TOKEN_ABC", + "--to-user-id", "ou_new_speaker", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "PUT"), "dry-run should contain PUT method, got: %s", output) + assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute/transcript/speaker"), "dry-run should contain API path, got: %s", output) + assert.True(t, strings.Contains(output, "from_speaker_id"), "dry-run should contain from_speaker_id, got: %s", output) + assert.True(t, strings.Contains(output, "ENCRYPTED_TOKEN_ABC"), "dry-run should contain the encrypted speaker id, got: %s", output) + assert.False(t, strings.Contains(output, "from_user_id"), "dry-run should not contain from_user_id when from-speaker-id is set, got: %s", output) +} + +func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "minutes", "+speaker-replace", + "--minute-token", "obcnexampleminute", + "--from-speaker-id", "说话人1", + "--to-user-id", "ou_new_speaker", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "GET"), "dry-run should contain GET for internal speaker list, got: %s", output) + assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute/transcript/speakerlist"), "dry-run should contain speakerlist API path, got: %s", output) + assert.True(t, strings.Contains(output, "PUT"), "dry-run should contain PUT method, got: %s", output) + assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute/transcript/speaker"), "dry-run should contain speaker replace path, got: %s", output) +} diff --git a/tests/cli_e2e/sheets/sheets_image_upload_dryrun_test.go b/tests/cli_e2e/sheets/sheets_image_upload_dryrun_test.go new file mode 100644 index 00000000..6bd3deb9 --- /dev/null +++ b/tests/cli_e2e/sheets/sheets_image_upload_dryrun_test.go @@ -0,0 +1,112 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestSheets_ImageUploadDryRunParentType pins the parent_type the sheets +// image-upload shortcuts emit in --dry-run output for native vs. imported +// "office" spreadsheets. For native tokens parent_type must be "sheet_image"; +// for tokens prefixed with "fake_office_" (the synthetic token an imported +// office spreadsheet carries) the backend requires "office_sheet_file". The +// three covered entries — sheets +media-upload (backward), sheets +// +cells-set-image, and sheets +create-float-image — are every image-upload +// surface that the office/native split fans out to. +func TestSheets_ImageUploadDryRunParentType(t *testing.T) { + setSheetsDryRunEnv(t) + + workDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(workDir, "img.png"), []byte("png-bytes"), 0o600)) + + type tc struct { + name string + args []string + token string + wantParentType string + } + tests := []tc{ + { + name: "media-upload native", + args: []string{ + "sheets", "+media-upload", + "--spreadsheet-token", "shtDryRunNative", + "--file", "img.png", + "--dry-run", + }, + token: "shtDryRunNative", + wantParentType: "sheet_image", + }, + { + name: "media-upload office", + args: []string{ + "sheets", "+media-upload", + "--spreadsheet-token", "fake_office_dryrun", + "--file", "img.png", + "--dry-run", + }, + token: "fake_office_dryrun", + wantParentType: "office_sheet_file", + }, + { + name: "cells-set-image native", + args: []string{ + "sheets", "+cells-set-image", + "--spreadsheet-token", "shtDryRunNative", + "--sheet-id", "sheet1", + "--range", "A1", + "--image", "img.png", + "--dry-run", + }, + token: "shtDryRunNative", + wantParentType: "sheet_image", + }, + { + name: "cells-set-image office", + args: []string{ + "sheets", "+cells-set-image", + "--spreadsheet-token", "fake_office_dryrun", + "--sheet-id", "sheet1", + "--range", "A1", + "--image", "img.png", + "--dry-run", + }, + token: "fake_office_dryrun", + wantParentType: "office_sheet_file", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: tt.args, + DefaultAs: "user", + WorkDir: workDir, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "api.0 must be the drive upload; stdout:\n%s", out) + require.Equal(t, "/open-apis/drive/v1/medias/upload_all", + gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out) + require.Equal(t, tt.wantParentType, gjson.Get(out, "api.0.body.parent_type").String(), + "parent_type for token %q must be %q; stdout:\n%s", tt.token, tt.wantParentType, out) + require.Equal(t, tt.token, gjson.Get(out, "api.0.body.parent_node").String(), + "parent_node must equal the spreadsheet token; stdout:\n%s", out) + }) + } +} diff --git a/tests/cli_e2e/sheets/sheets_table_put_typed_workflow_test.go b/tests/cli_e2e/sheets/sheets_table_put_typed_workflow_test.go index 9bf23583..9ea6c42b 100644 --- a/tests/cli_e2e/sheets/sheets_table_put_typed_workflow_test.go +++ b/tests/cli_e2e/sheets/sheets_table_put_typed_workflow_test.go @@ -5,6 +5,7 @@ package sheets import ( "context" + "strings" "testing" "time" @@ -93,19 +94,28 @@ func TestSheets_WorkbookCreateTypedWorkflow(t *testing.T) { t.Cleanup(cancel) suffix := clie2e.GenerateSuffix() - folderToken := drive.CreateDriveFolder(t, parentT, ctx, "lark-cli-e2e-wb-create-typed-"+suffix+"-folder", "bot", "") + title := "lark-cli-e2e-wb-create-typed-" + suffix // One-shot: create workbook + write typed payload (date + int + string). + // --folder-token is optional; omit it so the test does not depend on drive:drive + // (CreateDriveFolder) when validating the typed --sheets path. createRes, err := clie2e.RunCmd(ctx, clie2e.Request{ Args: []string{ "sheets", "+workbook-create", - "--title", "lark-cli-e2e-wb-create-typed-" + suffix, - "--folder-token", folderToken, + "--title", title, "--sheets", `{"sheets":[{"name":"销售","columns":["日期","金额","渠道"],"dtypes":{"日期":"datetime64[ns]","金额":"float64","渠道":"object"},"formats":{"金额":"$#,##0.00","日期":"yyyy-mm-dd"},"data":[["2024-01-15",1500.5,"门店"],["2024-02-02",2300.75,"线上"]]}]}`, }, DefaultAs: "bot", }) require.NoError(t, err) + if createRes.ExitCode != 0 { + combined := strings.ToLower(createRes.Stdout + "\n" + createRes.Stderr) + if strings.Contains(combined, "app_scope_not_applied") || + strings.Contains(combined, "missing_scopes") || + strings.Contains(combined, "99991672") { + t.Skipf("skip workbook-create typed workflow due to missing bot scope: %s", strings.TrimSpace(createRes.Stdout+"\n"+createRes.Stderr)) + } + } createRes.AssertExitCode(t, 0) createRes.AssertStdoutStatus(t, true)