mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 23:15:25 +08:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdd39b0196 | ||
|
|
1ffe870dc8 | ||
|
|
5da3075646 | ||
|
|
8fc7e12f9e | ||
|
|
27139a0919 | ||
|
|
c35b1ae2c5 | ||
|
|
c8341bbd7c | ||
|
|
634adfc745 | ||
|
|
62d8681b0b | ||
|
|
a2656e1385 | ||
|
|
8bd5049ebe | ||
|
|
69bcdd9e35 | ||
|
|
9b933f1a20 | ||
|
|
8e24166d90 | ||
|
|
ecf3209c52 | ||
|
|
a13bee8fda | ||
|
|
e5a83f5eaa | ||
|
|
d2ad5e4def | ||
|
|
511c24bd95 | ||
|
|
62ad335b26 | ||
|
|
d4d4f32ec6 | ||
|
|
aac94ceb5c | ||
|
|
2345b98d20 | ||
|
|
ccbf4a0bd6 |
8
.codecov.yml
Normal file
8
.codecov.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
informational: true
|
||||
patch:
|
||||
default:
|
||||
target: 60%
|
||||
40
.github/workflows/coverage.yml
vendored
40
.github/workflows/coverage.yml
vendored
@@ -2,22 +2,32 @@ name: Coverage
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/coverage.yml
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/coverage.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
codecov:
|
||||
runs-on: ubuntu-22.04
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
@@ -27,10 +37,18 @@ jobs:
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: go test -coverprofile=coverage.txt -covermode=atomic ./...
|
||||
run: go test -race -coverprofile=coverage.txt -covermode=atomic ./...
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
|
||||
with:
|
||||
files: coverage.txt
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Generate coverage report
|
||||
run: |
|
||||
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
|
||||
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "</details>" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
78
.github/workflows/lint.yml
vendored
78
.github/workflows/lint.yml
vendored
@@ -2,43 +2,36 @@ name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .golangci.yml
|
||||
- .github/workflows/lint.yml
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .golangci.yml
|
||||
- .github/workflows/lint.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
staticcheck:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Fetch meta_data.json
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Run staticcheck
|
||||
uses: dominikh/staticcheck-action@9716614d4101e79b4340dd97b10e54d68234e431 # v1
|
||||
with:
|
||||
install-go: false
|
||||
|
||||
golangci-lint:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
@@ -47,26 +40,21 @@ jobs:
|
||||
- name: Fetch meta_data.json
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Ensure go.mod and go.sum are tidy
|
||||
run: |
|
||||
go mod tidy
|
||||
if ! git diff --quiet go.mod go.sum; then
|
||||
echo "::error::go.mod or go.sum is not tidy. Run 'go mod tidy' and commit the changes."
|
||||
git diff go.mod go.sum
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@55c2c1448f86e01eaae002a5a3a9624417608d84 # v6
|
||||
with:
|
||||
version: latest
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
|
||||
|
||||
vet:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- name: Run govulncheck
|
||||
continue-on-error: true # informational until Go version is upgraded
|
||||
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Fetch meta_data.json
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Run go vet
|
||||
run: go vet ./...
|
||||
- name: Check dependency licenses
|
||||
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown
|
||||
|
||||
81
.github/workflows/pkg-pr-new.yml
vendored
Normal file
81
.github/workflows/pkg-pr-new.yml
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
name: PR Preview Package
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Build preview package
|
||||
run: ./scripts/build-pkg-pr-new.sh
|
||||
|
||||
- name: Publish to pkg.pr.new
|
||||
run: npx pkg-pr-new publish --no-compact --json output.json --comment=off ./.pkg-pr-new
|
||||
|
||||
- name: Comment install command
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs");
|
||||
const output = JSON.parse(fs.readFileSync("output.json", "utf8"));
|
||||
const url = output?.packages?.[0]?.url;
|
||||
if (!url) {
|
||||
throw new Error("No package URL found in output.json");
|
||||
}
|
||||
|
||||
const body = [
|
||||
"Install this PR change globally:",
|
||||
"",
|
||||
"```bash",
|
||||
`npm i -g ${url}`,
|
||||
"```",
|
||||
].join("\n");
|
||||
const issueNumber = context.issue.number;
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const existing = comments.find((comment) =>
|
||||
comment.user?.login === "github-actions[bot]" &&
|
||||
typeof comment.body === "string" &&
|
||||
comment.body.startsWith("Install this PR change globally:")
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body,
|
||||
});
|
||||
}
|
||||
16
.github/workflows/release.yml
vendored
16
.github/workflows/release.yml
vendored
@@ -33,3 +33,19 @@ jobs:
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
publish-npm:
|
||||
needs: goreleaser
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Publish to npm
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --access public
|
||||
|
||||
23
.github/workflows/tests.yml
vendored
23
.github/workflows/tests.yml
vendored
@@ -2,22 +2,32 @@ name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/tests.yml
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- .github/workflows/tests.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
unit-test:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
@@ -27,4 +37,7 @@ jobs:
|
||||
run: python3 scripts/fetch_meta.py
|
||||
|
||||
- name: Run tests
|
||||
run: go test -v -race -count=1 -timeout=30s ./cmd/... ./internal/... ./shortcuts/...
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/...
|
||||
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
|
||||
66
.golangci.yml
Normal file
66
.golangci.yml
Normal file
@@ -0,0 +1,66 @@
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- asasalint # checks for pass []any as any in variadic func(...any)
|
||||
- asciicheck # checks that code does not contain non-ASCII identifiers
|
||||
- bidichk # checks for dangerous unicode character sequences
|
||||
- bodyclose # checks whether HTTP response body is closed successfully
|
||||
- copyloopvar # detects places where loop variables are copied
|
||||
- durationcheck # checks for two durations multiplied together
|
||||
- exptostd # detects functions from golang.org/x/exp/ replaceable by std
|
||||
- fatcontext # detects nested contexts in loops
|
||||
- gocheckcompilerdirectives # validates go compiler directive comments (//go:)
|
||||
- gochecksumtype # checks exhaustiveness on Go "sum types"
|
||||
- gocritic # diagnostics for bugs, performance and style
|
||||
- gomoddirectives # checks for replace, retract, and exclude in go.mod
|
||||
- goprintffuncname # checks that printf-like functions end with f
|
||||
- govet # reports suspicious constructs
|
||||
- ineffassign # detects ineffective assignments
|
||||
- nilerr # finds code that returns nil even if error is not nil
|
||||
- nolintlint # reports ill-formed nolint directives
|
||||
- nosprintfhostport # checks for misuse of Sprintf to construct host:port
|
||||
- reassign # checks that package variables are not reassigned
|
||||
- unconvert # removes unnecessary type conversions
|
||||
- unused # checks for unused constants, variables, functions and types
|
||||
|
||||
# To enable later after fixing existing issues:
|
||||
# - errcheck # checks for unchecked errors
|
||||
# - errname # checks that error types are named XxxError
|
||||
# - errorlint # checks error wrapping best practices
|
||||
# - gosec # security-oriented linter
|
||||
# - misspell # finds commonly misspelled English words
|
||||
# - staticcheck # comprehensive static analysis
|
||||
|
||||
exclusions:
|
||||
paths:
|
||||
- generated
|
||||
rules:
|
||||
- path: _test\.go$
|
||||
linters:
|
||||
- bodyclose
|
||||
- gocritic
|
||||
|
||||
settings:
|
||||
gocritic:
|
||||
disabled-checks:
|
||||
- appendAssign
|
||||
- hugeParam
|
||||
disabled-tags:
|
||||
- style
|
||||
govet:
|
||||
enable:
|
||||
- httpresponse
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
- goimports
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
Binary file not shown.
36
CHANGELOG.md
36
CHANGELOG.md
@@ -2,6 +2,39 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.1] - 2026-03-31
|
||||
|
||||
### Features
|
||||
|
||||
- Add automatic CLI update detection and notification (#144)
|
||||
- Add npm publish job to release workflow (#145)
|
||||
- Support auto extension for downloads (#16)
|
||||
- Remove useless files (#131)
|
||||
- Normalize markdown message send/reply output (#28)
|
||||
- Add auto-pagination to messages search and update lark-im docs (#30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: Use base history read scope for record history list (#96)
|
||||
- Remove sensitive send scope from reply and forward shortcuts (#92)
|
||||
- Resolve silent failure in `lark-cli api` error output (#85)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Clarify field description usage in json (#90)
|
||||
- Update Base description to include all capabilities (#61)
|
||||
- Add official badge to distinguish from third-party Lark CLI tools (#103)
|
||||
- Rename user-facing Bitable references to Base (#11)
|
||||
- Add star history chart to readmes (#12)
|
||||
- Simplify installation steps by merging CLI and Skills into one section (#26)
|
||||
- Add npm version badge and improve AI agent tip wording (#23)
|
||||
- Emphasize Skills installation as required for AI Agents (#19)
|
||||
- Clarify install methods as alternatives and add source build steps
|
||||
|
||||
### CI
|
||||
|
||||
- Improve CI workflows and add golangci-lint config (#71)
|
||||
|
||||
## [v1.0.0] - 2026-03-28
|
||||
|
||||
### Initial Release
|
||||
@@ -27,7 +60,7 @@ Built-in shortcuts for commonly used Lark APIs, enabling concise commands like `
|
||||
- **Drive** — Upload, download, and manage cloud documents.
|
||||
- **Docs** — Work with Lark documents.
|
||||
- **Sheets** — Interact with spreadsheets.
|
||||
- **Base (Bitable)** — Manage multi-dimensional tables.
|
||||
- **Base** — Manage multi-dimensional tables.
|
||||
- **Calendar** — Create and manage calendar events.
|
||||
- **Mail** — Send and manage emails.
|
||||
- **Contact** — Look up users and departments.
|
||||
@@ -54,4 +87,5 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.1]: https://github.com/larksuite/cli/releases/tag/v1.0.1
|
||||
[v1.0.0]: https://github.com/larksuite/cli/releases/tag/v1.0.0
|
||||
|
||||
28
CLA.md
28
CLA.md
@@ -1,28 +0,0 @@
|
||||
> Thank you for your interest in open source projects hosted or managed by ByteDance Ltd. and/or its Affiliates ("**ByteDance**") . In order to clarify the intellectual property license granted with Contributions from any person or entity, ByteDance must have a Contributor License Agreement ("**CLA**") on file that has been signed by each Contributor, indicating agreement to the license terms below. This license is for your protection as a Contributor as well as the protection of ByteDance and its users; it does not change your rights to use your own Contributions for any other purpose.
|
||||
> If you are an individual making a submission on your own behalf, you should accept the Individual Contributor License Agreement. If you are making a submission on behalf of a legal entity (the “**Corporation**”), you should sign the separation Corporate Contributor License Agreement.
|
||||
|
||||
**ByteDance Individual Contributor License Agreement v1.** **1**
|
||||
By clicking “Accept” on this page, You accept and agree to the following terms and conditions for Your present and future Contributions submitted to ByteDance. Except for the license granted herein to ByteDance and recipients of software distributed by ByteDance, You reserve all right, title, and interest in and to Your Contributions.
|
||||
1.Definitions.
|
||||
"Affiliate" shall mean an entity that Controls, is Controlled by, or is under common Control with You or ByteDance, respectively (but only as long as such Control exists).
|
||||
"Control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
"Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to ByteDance for inclusion in, or documentation of, any of the products owned or managed by ByteDance (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to ByteDance or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, ByteDance for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution."
|
||||
"You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with ByteDance. For the avoidance of doubt, the Corporation making a Contribution and all of its Affiliates are considered to be a single Contributor and this CLA shall apply to Contributions Submitted by the Corporation or any of its Affiliates.
|
||||
2.Grant of Copyright License. Subject to the terms and conditions of this Agreement, You hereby grant to ByteDance and to recipients of software distributed by ByteDance a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works.
|
||||
3.Grant of Patent License. Subject to the terms and conditions of this Agreement, You hereby grant to ByteDance and to recipients of software distributed by ByteDance a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed.
|
||||
4.You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived such rights for your Contributions to ByteDance, or that your employer has executed a separate Corporate CLA with ByteDance.
|
||||
5.You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions.
|
||||
6.You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON- INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
7.Should You wish to submit work that is not Your original creation, You may submit it to ByteDance separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]".
|
||||
8.You agree to notify ByteDance of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect.
|
||||
9.You agree that contributions to Projects and information about contributions may be maintained indefinitely and disclosed publicly, including Your name and other information that You submit with your submission.
|
||||
10.This Agreement is the entire agreement and understanding between the parties, and supersedes any and all prior agreements, understandings or communications, written or oral, between the parties relating to the subject matter hereof. This Agreement may be assigned by ByteDance.
|
||||
|
||||
[ByteDance Corporate Contributor License Agreement v1.1](./ByteDance_Corporate_Contributor_License_Agreement_v1.1.pdf)
|
||||
|
||||
This version of the Contributor License Agreement allows a legal entity (the “Corporation”) to submit Contributions to the applicable project.
|
||||
ByteDance Corporate Contributor License Agreement v1.1.pdf
|
||||
A person authorized to sign legal documents on behalf of your employer (usually a VP or higher) must sign the Contributor Agreement on behalf of the employer.
|
||||
If you have not already signed this agreement, please complete and sign, then scan and email a pdf file of this Agreement to opensource-cla@bytedance.com. Please read this document carefully before signing and keep a copy for your records.
|
||||
|
||||
If you need to update your CLA, please email <opensource-cla@bytedance.com> from the email address associated with your individual or corporate information.
|
||||
67
README.md
67
README.md
@@ -2,10 +2,11 @@
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://go.dev/)
|
||||
[](https://www.npmjs.com/package/@larksuite/cli)
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
A command-line tool for [Lark/Feishu](https://www.larksuite.com/) Open Platform — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 19 AI Agent [Skills](./skills/).
|
||||
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 19 AI Agent [Skills](./skills/).
|
||||
|
||||
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
|
||||
|
||||
@@ -27,7 +28,7 @@ A command-line tool for [Lark/Feishu](https://www.larksuite.com/) Open Platform
|
||||
| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media |
|
||||
| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards |
|
||||
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
|
||||
| 📊 Base | Create and manage tables, fields, records, views, dashboards, data aggregation & analytics |
|
||||
| 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics |
|
||||
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
|
||||
| ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders |
|
||||
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
|
||||
@@ -46,31 +47,32 @@ Before you start, make sure you have:
|
||||
|
||||
### Quick Start (Human Users)
|
||||
|
||||
> **Tip:** If you have an AI Agent, you can hand this README to it and let the AI handle installation and setup — jump to [Quick Start (AI Agent)](#quick-start-ai-agent).
|
||||
> **Note for AI assistants:** If you are an AI Agent helping the user with installation, jump directly to [Quick Start (AI Agent)](#quick-start-ai-agent), which contains all the steps you need to complete.
|
||||
|
||||
#### Install CLI
|
||||
#### Install
|
||||
|
||||
**From npm (recommended):**
|
||||
Choose **one** of the following methods:
|
||||
|
||||
**Option 1 — From npm (recommended):**
|
||||
|
||||
```bash
|
||||
# Install CLI
|
||||
npm install -g @larksuite/cli
|
||||
|
||||
# Install CLI SKILL (required)
|
||||
npx skills add larksuite/cli -y -g
|
||||
```
|
||||
|
||||
**From source:**
|
||||
**Option 2 — From source:**
|
||||
|
||||
Requires Go `v1.23`+ and Python 3.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/larksuite/cli.git
|
||||
cd cli
|
||||
make install
|
||||
```
|
||||
|
||||
#### Install AI Agent Skills
|
||||
|
||||
[Skills](./skills/) are structured instruction documents that enable AI Agents to use this CLI:
|
||||
|
||||
```bash
|
||||
# Install all skills to current directory
|
||||
npx skills add larksuite/cli -y
|
||||
|
||||
# Install all skills globally
|
||||
# Install CLI SKILL (required)
|
||||
npx skills add larksuite/cli -y -g
|
||||
```
|
||||
|
||||
@@ -91,22 +93,35 @@ lark-cli calendar +agenda
|
||||
|
||||
> The following steps are for AI Agents. Some steps require the user to complete actions in a browser.
|
||||
|
||||
**Step 1 — Install**
|
||||
|
||||
```bash
|
||||
# 1. Install CLI
|
||||
# Install CLI
|
||||
npm install -g @larksuite/cli
|
||||
|
||||
# 2. Install Skills (enables AI Agent to use this CLI)
|
||||
npx skills add larksuite/cli --all -y
|
||||
# Install CLI SKILL (required)
|
||||
npx skills add larksuite/cli -y -g
|
||||
```
|
||||
|
||||
# 3. Configure app credentials
|
||||
# Important: run this command in the background. It will output an authorization URL — extract it and send it to the user. The command exits automatically after the user completes the setup in browser.
|
||||
**Step 2 — Configure app credentials**
|
||||
|
||||
> Run this command in the background. It will output an authorization URL — extract it and send it to the user. The command exits automatically after the user completes the setup in the browser.
|
||||
|
||||
```bash
|
||||
lark-cli config init --new
|
||||
```
|
||||
|
||||
# 4. Login
|
||||
# Same as above: run in the background, extract the authorization URL and send it to the user.
|
||||
**Step 3 — Login**
|
||||
|
||||
> Same as above: run in the background, extract the authorization URL and send it to the user.
|
||||
|
||||
```bash
|
||||
lark-cli auth login --recommend
|
||||
```
|
||||
|
||||
# 5. Verify
|
||||
**Step 4 — Verify**
|
||||
|
||||
```bash
|
||||
lark-cli auth status
|
||||
```
|
||||
|
||||
@@ -250,6 +265,10 @@ We recommend using the Lark/Feishu bot integrated with this tool as a private co
|
||||
|
||||
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#larksuite/cli&Date)
|
||||
|
||||
## Contributing
|
||||
|
||||
Community contributions are welcome! If you find a bug or have feature suggestions, please submit an [Issue](https://github.com/larksuite/cli/issues) or [Pull Request](https://github.com/larksuite/cli/pulls).
|
||||
|
||||
67
README.zh.md
67
README.zh.md
@@ -2,10 +2,11 @@
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://go.dev/)
|
||||
[](https://www.npmjs.com/package/@larksuite/cli)
|
||||
|
||||
[中文版](./README.zh.md) | [English](./README.md)
|
||||
|
||||
飞书/Lark 开放平台命令行工具 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 19 个 AI Agent [Skills](./skills/)。
|
||||
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 19 个 AI Agent [Skills](./skills/)。
|
||||
|
||||
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
|
||||
|
||||
@@ -27,7 +28,7 @@
|
||||
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
|
||||
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
|
||||
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
|
||||
| 📊 多维表格 | 创建和管理多维表格、字段、记录、视图、仪表盘,数据聚合分析 |
|
||||
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
|
||||
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
|
||||
| ✅ 任务 | 创建、查询、更新和完成任务;管理任务清单、子任务、评论与提醒 |
|
||||
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
|
||||
@@ -46,31 +47,32 @@
|
||||
|
||||
### 快速开始(人类用户)
|
||||
|
||||
> **Tip:** 如果你拥有 AI Agent,可以直接把本 README 丢给它,让 AI 帮你完成安装和配置 — 跳转到[快速开始(AI Agent)](#快速开始ai-agent)查看。
|
||||
> **Note for AI assistants:** 如果你是 AI Agent 且正在协助用户执行安装,请直接跳转到[快速开始(AI Agent)](#快速开始ai-agent)执行,那里包含你需要完成的所有步骤。
|
||||
|
||||
#### 安装 CLI
|
||||
#### 安装
|
||||
|
||||
**从 npm 安装(推荐):**
|
||||
以下两种方式**任选其一**:
|
||||
|
||||
**方式一 — 从 npm 安装(推荐):**
|
||||
|
||||
```bash
|
||||
# 安装 CLI
|
||||
npm install -g @larksuite/cli
|
||||
|
||||
# 安装 CLI SKILL(必需)
|
||||
npx skills add larksuite/cli -y -g
|
||||
```
|
||||
|
||||
**从源码安装:**
|
||||
**方式二 — 从源码安装:**
|
||||
|
||||
需要 Go `v1.23`+ 和 Python 3。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/larksuite/cli.git
|
||||
cd cli
|
||||
make install
|
||||
```
|
||||
|
||||
#### 安装 AI Agent Skills
|
||||
|
||||
[Skills](./skills/) 是结构化的指令文档,使 AI Agent 能够使用本 CLI:
|
||||
|
||||
```bash
|
||||
# 安装所有 skills 到当前目录
|
||||
npx skills add larksuite/cli -y
|
||||
|
||||
# 安装所有 skills 到全局
|
||||
# 安装 CLI SKILL(必需)
|
||||
npx skills add larksuite/cli -y -g
|
||||
```
|
||||
|
||||
@@ -91,22 +93,35 @@ lark-cli calendar +agenda
|
||||
|
||||
> 以下步骤面向 AI Agent,部分步骤需要用户在浏览器中配合完成。
|
||||
|
||||
**第 1 步 — 安装**
|
||||
|
||||
```bash
|
||||
# 1. 安装 CLI
|
||||
# 安装 CLI
|
||||
npm install -g @larksuite/cli
|
||||
|
||||
# 2. 安装 Skills(使 AI Agent 能够使用本 CLI)
|
||||
npx skills add larksuite/cli --all -y
|
||||
# 安装 CLI SKILL(必需)
|
||||
npx skills add larksuite/cli -y -g
|
||||
```
|
||||
|
||||
# 3. 配置应用凭证
|
||||
# 重要:在后台运行此命令,命令会输出一个授权链接,提取该链接并发送给用户,用户在浏览器中完成配置后命令会自动退出。
|
||||
**第 2 步 — 配置应用凭证**
|
||||
|
||||
> 在后台运行此命令,命令会输出一个授权链接,提取该链接并发送给用户,用户在浏览器中完成配置后命令会自动退出。
|
||||
|
||||
```bash
|
||||
lark-cli config init --new
|
||||
```
|
||||
|
||||
# 4. 登录
|
||||
# 同上,后台运行,提取授权链接发给用户
|
||||
**第 3 步 — 登录**
|
||||
|
||||
> 同上,后台运行,提取授权链接发给用户。
|
||||
|
||||
```bash
|
||||
lark-cli auth login --recommend
|
||||
```
|
||||
|
||||
# 5. 验证
|
||||
**第 4 步 — 验证**
|
||||
|
||||
```bash
|
||||
lark-cli auth status
|
||||
```
|
||||
|
||||
@@ -251,6 +266,10 @@ lark-cli schema im.messages.delete
|
||||
|
||||
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#larksuite/cli&Date)
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎社区贡献!如果你发现 bug 或有功能建议,请提交 [Issue](https://github.com/larksuite/cli/issues) 或 [Pull Request](https://github.com/larksuite/cli/pulls)。
|
||||
|
||||
@@ -198,15 +198,12 @@ func apiRun(opts *APIOptions) error {
|
||||
Out: out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
})
|
||||
// MarkRaw tells root error handler that the API response was already written
|
||||
// to stdout, so it should skip the stderr error envelope. Only apply when
|
||||
// HandleResponse actually wrote output (i.e. returned a business/API error
|
||||
// after printing JSON to stdout). Non-JSON HTTP errors (e.g. 404 text/plain)
|
||||
// produce no stdout output and need the envelope.
|
||||
if err != nil && client.IsJSONContentType(resp.Header.Get("Content-Type")) {
|
||||
// MarkRaw tells root error handler to skip enrichPermissionError,
|
||||
// preserving the original API error detail (log_id, troubleshooter, etc.).
|
||||
if err != nil {
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
|
||||
|
||||
@@ -446,10 +446,9 @@ func TestApiCmd_APIError_IsRaw(t *testing.T) {
|
||||
t.Error("expected API error from api command to be marked Raw")
|
||||
}
|
||||
|
||||
// stderr should NOT contain an error envelope (identity line is OK)
|
||||
if strings.Contains(stderr.String(), `"ok"`) {
|
||||
t.Error("expected no JSON error envelope on stderr for Raw API error")
|
||||
}
|
||||
// Note: stderr envelope output is tested at the root level (TestHandleRootError_*)
|
||||
// since WriteErrorEnvelope is called by handleRootError, not by cobra's Execute.
|
||||
_ = stderr
|
||||
}
|
||||
|
||||
func TestApiCmd_APIError_PreservesOriginalMessage(t *testing.T) {
|
||||
|
||||
@@ -14,9 +14,11 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
|
||||
// DoctorOptions holds inputs for the doctor command.
|
||||
@@ -60,6 +62,10 @@ func fail(name, msg, hint string) checkResult {
|
||||
return checkResult{Name: name, Status: "fail", Message: msg, Hint: hint}
|
||||
}
|
||||
|
||||
func warn(name, msg, hint string) checkResult {
|
||||
return checkResult{Name: name, Status: "warn", Message: msg, Hint: hint}
|
||||
}
|
||||
|
||||
func skip(name, msg string) checkResult {
|
||||
return checkResult{Name: name, Status: "skip", Message: msg}
|
||||
}
|
||||
@@ -68,6 +74,12 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
f := opts.Factory
|
||||
var checks []checkResult
|
||||
|
||||
// ── 0. CLI version & update check ──
|
||||
checks = append(checks, pass("cli_version", build.Version))
|
||||
if !opts.Offline {
|
||||
checks = append(checks, checkCLIUpdate()...)
|
||||
}
|
||||
|
||||
// ── 1. Config file ──
|
||||
_, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
@@ -214,6 +226,23 @@ func mustHTTPClient(f *cmdutil.Factory) *http.Client {
|
||||
return c
|
||||
}
|
||||
|
||||
// checkCLIUpdate actively queries the npm registry for the latest version.
|
||||
// Unlike the root-level async check, this does a synchronous fetch with timeout
|
||||
// and works regardless of build version (dev builds included).
|
||||
func checkCLIUpdate() []checkResult {
|
||||
latest, err := update.FetchLatest()
|
||||
if err != nil {
|
||||
return []checkResult{warn("cli_update", "check failed: "+err.Error(), "")}
|
||||
}
|
||||
current := build.Version
|
||||
if update.IsNewer(latest, current) {
|
||||
return []checkResult{warn("cli_update",
|
||||
fmt.Sprintf("%s → %s available", current, latest),
|
||||
"run: npm update -g @larksuite/cli")}
|
||||
}
|
||||
return []checkResult{pass("cli_update", latest+" (up to date)")}
|
||||
}
|
||||
|
||||
func finishDoctor(f *cmdutil.Factory, checks []checkResult) error {
|
||||
allOK := true
|
||||
for _, c := range checks {
|
||||
|
||||
69
cmd/root.go
69
cmd/root.go
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -65,7 +67,7 @@ AI AGENT SKILLS:
|
||||
teach the agent Lark API patterns, best practices, and workflows.
|
||||
|
||||
Install all skills:
|
||||
npx skills add larksuite/cli --all -y
|
||||
npx skills add larksuite/cli -g -y
|
||||
|
||||
Or pick specific domains:
|
||||
npx skills add larksuite/cli -s lark-calendar -y
|
||||
@@ -105,12 +107,68 @@ func Execute() int {
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
|
||||
// --- Update check (non-blocking) ---
|
||||
if !isCompletionCommand(os.Args) {
|
||||
setupUpdateNotice()
|
||||
}
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
return handleRootError(f, err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// setupUpdateNotice starts an async update check and wires the output decorator.
|
||||
func setupUpdateNotice() {
|
||||
// Sync: check cache immediately (no network, fast).
|
||||
if info := update.CheckCached(build.Version); info != nil {
|
||||
update.SetPending(info)
|
||||
}
|
||||
|
||||
// Async: refresh cache for this run (and future runs).
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
fmt.Fprintf(os.Stderr, "update check panic: %v\n", r)
|
||||
}
|
||||
}()
|
||||
update.RefreshCache(build.Version)
|
||||
// If cache was just populated for the first time, set pending now.
|
||||
if update.GetPending() == nil {
|
||||
if info := update.CheckCached(build.Version); info != nil {
|
||||
update.SetPending(info)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Wire the output decorator so JSON envelopes include "_notice".
|
||||
output.PendingNotice = func() map[string]interface{} {
|
||||
info := update.GetPending()
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"update": map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isCompletionCommand returns true if args indicate a shell completion request.
|
||||
// Update notifications must be suppressed for these to avoid corrupting
|
||||
// machine-parseable completion output.
|
||||
func isCompletionCommand(args []string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == "completion" || arg == "__complete" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleRootError dispatches a command error to the appropriate handler
|
||||
// and returns the process exit code.
|
||||
func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
@@ -126,12 +184,11 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
|
||||
// All other structured errors normalize to ExitError.
|
||||
if exitErr := asExitError(err); exitErr != nil {
|
||||
if exitErr.Raw {
|
||||
// Raw errors (e.g. from `api` command) already printed the full API
|
||||
// response to stdout; skip enrichment and duplicate stderr envelope.
|
||||
return exitErr.Code
|
||||
if !exitErr.Raw {
|
||||
// Raw errors (e.g. from `api` command) preserve the original API
|
||||
// error detail; skip enrichment which would clear it.
|
||||
enrichPermissionError(f, exitErr)
|
||||
}
|
||||
enrichPermissionError(f, exitErr)
|
||||
output.WriteErrorEnvelope(errOut, exitErr, string(f.ResolvedIdentity))
|
||||
return exitErr.Code
|
||||
}
|
||||
|
||||
279
cmd/root_e2e_test.go
Normal file
279
cmd/root_e2e_test.go
Normal file
@@ -0,0 +1,279 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"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"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// buildTestRootCmd creates a root command with api, service, and shortcut
|
||||
// subcommands wired to a test factory, simulating the real CLI command tree.
|
||||
func buildTestRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||
t.Helper()
|
||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||
rootCmd.SilenceErrors = true
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
cmd.SilenceUsage = true
|
||||
}
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
// executeE2E runs a command through the full command tree and handleRootError,
|
||||
// returning exit code — matching real CLI behavior.
|
||||
func executeE2E(t *testing.T, f *cmdutil.Factory, rootCmd *cobra.Command, args []string) int {
|
||||
t.Helper()
|
||||
rootCmd.SetArgs(args)
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
return handleRootError(f, err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// registerTokenStub registers a tenant_access_token stub so bot auth succeeds.
|
||||
func registerTokenStub(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/auth/v3/tenant_access_token/internal",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"tenant_access_token": "t-e2e-token", "expire": 7200,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// parseEnvelope parses stderr bytes into an ErrorEnvelope.
|
||||
func parseEnvelope(t *testing.T, stderr *bytes.Buffer) output.ErrorEnvelope {
|
||||
t.Helper()
|
||||
if stderr.Len() == 0 {
|
||||
t.Fatal("expected non-empty stderr, got empty")
|
||||
}
|
||||
var env output.ErrorEnvelope
|
||||
if err := json.Unmarshal(stderr.Bytes(), &env); err != nil {
|
||||
t.Fatalf("failed to parse stderr as ErrorEnvelope: %v\nstderr: %s", err, stderr.String())
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// assertEnvelope verifies exit code, stdout is empty, and stderr matches the
|
||||
// expected ErrorEnvelope exactly via reflect.DeepEqual.
|
||||
func assertEnvelope(t *testing.T, code int, wantCode int, stdout *bytes.Buffer, stderr *bytes.Buffer, want output.ErrorEnvelope) {
|
||||
t.Helper()
|
||||
if code != wantCode {
|
||||
t.Errorf("exit code: got %d, want %d", code, wantCode)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
|
||||
}
|
||||
got := parseEnvelope(t, stderr)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
gotJSON, _ := json.MarshalIndent(got, "", " ")
|
||||
wantJSON, _ := json.MarshalIndent(want, "", " ")
|
||||
t.Errorf("stderr envelope mismatch:\ngot:\n%s\nwant:\n%s", gotJSON, wantJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// --- api command ---
|
||||
|
||||
func TestE2E_Api_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-api-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
registerTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/messages",
|
||||
Body: map[string]interface{}{
|
||||
"code": 230002,
|
||||
"msg": "Bot/User can NOT be out of the chat.",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "test-log-id-001",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildTestRootCmd(t, f)
|
||||
code := executeE2E(t, f, rootCmd, []string{
|
||||
"api", "--as", "bot", "POST", "/open-apis/im/v1/messages",
|
||||
"--params", `{"receive_id_type":"chat_id"}`,
|
||||
"--data", `{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"test\"}"}`,
|
||||
})
|
||||
|
||||
// api uses MarkRaw: detail preserved, no enrichment
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 230002,
|
||||
Message: "API error: [230002] Bot/User can NOT be out of the chat.",
|
||||
Detail: map[string]interface{}{
|
||||
"log_id": "test-log-id-001",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_Api_PermissionError_NotEnriched(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-api-perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
registerTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/perm",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled for this app",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
"log_id": "test-log-id-perm",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildTestRootCmd(t, f)
|
||||
code := executeE2E(t, f, rootCmd, []string{
|
||||
"api", "--as", "bot", "GET", "/open-apis/test/perm",
|
||||
})
|
||||
|
||||
// api uses MarkRaw: enrichment skipped, detail preserved, no console_url
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "permission",
|
||||
Code: 99991672,
|
||||
Message: "Permission denied [99991672]",
|
||||
Hint: "check app permissions or re-authorize: lark-cli auth login",
|
||||
Detail: map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
||||
},
|
||||
"log_id": "test-log-id-perm",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- service command ---
|
||||
|
||||
func TestE2E_Service_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-svc-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
registerTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_fake",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99992356,
|
||||
"msg": "id not exist",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "test-log-id-svc",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildTestRootCmd(t, f)
|
||||
code := executeE2E(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_fake"}`, "--as", "bot",
|
||||
})
|
||||
|
||||
// service: no MarkRaw, non-permission error — detail preserved
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 99992356,
|
||||
Message: "API error: [99992356] id not exist",
|
||||
Detail: map[string]interface{}{
|
||||
"log_id": "test-log-id-svc",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_Service_PermissionError_Enriched(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-svc-perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
registerTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/chats/oc_test",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "scope not enabled",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "im:chat:readonly"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildTestRootCmd(t, f)
|
||||
code := executeE2E(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "bot",
|
||||
})
|
||||
|
||||
// service: no MarkRaw — enrichment applied, detail cleared, console_url set
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "permission",
|
||||
Code: 99991672,
|
||||
Message: "App scope not enabled: required scope im:chat:readonly [99991672]",
|
||||
Hint: "enable the scope in developer console (see console_url)",
|
||||
ConsoleURL: "https://open.feishu.cn/page/scope-apply?clientID=e2e-svc-perm&scopes=im%3Achat%3Areadonly",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- shortcut command ---
|
||||
|
||||
func TestE2E_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "e2e-sc-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
registerTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/messages",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{
|
||||
"code": 230002,
|
||||
"msg": "Bot/User can NOT be out of the chat.",
|
||||
},
|
||||
})
|
||||
|
||||
rootCmd := buildTestRootCmd(t, f)
|
||||
code := executeE2E(t, f, rootCmd, []string{
|
||||
"im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test",
|
||||
})
|
||||
|
||||
// shortcut: no MarkRaw, no HandleResponse — error via DoAPIJSON path
|
||||
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api_error",
|
||||
Code: 230002,
|
||||
Message: "HTTP 400: Bot/User can NOT be out of the chat.",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -65,7 +65,7 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRootError_RawError_SkipsEnrichmentAndEnvelope(t *testing.T) {
|
||||
func TestHandleRootError_RawError_SkipsEnrichmentButWritesEnvelope(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
@@ -82,9 +82,9 @@ func TestHandleRootError_RawError_SkipsEnrichmentAndEnvelope(t *testing.T) {
|
||||
if code != output.ExitAPI {
|
||||
t.Errorf("expected exit code %d, got %d", output.ExitAPI, code)
|
||||
}
|
||||
// stderr should be empty — no envelope written
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("expected empty stderr for Raw error, got: %s", stderr.String())
|
||||
// stderr should contain the error envelope
|
||||
if stderr.Len() == 0 {
|
||||
t.Error("expected non-empty stderr for Raw error — WriteErrorEnvelope should always run")
|
||||
}
|
||||
// The message should NOT have been enriched by enrichPermissionError
|
||||
// (ErrAPI sets "Permission denied [code]" but enrichment would replace it with "App scope not enabled: ...")
|
||||
|
||||
@@ -5,18 +5,20 @@ package output
|
||||
|
||||
// Envelope is the standard success response wrapper.
|
||||
type Envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Identity string `json:"identity,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Meta *Meta `json:"meta,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
Identity string `json:"identity,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Meta *Meta `json:"meta,omitempty"`
|
||||
Notice map[string]interface{} `json:"_notice,omitempty"`
|
||||
}
|
||||
|
||||
// ErrorEnvelope is the standard error response wrapper.
|
||||
type ErrorEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Identity string `json:"identity,omitempty"`
|
||||
Error *ErrDetail `json:"error"`
|
||||
Meta *Meta `json:"meta,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
Identity string `json:"identity,omitempty"`
|
||||
Error *ErrDetail `json:"error"`
|
||||
Meta *Meta `json:"meta,omitempty"`
|
||||
Notice map[string]interface{} `json:"_notice,omitempty"`
|
||||
}
|
||||
|
||||
// ErrDetail describes a structured error.
|
||||
@@ -34,3 +36,17 @@ type Meta struct {
|
||||
Count int `json:"count,omitempty"`
|
||||
Rollback string `json:"rollback,omitempty"`
|
||||
}
|
||||
|
||||
// PendingNotice, if set, returns system-level notices to inject as the
|
||||
// "_notice" field in JSON output envelopes. Set by cmd/root.go.
|
||||
// Returns nil when there is nothing to report.
|
||||
var PendingNotice func() map[string]interface{}
|
||||
|
||||
// GetNotice returns the current pending notice for struct-based callers.
|
||||
// Returns nil when there is nothing to report.
|
||||
func GetNotice() map[string]interface{} {
|
||||
if PendingNotice == nil {
|
||||
return nil
|
||||
}
|
||||
return PendingNotice()
|
||||
}
|
||||
|
||||
@@ -40,10 +40,11 @@ func WriteErrorEnvelope(w io.Writer, err *ExitError, identity string) {
|
||||
if err.Detail == nil {
|
||||
return
|
||||
}
|
||||
env := ErrorEnvelope{
|
||||
env := &ErrorEnvelope{
|
||||
OK: false,
|
||||
Identity: identity,
|
||||
Error: err.Detail,
|
||||
Notice: GetNotice(),
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
@@ -37,3 +39,112 @@ func TestMarkRaw_Nil(t *testing.T) {
|
||||
t.Error("expected MarkRaw(nil) to return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteErrorEnvelope_WithNotice(t *testing.T) {
|
||||
// Set up PendingNotice
|
||||
origNotice := PendingNotice
|
||||
PendingNotice = func() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"update": map[string]interface{}{
|
||||
"current": "1.0.0",
|
||||
"latest": "2.0.0",
|
||||
},
|
||||
}
|
||||
}
|
||||
defer func() { PendingNotice = origNotice }()
|
||||
|
||||
exitErr := &ExitError{
|
||||
Code: 1,
|
||||
Detail: &ErrDetail{Type: "api_error", Message: "something failed"},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
WriteErrorEnvelope(&buf, exitErr, "user")
|
||||
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("failed to parse output: %v", err)
|
||||
}
|
||||
|
||||
// Verify _notice is present
|
||||
notice, ok := env["_notice"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected _notice field in output")
|
||||
}
|
||||
update, ok := notice["update"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected _notice.update field")
|
||||
}
|
||||
if update["latest"] != "2.0.0" {
|
||||
t.Errorf("expected latest=2.0.0, got %v", update["latest"])
|
||||
}
|
||||
|
||||
// Verify standard fields
|
||||
if env["ok"] != false {
|
||||
t.Error("expected ok=false")
|
||||
}
|
||||
if env["identity"] != "user" {
|
||||
t.Errorf("expected identity=user, got %v", env["identity"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteErrorEnvelope_WithoutNotice(t *testing.T) {
|
||||
// Ensure PendingNotice is nil
|
||||
origNotice := PendingNotice
|
||||
PendingNotice = nil
|
||||
defer func() { PendingNotice = origNotice }()
|
||||
|
||||
exitErr := &ExitError{
|
||||
Code: 1,
|
||||
Detail: &ErrDetail{Type: "api_error", Message: "something failed"},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
WriteErrorEnvelope(&buf, exitErr, "bot")
|
||||
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("failed to parse output: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := env["_notice"]; ok {
|
||||
t.Error("expected no _notice field when PendingNotice is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteErrorEnvelope_NilDetail(t *testing.T) {
|
||||
exitErr := &ExitError{Code: 1}
|
||||
|
||||
var buf bytes.Buffer
|
||||
WriteErrorEnvelope(&buf, exitErr, "user")
|
||||
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("expected no output for nil Detail, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNotice(t *testing.T) {
|
||||
// Nil PendingNotice → nil
|
||||
origNotice := PendingNotice
|
||||
PendingNotice = nil
|
||||
if got := GetNotice(); got != nil {
|
||||
t.Errorf("expected nil, got %v", got)
|
||||
}
|
||||
|
||||
// With PendingNotice → returns value
|
||||
PendingNotice = func() map[string]interface{} {
|
||||
return map[string]interface{}{"update": "test"}
|
||||
}
|
||||
got := GetNotice()
|
||||
if got == nil || got["update"] != "test" {
|
||||
t.Errorf("expected {update: test}, got %v", got)
|
||||
}
|
||||
|
||||
// PendingNotice returns nil → nil
|
||||
PendingNotice = func() map[string]interface{} { return nil }
|
||||
if got := GetNotice(); got != nil {
|
||||
t.Errorf("expected nil, got %v", got)
|
||||
}
|
||||
|
||||
PendingNotice = origNotice
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
// PrintJson prints data as formatted JSON to w.
|
||||
func PrintJson(w io.Writer, data interface{}) {
|
||||
injectNotice(data)
|
||||
b, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "json marshal error: %v\n", err)
|
||||
@@ -22,6 +23,31 @@ func PrintJson(w io.Writer, data interface{}) {
|
||||
fmt.Fprintln(w, string(b))
|
||||
}
|
||||
|
||||
// injectNotice adds a "_notice" field into CLI envelope maps.
|
||||
// Only modifies map[string]interface{} values that have an "ok" key
|
||||
// (e.g. doctor, auth, config commands that build map envelopes directly).
|
||||
//
|
||||
// Struct-based envelopes (Envelope, ErrorEnvelope) are NOT handled here —
|
||||
// callers must set the Notice field explicitly via GetNotice().
|
||||
// See: shortcuts/common/runner.go Out(), output/errors.go WriteErrorEnvelope().
|
||||
func injectNotice(data interface{}) {
|
||||
if PendingNotice == nil {
|
||||
return
|
||||
}
|
||||
m, ok := data.(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, isEnvelope := m["ok"]; !isEnvelope {
|
||||
return
|
||||
}
|
||||
notice := PendingNotice()
|
||||
if notice == nil {
|
||||
return
|
||||
}
|
||||
m["_notice"] = notice
|
||||
}
|
||||
|
||||
// PrintNdjson prints data as NDJSON (Newline Delimited JSON) to w.
|
||||
func PrintNdjson(w io.Writer, data interface{}) {
|
||||
emit := func(item interface{}) {
|
||||
|
||||
101
internal/output/print_test.go
Normal file
101
internal/output/print_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPrintJson_InjectNotice_Map(t *testing.T) {
|
||||
origNotice := PendingNotice
|
||||
PendingNotice = func() map[string]interface{} {
|
||||
return map[string]interface{}{"update": "available"}
|
||||
}
|
||||
defer func() { PendingNotice = origNotice }()
|
||||
|
||||
data := map[string]interface{}{"ok": true, "data": "test"}
|
||||
var buf bytes.Buffer
|
||||
PrintJson(&buf, data)
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("failed to parse: %v", err)
|
||||
}
|
||||
notice, ok := got["_notice"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected _notice in map-based envelope")
|
||||
}
|
||||
if notice["update"] != "available" {
|
||||
t.Errorf("expected update=available, got %v", notice["update"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJson_InjectNotice_SkipsNonEnvelope(t *testing.T) {
|
||||
origNotice := PendingNotice
|
||||
PendingNotice = func() map[string]interface{} {
|
||||
return map[string]interface{}{"update": "available"}
|
||||
}
|
||||
defer func() { PendingNotice = origNotice }()
|
||||
|
||||
// Map without "ok" key should not get _notice
|
||||
data := map[string]interface{}{"name": "test"}
|
||||
var buf bytes.Buffer
|
||||
PrintJson(&buf, data)
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("failed to parse: %v", err)
|
||||
}
|
||||
if _, ok := got["_notice"]; ok {
|
||||
t.Error("expected no _notice for non-envelope map")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJson_Struct_PreservesNotice(t *testing.T) {
|
||||
origNotice := PendingNotice
|
||||
PendingNotice = nil // no global notice
|
||||
defer func() { PendingNotice = origNotice }()
|
||||
|
||||
// Struct with Notice already set should preserve it
|
||||
env := &Envelope{
|
||||
OK: true,
|
||||
Identity: "user",
|
||||
Data: "hello",
|
||||
Notice: map[string]interface{}{"update": "set-by-caller"},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
PrintJson(&buf, env)
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("failed to parse: %v", err)
|
||||
}
|
||||
notice, ok := got["_notice"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected _notice from struct field")
|
||||
}
|
||||
if notice["update"] != "set-by-caller" {
|
||||
t.Errorf("expected update=set-by-caller, got %v", notice["update"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJson_NoNotice(t *testing.T) {
|
||||
origNotice := PendingNotice
|
||||
PendingNotice = nil
|
||||
defer func() { PendingNotice = origNotice }()
|
||||
|
||||
data := map[string]interface{}{"ok": true, "data": "test"}
|
||||
var buf bytes.Buffer
|
||||
PrintJson(&buf, data)
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("failed to parse: %v", err)
|
||||
}
|
||||
if _, ok := got["_notice"]; ok {
|
||||
t.Error("expected no _notice when PendingNotice is nil")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"base": {
|
||||
"en": { "title": "Base", "description": "Table, field, record, and view management" },
|
||||
"zh": { "title": "多维表格", "description": "数据表、字段、记录、视图" }
|
||||
"en": { "title": "Base", "description": "Table, field, record, view, dashboard, workflow, form, role & permission management" },
|
||||
"zh": { "title": "多维表格", "description": "数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限管理" }
|
||||
},
|
||||
"calendar": {
|
||||
"en": { "title": "Calendar", "description": "Calendar, event, and attendee management" },
|
||||
|
||||
255
internal/update/update.go
Normal file
255
internal/update/update.go
Normal file
@@ -0,0 +1,255 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package update
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
const (
|
||||
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
|
||||
cacheTTL = 24 * time.Hour
|
||||
fetchTimeout = 5 * time.Second
|
||||
stateFile = "update-state.json"
|
||||
maxBody = 256 << 10 // 256 KB
|
||||
|
||||
)
|
||||
|
||||
// UpdateInfo holds version update information.
|
||||
type UpdateInfo struct {
|
||||
Current string `json:"current"`
|
||||
Latest string `json:"latest"`
|
||||
}
|
||||
|
||||
// Message returns a concise update notification.
|
||||
func (u *UpdateInfo) Message() string {
|
||||
return fmt.Sprintf("lark-cli %s available, current %s", u.Latest, u.Current)
|
||||
}
|
||||
|
||||
// pending stores the latest update info for the current process.
|
||||
var pending atomic.Pointer[UpdateInfo]
|
||||
|
||||
// SetPending stores the update info for consumption by output decorators.
|
||||
func SetPending(info *UpdateInfo) { pending.Store(info) }
|
||||
|
||||
// GetPending returns the pending update info, or nil.
|
||||
func GetPending() *UpdateInfo { return pending.Load() }
|
||||
|
||||
// DefaultClient is the HTTP client used for npm registry requests.
|
||||
// Override in tests with an httptest server client.
|
||||
var DefaultClient *http.Client
|
||||
|
||||
func httpClient() *http.Client {
|
||||
if DefaultClient != nil {
|
||||
return DefaultClient
|
||||
}
|
||||
return &http.Client{Timeout: fetchTimeout}
|
||||
}
|
||||
|
||||
// updateState is persisted to disk for caching.
|
||||
type updateState struct {
|
||||
LatestVersion string `json:"latest_version"`
|
||||
CheckedAt int64 `json:"checked_at"`
|
||||
}
|
||||
|
||||
// CheckCached checks the local cache only (no network). Always fast.
|
||||
func CheckCached(currentVersion string) *UpdateInfo {
|
||||
if shouldSkip(currentVersion) {
|
||||
return nil
|
||||
}
|
||||
state, _ := loadState()
|
||||
if state == nil || state.LatestVersion == "" {
|
||||
return nil
|
||||
}
|
||||
if !IsNewer(state.LatestVersion, currentVersion) {
|
||||
return nil
|
||||
}
|
||||
return &UpdateInfo{Current: currentVersion, Latest: state.LatestVersion}
|
||||
}
|
||||
|
||||
// RefreshCache fetches the latest version from npm and updates the local cache.
|
||||
// No-op if the cache is still fresh (< 24h). Safe to call from a goroutine.
|
||||
func RefreshCache(currentVersion string) {
|
||||
if shouldSkip(currentVersion) {
|
||||
return
|
||||
}
|
||||
state, _ := loadState()
|
||||
if state != nil && time.Since(time.Unix(state.CheckedAt, 0)) < cacheTTL {
|
||||
return // cache is fresh
|
||||
}
|
||||
latest, err := fetchLatestVersion()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = saveState(&updateState{
|
||||
LatestVersion: latest,
|
||||
CheckedAt: time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
func shouldSkip(version string) bool {
|
||||
if os.Getenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER") != "" {
|
||||
return true
|
||||
}
|
||||
// Suppress in CI environments.
|
||||
for _, key := range []string{"CI", "BUILD_NUMBER", "RUN_ID"} {
|
||||
if os.Getenv(key) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// No version info at all — can't compare.
|
||||
if version == "DEV" || version == "dev" || version == "" {
|
||||
return true
|
||||
}
|
||||
// Skip local dev builds (e.g. v1.0.0-12-g9b933f1-dirty from git describe).
|
||||
// Only released versions (clean X.Y.Z) should check for updates.
|
||||
if !isRelease(version) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isRelease returns true for published versions: clean semver (1.0.0)
|
||||
// and npm prerelease (1.0.0-beta.1, 1.0.0-rc.1).
|
||||
// Returns false for git describe dev builds (v1.0.0-12-g9b933f1-dirty).
|
||||
var gitDescribePattern = regexp.MustCompile(`-\d+-g[0-9a-f]{7,}`)
|
||||
|
||||
func isRelease(version string) bool {
|
||||
v := strings.TrimPrefix(version, "v")
|
||||
if ParseVersion(v) == nil {
|
||||
return false
|
||||
}
|
||||
return !gitDescribePattern.MatchString(v)
|
||||
}
|
||||
|
||||
// --- state file I/O ---
|
||||
|
||||
func statePath() string {
|
||||
return filepath.Join(core.GetConfigDir(), stateFile)
|
||||
}
|
||||
|
||||
func loadState() (*updateState, error) {
|
||||
data, err := os.ReadFile(statePath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var s updateState
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func saveState(s *updateState) error {
|
||||
dir := core.GetConfigDir()
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validate.AtomicWrite(statePath(), data, 0644)
|
||||
}
|
||||
|
||||
// FetchLatest queries the npm registry and returns the latest published version.
|
||||
// This is a synchronous call with timeout, intended for diagnostic commands (doctor).
|
||||
func FetchLatest() (string, error) {
|
||||
return fetchLatestVersion()
|
||||
}
|
||||
|
||||
// --- npm registry ---
|
||||
|
||||
type npmLatestResponse struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
func fetchLatestVersion() (string, error) {
|
||||
resp, err := httpClient().Get(registryURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("npm registry: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var result npmLatestResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.Version == "" {
|
||||
return "", fmt.Errorf("npm registry: empty version")
|
||||
}
|
||||
return result.Version, nil
|
||||
}
|
||||
|
||||
// --- semver helpers ---
|
||||
|
||||
// IsNewer returns true if version a should be considered an update over b.
|
||||
//
|
||||
// When both parse as semver, standard comparison applies.
|
||||
// When b cannot be parsed (e.g. bare commit hash "9b933f1"), any valid a
|
||||
// is considered newer — an unparseable local version is assumed outdated.
|
||||
// When a cannot be parsed, returns false (can't confirm it's newer).
|
||||
func IsNewer(a, b string) bool {
|
||||
ap := ParseVersion(a)
|
||||
bp := ParseVersion(b)
|
||||
if ap == nil {
|
||||
return false // can't confirm remote is newer
|
||||
}
|
||||
if bp == nil {
|
||||
return true // local version unparseable → assume outdated
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if ap[i] > bp[i] {
|
||||
return true
|
||||
}
|
||||
if ap[i] < bp[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ParseVersion parses "X.Y.Z" (with optional "v" prefix and pre-release suffix)
|
||||
// into [major, minor, patch]. Returns nil on invalid input.
|
||||
func ParseVersion(v string) []int {
|
||||
v = strings.TrimPrefix(v, "v")
|
||||
parts := strings.SplitN(v, ".", 3)
|
||||
if len(parts) != 3 {
|
||||
return nil
|
||||
}
|
||||
nums := make([]int, 3)
|
||||
for i, p := range parts {
|
||||
if idx := strings.IndexAny(p, "-+"); idx >= 0 {
|
||||
p = p[:idx]
|
||||
}
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
nums[i] = n
|
||||
}
|
||||
return nums
|
||||
}
|
||||
253
internal/update/update_test.go
Normal file
253
internal/update/update_test.go
Normal file
@@ -0,0 +1,253 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package update
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// roundTripFunc adapts a function to http.RoundTripper.
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
||||
|
||||
// clearSkipEnv unsets all env vars that shouldSkip checks,
|
||||
// preventing the host environment (e.g. CI=true) from polluting test results.
|
||||
func clearSkipEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, key := range []string{"LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "CI", "BUILD_NUMBER", "RUN_ID"} {
|
||||
t.Setenv(key, "")
|
||||
os.Unsetenv(key)
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseURL(raw string) *url.URL {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func TestIsNewer(t *testing.T) {
|
||||
tests := []struct {
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{"1.1.0", "1.0.0", true},
|
||||
{"1.0.0", "1.0.0", false},
|
||||
{"1.0.0", "1.1.0", false},
|
||||
{"2.0.0", "1.9.9", true},
|
||||
{"1.0.1", "1.0.0", true},
|
||||
{"v1.1.0", "1.0.0", true},
|
||||
{"1.1.0", "v1.0.0", true},
|
||||
{"0.0.1", "0.0.0", true},
|
||||
{"DEV", "1.0.0", false}, // unparseable remote → false
|
||||
{"1.0.0", "DEV", true}, // unparseable local → assume outdated
|
||||
{"1.0.0", "9b933f1", true}, // bare commit hash → assume outdated
|
||||
{"", "1.0.0", false}, // empty remote → false
|
||||
{"1.1.0", "v1.0.0-12-g9b933f1-dirty", true}, // git describe: 1.1.0 > 1.0.0
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := IsNewer(tt.a, tt.b)
|
||||
if got != tt.want {
|
||||
t.Errorf("IsNewer(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want []int
|
||||
}{
|
||||
{"1.2.3", []int{1, 2, 3}},
|
||||
{"v1.2.3", []int{1, 2, 3}},
|
||||
{"0.0.1", []int{0, 0, 1}},
|
||||
{"1.0.0-beta.1", []int{1, 0, 0}},
|
||||
{"DEV", nil},
|
||||
{"", nil},
|
||||
{"1.2", nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := ParseVersion(tt.input)
|
||||
if tt.want == nil {
|
||||
if got != nil {
|
||||
t.Errorf("ParseVersion(%q) = %v, want nil", tt.input, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if got == nil || got[0] != tt.want[0] || got[1] != tt.want[1] || got[2] != tt.want[2] {
|
||||
t.Errorf("ParseVersion(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSkip(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
version string
|
||||
env map[string]string
|
||||
want bool
|
||||
}{
|
||||
{"DEV", "DEV", nil, true},
|
||||
{"dev_lower", "dev", nil, true},
|
||||
{"empty", "", nil, true},
|
||||
{"CI", "1.0.0", map[string]string{"CI": "true"}, true},
|
||||
{"BUILD_NUMBER", "1.0.0", map[string]string{"BUILD_NUMBER": "42"}, true},
|
||||
{"RUN_ID", "1.0.0", map[string]string{"RUN_ID": "123"}, true},
|
||||
{"notifier_off", "1.0.0", map[string]string{"LARKSUITE_CLI_NO_UPDATE_NOTIFIER": "1"}, true},
|
||||
{"git_describe", "v1.0.0-12-g9b933f1", nil, true},
|
||||
{"git_dirty", "v1.0.0-12-g9b933f1-dirty", nil, true},
|
||||
{"commit_hash", "9b933f1", nil, true},
|
||||
{"clean_semver", "1.0.0", nil, false},
|
||||
{"clean_semver_v", "v1.0.0", nil, false},
|
||||
{"prerelease_beta", "1.0.0-beta.1", nil, false},
|
||||
{"prerelease_rc", "2.0.0-rc.1", nil, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
clearSkipEnv(t)
|
||||
for k, v := range tt.env {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
got := shouldSkip(tt.version)
|
||||
if got != tt.want {
|
||||
t.Errorf("shouldSkip(%q) = %v, want %v", tt.version, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRelease(t *testing.T) {
|
||||
tests := []struct {
|
||||
version string
|
||||
want bool
|
||||
}{
|
||||
{"1.0.0", true},
|
||||
{"v1.0.0", true},
|
||||
{"0.1.0", true},
|
||||
{"1.0.0-beta.1", true},
|
||||
{"1.0.0-rc.1", true},
|
||||
{"2.0.0-alpha.0", true},
|
||||
{"v1.0.0-12-g9b933f1", false}, // git describe
|
||||
{"v1.0.0-12-g9b933f1-dirty", false}, // git describe dirty
|
||||
{"v2.1.0-3-gabcdef0", false}, // git describe short
|
||||
{"9b933f1", false}, // bare commit hash
|
||||
{"DEV", false}, // dev marker
|
||||
{"", false}, // empty
|
||||
{"1.0", false}, // incomplete semver
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.version, func(t *testing.T) {
|
||||
got := isRelease(tt.version)
|
||||
if got != tt.want {
|
||||
t.Errorf("isRelease(%q) = %v, want %v", tt.version, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateInfoMethods(t *testing.T) {
|
||||
info := &UpdateInfo{Current: "1.0.0", Latest: "2.0.0"}
|
||||
|
||||
msg := info.Message()
|
||||
if !strings.Contains(msg, "2.0.0") {
|
||||
t.Errorf("Message() missing latest version: %s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "1.0.0") {
|
||||
t.Errorf("Message() missing current version: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCached(t *testing.T) {
|
||||
clearSkipEnv(t)
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
|
||||
// No cache → nil
|
||||
info := CheckCached("1.0.0")
|
||||
if info != nil {
|
||||
t.Errorf("expected nil with no cache, got %+v", info)
|
||||
}
|
||||
|
||||
// Write cache with newer version
|
||||
state := &updateState{LatestVersion: "2.0.0", CheckedAt: time.Now().Unix()}
|
||||
data, _ := json.Marshal(state)
|
||||
os.WriteFile(filepath.Join(tmp, stateFile), data, 0644)
|
||||
|
||||
info = CheckCached("1.0.0")
|
||||
if info == nil {
|
||||
t.Fatal("expected update info, got nil")
|
||||
}
|
||||
if info.Latest != "2.0.0" || info.Current != "1.0.0" {
|
||||
t.Errorf("unexpected info: %+v", info)
|
||||
}
|
||||
|
||||
// Same version → nil
|
||||
info = CheckCached("2.0.0")
|
||||
if info != nil {
|
||||
t.Errorf("expected nil when versions match, got %+v", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshCache(t *testing.T) {
|
||||
clearSkipEnv(t)
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
|
||||
// Set up mock npm registry via DefaultClient
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(npmLatestResponse{Version: "3.0.0"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Redirect all requests to the mock server.
|
||||
DefaultClient = srv.Client()
|
||||
DefaultClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
req.URL = mustParseURL(srv.URL + req.URL.Path)
|
||||
return http.DefaultTransport.RoundTrip(req)
|
||||
})
|
||||
defer func() { DefaultClient = nil }()
|
||||
|
||||
RefreshCache("1.0.0")
|
||||
|
||||
// Verify cache was written
|
||||
info := CheckCached("1.0.0")
|
||||
if info == nil {
|
||||
t.Fatal("expected update info after refresh, got nil")
|
||||
}
|
||||
if info.Latest != "3.0.0" {
|
||||
t.Errorf("expected latest 3.0.0, got %s", info.Latest)
|
||||
}
|
||||
|
||||
// Second refresh should be no-op (cache is fresh) — won't hit network.
|
||||
RefreshCache("1.0.0")
|
||||
}
|
||||
|
||||
func TestPendingAtomicAccess(t *testing.T) {
|
||||
// Initially nil
|
||||
if got := GetPending(); got != nil {
|
||||
t.Errorf("expected nil, got %+v", got)
|
||||
}
|
||||
|
||||
info := &UpdateInfo{Current: "1.0.0", Latest: "2.0.0"}
|
||||
SetPending(info)
|
||||
|
||||
got := GetPending()
|
||||
if got == nil || got.Current != "1.0.0" || got.Latest != "2.0.0" {
|
||||
t.Errorf("unexpected pending: %+v", got)
|
||||
}
|
||||
|
||||
// Clean up for other tests
|
||||
SetPending(nil)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
105
scripts/build-pkg-pr-new.sh
Executable file
105
scripts/build-pkg-pr-new.sh
Executable file
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OUT_DIR="$ROOT_DIR/.pkg-pr-new"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
python3 scripts/fetch_meta.py
|
||||
|
||||
rm -rf "$OUT_DIR"
|
||||
mkdir -p "$OUT_DIR/bin" "$OUT_DIR/scripts"
|
||||
|
||||
VERSION="$(node -p "require('./package.json').version")"
|
||||
DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
SHA="$(git rev-parse --short HEAD)"
|
||||
LDFLAGS="-s -w -X github.com/larksuite/cli/internal/build.Version=${VERSION}-${SHA} -X github.com/larksuite/cli/internal/build.Date=${DATE}"
|
||||
|
||||
build_target() {
|
||||
local goos="$1"
|
||||
local goarch="$2"
|
||||
local ext=""
|
||||
if [[ "$goos" == "windows" ]]; then
|
||||
ext=".exe"
|
||||
fi
|
||||
|
||||
local output="$OUT_DIR/bin/lark-cli-${goos}-${goarch}${ext}"
|
||||
echo "Building ${goos}/${goarch} -> ${output}"
|
||||
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags "$LDFLAGS" -o "$output" ./main.go
|
||||
}
|
||||
|
||||
build_target darwin arm64
|
||||
build_target linux amd64
|
||||
build_target darwin amd64
|
||||
build_target linux arm64
|
||||
build_target windows amd64
|
||||
build_target windows arm64
|
||||
|
||||
cat > "$OUT_DIR/scripts/run.js" <<'RUNJS'
|
||||
#!/usr/bin/env node
|
||||
const path = require("path");
|
||||
const { execFileSync } = require("child_process");
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
const platformMap = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
};
|
||||
|
||||
// TODO: Keep broad platform mapping for now; with pkg.pr.new 20MB limit we only ship a subset of binaries.
|
||||
// Track upstream progress before tightening runtime handling: https://github.com/stackblitz-labs/pkg.pr.new/pull/484
|
||||
|
||||
const archMap = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64",
|
||||
};
|
||||
|
||||
const platform = platformMap[process.platform];
|
||||
const arch = archMap[process.arch];
|
||||
|
||||
if (!platform || !arch) {
|
||||
console.error(`Unsupported platform: ${process.platform}-${process.arch}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ext = isWindows ? ".exe" : "";
|
||||
const binary = path.join(__dirname, "..", "bin", `lark-cli-${platform}-${arch}${ext}`);
|
||||
|
||||
try {
|
||||
execFileSync(binary, process.argv.slice(2), { stdio: "inherit" });
|
||||
} catch (err) {
|
||||
process.exit(err.status || 1);
|
||||
}
|
||||
RUNJS
|
||||
|
||||
chmod +x "$OUT_DIR/scripts/run.js"
|
||||
|
||||
cat > "$OUT_DIR/package.json" <<EOF_JSON
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "${VERSION}-pr.${SHA}",
|
||||
"description": "The official CLI for Lark/Feishu open platform (PR preview build)",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/larksuite/cli.git"
|
||||
},
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"bin",
|
||||
"scripts/run.js",
|
||||
"CHANGELOG.md",
|
||||
"LICENSE"
|
||||
]
|
||||
}
|
||||
EOF_JSON
|
||||
|
||||
cp CHANGELOG.md "$OUT_DIR/CHANGELOG.md"
|
||||
cp LICENSE "$OUT_DIR/LICENSE"
|
||||
|
||||
echo "Prepared pkg.pr.new package at $OUT_DIR"
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
var BaseDataQuery = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+data-query",
|
||||
Description: "Query and analyze Bitable data with JSON DSL (aggregation, filter, sort)",
|
||||
Description: "Query and analyze Base data with JSON DSL (aggregation, filter, sort)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"base:table:read"},
|
||||
AuthTypes: authTypes(),
|
||||
|
||||
@@ -14,7 +14,7 @@ var BaseRecordHistoryList = common.Shortcut{
|
||||
Command: "+record-history-list",
|
||||
Description: "List record change history",
|
||||
Risk: "read",
|
||||
Scopes: []string{"base:record:read"},
|
||||
Scopes: []string{"base:history:read"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
|
||||
90
shortcuts/calendar/calendar_rsvp.go
Normal file
90
shortcuts/calendar/calendar_rsvp.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var CalendarRsvp = common.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+rsvp",
|
||||
Description: "Reply to a calendar event (accept/decline/tentative)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"calendar:calendar.event:reply"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: false,
|
||||
Flags: []common.Flag{
|
||||
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||
{Name: "event-id", Desc: "event ID", Required: true},
|
||||
{Name: "rsvp-status", Desc: "reply status", Required: true, Enum: []string{"accept", "decline", "tentative"}},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
|
||||
d := common.NewDryRunAPI()
|
||||
switch calendarId {
|
||||
case "":
|
||||
d.Desc("(calendar-id omitted) Will use primary calendar")
|
||||
calendarId = "<primary>"
|
||||
case "primary":
|
||||
calendarId = "<primary>"
|
||||
}
|
||||
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||
status := strings.TrimSpace(runtime.Str("rsvp-status"))
|
||||
|
||||
return d.
|
||||
POST("/open-apis/calendar/v4/calendars/:calendar_id/events/:event_id/reply").
|
||||
Body(map[string]interface{}{"rsvp_status": status}).
|
||||
Set("calendar_id", calendarId).
|
||||
Set("event_id", eventId)
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
for _, flag := range []string{"calendar-id", "event-id", "rsvp-status"} {
|
||||
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
|
||||
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
|
||||
return output.ErrValidation(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||
if eventId == "" {
|
||||
return output.ErrValidation("event-id cannot be empty")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
|
||||
if calendarId == "" {
|
||||
calendarId = PrimaryCalendarIDStr
|
||||
}
|
||||
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||
status := strings.TrimSpace(runtime.Str("rsvp-status"))
|
||||
|
||||
_, err := runtime.DoAPIJSON("POST",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s/reply",
|
||||
validate.EncodePathSegment(calendarId),
|
||||
validate.EncodePathSegment(eventId)),
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"rsvp_status": status,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"calendar_id": calendarId,
|
||||
"event_id": eventId,
|
||||
"rsvp_status": status,
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -580,6 +580,118 @@ func TestFreebusy_APIError(t *testing.T) {
|
||||
// CalendarSuggestion tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CalendarRsvp tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRsvp_Success(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/primary/events/evt_rsvp1/reply",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarRsvp, []string{
|
||||
"+rsvp",
|
||||
"--event-id", "evt_rsvp1",
|
||||
"--rsvp-status", "accept",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, want := range []string{`"event_id": "evt_rsvp1"`, `"rsvp_status": "accept"`} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Errorf("stdout should contain %s, got: %s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRsvp_InvalidStatus(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
err := mountAndRun(t, CalendarRsvp, []string{
|
||||
"+rsvp",
|
||||
"--event-id", "evt_rsvp1",
|
||||
"--rsvp-status", "invalid_status",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for invalid status, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid value") {
|
||||
t.Errorf("error should mention invalid value, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRsvp_APIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/primary/events/evt_rsvp1/reply",
|
||||
Body: map[string]interface{}{
|
||||
"code": 190001,
|
||||
"msg": "permission denied",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarRsvp, []string{
|
||||
"+rsvp",
|
||||
"--event-id", "evt_rsvp1",
|
||||
"--rsvp-status", "decline",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error for API failure, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRsvp_RejectsDangerousChars(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
err := mountAndRun(t, CalendarRsvp, []string{
|
||||
"+rsvp",
|
||||
"--event-id", "evt_rsvp1\u202e",
|
||||
"--rsvp-status", "accept",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for dangerous characters, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "dangerous Unicode") && !strings.Contains(err.Error(), "control character") {
|
||||
t.Errorf("error should mention dangerous input, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRsvp_DryRun_TrimmedPrimaryCalendar(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
err := mountAndRun(t, CalendarRsvp, []string{
|
||||
"+rsvp",
|
||||
"--calendar-id", " primary ",
|
||||
"--event-id", "evt_rsvp1",
|
||||
"--rsvp-status", "accept",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"calendar_id": "\u003cprimary\u003e"`) {
|
||||
t.Errorf("dry-run should normalize primary calendar, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestion_Success(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -867,17 +979,17 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
|
||||
// Shortcuts() registration test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestShortcuts_Returns4(t *testing.T) {
|
||||
func TestShortcuts_Returns5(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
if len(shortcuts) != 4 {
|
||||
t.Fatalf("expected 4 shortcuts, got %d", len(shortcuts))
|
||||
if len(shortcuts) != 5 {
|
||||
t.Fatalf("expected 5 shortcuts, got %d", len(shortcuts))
|
||||
}
|
||||
|
||||
names := map[string]bool{}
|
||||
for _, s := range shortcuts {
|
||||
names[s.Command] = true
|
||||
}
|
||||
for _, want := range []string{"+agenda", "+create", "+freebusy", "+suggestion"} {
|
||||
for _, want := range []string{"+agenda", "+create", "+freebusy", "+rsvp", "+suggestion"} {
|
||||
if !names[want] {
|
||||
t.Errorf("missing shortcut %s", want)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ func Shortcuts() []common.Shortcut {
|
||||
CalendarAgenda,
|
||||
CalendarCreate,
|
||||
CalendarFreebusy,
|
||||
CalendarRsvp,
|
||||
CalendarSuggestion,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,7 +418,7 @@ func (ctx *RuntimeContext) IO() *cmdutil.IOStreams {
|
||||
|
||||
// Out prints a success JSON envelope to stdout.
|
||||
func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) {
|
||||
env := output.Envelope{OK: true, Identity: string(ctx.As()), Data: data, Meta: meta}
|
||||
env := output.Envelope{OK: true, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()}
|
||||
b, _ := json.MarshalIndent(env, "", " ")
|
||||
fmt.Fprintln(ctx.IO().Out, string(b))
|
||||
}
|
||||
|
||||
@@ -447,6 +447,17 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesSearch invalid page limit", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"query": "incident",
|
||||
"page-limit": "41",
|
||||
}, nil)
|
||||
err := ImMessagesSearch.Validate(context.Background(), runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "--page-limit must be an integer between 1 and 40") {
|
||||
t.Fatalf("ImMessagesSearch.Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesSearch invalid sender id", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"sender": "user_1",
|
||||
@@ -479,6 +490,45 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestMessagesSearchPaginationConfig(t *testing.T) {
|
||||
t.Run("default single page", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, nil, nil)
|
||||
autoPaginate, pageLimit := messagesSearchPaginationConfig(runtime)
|
||||
if autoPaginate {
|
||||
t.Fatal("messagesSearchPaginationConfig() autoPaginate = true, want false")
|
||||
}
|
||||
if pageLimit != messagesSearchDefaultPageLimit {
|
||||
t.Fatalf("messagesSearchPaginationConfig() pageLimit = %d, want %d", pageLimit, messagesSearchDefaultPageLimit)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("page all uses max limit", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, nil, map[string]bool{
|
||||
"page-all": true,
|
||||
})
|
||||
autoPaginate, pageLimit := messagesSearchPaginationConfig(runtime)
|
||||
if !autoPaginate {
|
||||
t.Fatal("messagesSearchPaginationConfig() autoPaginate = false, want true")
|
||||
}
|
||||
if pageLimit != messagesSearchMaxPageLimit {
|
||||
t.Fatalf("messagesSearchPaginationConfig() pageLimit = %d, want %d", pageLimit, messagesSearchMaxPageLimit)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit page limit enables auto pagination", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"page-limit": "3",
|
||||
}, nil)
|
||||
autoPaginate, pageLimit := messagesSearchPaginationConfig(runtime)
|
||||
if !autoPaginate {
|
||||
t.Fatal("messagesSearchPaginationConfig() autoPaginate = false, want true")
|
||||
}
|
||||
if pageLimit != 3 {
|
||||
t.Fatalf("messagesSearchPaginationConfig() pageLimit = %d, want 3", pageLimit)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestShortcutDryRunShapes(t *testing.T) {
|
||||
t.Run("ImChatCreate dry run includes params and body", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
|
||||
@@ -101,6 +101,9 @@ func TestResolveMarkdownAsPost(t *testing.T) {
|
||||
if !strings.Contains(got, `#### Title`) || !strings.Contains(got, `##### Subtitle`) {
|
||||
t.Fatalf("resolveMarkdownAsPost() = %q, want optimized heading levels", got)
|
||||
}
|
||||
if strings.Contains(got, `<br>`) {
|
||||
t.Fatalf("resolveMarkdownAsPost() = %q, want no literal <br>", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateContentFlags(t *testing.T) {
|
||||
|
||||
@@ -619,31 +619,22 @@ func readMp4Duration(f *os.File, fileSize int64) int64 {
|
||||
// Steps:
|
||||
// 1. Extract code blocks with placeholders to protect them
|
||||
// 2. Downgrade headings: H1 → H4, H2~H6 → H5 (only when H1~H3 present)
|
||||
// 3. Add <br> between consecutive headings
|
||||
// 4. Add spacing around tables with <br>
|
||||
// 5. Restore code blocks with <br> wrappers
|
||||
// 6. Compress excess blank lines
|
||||
// 7. Strip invalid image references (keep only img_xxx keys)
|
||||
// 3. Normalize spacing between consecutive headings and tables with blank lines
|
||||
// 4. Restore code blocks
|
||||
// 5. Compress excess blank lines
|
||||
// 6. Strip invalid image references (keep only img_xxx keys)
|
||||
var (
|
||||
reH2toH6 = regexp.MustCompile(`(?m)^#{2,6} (.+)$`)
|
||||
reH1 = regexp.MustCompile(`(?m)^# (.+)$`)
|
||||
reHasH1toH3 = regexp.MustCompile(`(?m)^#{1,3} `)
|
||||
reConsecH = regexp.MustCompile(`(?m)^(#{4,5} .+)\n{1,2}(#{4,5} )`)
|
||||
reTableNoGap = regexp.MustCompile(`(?m)^([^|\n].*)\n(\|.+\|)`)
|
||||
reTableBefore = regexp.MustCompile(`\n\n((?:\|.+\|[^\S\n]*\n?)+)`)
|
||||
reTableAfter = regexp.MustCompile(`(?m)((?:^\|.+\|[^\S\n]*\n?)+)`)
|
||||
reTableTxtPre = regexp.MustCompile(`(?m)^([^\n]+)\n\n(<br>)\n\n(\|)`)
|
||||
reTableBoldPre = regexp.MustCompile(`(?m)^(\*\*.+)\n\n(<br>)\n\n(\|)`)
|
||||
reTableTxtPost = regexp.MustCompile(`(?m)(\|[^\n]*\n)\n(<br>\n)([^\n]+)`)
|
||||
reExcessNL = regexp.MustCompile(`\n{3,}`)
|
||||
reInvalidImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`)
|
||||
reCodeBlock = regexp.MustCompile("```[\\s\\S]*?```")
|
||||
reH2toH6 = regexp.MustCompile(`(?m)^#{2,6} (.+)$`)
|
||||
reH1 = regexp.MustCompile(`(?m)^# (.+)$`)
|
||||
reHasH1toH3 = regexp.MustCompile(`(?m)^#{1,3} `)
|
||||
reConsecH = regexp.MustCompile(`(?m)^(#{4,5} .+)\n{1,2}(#{4,5} )`)
|
||||
reTableNoGap = regexp.MustCompile(`(?m)^([^|\n].*)\n(\|.+\|)`)
|
||||
reTableAfter = regexp.MustCompile(`(?m)((?:^\|.+\|[^\S\n]*\n?)+)`)
|
||||
reExcessNL = regexp.MustCompile(`\n{3,}`)
|
||||
reInvalidImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`)
|
||||
reCodeBlock = regexp.MustCompile("```[\\s\\S]*?```")
|
||||
)
|
||||
|
||||
func isTableSpacingProtectedLine(line string) bool {
|
||||
return strings.HasPrefix(line, "#### ") || strings.HasPrefix(line, "##### ") || strings.HasPrefix(line, "**")
|
||||
}
|
||||
|
||||
func optimizeMarkdownStyle(text string) string {
|
||||
const mark = "___CB_"
|
||||
var codeBlocks []string
|
||||
@@ -659,29 +650,13 @@ func optimizeMarkdownStyle(text string) string {
|
||||
r = reH1.ReplaceAllString(r, "#### $1")
|
||||
}
|
||||
|
||||
r = reConsecH.ReplaceAllString(r, "$1\n<br>\n$2")
|
||||
r = reConsecH.ReplaceAllString(r, "$1\n\n$2")
|
||||
|
||||
r = reTableNoGap.ReplaceAllString(r, "$1\n\n$2")
|
||||
r = reTableBefore.ReplaceAllString(r, "\n\n<br>\n\n$1")
|
||||
r = reTableAfter.ReplaceAllString(r, "$1\n<br>\n")
|
||||
r = reTableTxtPre.ReplaceAllStringFunc(r, func(m string) string {
|
||||
sub := reTableTxtPre.FindStringSubmatch(m)
|
||||
if len(sub) != 4 || isTableSpacingProtectedLine(sub[1]) {
|
||||
return m
|
||||
}
|
||||
return sub[1] + "\n" + sub[2] + "\n" + sub[3]
|
||||
})
|
||||
r = reTableBoldPre.ReplaceAllString(r, "$1\n$2\n\n$3")
|
||||
r = reTableTxtPost.ReplaceAllStringFunc(r, func(m string) string {
|
||||
sub := reTableTxtPost.FindStringSubmatch(m)
|
||||
if len(sub) != 4 || isTableSpacingProtectedLine(sub[3]) {
|
||||
return m
|
||||
}
|
||||
return sub[1] + sub[2] + sub[3]
|
||||
})
|
||||
r = reTableAfter.ReplaceAllString(r, "$1\n")
|
||||
|
||||
for i, block := range codeBlocks {
|
||||
r = strings.Replace(r, fmt.Sprintf("%s%d___", mark, i), "\n<br>\n"+block+"\n<br>\n", 1)
|
||||
r = strings.Replace(r, fmt.Sprintf("%s%d___", mark, i), block, 1)
|
||||
}
|
||||
|
||||
r = reExcessNL.ReplaceAllString(r, "\n\n")
|
||||
|
||||
@@ -263,7 +263,7 @@ func TestDownloadIMResourceToPathSuccess(t *testing.T) {
|
||||
}))
|
||||
|
||||
target := filepath.Join(t.TempDir(), "nested", "resource.bin")
|
||||
size, err := downloadIMResourceToPath(context.Background(), runtime, "om_123", "file_123", "file", target)
|
||||
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_123", "file_123", "file", target)
|
||||
if err != nil {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
@@ -307,7 +307,7 @@ func TestDownloadIMResourceToPathHTTPErrorBody(t *testing.T) {
|
||||
}
|
||||
}))
|
||||
|
||||
_, err := downloadIMResourceToPath(context.Background(), runtime, "om_403", "file_403", "file", filepath.Join(t.TempDir(), "out.bin"))
|
||||
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_403", "file_403", "file", filepath.Join(t.TempDir(), "out.bin"))
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 403: denied") {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -282,7 +282,7 @@ func TestOptimizeMarkdownStyle(t *testing.T) {
|
||||
{
|
||||
name: "heading downgrade H1 and H2",
|
||||
input: "# Title\n## Section\ntext",
|
||||
want: "#### Title\n<br>\n##### Section\ntext",
|
||||
want: "#### Title\n\n##### Section\ntext",
|
||||
},
|
||||
{
|
||||
name: "no downgrade when no H1-H3",
|
||||
@@ -292,17 +292,17 @@ func TestOptimizeMarkdownStyle(t *testing.T) {
|
||||
{
|
||||
name: "code block protected",
|
||||
input: "# Title\n```\n# not a heading\n```\ntext",
|
||||
want: "#### Title\n\n<br>\n```\n# not a heading\n```\n<br>\n\ntext",
|
||||
want: "#### Title\n```\n# not a heading\n```\ntext",
|
||||
},
|
||||
{
|
||||
name: "table spacing",
|
||||
input: "text\n| A | B |\n| - | - |\n| 1 | 2 |\nafter",
|
||||
want: "text\n<br>\n| A | B |\n| - | - |\n| 1 | 2 |\n<br>\nafter",
|
||||
want: "text\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\nafter",
|
||||
},
|
||||
{
|
||||
name: "table spacing keeps heading separation",
|
||||
input: "# Title\n| A | B |\n| - | - |\n| 1 | 2 |\n## Next",
|
||||
want: "#### Title\n\n<br>\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n<br>\n##### Next",
|
||||
want: "#### Title\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n##### Next",
|
||||
},
|
||||
{
|
||||
name: "excess blank lines compressed",
|
||||
@@ -483,7 +483,7 @@ func TestDownloadIMResourceToPathHTTPClientError(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
_, err := downloadIMResourceToPath(context.Background(), runtime, "om_123", "img_123", "image", "out.bin")
|
||||
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_123", "img_123", "image", "out.bin")
|
||||
if err == nil || !strings.Contains(err.Error(), "http client unavailable") {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -72,12 +72,12 @@ var ImMessagesResourcesDownload = common.Shortcut{
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
|
||||
sizeBytes, err := downloadIMResourceToPath(ctx, runtime, messageId, fileKey, fileType, safePath)
|
||||
finalPath, sizeBytes, err := downloadIMResourceToPath(ctx, runtime, messageId, fileKey, fileType, safePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{"saved_path": safePath, "size_bytes": sizeBytes}, nil)
|
||||
runtime.Out(map[string]interface{}{"saved_path": finalPath, "size_bytes": sizeBytes}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -108,7 +108,38 @@ func normalizeDownloadOutputPath(fileKey, outputPath string) (string, error) {
|
||||
|
||||
const defaultIMResourceDownloadTimeout = 120 * time.Second
|
||||
|
||||
func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContext, messageID, fileKey, fileType, safePath string) (int64, error) {
|
||||
var imMimeToExt = map[string]string{
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
"application/pdf": ".pdf",
|
||||
"video/mp4": ".mp4",
|
||||
"video/3gpp": ".3gp",
|
||||
"video/x-msvideo": ".avi",
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/ogg": ".ogg",
|
||||
"audio/wav": ".wav",
|
||||
"text/plain": ".txt",
|
||||
"text/html": ".html",
|
||||
"text/css": ".css",
|
||||
"text/csv": ".csv",
|
||||
"application/zip": ".zip",
|
||||
"application/x-zip-compressed": ".zip",
|
||||
"application/x-rar-compressed": ".rar",
|
||||
"application/json": ".json",
|
||||
"application/xml": ".xml",
|
||||
"application/octet-stream": ".bin",
|
||||
"application/msword": ".doc",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
|
||||
"application/vnd.ms-excel": ".xls",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
||||
"application/vnd.ms-powerpoint": ".ppt",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
|
||||
}
|
||||
|
||||
func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContext, messageID, fileKey, fileType, safePath string) (string, int64, error) {
|
||||
query := larkcore.QueryParams{}
|
||||
query.Set("type", fileType)
|
||||
downloadResp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
@@ -121,24 +152,36 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex
|
||||
QueryParams: query,
|
||||
}, defaultIMResourceDownloadTimeout)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return "", 0, err
|
||||
}
|
||||
defer downloadResp.Body.Close()
|
||||
|
||||
if downloadResp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(io.LimitReader(downloadResp.Body, 4096))
|
||||
if len(body) > 0 {
|
||||
return 0, output.ErrNetwork("download failed: HTTP %d: %s", downloadResp.StatusCode, strings.TrimSpace(string(body)))
|
||||
return "", 0, output.ErrNetwork("download failed: HTTP %d: %s", downloadResp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return 0, output.ErrNetwork("download failed: HTTP %d", downloadResp.StatusCode)
|
||||
return "", 0, output.ErrNetwork("download failed: HTTP %d", downloadResp.StatusCode)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
|
||||
return 0, output.Errorf(output.ExitInternal, "api_error", "cannot create parent directory: %s", err)
|
||||
return "", 0, output.Errorf(output.ExitInternal, "api_error", "cannot create parent directory: %s", err)
|
||||
}
|
||||
sizeBytes, err := validate.AtomicWriteFromReader(safePath, downloadResp.Body, 0600)
|
||||
|
||||
// Auto-detect extension from Content-Type if missing
|
||||
finalPath := safePath
|
||||
if filepath.Ext(safePath) == "" {
|
||||
contentType := downloadResp.Header.Get("Content-Type")
|
||||
mimeType := strings.Split(contentType, ";")[0]
|
||||
mimeType = strings.TrimSpace(mimeType)
|
||||
if ext, ok := imMimeToExt[mimeType]; ok {
|
||||
finalPath = safePath + ext
|
||||
}
|
||||
}
|
||||
|
||||
sizeBytes, err := validate.AtomicWriteFromReader(finalPath, downloadResp.Body, 0600)
|
||||
if err != nil {
|
||||
return 0, output.Errorf(output.ExitInternal, "api_error", "cannot create file: %s", err)
|
||||
return "", 0, output.Errorf(output.ExitInternal, "api_error", "cannot create file: %s", err)
|
||||
}
|
||||
return sizeBytes, nil
|
||||
return finalPath, sizeBytes, nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -16,6 +17,15 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
messagesSearchDefaultPageSize = 20
|
||||
messagesSearchMaxPageSize = 50
|
||||
messagesSearchDefaultPageLimit = 20
|
||||
messagesSearchMaxPageLimit = 40
|
||||
messagesSearchMGetBatchSize = 50
|
||||
messagesSearchChatBatchSize = 50
|
||||
)
|
||||
|
||||
var ImMessagesSearch = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+messages-search",
|
||||
@@ -37,6 +47,8 @@ var ImMessagesSearch = common.Shortcut{
|
||||
{Name: "end", Desc: "end time(ISO 8601) with local timezone offset (e.g. 2026-03-25T23:59:59+08:00)"},
|
||||
{Name: "page-size", Default: "20", Desc: "page size (1-50)"},
|
||||
{Name: "page-token", Desc: "page token"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate search results"},
|
||||
{Name: "page-limit", Type: "int", Default: "20", Desc: "max search pages when auto-pagination is enabled (default 20, max 40)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
req, err := buildMessagesSearchRequest(runtime)
|
||||
@@ -49,8 +61,14 @@ var ImMessagesSearch = common.Shortcut{
|
||||
dryParams[k] = vs[0]
|
||||
}
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Step 1: search messages").
|
||||
autoPaginate, pageLimit := messagesSearchPaginationConfig(runtime)
|
||||
d := common.NewDryRunAPI()
|
||||
if autoPaginate {
|
||||
d = d.Desc(fmt.Sprintf("Step 1: search messages (auto-paginates up to %d page(s))", pageLimit))
|
||||
} else {
|
||||
d = d.Desc("Step 1: search messages")
|
||||
}
|
||||
return d.
|
||||
POST("/open-apis/im/v1/messages/search").
|
||||
Params(dryParams).
|
||||
Body(req.body).
|
||||
@@ -67,12 +85,10 @@ var ImMessagesSearch = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
searchData, err := runtime.DoAPIJSON(http.MethodPost, "/open-apis/im/v1/messages/search", req.params, req.body)
|
||||
rawItems, hasMore, nextPageToken, truncatedByLimit, pageLimit, err := searchMessages(runtime, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rawItems, _ := searchData["items"].([]interface{})
|
||||
hasMore, nextPageToken := common.PaginationMeta(searchData)
|
||||
|
||||
if len(rawItems) == 0 {
|
||||
outData := map[string]interface{}{
|
||||
@@ -99,8 +115,7 @@ var ImMessagesSearch = common.Shortcut{
|
||||
}
|
||||
|
||||
// ── Step 2: Batch fetch message details (mget) ──
|
||||
mgetURL := buildMGetURL(messageIds)
|
||||
mgetData, err := runtime.DoAPIJSON(http.MethodGet, mgetURL, nil, nil)
|
||||
msgItems, err := batchMGetMessages(runtime, messageIds)
|
||||
if err != nil {
|
||||
// Fallback when mget fails: return ID list only
|
||||
outData := map[string]interface{}{
|
||||
@@ -118,37 +133,22 @@ var ImMessagesSearch = common.Shortcut{
|
||||
})
|
||||
return nil
|
||||
}
|
||||
msgItems, _ := mgetData["items"].([]interface{})
|
||||
|
||||
// ── Step 3: Batch fetch chat info ──
|
||||
chatIdSet := map[string]bool{}
|
||||
chatIds := make([]string, 0, len(msgItems))
|
||||
chatSeen := make(map[string]bool)
|
||||
for _, item := range msgItems {
|
||||
m, _ := item.(map[string]interface{})
|
||||
if chatId, _ := m["chat_id"].(string); chatId != "" {
|
||||
chatIdSet[chatId] = true
|
||||
if !chatSeen[chatId] {
|
||||
chatSeen[chatId] = true
|
||||
chatIds = append(chatIds, chatId)
|
||||
}
|
||||
}
|
||||
}
|
||||
chatContexts := map[string]map[string]interface{}{}
|
||||
if len(chatIdSet) > 0 {
|
||||
chatIds := make([]string, 0, len(chatIdSet))
|
||||
for id := range chatIdSet {
|
||||
chatIds = append(chatIds, id)
|
||||
}
|
||||
chatRes, chatErr := runtime.DoAPIJSON(
|
||||
http.MethodPost, "/open-apis/im/v1/chats/batch_query",
|
||||
larkcore.QueryParams{"user_id_type": []string{"open_id"}},
|
||||
map[string]interface{}{"chat_ids": chatIds},
|
||||
)
|
||||
if chatErr == nil {
|
||||
if chatItems, ok := chatRes["items"].([]interface{}); ok {
|
||||
for _, ci := range chatItems {
|
||||
cm, _ := ci.(map[string]interface{})
|
||||
if cid, _ := cm["chat_id"].(string); cid != "" {
|
||||
chatContexts[cid] = cm
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(chatIds) > 0 {
|
||||
chatContexts = batchQueryChatContexts(runtime, chatIds)
|
||||
}
|
||||
|
||||
// ── Step 4: Format message content + attach chat context ──
|
||||
@@ -225,6 +225,9 @@ var ImMessagesSearch = common.Shortcut{
|
||||
moreHint = " (more available, use --page-token to fetch next page)"
|
||||
}
|
||||
fmt.Fprintf(w, "\n%d search result(s)%s\n", len(enriched), moreHint)
|
||||
if truncatedByLimit {
|
||||
fmt.Fprintf(w, "warning: stopped after fetching %d page(s); use --page-limit, --page-all, or --page-token to continue\n", pageLimit)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
@@ -247,6 +250,14 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
|
||||
endFlag := runtime.Str("end")
|
||||
pageSizeStr := runtime.Str("page-size")
|
||||
pageToken := runtime.Str("page-token")
|
||||
pageLimitStr := strings.TrimSpace(runtime.Str("page-limit"))
|
||||
|
||||
if runtime.Cmd != nil && runtime.Cmd.Flags().Changed("page-limit") {
|
||||
pageLimit, err := strconv.Atoi(pageLimitStr)
|
||||
if err != nil || pageLimit < 1 || pageLimit > messagesSearchMaxPageLimit {
|
||||
return nil, output.ErrValidation("--page-limit must be an integer between 1 and 40")
|
||||
}
|
||||
}
|
||||
|
||||
filter := map[string]interface{}{}
|
||||
timeRange := map[string]interface{}{}
|
||||
@@ -322,14 +333,14 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
|
||||
body["filter"] = filter
|
||||
}
|
||||
|
||||
pageSize := 20
|
||||
pageSize := messagesSearchDefaultPageSize
|
||||
if pageSizeStr != "" {
|
||||
n, err := strconv.Atoi(pageSizeStr)
|
||||
if err != nil || n < 1 {
|
||||
return nil, output.ErrValidation("--page-size must be an integer between 1 and 50")
|
||||
}
|
||||
if n > 50 {
|
||||
n = 50
|
||||
if n > messagesSearchMaxPageSize {
|
||||
n = messagesSearchMaxPageSize
|
||||
}
|
||||
pageSize = n
|
||||
}
|
||||
@@ -346,3 +357,124 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
|
||||
body: body,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func messagesSearchPaginationConfig(runtime *common.RuntimeContext) (autoPaginate bool, pageLimit int) {
|
||||
autoPaginate = runtime.Bool("page-all")
|
||||
if runtime.Cmd != nil && runtime.Cmd.Flags().Changed("page-limit") {
|
||||
autoPaginate = true
|
||||
}
|
||||
|
||||
pageLimit = messagesSearchDefaultPageLimit
|
||||
if runtime.Cmd != nil && runtime.Cmd.Flags().Changed("page-limit") {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(runtime.Str("page-limit"))); err == nil && n > 0 {
|
||||
pageLimit = min(n, messagesSearchMaxPageLimit)
|
||||
}
|
||||
} else if runtime.Bool("page-all") {
|
||||
pageLimit = messagesSearchMaxPageLimit
|
||||
}
|
||||
return autoPaginate, pageLimit
|
||||
}
|
||||
|
||||
func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest) ([]interface{}, bool, string, bool, int, error) {
|
||||
autoPaginate, pageLimit := messagesSearchPaginationConfig(runtime)
|
||||
pageToken := ""
|
||||
if tokens := req.params["page_token"]; len(tokens) > 0 {
|
||||
pageToken = tokens[0]
|
||||
}
|
||||
|
||||
pageSize := strconv.Itoa(messagesSearchDefaultPageSize)
|
||||
if sizes := req.params["page_size"]; len(sizes) > 0 {
|
||||
pageSize = sizes[0]
|
||||
}
|
||||
|
||||
var (
|
||||
allItems []interface{}
|
||||
lastHasMore bool
|
||||
lastPageToken string
|
||||
truncatedByLimit bool
|
||||
pageCount int
|
||||
)
|
||||
|
||||
for {
|
||||
pageCount++
|
||||
params := larkcore.QueryParams{
|
||||
"page_size": []string{pageSize},
|
||||
}
|
||||
if pageToken != "" {
|
||||
params["page_token"] = []string{pageToken}
|
||||
}
|
||||
|
||||
searchData, err := runtime.DoAPIJSON(http.MethodPost, "/open-apis/im/v1/messages/search", params, req.body)
|
||||
if err != nil {
|
||||
return nil, false, "", false, pageLimit, err
|
||||
}
|
||||
|
||||
items, _ := searchData["items"].([]interface{})
|
||||
allItems = append(allItems, items...)
|
||||
lastHasMore, lastPageToken = common.PaginationMeta(searchData)
|
||||
|
||||
if !autoPaginate || !lastHasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
if pageCount >= pageLimit {
|
||||
truncatedByLimit = true
|
||||
break
|
||||
}
|
||||
|
||||
pageToken = lastPageToken
|
||||
}
|
||||
|
||||
return allItems, lastHasMore, lastPageToken, truncatedByLimit, pageLimit, nil
|
||||
}
|
||||
|
||||
func batchMGetMessages(runtime *common.RuntimeContext, messageIds []string) ([]interface{}, error) {
|
||||
var items []interface{}
|
||||
for _, batch := range chunkStrings(messageIds, messagesSearchMGetBatchSize) {
|
||||
mgetData, err := runtime.DoAPIJSON(http.MethodGet, buildMGetURL(batch), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
batchItems, _ := mgetData["items"].([]interface{})
|
||||
items = append(items, batchItems...)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func batchQueryChatContexts(runtime *common.RuntimeContext, chatIds []string) map[string]map[string]interface{} {
|
||||
chatContexts := map[string]map[string]interface{}{}
|
||||
for _, batch := range chunkStrings(chatIds, messagesSearchChatBatchSize) {
|
||||
chatRes, chatErr := runtime.DoAPIJSON(
|
||||
http.MethodPost, "/open-apis/im/v1/chats/batch_query",
|
||||
larkcore.QueryParams{"user_id_type": []string{"open_id"}},
|
||||
map[string]interface{}{"chat_ids": batch},
|
||||
)
|
||||
if chatErr != nil {
|
||||
continue
|
||||
}
|
||||
if chatItems, ok := chatRes["items"].([]interface{}); ok {
|
||||
for _, ci := range chatItems {
|
||||
cm, _ := ci.(map[string]interface{})
|
||||
if cid, _ := cm["chat_id"].(string); cid != "" {
|
||||
chatContexts[cid] = cm
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return chatContexts
|
||||
}
|
||||
|
||||
func chunkStrings(items []string, chunkSize int) [][]string {
|
||||
if len(items) == 0 || chunkSize <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
chunks := make([][]string, 0, (len(items)+chunkSize-1)/chunkSize)
|
||||
for start := 0; start < len(items); start += chunkSize {
|
||||
end := start + chunkSize
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
chunks = append(chunks, items[start:end])
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
285
shortcuts/im/im_messages_search_execute_test.go
Normal file
285
shortcuts/im/im_messages_search_execute_test.go
Normal file
@@ -0,0 +1,285 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newMessagesSearchRuntime(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool, rt http.RoundTripper) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
runtime := newBotShortcutRuntime(t, rt)
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
|
||||
stringFlagNames := []string{
|
||||
"query",
|
||||
"page-size",
|
||||
"page-token",
|
||||
"page-limit",
|
||||
}
|
||||
for _, name := range stringFlagNames {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
boolFlagNames := []string{"page-all"}
|
||||
for _, name := range boolFlagNames {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags() error = %v", err)
|
||||
}
|
||||
for name, value := range stringFlags {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("Flags().Set(%q) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
for name, value := range boolFlags {
|
||||
if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[value]); err != nil {
|
||||
t.Fatalf("Flags().Set(%q) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
runtime.Cmd = cmd
|
||||
runtime.Format = "pretty"
|
||||
return runtime
|
||||
}
|
||||
|
||||
func TestImMessagesSearchExecuteAutoPaginationBatches(t *testing.T) {
|
||||
var (
|
||||
searchPageTokens []string
|
||||
mgetBatchSizes []int
|
||||
chatBatchSizes []int
|
||||
)
|
||||
|
||||
runtime := newMessagesSearchRuntime(t, map[string]string{
|
||||
"query": "incident",
|
||||
"page-limit": "2",
|
||||
}, map[string]bool{
|
||||
"page-all": true,
|
||||
}, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "tenant_access_token"):
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"tenant_access_token": "tenant-token",
|
||||
"expire": 7200,
|
||||
}), nil
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/search"):
|
||||
pageToken := req.URL.Query().Get("page_token")
|
||||
searchPageTokens = append(searchPageTokens, pageToken)
|
||||
switch pageToken {
|
||||
case "":
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": buildSearchResultItems(1, 50),
|
||||
"has_more": true,
|
||||
"page_token": "tok_p2",
|
||||
},
|
||||
}), nil
|
||||
case "tok_p2":
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": buildSearchResultItems(51, 55),
|
||||
"has_more": true,
|
||||
"page_token": "tok_p3",
|
||||
},
|
||||
}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected search page_token: %q", pageToken)
|
||||
}
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/mget"):
|
||||
ids := req.URL.Query()["message_ids"]
|
||||
mgetBatchSizes = append(mgetBatchSizes, len(ids))
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": buildMessageDetails(ids),
|
||||
},
|
||||
}), nil
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/batch_query"):
|
||||
var body struct {
|
||||
ChatIDs []string `json:"chat_ids"`
|
||||
}
|
||||
rawBody, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll() error = %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(rawBody, &body); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
chatBatchSizes = append(chatBatchSizes, len(body.ChatIDs))
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": buildChatContexts(body.ChatIDs),
|
||||
},
|
||||
}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
}))
|
||||
|
||||
if err := ImMessagesSearch.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("ImMessagesSearch.Execute() error = %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(searchPageTokens, []string{"", "tok_p2"}) {
|
||||
t.Fatalf("search page tokens = %#v, want %#v", searchPageTokens, []string{"", "tok_p2"})
|
||||
}
|
||||
if !reflect.DeepEqual(mgetBatchSizes, []int{50, 5}) {
|
||||
t.Fatalf("mget batch sizes = %#v, want %#v", mgetBatchSizes, []int{50, 5})
|
||||
}
|
||||
if !reflect.DeepEqual(chatBatchSizes, []int{50, 5}) {
|
||||
t.Fatalf("chat batch sizes = %#v, want %#v", chatBatchSizes, []int{50, 5})
|
||||
}
|
||||
|
||||
outBuf, _ := runtime.Factory.IOStreams.Out.(*bytes.Buffer)
|
||||
if outBuf == nil {
|
||||
t.Fatal("stdout buffer missing")
|
||||
}
|
||||
output := outBuf.String()
|
||||
if !strings.Contains(output, "55 search result(s)") {
|
||||
t.Fatalf("stdout = %q, want search results summary", output)
|
||||
}
|
||||
if !strings.Contains(output, "warning: stopped after fetching 2 page(s)") {
|
||||
t.Fatalf("stdout = %q, want page limit warning", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImMessagesSearchExecuteExplicitPageLimitWithoutPageAll(t *testing.T) {
|
||||
var searchCalls int
|
||||
|
||||
runtime := newMessagesSearchRuntime(t, map[string]string{
|
||||
"query": "incident",
|
||||
"page-limit": "2",
|
||||
}, nil, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "tenant_access_token"):
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"tenant_access_token": "tenant-token",
|
||||
"expire": 7200,
|
||||
}), nil
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/search"):
|
||||
searchCalls++
|
||||
pageToken := req.URL.Query().Get("page_token")
|
||||
if searchCalls == 1 {
|
||||
if pageToken != "" {
|
||||
return nil, fmt.Errorf("unexpected first page token: %q", pageToken)
|
||||
}
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": buildSearchResultItems(1, 1),
|
||||
"has_more": true,
|
||||
"page_token": "tok_p2",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
if pageToken != "tok_p2" {
|
||||
return nil, fmt.Errorf("unexpected second page token: %q", pageToken)
|
||||
}
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": buildSearchResultItems(2, 2),
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
}), nil
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/mget"):
|
||||
ids := req.URL.Query()["message_ids"]
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": buildMessageDetails(ids),
|
||||
},
|
||||
}), nil
|
||||
case strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/batch_query"):
|
||||
var body struct {
|
||||
ChatIDs []string `json:"chat_ids"`
|
||||
}
|
||||
rawBody, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll() error = %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(rawBody, &body); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": buildChatContexts(body.ChatIDs),
|
||||
},
|
||||
}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
}))
|
||||
|
||||
if err := ImMessagesSearch.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("ImMessagesSearch.Execute() error = %v", err)
|
||||
}
|
||||
if searchCalls != 2 {
|
||||
t.Fatalf("searchCalls = %d, want 2", searchCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func buildSearchResultItems(start, end int) []interface{} {
|
||||
items := make([]interface{}, 0, end-start+1)
|
||||
for i := start; i <= end; i++ {
|
||||
items = append(items, map[string]interface{}{
|
||||
"meta_data": map[string]interface{}{
|
||||
"message_id": fmt.Sprintf("om_%03d", i),
|
||||
},
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func buildMessageDetails(ids []string) []interface{} {
|
||||
items := make([]interface{}, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
suffix := strings.TrimPrefix(id, "om_")
|
||||
items = append(items, map[string]interface{}{
|
||||
"message_id": id,
|
||||
"msg_type": "text",
|
||||
"create_time": "1710000000",
|
||||
"chat_id": "oc_" + suffix,
|
||||
"sender": map[string]interface{}{
|
||||
"id": "cli_bot",
|
||||
"name": "Bot",
|
||||
"sender_type": "bot",
|
||||
},
|
||||
"body": map[string]interface{}{
|
||||
"content": fmt.Sprintf(`{"text":"message %s"}`, suffix),
|
||||
},
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func buildChatContexts(chatIDs []string) []interface{} {
|
||||
items := make([]interface{}, 0, len(chatIDs))
|
||||
for _, chatID := range chatIDs {
|
||||
items = append(items, map[string]interface{}{
|
||||
"chat_id": chatID,
|
||||
"chat_mode": "group",
|
||||
"name": "Chat " + strings.TrimPrefix(chatID, "oc_"),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
@@ -188,7 +188,7 @@ var ImMessagesSend = common.Shortcut{
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve content type
|
||||
if markdown != "" {
|
||||
msgType, content = "post", resolveMarkdownAsPost(ctx, runtime, markdown)
|
||||
} else if mt, c, err := resolveMediaContent(ctx, runtime, text, imageVal, fileVal, videoVal, videoCoverVal, audioVal); err != nil {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -1854,6 +1855,33 @@ func checkAttachmentSizeLimit(filePaths []string, extraBytes int64, extraCount .
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateConfirmSendScope checks that the user's token includes the
|
||||
// mail:user_mailbox.message:send scope when --confirm-send is set.
|
||||
// This scope is not declared in the shortcut's static Scopes (to keep the
|
||||
// default draft-only path accessible without the sensitive send permission),
|
||||
// so we validate it dynamically here.
|
||||
func validateConfirmSendScope(runtime *common.RuntimeContext) error {
|
||||
if !runtime.Bool("confirm-send") {
|
||||
return nil
|
||||
}
|
||||
appID := runtime.Config.AppID
|
||||
userOpenId := runtime.UserOpenId()
|
||||
if appID == "" || userOpenId == "" {
|
||||
return nil
|
||||
}
|
||||
stored := auth.GetStoredToken(appID, userOpenId)
|
||||
if stored == nil {
|
||||
return nil
|
||||
}
|
||||
required := []string{"mail:user_mailbox.message:send"}
|
||||
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
fmt.Sprintf("--confirm-send requires scope: %s", strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to grant the send permission", strings.Join(missing, " ")))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateComposeHasAtLeastOneRecipient(to, cc, bcc string) error {
|
||||
if strings.TrimSpace(to) == "" && strings.TrimSpace(cc) == "" && strings.TrimSpace(bcc) == "" {
|
||||
return fmt.Errorf("at least one recipient (--to, --cc, or --bcc) is required")
|
||||
|
||||
52
shortcuts/mail/mail_confirm_send_scope_test.go
Normal file
52
shortcuts/mail/mail_confirm_send_scope_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestConfirmSendMissingScopeReply(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailReply, []string{
|
||||
"+reply", "--message-id", "msg_001", "--body", "hello", "--confirm-send",
|
||||
}, f, stdout)
|
||||
assertMissingSendScope(t, err)
|
||||
}
|
||||
|
||||
func TestConfirmSendMissingScopeReplyAll(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailReplyAll, []string{
|
||||
"+reply-all", "--message-id", "msg_001", "--body", "hello", "--confirm-send",
|
||||
}, f, stdout)
|
||||
assertMissingSendScope(t, err)
|
||||
}
|
||||
|
||||
func TestConfirmSendMissingScopeForward(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailForward, []string{
|
||||
"+forward", "--message-id", "msg_001", "--to", "alice@example.com", "--confirm-send",
|
||||
}, f, stdout)
|
||||
assertMissingSendScope(t, err)
|
||||
}
|
||||
|
||||
func assertMissingSendScope(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when token lacks send scope with --confirm-send, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Errorf("expected exit code %d (ExitAuth), got %d", output.ExitAuth, exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "missing_scope" {
|
||||
t.Errorf("expected detail type missing_scope, got %+v", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,8 @@ var MailDraftCreate = common.Shortcut{
|
||||
{Name: "cc", Desc: "Optional. Full Cc recipient list. Separate multiple addresses with commas. Display-name format is supported."},
|
||||
{Name: "bcc", Desc: "Optional. Full Bcc recipient list. Separate multiple addresses with commas. Display-name format is supported."},
|
||||
{Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring HTML auto-detection. Cannot be used with --inline."},
|
||||
{Name: "attach", Desc: "Optional. Regular attachment file paths. Separate multiple paths with commas. Each path must point to a readable local file."},
|
||||
{Name: "inline", Desc: "Optional. Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<local-path>\"}. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
{Name: "attach", Desc: "Optional. Regular attachment file paths (relative path only). Separate multiple paths with commas. Each path must point to a readable local file."},
|
||||
{Name: "inline", Desc: "Optional. Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
input, err := parseDraftCreateInput(runtime)
|
||||
|
||||
@@ -32,7 +32,7 @@ var MailDraftEdit = common.Shortcut{
|
||||
{Name: "set-to", Desc: "Replace the entire To recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."},
|
||||
{Name: "set-cc", Desc: "Replace the entire Cc recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."},
|
||||
{Name: "set-bcc", Desc: "Replace the entire Bcc recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."},
|
||||
{Name: "patch-file", Desc: "Edit entry point for body edits, incremental recipient changes, header edits, attachment changes, or inline-image changes. All body edits MUST go through --patch-file. Two body ops: set_body (full replacement including quote) and set_reply_body (replaces only user-authored content, auto-preserves quote block). Run --inspect first to check has_quoted_content, then --print-patch-template for the JSON structure."},
|
||||
{Name: "patch-file", Desc: "Edit entry point for body edits, incremental recipient changes, header edits, attachment changes, or inline-image changes. All body edits MUST go through --patch-file. Two body ops: set_body (full replacement including quote) and set_reply_body (replaces only user-authored content, auto-preserves quote block). Run --inspect first to check has_quoted_content, then --print-patch-template for the JSON structure. Relative path only."},
|
||||
{Name: "print-patch-template", Type: "bool", Desc: "Print the JSON template and supported operations for the --patch-file flag. Recommended first step before generating a patch file. No draft read or write is performed."},
|
||||
{Name: "inspect", Type: "bool", Desc: "Inspect the draft without modifying it. Returns the draft projection including subject, recipients, body summary, has_quoted_content (whether the draft contains a reply/forward quote block), attachments_summary (with part_id and cid for each attachment), and inline_summary. Run this BEFORE editing body to check has_quoted_content: if true, use set_reply_body in --patch-file to preserve the quote; if false, use set_body."},
|
||||
},
|
||||
@@ -307,10 +307,10 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
|
||||
{"op": "set_reply_body", "shape": map[string]interface{}{"value": "string (user-authored content only, WITHOUT the quote block; the quote block is re-appended automatically)"}},
|
||||
{"op": "set_header", "shape": map[string]interface{}{"name": "string", "value": "string"}},
|
||||
{"op": "remove_header", "shape": map[string]interface{}{"name": "string"}},
|
||||
{"op": "add_attachment", "shape": map[string]interface{}{"path": "string"}},
|
||||
{"op": "add_attachment", "shape": map[string]interface{}{"path": "string(relative path)"}},
|
||||
{"op": "remove_attachment", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}},
|
||||
{"op": "add_inline", "shape": map[string]interface{}{"path": "string", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}},
|
||||
{"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}},
|
||||
{"op": "add_inline", "shape": map[string]interface{}{"path": "string(relative path)", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}},
|
||||
{"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string(relative path)", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}},
|
||||
{"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}},
|
||||
},
|
||||
"supported_ops_by_group": []map[string]interface{}{
|
||||
@@ -340,10 +340,10 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
|
||||
{
|
||||
"group": "attachments_and_inline",
|
||||
"ops": []map[string]interface{}{
|
||||
{"op": "add_attachment", "shape": map[string]interface{}{"path": "string"}},
|
||||
{"op": "add_attachment", "shape": map[string]interface{}{"path": "string(relative path)"}},
|
||||
{"op": "remove_attachment", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}},
|
||||
{"op": "add_inline", "shape": map[string]interface{}{"path": "string", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}},
|
||||
{"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}},
|
||||
{"op": "add_inline", "shape": map[string]interface{}{"path": "string(relative path)", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}},
|
||||
{"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string(relative path)", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}},
|
||||
{"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}},
|
||||
},
|
||||
},
|
||||
@@ -360,6 +360,7 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
|
||||
},
|
||||
"notes": []string{
|
||||
"`ops` is executed in order",
|
||||
"all file paths (--patch-file and `path` fields in ops) must be relative — no absolute paths or .. traversal",
|
||||
"all body edits MUST go through --patch-file; there is no --set-body flag",
|
||||
"`set_body` replaces the ENTIRE body including any reply/forward quote block; when the draft has both text/plain and text/html, it updates the HTML body and regenerates the plain-text summary, so the input should be HTML",
|
||||
"`set_reply_body` replaces only the user-authored portion of the body and automatically re-appends the trailing reply/forward quote block (generated by +reply or +forward); the value you pass should contain ONLY the new user-authored content WITHOUT the quote block — the quote block will be re-inserted automatically; if the user wants to modify content INSIDE the quote block, use `set_body` instead for full replacement; if the draft has no quote block, it behaves identically to `set_body`",
|
||||
|
||||
@@ -20,7 +20,7 @@ var MailForward = common.Shortcut{
|
||||
Command: "+forward",
|
||||
Description: "Forward a message and save as draft (default). Use --confirm-send to send immediately after user confirmation. Original message block included automatically.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"mail:user_mailbox.message:send", "mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "message-id", Desc: "Required. Message ID to forward", Required: true},
|
||||
@@ -30,8 +30,8 @@ var MailForward = common.Shortcut{
|
||||
{Name: "cc", Desc: "CC email address(es), comma-separated"},
|
||||
{Name: "bcc", Desc: "BCC email address(es), comma-separated"},
|
||||
{Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring all HTML auto-detection. Cannot be used with --inline."},
|
||||
{Name: "attach", Desc: "Attachment file path(s), comma-separated (appended after original attachments)"},
|
||||
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<local-path>\"}. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
{Name: "attach", Desc: "Attachment file path(s), comma-separated, appended after original attachments (relative path only)"},
|
||||
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
{Name: "confirm-send", Type: "bool", Desc: "Send the forward immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -55,6 +55,9 @@ var MailForward = common.Shortcut{
|
||||
return api
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateConfirmSendScope(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Bool("confirm-send") {
|
||||
if err := validateComposeHasAtLeastOneRecipient(runtime.Str("to"), runtime.Str("cc"), runtime.Str("bcc")); err != nil {
|
||||
return err
|
||||
|
||||
@@ -18,7 +18,7 @@ var MailReply = common.Shortcut{
|
||||
Command: "+reply",
|
||||
Description: "Reply to a message and save as draft (default). Use --confirm-send to send immediately after user confirmation. Sets Re: subject, In-Reply-To, and References headers automatically.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"mail:user_mailbox.message:send", "mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "message-id", Desc: "Required. Message ID to reply to", Required: true},
|
||||
@@ -28,8 +28,8 @@ var MailReply = common.Shortcut{
|
||||
{Name: "cc", Desc: "Additional CC email address(es), comma-separated"},
|
||||
{Name: "bcc", Desc: "BCC email address(es), comma-separated"},
|
||||
{Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring all HTML auto-detection. Cannot be used with --inline."},
|
||||
{Name: "attach", Desc: "Attachment file path(s), comma-separated"},
|
||||
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<local-path>\"}. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
|
||||
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
{Name: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -52,6 +52,9 @@ var MailReply = common.Shortcut{
|
||||
return api
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateConfirmSendScope(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateComposeInlineAndAttachments(runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -18,7 +18,7 @@ var MailReplyAll = common.Shortcut{
|
||||
Command: "+reply-all",
|
||||
Description: "Reply to all recipients and save as draft (default). Use --confirm-send to send immediately after user confirmation. Includes all original To and CC automatically.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"mail:user_mailbox.message:send", "mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "message-id", Desc: "Required. Message ID to reply to all recipients", Required: true},
|
||||
@@ -29,8 +29,8 @@ var MailReplyAll = common.Shortcut{
|
||||
{Name: "bcc", Desc: "BCC email address(es), comma-separated"},
|
||||
{Name: "remove", Desc: "Address(es) to exclude from the outgoing reply, comma-separated"},
|
||||
{Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring all HTML auto-detection. Cannot be used with --inline."},
|
||||
{Name: "attach", Desc: "Attachment file path(s), comma-separated"},
|
||||
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<local-path>\"}. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
|
||||
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
{Name: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -53,6 +53,9 @@ var MailReplyAll = common.Shortcut{
|
||||
return api
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateConfirmSendScope(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateComposeInlineAndAttachments(runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -28,8 +28,8 @@ var MailSend = common.Shortcut{
|
||||
{Name: "cc", Desc: "CC email address(es), comma-separated"},
|
||||
{Name: "bcc", Desc: "BCC email address(es), comma-separated"},
|
||||
{Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring HTML auto-detection. Cannot be used with --inline."},
|
||||
{Name: "attach", Desc: "Attachment file path(s), comma-separated"},
|
||||
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<local-path>\"}. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
{Name: "attach", Desc: "Attachment file path(s), comma-separated (relative path only)"},
|
||||
{Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"<unique-id>\",\"file_path\":\"<relative-path>\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via <img src=\"cid:...\"> in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."},
|
||||
{Name: "confirm-send", Type: "bool", Desc: "Send the email immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
|
||||
@@ -42,7 +42,7 @@ func mailShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *by
|
||||
RefreshToken: "test-refresh-token",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
|
||||
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
|
||||
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:send mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly",
|
||||
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly",
|
||||
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
|
||||
}
|
||||
if err := auth.SetStoredToken(token); err != nil {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Bitable Formula Writing Guide
|
||||
# Base Formula Writing Guide
|
||||
|
||||
## Mandatory Read Acknowledgement
|
||||
|
||||
@@ -121,7 +121,7 @@ When using comparison operators (`>`, `>=`, `<`, `<=`, `=`, `!=`), **both sides
|
||||
|
||||
## Section 4: Operators
|
||||
|
||||
Bitable formulas **only allow** the following operators. `like`, `in`, `<>`, `**`, `^` etc. are prohibited.
|
||||
Base formulas **only allow** the following operators. `like`, `in`, `<>`, `**`, `^` etc. are prohibited.
|
||||
|
||||
| Category | Operators | Description |
|
||||
| ------------- | -------------------------- | -------------------------------------------------------------------------- |
|
||||
|
||||
@@ -24,6 +24,11 @@ lark-cli base +field-create \
|
||||
--base-token app_xxx \
|
||||
--table-id tbl_xxx \
|
||||
--json '{"name":"状态","type":"select","multiple":false,"options":[{"name":"Todo","hue":"Blue","lightness":"Lighter"},{"name":"Done","hue":"Green","lightness":"Light"}]}'
|
||||
|
||||
lark-cli base +field-create \
|
||||
--base-token app_xxx \
|
||||
--table-id tbl_xxx \
|
||||
--json '{"name":"负责人","type":"user","multiple":false,"description":"用于标记记录的直接负责人;协作约定可参考[团队字段约定](https://example.com/field-spec)"}'
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -33,7 +38,6 @@ lark-cli base +field-create \
|
||||
| `--base-token <token>` | 是 | Base Token |
|
||||
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
|
||||
| `--json <body>` | 是 | 字段属性 JSON 对象 |
|
||||
|
||||
## API 入参详情
|
||||
|
||||
**HTTP 方法和路径:**
|
||||
@@ -46,6 +50,7 @@ POST /open-apis/base/v3/bases/:base_token/tables/:table_id/fields
|
||||
|
||||
- `--json` 必须是 **JSON 对象**,顶层直接传字段定义,不要再套一层。
|
||||
- 顶层最少包含:`name`、`type`。
|
||||
- 如需字段说明,直接传 `description`;支持纯文本,也支持 Markdown 链接,如 `协作约定可参考[团队字段约定](https://example.com/field-spec)`。
|
||||
- `type` 不同,必填子字段不同:
|
||||
- `select`:用 `multiple` + `options`(`options` 里只传 `name/hue/lightness`,不要传 `id`)。
|
||||
- `link`:必须有 `link_table`,可选 `bidirectional`、`bidirectional_link_field_name`。
|
||||
@@ -66,6 +71,17 @@ POST /open-apis/base/v3/bases/:base_token/tables/:table_id/fields
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "负责人",
|
||||
"type": "user",
|
||||
"multiple": false,
|
||||
"description": "用于标记记录的直接负责人;协作约定可参考[团队字段约定](https://example.com/field-spec)"
|
||||
}
|
||||
```
|
||||
|
||||
## 返回重点
|
||||
|
||||
- 返回 `field` 和 `created: true`。
|
||||
@@ -78,7 +94,7 @@ POST /open-apis/base/v3/bases/:base_token/tables/:table_id/fields
|
||||
## 坑点
|
||||
|
||||
- ⚠️ 这是写入操作,执行前必须确认。
|
||||
- ⚠️ 当 `--json.type` 是 `formula` 或 `lookup` 时,先读对应 guide,再创建。
|
||||
- ⚠️ 当 `type` 是 `formula` 或 `lookup` 时,先读对应 guide,再创建。
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -12,6 +12,12 @@ lark-cli base +field-update \
|
||||
--table-id tbl_xxx \
|
||||
--field-id fld_xxx \
|
||||
--json '{"name":"状态","type":"select","multiple":false,"options":[{"name":"Todo","hue":"Blue","lightness":"Lighter"},{"name":"Doing","hue":"Orange","lightness":"Light"},{"name":"Done","hue":"Green","lightness":"Light"}]}'
|
||||
|
||||
lark-cli base +field-update \
|
||||
--base-token app_xxx \
|
||||
--table-id tbl_xxx \
|
||||
--field-id fld_xxx \
|
||||
--json '{"name":"负责人","type":"user","multiple":false,"description":"用于标记记录的直接负责人;协作约定可参考[团队字段约定](https://example.com/field-spec)"}'
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -22,7 +28,6 @@ lark-cli base +field-update \
|
||||
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
|
||||
| `--field-id <id_or_name>` | 是 | 字段 ID 或字段名 |
|
||||
| `--json <body>` | 是 | 字段属性 JSON 对象 |
|
||||
|
||||
## API 入参详情
|
||||
|
||||
**HTTP 方法和路径:**
|
||||
@@ -35,6 +40,7 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
|
||||
|
||||
- `--json` 必须是 **JSON 对象**,顶层直接传字段定义。
|
||||
- 更新语义是 `PUT`(全量字段配置更新),不要只传零散片段;至少显式包含 `name`、`type`,并补齐该类型所需关键配置。
|
||||
- 如需字段说明,直接传 `description`;支持纯文本,也支持 Markdown 链接,如 `协作约定可参考[团队字段约定](https://example.com/field-spec)`。
|
||||
- `select` 更新时:`options` 仍按对象数组传,避免混入无效字段。
|
||||
- `link` 更新限制:
|
||||
- 不能把非 `link` 字段改成 `link`,也不能把 `link` 改成非 `link`。
|
||||
@@ -55,6 +61,17 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "负责人",
|
||||
"type": "user",
|
||||
"multiple": false,
|
||||
"description": "用于标记记录的直接负责人;协作约定可参考[团队字段约定](https://example.com/field-spec)"
|
||||
}
|
||||
```
|
||||
|
||||
## 返回重点
|
||||
|
||||
- 返回 `field` 和 `updated: true`。
|
||||
@@ -69,7 +86,7 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
|
||||
|
||||
- ⚠️ 这是全量字段属性更新语义,不是 patch。
|
||||
- ⚠️ 这是写入操作,执行前必须确认。
|
||||
- ⚠️ 当 `--json.type` 是 `formula` 或 `lookup` 时,先阅读对应指南再执行。
|
||||
- ⚠️ 当 `type` 是 `formula` 或 `lookup` 时,先阅读对应指南再执行。
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -8,15 +8,24 @@
|
||||
|
||||
- `--json` 必须是 JSON 对象。
|
||||
- 顶层统一使用:`type` + `name` + 类型特有字段。
|
||||
- 如需字段说明,直接传 `description`;支持纯文本,也支持 Markdown 链接。
|
||||
- 不要使用旧结构:`field_name`、`property`、`ui_type`、数字枚举 `type`。
|
||||
- `+field-update` 是 `PUT` 语义,建议先 `+field-get` 再全量提交目标字段配置。
|
||||
- `type=formula` 或 `type=lookup` 创建时,必须先读对应 guide。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "text",
|
||||
"name": "需求背景",
|
||||
"description": "记录需求背景与已知约束;填写口径可参考[说明模板](https://example.com/spec)"
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 各类型格式与示例
|
||||
|
||||
### 2.1 text
|
||||
|
||||
**要求**:`name` 必填;`style.type` 可选,默认 `plain`。
|
||||
**要求**:`name` 必填;可选传 `description`;`style.type` 可选,默认 `plain`。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -36,6 +45,7 @@
|
||||
"properties": {
|
||||
"type": { "type": "string", "const": "text", "description": "Text field type" },
|
||||
"name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" },
|
||||
"description": { "type": "string", "description": "Field description; supports plain text or Markdown links" },
|
||||
"style": {
|
||||
"type": "object",
|
||||
"properties": { "type": { "type": "string", "enum": ["plain", "phone", "url", "email", "barcode"], "description": "Text style type" } },
|
||||
@@ -101,6 +111,7 @@
|
||||
"properties": {
|
||||
"type": { "type": "string", "const": "number", "description": "Number field type" },
|
||||
"name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" },
|
||||
"description": { "type": "string", "description": "Field description; supports plain text or Markdown links" },
|
||||
"style": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -197,6 +208,7 @@
|
||||
"properties": {
|
||||
"type": { "type": "string", "const": "select", "description": "Select field type" },
|
||||
"name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" },
|
||||
"description": { "type": "string", "description": "Field description; supports plain text or Markdown links" },
|
||||
"multiple": { "type": "boolean", "default": false, "description": "Allow multiple" },
|
||||
"options": {
|
||||
"type": "array",
|
||||
@@ -250,6 +262,7 @@
|
||||
"properties": {
|
||||
"type": { "type": "string", "const": "datetime", "description": "Date time type" },
|
||||
"name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" },
|
||||
"description": { "type": "string", "description": "Field description; supports plain text or Markdown links" },
|
||||
"style": {
|
||||
"type": "object",
|
||||
"properties": { "format": { "type": "string", "enum": ["yyyy/MM/dd", "yyyy/MM/dd HH:mm", "yyyy/MM/dd HH:mm Z", "yyyy-MM-dd", "yyyy-MM-dd HH:mm", "yyyy-MM-dd HH:mm Z", "MM-dd", "MM/dd/yyyy", "dd/MM/yyyy"], "default": "yyyy/MM/dd", "description": "Date format" } },
|
||||
@@ -273,6 +286,7 @@
|
||||
"properties": {
|
||||
"type": { "type": "string", "const": "created_at", "description": "Created time type" },
|
||||
"name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" },
|
||||
"description": { "type": "string", "description": "Field description; supports plain text or Markdown links" },
|
||||
"style": {
|
||||
"type": "object",
|
||||
"properties": { "format": { "type": "string", "enum": ["yyyy/MM/dd", "yyyy/MM/dd HH:mm", "yyyy/MM/dd HH:mm Z", "yyyy-MM-dd", "yyyy-MM-dd HH:mm", "yyyy-MM-dd HH:mm Z", "MM-dd", "MM/dd/yyyy", "dd/MM/yyyy"], "default": "yyyy/MM/dd", "description": "Date format" } },
|
||||
@@ -296,6 +310,7 @@
|
||||
"properties": {
|
||||
"type": { "type": "string", "const": "updated_at", "description": "Modified time type" },
|
||||
"name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" },
|
||||
"description": { "type": "string", "description": "Field description; supports plain text or Markdown links" },
|
||||
"style": {
|
||||
"type": "object",
|
||||
"properties": { "format": { "type": "string", "enum": ["yyyy/MM/dd", "yyyy/MM/dd HH:mm", "yyyy/MM/dd HH:mm Z", "yyyy-MM-dd", "yyyy-MM-dd HH:mm", "yyyy-MM-dd HH:mm Z", "MM-dd", "MM/dd/yyyy", "dd/MM/yyyy"], "default": "yyyy/MM/dd", "description": "Date format" } },
|
||||
@@ -330,7 +345,7 @@
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": { "type": { "type": "string", "const": "user", "description": "User field type" }, "name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" }, "multiple": { "type": "boolean", "default": true, "description": "Allow multiple" } },
|
||||
"properties": { "type": { "type": "string", "const": "user", "description": "User field type" }, "name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" }, "description": { "type": "string", "description": "Field description; supports plain text or Markdown links" }, "multiple": { "type": "boolean", "default": true, "description": "Allow multiple" } },
|
||||
"required": ["type", "name"],
|
||||
"additionalProperties": false,
|
||||
"description": "User field",
|
||||
@@ -343,7 +358,7 @@
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": { "type": { "type": "string", "const": "created_by", "description": "Created by type" }, "name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" } },
|
||||
"properties": { "type": { "type": "string", "const": "created_by", "description": "Created by type" }, "name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" }, "description": { "type": "string", "description": "Field description; supports plain text or Markdown links" } },
|
||||
"required": ["type", "name"],
|
||||
"additionalProperties": false,
|
||||
"description": "Created by field",
|
||||
@@ -356,7 +371,7 @@
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": { "type": { "type": "string", "const": "updated_by", "description": "Modified by type" }, "name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" } },
|
||||
"properties": { "type": { "type": "string", "const": "updated_by", "description": "Modified by type" }, "name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" }, "description": { "type": "string", "description": "Field description; supports plain text or Markdown links" } },
|
||||
"required": ["type", "name"],
|
||||
"additionalProperties": false,
|
||||
"description": "Modified by field",
|
||||
@@ -386,6 +401,7 @@
|
||||
"properties": {
|
||||
"type": { "type": "string", "const": "link", "description": "Link field type" },
|
||||
"name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" },
|
||||
"description": { "type": "string", "description": "Field description; supports plain text or Markdown links" },
|
||||
"link_table": { "type": "string", "minLength": 1, "maxLength": 100, "description": "Linked table" },
|
||||
"bidirectional": { "type": "boolean", "default": false, "description": "Bidirectional link" },
|
||||
"bidirectional_link_field_name": { "$ref": "#/properties/name", "description": "Bidirectional link field name" }
|
||||
@@ -414,7 +430,7 @@
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": { "type": { "type": "string", "const": "formula", "description": "Formula field type" }, "name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" }, "expression": { "type": "string", "description": "Formula expression" } },
|
||||
"properties": { "type": { "type": "string", "const": "formula", "description": "Formula field type" }, "name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" }, "description": { "type": "string", "description": "Field description; supports plain text or Markdown links" }, "expression": { "type": "string", "description": "Formula expression" } },
|
||||
"required": ["type", "name", "expression"],
|
||||
"additionalProperties": false,
|
||||
"description": "Formula field",
|
||||
@@ -451,6 +467,7 @@
|
||||
"properties": {
|
||||
"type": { "type": "string", "const": "lookup", "description": "Lookup field type" },
|
||||
"name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" },
|
||||
"description": { "type": "string", "description": "Field description; supports plain text or Markdown links" },
|
||||
"from": { "type": "string", "minLength": 1, "maxLength": 100, "description": "Source data table" },
|
||||
"select": { "type": "string", "minLength": 1, "maxLength": 100, "description": "Field to aggregate from source table" },
|
||||
"where": {
|
||||
@@ -545,6 +562,7 @@
|
||||
"properties": {
|
||||
"type": { "type": "string", "const": "auto_number", "description": "Auto number type" },
|
||||
"name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" },
|
||||
"description": { "type": "string", "description": "Field description; supports plain text or Markdown links" },
|
||||
"style": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -620,7 +638,7 @@
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": { "type": { "type": "string", "const": "attachment", "description": "Attachment field type" }, "name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" } },
|
||||
"properties": { "type": { "type": "string", "const": "attachment", "description": "Attachment field type" }, "name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" }, "description": { "type": "string", "description": "Field description; supports plain text or Markdown links" } },
|
||||
"required": ["type", "name"],
|
||||
"additionalProperties": false,
|
||||
"description": "Attachment field",
|
||||
@@ -633,7 +651,7 @@
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": { "type": { "type": "string", "const": "location", "description": "Location field type" }, "name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" } },
|
||||
"properties": { "type": { "type": "string", "const": "location", "description": "Location field type" }, "name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" }, "description": { "type": "string", "description": "Field description; supports plain text or Markdown links" } },
|
||||
"required": ["type", "name"],
|
||||
"additionalProperties": false,
|
||||
"description": "Location field",
|
||||
@@ -646,7 +664,7 @@
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": { "type": { "type": "string", "const": "checkbox", "description": "Checkbox field type" }, "name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" } },
|
||||
"properties": { "type": { "type": "string", "const": "checkbox", "description": "Checkbox field type" }, "name": { "type": "string", "minLength": 1, "maxLength": 1000, "description": "Field name" }, "description": { "type": "string", "description": "Field description; supports plain text or Markdown links" } },
|
||||
"required": ["type", "name"],
|
||||
"additionalProperties": false,
|
||||
"description": "Checkbox field",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Bitable Lookup Field Configuration Guide
|
||||
# Base Lookup Field Configuration Guide
|
||||
|
||||
## Mandatory Read Acknowledgement
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-calendar
|
||||
version: 1.0.0
|
||||
description: "飞书日历(calendar):提供日历与日程(会议)的全面管理能力。核心场景包括:查看/搜索日程、创建/更新日程、管理参会人、查询忙闲状态及推荐空闲时段。高频操作请优先使用 Shortcuts:+agenda(快速概览今日/近期行程)、+create(创建日程并按需邀请参会人)、+freebusy(查询用户主日历的忙闲信息和rsvp的状态)、+suggestion(针对时间未确定的预约日程需求,提供多个时间推荐方案)。"
|
||||
description: "飞书日历(calendar):提供日历与日程(会议)的全面管理能力。核心场景包括:查看/搜索日程、创建/更新日程、管理参会人、查询忙闲状态及推荐空闲时段。高频操作请优先使用 Shortcuts:+agenda(快速概览今日/近期行程)、+create(创建日程并按需邀请参会人)、+freebusy(查询用户主日历的忙闲信息和rsvp的状态)、+rsvp(回复日程邀请)、+suggestion(针对时间未确定的预约日程需求,提供多个时间推荐方案)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -81,6 +81,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli calendar +<verb> [flags]`
|
||||
| [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) |
|
||||
| [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人(ISO 8601 时间) |
|
||||
| [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和rsvp的状态 |
|
||||
| [`+rsvp`](references/lark-calendar-rsvp.md) | 回复日程(接受/拒绝/待定) |
|
||||
| [`+suggestion`](references/lark-calendar-suggestion.md) | 针对时间未确定的预约日程需求,提供多个时间推荐方案 |
|
||||
|
||||
## +suggestion 使用
|
||||
|
||||
42
skills/lark-calendar/references/lark-calendar-rsvp.md
Normal file
42
skills/lark-calendar/references/lark-calendar-rsvp.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# calendar +rsvp
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
回复指定的日程,更新当前用户的 RSVP 状态(接受、拒绝或待定)。
|
||||
|
||||
需要的scopes: ["calendar:calendar.event:reply"]
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 回复日程为接受 (使用主日历)
|
||||
lark-cli calendar +rsvp --event-id evt_xxx --rsvp-status accept
|
||||
|
||||
# 回复日程为拒绝
|
||||
lark-cli calendar +rsvp --event-id evt_xxx --rsvp-status decline
|
||||
|
||||
# 回复日程为待定
|
||||
lark-cli calendar +rsvp --event-id evt_xxx --rsvp-status tentative
|
||||
|
||||
# 指定其他日历下的日程
|
||||
lark-cli calendar +rsvp --calendar-id cal_xxx --event-id evt_xxx --rsvp-status accept
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--event-id <id>` | **是** | 日程 ID |
|
||||
| `--rsvp-status <status>` | **是** | 回复状态,可选值:`accept` (接受), `decline` (拒绝), `tentative` (待定) |
|
||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用主日历) |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 提示
|
||||
|
||||
- 只能回复你被邀请的日程。
|
||||
- 调用前通常需要通过 `+agenda` 等命令获取到具体的 `event-id`。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-calendar](../SKILL.md) -- 日历全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
@@ -437,7 +437,7 @@ lark-cli docs +create --title "空白画板示例" --markdown '<whiteboard type=
|
||||
- 读取时只能获取 token,可通过 media-download 查看内容,无法直接读出画板内部内容
|
||||
- 画板编辑:详见 [SKILL.md](../SKILL.md#重要说明画板编辑)
|
||||
|
||||
### 多维表格(Bitable)
|
||||
### 多维表格(Base)
|
||||
|
||||
```html
|
||||
<bitable view="table"/>
|
||||
|
||||
@@ -63,7 +63,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
|
||||
| [`+messages-mget`](references/lark-im-messages-mget.md) | Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies |
|
||||
| [`+messages-reply`](references/lark-im-messages-reply.md) | Reply to a message (supports thread replies) with bot identity; bot-only; supports text/markdown/post/media replies, reply-in-thread, idempotency key |
|
||||
| [`+messages-resources-download`](references/lark-im-messages-resources-download.md) | Download images/files from a message; user/bot; downloads image/file resources by message-id and file-key to a safe relative output path |
|
||||
| [`+messages-search`](references/lark-im-messages-search.md) | Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, enriches results via mget and chats batch_query |
|
||||
| [`+messages-search`](references/lark-im-messages-search.md) | Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, supports auto-pagination via `--page-all` / `--page-limit`, enriches results via batched mget and chats batch_query |
|
||||
| [`+messages-send`](references/lark-im-messages-send.md) | Send a message to a chat or direct message with bot identity; bot-only; sends to chat-id or user-id with text/markdown/post/media, supports idempotency key |
|
||||
| [`+threads-messages-list`](references/lark-im-threads-messages-list.md) | List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination |
|
||||
|
||||
@@ -136,4 +136,3 @@ lark-cli im <resource> <method> [flags] # 调用 API
|
||||
| `pins.create` | `im:message.pins:write_only` |
|
||||
| `pins.delete` | `im:message.pins:write_only` |
|
||||
| `pins.list` | `im:message.pins:read` |
|
||||
|
||||
|
||||
@@ -18,10 +18,88 @@ Replies sent by this tool are visible to other people. Before calling it, you **
|
||||
|
||||
When using `--as bot`, the reply is sent in the app's name, so make sure the app has already been added to the target chat.
|
||||
|
||||
## Choose The Right Content Flag
|
||||
|
||||
| Need | Recommended flag | Why |
|
||||
|------|------|------|
|
||||
| Reply with plain text exactly as written | `--text` | Wrapped directly to `{"text":"..."}` |
|
||||
| Reply with simple Markdown and accept conversion | `--markdown` | Automatically converted to `post` JSON |
|
||||
| Precisely control the reply payload | `--content` | You provide the exact JSON |
|
||||
| Reply with media | `--image` / `--file` / `--video` / `--audio` | Shortcut uploads local files automatically |
|
||||
|
||||
### `--text` vs `--markdown`
|
||||
|
||||
- Use `--text` when the reply should remain plain text and you want exact control over line breaks, spacing, indentation, code samples, or literal Markdown characters.
|
||||
- Use `--markdown` when you want a lightweight formatted reply and you accept that the shortcut will normalize and rewrite parts of the content before sending.
|
||||
- Use `--content` when you need exact `post` JSON, a card, a title, multiple locales, or any structure that `--markdown` cannot express reliably.
|
||||
|
||||
## What `--markdown` Really Does
|
||||
|
||||
`--markdown` does **not** send arbitrary raw Markdown to the API.
|
||||
|
||||
The shortcut:
|
||||
|
||||
1. Forces `msg_type=post`
|
||||
2. Resolves remote Markdown images like ``
|
||||
3. Normalizes the Markdown for Feishu post rendering
|
||||
4. Wraps the final content as:
|
||||
|
||||
```json
|
||||
{"zh_cn":{"content":[[{"tag":"md","text":"..."}]]}}
|
||||
```
|
||||
|
||||
So `--markdown` is a convenience mode, not a full Markdown compatibility layer.
|
||||
|
||||
### Current Markdown Caveats
|
||||
|
||||
- It does **not** promise full CommonMark / GitHub Flavored Markdown support.
|
||||
- It always becomes a `post` payload with a single `zh_cn` locale.
|
||||
- It does **not** let you set a `post` title.
|
||||
- Headings are rewritten:
|
||||
- `# Title` becomes `#### Title`
|
||||
- `##` to `######` are normalized to `#####` when the content contains H1-H3
|
||||
- Consecutive headings are separated with blank lines after heading normalization.
|
||||
- Block spacing and line breaks may be normalized during conversion.
|
||||
- Code blocks are preserved as code blocks.
|
||||
- Excess blank lines are compressed.
|
||||
- Only remote `http://...`, `https://...`, or already-uploaded `img_xxx` Markdown images are kept reliably.
|
||||
- Local paths in Markdown image syntax like `` are **not** auto-uploaded by `--markdown`.
|
||||
- If remote Markdown image handling fails, that image is removed with a warning.
|
||||
|
||||
If you need exact output, use `--msg-type post --content ...` instead of `--markdown`.
|
||||
|
||||
## Preserving Formatting
|
||||
|
||||
If the reply contains multiple lines, code blocks, indentation, tabs, or a lot of escaping, prefer `$'...'`.
|
||||
|
||||
### When formatting must be preserved
|
||||
|
||||
Use `--text` plus `$'...'`:
|
||||
|
||||
```bash
|
||||
lark-cli im +messages-reply --message-id om_xxx --text $'Received\nI will check this today.\nOwner: alice'
|
||||
```
|
||||
|
||||
```bash
|
||||
lark-cli im +messages-reply --message-id om_xxx --text $'```sql\nselect * from jobs;\n```'
|
||||
```
|
||||
|
||||
This keeps the reply as plain text instead of converting it to a `post`.
|
||||
|
||||
### When formatting does not need exact preservation
|
||||
|
||||
Use `--markdown`:
|
||||
|
||||
```bash
|
||||
lark-cli im +messages-reply --message-id om_xxx --markdown $'## Follow-up\n\n- I reproduced it\n- I am fixing it'
|
||||
```
|
||||
|
||||
This is better for quick readable formatting, but the final payload may still differ from the source text because headings and spacing are normalized before sending.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Reply to a message (plain text, bot identity, --text is recommended)
|
||||
# Reply to a message (plain text, --text is recommended for normal replies)
|
||||
lark-cli im +messages-reply --message-id om_xxx --text "Received"
|
||||
|
||||
# Equivalent manual JSON
|
||||
@@ -30,13 +108,16 @@ lark-cli im +messages-reply --message-id om_xxx --content '{"text":"Received"}'
|
||||
# Reply as a bot
|
||||
lark-cli im +messages-reply --message-id om_xxx --text "bot reply" --as bot
|
||||
|
||||
# Reply with preserved multi-line text
|
||||
lark-cli im +messages-reply --message-id om_xxx --text $'Line 1\nLine 2\n indented line'
|
||||
|
||||
# Reply inside the thread (message appears in the target thread)
|
||||
lark-cli im +messages-reply --message-id om_xxx --text "Let's discuss this" --reply-in-thread
|
||||
|
||||
# Bot identity + thread reply
|
||||
lark-cli im +messages-reply --message-id om_xxx --text "bot reply" --as bot --reply-in-thread
|
||||
# Reply with basic Markdown (will be converted to post JSON)
|
||||
lark-cli im +messages-reply --message-id om_xxx --markdown $'## Reply\n\n- item 1\n- item 2'
|
||||
|
||||
# Reply with a rich-text message
|
||||
# If you need exact post structure, send JSON directly
|
||||
lark-cli im +messages-reply --message-id om_xxx --msg-type post --content '{"zh_cn":{"title":"Reply","content":[[{"tag":"text","text":"Detailed content"}]]}}'
|
||||
|
||||
# Reply with a local image (uploaded automatically before sending)
|
||||
@@ -52,7 +133,7 @@ lark-cli im +messages-reply --message-id om_xxx --video ./demo.mp4 --video-cover
|
||||
lark-cli im +messages-reply --message-id om_xxx --text "Received" --idempotency-key my-unique-id
|
||||
|
||||
# Preview the request without executing it
|
||||
lark-cli im +messages-reply --message-id om_xxx --text "Test" --dry-run
|
||||
lark-cli im +messages-reply --message-id om_xxx --markdown $'## Test\n\nhello' --dry-run
|
||||
```
|
||||
|
||||
## Parameters
|
||||
@@ -60,15 +141,15 @@ lark-cli im +messages-reply --message-id om_xxx --text "Test" --dry-run
|
||||
| Parameter | Required | Description |
|
||||
|------|------|------|
|
||||
| `--message-id <id>` | Yes | ID of the message being replied to (`om_xxx`) |
|
||||
| `--msg-type <type>` | No | Message type (default `text`): `text`, `post`, `image`, `file`, `audio`, `media`, `interactive`, `share_chat`, `share_user` |
|
||||
| `--content <json>` | One of content options | Reply content as a JSON string; format depends on `msg_type` |
|
||||
| `--text <string>` | One of content options | Plain text message (automatically wrapped as `{"text":"..."}` JSON) |
|
||||
| `--markdown <string>` | One of content options | Markdown text (auto-wrapped as post format with style optimization; image URLs auto-resolved) |
|
||||
| `--image <path\|key>` | One of content options | Local image path, `image_key` (`img_xxx`)|
|
||||
| `--file <path\|key>` | One of content options | Local file path, `file_key` (`file_xxx`)|
|
||||
| `--video <path\|key>` | One of content options | Local video path, `file_key`; **must be used together with `--video-cover`** |
|
||||
| `--video-cover <path\|key>` | **Required with `--video`** | Video cover image path, `image_key` (`img_xxx`) |
|
||||
| `--audio <path\|key>` | One of content options | Local audio path, `file_key` |
|
||||
| `--msg-type <type>` | No | Message type (default `text`). If you use `--text` / `--markdown` / media flags, the effective type is inferred automatically. Explicitly setting a conflicting `--msg-type` fails validation |
|
||||
| `--content <json>` | One content option | Exact reply content as JSON. The JSON must match the effective `--msg-type` |
|
||||
| `--text <string>` | One content option | Plain text reply. Best default when you need exact text and formatting preservation |
|
||||
| `--markdown <string>` | One content option | Convenience Markdown input. Internally converted to `post` JSON with Feishu-specific normalization |
|
||||
| `--image <path\|key>` | One content option | Local image path or `image_key` (`img_xxx`) |
|
||||
| `--file <path\|key>` | One content option | Local file path or `file_key` (`file_xxx`) |
|
||||
| `--video <path\|key>` | One content option | Local video path or `file_key`; **must be used together with `--video-cover`** |
|
||||
| `--video-cover <path\|key>` | **Required with `--video`** | Video cover image path or `image_key` (`img_xxx`) |
|
||||
| `--audio <path\|key>` | One content option | Local audio path or `file_key` |
|
||||
| `--reply-in-thread` | No | Reply inside the thread. The reply appears in the target message's thread instead of the main chat stream |
|
||||
| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one reply within 1 hour |
|
||||
| `--as <identity>` | No | Identity type: `bot` only |
|
||||
@@ -78,6 +159,15 @@ lark-cli im +messages-reply --message-id om_xxx --text "Test" --dry-run
|
||||
>
|
||||
> **Video cover rule:** `--video` **must** be accompanied by `--video-cover`. Omitting `--video-cover` when using `--video` will fail validation. `--video-cover` cannot be used without `--video`.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
- Choosing `--markdown` when you actually need exact plain text. If exact line breaks and spacing matter, use `--text`, usually with `$'...'`.
|
||||
- Assuming `--markdown` supports all Markdown features. It does not; it is converted into a Feishu `post` payload and rewritten first.
|
||||
- Putting local image paths inside Markdown like ``. `--markdown` does not auto-upload those paths.
|
||||
- Using `--content` without making the JSON match the effective `--msg-type`.
|
||||
- Explicitly setting `--msg-type` to something that conflicts with `--text`, `--markdown`, or media flags.
|
||||
- Mixing `--text`, `--markdown`, or `--content` with media flags in one command.
|
||||
|
||||
## Return Value
|
||||
|
||||
```json
|
||||
@@ -108,16 +198,22 @@ The reply appears in the target message's thread and does not show up in the mai
|
||||
|
||||
## @Mention Format (text / post)
|
||||
|
||||
- @specific user: `<at user_id="ou_xxx">name</at>`
|
||||
- Recommended format: `<at user_id="ou_xxx">name</at>`
|
||||
- @all: `<at user_id="all"></at>`
|
||||
- The shortcut normalizes common variants like `<at id=...>` and `<at open_id=...>` into `user_id`, but `user_id` remains the recommended documented form
|
||||
|
||||
## Notes
|
||||
|
||||
- `--message-id` must be a valid message ID in `om_xxx` format
|
||||
- `--content` must be a valid JSON string
|
||||
- `--reply-in-thread` is only meaningful in group chats
|
||||
- `--image`/`--file`/`--video`/`--audio`/`--video-cover` support local file paths; use relative paths within the current working directory. The shortcut automatically uploads the file first and then sends the reply
|
||||
- If the provided value starts with `img_` or `file_`, it is treated as an existing key and used directly
|
||||
- When using `--video`, `--video-cover` is **required** as the video cover. Omitting `--video-cover` with `--video` will produce a validation error. `--video-cover` cannot be used without `--video`
|
||||
- `--content` must be valid JSON
|
||||
- When using `--content`, you are responsible for making the JSON structure match the effective `msg_type`
|
||||
- `--reply-in-thread` adds `reply_in_thread=true` to the API request
|
||||
- `--reply-in-thread` is mainly meaningful in chats that support thread replies
|
||||
- `--image`/`--file`/`--video`/`--audio`/`--video-cover` support local file paths; the shortcut uploads first and then sends the reply
|
||||
- If the provided media value starts with `img_` or `file_`, it is treated as an existing key and used directly
|
||||
- `--markdown` always sends `msg_type=post`
|
||||
- If you explicitly set `--msg-type` and it conflicts with the chosen content flag, validation fails
|
||||
- When using `--video`, `--video-cover` is required as the video cover
|
||||
- `--dry-run` uses placeholder image keys for remote Markdown images and placeholder media keys for local uploads
|
||||
- Failures return error codes and messages
|
||||
- `--as bot` uses a tenant access token (TAT), and requires the `im:message:send_as_bot` scope
|
||||
- `--as bot` uses a tenant access token (TAT), and requires the `im:message:send_as_bot` scope
|
||||
@@ -6,7 +6,7 @@ Search Feishu messages across conversations. This shortcut automatically perform
|
||||
|
||||
> **User identity only** (`--as user`). Bot identity is not supported.
|
||||
|
||||
This skill maps to the shortcut: `lark-cli im +messages-search` (internally calls `POST /open-apis/im/v1/messages/search` + `GET /open-apis/im/v1/messages/mget`, then batch-fetches chat context).
|
||||
This skill maps to the shortcut: `lark-cli im +messages-search` (internally calls `POST /open-apis/im/v1/messages/search` + batched `GET /open-apis/im/v1/messages/mget`, then batch-fetches chat context).
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -49,6 +49,12 @@ lark-cli im +messages-search --query "test" --format csv
|
||||
# Pagination
|
||||
lark-cli im +messages-search --query "test" --page-token <PAGE_TOKEN>
|
||||
|
||||
# Auto-pagination across multiple pages
|
||||
lark-cli im +messages-search --query "test" --page-all --format json
|
||||
|
||||
# Auto-pagination with an explicit page cap
|
||||
lark-cli im +messages-search --query "test" --page-limit 5 --format json
|
||||
|
||||
# Preview the request without executing it
|
||||
lark-cli im +messages-search --query "test" --dry-run
|
||||
```
|
||||
@@ -69,6 +75,8 @@ lark-cli im +messages-search --query "test" --dry-run
|
||||
| `--end <time>` | No | End time with local timezone offset required (e.g. `2026-03-25T23:59:59+08:00`) |
|
||||
| `--page-size <n>` | No | Page size (default 20, range 1-50) |
|
||||
| `--page-token <token>` | No | Pagination token for the next page |
|
||||
| `--page-all` | No | Automatically paginate through all result pages (up to 40 pages) |
|
||||
| `--page-limit <n>` | No | Max pages to fetch when auto-pagination is enabled (default 20, max 40). Setting it explicitly also enables auto-pagination |
|
||||
| `--format <fmt>` | No | Output format: `json` (default) / `pretty` / `table` / `ndjson` / `csv` |
|
||||
| `--as <identity>` | No | Identity type (defaults to and only supports `user`) |
|
||||
| `--dry-run` | No | Print the request only, do not execute it |
|
||||
@@ -85,8 +93,9 @@ The shortcut automatically performs:
|
||||
|
||||
1. The **search API** returns matching `message_id` values
|
||||
2. The **mget API** fetches full message content for those message IDs in batch
|
||||
3. Chat context lookup is fetched in batch and attached to each message
|
||||
|
||||
The user does not need to manage the orchestration manually.
|
||||
The user does not need to manage the orchestration manually. When search results span multiple pages, the shortcut can also paginate automatically with `--page-all` or `--page-limit`.
|
||||
|
||||
### 3. Conversation context is enriched automatically
|
||||
|
||||
@@ -115,7 +124,15 @@ Each message in JSON output contains:
|
||||
| `mentions` | Array of @mentions in the message; each item contains `{id, key, name}`. Present only when the message contains @mentions |
|
||||
| `thread_id` | Thread ID (`omt_xxx`) if the message has replies in a thread. Present only when replies exist |
|
||||
|
||||
### 4. Search results contain follow-up clues
|
||||
### 4. Pagination behavior
|
||||
|
||||
- Default behavior is still **single-page**.
|
||||
- `--page-token` is the manual continuation mechanism when you already have a token from a previous response.
|
||||
- `--page-all` enables auto-pagination and uses a default cap of **40 pages**.
|
||||
- `--page-limit <n>` enables auto-pagination with an explicit cap. If you pass `--page-limit` without `--page-all`, auto-pagination is still enabled.
|
||||
- When auto-pagination stops because of the configured page cap, the response still includes the last `has_more` / `page_token` so you can continue manually.
|
||||
|
||||
### 5. Search results contain follow-up clues
|
||||
|
||||
In JSON output, each message includes `chat_id` and `thread_id` (when present). Use them with other shortcuts for deeper inspection:
|
||||
|
||||
@@ -156,25 +173,28 @@ When the user asks you to summarize work, generate a weekly report, or compile a
|
||||
### Strategy
|
||||
|
||||
1. **Start with targeted filters** — use `--chat-id`, `--sender`, `--start`, `--end` to narrow the scope as much as possible before paginating.
|
||||
2. **Fetch all pages** — after the first call, check the output for `has_more` and `page_token`. If `has_more` is true, immediately issue the next call with `--page-token <token>`. Repeat until `has_more` is false or the results are clearly sufficient.
|
||||
2. **Prefer auto-pagination** — for report and summary tasks, use `--page-all --format json` by default. If you need a bounded run, use `--page-limit <n> --format json`.
|
||||
3. **Accumulate before summarizing** — collect all pages of messages first, then analyze and summarize. Do not summarize after the first page alone — you will miss important context.
|
||||
4. **Use `--format json`** — JSON output includes `has_more` and `page_token` fields needed for pagination. `pretty` and `table` formats omit these fields and are not suitable for pagination. Note: `pretty` is human-readable (per-message rows); `table` is a flat key-value dump of the response envelope and is not human-readable for message lists.
|
||||
4. **Fall back to `--page-token` when resuming** — if auto-pagination hits the configured page cap and the response still has `has_more=true`, continue from the returned `page_token`.
|
||||
5. **Use `--format json`** — JSON output includes `has_more` and `page_token` fields needed for pagination. `pretty` and `table` formats are useful for reading but not for resuming pagination reliably.
|
||||
|
||||
### Example: Weekly work summary from a project chat
|
||||
|
||||
```bash
|
||||
# Page 1
|
||||
lark-cli im +messages-search --query "" --chat-id oc_xxx --sender ou_me --start "2026-03-18T00:00:00+08:00" --end "2026-03-25T23:59:59+08:00" --page-size 50 --format json
|
||||
# Preferred: fetch automatically
|
||||
lark-cli im +messages-search --query "" --chat-id oc_xxx --sender ou_me --start "2026-03-18T00:00:00+08:00" --end "2026-03-25T23:59:59+08:00" --page-size 50 --page-all --format json
|
||||
|
||||
# Page 2 (if has_more is true)
|
||||
lark-cli im +messages-search --query "" --chat-id oc_xxx --sender ou_me --start "2026-03-18T00:00:00+08:00" --end "2026-03-25T23:59:59+08:00" --page-size 50 --page-token <token_from_page_1> --format json
|
||||
# If you need to cap the run explicitly
|
||||
lark-cli im +messages-search --query "" --chat-id oc_xxx --sender ou_me --start "2026-03-18T00:00:00+08:00" --end "2026-03-25T23:59:59+08:00" --page-size 50 --page-limit 5 --format json
|
||||
|
||||
# Continue until has_more is false, then summarize all collected messages.
|
||||
# If the bounded run still returns has_more=true, continue manually
|
||||
lark-cli im +messages-search --query "" --chat-id oc_xxx --sender ou_me --start "2026-03-18T00:00:00+08:00" --end "2026-03-25T23:59:59+08:00" --page-size 50 --page-token <token_from_previous_run> --format json
|
||||
```
|
||||
|
||||
### Key points
|
||||
|
||||
- **Always paginate exhaustively** for summary tasks. A single page of 20-50 messages is usually insufficient for a meaningful work summary.
|
||||
- Prefer `--page-all`; use `--page-limit` only when you need to bound runtime or output volume.
|
||||
- If the user does not specify a time range, default to the current week (Monday to today) for weekly reports, or ask for clarification.
|
||||
- When summarizing, group messages by topic/thread rather than by chronological order for better readability.
|
||||
|
||||
|
||||
@@ -18,10 +18,90 @@ Messages sent by this tool are visible to other people. Before calling it, you *
|
||||
|
||||
When using `--as bot`, the message is sent in the app's name, so make sure the app has already been added to the target chat.
|
||||
|
||||
## Choose The Right Content Flag
|
||||
|
||||
| Need | Recommended flag | Why |
|
||||
|------|------|------|
|
||||
| Send plain text exactly as written | `--text` | Wrapped directly to `{"text":"..."}`; no Markdown conversion |
|
||||
| Send simple Markdown and accept Feishu-style rendering | `--markdown` | Automatically converted to `post` JSON |
|
||||
| Precisely control the final payload | `--content` | You provide the exact JSON for `text` / `post` / `interactive` / `share_*` / media payloads |
|
||||
| Send image / file / video / audio | `--image` / `--file` / `--video` / `--audio` | Shortcut uploads local files automatically |
|
||||
|
||||
### `--text` vs `--markdown`
|
||||
|
||||
- Use `--text` when the content should stay as plain text, including exact line breaks, indentation, code samples, shell snippets, or Markdown characters that should **not** be reinterpreted.
|
||||
- Use `--markdown` when you want basic Markdown-style rendering and you accept that the shortcut will normalize and rewrite parts of the content before sending.
|
||||
- Use `--content` when `--markdown` is not enough, especially if you need exact `post` JSON, a title, multiple locales, cards, or unsupported rich structures.
|
||||
|
||||
## What `--markdown` Really Does
|
||||
|
||||
`--markdown` is **not** sent as raw Markdown API content.
|
||||
|
||||
The shortcut does all of the following before sending:
|
||||
|
||||
1. Forces `msg_type=post`
|
||||
2. Resolves remote Markdown images like `` by downloading and uploading them first
|
||||
3. Normalizes the Markdown for Feishu post rendering
|
||||
4. Wraps the result as:
|
||||
|
||||
```json
|
||||
{"zh_cn":{"content":[[{"tag":"md","text":"..."}]]}}
|
||||
```
|
||||
|
||||
This means `--markdown` is convenient, but it is not a full-fidelity Markdown transport.
|
||||
|
||||
### Current Markdown Caveats
|
||||
|
||||
- It does **not** promise full CommonMark / GitHub Flavored Markdown support.
|
||||
- It always becomes a `post` payload with a single `zh_cn` locale.
|
||||
- It does **not** let you set a `post` title. If you need a title, use `--msg-type post --content ...`.
|
||||
- Headings are rewritten:
|
||||
- `# Title` becomes `#### Title`
|
||||
- `##` to `######` are normalized to `#####` when the content contains H1-H3
|
||||
- Consecutive headings are separated with blank lines after heading normalization.
|
||||
- Block spacing and line breaks may be normalized during conversion.
|
||||
- Code blocks are preserved as code blocks.
|
||||
- Excess blank lines are compressed.
|
||||
- Only `http://...`, `https://...`, or already-uploaded `img_xxx` Markdown images are kept reliably.
|
||||
- Local paths in Markdown image syntax like `` are **not** auto-uploaded by `--markdown`; they may be stripped during optimization.
|
||||
- If remote Markdown image download/upload fails, that image is removed with a warning.
|
||||
|
||||
If any of the above is unacceptable, do **not** use `--markdown`; use `--content` and provide the final JSON yourself.
|
||||
|
||||
## Preserving Formatting
|
||||
|
||||
If the message has multiple lines, indentation, code blocks, tabs, or many quotes/backslashes, prefer shell ANSI-C quoting with `$'...'`.
|
||||
|
||||
This is especially useful in `zsh` / `bash` because it lets you write `\n` explicitly instead of relying on the shell to preserve literal newlines.
|
||||
|
||||
### When formatting must be preserved
|
||||
|
||||
Use `--text` plus `$'...'`:
|
||||
|
||||
```bash
|
||||
lark-cli im +messages-send --chat-id oc_xxx --text $'Build failed\nBranch: feature/im-docs\nAction: please check logs'
|
||||
```
|
||||
|
||||
```bash
|
||||
lark-cli im +messages-send --chat-id oc_xxx --text $'```bash\nmake test\nmake lint\n```'
|
||||
```
|
||||
|
||||
Use this path when you want the receiver to see the text exactly as entered, not a converted Markdown post.
|
||||
|
||||
### When formatting does not need exact preservation
|
||||
|
||||
Use `--markdown`:
|
||||
|
||||
```bash
|
||||
lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Release Notes\n\n- Added send shortcut\n- Added reply shortcut'
|
||||
```
|
||||
|
||||
This is better for lightweight readable formatting, but the final content may not match the source text byte-for-byte because the shortcut normalizes headings and spacing before sending.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Send plain text (--text is recommended; it is wrapped into JSON automatically)
|
||||
# Send plain text (--text is recommended for normal messages)
|
||||
lark-cli im +messages-send --chat-id oc_xxx --text "Hello"
|
||||
|
||||
# Equivalent manual JSON
|
||||
@@ -30,7 +110,13 @@ lark-cli im +messages-send --chat-id oc_xxx --content '{"text":"Hello"}'
|
||||
# Send to a direct message (pass open_id)
|
||||
lark-cli im +messages-send --user-id ou_xxx --text "Hello"
|
||||
|
||||
# Send a rich-text message
|
||||
# Send multi-line text while preserving formatting
|
||||
lark-cli im +messages-send --chat-id oc_xxx --text $'Line 1\nLine 2\n indented line'
|
||||
|
||||
# Send basic Markdown (will be converted to post JSON)
|
||||
lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Update\n\n- item 1\n- item 2'
|
||||
|
||||
# If you need exact post structure, send JSON directly
|
||||
lark-cli im +messages-send --chat-id oc_xxx --msg-type post --content '{"zh_cn":{"title":"Title","content":[[{"tag":"text","text":"Body"}]]}}'
|
||||
|
||||
# Send a local image (uploaded automatically before sending)
|
||||
@@ -53,7 +139,7 @@ lark-cli im +messages-send --chat-id oc_xxx --audio ./voice.opus
|
||||
lark-cli im +messages-send --chat-id oc_xxx --text "Hello" --idempotency-key my-unique-id
|
||||
|
||||
# Preview the request without executing it
|
||||
lark-cli im +messages-send --chat-id oc_xxx --text "Test" --dry-run
|
||||
lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhello' --dry-run
|
||||
```
|
||||
|
||||
## Parameters
|
||||
@@ -62,15 +148,15 @@ lark-cli im +messages-send --chat-id oc_xxx --text "Test" --dry-run
|
||||
|------|------|------|
|
||||
| `--chat-id <id>` | One of two | Group chat ID (`oc_xxx`) |
|
||||
| `--user-id <id>` | One of two | User open_id (`ou_xxx`) for direct messages |
|
||||
| `--text <string>` | One of seven content options | Plain text message (automatically wrapped as `{"text":"..."}` JSON) |
|
||||
| `--markdown <string>` | One of seven content options | Markdown text (auto-wrapped as post format with style optimization; image URLs auto-resolved) |
|
||||
| `--content <json>` | One of seven content options | Message content JSON string; format depends on `msg_type` |
|
||||
| `--image <path\|key>` | One of seven content options | Local image path or `image_key` (`img_xxx`). Local paths are uploaded automatically |
|
||||
| `--file <path\|key>` | One of seven content options | Local file path or `file_key` (`file_xxx`). Local paths are uploaded automatically |
|
||||
| `--video <path\|key>` | One of seven content options | Local video path or `file_key`. Local paths are uploaded automatically. **Must be paired with `--video-cover`** |
|
||||
| `--text <string>` | One content option | Plain text message. Best default for exact text and preserved formatting. Automatically wrapped as `{"text":"..."}` |
|
||||
| `--markdown <string>` | One content option | Convenience Markdown input. Internally converted to `post` JSON with Feishu-specific normalization; not full Markdown passthrough |
|
||||
| `--content <json>` | One content option | Exact message content JSON string; use this when you need full control over `msg_type` and payload. The JSON must match the effective `--msg-type` |
|
||||
| `--image <path\|key>` | One content option | Local image path or `image_key` (`img_xxx`). Local paths are uploaded automatically |
|
||||
| `--file <path\|key>` | One content option | Local file path or `file_key` (`file_xxx`). Local paths are uploaded automatically |
|
||||
| `--video <path\|key>` | One content option | Local video path or `file_key`. Local paths are uploaded automatically. **Must be paired with `--video-cover`** |
|
||||
| `--video-cover <path\|key>` | **Required with `--video`** | Video cover image path or `image_key` (`img_xxx`). Local paths are uploaded automatically |
|
||||
| `--audio <path\|key>` | One of seven content options | Local audio path or `file_key`. Local paths are uploaded automatically |
|
||||
| `--msg-type <type>` | No | Message type (default `text`): `text`, `post`, `image`, `file`, `audio`, `media`, `interactive`, `share_chat`, `share_user`. Automatically set when using `--text`/`--image`/`--file`/`--video`/`--audio` |
|
||||
| `--audio <path\|key>` | One content option | Local audio path or `file_key`. Local paths are uploaded automatically |
|
||||
| `--msg-type <type>` | No | Message type (default `text`). If you use `--text` / `--markdown` / media flags, the effective type is inferred automatically. Explicitly setting a conflicting `--msg-type` fails validation |
|
||||
| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one message within 1 hour |
|
||||
| `--as <identity>` | No | Identity type: `bot` only |
|
||||
| `--dry-run` | No | Print the request only, do not execute it |
|
||||
@@ -79,6 +165,15 @@ lark-cli im +messages-send --chat-id oc_xxx --text "Test" --dry-run
|
||||
>
|
||||
> **Video cover rule:** `--video` **must** be accompanied by `--video-cover`. Omitting `--video-cover` when using `--video` will fail validation. `--video-cover` cannot be used without `--video`.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
- Choosing `--markdown` when you actually need exact plain text. If exact line breaks and spacing matter, use `--text`, usually with `$'...'`.
|
||||
- Assuming `--markdown` supports all Markdown features. It does not; it is converted into a Feishu `post` payload and rewritten first.
|
||||
- Putting local image paths inside Markdown like ``. `--markdown` does not auto-upload those paths.
|
||||
- Using `--content` without making the JSON match the effective `--msg-type`.
|
||||
- Explicitly setting `--msg-type` to something that conflicts with `--text`, `--markdown`, or media flags.
|
||||
- Mixing `--text`, `--markdown`, or `--content` with media flags in one command.
|
||||
|
||||
## `content` Format Reference
|
||||
|
||||
| `msg_type` | Example `content` |
|
||||
@@ -105,16 +200,21 @@ lark-cli im +messages-send --chat-id oc_xxx --text "Test" --dry-run
|
||||
|
||||
## @Mention Format (text / post)
|
||||
|
||||
- @specific user: `<at user_id="ou_xxx">name</at>`
|
||||
- Recommended format: `<at user_id="ou_xxx">name</at>`
|
||||
- @all: `<at user_id="all"></at>`
|
||||
- The shortcut normalizes common variants like `<at id=...>` and `<at open_id=...>` into `user_id`, but you should still document examples with `user_id`
|
||||
|
||||
## Notes
|
||||
|
||||
- `--chat-id` and `--user-id` are mutually exclusive; you must provide exactly one
|
||||
- `--content` must be a valid JSON string
|
||||
- `--image`/`--file`/`--video`/`--audio` support local file paths; use relative paths within the current working directory. The shortcut automatically uploads the file first and then sends the message. You do not need to call a separate upload command manually
|
||||
- If the provided value starts with `img_` or `file_`, it is treated as an existing key and used directly
|
||||
- When using `--video`, `--video-cover` is **required** as the video cover (`image_key`). Omitting `--video-cover` with `--video` will produce a validation error. `--video-cover` cannot be used without `--video`
|
||||
- `--content` must be valid JSON
|
||||
- When using `--content`, you are responsible for making the JSON structure match the effective `msg_type`
|
||||
- `--image`/`--file`/`--video`/`--audio` support local file paths; the shortcut uploads first and then sends the message
|
||||
- If the provided media value starts with `img_` or `file_`, it is treated as an existing key and used directly
|
||||
- `--markdown` always sends `msg_type=post`, even if you do not explicitly set `--msg-type post`
|
||||
- If you explicitly set `--msg-type` and it conflicts with the chosen content flag, validation fails
|
||||
- When using `--video`, `--video-cover` is required as the video cover
|
||||
- `--dry-run` uses placeholder image keys for remote Markdown images and placeholder media keys for local uploads
|
||||
- Failures return an error code and message
|
||||
- `--as bot` uses a tenant access token (TAT) and requires the `im:message:send_as_bot` scope
|
||||
- When sending as a bot, the app must already be in the target group or already have a direct-message relationship with the target user
|
||||
- When sending as a bot, the app must already be in the target group or already have a direct-message relationship with the target user
|
||||
@@ -48,8 +48,8 @@ lark-cli mail +draft-create --to alice@example.com --subject '测试' --body 'te
|
||||
| `--cc <emails>` | 否 | 完整抄送列表,多个用逗号分隔 |
|
||||
| `--bcc <emails>` | 否 | 完整密送列表,多个用逗号分隔 |
|
||||
| `--plain-text` | 否 | 强制纯文本模式,忽略 HTML 自动检测。不可与 `--inline` 同时使用 |
|
||||
| `--attach <paths>` | 否 | 普通附件文件路径,多个用逗号分隔 |
|
||||
| `--inline <json>` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `<img src="cid:a1b2c3d4e5f6a7b8c9d0">` 引用 |
|
||||
| `--attach <paths>` | 否 | 普通附件文件路径,多个用逗号分隔。相对路径 |
|
||||
| `--inline <json>` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`(相对路径)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `<img src="cid:a1b2c3d4e5f6a7b8c9d0">` 引用 |
|
||||
| `--format <mode>` | 否 | 输出格式:`json`(默认)/ `pretty` / `table` / `ndjson` / `csv` |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ lark-cli mail +draft-edit --draft-id <draft-id> --set-subject '测试' --dry-run
|
||||
| `--set-to <emails>` | 否 | 用此处提供的地址替换整个 To 收件人列表 |
|
||||
| `--set-cc <emails>` | 否 | 用此处提供的地址替换整个 Cc 抄送列表 |
|
||||
| `--set-bcc <emails>` | 否 | 用此处提供的地址替换整个 Bcc 密送列表 |
|
||||
| `--patch-file <path>` | 否 | 所有正文编辑、增量收件人编辑、邮件头编辑、附件变更和内嵌图片变更的入口。先运行 `--print-patch-template` 查看 JSON 结构 |
|
||||
| `--patch-file <path>` | 否 | 所有正文编辑、增量收件人编辑、邮件头编辑、附件变更和内嵌图片变更的入口。相对路径。先运行 `--print-patch-template` 查看 JSON 结构 |
|
||||
| `--print-patch-template` | 否 | 打印 `--patch-file` 的 JSON 模板和支持的操作。建议在生成补丁文件前先运行此命令。不会读取或写入草稿 |
|
||||
| `--inspect` | 否 | 查看草稿但不修改。返回包含 `has_quoted_content`(是否有引用区)、`attachments_summary`(含每个附件的 `part_id`、`cid`、`filename`)和 `inline_summary` 的草稿投影 |
|
||||
| `--format <mode>` | 否 | 输出格式:`json`(默认)/ `pretty` / `table` / `ndjson` / `csv` |
|
||||
@@ -222,6 +222,7 @@ lark-cli mail +draft-edit --draft-id <draft_id> --inspect
|
||||
|
||||
- `ops` 按顺序执行
|
||||
- `target` 接受 `part_id` 或 `cid`;优先级:`part_id` > `cid`
|
||||
- **所有文件路径(`--patch-file` 及 ops 中的 `path`)必须为相对路径**
|
||||
- **正文编辑没有 flag,必须通过 `--patch-file`**
|
||||
- **`set_body` 是完整替换** — 它替换整个正文内容(包括引用区)
|
||||
- **`set_reply_body` 仅替换引用区前面的用户撰写部分** — 引用区自动重新拼接;value 只传用户撰写内容,不要包含引用区;如果用户要修改引用区内容,用 `set_body` 全量覆盖
|
||||
@@ -250,10 +251,10 @@ lark-cli mail +draft-edit --draft-id <draft_id> --inspect
|
||||
lark-cli mail +draft-edit --draft-id <draft_id> --inspect
|
||||
|
||||
# 2. 编辑草稿(元数据用 flag,正文用 patch-file)
|
||||
cat > /tmp/patch.json << 'EOF'
|
||||
cat > ./patch.json << 'EOF'
|
||||
{ "ops": [{ "op": "set_body", "value": "<p>更新后的内容</p>" }] }
|
||||
EOF
|
||||
lark-cli mail +draft-edit --draft-id <draft_id> --set-subject '最终版本' --patch-file /tmp/patch.json
|
||||
lark-cli mail +draft-edit --draft-id <draft_id> --set-subject '最终版本' --patch-file ./patch.json
|
||||
|
||||
# 3. 发送草稿
|
||||
lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
@@ -271,10 +272,10 @@ lark-cli mail +draft-edit --draft-id <draft_id> --inspect
|
||||
# body_html_summary: "<div>原有回复内容</div>..."
|
||||
|
||||
# 2. 使用 set_reply_body 编辑正文(value 只传用户撰写内容,不含引用区)
|
||||
cat > /tmp/patch.json << 'EOF'
|
||||
cat > ./patch.json << 'EOF'
|
||||
{ "ops": [{ "op": "set_reply_body", "value": "<p>修改后的回复内容</p>" }] }
|
||||
EOF
|
||||
lark-cli mail +draft-edit --draft-id <draft_id> --patch-file /tmp/patch.json
|
||||
lark-cli mail +draft-edit --draft-id <draft_id> --patch-file ./patch.json
|
||||
```
|
||||
|
||||
**注意:** 如果误用 `set_body`,引用区将被覆盖丢失。如果用户明确要去掉引用区或修改引用区内容,则应使用 `set_body`。
|
||||
@@ -288,7 +289,7 @@ lark-cli mail +draft-edit --draft-id <draft_id> --inspect
|
||||
# [{"part_id":"1.3","filename":"report.pdf","content_type":"application/pdf"}]
|
||||
|
||||
# 2. 编写补丁文件,使用步骤 1 中获取的 part_id
|
||||
cat > /tmp/patch.json << 'EOF'
|
||||
cat > ./patch.json << 'EOF'
|
||||
{
|
||||
"ops": [
|
||||
{ "op": "remove_attachment", "target": { "part_id": "1.3" } }
|
||||
@@ -298,7 +299,7 @@ cat > /tmp/patch.json << 'EOF'
|
||||
EOF
|
||||
|
||||
# 3. 应用补丁
|
||||
lark-cli mail +draft-edit --draft-id <draft_id> --patch-file /tmp/patch.json
|
||||
lark-cli mail +draft-edit --draft-id <draft_id> --patch-file ./patch.json
|
||||
```
|
||||
|
||||
### 在正文中插入内嵌图片
|
||||
@@ -313,7 +314,7 @@ lark-cli mail +draft-edit --draft-id <draft_id> --inspect
|
||||
# projection.inline_summary: [{"part_id":"1.1.2","cid":"existing.png", ...}]
|
||||
|
||||
# 2. 编写补丁(注意:回复草稿用 set_reply_body,普通草稿用 set_body)
|
||||
cat > /tmp/patch.json << 'EOF'
|
||||
cat > ./patch.json << 'EOF'
|
||||
{
|
||||
"ops": [
|
||||
{ "op": "set_body", "value": "<div>原有内容<img src=\"cid:existing.png\" /><img src=\"cid:new-image\" /></div>" },
|
||||
@@ -324,7 +325,7 @@ cat > /tmp/patch.json << 'EOF'
|
||||
EOF
|
||||
|
||||
# 3. 应用补丁
|
||||
lark-cli mail +draft-edit --draft-id <draft_id> --patch-file /tmp/patch.json
|
||||
lark-cli mail +draft-edit --draft-id <draft_id> --patch-file ./patch.json
|
||||
```
|
||||
|
||||
### 使用 patch-file 进行高级编辑
|
||||
@@ -334,7 +335,7 @@ lark-cli mail +draft-edit --draft-id <draft_id> --patch-file /tmp/patch.json
|
||||
lark-cli mail +draft-edit --print-patch-template
|
||||
|
||||
# 2. 编写补丁文件(例如添加一个抄送并移除一个附件)
|
||||
cat > /tmp/patch.json << 'EOF'
|
||||
cat > ./patch.json << 'EOF'
|
||||
{
|
||||
"ops": [
|
||||
{ "op": "add_recipient", "field": "cc", "address": "carol@example.com", "name": "Carol" },
|
||||
@@ -345,7 +346,7 @@ cat > /tmp/patch.json << 'EOF'
|
||||
EOF
|
||||
|
||||
# 3. 应用补丁
|
||||
lark-cli mail +draft-edit --draft-id <draft_id> --patch-file /tmp/patch.json
|
||||
lark-cli mail +draft-edit --draft-id <draft_id> --patch-file ./patch.json
|
||||
```
|
||||
|
||||
## 相关命令
|
||||
|
||||
@@ -63,8 +63,8 @@ lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --dry-run
|
||||
| `--cc <emails>` | 否 | 抄送邮箱,多个用逗号分隔 |
|
||||
| `--bcc <emails>` | 否 | 密送邮箱,多个用逗号分隔 |
|
||||
| `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 |
|
||||
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔(追加在原邮件附件之后) |
|
||||
| `--inline <json>` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `<img src="cid:...">` 引用 |
|
||||
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔,追加在原邮件附件之后。相对路径 |
|
||||
| `--inline <json>` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`(相对路径)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `<img src="cid:...">` 引用 |
|
||||
| `--confirm-send` | 否 | 确认发送转发(默认只保存草稿)。仅在用户明确确认后使用 |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
@@ -157,10 +157,10 @@ lark-cli mail user_mailbox.messages batch_modify_message --params '{"user_mailbo
|
||||
|
||||
```bash
|
||||
# 编辑转发草稿正文(自动保留引用区)
|
||||
cat > /tmp/patch.json << 'EOF'
|
||||
cat > ./patch.json << 'EOF'
|
||||
{ "ops": [{ "op": "set_reply_body", "value": "<p>修改后的转发附言</p>" }] }
|
||||
EOF
|
||||
lark-cli mail +draft-edit --draft-id <draft_id> --patch-file /tmp/patch.json
|
||||
lark-cli mail +draft-edit --draft-id <draft_id> --patch-file ./patch.json
|
||||
```
|
||||
|
||||
如果用户要修改引用区内容或去掉引用区,则使用 `set_body` 全量替换。
|
||||
|
||||
@@ -67,8 +67,8 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '测试' --dry-run
|
||||
| `--bcc <emails>` | 否 | 密送邮箱,多个用逗号分隔 |
|
||||
| `--remove <emails>` | 否 | 从自动聚合结果中排除的邮箱,多个用逗号分隔 |
|
||||
| `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 |
|
||||
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔 |
|
||||
| `--inline <json>` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `<img src="cid:...">` 引用 |
|
||||
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔。相对路径 |
|
||||
| `--inline <json>` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`(相对路径)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `<img src="cid:...">` 引用 |
|
||||
| `--confirm-send` | 否 | 确认发送回复(默认只保存草稿)。仅在用户明确确认后使用 |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
|
||||
@@ -70,8 +70,8 @@ lark-cli mail +reply --message-id <邮件ID> --body '<p>测试</p>' --dry-run
|
||||
| `--cc <emails>` | 否 | 抄送邮箱,多个用逗号分隔 |
|
||||
| `--bcc <emails>` | 否 | 密送邮箱,多个用逗号分隔 |
|
||||
| `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 |
|
||||
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔 |
|
||||
| `--inline <json>` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `<img src="cid:...">` 引用 |
|
||||
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔。相对路径 |
|
||||
| `--inline <json>` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`(相对路径)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `<img src="cid:...">` 引用 |
|
||||
| `--confirm-send` | 否 | 确认发送回复(默认只保存草稿)。仅在用户明确确认后使用 |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
@@ -162,17 +162,17 @@ lark-cli mail user_mailbox.messages batch_modify_message --params '{"user_mailbo
|
||||
|
||||
```bash
|
||||
# 编辑回复草稿正文(自动保留引用区)
|
||||
cat > /tmp/patch.json << 'EOF'
|
||||
cat > ./patch.json << 'EOF'
|
||||
{ "ops": [{ "op": "set_reply_body", "value": "<p>修改后的回复内容</p>" }] }
|
||||
EOF
|
||||
lark-cli mail +draft-edit --draft-id <draft_id> --patch-file /tmp/patch.json
|
||||
lark-cli mail +draft-edit --draft-id <draft_id> --patch-file ./patch.json
|
||||
```
|
||||
|
||||
如果用户要修改引用区内容或去掉引用区,则使用 `set_body` 全量替换。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 需要已登录(`lark-cli auth login --scope "mail:user_mailbox.message:send mail:user_mailbox.message:readonly mail:user_mailbox:readonly"`)且具备写/读邮件权限
|
||||
- 需要已登录(`lark-cli auth login --scope "mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox:readonly"`)且具备写/读邮件权限
|
||||
- 邮件 ID 可从 `lark-cli mail user_mailbox.messages list` 获取
|
||||
- `--bcc` 仅在发送链路中生效,通常不会在收件方看到
|
||||
|
||||
|
||||
@@ -67,8 +67,8 @@ lark-cli mail +send --to alice@example.com --subject '测试' --body '<p>test</p
|
||||
| `--cc <emails>` | 否 | 抄送邮箱,多个用逗号分隔 |
|
||||
| `--bcc <emails>` | 否 | 密送邮箱,多个用逗号分隔 |
|
||||
| `--plain-text` | 否 | 强制纯文本模式,忽略 HTML 自动检测。不可与 `--inline` 同时使用 |
|
||||
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔 |
|
||||
| `--inline <json>` | 否 | 内嵌图片 JSON 数组,每项包含 `cid` 和 `file_path`。CID 为唯一标识符,可使用随机十六进制字符串(如 `a1b2c3d4e5f6a7b8c9d0`)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用 |
|
||||
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔。相对路径 |
|
||||
| `--inline <json>` | 否 | 内嵌图片 JSON 数组,每项包含 `cid` 和 `file_path`(相对路径)。CID 为唯一标识符,可使用随机十六进制字符串(如 `a1b2c3d4e5f6a7b8c9d0`)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用 |
|
||||
| `--confirm-send` | 否 | 确认发送邮件(默认只保存草稿)。仅在用户明确确认收件人和内容后使用 |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
@@ -131,8 +131,8 @@ lark-cli mail user_mailbox.messages send_status --params '{"user_mailbox_id":"me
|
||||
## 实现说明
|
||||
|
||||
- 使用 EML 构建器生成完整 MIME 邮件并 base64url 编码后发送。
|
||||
- `--attach` 作为普通附件添加。
|
||||
- `--inline` 接受 JSON 数组,每项需提供 `cid`(唯一标识符,可用随机十六进制字符串)和 `file_path`,作为 inline part 嵌入邮件。
|
||||
- `--attach` 作为普通附件添加。相对路径。
|
||||
- `--inline` 接受 JSON 数组,每项需提供 `cid`(唯一标识符,可用随机十六进制字符串)和 `file_path`(相对路径),作为 inline part 嵌入邮件。
|
||||
|
||||
## 相关命令
|
||||
|
||||
|
||||
@@ -73,6 +73,21 @@ lark-cli auth login --scope "calendar:calendar:readonly"
|
||||
```
|
||||
|
||||
|
||||
## 更新检查
|
||||
|
||||
lark-cli 命令执行后,如果检测到新版本,JSON 输出中会包含 `_notice.update` 字段(含 `message`、`command` 等)。
|
||||
|
||||
**当你在输出中看到 `_notice.update` 时,完成用户当前请求后,主动提议帮用户更新**:
|
||||
|
||||
1. 告知用户当前版本和最新版本号
|
||||
2. 提议执行更新(CLI 和 Skills 需要同时更新):
|
||||
```bash
|
||||
npm update -g @larksuite/cli && npx skills add larksuite/cli -g -y
|
||||
```
|
||||
3. 更新完成后提醒用户:**退出并重新打开 AI Agent**以加载最新 Skills
|
||||
|
||||
**规则**:不要静默忽略更新提示。即使当前任务与更新无关,也应在完成用户请求后补充告知。
|
||||
|
||||
## 安全规则
|
||||
|
||||
- **禁止输出密钥**(appSecret、accessToken)到终端明文。
|
||||
|
||||
Reference in New Issue
Block a user