Compare commits

...

8 Commits

Author SHA1 Message Date
liangshuo-1
d126ea2f92 chore(release): v1.0.44 (#1176) 2026-05-29 19:43:31 +08:00
max
1ba107da2e fix(vc): correct --minute-token to --minute-tokens in recording reference (#1170)
Fix 3 occurrences of --minute-token (singular) to --minute-tokens
(plural) in lark-vc-recording.md to match the actual CLI flag
definition in minutes_download.go.
2026-05-29 16:42:54 +08:00
yballul-bytedance
0e6274d947 feat(base): add dashboard block data shortcut and workflow docs (#1067)
Change-Id: I52c471886bdb2d4b7be021ce86c34bbb78385017
2026-05-29 16:35:32 +08:00
lhfer
e18ea9a2e8 fix(im): correct 64-bit MP4 box size handling to prevent panic on crafted media (#1165)
The size==1 (64-bit "largesize") branch of all three MP4 box walkers
(findMP4Box, readMp4DurationBytes, readMp4Duration) set boxEnd to the raw
largesize instead of offset+largesize — even though the 32-bit branch right
below correctly uses offset+size. Two consequences:

- Correctness: for any MP4 that carries a 64-bit box size at a non-zero
  offset, the box walk is computed from the wrong end, so the moov/mvhd
  lookup is truncated and the media duration is silently lost.

- Robustness/security (CWE-190): the unguarded uint64->int(64) conversion of
  a largesize with the high bit set yields a negative boxEnd. The in-memory
  walkers then assign it to offset and feed it back as a slice index
  (data[offset:]), panicking with "slice bounds out of range" and crashing
  the CLI on a crafted or corrupt MP4. This is reachable via URL-sourced IM
  media, whose bytes the caller does not control.

Fix: compute boxEnd as offset+largesize (matching the 32-bit branch) and
reject largesize values smaller than the 16-byte header or larger than the
remaining input. Malformed media now honours the parsers' best-effort
contract by returning 0/-1 instead of panicking, and the bounds guarantee
the conversion can no longer overflow.

Add regression tests covering both the overflow (must not panic) and a
64-bit box at a non-zero offset (must walk correctly).
2026-05-29 16:04:21 +08:00
shifengjuan-dev
365e0a2880 feat(im/chat-list): support --types flag for listing p2p single chats (#1077)
Add a new --types flag (string_slice; values from {group, p2p}) to
+chat-list, backed by the new GET /open-apis/im/v1/chats `types` query
parameter. Accepts CSV (--types group,p2p) and repeated-flag forms
(--types group --types p2p).

Defaults to groups-only (backward compatible). Under user identity,
p2p single chats appear with chat_mode="p2p" plus p2p_target_type /
p2p_target_id fields. Under bot identity:

  - --types=p2p alone is rejected at validation
  - --types=p2p,group is silently downgraded to types=group (no runtime
    notice; skill docs document this contract)

Updates Shortcut.Description, lark-im SKILL.md (frontmatter trigger
+ shortcut table row), and the chat-list reference doc with command
examples, the new parameter, output field documentation, and a
dedicated "Bot identity and p2p" section.

Change-Id: I637ce23b3c6ce4ec350f0ac26dbac8120761bb71
2026-05-29 15:29:37 +08:00
syh-cpdsss
0a2c3202cb fix: whiteboard skill (#1166)
Change-Id: Ib1da37c1520d7697eaee7146555185ffbc749217
2026-05-29 14:23:11 +08:00
JackZhao10086
176d452cc1 feat: add agent header support (#1158)
* feat: add agent header support
2026-05-29 13:44:15 +08:00
liangshuo-1
a2cc5e124e fix(install): detect curl version before using --ssl-revoke-best-effort (#1124)
* fix(install): detect curl version before using --ssl-revoke-best-effort

(cherry picked from commit da14737702)

* test(install): cover curl version gate and refactor for testability

Extract the version comparison out of curlSupportsSslRevokeBestEffort()
into a pure isCurlVersionSupported(output), so the >= 7.70.0 logic is unit
testable without spawning curl. Add cases for 7.55.1 / 7.69.0 / 7.70.0 /
8.x plus the unparseable and libcurl-token edge cases (the regex must read
the leading "curl X.Y.Z", not the trailing "libcurl/X.Y.Z").

Memoize the `curl --version` probe: curl's version is invariant for the
install's lifetime while download() runs once per mirror URL, so probe at
most once instead of re-spawning curl on every attempt.

---------

Co-authored-by: EllienTang <146210093+Ellien-Tang@users.noreply.github.com>
Co-authored-by: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com>
2026-05-28 22:51:16 +08:00
29 changed files with 2261 additions and 66 deletions

View File

@@ -2,6 +2,21 @@
All notable changes to this project will be documented in this file.
## [v1.0.44] - 2026-05-29
### Features
- **base**: Add dashboard block data shortcut and workflow docs (#1067)
- **im**: Support `--types` flag for listing p2p single chats in `chat-list` (#1077)
- **agent**: Add agent header support (#1158)
### Bug Fixes
- **im**: Correct 64-bit MP4 box size handling to prevent panic on crafted media (#1165)
- **install**: Detect curl version before using `--ssl-revoke-best-effort` (#1124)
- **vc**: Correct `--minute-token` to `--minute-tokens` in recording reference (#1170)
- **whiteboard**: Fix whiteboard skill (#1166)
## [v1.0.43] - 2026-05-28
### Features
@@ -933,6 +948,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44
[v1.0.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43
[v1.0.42]: https://github.com/larksuite/cli/releases/tag/v1.0.42
[v1.0.41]: https://github.com/larksuite/cli/releases/tag/v1.0.41

View File

@@ -6,15 +6,18 @@ package cmdutil
import (
"context"
"net/http"
"os"
"reflect"
"runtime/debug"
"strings"
"sync"
"unicode"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/envvars"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
@@ -24,6 +27,7 @@ const (
HeaderBuild = "X-Cli-Build"
HeaderShortcut = "X-Cli-Shortcut"
HeaderExecutionId = "X-Cli-Execution-Id"
HeaderAgentTrace = "X-Agent-Trace"
SourceValue = "lark-cli"
@@ -36,6 +40,8 @@ const (
BuildKindUnknown = "unknown"
officialModulePath = "github.com/larksuite/cli"
agentTraceMaxLen = 256
)
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
@@ -43,6 +49,25 @@ func UserAgentValue() string {
return SourceValue + "/" + build.Version
}
// AgentTraceValue returns a header-safe value from the
// LARKSUITE_CLI_AGENT_TRACE environment variable. It trims
// surrounding whitespace, rejects values containing any Unicode
// control character or exceeding agentTraceMaxLen, and returns ""
// for any invalid or empty value. Callers can use the result
// directly in HTTP headers without further sanitisation.
func AgentTraceValue() string {
v := strings.TrimSpace(os.Getenv(envvars.CliAgentTrace))
if v == "" || len(v) > agentTraceMaxLen {
return ""
}
for _, r := range v {
if unicode.IsControl(r) {
return ""
}
}
return v
}
// BaseSecurityHeaders returns headers that every request must carry.
func BaseSecurityHeaders() http.Header {
h := make(http.Header)
@@ -50,6 +75,9 @@ func BaseSecurityHeaders() http.Header {
h.Set(HeaderVersion, build.Version)
h.Set(HeaderBuild, DetectBuildKind())
h.Set(HeaderUserAgent, UserAgentValue())
if v := AgentTraceValue(); v != "" {
h.Set(HeaderAgentTrace, v)
}
return h
}

View File

@@ -6,10 +6,12 @@ package cmdutil
import (
"context"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/extension/credential"
envcred "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
@@ -260,3 +262,134 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
}
}
}
// ---------------------------------------------------------------------------
// AgentTraceValue / HeaderAgentTrace
// ---------------------------------------------------------------------------
func TestAgentTraceValue_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty when env unset", got)
}
}
func TestAgentTraceValue_ReturnsCleanValue(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "trace-abc-123")
if got := AgentTraceValue(); got != "trace-abc-123" {
t.Fatalf("AgentTraceValue() = %q, want %q", got, "trace-abc-123")
}
}
func TestAgentTraceValue_TrimsWhitespace(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
if got := AgentTraceValue(); got != "trace-trim" {
t.Fatalf("AgentTraceValue() = %q, want %q (whitespace trimmed)", got, "trace-trim")
}
}
func TestAgentTraceValue_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " ")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for whitespace-only value", got)
}
}
func TestAgentTraceValue_RejectsCRLF(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for CR/LF value", got)
}
}
func TestAgentTraceValue_RejectsLF(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for LF value", got)
}
}
func TestAgentTraceValue_RejectsTab(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\tinjected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for tab value", got)
}
}
func TestAgentTraceValue_RejectsControlChar(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\x01injected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for control char value", got)
}
}
func TestAgentTraceValue_RejectsDEL(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\x7finjected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for DEL value", got)
}
}
func TestAgentTraceValue_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentTraceMaxLen+1)
t.Setenv(envvars.CliAgentTrace, longVal)
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
}
}
func TestAgentTraceValue_AcceptsMaxLengthValue(t *testing.T) {
val := strings.Repeat("a", agentTraceMaxLen)
t.Setenv(envvars.CliAgentTrace, val)
if got := AgentTraceValue(); got != val {
t.Fatalf("AgentTraceValue() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
}
}
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentTrace); v != "" {
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent when env unset", HeaderAgentTrace, v)
}
}
func TestBaseSecurityHeaders_IncludesAgentTraceHeaderWhenEnvSet(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "trace-xyz-789")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentTrace); v != "trace-xyz-789" {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderAgentTrace, v, "trace-xyz-789")
}
}
func TestBaseSecurityHeaders_AgentTraceTrimmedWhitespace(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentTrace); v != "trace-trim" {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q (whitespace trimmed)", HeaderAgentTrace, v, "trace-trim")
}
}
func TestBaseSecurityHeaders_AgentTraceOnlyWhitespace_Skipped(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " ")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentTrace); v != "" {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want absent for whitespace-only value", HeaderAgentTrace, v)
}
}
func TestBaseSecurityHeaders_AgentTraceRejectsCRLFInjection(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentTrace); v != "" {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want absent for CR/LF value", HeaderAgentTrace, v)
}
}
func TestBaseSecurityHeaders_AgentTraceRejectsLFInjection(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentTrace); v != "" {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want absent for LF value", HeaderAgentTrace, v)
}
}

View File

@@ -18,4 +18,6 @@ const (
// Content safety scanning mode
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
)

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.43",
"version": "1.0.44",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -110,6 +110,52 @@ function getMirrorUrls(env) {
return urls;
}
/**
* Decide from a `curl --version` output whether curl is >= 7.70.0 — the
* release (2020-04-29) that introduced --ssl-revoke-best-effort. Kept pure
* (no I/O) so the version-comparison logic can be unit tested without
* spawning a process. Reads the leading "curl X.Y.Z" token, ignoring the
* trailing "libcurl/X.Y.Z" that may report a different version.
*
* @param {string} versionOutput raw stdout of `curl --version`
* @returns {boolean} true when the parsed version is >= 7.70.0
*/
function isCurlVersionSupported(versionOutput) {
const match = String(versionOutput).match(/^\s*curl\s+(\d+)\.(\d+)\.(\d+)/i);
if (!match) return false;
const major = parseInt(match[1], 10);
const minor = parseInt(match[2], 10);
return major > 7 || (major === 7 && minor >= 70);
}
// Memoized probe result. curl's version is invariant for the lifetime of the
// install, while download() runs once per mirror URL — so probe at most once.
let _curlSupportsSslRevokeBestEffort;
/**
* Detect whether the system curl supports --ssl-revoke-best-effort. Older
* versions (notably the curl 7.55.1 shipped with older Windows 10 builds)
* exit with "unknown option" if the flag is passed.
*
* @returns {boolean} true when curl >= 7.70.0 is available
*/
function curlSupportsSslRevokeBestEffort() {
if (_curlSupportsSslRevokeBestEffort !== undefined) {
return _curlSupportsSslRevokeBestEffort;
}
try {
const output = execFileSync("curl", ["--version"], {
stdio: ["ignore", "pipe", "ignore"],
encoding: "utf8",
timeout: 5000,
});
_curlSupportsSslRevokeBestEffort = isCurlVersionSupported(output);
} catch (_) {
_curlSupportsSslRevokeBestEffort = false;
}
return _curlSupportsSslRevokeBestEffort;
}
function download(url, destPath) {
assertAllowedHost(url);
const args = [
@@ -119,8 +165,11 @@ function download(url, destPath) {
"--output", destPath,
];
// --ssl-revoke-best-effort: on Windows (Schannel), avoid CRYPT_E_REVOCATION_OFFLINE
// errors when the certificate revocation list server is unreachable
if (isWindows) args.unshift("--ssl-revoke-best-effort");
// errors when the certificate revocation list server is unreachable.
// Only use it when the system curl is new enough (>= 7.70.0).
if (isWindows && curlSupportsSslRevokeBestEffort()) {
args.unshift("--ssl-revoke-best-effort");
}
args.push(url);
execFileSync("curl", args, { stdio: ["ignore", "ignore", "pipe"] });
}
@@ -294,4 +343,4 @@ if (require.main === module) {
}
}
module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls };
module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls, curlSupportsSslRevokeBestEffort, isCurlVersionSupported };

View File

@@ -9,7 +9,7 @@ const os = require("os");
const crypto = require("crypto");
const { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls } = require("./install.js");
const { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls, isCurlVersionSupported } = require("./install.js");
describe("getExpectedChecksum", () => {
function makeTmpChecksums(content) {
@@ -278,3 +278,55 @@ describe("resolveMirrorUrls", () => {
);
});
});
describe("isCurlVersionSupported", () => {
// --ssl-revoke-best-effort was introduced in curl 7.70.0; below that the
// flag is unknown and `curl` exits non-zero (see issue #1099).
it("returns false for curl 7.55.1 (older Windows 10, flag unknown)", () => {
assert.equal(
isCurlVersionSupported("curl 7.55.1 (x86_64-pc-win32) libcurl/7.55.1"),
false
);
});
it("returns false for curl 7.69.0 (just below the 7.70.0 threshold)", () => {
assert.equal(
isCurlVersionSupported("curl 7.69.0 (x86_64-pc-win32) libcurl/7.69.0"),
false
);
});
it("returns true for curl 7.70.0 (flag introduced here)", () => {
assert.equal(
isCurlVersionSupported("curl 7.70.0 (x86_64-pc-win32) libcurl/7.70.0"),
true
);
});
it("returns true for a future major (curl 8.x)", () => {
assert.equal(
isCurlVersionSupported("curl 8.5.0 (x86_64-apple-darwin) libcurl/8.5.0"),
true
);
});
it("returns false when no version can be parsed", () => {
assert.equal(isCurlVersionSupported("not a curl version string"), false);
assert.equal(isCurlVersionSupported(""), false);
});
it("reads the leading 'curl X.Y.Z', not the trailing libcurl/X.Y.Z", () => {
// Guards the regex against latching onto "libcurl/7.55.1" when the
// curl binary itself is new enough.
assert.equal(
isCurlVersionSupported("curl 8.0.0 (x86_64) libcurl/7.55.1"),
true
);
});
it("does not match a 'libcurl X.Y.Z' token (anchored to leading curl)", () => {
// "libcurl 8.0.0" contains the substring "curl 8.0.0"; the leading
// anchor keeps it from being mistaken for a real curl version line.
assert.equal(isCurlVersionSupported("libcurl 8.0.0"), false);
});
});

