From d4d4f32ec6b77eef85d970e0138aeb520b8d6ca0 Mon Sep 17 00:00:00 2001 From: liuxinyanglxy <109052391+liuxinyanglxy@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:29:42 +0800 Subject: [PATCH 01/39] docs: add npm version badge and improve AI agent tip wording (#23) Add npm version badge to both README files for better package discoverability. Reword the Quick Start tip to directly address AI assistants instead of human users. Change-Id: I9fb4252e4a7bde4ab6644c6ca6e63dc5d34b6f0c Co-authored-by: Claude Opus 4.6 (1M context) --- README.md | 9 ++++++--- README.zh.md | 7 +++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4a5b1a8e..65dbd086 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Go Version](https://img.shields.io/badge/go-%3E%3D1.23-blue.svg)](https://go.dev/) +[![npm version](https://img.shields.io/npm/v/@larksuite/cli.svg)](https://www.npmjs.com/package/@larksuite/cli) [中文版](./README.zh.md) | [English](./README.md) @@ -46,7 +47,7 @@ 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 @@ -70,6 +71,8 @@ make install #### Install AI Agent Skills +> **This is a critical step — do not skip it.** Skills are required for AI Agents to use this CLI properly. Without them, no Lark capabilities can be invoked. + [Skills](./skills/) are structured instruction documents that enable AI Agents to use this CLI: ```bash @@ -105,10 +108,10 @@ npm install -g @larksuite/cli **Step 2 — Install Skills (core step, do not skip)** -> Skills are the instruction files that tell the AI Agent what commands are available and how to call them. This step is essential for the Agent to work with lark-cli. +> Skills are the instruction files that tell the AI Agent what commands are available and how to call them. ```bash -npx skills add larksuite/cli --all -y +npx skills add larksuite/cli -y -g ``` **Step 3 — Configure app credentials** diff --git a/README.zh.md b/README.zh.md index 921177b7..ebaf2487 100644 --- a/README.zh.md +++ b/README.zh.md @@ -2,6 +2,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Go Version](https://img.shields.io/badge/go-%3E%3D1.23-blue.svg)](https://go.dev/) +[![npm version](https://img.shields.io/npm/v/@larksuite/cli.svg)](https://www.npmjs.com/package/@larksuite/cli) [中文版](./README.zh.md) | [English](./README.md) @@ -46,7 +47,7 @@ ### 快速开始(人类用户) -> **Tip:** 如果你拥有 AI Agent,可以直接把本 README 丢给它,让 AI 帮你完成安装和配置 — 跳转到[快速开始(AI Agent)](#快速开始ai-agent)查看。 +> **Note for AI assistants:** 如果你是 AI Agent 且正在协助用户执行安装,请直接跳转到[快速开始(AI Agent)](#快速开始ai-agent)执行,那里包含你需要完成的所有步骤。 #### 安装 CLI @@ -70,6 +71,8 @@ make install #### 安装 AI Agent Skills +> **这是核心步骤,请勿跳过。** Skills 是 AI Agent 正确使用本 CLI 的前提。 + [Skills](./skills/) 是结构化的指令文档,使 AI Agent 能够使用本 CLI: ```bash @@ -108,7 +111,7 @@ npm install -g @larksuite/cli > Skills 是告诉 AI Agent 有哪些命令可用及如何调用的指令文件,是 Agent 使用 lark-cli 的前提。 ```bash -npx skills add larksuite/cli --all -y +npx skills add larksuite/cli -y -g ``` **第 3 步 — 配置应用凭证** From 62ad335b26f76afc2dcd7ba1d8707ed2b48896b8 Mon Sep 17 00:00:00 2001 From: liuxinyanglxy <109052391+liuxinyanglxy@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:33:24 +0800 Subject: [PATCH 02/39] docs: simplify installation steps by merging CLI and Skills into one section (#26) Combine the separate "Install CLI" and "Install AI Agent Skills" sections into a single "Install" section for both human and AI Agent quick start guides. Renumber the AI Agent steps accordingly. Change-Id: I4ac10538d912a8889f52ea5a85e757d3e8bad21e --- README.md | 35 ++++++++++++----------------------- README.zh.md | 35 ++++++++++++----------------------- 2 files changed, 24 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 65dbd086..f0c0a55f 100644 --- a/README.md +++ b/README.md @@ -49,14 +49,18 @@ Before you start, make sure you have: > **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 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 ``` **Option 2 — From source:** @@ -67,19 +71,8 @@ Requires Go `v1.23`+ and Python 3. git clone https://github.com/larksuite/cli.git cd cli make install -``` - -#### Install AI Agent Skills - -> **This is a critical step — do not skip it.** Skills are required for AI Agents to use this CLI properly. Without them, no Lark capabilities can be invoked. - -[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 ``` @@ -100,21 +93,17 @@ 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 CLI** +**Step 1 — Install** ```bash +# Install CLI npm install -g @larksuite/cli -``` -**Step 2 — Install Skills (core step, do not skip)** - -> Skills are the instruction files that tell the AI Agent what commands are available and how to call them. - -```bash +# Install CLI SKILL (required) npx skills add larksuite/cli -y -g ``` -**Step 3 — Configure app credentials** +**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. @@ -122,7 +111,7 @@ npx skills add larksuite/cli -y -g lark-cli config init --new ``` -**Step 4 — Login** +**Step 3 — Login** > Same as above: run in the background, extract the authorization URL and send it to the user. @@ -130,7 +119,7 @@ lark-cli config init --new lark-cli auth login --recommend ``` -**Step 5 — Verify** +**Step 4 — Verify** ```bash lark-cli auth status diff --git a/README.zh.md b/README.zh.md index ebaf2487..bcd26b2f 100644 --- a/README.zh.md +++ b/README.zh.md @@ -49,14 +49,18 @@ > **Note for AI assistants:** 如果你是 AI Agent 且正在协助用户执行安装,请直接跳转到[快速开始(AI Agent)](#快速开始ai-agent)执行,那里包含你需要完成的所有步骤。 -#### 安装 CLI +#### 安装 以下两种方式**任选其一**: **方式一 — 从 npm 安装(推荐):** ```bash +# 安装 CLI npm install -g @larksuite/cli + +# 安装 CLI SKILL(必需) +npx skills add larksuite/cli -y -g ``` **方式二 — 从源码安装:** @@ -67,19 +71,8 @@ npm install -g @larksuite/cli git clone https://github.com/larksuite/cli.git cd cli make install -``` - -#### 安装 AI Agent Skills - -> **这是核心步骤,请勿跳过。** Skills 是 AI Agent 正确使用本 CLI 的前提。 - -[Skills](./skills/) 是结构化的指令文档,使 AI Agent 能够使用本 CLI: -```bash -# 安装所有 skills 到当前目录 -npx skills add larksuite/cli -y - -# 安装所有 skills 到全局 +# 安装 CLI SKILL(必需) npx skills add larksuite/cli -y -g ``` @@ -100,21 +93,17 @@ lark-cli calendar +agenda > 以下步骤面向 AI Agent,部分步骤需要用户在浏览器中配合完成。 -**第 1 步 — 安装 CLI** +**第 1 步 — 安装** ```bash +# 安装 CLI npm install -g @larksuite/cli -``` -**第 2 步 — 安装 Skills(核心步骤,请勿跳过)** - -> Skills 是告诉 AI Agent 有哪些命令可用及如何调用的指令文件,是 Agent 使用 lark-cli 的前提。 - -```bash +# 安装 CLI SKILL(必需) npx skills add larksuite/cli -y -g ``` -**第 3 步 — 配置应用凭证** +**第 2 步 — 配置应用凭证** > 在后台运行此命令,命令会输出一个授权链接,提取该链接并发送给用户,用户在浏览器中完成配置后命令会自动退出。 @@ -122,7 +111,7 @@ npx skills add larksuite/cli -y -g lark-cli config init --new ``` -**第 4 步 — 登录** +**第 3 步 — 登录** > 同上,后台运行,提取授权链接发给用户。 @@ -130,7 +119,7 @@ lark-cli config init --new lark-cli auth login --recommend ``` -**第 5 步 — 验证** +**第 4 步 — 验证** ```bash lark-cli auth status From 511c24bd95a2843b2ef6c2610f2fa491f3206d50 Mon Sep 17 00:00:00 2001 From: TimZhong Date: Sat, 28 Mar 2026 16:00:14 +0000 Subject: [PATCH 03/39] docs: add star history chart to readmes (#12) --- README.md | 4 ++++ README.zh.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/README.md b/README.md index f0c0a55f..3961fe10 100644 --- a/README.md +++ b/README.md @@ -265,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 + +[![Star History Chart](https://api.star-history.com/svg?repos=larksuite/cli&type=Date)](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). diff --git a/README.zh.md b/README.zh.md index bcd26b2f..849c8a30 100644 --- a/README.zh.md +++ b/README.zh.md @@ -266,6 +266,10 @@ lark-cli schema im.messages.delete 请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。 +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=larksuite/cli&type=Date)](https://star-history.com/#larksuite/cli&Date) + ## 贡献 欢迎社区贡献!如果你发现 bug 或有功能建议,请提交 [Issue](https://github.com/larksuite/cli/issues) 或 [Pull Request](https://github.com/larksuite/cli/pulls)。 From d2ad5e4def7ff89adb637f0e8db8ee6697df18ce Mon Sep 17 00:00:00 2001 From: TimZhong Date: Sat, 28 Mar 2026 16:00:52 +0000 Subject: [PATCH 04/39] docs: rename user-facing Bitable references to Base (#11) --- CHANGELOG.md | 2 +- shortcuts/base/base_data_query.go | 2 +- skills/lark-base/references/formula-field-guide.md | 4 ++-- skills/lark-base/references/lookup-field-guide.md | 2 +- skills/lark-doc/references/lark-doc-create.md | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cad474a1..f57b4fd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,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. diff --git a/shortcuts/base/base_data_query.go b/shortcuts/base/base_data_query.go index d316e4f2..f3724c1b 100644 --- a/shortcuts/base/base_data_query.go +++ b/shortcuts/base/base_data_query.go @@ -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(), diff --git a/skills/lark-base/references/formula-field-guide.md b/skills/lark-base/references/formula-field-guide.md index bf673676..6ffe315a 100644 --- a/skills/lark-base/references/formula-field-guide.md +++ b/skills/lark-base/references/formula-field-guide.md @@ -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 | | ------------- | -------------------------- | -------------------------------------------------------------------------- | diff --git a/skills/lark-base/references/lookup-field-guide.md b/skills/lark-base/references/lookup-field-guide.md index d5df607b..e99e5771 100644 --- a/skills/lark-base/references/lookup-field-guide.md +++ b/skills/lark-base/references/lookup-field-guide.md @@ -1,4 +1,4 @@ -# Bitable Lookup Field Configuration Guide +# Base Lookup Field Configuration Guide ## Mandatory Read Acknowledgement diff --git a/skills/lark-doc/references/lark-doc-create.md b/skills/lark-doc/references/lark-doc-create.md index d0ce0783..b26a162f 100644 --- a/skills/lark-doc/references/lark-doc-create.md +++ b/skills/lark-doc/references/lark-doc-create.md @@ -437,7 +437,7 @@ lark-cli docs +create --title "空白画板示例" --markdown ' From e5a83f5eaab0a0724334b291249fb776ffd4ff14 Mon Sep 17 00:00:00 2001 From: liangshuo-1 Date: Mon, 30 Mar 2026 11:09:31 +0800 Subject: [PATCH 05/39] ci: improve CI workflows and add golangci-lint config (#71) * ci: improve CI workflows and add golangci-lint config - Add path filters to avoid unnecessary CI runs on non-Go changes - Use go-version-file instead of hardcoded Go version - Unify runners to ubuntu-latest - Consolidate staticcheck/vet into golangci-lint with curated linter set - Add go mod tidy check, govulncheck, and dependency license check - Enable race detector in coverage, increase test timeout to 5m - Add build verification step to tests workflow - Add .codecov.yml with patch coverage target (60%) - Add .golangci.yml (v2) with security and correctness linters Change-Id: I409beb21cc1f1568ff47739c0a00f6214c10a0dd * ci: replace Codecov upload with GitHub Job Summary coverage report - Remove Codecov action dependency and CODECOV_TOKEN usage - Generate coverage report using go tool cover and display in Job Summary - Rename job from 'codecov' to 'coverage' - Remove .codecov.yml from paths filter Change-Id: Ib65dab6c4d7117c3300a9ea31eb1550537c72f88 * ci: trigger lint workflow Change-Id: Ic1c492dd339f5460d2be2971ac65ea8f99e524eb * ci: replace golangci-lint action with go run to avoid action whitelist restriction Change-Id: I87274abf9780eb8b6350e98a27302ec5acc2a2e5 * ci: replace golangci-lint action with go run, keep incremental lint via --new-from-rev Change-Id: I3d4a13cfd7b6c02e4098b04b8533a7248185c077 * ci: add fetch-depth 0 to lint checkout for incremental lint to work Change-Id: I112279c5ec06dc0aa3aa7e01d564ea27fbd20533 * ci: disable errcheck linter due to high volume of existing violations Change-Id: Iec57e8fbe42699f687d931d9dde2f879f2ae5b02 * ci: align golangci-lint config with GitHub CLI, make govulncheck non-blocking - Add exptostd, gocheckcompilerdirectives, gochecksumtype, gomoddirectives linters - Move gosec, staticcheck, errname, errorlint, misspell to TODO for later enablement - Remove G104 exclusion (errcheck is disabled) - Make govulncheck continue-on-error until Go version is upgraded Change-Id: I330ece4f202229aee1e2f50790f6b22738704c05 * ci: fix go-licenses module path for v2 Change-Id: Ifd018ebe79cd18402171417b1b73313af2d23c6d --- .codecov.yml | 8 ++++ .github/workflows/coverage.yml | 42 ++++++++++++------ .github/workflows/lint.yml | 78 ++++++++++++++-------------------- .github/workflows/tests.yml | 23 +++++++--- .golangci.yml | 66 ++++++++++++++++++++++++++++ 5 files changed, 155 insertions(+), 62 deletions(-) create mode 100644 .codecov.yml create mode 100644 .golangci.yml diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 00000000..2f0a040f --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,8 @@ +coverage: + status: + project: + default: + informational: true + patch: + default: + target: 60% diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 5aba7b0d..351cf3d9 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -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 ./... - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5 - with: - files: coverage.txt - token: ${{ secrets.CODECOV_TOKEN }} + run: go test -race -coverprofile=coverage.txt -covermode=atomic ./... + + - 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" >> $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 "
" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2d0da6b6..cec20a8b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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: Run golangci-lint - uses: golangci/golangci-lint-action@55c2c1448f86e01eaae002a5a3a9624417608d84 # v6 - with: - version: latest - - vet: - runs-on: ubuntu-22.04 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - 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 - - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 - with: - go-version: '1.23' - - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: '3.x' + - name: Run golangci-lint + run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main - - name: Fetch meta_data.json - run: python3 scripts/fetch_meta.py + - 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 ./... - - 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 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 11136dcf..58351696 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 ./... diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..4690fe93 --- /dev/null +++ b/.golangci.yml @@ -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 From a13bee8fda5721e3b9c983e5dcd31fcf741c1154 Mon Sep 17 00:00:00 2001 From: evandance <120630830+evandance@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:19:24 +0800 Subject: [PATCH 06/39] fix: resolve silent failure in `lark-cli api` error output (#39) (#85) MarkRaw previously skipped both enrichPermissionError and WriteErrorEnvelope, causing api command to exit 1 with no output on API errors. Now MarkRaw only skips enrichPermissionError while WriteErrorEnvelope always runs, ensuring stderr error envelope is always written. - Simplify apiRun MarkRaw logic (remove unnecessary IsJSONContentType check) - Update existing tests to match new behavior - Add 5 e2e tests covering api/service/shortcut error output --- cmd/api/api.go | 11 +- cmd/api/api_test.go | 7 +- cmd/root.go | 9 +- cmd/root_e2e_test.go | 279 +++++++++++++++++++++++++++++++++++++++++++ cmd/root_test.go | 8 +- 5 files changed, 294 insertions(+), 20 deletions(-) create mode 100644 cmd/root_e2e_test.go diff --git a/cmd/api/api.go b/cmd/api/api.go index d2ec7098..587d4527 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -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 { diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index 362a6c02..730aae0b 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -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) { diff --git a/cmd/root.go b/cmd/root.go index 6cf9f624..f02ddfcc 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -126,12 +126,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 } diff --git a/cmd/root_e2e_test.go b/cmd/root_e2e_test.go new file mode 100644 index 00000000..afdae1b4 --- /dev/null +++ b/cmd/root_e2e_test.go @@ -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.", + }, + }) +} diff --git a/cmd/root_test.go b/cmd/root_test.go index f5668d5e..940270b1 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -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: ...") From ecf3209c52558e5f3ddd0ed2df0b7c8e5d39890f Mon Sep 17 00:00:00 2001 From: feng zhi hao Date: Mon, 30 Mar 2026 18:19:11 +0800 Subject: [PATCH 07/39] fix: remove sensitive send scope from reply and forward shortcuts (#92) * fix: remove sensitive send scope from reply and forward shortcuts Remove mail:user_mailbox.message:send from the required scopes of +reply, +reply-all, and +forward shortcuts. This scope is sensitive and may not be granted, while these shortcuts default to saving drafts and do not strictly require it. * fix: validate send scope dynamically when --confirm-send is set Add validateConfirmSendScope() to check mail:user_mailbox.message:send in the Validate phase when --confirm-send is used, preventing the "draft created but send failed" scenario. Add regression tests for +reply, +reply-all, and +forward. --- shortcuts/mail/helpers.go | 28 ++++++++++ .../mail/mail_confirm_send_scope_test.go | 52 +++++++++++++++++++ shortcuts/mail/mail_forward.go | 5 +- shortcuts/mail/mail_reply.go | 5 +- shortcuts/mail/mail_reply_all.go | 5 +- shortcuts/mail/mail_shortcut_test.go | 2 +- .../lark-mail/references/lark-mail-reply.md | 2 +- 7 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 shortcuts/mail/mail_confirm_send_scope_test.go diff --git a/shortcuts/mail/helpers.go b/shortcuts/mail/helpers.go index 4c8c3fcf..e054a7dd 100644 --- a/shortcuts/mail/helpers.go +++ b/shortcuts/mail/helpers.go @@ -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") diff --git a/shortcuts/mail/mail_confirm_send_scope_test.go b/shortcuts/mail/mail_confirm_send_scope_test.go new file mode 100644 index 00000000..e93fb215 --- /dev/null +++ b/shortcuts/mail/mail_confirm_send_scope_test.go @@ -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) + } +} diff --git a/shortcuts/mail/mail_forward.go b/shortcuts/mail/mail_forward.go index 7af87421..5b767e67 100644 --- a/shortcuts/mail/mail_forward.go +++ b/shortcuts/mail/mail_forward.go @@ -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}, @@ -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 diff --git a/shortcuts/mail/mail_reply.go b/shortcuts/mail/mail_reply.go index 465b6b60..638665e2 100644 --- a/shortcuts/mail/mail_reply.go +++ b/shortcuts/mail/mail_reply.go @@ -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}, @@ -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 { diff --git a/shortcuts/mail/mail_reply_all.go b/shortcuts/mail/mail_reply_all.go index adfb81ae..91d600bf 100644 --- a/shortcuts/mail/mail_reply_all.go +++ b/shortcuts/mail/mail_reply_all.go @@ -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}, @@ -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 { diff --git a/shortcuts/mail/mail_shortcut_test.go b/shortcuts/mail/mail_shortcut_test.go index 2c9d389a..88fa89e0 100644 --- a/shortcuts/mail/mail_shortcut_test.go +++ b/shortcuts/mail/mail_shortcut_test.go @@ -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 { diff --git a/skills/lark-mail/references/lark-mail-reply.md b/skills/lark-mail/references/lark-mail-reply.md index a938054d..baa51306 100644 --- a/skills/lark-mail/references/lark-mail-reply.md +++ b/skills/lark-mail/references/lark-mail-reply.md @@ -172,7 +172,7 @@ lark-cli mail +draft-edit --draft-id --patch-file /tmp/patch.json ## 注意事项 -- 需要已登录(`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` 仅在发送链路中生效,通常不会在收件方看到 From 8e24166d909178c0ecd6ac43f3ec9e8c1512964d Mon Sep 17 00:00:00 2001 From: kongenpei Date: Mon, 30 Mar 2026 19:40:17 +0800 Subject: [PATCH 08/39] fix(base): use base history read scope for record history list (#96) Co-authored-by: kongenpei --- shortcuts/base/record_history_list.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shortcuts/base/record_history_list.go b/shortcuts/base/record_history_list.go index d9ce8f4e..3a2f08e6 100644 --- a/shortcuts/base/record_history_list.go +++ b/shortcuts/base/record_history_list.go @@ -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), From 9b933f1a20d6eb1a495538072d0c12cc99d252c2 Mon Sep 17 00:00:00 2001 From: Schumi Lin Date: Mon, 30 Mar 2026 20:15:42 +0800 Subject: [PATCH 09/39] docs: add official badge to distinguish from third-party Lark CLI tools (#103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add official badge and maintainer attribution to README Many third-party Feishu/Lark CLI tools exist, causing confusion about which is the official one. Add an "Official" badge and a blockquote clearly stating this repo is maintained by the Lark/Feishu Open Platform team at larksuite. Co-Authored-By: Claude Opus 4.6 (1M context) * docs: use 飞书官方 CLI 工具 in Chinese README Co-Authored-By: Claude Opus 4.6 (1M context) * docs: merge official attribution into description, drop Open Platform wording Remove the separate blockquote and fold official status into the main description line. Also remove "Open Platform" — this is the Lark/Feishu CLI tool, not an "Open Platform CLI tool". Co-Authored-By: Claude Opus 4.6 (1M context) * docs: remove self-made Official badge No major official repo (OpenAI, Anthropic, Stripe, AWS, HashiCorp) uses a custom shields.io "Official" badge — anyone can make one, so it signals nothing. The org namespace (larksuite/) and the "official" wording in the description are sufficient. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- README.md | 2 +- README.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3961fe10..68814c79 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [中文版](./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) diff --git a/README.zh.md b/README.zh.md index 849c8a30..19cae9f1 100644 --- a/README.zh.md +++ b/README.zh.md @@ -6,7 +6,7 @@ [中文版](./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) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献) From 69bcdd9e350ea5d5027314ce34d3c4bbfeef3d3f Mon Sep 17 00:00:00 2001 From: YangJunzhou-01 Date: Mon, 30 Mar 2026 23:00:41 +0800 Subject: [PATCH 10/39] feat: add auto-pagination to messages search and update lark-im docs (#30) Change-Id: Ic50e891d2385c2e3ac902cd89d95c3db99f94050 --- shortcuts/im/builders_test.go | 50 +++ shortcuts/im/im_messages_search.go | 198 ++++++++++-- .../im/im_messages_search_execute_test.go | 285 ++++++++++++++++++ skills/lark-im/SKILL.md | 3 +- .../references/lark-im-messages-search.md | 40 ++- 5 files changed, 531 insertions(+), 45 deletions(-) create mode 100644 shortcuts/im/im_messages_search_execute_test.go diff --git a/shortcuts/im/builders_test.go b/shortcuts/im/builders_test.go index 36e06521..a4a54de0 100644 --- a/shortcuts/im/builders_test.go +++ b/shortcuts/im/builders_test.go @@ -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{ diff --git a/shortcuts/im/im_messages_search.go b/shortcuts/im/im_messages_search.go index 3b0f7dfc..d122cc23 100644 --- a/shortcuts/im/im_messages_search.go +++ b/shortcuts/im/im_messages_search.go @@ -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 +} diff --git a/shortcuts/im/im_messages_search_execute_test.go b/shortcuts/im/im_messages_search_execute_test.go new file mode 100644 index 00000000..6cccd184 --- /dev/null +++ b/shortcuts/im/im_messages_search_execute_test.go @@ -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 +} diff --git a/skills/lark-im/SKILL.md b/skills/lark-im/SKILL.md index e33ee515..c7f86dd2 100644 --- a/skills/lark-im/SKILL.md +++ b/skills/lark-im/SKILL.md @@ -63,7 +63,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli im + [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 [flags] # 调用 API | `pins.create` | `im:message.pins:write_only` | | `pins.delete` | `im:message.pins:write_only` | | `pins.list` | `im:message.pins:read` | - diff --git a/skills/lark-im/references/lark-im-messages-search.md b/skills/lark-im/references/lark-im-messages-search.md index 4cf3f295..68858395 100644 --- a/skills/lark-im/references/lark-im-messages-search.md +++ b/skills/lark-im/references/lark-im-messages-search.md @@ -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 +# 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