View File

@@ -268,6 +268,39 @@ func TestBaseDashboardBlockExecuteGet(t *testing.T) {
})
}
// TestBaseDashboardBlockExecuteGetData tests the +dashboard-block-get-data command.
func TestBaseDashboardBlockExecuteGetData(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/dashboards/blocks/blk_chart/data",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"dimensions": []interface{}{
map[string]interface{}{"field_name": "文本", "alias": "dim_text"},
},
"measures": []interface{}{
map[string]interface{}{"field_name": "Bitable_Dashboard_Count", "aggregation": "count_all", "alias": "me_count"},
},
"main_data": []interface{}{
map[string]interface{}{
"dim_text": map[string]interface{}{"value": "A"},
"me_count": map[string]interface{}{"value": 3},
},
},
},
},
})
if err := runShortcut(t, BaseDashboardBlockGetData, []string{"+dashboard-block-get-data", "--base-token", "app_x", "--block-id", "blk_chart"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"dimensions"`) || !strings.Contains(got, `"main_data"`) || !strings.Contains(got, `"dim_text"`) {
t.Fatalf("stdout=%s", got)
}
}
// TestBaseDashboardBlockExecuteCreate tests the +dashboard-block-create command.
func TestBaseDashboardBlockExecuteCreate(t *testing.T) {
t.Run("with data-config", func(t *testing.T) {
@@ -537,6 +570,19 @@ func TestBaseDashboardBlockDryRun_Get(t *testing.T) {
}
}
// TestBaseDashboardBlockDryRun_GetData tests the +dashboard-block-get-data --dry-run flag.
func TestBaseDashboardBlockDryRun_GetData(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
args := []string{"+dashboard-block-get-data", "--base-token", "app_x", "--block-id", "blk_a", "--dry-run", "--format", "pretty"}
if err := runShortcut(t, BaseDashboardBlockGetData, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "GET /open-apis/base/v3/bases/app_x/dashboards/blocks/blk_a/data") || !strings.Contains(got, "blk_a") {
t.Fatalf("stdout=%s", got)
}
}
// TestBaseDashboardBlockDryRun_Create tests the +dashboard-block-create --dry-run flag.
func TestBaseDashboardBlockDryRun_Create(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)

View File

@@ -146,7 +146,7 @@ func TestShortcutsCatalog(t *testing.T) {
"+form-questions-create", "+form-questions-delete", "+form-questions-update", "+form-questions-list",
"+form-submit",
"+dashboard-list", "+dashboard-get", "+dashboard-create", "+dashboard-update", "+dashboard-delete", "+dashboard-arrange",
"+dashboard-block-list", "+dashboard-block-get", "+dashboard-block-create", "+dashboard-block-update", "+dashboard-block-delete",
"+dashboard-block-list", "+dashboard-block-get", "+dashboard-block-get-data", "+dashboard-block-create", "+dashboard-block-update", "+dashboard-block-delete",
}
if len(shortcuts) != len(want) {
t.Fatalf("len(shortcuts)=%d want=%d", len(shortcuts), len(want))

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
var BaseDashboardBlockGetData = common.Shortcut{
Service: "base",
Command: "+dashboard-block-get-data",
Description: "Get computed data for a dashboard chart block",
Risk: "read",
Scopes: []string{"base:dashboard:read"},
AuthTypes: authTypes(),
HasFormat: true,
Flags: []common.Flag{
baseTokenFlag(true),
blockIDFlag(true),
},
Tips: []string{
"lark-cli base +dashboard-block-get-data --base-token <base_token> --block-id <block_id>",
"Use +dashboard-block-get first when you need block metadata like name, type, or data_config.",
"This command returns computed chart protocol JSON directly, not wrapped block metadata.",
"Text blocks do not have computed chart data; this shortcut is for chart/statistics blocks.",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return dryRunDashboardBlockGetData(ctx, runtime)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeDashboardBlockGetData(runtime)
},
}

View File

@@ -104,6 +104,14 @@ func dryRunDashboardBlockGet(_ context.Context, runtime *common.RuntimeContext)
Params(params)
}
// dryRunDashboardBlockGetData returns a DryRunAPI for getting computed data for a dashboard block.
func dryRunDashboardBlockGetData(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET("/open-apis/base/v3/bases/:base_token/dashboards/blocks/:block_id/data").
Set("base_token", runtime.Str("base-token")).
Set("block_id", runtime.Str("block-id"))
}
// dryRunDashboardBlockCreate returns a DryRunAPI for creating a dashboard block.
func dryRunDashboardBlockCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
@@ -261,6 +269,16 @@ func executeDashboardBlockGet(runtime *common.RuntimeContext) error {
return nil
}
// executeDashboardBlockGetData retrieves computed data for a dashboard chart block.
func executeDashboardBlockGetData(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "dashboards", "blocks", runtime.Str("block-id"), "data"), nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
}
// executeDashboardBlockCreate creates a new dashboard block.
func executeDashboardBlockCreate(runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)

View File

@@ -84,6 +84,7 @@ func Shortcuts() []common.Shortcut {
BaseDashboardArrange,
BaseDashboardBlockList,
BaseDashboardBlockGet,
BaseDashboardBlockGetData,
BaseDashboardBlockCreate,
BaseDashboardBlockUpdate,
BaseDashboardBlockDelete,

View File

@@ -543,7 +543,17 @@ func findMP4Box(data []byte, start, end int, boxType string) (int, int) {
if offset+16 > end {
return -1, -1
}
boxEnd = int(binary.BigEndian.Uint64(data[offset+8:]))
// 64-bit "largesize" is the whole box length including its 16-byte
// header, so the box ends at offset+largesize (mirroring the
// offset+size used for 32-bit boxes below). Reject sizes that do not
// fit the search window; this also rejects values that would
// overflow int and drive boxEnd negative (CWE-190), which would
// otherwise index data out of range and panic.
largesize := binary.BigEndian.Uint64(data[offset+8:])
if largesize < 16 || largesize > uint64(end-offset) {
return -1, -1
}
boxEnd = offset + int(largesize)
dataStart = offset + 16
default:
if size < 8 {
@@ -688,7 +698,16 @@ func readMp4DurationBytes(data []byte) int64 {
if offset+16 > fileSize {
return 0
}
boxEnd = int64(binary.BigEndian.Uint64(data[offset+8 : offset+16]))
// 64-bit "largesize" is the whole box length including its 16-byte
// header, so the box ends at offset+largesize (mirroring offset+size
// for 32-bit boxes). Reject sizes that do not fit the file; this also
// rejects values that would overflow int64 and drive boxEnd negative
// (CWE-190), which would otherwise index data out of range and panic.
largesize := binary.BigEndian.Uint64(data[offset+8 : offset+16])
if largesize < 16 || largesize > uint64(fileSize-offset) {
return 0
}
boxEnd = offset + int64(largesize)
dataStart = offset + 16
case size < 8:
return 0
@@ -749,7 +768,16 @@ func readMp4Duration(f fileio.File, fileSize int64) int64 {
if _, err := f.ReadAt(hdr[8:16], offset+8); err != nil {
return 0
}
boxEnd = int64(binary.BigEndian.Uint64(hdr[8:16]))
// 64-bit "largesize" is the whole box length including its 16-byte
// header, so the box ends at offset+largesize (mirroring offset+size
// for 32-bit boxes). Reject sizes that do not fit the file; this also
// rejects values that would overflow int64 and drive boxEnd negative
// (CWE-190).
largesize := binary.BigEndian.Uint64(hdr[8:16])
if largesize < 16 || largesize > uint64(fileSize-offset) {
return 0
}
boxEnd = offset + int64(largesize)
dataStart = offset + 16
case size < 8:
return 0

View File

@@ -7,6 +7,7 @@ import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
@@ -15,13 +16,31 @@ import (
// imChatListPath is the upstream HTTP path for the +chat-list shortcut.
const imChatListPath = "/open-apis/im/v1/chats"
// bot_strip_p2p is the request-level adjustment notice emitted when bot
// identity receives a mixed --types containing "p2p": the p2p value is
// removed from the outgoing query (which the API would otherwise reject)
// and the caller is informed via a stderr warning + a structured entry
// in outData["notices"]. This is a notice, not a filter — it lives in a
// separate slot from outData["filter"] so the two never collide.
const (
botStripP2pCode = "bot_strip_p2p"
botStripP2pMessage = "To protect user privacy, bot identity cannot list p2p chats; --types=p2p,group was sent as types=group. Use --as user to include p2p."
)
// writeBotStripP2pWarning prints the bot_strip_p2p adjustment to stderr in
// the repo's standard "warning: <code>: <message>" form (matches the format
// used in shortcuts/common/runner.go's unknown-format fallback).
func writeBotStripP2pWarning(errOut io.Writer) {
fmt.Fprintf(errOut, "warning: %s: %s\n", botStripP2pCode, botStripP2pMessage)
}
// ImChatList is the +chat-list shortcut: wraps GET /open-apis/im/v1/chats to
// list groups the current user/bot is a member of. Supports sort order,
// pagination, and (user identity only) muted-chat filtering via --exclude-muted.
var ImChatList = common.Shortcut{
Service: "im",
Command: "+chat-list",
Description: "List groups the current user/bot is a member of; user/bot; supports sorting, pagination, and --exclude-muted (user identity only)",
Description: "List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only)",
Risk: "read",
Scopes: []string{"im:chat:read"},
AuthTypes: []string{"user", "bot"},
@@ -29,28 +48,53 @@ var ImChatList = common.Shortcut{
Flags: []common.Flag{
{Name: "user-id-type", Default: "open_id", Desc: "ID type for owner_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
{Name: "sort-type", Default: "ByCreateTimeAsc", Desc: "sort order", Enum: []string{"ByCreateTimeAsc", "ByActiveTimeDesc"}},
{Name: "types", Type: "string_slice", Desc: "chat types to include (group, p2p); omit = groups only (backward compatible); p2p requires user identity"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
{Name: "page-token", Desc: "pagination token for next page"},
{Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"},
},
// DryRun previews the GET /open-apis/im/v1/chats request without executing.
// When bot identity strips p2p from --types, emits the same stderr warning
// Execute would emit, so DryRun output truthfully reflects what the API
// will receive (matches the shortcuts/drive/drive_search.go pattern of
// echoing request-level adjustments in both DryRun and Execute).
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
effective, stripped, _ := resolveTypes(runtime) // Validate has already guaranteed err == nil
if stripped {
writeBotStripP2pWarning(runtime.IO().ErrOut)
}
return common.NewDryRunAPI().
GET(imChatListPath).
Params(buildChatListParams(runtime))
Params(buildChatListParams(runtime, effective))
},
// Validate enforces flag preconditions; only --page-size has bounds (1-100).
// Validate enforces flag preconditions: page-size bounds, --types element
// enum, and the bot + single-p2p rejection (mixed types degrade in Execute).
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if n := runtime.Int("page-size"); n < 1 || n > 100 {
return output.ErrValidation("--page-size must be an integer between 1 and 100")
}
parts, err := normalizeTypes(runtime.StrSlice("types"))
if err != nil {
return err
}
if len(parts) == 1 && parts[0] == "p2p" && runtime.IsBot() {
return output.ErrValidation(
`--types=p2p (single chats) is only supported with user identity (--as user). To protect user privacy, bot identity cannot list p2p chats. Use --as user, or include "group" in --types.`)
}
return nil
},
// Execute fetches one page of chats, optionally applies --exclude-muted
// via MaybeApplyMuteFilter, and renders the result. outData["filter"] is
// populated only when --exclude-muted is set (backward compatible).
// outData["notices"] is populated only when bot identity strips p2p from
// --types — a request-level adjustment that lives in its own slot so it
// never collides with the row-level mute filter.
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
params := buildChatListParams(runtime)
effective, stripped, _ := resolveTypes(runtime) // Validate guarantees err == nil
if stripped {
writeBotStripP2pWarning(runtime.IO().ErrOut)
}
params := buildChatListParams(runtime, effective)
resData, err := runtime.CallAPI("GET", imChatListPath, params, nil)
if err != nil {
return err
@@ -88,6 +132,11 @@ var ImChatList = common.Shortcut{
if mfOut.Meta.Applied != "" {
outData["filter"] = MuteFilterMetaToMap(mfOut.Meta)
}
if stripped {
outData["notices"] = []map[string]interface{}{
{"code": botStripP2pCode, "message": botStripP2pMessage},
}
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
if len(items) == 0 {
@@ -115,6 +164,17 @@ var ImChatList = common.Shortcut{
if status, _ := m["chat_status"].(string); status != "" {
row["chat_status"] = status
}
if chatMode, _ := m["chat_mode"].(string); chatMode != "" {
row["chat_mode"] = chatMode
if chatMode == "p2p" {
if pt, _ := m["p2p_target_type"].(string); pt != "" {
row["p2p_target_type"] = pt
}
if pid, _ := m["p2p_target_id"].(string); pid != "" {
row["p2p_target_id"] = pid
}
}
}
rows = append(rows, row)
}
output.PrintTable(w, rows)
@@ -135,11 +195,76 @@ var ImChatList = common.Shortcut{
},
}
// buildChatListParams builds the query parameters for the GET /im/v1/chats
// call from the runtime flag values. user_id_type and sort_type are always
// present (their flag defaults are non-empty); page_token is omitted when
// empty; page_size falls back to the API default of 20 when not provided.
func buildChatListParams(runtime *common.RuntimeContext) map[string]interface{} {
// normalizeTypes validates and normalizes the --types slice already parsed by cobra.
// cobra's StringSlice handles the CSV split automatically — both --types=p2p,group
// and repeated --types p2p --types group arrive here as a 2-element []string,
// so this function never re-splits on commas.
// Returns the normalized (lowercased, deduped, in input order) parts on success.
// Empty raw input is a no-op (returns nil, nil).
// Returns ErrValidation when any element is empty or outside {"p2p", "group"}.
func normalizeTypes(raw []string) ([]string, error) {
if len(raw) == 0 {
return nil, nil
}
seen := make(map[string]struct{}, len(raw))
out := make([]string, 0, len(raw))
for _, p := range raw {
p = strings.TrimSpace(strings.ToLower(p))
if p == "" {
return nil, output.ErrValidation("--types must contain at least one of p2p, group")
}
if p != "p2p" && p != "group" {
return nil, output.ErrValidation("--types contains invalid value %q: expected one of p2p, group", p)
}
if _, dup := seen[p]; dup {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
return out, nil
}
// resolveTypes layers bot identity downgrade on top of normalizeTypes.
// Under bot identity, "p2p" is stripped from the parts and the caller is
// informed (DryRun / Execute emit a stderr warning; Execute additionally
// writes a structured entry under outData["notices"]).
// Validate has already rejected "bot + parts == ['p2p']" cases, so kept is
// never empty here.
//
// Returns (effective CSV, stripped, err):
// - effective: comma-joined types to send as the API query param
// - stripped: true iff bot identity removed "p2p" from a mixed --types value
// - err: forwarded from normalizeTypes
func resolveTypes(runtime *common.RuntimeContext) (string, bool, error) {
parts, err := normalizeTypes(runtime.StrSlice("types"))
if err != nil {
return "", false, err
}
if !runtime.IsBot() {
return strings.Join(parts, ","), false, nil
}
// Bot identity: strip "p2p" so the API call succeeds with just groups.
// Validate has already rejected the "bot + only p2p" case, so kept is never empty here.
// Allocate a fresh slice (rather than aliasing parts[:0]) — parts has at most 2
// elements so the cost is negligible, and avoiding shared backing storage removes
// a class of "two slices, same array" surprises if a future caller keeps parts.
stripped := false
kept := make([]string, 0, len(parts))
for _, p := range parts {
if p == "p2p" {
stripped = true
continue
}
kept = append(kept, p)
}
return strings.Join(kept, ","), stripped, nil
}
// buildChatListParams builds the query parameters. effectiveTypes is the
// CSV string already normalized + bot-stripped by resolveTypes; pass "" to
// omit the types query param entirely (backward compatible default).
func buildChatListParams(runtime *common.RuntimeContext, effectiveTypes string) map[string]interface{} {
params := map[string]interface{}{
"user_id_type": runtime.Str("user-id-type"),
"sort_type": runtime.Str("sort-type"),
@@ -152,5 +277,8 @@ func buildChatListParams(runtime *common.RuntimeContext) map[string]interface{}
if pt := runtime.Str("page-token"); pt != "" {
params["page_token"] = pt
}
if effectiveTypes != "" {
params["types"] = effectiveTypes
}
return params
}

View File

@@ -4,26 +4,41 @@
package im
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// newChatListTestRuntimeContext mirrors newMessagesSearchTestRuntimeContext
// it registers page-size as Int (the existing newTestRuntimeContext registers
// it as String, which would short-circuit our buildChatListParams logic).
// newChatListTestRuntimeContext registers flags and returns a user-identity runtime context.
func newChatListTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
return newChatListTestRuntimeContextWithIdentity(t, stringFlags, boolFlags, core.AsUser)
}
// newChatListTestRuntimeContextWithIdentity is the identity-aware variant.
func newChatListTestRuntimeContextWithIdentity(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool, as core.Identity) *common.RuntimeContext {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 20, "")
for name := range stringFlags {
if name == "page-size" {
continue
}
cmd.Flags().String(name, "", "")
if name == "types" {
cmd.Flags().StringSlice(name, nil, "")
} else {
cmd.Flags().String(name, "", "")
}
}
for name := range boolFlags {
cmd.Flags().Bool(name, false, "")
@@ -37,11 +52,22 @@ func newChatListTestRuntimeContext(t *testing.T, stringFlags map[string]string,
}
}
for name, val := range boolFlags {
if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[val]); err != nil {
if err := cmd.Flags().Set(name, strconv.FormatBool(val)); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
return &common.RuntimeContext{Cmd: cmd}
rt := common.TestNewRuntimeContextWithIdentity(cmd, nil, as)
// Attach a minimal Factory with IOStreams so DryRun / Execute paths that
// emit stderr warnings (e.g. bot_strip_p2p) don't panic on runtime.IO().
// Stays pure-logic — no HTTP client, no httpmock; integration tests use
// newBotShortcutRuntime / newUserShortcutRuntime for that.
rt.Factory = &cmdutil.Factory{
IOStreams: &cmdutil.IOStreams{
Out: &bytes.Buffer{},
ErrOut: &bytes.Buffer{},
},
}
return rt
}
func TestBuildChatListParams_Defaults(t *testing.T) {
@@ -49,7 +75,7 @@ func TestBuildChatListParams_Defaults(t *testing.T) {
"user-id-type": "open_id",
"sort-type": "ByCreateTimeAsc",
}, nil)
got := buildChatListParams(rt)
got := buildChatListParams(rt, "")
if got["user_id_type"] != "open_id" {
t.Fatalf("user_id_type = %v", got["user_id_type"])
}
@@ -62,6 +88,9 @@ func TestBuildChatListParams_Defaults(t *testing.T) {
if _, present := got["page_token"]; present {
t.Fatalf("page_token should be omitted when empty")
}
if _, present := got["types"]; present {
t.Fatalf("types should be omitted when --types is empty")
}
}
func TestBuildChatListParams_Overrides(t *testing.T) {
@@ -71,7 +100,7 @@ func TestBuildChatListParams_Overrides(t *testing.T) {
"page-size": "50",
"page-token": "tok_xyz",
}, nil)
got := buildChatListParams(rt)
got := buildChatListParams(rt, "")
if got["user_id_type"] != "user_id" {
t.Fatalf("user_id_type = %v", got["user_id_type"])
}
@@ -126,3 +155,459 @@ func TestImChatList_DryRun_IncludesEndpoint(t *testing.T) {
t.Fatalf("DryRun missing page_size: %s", got)
}
}
func TestNormalizeTypes(t *testing.T) {
cases := []struct {
name string
raw []string
want []string
wantErr string // substring match
}{
{"empty returns nil no error", nil, nil, ""},
{"single p2p", []string{"p2p"}, []string{"p2p"}, ""},
{"single group", []string{"group"}, []string{"group"}, ""},
{"p2p,group preserves order", []string{"p2p", "group"}, []string{"p2p", "group"}, ""},
{"group,p2p preserves order", []string{"group", "p2p"}, []string{"group", "p2p"}, ""},
{"trim whitespace", []string{" p2p ", " group "}, []string{"p2p", "group"}, ""},
{"lowercase", []string{"P2P", "GROUP"}, []string{"p2p", "group"}, ""},
{"dedupe", []string{"p2p", "p2p"}, []string{"p2p"}, ""},
{"empty element rejected", []string{""}, nil, "must contain at least one of p2p, group"},
{"invalid element rejected", []string{"private"}, nil, `expected one of p2p, group`},
{"mixed invalid rejected", []string{"p2p", "private"}, nil, `expected one of p2p, group`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := normalizeTypes(c.raw)
if c.wantErr != "" {
if err == nil {
t.Fatalf("normalizeTypes(%v) err = nil; want substring %q", c.raw, c.wantErr)
}
if !strings.Contains(err.Error(), c.wantErr) {
t.Fatalf("normalizeTypes(%v) err = %v; want substring %q", c.raw, err, c.wantErr)
}
return
}
if err != nil {
t.Fatalf("normalizeTypes(%v) unexpected err = %v", c.raw, err)
}
if len(got) != len(c.want) {
t.Fatalf("normalizeTypes(%v) = %v; want %v", c.raw, got, c.want)
}
for i := range got {
if got[i] != c.want[i] {
t.Fatalf("normalizeTypes(%v)[%d] = %q; want %q", c.raw, i, got[i], c.want[i])
}
}
})
}
}
func TestResolveTypes(t *testing.T) {
cases := []struct {
name string
raw string
as core.Identity
wantEffective string
wantStripped bool
}{
{"user empty", "", core.AsUser, "", false},
{"user p2p", "p2p", core.AsUser, "p2p", false},
{"user p2p,group", "p2p,group", core.AsUser, "p2p,group", false},
{"user group,p2p preserves order", "group,p2p", core.AsUser, "group,p2p", false},
{"user normalized casing", "P2P,GROUP", core.AsUser, "p2p,group", false},
{"bot empty", "", core.AsBot, "", false},
{"bot group only", "group", core.AsBot, "group", false},
{"bot p2p,group strips p2p", "p2p,group", core.AsBot, "group", true},
{"bot group,p2p strips p2p", "group,p2p", core.AsBot, "group", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rt := newChatListTestRuntimeContextWithIdentity(t, map[string]string{"types": c.raw}, nil, c.as)
effective, stripped, err := resolveTypes(rt)
if err != nil {
t.Fatalf("resolveTypes() unexpected err = %v", err)
}
if effective != c.wantEffective {
t.Fatalf("effective = %q; want %q", effective, c.wantEffective)
}
if stripped != c.wantStripped {
t.Fatalf("stripped = %v; want %v", stripped, c.wantStripped)
}
})
}
}
func TestBuildChatListParams_TypesPassthrough(t *testing.T) {
rt := newChatListTestRuntimeContext(t, map[string]string{
"user-id-type": "open_id",
"sort-type": "ByCreateTimeAsc",
}, nil)
got := buildChatListParams(rt, "p2p,group")
if got["types"] != "p2p,group" {
t.Fatalf("types = %v; want \"p2p,group\"", got["types"])
}
}
func TestImChatList_Validate_Types(t *testing.T) {
cases := []struct {
name string
typesRaw string
as core.Identity
wantErr string // substring; "" means no error
}{
{"user empty ok", "", core.AsUser, ""},
{"user p2p ok", "p2p", core.AsUser, ""},
{"user group ok", "group", core.AsUser, ""},
{"user p2p,group ok", "p2p,group", core.AsUser, ""},
{"user invalid element rejected", "private", core.AsUser, "expected one of p2p, group"},
{"user comma-only rejected", ",", core.AsUser, "must contain at least one of p2p, group"},
{"bot empty ok", "", core.AsBot, ""},
{"bot group ok", "group", core.AsBot, ""},
{"bot p2p,group ok (degraded at Execute)", "p2p,group", core.AsBot, ""},
{"bot single p2p rejected", "p2p", core.AsBot, "only supported with user identity"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rt := newChatListTestRuntimeContextWithIdentity(t,
map[string]string{"types": c.typesRaw, "page-size": "20"},
nil, c.as)
err := ImChatList.Validate(context.Background(), rt)
if c.wantErr == "" {
if err != nil {
t.Fatalf("Validate() unexpected err = %v", err)
}
return
}
if err == nil {
t.Fatalf("Validate() err = nil; want substring %q", c.wantErr)
}
if !strings.Contains(err.Error(), c.wantErr) {
t.Fatalf("Validate() err = %v; want substring %q", err, c.wantErr)
}
})
}
}
// attachChatListCmd builds a cobra.Command pre-loaded with all flags ImChatList
// reads, applies stringFlags / boolFlags, and assigns it to runtime.Cmd. Format
// is forced to "json" so Execute output lands in a parseable form on
// runtime.Factory.IOStreams.Out.
func attachChatListCmd(t *testing.T, runtime *common.RuntimeContext, stringFlags map[string]string, boolFlags map[string]bool) {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 20, "")
cmd.Flags().String("user-id-type", "open_id", "")
cmd.Flags().String("sort-type", "ByCreateTimeAsc", "")
cmd.Flags().StringSlice("types", nil, "")
cmd.Flags().String("page-token", "", "")
cmd.Flags().Bool("exclude-muted", false, "")
cmd.Flags().Bool("dry-run", false, "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
for name, val := range stringFlags {
if err := cmd.Flags().Set(name, val); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
for name, val := range boolFlags {
if err := cmd.Flags().Set(name, strconv.FormatBool(val)); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
runtime.Cmd = cmd
runtime.Format = "json"
}
// chatListOutBuf retrieves the captured stdout buffer for assertions.
func chatListOutBuf(t *testing.T, runtime *common.RuntimeContext) *bytes.Buffer {
t.Helper()
buf, ok := runtime.Factory.IOStreams.Out.(*bytes.Buffer)
if !ok {
t.Fatalf("expected IOStreams.Out to be *bytes.Buffer")
}
return buf
}
// chatListErrBuf retrieves the captured stderr buffer for assertions
// (used to verify request-level warnings like `bot_strip_p2p`).
func chatListErrBuf(t *testing.T, runtime *common.RuntimeContext) *bytes.Buffer {
t.Helper()
buf, ok := runtime.Factory.IOStreams.ErrOut.(*bytes.Buffer)
if !ok {
t.Fatalf("expected IOStreams.ErrOut to be *bytes.Buffer")
}
return buf
}
func TestImChatList_Execute_BotStripsP2p(t *testing.T) {
var capturedURL string
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
capturedURL = req.URL.String()
body := `{"code":0,"msg":"ok","data":{"items":[{"chat_id":"oc_g","name":"G","chat_mode":"group"}],"has_more":false,"page_token":""}}`
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
}))
attachChatListCmd(t, rt, map[string]string{"types": "p2p,group"}, nil)
if err := ImChatList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() err = %v", err)
}
if !strings.Contains(capturedURL, "types=group") {
t.Fatalf("request URL = %s; want types=group (bot strips p2p)", capturedURL)
}
if strings.Contains(capturedURL, "p2p") {
t.Fatalf("request URL = %s; must NOT contain p2p (bot stripped it)", capturedURL)
}
// Structured notice: outData["notices"] contains a {code, message} entry
// for bot_strip_p2p (request-level adjustment, not a row-level filter).
out := chatListOutBuf(t, rt).String()
for _, want := range []string{`"notices"`, `"code": "bot_strip_p2p"`, `"message"`} {
if !strings.Contains(out, want) {
t.Fatalf("stdout JSON missing notice field %q:\n%s", want, out)
}
}
// filter slot must remain mute-scoped: bot_strip_p2p must not leak into
// outData["filter"].applied (no priority conflict by design).
if strings.Contains(out, `"applied": "bot_strip_p2p"`) {
t.Fatalf("bot_strip_p2p should not appear in filter.applied (separate slot):\n%s", out)
}
// Stderr: matches repo `warning: <code>: <message>` convention (cf.
// shortcuts/common/runner.go unknown-format fallback).
errOut := chatListErrBuf(t, rt).String()
if !strings.Contains(errOut, "warning: bot_strip_p2p:") {
t.Fatalf("stderr missing `warning: bot_strip_p2p:` prefix:\n%s", errOut)
}
}
// TestImChatList_DryRun_BotStripsP2pStderrNotice verifies the DryRun branch
// also emits the bot_strip_p2p warning to stderr so a previewed request
// truthfully reflects what Execute would send (drive_search.go DryRun parity).
func TestImChatList_DryRun_BotStripsP2pStderrNotice(t *testing.T) {
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Fatalf("DryRun should not make HTTP calls; got: %s", req.URL.String())
return nil, nil
}))
attachChatListCmd(t, rt, map[string]string{"types": "p2p,group"}, nil)
dr := ImChatList.DryRun(context.Background(), rt)
if dr == nil {
t.Fatalf("DryRun returned nil")
}
errOut := chatListErrBuf(t, rt).String()
if !strings.Contains(errOut, "warning: bot_strip_p2p:") {
t.Fatalf("DryRun stderr missing `warning: bot_strip_p2p:` prefix:\n%s", errOut)
}
}
func TestImChatList_RowRendering_P2pFields(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
body := `{"code":0,"msg":"ok","data":{"items":[
{"chat_id":"oc_g","name":"Group","chat_mode":"group","owner_id":"ou_owner"},
{"chat_id":"oc_p","name":"Peer","chat_mode":"p2p","p2p_target_type":"user","p2p_target_id":"ou_peer"}
],"has_more":false,"page_token":""}}`
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
}))
attachChatListCmd(t, rt, map[string]string{"types": "p2p,group"}, nil)
if err := ImChatList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() err = %v", err)
}
out := chatListOutBuf(t, rt).String()
for _, want := range []string{"oc_g", "oc_p", "Group", "Peer", `"chat_mode": "p2p"`, `"p2p_target_id": "ou_peer"`} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q; got: %s", want, out)
}
}
}
// TestImChatList_Execute_PrettyOutputRendersP2pRow exercises the pretty-format
// rendering closure in Execute, including the new chat_mode=="p2p" branch that
// surfaces p2p_target_type / p2p_target_id, and the has_more footer that
// echoes back the page_token.
func TestImChatList_Execute_PrettyOutputRendersP2pRow(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
body := `{"code":0,"msg":"ok","data":{"items":[
{"chat_id":"oc_g","name":"Group","chat_mode":"group","owner_id":"ou_owner","description":"a group","external":false,"chat_status":"normal"},
{"chat_id":"oc_p","name":"Peer","chat_mode":"p2p","p2p_target_type":"user","p2p_target_id":"ou_peer"}
],"has_more":true,"page_token":"next_tok"}}`
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
}))
attachChatListCmd(t, rt, map[string]string{"types": "p2p,group"}, nil)
rt.Format = "pretty"
if err := ImChatList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() err = %v", err)
}
out := chatListOutBuf(t, rt).String()
for _, want := range []string{"oc_g", "Group", "a group", "ou_owner", "normal"} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing group-row field %q:\n%s", want, out)
}
}
for _, want := range []string{"oc_p", "Peer", "p2p", "ou_peer"} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing p2p-row field %q:\n%s", want, out)
}
}
if !strings.Contains(out, "2 chat(s) listed") {
t.Fatalf("pretty output missing footer count:\n%s", out)
}
if !strings.Contains(out, "next_tok") {
t.Fatalf("pretty output missing page_token in has_more footer:\n%s", out)
}
}
func TestImChatList_DryRun_TypesPassthrough(t *testing.T) {
cases := []struct {
name string
as core.Identity
typesRaw string
wantSub string // substring expected in dry-run JSON
wantErr bool // whether Validate should reject before DryRun runs
}{
{"user p2p", core.AsUser, "p2p", `"types":"p2p"`, false},
{"user p2p,group", core.AsUser, "p2p,group", `"types":"p2p,group"`, false},
{"bot p2p,group strips to group", core.AsBot, "p2p,group", `"types":"group"`, false},
{"bot group passes", core.AsBot, "group", `"types":"group"`, false},
{"bot single p2p rejected at Validate", core.AsBot, "p2p", "", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rt := newChatListTestRuntimeContextWithIdentity(t, map[string]string{
"user-id-type": "open_id",
"sort-type": "ByCreateTimeAsc",
"page-size": "20",
"types": c.typesRaw,
}, nil, c.as)
if err := ImChatList.Validate(context.Background(), rt); err != nil {
if !c.wantErr {
t.Fatalf("Validate() unexpected err = %v", err)
}
return
}
if c.wantErr {
t.Fatalf("Validate() err = nil; want rejection")
}
got := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), rt))
if !strings.Contains(got, c.wantSub) {
t.Fatalf("DryRun = %s; want substring %q", got, c.wantSub)
}
})
}
}
func TestImChatList_RowRendering_ChatModeAbsent(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
// Response items deliberately omit chat_mode / p2p_target_* (legacy/defensive case).
body := `{"code":0,"msg":"ok","data":{"items":[
{"chat_id":"oc_g1","name":"Group1","owner_id":"ou_owner"},
{"chat_id":"oc_g2","name":"Group2","external":true}
],"has_more":false,"page_token":""}}`
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
}))
attachChatListCmd(t, rt, nil, nil) // no --types; default behavior
if err := ImChatList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() err = %v", err)
}
out := chatListOutBuf(t, rt).String()
// chat_mode / p2p_target_* must NOT appear since the API didn't return them.
for _, forbidden := range []string{`"chat_mode"`, `"p2p_target_type"`, `"p2p_target_id"`} {
// "chats[].chat_mode" is the row-level field — JSON envelope might include it as null or omit it;
// asserting the rendered table fields are missing is the goal.
// The JSON pass-through preserves whatever API returned (omitted here),
// so neither path should produce these strings.
if strings.Contains(out, forbidden) {
t.Fatalf("output unexpectedly contains %q (should not appear when API omitted these fields); got: %s", forbidden, out)
}
}
// Sanity: the two chat IDs must still be present (renderer didn't crash).
for _, want := range []string{"oc_g1", "oc_g2", "Group1", "Group2"} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q; got: %s", want, out)
}
}
}
func TestImChatList_Execute_UserMuteFiltersP2p(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
path := req.URL.Path
switch {
case strings.HasSuffix(path, "/im/v1/chats"):
body := `{"code":0,"msg":"ok","data":{"items":[
{"chat_id":"oc_g","name":"Group","chat_mode":"group","owner_id":"ou_owner"},
{"chat_id":"oc_p","name":"Peer","chat_mode":"p2p","p2p_target_type":"user","p2p_target_id":"ou_peer"}
],"has_more":false,"page_token":""}}`
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
case strings.HasSuffix(path, "/chat_user_setting/batch_get_mute_status"):
// Mark oc_p (the p2p) as muted; oc_g not muted.
body := `{"code":0,"msg":"ok","data":{"items":[
{"chat_id":"oc_g","is_muted":false},
{"chat_id":"oc_p","is_muted":true}
]}}`
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
}
t.Fatalf("unexpected request path: %s", path)
return nil, nil
}))
attachChatListCmd(t, rt,
map[string]string{"types": "p2p,group"},
map[string]bool{"exclude-muted": true})
if err := ImChatList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() err = %v", err)
}
out := chatListOutBuf(t, rt).String()
var parsed struct {
Data struct {
Chats []map[string]interface{} `json:"chats"`
Filter struct {
Applied string `json:"applied"`
FilteredCount int `json:"filtered_count"`
} `json:"filter"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("Unmarshal output failed: %v; raw: %s", err, out)
}
if parsed.Data.Filter.Applied != "exclude_muted" {
t.Fatalf("filter.applied = %q; want exclude_muted (no bot_strip_p2p under user). Raw: %s",
parsed.Data.Filter.Applied, out)
}
if parsed.Data.Filter.FilteredCount != 1 {
t.Fatalf("filter.filtered_count = %d; want 1 (the muted p2p row). Raw: %s",
parsed.Data.Filter.FilteredCount, out)
}
// The muted p2p row should be gone from chats; only oc_g remains.
if len(parsed.Data.Chats) != 1 {
t.Fatalf("expected 1 chat after muting; got %d. Raw: %s", len(parsed.Data.Chats), out)
}
if parsed.Data.Chats[0]["chat_id"] != "oc_g" {
t.Fatalf("remaining chat = %v; want oc_g", parsed.Data.Chats[0]["chat_id"])
}
}

View File

@@ -0,0 +1,85 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"encoding/binary"
"testing"
)
// build64BitBox builds an ISO-BMFF box using the 64-bit "largesize" form: the
// 32-bit size field is set to 1 and an 8-byte largesize follows the 4-byte box
// type. largesize is the total box length including the 16-byte header.
func build64BitBox(boxType string, largesize uint64, payload []byte) []byte {
box := make([]byte, 16+len(payload))
binary.BigEndian.PutUint32(box[0:4], 1) // size == 1 → 64-bit largesize follows
copy(box[4:8], boxType)
binary.BigEndian.PutUint64(box[8:16], largesize)
copy(box[16:], payload)
return box
}
// build32BitBox builds an ISO-BMFF box using the ordinary 32-bit size form.
func build32BitBox(boxType string, payload []byte) []byte {
box := make([]byte, 8+len(payload))
binary.BigEndian.PutUint32(box[0:4], uint32(len(box)))
copy(box[4:8], boxType)
copy(box[8:], payload)
return box
}
// TestMP4BoxLargeSizeOverflowNoPanic guards the 64-bit box-size branch against
// CWE-190 integer overflow. A largesize whose high bit is set converts to a
// negative offset; without a bounds guard that offset indexes the input slice
// out of range and panics, crashing the CLI on a crafted/corrupt MP4 (the
// in-memory walkers run on URL-sourced media that the caller does not control).
// The walkers' contract is best-effort: malformed input must return 0, not panic.
func TestMP4BoxLargeSizeOverflowNoPanic(t *testing.T) {
// A single top-level box in the 64-bit form with largesize = 2^64-1.
data := build64BitBox("ftyp", 0xFFFFFFFFFFFFFFFF, nil)
if got := readMp4DurationBytes(data); got != 0 {
t.Errorf("readMp4DurationBytes(overflow largesize) = %d, want 0", got)
}
if got := parseMp4Duration(data); got != 0 {
t.Errorf("parseMp4Duration(overflow largesize) = %d, want 0", got)
}
if start, end := findMP4Box(data, 0, len(data), "ftyp"); start != -1 || end != -1 {
t.Errorf("findMP4Box(overflow largesize) = (%d, %d), want (-1, -1)", start, end)
}
}
// TestMP4Box64BitSizeAtNonZeroOffset locks in correct handling of a 64-bit box
// that does not start at offset 0. boxEnd must be offset+largesize (as the
// 32-bit branch already does with offset+size); dropping the offset truncates
// the box and the duration is silently lost.
func TestMP4Box64BitSizeAtNonZeroOffset(t *testing.T) {
mvhd := buildMvhdBox(0, 1000, 5000) // timescale=1000, duration=5000 → 5000ms
// moov carried as a 64-bit box: largesize = 16-byte header + mvhd payload.
moov := build64BitBox("moov", uint64(16+len(mvhd)), mvhd)
// Precede moov with a 32-bit ftyp box so it sits at a non-zero offset —
// that is where the missing "offset +" surfaces.
data := append(build32BitBox("ftyp", []byte("isom")), moov...)
if got := readMp4DurationBytes(data); got != 5000 {
t.Errorf("readMp4DurationBytes(64-bit moov at offset>0) = %d, want 5000", got)
}
}
// TestFindMP4Box64BitSizeAtNonZeroOffset is the findMP4Box-level analogue: a
// 64-bit box preceding the target must advance the cursor by offset+largesize
// so the following box is located at the right position.
func TestFindMP4Box64BitSizeAtNonZeroOffset(t *testing.T) {
free := build64BitBox("free", 24, make([]byte, 8)) // 16-byte header + 8 bytes
target := build32BitBox("mvhd", []byte("payload!"))
data := append(free, target...)
start, end := findMP4Box(data, 0, len(data), "mvhd")
if start < 0 {
t.Fatalf("findMP4Box did not find mvhd after a 64-bit box (start=%d)", start)
}
if got := string(data[start:end]); got != "payload!" {
t.Errorf("findMP4Box returned %q, want %q", got, "payload!")
}
}

View File

@@ -97,6 +97,14 @@ func TestRegisterShortcutsMountsBaseCommands(t *testing.T) {
if workspaceCmd == nil || workspaceCmd.Name() != "+base-get" {
t.Fatalf("base workspace shortcut not mounted: %#v", workspaceCmd)
}
blockDataCmd, _, err := program.Find([]string{"base", "+dashboard-block-get-data"})
if err != nil {
t.Fatalf("find dashboard block get-data shortcut: %v", err)
}
if blockDataCmd == nil || blockDataCmd.Name() != "+dashboard-block-get-data" {
t.Fatalf("base dashboard block get-data shortcut not mounted: %#v", blockDataCmd)
}
}
// Service-level cobra commands created by RegisterShortcuts must carry

View File

@@ -1,6 +1,6 @@
---
name: lark-base
version: 1.2.1
version: 1.2.2
description: "当需要用 lark-cli 操作飞书多维表格Base时调用搜索 Base、建表、字段管理、记录读写、记录分享链接、视图配置、历史查询以及角色/表单/仪表盘管理/工作流;也适用于把旧的 +table / +field / +record 写法改成当前命令写法。涉及字段设计、公式字段、查找引用、跨表计算、行级派生指标、数据分析需求时也必须使用本 skill。"
metadata:
requires:
@@ -178,6 +178,7 @@ metadata:
| `+dashboard-list / +dashboard-get` | 列出仪表盘,或获取仪表盘详情 | [`lark-base-dashboard-list.md`](references/lark-base-dashboard-list.md)、[`lark-base-dashboard-get.md`](references/lark-base-dashboard-get.md)、[`lark-base-dashboard.md`](references/lark-base-dashboard.md) | 进入仪表盘语义后先读 guide`+dashboard-list` 只能串行执行 |
| `+dashboard-create / +dashboard-update / +dashboard-delete` | 创建、更新或删除仪表盘 | [`lark-base-dashboard-create.md`](references/lark-base-dashboard-create.md)、[`lark-base-dashboard-update.md`](references/lark-base-dashboard-update.md)、[`lark-base-dashboard-delete.md`](references/lark-base-dashboard-delete.md)、[`lark-base-dashboard.md`](references/lark-base-dashboard.md) | 创建前先明确看板目标和展示场景;更新前先读取当前配置;删除前先确认目标 |
| `+dashboard-block-list / +dashboard-block-get` | 列出图表组件,或获取单个 block 详情 | [`lark-base-dashboard-block-list.md`](references/lark-base-dashboard-block-list.md)、[`lark-base-dashboard-block-get.md`](references/lark-base-dashboard-block-get.md)、[`lark-base-dashboard.md`](references/lark-base-dashboard.md)、[`dashboard-block-data-config.md`](references/dashboard-block-data-config.md) | `+dashboard-block-list` 只能串行执行;查看配置细节时读 block config 文档 |
| `+dashboard-block-get-data` | 获取图表组件的计算结果 | [`lark-base-dashboard-block-get-data.md`](references/lark-base-dashboard-block-get-data.md)、[`lark-base-dashboard.md`](references/lark-base-dashboard.md)、[`dashboard-block-data-config.md`](references/dashboard-block-data-config.md) | 适合读取图表组件的最终计算结果;此命令不返回 block 元数据,只返回计算结果 |
| `+dashboard-block-create / +dashboard-block-update / +dashboard-block-delete` | 创建、更新或删除图表组件 | [`lark-base-dashboard-block-create.md`](references/lark-base-dashboard-block-create.md)、[`lark-base-dashboard-block-update.md`](references/lark-base-dashboard-block-update.md)、[`lark-base-dashboard-block-delete.md`](references/lark-base-dashboard-block-delete.md)、[`lark-base-dashboard.md`](references/lark-base-dashboard.md)、[`dashboard-block-data-config.md`](references/dashboard-block-data-config.md) | 涉及 `data_config`、图表类型、filter 时要读 block config 文档;删除前先确认目标 |
### 2.8 表单模块

View File

@@ -0,0 +1,717 @@
# base +dashboard-block-get-data
> **前置条件:** 先阅读 [lark-base-dashboard.md](lark-base-dashboard.md) 了解 dashboard 整体工作流。
获取仪表盘图表组件block的**最终计算结果**,返回一份适合 AI 直接消费的图表协议 JSON。
这个命令适合以下场景:
1. 读取柱状图 / 条形图 / 折线图 / 饼图 / 环形图 / 面积图 / 组合图 / 散点图 / 漏斗图 / 雷达图 / 词云 / 指标卡的**实际计算结果**
2. 把图表结果交给 AI 做后续总结、趋势解释、同比/环比说明、异常点提取;
3. 在**不读取原始记录**的前提下,直接消费图表层已经聚合好的结果;
4. 验证某个图表当前展示的数据是否符合预期。
> [!IMPORTANT]
> - 本命令返回的是**图表结果协议**,不是 block 元数据;
> - 如果你需要 `name`、`type`、`layout`、`data_config` 等配置,请先用 [`+dashboard-block-get`](lark-base-dashboard-block-get.md)
> - 文本组件(`text`)不涉及计算,不适用本命令;
## 一句话理解
`+dashboard-block-get-data` = **拿图表“算出来的结果”**,而不是拿图表“怎么配置的”。
---
## 支持的图表类型
当前支持以下图表类型的数据计算与返回:
### 二维图表10 种)
- 柱状图
- 条形图
- 折线图
- 饼图
- 环形图
- 面积图
- 组合图
- 散点图
- 漏斗图
- 雷达图
### 特殊类型2 种)
- 词云
- 指标卡statistics
> [!CAUTION]
> 文本组件虽然也属于 dashboard block但它不产生可计算数据因此不会返回本协议。
---
## 推荐命令
```bash
lark-cli base +dashboard-block-get-data \
--base-token bascn***************CtadY \
--block-id chtxxxxxxxx
```
如果你还不知道目标 block 的 ID典型顺序是
```bash
# 先看仪表盘里有哪些组件
lark-cli base +dashboard-block-list \
--base-token bascn***************CtadY \
--dashboard-id blkxxxxxxxx
# 再读取某个组件的最终计算结果
lark-cli base +dashboard-block-get-data \
--base-token bascn***************CtadY \
--block-id chtxxxxxxxx
```
如果你需要先确认组件类型、名称或 `data_config`,请先执行:
```bash
lark-cli base +dashboard-block-get \
--base-token bascn***************CtadY \
--dashboard-id blkxxxxxxxx \
--block-id chtxxxxxxxx
```
---
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--base-token <token>` | 是 | Base Token标识目标多维表格 |
| `--block-id <id>` | 是 | 图表 Block ID即目标组件的唯一标识 |
| `--format <fmt>` | 否 | 输出格式,遵循 CLI 全局输出格式规则 |
| `--dry-run` | 否 | 只预览 API 调用,不真正执行 |
> [!TIP]
> 这个命令**不需要** `--dashboard-id`。只要 `base_token + block_id` 即可定位并读取图表结果。
---
## 返回结构总览
服务端响应外层仍然是标准 OpenAPI 包装:
```json
{
"code": 0,
"msg": "success",
"data": {
"dimensions": [...],
"measures": [...],
"main_data": [...]
}
}
```
其中 `data` 就是 CLI 图表协议本体。不同图表类型的 `data` 结构略有不同:
| 图表类型 | 一定有 | 可能有 |
|----------|--------|--------|
| 二维图表 | `dimensions` / `measures` / `main_data` | 无 |
| 词云 | `dimensions` / `measures` / `main_data` | 无 |
| 指标卡 | `dimensions` / `measures` / `main_data` | `comparison_data` / `trend_data` |
---
## 协议字段说明
### 1) `dimensions`
维度定义数组,告诉你主结果里每个 `dim_*` key 代表什么字段。
```json
[
{
"field_name": "文本",
"alias": "dim_5bKp"
}
]
```
字段含义:
| 字段 | 说明 |
|------|------|
| `field_name` | 维度字段显示名称 |
| `alias` | 维度别名,在 `main_data` / `trend_data` 中作为 key 使用 |
### 2) `measures`
指标定义数组,告诉你每个 `me_*` key 代表什么聚合指标。
```json
[
{
"field_name": "Count",
"aggregation": "count_all",
"alias": "me_Y291bnRfYWxsX0NvdW50"
}
]
```
字段含义:
| 字段 | 说明 |
|------|------|
| `field_name` | 统计该指标时所使用的字段名称;当 `aggregation = count_all` 时固定为 `Count`,表示统计记录总数 |
| `aggregation` | 聚合方式,常见值:`count_all` / `count` / `sum` / `avg` / `min` / `max` |
| `alias` | 指标别名,在 `main_data` / `comparison_data` / `trend_data` 中作为 key 使用 |
例如:
- 如果统计“销售额”的求和,则 `field_name = 销售额``aggregation = sum`
- 如果统计记录总数,则 `field_name = Count``aggregation = count_all`
### 3) `main_data`
主结果集。每一行都是一个对象key 不是字段名本身,而是 `dimensions` / `measures` 中声明过的 `alias`
```json
[
{
"dim_5bKp": {"value": "A"},
"me_Y291bnRfYWxsX0NvdW50": {"value": 3}
}
]
```
### 4) `comparison_data`
仅指标卡可能返回。表示同/环比的两个值,顺序固定为:
1. 当前周期值
2. 对比周期值
> [!NOTE]
> 原始协议里通常**不直接展示周期名称**,只提供对应的值。因此解释“同比”还是“环比”、以及比较窗口具体是什么,通常要结合组件配置或 UI 上下文理解。
### 5) `trend_data`
仅指标卡可能返回。表示时间序列趋势,每一行通常包含一个时间维度和一个指标值。
---
## alias 规则与读取方式
你不应该把 alias 当成人类可读字段名,而应把它视为**结果表里的列 ID**。
常见生成规则:
- 维度 alias`dim_` + `base64(field_name)`
- 指标 alias`me_` + `base64(aggregation + "_" + field_name)`
> [!NOTE]
> 为了便于阅读,本文档中的部分示例会使用**简化后的 alias**(例如 `dim_xxx`、`me_xxx` 或较短的示例值),不保证和真实返回值逐字符一致。
> 在实际读取结果时,应始终以 `dimensions` / `measures` 中声明的 alias 为准,而不要假设所有示例都严格展开成完整编码值。
例如:
```json
{
"dimensions": [
{"field_name": "文本", "alias": "dim_5bKp"}
],
"measures": [
{"field_name": "Count", "aggregation": "count_all", "alias": "me_xxx"}
],
"main_data": [
{
"dim_5bKp": {"value": "A"},
"me_xxx": {"value": 3}
}
]
}
```
应解读为:
- `dim_5bKp` 对应字段“文本”,取值是 `A`
- `me_xxx` 对应指标 `count_all(Count)`,取值是 `3`
> [!TIP]
> 读取结果时,**先看 `dimensions` / `measures`,再解 `main_data`**。不要仅凭 alias 名字猜含义。
---
## 各图表类型的协议细节
### 一、二维图表
适用于:柱状图、条形图、折线图、饼图、环形图、面积图、组合图、散点图、漏斗图、雷达图。
#### 结构特征
- `dimensions`:通常有 `1~2` 个维度
- 不分组聚合时:通常 1 个维度
- 开启分组聚合时:通常 2 个维度
- `measures`:指标定义数组
- `main_data`:按“维度组合”展开后的行数据
#### 这类数据代表什么
二维图表返回的本质上是一张**聚合结果表**
- 每一行代表一个维度值,或一组维度组合;
- 每一个 measure 值代表该维度下算出来的指标结果;
- 如果图表开启了分组聚合,那么每一行表示“主维度 + 分组维度”的一个组合结果;
- 如果图表是折线图、面积图这类带时间轴的图,通常可以把第一维理解为横轴、把 measure 理解为纵轴数值;
- 如果图表是饼图、环形图这类占比图,通常可以把每一行理解为一个扇区对应的分类及其数值。
换句话说AI 在读取这类结果时可以把它当作“按某些维度聚合后的统计明细表”适合进一步做排序、Top N、占比解释、分组对比和趋势总结。
#### 示例 1普通二维图表无分组聚合
```json
{
"dimensions": [
{
"field_name": "文本",
"alias": "dim_5bKp"
}
],
"measures": [
{
"aggregation": "count_all",
"field_name": "Count",
"alias": "me_Y291bnRfYWxsX0NvdW50"
}
],
"main_data": [
{
"dim_5bKp": {"value": "A"},
"me_Y291bnRfYWxsX0NvdW50": {"value": 3}
},
{
"dim_5bKp": {"value": "B"},
"me_Y291bnRfYWxsX0NvdW50": {"value": 2}
},
{
"dim_5bKp": {"value": "C"},
"me_Y291bnRfYWxsX0NvdW50": {"value": 2}
}
]
}
```
可解读为:
- 维度字段是“文本”
- 指标是“按记录总数统计”
- 当“文本”字段为 `A` 时,对应的 `Count` 指标值是 `3`
- 当“文本”字段为 `B` 时,对应的 `Count` 指标值是 `2`
- 当“文本”字段为 `C` 时,对应的 `Count` 指标值是 `2`
#### 示例 2二维图表开启分组聚合
```json
{
"dimensions": [
{
"field_name": "文本",
"alias": "dim_5bKp"
},
{
"field_name": "单选",
"alias": "dim_5aSl"
}
],
"measures": [
{
"aggregation": "count_all",
"field_name": "Count",
"alias": "me_YW91bnR"
}
],
"main_data": [
{
"dim_5bKp": {"value": "A"},
"dim_5aSl": {"value": "a-1"},
"me_YW91bnR": {"value": 2}
},
{
"dim_5bKp": {"value": "A"},
"dim_5aSl": {"value": "a-2"},
"me_YW91bnR": {"value": 1}
},
{
"dim_5bKp": {"value": "B"},
"dim_5aSl": {"value": "b-1"},
"me_YW91bnR": {"value": 1}
},
{
"dim_5bKp": {"value": "C"},
"dim_5aSl": {"value": "c-1"},
"me_YW91bnR": {"value": 2}
}
]
}
```
可解读为:
- 第一维是“文本”,第二维是“单选”,指标是“按记录总数统计”
- 当“文本”字段为 `A`、且“单选”字段为 `a-1` 时,对应的指标值是 `2`
- 当“文本”字段为 `A`、且“单选”字段为 `a-2` 时,对应的指标值是 `1`
- 当“文本”字段为 `B`、且“单选”字段为 `b-1` 时,对应的指标值是 `1`
- 当“文本”字段为 `C`、且“单选”字段为 `c-1` 时,对应的指标值是 `2`
- 如果按“文本”字段汇总,那么“文本”字段为 `A` 时总指标值是 `3`;为 `B` 时总指标值是 `1`;为 `C` 时总指标值是 `2`
---
### 二、词云
#### 结构特征
词云协议仍然沿用 `dimensions + measures + main_data` 的结构,但语义稍有不同:
- `dimensions` 对应被分词的字段;
- `main_data` 每一行代表一个词;
- `measure` 的 value 表示按该词分组后计算出来的统计值。
#### 这类数据代表什么
词云返回的不是“原文列表”,而是**按词分组后的聚合统计结果**
- `dimensions` 定义的是被分词的来源字段;
- `measure` 对应的是该词在当前图表统计范围内对应的统计值,具体含义取决于聚合方式和指标字段;
- `main_data` 的每一行都可以理解成“某个词 + 该词对应的统计结果”,其中该维度的具体 value 就是拆分出来的词;
- 返回结果通常已经结合图表当前过滤条件、时间范围、数据权限等上下文计算完成。
因此AI 读取词云数据时,更适合做“关键词排序”“热点词解释”“按词聚合结果分析”“主题归纳”,而不是把它当成逐条文本记录去理解。
#### 示例
```json
{
"dimensions": [
{
"field_name": "文本",
"alias": "dim_5bKp"
}
],
"measures": [
{
"aggregation": "count_all",
"field_name": "Count",
"alias": "me_YW91bnR"
}
],
"main_data": [
{
"dim_5bKp": {"value": "A"},
"me_YW91bnR": {"value": 3}
},
{
"dim_5bKp": {"value": "B"},
"me_YW91bnR": {"value": 2}
},
{
"dim_5bKp": {"value": "C"},
"me_YW91bnR": {"value": 2}
}
]
}
```
可解读为:
- 被统计的分词字段是“文本”
- 当前示例里的 measure 是 `count_all(Count)`,所以这里的统计值可以理解为“按词分组后的记录总数”
- 当分词结果为 `A` 时,对应的统计值是 `3`
- 当分词结果为 `B` 时,对应的统计值是 `2`
- 当分词结果为 `C` 时,对应的统计值是 `2`
- 按统计值排序,分词结果 `A` 对应的值最高
- 分词结果 `B``C` 的统计值相同,说明它们处于同一梯队
---
### 三、指标卡statistics
指标卡除了主值外,还可能包含同/环比与趋势结果,是本命令里结构最特殊的一类。
#### 结构特征
- `measures`**有且仅有一个指标**
- `main_data`:通常只有一行,表示总指标值
- `comparison_data`:可选,表示当前周期值与对比周期值
- `trend_data`:可选,表示趋势序列
- `dimensions`:可能包含同/环比日期字段、趋势日期字段
#### 这类数据代表什么
指标卡返回的核心是一个**主指标摘要**,外加可选的比较信息和趋势信息:
- `main_data` 表示当前卡片最核心、最醒目的那个主值;它通常是某个表的记录总数,或某个字段的聚合值,本身**不带时间周期概念**
- `comparison_data` 表示用于同/环比展示的两个数值,通常是“当前周期值”和“对比周期值”;它们表示某个时间周期下的记录总数,或某个字段的聚合值;
- `trend_data` 表示这个指标在一段时间内的变化轨迹,用来支持走势判断;
- `dimensions` 在指标卡里通常不是拿来做主分组展示,而是给 `trend_data` 或同/环比相关日期字段提供语义说明。
例如:
- `main_data = 7` 可以理解为当前卡片展示的主数据,比如某张表当前总记录数是 `7`
- `comparison_data[0] = 6` 则表示某个比较周期下的当前值,比如“本月记录总数 = 6”
- 因此,`main_data``comparison_data[0]` **不一定相等**,因为两者表达的口径并不完全相同。
因此AI 在解读指标卡时,应该优先回答这几个问题:
1. 当前主值是多少;
2. 和对比周期相比是上升、下降还是持平;
3. 趋势整体是增长、波动还是下滑;
4. 是否存在明显的异常峰值或低谷。
> [!NOTE]
> 当指标卡**同时指定同/环比和趋势**时,`dimensions` 中日期维度的顺序是固定的:
> 1. 第一个元素是**趋势**对应的日期维度;
> 2. 第二个元素是**同/环比**对应的日期维度。
>
> 另外要注意:`comparison_data` 自身通常**不直接携带日期字段**,它只给出“当前周期值 / 对比周期值”。
> `dimensions` 中的第一个日期维度会直接出现在 `trend_data` 中,作为趋势序列的时间列;
> 第二个日期维度则主要用于补充“该卡片配置了哪类比较相关日期字段”的语义。
#### 示例
```json
{
"dimensions": [
{
"field_name": "日期",
"alias": "dim_ZGF0ZQ"
},
{
"field_name": "日期2",
"alias": "dim_ZGF0ZTI"
}
],
"measures": [
{
"aggregation": "count_all",
"field_name": "Count",
"alias": "me_YW91b"
}
],
"main_data": [
{
"me_YW91b": {"value": 7}
}
],
"comparison_data": [
{
"me_YW91b": {"value": 6}
},
{
"me_YW91b": {"value": 0}
}
],
"trend_data": [
{
"dim_ZGF0ZQ": {"value": "2026-01-15"},
"me_YW91b": {"value": 1}
},
{
"dim_ZGF0ZQ": {"value": "2026-01-17"},
"me_YW91b": {"value": 1}
},
{
"dim_ZGF0ZQ": {"value": "2026-03-22"},
"me_YW91b": {"value": 1}
},
{
"dim_ZGF0ZQ": {"value": "2026-04-24"},
"me_YW91b": {"value": 2}
},
{
"dim_ZGF0ZQ": {"value": "2026-05-01"},
"me_YW91b": {"value": 1}
}
]
}
```
可解读为:
- 当前主指标值 = `7`
- 当前主指标值不带时间周期概念,可理解为当前卡片主数据
- comparison_data[0] = 当前周期值 `6`,例如某个时间周期(如本月)下的统计值
- comparison_data[1] = 对比周期值 `0`
- `dimensions[0]` 对应趋势日期维度,因此实际出现在 `trend_data`
- `dimensions[1]` 对应同/环比相关的日期维度,用来补充比较语义
- trend_data 展示该指标随时间的变化序列
- 从 comparison_data 看,当前周期相较对比周期是上升的,并且对比周期值为 0
- 从 trend_data 看,这个指标并不是每天都有值,而是在若干离散日期出现
- 趋势序列里的最高点出现在 `2026-04-24`,值为 `2`
- 其余出现的日期大多为 `1`,说明整体上有波动,但暂时没有持续快速增长的趋势
> [!NOTE]
> `comparison_data` 只告诉你“当前值 / 对比值”,**不额外标出日期区间文本**。如果用户需要完整说明“和上周比”还是“和上月比”,通常要结合组件配置或界面上下文进一步判断。
---
## 如何正确解读返回值
建议按下面顺序阅读:
1. **先看 `dimensions`**:确认每个 `dim_*` alias 对应哪个字段;
2. **再看 `measures`**:确认每个 `me_*` alias 是什么聚合方式;
3. **最后读 `main_data` / `comparison_data` / `trend_data`**:把 alias 还原成“字段名 + 指标名”再做解释。
### 推荐解释模板
如果要把结果转成自然语言,建议不要只“复述数值”,而应尽量覆盖下面几个层次:
1. **先解释指标含义**:说明 measure 代表“记录总数”“某字段求和”“平均值”等;
2. **再给出核心结果**:明确当前主值、主要分类、主要组合或主要词项;
3. **做排序或 Top N 提炼**:指出最高、最低、前几名、同一梯队;
4. **补充分组/对比关系**:如果有第二维或 comparison_data就说明比较对象和差异
5. **分析趋势或异常点**:如果有时间序列,指出上升、下降、波动、峰值、低谷;
6. **最后给一句结论**:总结最值得关注的信息。
可参考下面模板:
- 二维图表:
- 基础模板:`按 <维度字段> 统计,当前指标 <指标含义>;其中 <维度值1>=<指标值1><维度值2>=<指标值2> ...`
- 增强模板:`按 <维度字段> 统计,当前指标表示 <指标含义>。从结果看,<Top1维度值> 的值最高,为 <Top1值><Top2维度值> 和 <Top3维度值> 紧随其后。若按 Top N 看,前 <N> 项合计贡献了 ...;若看低值项,<低值维度值> 最低,为 <低值>。整体上,<一句总结>`
- 分组聚合图表:
- 基础模板:`按 <维度1> 统计,并以 <维度2> 分组,得到 <组合1>=<值1><组合2>=<值2> ...`
- 增强模板:`当前指标表示 <指标含义>。按 <维度1> 拆分后,不同 <维度2> 组之间存在明显差异:例如 <组合1> = <值1><组合2> = <值2>。如果按 <维度1> 汇总,<Top1维度1值> 总值最高,为 <汇总值>;如果看组内对比,<某组> 在 <某维度1值> 下表现最强 / 最弱。整体说明 <一句总结>`
- 词云:
- 基础模板:`按分词结果统计,当前指标表示 <指标含义>;其中 <词1>=<统计值1><词2>=<统计值2> ...`
- 增强模板:`当前词云反映的是“按词分组后的 <指标含义>”。从结果看,<Top1词> 的值最高,为 <值1>,说明它是当前最突出的关键词;<Top2词>、<Top3词> 处于第二梯队。如果按 Top N 看,主要关注词集中在 <主题A>、<主题B>;如果有多个词数值接近,可归为同一热点层级。整体上,这组词更适合用来总结 <主题/热点/关注点>`
- 指标卡:
- 基础模板:`当前主指标值为 <main_data>;当前周期值为 <comparison_data[0]>;对比周期值为 <comparison_data[1]>;趋势上 ...`
- 增强模板:`当前主指标表示 <指标含义>,主值为 <main_data>。若看周期比较,当前周期值为 <comparison_data[0]>,对比周期值为 <comparison_data[1]>,因此整体表现为 <上升/下降/持平>。若看趋势序列,最高点出现在 <日期>,值为 <峰值>;最低点出现在 <日期>,值为 <低值>;整体走势表现为 <持续增长/阶段波动/明显回落>。如果需要给出结论,可总结为:<一句总结>`
> [!TIP]
> 当用户明确要求“帮我分析”“帮我总结”“帮我找异常 / Top N / 趋势”时,优先采用增强模板,而不是只逐条复述原始数值。
---
## 常见工作流
### 场景 1用户要“拿这个图表当前展示的数据”
```bash
# 如果已知 block_id直接读结果
lark-cli base +dashboard-block-get-data \
--base-token xxx \
--block-id chtxxxxxxxx
```
### 场景 2用户说“帮我分析这个图表”但你还不知道它是什么组件
```bash
# 先看组件配置,确认它是不是支持计算的图表类型
lark-cli base +dashboard-block-get \
--base-token xxx \
--dashboard-id blk_xxx \
--block-id chtxxxxxxxx
# 再读最终计算结果
lark-cli base +dashboard-block-get-data \
--base-token xxx \
--block-id chtxxxxxxxx
```
### 场景 3用户要找“仪表盘里哪个图的结果异常”
```bash
# 先列组件
lark-cli base +dashboard-block-list \
--base-token xxx \
--dashboard-id blk_xxx
# 再针对可疑 block 逐个取结果
lark-cli base +dashboard-block-get-data \
--base-token xxx \
--block-id chtxxxxxxxx
```
---
## 何时优先用这个命令
- 用户说“帮我拿这个图表算出来的数据 / 结果 / 指标”
- 用户已经知道 `block_id`,目标是**读取结果**而不是看配置
- 用户后续还要让 AI 对图表结果做解释、归纳、比较、总结
- 你只关心图表层的聚合产出,不需要回到底表逐条读记录
## 何时不要误用
- 想看 block 的 `data_config`、名称、类型、布局 → 用 `+dashboard-block-get`
- 想列出仪表盘里有哪些组件 → 用 `+dashboard-block-list`
- 想修改或新建组件 → 用 `+dashboard-block-update` / `+dashboard-block-create`
- 想看原始记录明细,而不是图表聚合结果 → 回到 `record-*`
- 目标是文本组件 → 本命令不适用
---
## 常见误区
### 误区 1把这个命令当成“获取 block 详情”
不是。这个命令不返回:
- block 名称
- block 类型
- layout
- `data_config`
- 所属 dashboard 信息
这些都应该通过 [`+dashboard-block-get`](lark-base-dashboard-block-get.md) 获取。
### 误区 2以为它返回的是原始记录
不是。它返回的是**图表聚合后的最终结果**。如果图表本身做了过滤、分组、聚合、时间窗口限制,返回值反映的是图表视角,不是原始表全量明细。
### 误区 3直接把 alias 当真实字段名读
不应该。alias 只是协议里的键,必须结合 `dimensions` / `measures` 还原语义。
### 误区 4看到指标卡的 `comparison_data` 就以为已经知道“同比/环比周期文本”
不一定。它只给出比较值,不一定给出周期标签。若要精确解释比较窗口,通常还需要组件配置或 UI 上下文。
---
## dry-run 用途
可用来确认最终会调用的接口路径:
```bash
lark-cli base +dashboard-block-get-data \
--base-token bascn_example_token \
--block-id chtxxxxxxxx \
--dry-run \
--format pretty
```
你应能看到类似:
```text
GET /open-apis/base/v3/bases/bascn_example_token/dashboards/blocks/chtxxxxxxxx/data
```
适合在以下场景使用:
- 校验 `base_token` / `block_id` 是否传对;
- 调试 agent 生成的命令;
- 编写自动化测试时确认请求结构。
---
## 参考
- [lark-base-dashboard.md](lark-base-dashboard.md) — dashboard 模块总指引
- [lark-base-dashboard-block-get.md](lark-base-dashboard-block-get.md) — 获取 block 元数据
- [dashboard-block-data-config.md](dashboard-block-data-config.md) — data_config 结构和组件类型说明

View File

@@ -18,6 +18,7 @@ Dashboard 是 Base 中的数据可视化看板,可以把表格数据变成**
| 在仪表盘里添加组件 | `+dashboard-block-create` | 先读 [lark-base-dashboard-block-create.md](lark-base-dashboard-block-create.md),再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) |
| 修改组件 | `+dashboard-block-update` | 先读 [lark-base-dashboard-block-update.md](lark-base-dashboard-block-update.md),再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) |
| 查看仪表盘有哪些组件 | `+dashboard-get``+dashboard-block-list` | 本页下方「查看仪表盘」 |
| 读取图表最终计算结果 | `+dashboard-block-get-data` | [lark-base-dashboard-block-get-data.md](lark-base-dashboard-block-get-data.md) |
| 智能重排组件布局 | `+dashboard-arrange` | [lark-base-dashboard-arrange.md](lark-base-dashboard-arrange.md) |
## 典型场景工作流
@@ -151,6 +152,7 @@ lark-cli base +dashboard-arrange \
- 想看仪表盘整体结构(含主题、所有组件名称和类型)→ 用 **方式 A**
- 只想快速查看有哪些组件 → 用 **方式 B**
- 想看某个组件的详细 data_config 配置 → 用 **方式 C**
- 想看某个图表/指标卡实际算出来的数据 → 用 **方式 D**
```bash
# 第 1 步:列出仪表盘,定位到当前仪表盘
@@ -167,6 +169,9 @@ lark-cli base +dashboard-block-list --base-token xxx --dashboard-id blk_xxx
# 方式 C查看某个组件的详细配置
lark-cli base +dashboard-block-get --base-token xxx --dashboard-id blk_xxx --block-id chtxxxxxxxx
# 方式 D查看某个图表组件的计算结果AI 友好的 chart protocol
lark-cli base +dashboard-block-get-data --base-token xxx --block-id chtxxxxxxxx
# 最后:把获取到的现状信息整理好告诉用户
```
@@ -223,6 +228,9 @@ A: 在「添加新组件」或「编辑组件」前查看已有组件可以:
- 避免重复创建相似的组件
- 参考已有组件的 data_config 结构作为模板
**Q: 我想直接拿图表算好的结果给 AI 分析,应该用什么?**
A: 用 `+dashboard-block-get-data`。它直接返回图表协议 JSON`dimensions / measures / main_data`,指标卡还可能有 `comparison_data / trend_data`),适合让 AI 做后续总结、比对和解释。注意它不返回 block 名称、类型、layout、data_config 等元数据;如果你还需要这些信息,先用 `+dashboard-block-get`
## 命令详细文档
| CLI 命令 | 说明 | 详细文档 |
@@ -235,6 +243,7 @@ A: 在「添加新组件」或「编辑组件」前查看已有组件可以:
| `+dashboard-arrange` | 智能重排布局 | [lark-base-dashboard-arrange.md](lark-base-dashboard-arrange.md) |
| `+dashboard-block-list` | 列出组件 | [lark-base-dashboard-block-list.md](lark-base-dashboard-block-list.md) |
| `+dashboard-block-get` | 获取单个组件详情 | [lark-base-dashboard-block-get.md](lark-base-dashboard-block-get.md) |
| `+dashboard-block-get-data` | 获取图表组件计算结果 | [lark-base-dashboard-block-get-data.md](lark-base-dashboard-block-get-data.md) |
| `+dashboard-block-create` | 创建组件 | [lark-base-dashboard-block-create.md](lark-base-dashboard-block-create.md) |
| `+dashboard-block-update` | 更新组件 | [lark-base-dashboard-block-update.md](lark-base-dashboard-block-update.md) |
| `+dashboard-block-delete` | 删除组件 | [lark-base-dashboard-block-delete.md](lark-base-dashboard-block-delete.md) |

View File

@@ -56,6 +56,7 @@
| 场景 | 步骤组合 | 示例 |
|------|---------|------|
| 新增触发+通知 | AddRecordTrigger → LarkMessageAction | [下方](#示例1-新增记录触发--发送消息) |
| 按钮点击+调用外部接口+写入日志 | ButtonTrigger → HTTPClientAction → AddRecordAction | [下方](#示例-6-按钮触发--调用外部接口--写入同步日志) |
| 定时+循环 | TimerTrigger → FindRecordAction → Loop → LarkMessageAction | [下方](#示例2-定时触发--查找记录--循环遍历--发送消息) |
| 条件判断 | ... → IfElseBranch → 分支处理 | [下方](#示例3-条件分支-ifelsebranch) |
| 多路分类 | ... → SwitchBranch → 多分支处理 | [下方](#示例4-多路分支-switchbranch) |
@@ -628,6 +629,119 @@
---
### 示例 6: 按钮触发 + 调用外部接口 + 写入同步日志
**场景**: 在「客户线索表」里给每条记录配置一个“同步到 CRM”按钮。销售点击按钮后Workflow 调用外部 CRM 接口同步当前线索,再在「同步日志表」新增一条记录,方便后续审计和排查。
```json
{
"client_token": "1704067206",
"title": "线索一键同步到 CRM",
"steps": [
{
"id": "step_button_trigger",
"type": "ButtonTrigger",
"title": "点击同步到 CRM 按钮时触发",
"next": "step_call_crm_api",
"data": {
"button_type": "buttonField",
"table_name": "客户线索表"
}
},
{
"id": "step_call_crm_api",
"type": "HTTPClientAction",
"title": "调用 CRM 同步接口",
"next": "step_add_sync_log",
"data": {
"method": "POST",
"url": [
{ "value_type": "text", "value": "https://api.example-crm.com/v1/leads/sync" }
],
"headers": [
{ "key": "Content-Type", "value": [{ "value_type": "text", "value": "application/json" }] },
{ "key": "X-System", "value": [{ "value_type": "text", "value": "lark_base_workflow" }] }
],
"body_type": "raw",
"raw_body": [
{ "value_type": "text", "value": "{\"lead_name\":\"" },
{ "value_type": "ref", "value": "$.step_button_trigger.fldLeadName" },
{ "value_type": "text", "value": "\",\"mobile\":\"" },
{ "value_type": "ref", "value": "$.step_button_trigger.fldMobile" },
{ "value_type": "text", "value": "\",\"company\":\"" },
{ "value_type": "ref", "value": "$.step_button_trigger.fldCompany" },
{ "value_type": "text", "value": "\",\"owner\":\"" },
{ "value_type": "ref", "value": "$.step_button_trigger.fldOwner" },
{ "value_type": "text", "value": "\",\"source_record_id\":\"" },
{ "value_type": "ref", "value": "$.step_button_trigger.recordId" },
{ "value_type": "text", "value": "\"}" }
],
"response_type": "json",
"response_value": "{\"success\":true,\"message\":\"lead synced successfully\"}"
}
},
{
"id": "step_add_sync_log",
"type": "AddRecordAction",
"title": "写入同步日志",
"next": null,
"data": {
"table_name": "同步日志表",
"field_values": [
{
"field_name": "线索名称",
"value": [{ "value_type": "ref", "value": "$.step_button_trigger.fldLeadName" }]
},
{
"field_name": "手机号",
"value": [{ "value_type": "ref", "value": "$.step_button_trigger.fldMobile" }]
},
{
"field_name": "公司名称",
"value": [{ "value_type": "ref", "value": "$.step_button_trigger.fldCompany" }]
},
{
"field_name": "负责人",
"value": [{ "value_type": "ref", "value": "$.step_button_trigger.fldOwner" }]
},
{
"field_name": "来源记录ID",
"value": [{ "value_type": "ref", "value": "$.step_button_trigger.recordId" }]
},
{
"field_name": "同步状态",
"value": [{ "value_type": "text", "value": "已提交 CRM 同步" }]
},
{
"field_name": "同步是否成功",
"value": [{ "value_type": "ref", "value": "$.step_call_crm_api.body.success" }]
},
{
"field_name": "同步结果说明",
"value": [{ "value_type": "ref", "value": "$.step_call_crm_api.body.message" }]
},
{
"field_name": "备注",
"value": [{ "value_type": "text", "value": "由按钮触发自动发起同步请求" }]
}
]
}
}
]
}
```
**关键点**:
- `ButtonTrigger` 适合“人工确认后再执行”的场景,比如同步 CRM、推送 ERP、发起审批等
- `button_type: "buttonField"` 表示按钮挂在记录上,因此可以直接引用当前记录的字段和值
- `HTTPClientAction.raw_body` 可以通过 `text + ref + text` 的方式动态拼接 JSON 请求体
- `HTTPClientAction` 的输出引用规则是:`response_type=none` 时不可引用;`response_type=text` 时只能用 `$.stepId` 引整个文本;`response_type=json` 时用 `$.stepId.body` 引整个 body、用 `$.stepId.body.字段名` 引 body 中字段,同时 `$.stepId.status_code` 表示 HTTP 返回状态码
- `HTTPClientAction.response_value` 中声明了哪些字段,后续节点就只能引用这些字段;例如 `$.step_call_crm_api.body.success``$.step_call_crm_api.body.message`
- `AddRecordAction` 常用于写日志表、操作审计表、同步结果表,便于追踪谁在什么时候触发了外部调用
- 示例里的 `fldLeadName` / `fldMobile` / `fldCompany` / `fldOwner` 只是占位的 fieldId请以实际表字段 ID 为准
---
## 构造技巧
### Loop 构造要点

View File

@@ -98,6 +98,7 @@
| `ChangeRecordTrigger` | 记录满足条件时触发 |
| `TimerTrigger` | 定时触发 |
| `ReminderTrigger` | 日期提醒触发 |
| `ButtonTrigger` | 按钮点击触发 |
| `LarkMessageTrigger` | 接收飞书消息触发 |
> 所有 Trigger 节点**请勿设置** `children` ,通过 `next` 串联后继。
@@ -120,6 +121,7 @@
| `AddRecordAction` | 新增记录 |
| `SetRecordAction` | 更新记录 |
| `FindRecordAction` | 查找记录 |
| `HTTPClientAction` | HTTP 请求 |
| `Delay` | 延迟 |
| `LarkMessageAction` | 发送飞书消息 |
| `GenerateAiTextAction` | AI 生成文本 |
@@ -256,6 +258,23 @@
| `condition_list` | 否 | 过滤条件数组,数组中每个元素为 AndCondition 结构,多个 AndCondition 之间为 OR 关系 |
### ButtonTrigger
```json
{
"button_type": "buttonField",
"table_name": "审批表"
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `button_type` | 是 | 按钮类型:`buttonField`(表格里的按钮,可操作当前记录数据)/ `buttonElement`(仪表盘、应用页面上的按钮,可执行整体操作) |
| `table_name` | 否 | 绑定的数据表名,仅 `button_type=buttonField` 时填写 |
> `buttonField` 和 `buttonElement` 的输出能力不同详见下方「ButtonTrigger按钮触发器」输出说明。
### LarkMessageTrigger
```json
@@ -351,6 +370,48 @@
| `filter_info` | 否* | RecordFilterInfo`ref_info` 互斥) |
| `ref_info` | 否* | RefInfo`filter_info` 互斥) |
### HTTPClientAction
```json
{
"method": "POST",
"url": [{ "value_type": "text", "value": "https://api.example.com/webhook" }],
"queries": [
{ "key": "source", "value": [{ "value_type": "text", "value": "workflow" }] }
],
"headers": [
{ "key": "Content-Type", "value": [{ "value_type": "text", "value": "application/json" }] }
],
"body_type": "raw",
"raw_body": [
{ "value_type": "text", "value": "{\"record_id\":\"" },
{ "value_type": "ref", "value": "$.step_1.recordId" },
{ "value_type": "text", "value": "\"}" }
],
"response_type": "json",
"response_value": "{\"success\":true,\"message\":\"data fetched successfully\"}"
}
```
| 字段 | 必填 | 说明 |
|------|-----|------|
| `method` | 否 | 请求方法:`GET` / `POST` / `PUT` / `PATCH` / `DELETE`,默认 `POST` |
| `url` | 是 | ValueInfo[],请求 URL支持 `text` / `ref` 拼接 |
| `queries` | 否 | KeyValue[],查询参数 |
| `headers` | 否 | KeyValue[],请求头 |
| `body_type` | 否 | 请求体类型:`none` / `raw` / `form-data` / `form-urlencoded`,默认 `raw` |
| `raw_body` | 否 | ValueInfo[],原始请求体,仅 `body_type=raw` 时使用 |
| `form_body` | 否 | KeyValue[],表单数据,仅 `body_type=form-data``body_type=form-urlencoded` 时使用 |
| `response_type` | 否 | 响应类型:`none` / `text` / `json`,默认 `json` |
| `response_value` | 否 | stringJSON 字符串形式的响应结果示例;仅当 `response_type=json` 时必填 |
`KeyValue`
| 字段 | 类型 | 说明 |
|------|------|------|
| `key` | string | 参数名 / 请求头名 |
| `value` | ValueInfo[] | 参数值 / 请求头值,支持 `text` / `ref` |
### Delay
```json
@@ -564,8 +625,8 @@ $.{stepId}.{pathId}.{childPathId}.{grandChildPathId}
| pathId | 说明 | 引用示例 |
|--------|------|----------|
| `{fieldId}` | 字段id从配置表的所有字段或者指定字段id生成可下钻字段属性 | `$.{stepId}.{fieldId}` |
| `{fieldId}.fieldId` | 字段id属性 | `$.{stepId}.{fieldId}.fieldId}` |
| `{fieldId}.fieldName` | 字段名属性 | `$.{stepId}.{fieldId}.fieldName}` |
| `{fieldId}.fieldId` | 字段id属性 | `$.{stepId}.{fieldId}.fieldId` |
| `{fieldId}.fieldName` | 字段名属性 | `$.{stepId}.{fieldId}.fieldName` |
| `startTime` | 触发时间戳 | `$.{stepId}.startTime` |
| `recordId` | 记录 ID | `$.{stepId}.recordId` |
| `recordLink` | 记录链接 | `$.{stepId}.recordLink` |
@@ -583,6 +644,34 @@ $.{stepId}.{pathId}.{childPathId}.{grandChildPathId}
**recordLink 的 children**:如果配置了数据表,则为该表所有视图的列表,每个视图 `{ pathId: viewId, pathName: viewName, pathType: 'string' }`。引用示例:`$.{stepId}.recordLink.{viewId}`
##### ButtonTrigger按钮触发器
`ButtonTrigger` 的输出取决于 `button_type`
#### `button_type = buttonField`
| pathId | 说明 | 引用示例 |
|--------|------|----------|
| `{fieldId}` | 字段id从配置表的所有字段或者指定字段id生成可下钻字段属性 | `$.{stepId}.{fieldId}` |
| `{fieldId}.fieldId` | 字段id属性 | `$.{stepId}.{fieldId}.fieldId` |
| `{fieldId}.fieldName` | 字段名属性 | `$.{stepId}.{fieldId}.fieldName` |
| `recordId` | 记录 ID | `$.{stepId}.recordId` |
| `recordLink` | 记录链接 | `$.{stepId}.recordLink` |
| `recordCreatedUser` | 记录创建者 | `$.{stepId}.recordCreatedUser` |
| `recordModifiedUser` | 最后修改者 | `$.{stepId}.recordModifiedUser` |
| `recordModifiedTime` | 最后修改时间 | `$.{stepId}.recordModifiedTime` |
| `time` | 触发时间 | `$.{stepId}.time` |
| `user` | 触发人 | `$.{stepId}.user` |
| `buttonName` | 触发的按钮名称 | `$.{stepId}.buttonName` |
#### `button_type = buttonElement`
| pathId | 说明 | 引用示例 |
|--------|------|----------|
| `time` | 触发时间 | `$.{stepId}.time` |
| `user` | 触发人 | `$.{stepId}.user` |
| `buttonName` | 触发的按钮名称 | `$.{stepId}.buttonName` |
##### TimerTrigger定时触发器
| pathId | 说明 | 引用示例 |
@@ -633,8 +722,8 @@ $.{stepId}.{pathId}.{childPathId}.{grandChildPathId}
| pathId | 说明 | 引用示例 |
|--------|------|----------|
| `{fieldId}` | 用户配置的字段值,可下钻字段属性 | `$.{stepId}.{fieldId}` |
| `{fieldId}.fieldId` | 用户配置的字段id | `$.{stepId}.{fieldId}.fieldId}` |
| `{fieldId}.fieldName` | 用户配置的字段名 | `$.{stepId}.{fieldId}.fieldName}` |
| `{fieldId}.fieldId` | 用户配置的字段id | `$.{stepId}.{fieldId}.fieldId` |
| `{fieldId}.fieldName` | 用户配置的字段名 | `$.{stepId}.{fieldId}.fieldName` |
| `recordId` | 新增的记录 ID | `$.{stepId}.recordId` |
| `recordLink` | 新增的记录 URL | `$.{stepId}.recordLink` |
@@ -643,10 +732,56 @@ $.{stepId}.{pathId}.{childPathId}.{grandChildPathId}
| pathId | 说明 | 引用示例 |
|--------|------|----------|
| `{fieldId}` | 用户配置的字段值,可下钻字段属性 | `$.{stepId}.{fieldId}` |
| `{fieldId}.fieldId` | 用户配置的字段id | `$.{stepId}.{fieldId}.fieldId}` |
| `{fieldId}.fieldName` | 用户配置的字段名 | `$.{stepId}.{fieldId}.fieldName}` |
| `{fieldId}.fieldId` | 用户配置的字段id | `$.{stepId}.{fieldId}.fieldId` |
| `{fieldId}.fieldName` | 用户配置的字段名 | `$.{stepId}.{fieldId}.fieldName` |
| `recordId` | 记录 ID 数组(因可能更新多条记录) | `$.{stepId}.recordId` |
##### HTTPClientActionHTTP 请求)
HTTPClientAction 的输出取决于 `response_type`
| response_type | 是否可引用 | 输出说明 | 引用示例 |
|--------------|-----------|----------|----------|
| `none` | 否 | 无任何可引用输出 | 不支持引用 |
| `text` | 是 | 整个响应文本作为节点整体输出 | `$.{stepId}` |
| `json` | 是 | 响应体整体挂在 `body` 下,同时返回 `status_code`;仅可引用 `response_value` 中声明的字段 | `$.{stepId}.body``$.{stepId}.body.success``$.{stepId}.body.message``$.{stepId}.status_code` |
**补充说明**
-`response_type = none` 时,后续节点无法引用 HTTPClientAction 的任何输出
-`response_type = text` 时,`$.{stepId}` 表示整个响应文本
-`response_type = json` 时,`$.{stepId}.body` 表示整个 JSON body`$.{stepId}.body.字段名` 表示 body 中某个字段
- 仅当 `response_type = json` 时,`$.{stepId}.status_code` 表示请求该 HTTP URL 后返回的 HTTP 状态码
- 仅当 `response_type = json` 时,`response_value` 必填
-`response_type = json` 时,后续节点只能引用 `response_value` 中声明过的字段
**案例**
假设某个 `HTTPClientAction` 的配置如下:
```json
{
"id": "step_http_1",
"type": "HTTPClientAction",
"data": {
"response_type": "json",
"response_value": "{\"success\":true,\"message\":\"ok\"}"
}
}
```
则后续节点仅可以引用:
- `$.step_http_1.body`
- `$.step_http_1.body.success`
- `$.step_http_1.body.message`
- `$.step_http_1.status_code`
但**不能**引用未在 `response_value` 中声明的字段,例如:
- `$.step_http_1.body.data`
- `$.step_http_1.body.request_id`
##### GenerateAiTextActionAI 生成文本)
| pathId | 说明 | 引用示例 |
@@ -744,11 +879,13 @@ $.{stepId}.{fieldId}.fileToken → 文件 Token 列表array<string>,仅
| ChangeRecordTrigger | 触发器 | ✅ | 动态(表字段 + 记录属性) |
| SetRecordTrigger | 触发器 | ✅ | 动态(表字段 + 记录属性) |
| ReminderTrigger | 触发器 | ✅ | 动态(表字段 + 记录属性) |
| ButtonTrigger | 触发器 | ✅ | 动态(表字段 + 记录属性buttonElement 仅基础触发属性) |
| TimerTrigger | 触发器 | ✅ | 静态(仅 scheduleTime |
| LarkMessageTrigger | 触发器 | ✅ | 静态(消息属性列表) |
| FindRecordAction | 动作 | ✅ | 动态(用户选择的字段) |
| AddRecordAction | 动作 | ✅ | 动态(用户配置的字段) |
| SetRecordAction | 动作 | ✅ | 动态(用户配置的字段) |
| HTTPClientAction | 动作 | ✅ | 动态(取决于用户配置的 HTTP 响应输出) |
| GenerateAiTextAction | 动作 | ✅ | 静态(单 string |
| Delay | 动作 | ❌ | 无输出 |
| LarkMessageAction | 动作 | ❌ | 无输出 |

View File

@@ -151,7 +151,7 @@ PUT /open-apis/base/v3/bases/:base_token/workflows/:workflow_id
## 坑点
- ⚠️ **PUT 是全量覆盖**:传什么就写什么;如果只传 `title` 不传 `steps`,原有 steps 会被清空;如需只改标题,使用 PATCH 接口(目前无对应 shortcut可参考 API 文档直接调用)
- ⚠️ **PUT 是全量覆盖**:传什么就写什么;如果只传 `title` 不传 `steps`,原有 steps 会被清空
- ⚠️ **workflow_id 前缀**:以 `wkf` 开头,从 URL 的 `?table=wkf...` 提取;和 table_id`tbl` 开头)混淆会导致 `[2200] Internal Error`
- ⚠️ **steps 中 id 字段必须唯一**:每个步骤的 `id` 在同一工作流内必须唯一;`next``children.links[].to` 引用的 ID 必须在 steps 数组中存在
- ⚠️ **更新不影响 enabled 状态**`+workflow-update` 不会改变工作流的 `enabled/disabled` 状态;需要另外调用 `+workflow-enable` / `+workflow-disable`

View File

@@ -73,7 +73,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
| Shortcut | 说明 |
|----------|------|
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager |
| [`+chat-list`](references/lark-im-chat-list.md) | List groups the current user/bot is a member of; user/bot; supports sorting, pagination, and --exclude-muted (user identity only) |
| [`+chat-list`](references/lark-im-chat-list.md) | List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only) |
| [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination |
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only) |
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description |

View File

@@ -2,7 +2,9 @@
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
List groups the current user (or bot, with `--as bot`) is a member of. Useful for enumerating "my chats" without a search keyword, or for bulk operations against the caller's chats. Supports pagination, sort order, and (user identity only) muted-chat filtering.
List chats the current user (or bot, with `--as bot`) is a member of. **Not a search API — there is no `--query` parameter; the call always returns the full member list, paginated.** For keyword-based lookup (e.g. find a group by name or by member), use [`+chat-search`](lark-im-chat-search.md) instead.
**Defaults to groups only**; pass `--types=p2p,group` (or `--types p2p --types group`) to also include p2p single chats (user identity only — see ["Bot identity and p2p"](#bot-identity-and-p2p)). Supports pagination, sort order, and (user identity only) muted-chat filtering.
This skill maps to the shortcut: `lark-cli im +chat-list` (internally calls `GET /open-apis/im/v1/chats`).
@@ -29,6 +31,15 @@ lark-cli im +chat-list --format json
# Preview the request without executing it
lark-cli im +chat-list --dry-run
# Include p2p single chats (user identity only) — comma form
lark-cli im +chat-list --as user --types p2p,group
# Same, using repeat flag instead of CSV
lark-cli im +chat-list --as user --types p2p --types group
# Only p2p single chats (user identity only)
lark-cli im +chat-list --as user --types p2p
```
## Parameters
@@ -36,6 +47,7 @@ lark-cli im +chat-list --dry-run
| Parameter | Required | Limits | Description |
|------|------|------|------|
| `--user-id-type <type>` | No | `open_id` (default), `union_id`, `user_id` | ID type used for `owner_id` in the response |
| `--types <strings>` | No | `group`, `p2p` (comma-separated or repeated) | Chat types to include. Omitted = groups only (backward compatible). `p2p` requires user identity (`--as user`); under `--as bot`, `--types=p2p` alone is rejected and `--types=p2p,group` is silently downgraded to `group` |
| `--sort-type <type>` | No | `ByCreateTimeAsc` (default), `ByActiveTimeDesc` | Result ordering |
| `--page-size <n>` | No | 1-100, default 20 | Number of results per page |
| `--page-token <token>` | No | - | Pagination token from the previous response |
@@ -55,6 +67,44 @@ lark-cli im +chat-list --dry-run
| `owner_id` | Owner ID (type controlled by `--user-id-type`) |
| `external` | Whether the chat is external |
| `chat_status` | Chat status (`normal` / `dissolved` / `dissolved_save`) |
| `chat_mode` | Chat mode discriminator: `group` (regular) / `topic` (topic group) / `p2p` (single chat) |
| `p2p_target_type` | Peer type, e.g., `user` |
| `p2p_target_id` | Peer ID (type controlled by `--user-id-type`) |
## Including p2p single chats
Default behavior lists groups only — same as before this feature. To include p2p, pass `--types`:
| User intent | Call | Identity |
|---|---|---|
| "list my groups" / 我的群 / 我加入了哪些群 | (default, omit `--types`) | user or bot |
| "list my p2p chats" / 我的单聊 / 我跟谁有 1v1 | `--types p2p` | **user only** |
| "all my chats" / 全部聊天 / 所有会话 (ambiguous) | `--types p2p,group` | **user only** |
For p2p rows in the response: `name` is the peer's display name, `owner_id` follows group semantics, `chat_mode = "p2p"`, and `p2p_target_type` / `p2p_target_id` identify the peer.
## Bot identity and p2p
`tenant_access_token` cannot list p2p chats — to protect user privacy, bot identity is not permitted to enumerate p2p single chats. Behavior under `--as bot`:
- `--as bot --types=p2p` → rejected at validation time with an actionable error; no request is sent.
- `--as bot --types=p2p,group` → CLI strips `p2p` and sends `types=group`. Request proceeds; only groups are returned. The strip is a **request-level adjustment**, surfaced two ways so neither humans nor agents miss it:
- **stderr**: `warning: bot_strip_p2p: To protect user privacy, bot identity cannot list p2p chats; --types=p2p,group was sent as types=group. Use --as user to include p2p.` (matches the `warning: <code>: <message>` convention in `shortcuts/common/runner.go`)
- **stdout JSON**: a top-level `notices` array gains a structured entry:
```json
{
"chats": [...],
"notices": [
{ "code": "bot_strip_p2p", "message": "To protect user privacy, bot identity cannot list p2p chats; …" }
]
}
```
- The `filter` slot stays scoped to `--exclude-muted`; `notices` is a separate top-level key, so the two never collide and no priority is needed when both fire.
- DryRun emits the same stderr warning so a previewed request truthfully reflects what Execute will send (parity with `shortcuts/drive/drive_search.go`).
- `--as bot --types=group` → accepted, returns groups normally.
- `--as bot` (no `--types`) → unchanged, returns groups.
To include p2p single chats, switch to user identity: `--as user --types=p2p,group`.
## Filtering muted chats
@@ -111,3 +161,6 @@ done
| Permission denied (99991679) with `--as user` | UAT is not authorized for `im:chat:read` | Run `lark-cli auth login --scope "im:chat:read"` |
| `Bot ability is not activated` (232025) | The app does not have bot capability enabled | Enable bot capability in the Open Platform console |
| `--exclude-muted` returns all chats unfiltered and `hint` says "no effect under bot identity" | Running under `--as bot` (mute API is UAT-only) | Switch to `--as user` for mute filtering |
| `--types=p2p (single chats) is only supported with user identity` | `--as bot` + `--types=p2p` (single-value only; mixed `--types=p2p,group` is downgraded to `group` and surfaces a `bot_strip_p2p` notice via stderr + `outData["notices"]` — see "Bot identity and p2p") | Use `--as user`, or include `group` in `--types` (the bot proceeds with `group` only and emits the `bot_strip_p2p` notice) |
> Full error message of the row above: `--types=p2p (single chats) is only supported with user identity (--as user). To protect user privacy, bot identity cannot list p2p chats. Use --as user, or include "group" in --types.`

View File

@@ -82,7 +82,7 @@ lark-cli vc +recording --meeting-ids 69xxxxxxxxxxxxx28 --dry-run
lark-cli vc +recording --meeting-ids xxx
# 第 2 步:使用上一步返回的 minute_token 下载妙记文件
lark-cli minutes +download --minute-token <minute_token>
lark-cli minutes +download --minute-tokens <minute_token>
```
### 场景 2知道 meeting_id想查询妙记基础信息
@@ -115,7 +115,7 @@ lark-cli vc +search --query "周会" --start 2026-03-10
lark-cli vc +recording --meeting-ids <ids>
# 第 3 步:使用其中一个 minute_token 下载妙记文件
lark-cli minutes +download --minute-token <token>
lark-cli minutes +download --minute-tokens <token>
```
### 场景 5从日历事件获取录制
@@ -125,7 +125,7 @@ lark-cli minutes +download --minute-token <token>
lark-cli vc +recording --calendar-event-ids <event_id>
# 第 2 步:使用上一步返回的 minute_token 下载妙记文件
lark-cli minutes +download --minute-token <minute_token>
lark-cli minutes +download --minute-tokens <minute_token>
```
## 常见错误与排查

View File

@@ -31,10 +31,6 @@ metadata:
| 绘制复杂图表(架构/流程/组织等)| → **[§ 创作 Workflow](#创作-workflow)** |
| 修改/重绘已有复杂画板 | → **[§ 修改 Workflow](#修改-workflow)** |
> **⚠️ 强制规范(通过 stdin 更新)**
> 数据来源于本地文件时,**必须**使用 `--source - --input_format <格式>`。
> 例:`cat chart.mmd | lark-cli whiteboard +update <token> --source - --input_format mermaid`
## Shortcuts
| Shortcut | 说明 |
@@ -54,7 +50,7 @@ metadata:
| 用户给了什么 | 怎么获取 |
|---|---|
| 直接给了 whiteboard token`wbcnXXX`| 直接使用 |
| 文档 URL 或 doc_id文档中已有画板 | `lark-cli docs +fetch --api-version v2 --doc <URL> --as user`,从返回的 `<whiteboard token="xxx"/>` 提取 |
| 文档 URL 或 doc_id文档中已有画板 | `lark-cli docs +fetch --doc <URL> --as user`,从返回的 `<whiteboard token="xxx"/>` 提取 |
| 文档 URL 或 doc_id需要新建画板 | `lark-cli docs +update --api-version v2 --doc <doc_id> --command append --content '<whiteboard type="blank"></whiteboard>' --as user`,从响应 `data.new_blocks[0].block_token` 取得(`block_type == "whiteboard"` 的那条;参数详见 lark-doc SKILL.md|
**Step 2渲染 & 写入**
@@ -89,11 +85,11 @@ metadata:
**然后按图表类型 × 身份选路径**,读对应文件按其完整 workflow 执行(含读 scene 指南、生成内容、渲染审查、交付):
| 图表类型 | 身份 | 路径 |
|---|---|---|
| 思维导图、时序图、类图、饼图、甘特图 | 任何身份 | [`routes/mermaid.md`](routes/mermaid.md) |
| 其他图表 | `Claude` / `Gemini` / `GPT` / `GLM` | [`routes/svg.md`](routes/svg.md) |
| 其他图表 | `Doubao` / `Seed` / `Other` | [`routes/dsl.md`](routes/dsl.md) |
| 图表类型 | 身份 | 路径 |
|------------------------|-------------------------------------|------------------------------------------|
| 思维导图、流程图、时序图、类图、饼图、甘特图 | 任何身份 | [`routes/mermaid.md`](routes/mermaid.md) |
| 其他图表 | `Claude` / `Gemini` / `GPT` / `GLM` | [`routes/svg.md`](routes/svg.md) |
| 其他图表 | `Doubao` / `Seed` / `Other` | [`routes/dsl.md`](routes/dsl.md) |
> **⚠️ SVG 路径失败回退**:走 `routes/svg.md` 时,碰到以下情况之一 → **丢弃当前 SVG改读 `routes/dsl.md` 从零重画,不要逐行修补**
> - 渲染命令直接报错(语法级崩溃,不是 `--check` 的 warn/error
@@ -118,26 +114,18 @@ diagram.png ← 渲染结果
### 写入画板
> [!CAUTION]
> **写入前强制 dry-run**:向已有内容的画板写入时,必须先加 `--overwrite --dry-run` 探测
> 输出含 `XX whiteboard nodes will be deleted` → 必须向用户确认后才能执行
> 关于 --overwrite
> 画板更新命令中,若不携带 --overwrite flag则是增量更新画板内容若画板内已有内容的话新增内容可能会和已有内容重叠导致问题
> 因此,若需要整体更新画板内容,需携带 --overwrite flag 覆盖式更新
```bash
# 第一步dry-run 探测
npx -y @larksuite/whiteboard-cli@^0.2.11 -i <产物文件> --to openapi --format json \
| lark-cli whiteboard +update \
--whiteboard-token <Token> \
--source - --input_format raw \
--idempotent-token <10+字符唯一串> \
--overwrite --dry-run --as user
# 第二步:确认后执行
npx -y @larksuite/whiteboard-cli@^0.2.11 -i <产物文件> --to openapi --format json \
| lark-cli whiteboard +update \
--whiteboard-token <Token> \
--source - --input_format raw \
--idempotent-token <10+字符唯一串> \
--overwrite --as user
--as user \
--overwrite
```
> `--idempotent-token` 最少 10 字符,建议用时间戳+标识拼接(如 `1744800000-board-1`),避免重试导致重复写入。

View File

@@ -54,10 +54,10 @@ cat diagram.puml | lark-cli whiteboard +update \
# 编写 Mermaid 代码
cat > diagram.mmd << 'EOF'
graph TD
A[开始] --&gt; B{判断}
B --&gt;|是| C[处理]
B --&gt;|否| D[结束]
C --&gt; D
A[开始] --> B{判断}
B -->|是| C[处理]
B -->|否| D[结束]
C --> D
EOF
# 从文件读取并更新

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBaseDashboardBlockGetDataDryRun(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+dashboard-block-get-data",
"--base-token", "app_x",
"--block-id", "blk_chart",
"--dry-run",
},
BinaryPath: "../../../lark-cli",
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/base/v3/bases/app_x/dashboards/blocks/blk_chart/data")
assert.Contains(t, output, `"method": "GET"`)
assert.Contains(t, output, `"block_id": "blk_chart"`)
assert.Contains(t, output, `"base_token": "app_x"`)
}
func TestBaseDashboardBlockGetDataDryRun_MissingRequiredFlags(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+dashboard-block-get-data",
"--dry-run",
},
BinaryPath: "../../../lark-cli",
DefaultAs: "bot",
})
require.NoError(t, err)
assert.NotEqual(t, 0, result.ExitCode)
assert.Contains(t, result.Stderr, "base-token")
assert.Contains(t, result.Stderr, "block-id")
}