Compare commits

..

8 Commits

Author SHA1 Message Date
liuxinyang.lxy
cbd729757b docs(event): slim lark-event references to recipes + gotchas; CLI owns structure
- references (im/vc/minutes/whiteboard): drop key catalogs, field/scope tables
  and other content already emitted by `event list`/`event schema`; keep only
  jq recipes, domain semantic gotchas, required params, and per-key identity.
  ~55% token cut across the four references (4343 -> 1940, cl100k_base).
- vc: document the 3 previously-undocumented recording keys, then replace the
  hardcoded key catalog with a pointer to `event list` to stop doc-vs-code drift.
- SKILL.md Topic index: reword rows to advertise recipes/gotchas and route
  structure lookups to the CLI, matching the slimmed references.

Validated by a command-correctness eval (skill+CLI available): 21/21 across the
four domains, identity correct per key, whiteboard required `-p whiteboard_id`
preserved; the VC recording-transcript key (previously unanswerable from the
skill alone) now resolves.

Change-Id: I666e9706ae6ef3e2bbcd56a3fb70c4f8be94182c
2026-06-13 18:14:19 +08:00
liangshuo-1
5a806febc8 fix: reduce base shortcut token overhead (#1426 squashed)
Squashed net diff of PR #1426 (github-base-token-improve): slim
lark-base SKILL.md + formula/workflow references, split the workflow
guide/schema into per-step references, add guess-tolerant flag aliases
and misuse hints for base record shortcuts (with containment-based
suggest), and drive import adjustments.

Combined onto eval/skills-combined alongside #1450 #1389 #1395 #1410.

Change-Id: Iab1f0c4f1a4c93fd9dbd49bc702cb0ef41022bda
2026-06-13 18:11:37 +08:00
lijiayi.2333
aaced42956 docs(mail): trim lark-mail skill context
Change-Id: I40c3d0429b1352949b5b09a79c5700b26fca955d
2026-06-13 18:10:59 +08:00
luozhixiong
a55bd76966 docs: trim lark-im description under 200-token skill gate
Drop the redundant Feed-pin parenthetical from the SKILL.md frontmatter description so it fits the non-waivable <=200-token skill-quality gate (206 -> 194, cl100k_base). Behavior-neutral: only the WHEN-routing copy is shortened; skill body and commands are unchanged. Mirrors the same one-line trim in larksuite-cli-registry skill-meta.yaml (im.description).
2026-06-13 18:09:27 +08:00
luozhixiong
c15cb120a1 docs: slim and reorder lark-im SKILL.md (4253 -> 2040 tokens)
Regenerate lark-im SKILL.md (4253 -> 2040 tokens, tiktoken cl100k_base, -52%)
via per-domain gen-skills flags. Drop the scope permission table and the
duplicated schema-usage note, compact API Resources to a terse identity index,
render the Shortcuts table as one-line routing, and move Shortcuts ahead of the
concept sections (executable-first). Full detail stays in --help / schema (the
source of truth); Go Desc and --help are unchanged. Add the NOT boundary, a
good/bad sender example, Flag-type rationale, and anti-hallucination guidance
with a safe fallback (guide users to the Feishu client; do not auto-execute
high-risk raw-API writes).

The master template is variabilized with defaults preserving current output, so
other domains regenerate byte-identical (verified).

Eval (skillave, IM, 9 cases x 3 runs, lark-cli-eval-analyze two-table compare):
mean pass_rate 0.939 -> 0.969, duration -22%, tool-calls -26%, zero structural
regression; anti-hallucination case 0.58 -> 1.00.

Depends on larksuite-cli-registry change (skill-meta.yaml per-domain flags +
gen-skills.py); must merge together with (or before) this PR.

Change-Id: I33c62664388542bfd8ba85ea06c1c5a493c0d935
2026-06-13 18:09:21 +08:00
mayang.my
93e4fc7af0 docs: improve task skill scoped resolution
Change-Id: I4068d821adf1b249d00950a8f6ff53414825aeff
2026-06-13 18:02:14 +08:00
liangshuo-1
feed8fb884 docs(lark-shared): restructure into prioritized rules + on-demand references
Rewrite the always-loaded SKILL.md from a 168-line monolith into a slim
core: a positioning line plus 7 mental-model "通用准则" ordered by agent
attention priority (silent/proactive rules first; loud-triggered and
low-frequency ones last), and a short routing list. Mechanics and edge
cases move into on-demand references/ (loaded only when relevant).

References (named with the lark-shared- prefix, matching the per-domain
convention):
- lark-shared-auth-split-flow.md     split-flow steps (marked must-read)
- lark-shared-high-risk-approval.md  exit-10 envelope forms + predict/preview
- lark-shared-identity-and-permissions.md  identity model + scope recovery
- lark-shared-config-init.md         first-run config (blocking, no split-flow)
- lark-shared-update-notice.md       _notice handling (update/skills/deprecated)

Fix doc-vs-implementation drift confirmed against the code:
- exit-10 keys on exit code 10, not the type string; covers both the flat
  (type=confirmation_required) and typed (type=confirmation + subtype)
  envelopes, and reads the confirm flag from hint (--yes / --force).
- distinguish permission_violations (raw API) vs missing_scopes (CLI error).
- complete _notice keys (update / skills / deprecated_command).
- identity failure is silent-or-loud per command, not always empty.

Switch description to Chinese; bump version 1.0.0 -> 1.1.0.

Change-Id: I2dff478ecdc05a13f2d750944f637ed2374961e7
2026-06-13 17:58:07 +08:00
raistlin042
0fbfe68726 docs: drop Miaoda brand word from apps command help text (#1399) 2026-06-13 14:00:30 +08:00
136 changed files with 3367 additions and 6030 deletions

View File

@@ -7,7 +7,10 @@
// carrying their own copy.
package suggest
import "sort"
import (
"sort"
"strings"
)
// Levenshtein computes the classic edit distance between two strings. It is
// rune-aware, so it is correct for multi-byte input.
@@ -51,22 +54,29 @@ func Levenshtein(a, b string) int {
// signal of intent that raw edit distance misses.
func Closest(typed string, candidates []string, maxN int) []string {
type scored struct {
name string
prefix int
dist int
name string
contain bool
prefix int
dist int
}
limit := editLimit(typed)
ranked := make([]scored, 0, len(candidates))
for _, c := range candidates {
p := sharedPrefixLen(typed, c)
d := Levenshtein(typed, c)
// Keep only plausible matches: a meaningful shared prefix, or an edit
// distance within budget. Drop everything else so the hint stays short.
if p >= 3 || d <= limit {
ranked = append(ranked, scored{name: c, prefix: p, dist: d})
ct := containsSegment(typed, c)
// Keep only plausible matches: a meaningful shared prefix, an edit
// distance within budget, or one name containing the other (a missing
// namespace prefix like "+block-list" vs "+base-block-list"). Drop
// everything else so the hint stays short.
if p >= 3 || d <= limit || ct {
ranked = append(ranked, scored{name: c, contain: ct, prefix: p, dist: d})
}
}
sort.Slice(ranked, func(i, j int) bool {
if ranked[i].contain != ranked[j].contain {
return ranked[i].contain
}
if ranked[i].prefix != ranked[j].prefix {
return ranked[i].prefix > ranked[j].prefix
}
@@ -94,6 +104,21 @@ func editLimit(s string) int {
return 2
}
// containsSegment reports whether one name contains the other as a substring
// after stripping the "+"/"--" sigils. It catches hallucinated names that drop
// a namespace prefix (e.g. "+block-list" for "+base-block-list"), which share
// almost no prefix and sit far beyond the edit-distance budget. The shorter
// side must be at least 5 runes so generic fragments like "list" do not match
// half the catalog.
func containsSegment(a, b string) bool {
a = strings.TrimLeft(a, "+-")
b = strings.TrimLeft(b, "+-")
if len([]rune(a)) > len([]rune(b)) {
a, b = b, a
}
return len([]rune(a)) >= 5 && strings.Contains(b, a)
}
func sharedPrefixLen(a, b string) int {
ra, rb := []rune(a), []rune(b)
n := 0

View File

@@ -13,12 +13,12 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// AppsAccessScopeGet reads the current access scope configuration of a Miaoda app.
// AppsAccessScopeGet reads the current access scope configuration of an app.
// 响应原样透传服务端契约(字符串 scope 枚举 All/Tenant/Range + 拆分的 users/departments/chats 数组)。
var AppsAccessScopeGet = common.Shortcut{
Service: appsService,
Command: "+access-scope-get",
Description: "Get Miaoda app access scope configuration",
Description: "Get app access scope configuration",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +access-scope-get --app-id <app_id>",
@@ -39,7 +39,7 @@ var AppsAccessScopeGet = common.Shortcut{
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))).
Desc("Get Miaoda app access scope")
Desc("Get app access scope")
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))

View File

@@ -24,7 +24,7 @@ var allowedAccessTargetTypes = map[string]bool{
var AppsAccessScopeSet = common.Shortcut{
Service: appsService,
Command: "+access-scope-set",
Description: "Set Miaoda app access scope (specific / public / tenant)",
Description: "Set app access scope (specific / public / tenant)",
Risk: "write",
Tips: []string{
`Example: lark-cli apps +access-scope-set --app-id <app_id> --scope tenant`,
@@ -52,7 +52,7 @@ var AppsAccessScopeSet = common.Shortcut{
appID := strings.TrimSpace(rctx.Str("app-id"))
dry := common.NewDryRunAPI().
PUT(fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))).
Desc("Set Miaoda app access scope")
Desc("Set app access scope")
body, bodyErr := buildAccessScopeBody(rctx)
if bodyErr != nil {
dry.Set("body_error", bodyErr.Error())

View File

@@ -12,13 +12,13 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
const createHint = "verify --app-type is html or full_stack and --name is non-empty; if this is a permission error, confirm your account can create Miaoda apps"
const createHint = "verify --app-type is html or full_stack and --name is non-empty; if this is a permission error, confirm your account can create apps"
// AppsCreate creates a new Miaoda app.
// AppsCreate creates a new app.
var AppsCreate = common.Shortcut{
Service: appsService,
Command: "+create",
Description: "Create a new Miaoda app",
Description: "Create a new app",
Risk: "write",
Tips: []string{
`Example: lark-cli apps +create --name "审批系统" --app-type full_stack`,
@@ -42,7 +42,7 @@ var AppsCreate = common.Shortcut{
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST(apiBasePath + "/apps").
Desc("Create a Miaoda app").
Desc("Create an app").
Body(buildAppsCreateBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {

View File

@@ -14,7 +14,7 @@ import (
const dbEnvCreateHint = "verify --app-id is correct; if the app is already multi-env this is a conflict — inspect current tables with `lark-cli apps +db-table-list --app-id <app_id> --env dev`"
// AppsDBEnvCreate creates a DB environment for a Miaoda app拆分单库为 dev/online 多环境)。
// AppsDBEnvCreate creates a DB environment for an app拆分单库为 dev/online 多环境)。
//
// 调 POST /apps/{app_id}/db_dev_init。--env 指定要创建的环境,由调用方传入,目前只支持 dev。
// 不可逆:单库一旦拆成 dev/online 双库无法回退。Risk: high-risk-write 触发框架自动注入 --yes 确认关卡。
@@ -30,7 +30,7 @@ var AppsDBEnvCreate = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "app-id", Desc: "app id", Required: true},
{Name: "env", Default: "dev", Enum: []string{"dev"}, Desc: "environment to create (only dev supported for now)"},
{Name: "sync-data", Type: "bool", Desc: "copy existing online data into the new environment (default off)"},
},
@@ -42,7 +42,7 @@ var AppsDBEnvCreate = common.Shortcut{
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(appDbEnvCreatePath(appID)).
Desc("Create Miaoda app DB environment").
Desc("Create app DB environment").
Body(buildDBEnvCreateBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {

View File

@@ -17,7 +17,7 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// AppsDBExecute executes SQL against a Miaoda app database.
// AppsDBExecute executes SQL against an app database.
//
// POST /apps/{app_id}/sql_commandsCLI 永远带 ?transactional=false 进入 DBA 模式
// (不默认包事务、支持 DDL、result 字符串内嵌结构化 JSON
@@ -45,7 +45,7 @@ import (
var AppsDBExecute = common.Shortcut{
Service: appsService,
Command: "+db-execute",
Description: "Execute SQL (SELECT / DML / DDL) against a Miaoda app database",
Description: "Execute SQL (SELECT / DML / DDL) against an app database",
Risk: "high-risk-write",
Tips: []string{
`Example: lark-cli apps +db-execute --app-id <app_id> --sql "SELECT * FROM orders LIMIT 10" --yes`,
@@ -56,7 +56,7 @@ var AppsDBExecute = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "app-id", Desc: "app id", Required: true},
{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
Input: []string{common.Stdin}},
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
@@ -97,7 +97,7 @@ var AppsDBExecute = common.Shortcut{
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(appSQLPath(appID)).
Desc("Execute SQL on Miaoda app database").
Desc("Execute SQL on app database").
Params(buildDBSQLParams(rctx)).
Body(buildDBSQLBody(rctx))
},

View File

@@ -35,7 +35,7 @@ var AppsDBTableGet = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "app-id", Desc: "app id", Required: true},
{Name: "table", Desc: "table name", Required: true},
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
},
@@ -52,7 +52,7 @@ var AppsDBTableGet = common.Shortcut{
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(appTablePath(appID, strings.TrimSpace(rctx.Str("table")))).
Desc("Get Miaoda app db table schema").
Desc("Get app db table schema").
Params(buildDBTableGetParams(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {

View File

@@ -15,7 +15,7 @@ import (
const dbTableListHint = "verify --app-id is correct; if targeting --env dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --env dev`"
// AppsDBTableList lists tables in a Miaoda app's database.
// AppsDBTableList lists tables in an app's database.
//
// GET /apps/{app_id}/tablescursor 分页response items[] 含 estimated_row_count /
// size_bytes optional 字段,默认返回,不必额外传 query。
@@ -29,7 +29,7 @@ const dbTableListHint = "verify --app-id is correct; if targeting --env dev, cre
var AppsDBTableList = common.Shortcut{
Service: appsService,
Command: "+db-table-list",
Description: "List tables in a Miaoda app database (cursor pagination)",
Description: "List tables in an app database (cursor pagination)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +db-table-list --app-id <app_id>",
@@ -39,7 +39,7 @@ var AppsDBTableList = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "app-id", Desc: "app id", Required: true},
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
@@ -52,7 +52,7 @@ var AppsDBTableList = common.Shortcut{
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(appTablesPath(appID)).
Desc("List Miaoda app db tables").
Desc("List app db tables").
Params(buildDBTableListParams(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {

View File

@@ -19,7 +19,7 @@ import (
var AppsHTMLPublish = common.Shortcut{
Service: appsService,
Command: "+html-publish",
Description: "Publish HTML to a Miaoda app (single multipart POST returns the access URL)",
Description: "Publish HTML to an app (single multipart POST returns the access URL)",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +html-publish --app-id <app_id> --path ./dist",
@@ -29,7 +29,7 @@ var AppsHTMLPublish = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "path", Desc: "path to HTML file or directory", Required: true},
{Name: "allow-sensitive", Type: "bool", Desc: "skip the credential-file scan (allow .env / .npmrc / .aws/credentials / etc. in the publish payload)"},
},
@@ -179,7 +179,7 @@ func ensureIndexHTML(candidates []htmlPublishCandidate) error {
}
}
return appsFailedPreconditionParamError("--path", "--path is missing index.html").
WithHint("Miaoda uses index.html as the app entrypoint; for a directory put index.html at the root, or pass a single file named index.html")
WithHint("index.html is the app entrypoint; for a directory put index.html at the root, or pass a single file named index.html")
}
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {

View File

@@ -27,8 +27,8 @@ const defaultInitBranch = "sprint/default"
// the non-empty (`app sync`) path stays a single commit.
const (
commitMsgAppCode = "chore: initialize app project code"
commitMsgAppConfig = "chore: initialize miaoda app config"
commitMsgUpgrade = "chore: initialize miaoda app repository"
commitMsgAppConfig = "chore: initialize app config"
commitMsgUpgrade = "chore: initialize app repository"
)
// scaffold kinds returned by runScaffold and consumed by commitAndPushIfDirty.
@@ -49,11 +49,11 @@ const (
// can swap in a fakeCommandRunner. Production uses execCommandRunner.
var initRunner commandRunner = execCommandRunner{}
// AppsInit initializes a Miaoda app's code and local development environment.
// AppsInit initializes an app's code and local development environment.
var AppsInit = common.Shortcut{
Service: appsService,
Command: "+init",
Description: "Initialize a Miaoda app's code and local development environment",
Description: "Initialize an app's code and local development environment",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +init --app-id <app_id> --dir <dir>",
@@ -73,7 +73,7 @@ var AppsInit = common.Shortcut{
// envelope. The spec and the E2E assert exit-2 + a structured
// {"ok":false,"error":{...}} envelope for missing --app-id, so the empty
// check lives in Validate (typed validation error -> exit 2).
{Name: "app-id", Desc: "Miaoda app ID"},
{Name: "app-id", Desc: "app ID"},
{Name: "dir", Desc: "clone target directory; absolute or relative path (default ./<app-id>)"},
{Name: "template", Desc: "code-init template for an empty repo; optional — if omitted, derived from the app's tech stack"},
},
@@ -87,7 +87,7 @@ var AppsInit = common.Shortcut{
appID := strings.TrimSpace(rctx.Str("app-id"))
template := resolveTemplate(rctx, appID)
dry := common.NewDryRunAPI().
Desc("Initialize Miaoda app code (credential-init, clone, checkout, npx code-init, optional commit/push)").
Desc("Initialize app code (credential-init, clone, checkout, npx code-init, optional commit/push)").
Set("credential_init", fmt.Sprintf("apps +git-credential-init --app-id %s --format json", appID)).
Set("checkout", "git checkout "+defaultInitBranch).
Set("scaffold", fmt.Sprintf("empty repo: npx -y --prefer-online %s app init --template %s --app-id %s; non-empty: npx -y --prefer-online %s app sync + .spark/meta.json app_id patch + conditional skills sync --local", miaodaCLIPkg, template, appID, miaodaCLIPkg)).
@@ -191,7 +191,7 @@ func ensureEmptyDir(dir string) error {
return nil
}
// isAlreadyInitialized reports whether dir is an already-initialized Miaoda app
// isAlreadyInitialized reports whether dir is an already-initialized app
// repo, detected by the presence of <dir>/.spark/meta.json (regardless of its
// app_id value). Used to short-circuit +init into a friendly no-op.
func isAlreadyInitialized(dir string) bool {
@@ -379,7 +379,7 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
}
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
// initialized Miaoda app repo -> skip clone/scaffold/commit, but still refresh
// initialized app repo -> skip clone/scaffold/commit, but still refresh
// the local env so a re-run picks up the latest startup env vars.
if isAlreadyInitialized(dir) {
initLogf(rctx, "Already initialized at %s — refreshing local environment", dir)
@@ -556,7 +556,7 @@ func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID st
// commitAndPushIfDirty commits and pushes only when the working tree has
// changes; a clean tree is a no-op (returns false,false). For the empty-repo
// init path (scaffoldKind == "init") it splits the scaffolded tree into two
// commits — app project code, then Miaoda config (.spark/.agent) — skipping
// commits — app project code, then app config (.spark/.agent) — skipping
// either commit when that group has no changes (no empty commits). Other paths
// commit once. Push is a single `git push origin <branch>` for all commits.
func commitAndPushIfDirty(ctx context.Context, dir, scaffoldKind string) (committed, pushed bool, err error) {
@@ -621,7 +621,7 @@ func stageAndCommit(ctx context.Context, dir, message string, pathspecs ...strin
// classifyPorcelain parses `git status --porcelain` output and partitions the
// changed paths into the "app code" group (anything outside .spark/ and .agent/)
// and the "Miaoda config" group (.spark/ and .agent/). It returns the exact
// and the "app config" group (.spark/ and .agent/). It returns the exact
// porcelain paths so callers can stage them verbatim: porcelain never lists
// gitignored files, so `git add -- <these paths>` never trips git's ignored-path
// error. (Naming an ignored dir explicitly — or combining a "." pathspec with
@@ -658,7 +658,7 @@ func porcelainPath(line string) string {
return p
}
// isConfigPath reports whether p is the Miaoda app-config group: the .spark or
// isConfigPath reports whether p is the app-config group: the .spark or
// .agent directory itself, or anything under them. ".sparkrc" is NOT config.
func isConfigPath(p string) bool {
return p == ".spark" || p == ".agent" ||

View File

@@ -835,7 +835,7 @@ func TestAppsInit_EmptyRepo_TwoCommits(t *testing.T) {
t.Fatalf("unexpected: %v", err)
}
msgs := commitMessages(f.calls)
want := []string{"chore: initialize app project code", "chore: initialize miaoda app config"}
want := []string{"chore: initialize app project code", "chore: initialize app config"}
if len(msgs) != 2 || msgs[0] != want[0] || msgs[1] != want[1] {
t.Fatalf("commit messages = %v, want %v", msgs, want)
}
@@ -896,7 +896,7 @@ func TestAppsInit_EmptyRepo_ConfigOnly_SingleCommit(t *testing.T) {
t.Fatalf("unexpected: %v", err)
}
msgs := commitMessages(f.calls)
if len(msgs) != 1 || msgs[0] != "chore: initialize miaoda app config" {
if len(msgs) != 1 || msgs[0] != "chore: initialize app config" {
t.Fatalf("commit messages = %v, want one config commit", msgs)
}
}
@@ -916,7 +916,7 @@ func TestAppsInit_NonEmpty_SingleInitCommit(t *testing.T) {
t.Fatalf("unexpected: %v", err)
}
msgs := commitMessages(f.calls)
if len(msgs) != 1 || msgs[0] != "chore: initialize miaoda app repository" {
if len(msgs) != 1 || msgs[0] != "chore: initialize app repository" {
t.Fatalf("commit messages = %v, want one upgrade commit", msgs)
}
for _, c := range f.calls {

View File

@@ -12,7 +12,7 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// AppsList lists Miaoda apps visible to the calling user (cursor pagination).
// AppsList lists apps visible to the calling user (cursor pagination).
//
// Supports name fuzzy match (--keyword), ownership-dimension filter
// (--ownership: all / mine / shared), and app-type filter (--app-type). See
@@ -22,7 +22,7 @@ import (
var AppsList = common.Shortcut{
Service: appsService,
Command: "+list",
Description: "List Miaoda apps visible to the calling user (cursor pagination)",
Description: "List apps visible to the calling user (cursor pagination)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +list",
@@ -42,7 +42,7 @@ var AppsList = common.Shortcut{
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET(apiBasePath + "/apps").
Desc("List Miaoda apps").
Desc("List apps").
Params(buildAppsListParams(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {

View File

@@ -13,11 +13,11 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// AppsReleaseCreate creates a release for a Miaoda app.
// AppsReleaseCreate creates a release for an app.
var AppsReleaseCreate = common.Shortcut{
Service: appsService,
Command: "+release-create",
Description: "Create a release for a Miaoda app (returns release_id for status polling)",
Description: "Create a release for an app (returns release_id for status polling)",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +release-create --app-id <app_id>",
@@ -27,7 +27,7 @@ var AppsReleaseCreate = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "branch", Desc: "release branch (server uses default if omitted)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {

View File

@@ -26,7 +26,7 @@ var AppsReleaseGet = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "release-id", Desc: "release ID (the release_id returned by +release-create)", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {

View File

@@ -14,11 +14,11 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// AppsReleaseList lists a Miaoda app's release history (most recent first).
// AppsReleaseList lists an app's release history (most recent first).
var AppsReleaseList = common.Shortcut{
Service: appsService,
Command: "+release-list",
Description: "List a Miaoda app's release history (most recent first)",
Description: "List an app's release history (most recent first)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +release-list --app-id <app_id>",
@@ -28,7 +28,7 @@ var AppsReleaseList = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "status", Enum: []string{"publishing", "finished", "failed"}, Desc: "filter by release status: publishing | finished | failed"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (max 500)"},
{Name: "page-token", Desc: "pagination cursor from a previous response"},

View File

@@ -13,11 +13,11 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// AppsSessionCreate creates a new session under an existing Miaoda app.
// AppsSessionCreate creates a new session under an existing app.
var AppsSessionCreate = common.Shortcut{
Service: appsService,
Command: "+session-create",
Description: "Create a session under a Miaoda app",
Description: "Create a session under an app",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +session-create --app-id <app_id>",
@@ -37,7 +37,7 @@ var AppsSessionCreate = common.Shortcut{
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST(sessionsPath(rctx.Str("app-id"))).
Desc("Create a session under a Miaoda app")
Desc("Create a session under an app")
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPITyped("POST", sessionsPath(rctx.Str("app-id")), nil, nil)

View File

@@ -12,11 +12,11 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// AppsSessionList lists sessions under a Miaoda app (cursor pagination, single page).
// AppsSessionList lists sessions under an app (cursor pagination, single page).
var AppsSessionList = common.Shortcut{
Service: appsService,
Command: "+session-list",
Description: "List sessions under a Miaoda app (cursor pagination)",
Description: "List sessions under an app (cursor pagination)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +session-list --app-id <app_id>",
@@ -39,7 +39,7 @@ var AppsSessionList = common.Shortcut{
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET(sessionsPath(rctx.Str("app-id"))).
Desc("List sessions under a Miaoda app").
Desc("List sessions under an app").
Params(buildSessionListParams(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {

View File

@@ -13,11 +13,11 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// AppsUpdate partially updates a Miaoda app's name / description.
// AppsUpdate partially updates an app's name / description.
var AppsUpdate = common.Shortcut{
Service: appsService,
Command: "+update",
Description: "Partially update a Miaoda app (only provided fields are sent)",
Description: "Partially update an app (only provided fields are sent)",
Risk: "write",
Tips: []string{
`Example: lark-cli apps +update --app-id <app_id> --name "新名称"`,
@@ -49,7 +49,7 @@ var AppsUpdate = common.Shortcut{
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
PATCH(fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))).
Desc("Update a Miaoda app").
Desc("Update an app").
Body(buildAppsUpdateBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {

View File

@@ -12,12 +12,12 @@ import (
// appsService 是 CLI 命令的 service 前缀lark-cli apps ...)。
const appsService = "apps"
// apiBasePath is the registered OAPI prefix for the Miaoda apps domain.
// apiBasePath is the registered OAPI prefix for the apps domain.
const apiBasePath = "/open-apis/spark/v1"
// appIDListHint is the shared recovery hint for commands whose most likely
// failure cause is a wrong/inaccessible --app-id. It points at +list to find
// the correct Miaoda app id. The app_/cli_ format rule is taught in
// the correct app id. The app_/cli_ format rule is taught in
// lark-apps SKILL.md ("app_id 获取"); the hint stays lean and does not repeat it.
const appIDListHint = "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`"

View File

@@ -35,12 +35,12 @@ const gitCredentialIssuePath = apiBasePath + "/apps/:app_id/git_info"
// gitCredentialIssueHint is the actionable next-step attached to a failed
// Git-credential issuance. A 5xx is flagged retryable separately at the call site.
const gitCredentialIssueHint = "failed to issue the Git credential: verify --app-id is correct and you have developer access to this Miaoda app; a 5xx is a transient server error and is safe to retry"
const gitCredentialIssueHint = "failed to issue the Git credential: verify --app-id is correct and you have developer access to this app; a 5xx is a transient server error and is safe to retry"
var AppsGitCredentialInit = common.Shortcut{
Service: appsService,
Command: "+git-credential-init",
Description: "Initialize Git credentials and a URL-scoped Git helper for a Miaoda app repository",
Description: "Initialize Git credentials and a URL-scoped Git helper for an app repository",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +git-credential-init --app-id <app_id>",
@@ -49,7 +49,7 @@ var AppsGitCredentialInit = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
{Name: "app-id", Desc: "app ID", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
@@ -64,7 +64,7 @@ var AppsGitCredentialInit = common.Shortcut{
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(gitCredentialIssuePath).
Desc("Issue a Miaoda Git repository PAT").
Desc("Issue an app Git repository PAT").
Set("mode", "api-plus-local-setup").
Set("action", "initialize_local_git_credential").
Set("app_id", appID).
@@ -81,7 +81,7 @@ var AppsGitCredentialInit = common.Shortcut{
manager := newGitCredentialManager(appID, rctx.Factory.Keychain, runtimeIssuer{rctx: rctx})
result, err := manager.Init(ctx, profileFromConfig(rctx.Config), appID)
if err != nil {
return gitCredentialLocalError("Initialize local Miaoda Git credential", err)
return gitCredentialLocalError("Initialize local app Git credential", err)
}
payload := map[string]interface{}{
"app_id": result.AppID,
@@ -119,7 +119,7 @@ var AppsGitCredentialInit = common.Shortcut{
var AppsGitCredentialRemove = common.Shortcut{
Service: appsService,
Command: "+git-credential-remove",
Description: "Remove local Git credentials and the URL-scoped Git helper for a Miaoda app repository",
Description: "Remove local Git credentials and the URL-scoped Git helper for an app repository",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +git-credential-remove --app-id <app_id>",
@@ -128,7 +128,7 @@ var AppsGitCredentialRemove = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app ID", Required: true},
{Name: "app-id", Desc: "app ID", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
@@ -159,7 +159,7 @@ var AppsGitCredentialRemove = common.Shortcut{
manager := newGitCredentialManager(appID, rctx.Factory.Keychain, nil)
result, err := manager.Remove(ctx, profileFromConfig(rctx.Config), appID)
if err != nil {
return gitCredentialLocalError("Remove local Miaoda Git credential", err)
return gitCredentialLocalError("Remove local app Git credential", err)
}
payload := map[string]interface{}{
"app_id": result.AppID,
@@ -193,7 +193,7 @@ var AppsGitCredentialRemove = common.Shortcut{
var AppsGitCredentialList = common.Shortcut{
Service: appsService,
Command: "+git-credential-list",
Description: "List local Git credentials for Miaoda app repositories",
Description: "List local Git credentials for app repositories",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +git-credential-list",
@@ -215,7 +215,7 @@ var AppsGitCredentialList = common.Shortcut{
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
records, err := listGitCredentialRecords(rctx.Factory.Keychain, time.Now)
if err != nil {
return gitCredentialLocalError("List local Miaoda Git credentials", err)
return gitCredentialLocalError("List local app Git credentials", err)
}
payload := map[string]interface{}{
"count": len(records),
@@ -252,7 +252,7 @@ func InstallOnApps(parent *cobra.Command, f *cmdutil.Factory) {
func newGitCredentialHelperCommand(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "git-credential-helper get|store|erase",
Short: "Git credential helper for Miaoda app repositories",
Short: "Git credential helper for app repositories",
Hidden: true,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
@@ -260,7 +260,7 @@ func newGitCredentialHelperCommand(f *cmdutil.Factory) *cobra.Command {
return runGitCredentialHelper(cmd.Context(), f, strings.TrimSpace(appID), args[0])
},
}
cmd.Flags().String("app-id", "", "Miaoda app ID")
cmd.Flags().String("app-id", "", "app ID")
_ = cmd.Flags().MarkHidden("app-id")
return cmd
}
@@ -457,10 +457,10 @@ func issuedFromData(appID string, data map[string]interface{}) (*gitcred.IssuedC
issued.AppID = appID
}
if issued.GitHTTPURL == "" {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing gitURL")
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response missing gitURL")
}
if issued.PAT == "" {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing token")
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response missing token")
}
return issued, nil
}
@@ -479,7 +479,7 @@ func parseIssueCredentialData(resp *larkcore.ApiResp, err error, cc errclass.Cla
detail := logIDDetail(resp)
if resp == nil || len(resp.RawBody) == 0 {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"Issue Miaoda Git credential: empty response body")
"Issue app Git credential: empty response body")
}
var result map[string]any
jsonErr := json.Unmarshal(resp.RawBody, &result)
@@ -522,7 +522,7 @@ func checkGitInfoBaseResp(result map[string]any, logID string) error {
if message == "" {
message = "Git credential API returned non-zero BaseResp status"
}
baseErr := errs.NewAPIError(errs.SubtypeUnknown, "Issue Miaoda Git credential: %s", message).WithCode(int(code))
baseErr := errs.NewAPIError(errs.SubtypeUnknown, "Issue app Git credential: %s", message).WithCode(int(code))
if logID != "" {
baseErr = baseErr.WithLogID(logID)
}

View File

@@ -699,7 +699,7 @@ func assertStringSliceEqual(t *testing.T, got, want []string) {
func TestGitCredentialLocalErrorWrapsOnlyPlainErrors(t *testing.T) {
plain := errors.New("git config failed")
wrapped := gitCredentialLocalError("List local Miaoda Git credentials", plain)
wrapped := gitCredentialLocalError("List local app Git credentials", plain)
var configErr *errs.ConfigError
if !errors.As(wrapped, &configErr) {
t.Fatalf("plain local error wrapped as %T, want *errs.ConfigError", wrapped)

View File

@@ -458,19 +458,19 @@ func defaultUsername(username string) string {
func validateIssuedCredential(appID, normalizedURL string, issued *IssuedCredential, now int64) error {
if issued == nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: empty credential")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: empty credential")
}
if issued.AppID != "" && issued.AppID != appID {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response app_id %q does not match requested app_id %q", issued.AppID, appID)
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response app_id %q does not match requested app_id %q", issued.AppID, appID)
}
if normalizedURL == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing gitURL")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response missing gitURL")
}
if strings.TrimSpace(issued.PAT) == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing token")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response missing token")
}
if issued.ExpiresAt <= now {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response expiredTime must be in the future")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response expiredTime must be in the future")
}
return nil
}

View File

@@ -27,7 +27,7 @@ const (
)
// CredentialFile is the app-scoped non-secret metadata persisted under the
// Miaoda app storage directory.
// app storage directory.
type CredentialFile struct {
Version int `json:"version"`
CredentialRecord

View File

@@ -53,7 +53,7 @@ func (api appsHTMLPublishAPI) HTMLPublish(ctx context.Context, appID string, tar
return &htmlPublishResponse{URL: url}, nil
}
// OAPI business error codes returned by the Miaoda
// OAPI business error codes returned by the
// /apps/{id}/upload_and_release_html_code endpoint. Owned by the backend
// service; update when new codes are documented in the OAPI spec.
const (
@@ -66,7 +66,7 @@ func buildHTMLPublishFailureHint(code int) string {
case errCodeBuildFailed:
return "server-side build failed: run `lark-cli apps +html-publish --app-id <your-app-id> --path <path> --dry-run` to inspect the packaged file list"
case errCodeAppNotFound:
return "the app does not exist or the caller has no access; ask the user to confirm the app_id (extract it from the Miaoda app URL https://miaoda.feishu.cn/app/app_xxx after /app/, or take the app_xxx string directly)"
return "the app does not exist or the caller has no access; ask the user to confirm the app_id (extract it from the app URL https://miaoda.feishu.cn/app/app_xxx after /app/, or take the app_xxx string directly)"
default:
return ""
}

View File

@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"encoding/json"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -20,6 +21,7 @@ var BaseDataQuery = common.Shortcut{
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
{Name: "table-id", Hidden: true},
{Name: "dsl", Desc: "query JSON DSL; read lark-base-data-query-guide.md first, then lark-base-data-query.md for the full DSL SSOT", Required: true},
},
Tips: []string{
@@ -28,6 +30,9 @@ var BaseDataQuery = common.Shortcut{
"`dimensions` and `measures` cannot both be empty.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("table-id")) != "" {
return baseFlagErrorf("+data-query does not support --table-id; put table names/fields inside --dsl (read lark-base-data-query-guide.md)")
}
var dsl map[string]interface{}
dec := json.NewDecoder(bytes.NewReader([]byte(runtime.Str("dsl"))))
dec.UseNumber()

View File

@@ -73,6 +73,14 @@ func TestDryRunFieldOps(t *testing.T) {
)
assertDryRunContains(t, dryRunFieldList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields", "offset=0", "limit=200")
batchListRT := newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "app_x"},
map[string][]string{"table-id": {"tbl_1", "tbl_2"}},
nil,
map[string]int{"offset": 0, "limit": 50},
)
assertDryRunContains(t, dryRunFieldListBatch(ctx, batchListRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields", "GET /open-apis/base/v3/bases/app_x/tables/tbl_2/fields", "limit=50")
rt := newBaseTestRuntime(
map[string]string{
"base-token": "app_x",

View File

@@ -1066,6 +1066,129 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
}
})
t.Run("list resolves table name", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"tables": []interface{}{
map[string]interface{}{"id": "tbl_orders", "name": "Orders"},
}, "total": 1},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_orders/fields",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"fields": []interface{}{
map[string]interface{}{"id": "fld_name", "name": "Name", "type": "text"},
}, "total": 1},
},
})
if err := runShortcut(t, BaseFieldList, []string{"+field-list", "--base-token", "app_x", "--table-id", "Orders"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"fields"`) || !strings.Contains(got, `"name": "Name"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list batch multiple tables", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_a/fields",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"fields": []interface{}{
map[string]interface{}{"id": "fld_a", "name": "Name", "type": "text", "style": map[string]interface{}{"type": "plain"}},
}, "total": 1},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_b/fields",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"fields": []interface{}{
map[string]interface{}{"id": "fld_b", "name": "Status", "type": "select", "options": []interface{}{map[string]interface{}{"name": "Todo", "color": "red"}}},
}, "total": 1},
},
})
if err := runShortcut(t, BaseFieldListBatch, []string{"+field-list-batch", "--base-token", "app_x", "--table-id", "tbl_a", "--table-id", "tbl_b", "--compact"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"tables"`) || !strings.Contains(got, `"table_id": "tbl_a"`) || !strings.Contains(got, `"table_id": "tbl_b"`) || !strings.Contains(got, `"options": [`) || !strings.Contains(got, `"Todo"`) || !strings.Contains(got, `"style"`) || strings.Contains(got, `"color"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list batch resolves table names", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"tables": []interface{}{
map[string]interface{}{"id": "tbl_orders", "name": "Orders"},
}, "total": 1},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_a/fields",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"fields": []interface{}{
map[string]interface{}{"id": "fld_a", "name": "Name", "type": "text"},
}, "total": 1},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_orders/fields",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"fields": []interface{}{
map[string]interface{}{"id": "fld_order", "name": "Status", "type": "select"},
}, "total": 1},
},
})
if err := runShortcut(t, BaseFieldListBatch, []string{"+field-list-batch", "--base-token", "app_x", "--table-id", "tbl_a", "--table-id", "Orders"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"table_id": "tbl_a"`) || !strings.Contains(got, `"table_id": "tbl_orders"`) || !strings.Contains(got, `"table_ref": "Orders"`) || !strings.Contains(got, `"table_name": "Orders"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list batch default keeps full fields", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_b/fields",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"fields": []interface{}{
map[string]interface{}{"id": "fld_b", "name": "Status", "type": "select", "options": []interface{}{map[string]interface{}{"name": "Todo", "color": "red"}}},
}, "total": 1},
},
})
if err := runShortcut(t, BaseFieldListBatch, []string{"+field-list-batch", "--base-token", "app_x", "--table-id", "tbl_b"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"table_id": "tbl_b"`) || !strings.Contains(got, `"color": "red"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("get", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -1434,6 +1557,48 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("search accepts query alias", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
searchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Title"},
"field_id_list": []interface{}{"fld_title"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Created by AI"}},
"has_more": false,
},
},
}
reg.Register(searchStub)
if err := runShortcut(
t,
BaseRecordSearch,
[]string{
"+record-search",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--query", "Created",
"--search-field", "Title",
"--format", "json",
},
factory,
stdout,
); err != nil {
t.Fatalf("err=%v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(searchStub.CapturedBody, &body); err != nil {
t.Fatalf("captured body json err=%v body=%s", err, string(searchStub.CapturedBody))
}
if body["keyword"] != "Created" {
t.Fatalf("captured body=%#v", body)
}
})
t.Run("search with filter json file", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
tmp := t.TempDir()
@@ -1525,20 +1690,29 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list legacy fields flag rejected", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
t.Run("list fields alias projects columns", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records?field_id=Name&limit=100&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
})
t.Run("list legacy fields flag rejected in dry-run", func(t *testing.T) {
t.Run("list fields alias works in dry-run", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name", "--dry-run"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name", "--dry-run"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, "field_id=Name") {
t.Fatalf("stdout=%s", got)
}
})
t.Run("get", func(t *testing.T) {

View File

@@ -59,6 +59,23 @@ func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlag
return &common.RuntimeContext{Cmd: cmd, Config: &core.CliConfig{UserOpenId: "ou_test"}}
}
func TestFieldSearchOptionsAlias(t *testing.T) {
runtime := newBaseTestRuntime(map[string]string{"field-name": "Status"}, nil, nil)
if got := fieldSearchOptionsRef(runtime); got != "Status" {
t.Fatalf("field ref=%q", got)
}
if err := BaseFieldSearchOptions.Validate(context.Background(), runtime); err != nil {
t.Fatalf("err=%v", err)
}
}
func TestFieldSearchOptionsRequiresFieldRef(t *testing.T) {
err := BaseFieldSearchOptions.Validate(context.Background(), newBaseTestRuntime(map[string]string{}, nil, nil))
if err == nil || !strings.Contains(err.Error(), "--field-id is required") {
t.Fatalf("err=%v", err)
}
}
func TestBaseAction(t *testing.T) {
t.Run("missing action", func(t *testing.T) {
runtime := newBaseTestRuntime(map[string]string{"get": ""}, map[string]bool{"list": false}, nil)
@@ -135,7 +152,7 @@ func TestShortcutsCatalog(t *testing.T) {
want := []string{
"+base-block-list", "+base-block-create", "+base-block-move", "+base-block-rename", "+base-block-delete",
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
"+field-list", "+field-list-batch", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
"+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename",
"+record-list", "+record-search", "+record-get", "+record-upsert", "+record-batch-create", "+record-batch-update", "+record-share-link-create", "+record-upload-attachment", "+record-download-attachment", "+record-remove-attachment", "+record-delete",
"+record-history-list",
@@ -1088,6 +1105,22 @@ func TestBaseRecordValidate(t *testing.T) {
)); err != nil {
t.Fatalf("record search flag validate err=%v", err)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "query": "Alice"},
map[string][]string{"search-field": {"Name"}},
nil,
nil,
)); err != nil {
t.Fatalf("record search query alias validate err=%v", err)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice", "query": "Bob"},
map[string][]string{"search-field": {"Name"}},
nil,
nil,
)); err == nil || !strings.Contains(err.Error(), "use only one") {
t.Fatalf("err=%v", err)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
map[string]string{
"base-token": "b",

View File

@@ -19,6 +19,7 @@ var BaseDashboardBlockGetData = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
baseTokenFlag(true),
{Name: "dashboard-id", Hidden: true},
blockIDFlag(true),
},
Tips: []string{

View File

@@ -21,6 +21,7 @@ var BaseFieldList = common.Shortcut{
tableRefFlag(true),
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
{Name: "compact", Type: "bool", Desc: "return compact field objects (id/name/type/style/options) for lower context cost; default returns full field objects"},
},
DryRun: dryRunFieldList,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
var BaseFieldListBatch = common.Shortcut{
Service: "base",
Command: "+field-list-batch",
Description: "List fields for multiple tables in one call",
Risk: "read",
Scopes: []string{"base:field:read"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
{Name: "table-id", Type: "string_array", Desc: tableRefFlag(true).Desc + "; repeat to list fields for multiple tables", Required: true},
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
{Name: "compact", Type: "bool", Desc: "return compact field objects (id/name/type/style/options) for lower context cost; default returns full field objects"},
},
DryRun: dryRunFieldListBatch,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeFieldListBatch(runtime)
},
}

View File

@@ -10,6 +10,12 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
type fieldListTableRef struct {
input string
id string
name string
}
func dryRunFieldList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
offset := runtime.Int("offset")
if offset < 0 {
@@ -23,6 +29,22 @@ func dryRunFieldList(_ context.Context, runtime *common.RuntimeContext) *common.
Set("table_id", baseTableID(runtime))
}
func dryRunFieldListBatch(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
offset := runtime.Int("offset")
if offset < 0 {
offset = 0
}
limit := common.ParseIntBounded(runtime, "limit", 1, 200)
dry := common.NewDryRunAPI()
for _, tableIDValue := range runtime.StrArray("table-id") {
dry.GET(baseV3Path("bases", runtime.Str("base-token"), "tables", tableIDValue, "fields")).
Params(map[string]interface{}{"offset": offset, "limit": limit}).
Set("base_token", runtime.Str("base-token")).
Set("table_id", tableIDValue)
}
return dry
}
func dryRunFieldGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id").
@@ -61,6 +83,7 @@ func dryRunFieldDelete(_ context.Context, runtime *common.RuntimeContext) *commo
}
func dryRunFieldSearchOptions(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
fieldRef := fieldSearchOptionsRef(runtime)
params := map[string]interface{}{
"offset": runtime.Int("offset"),
"limit": runtime.Int("limit"),
@@ -68,15 +91,15 @@ func dryRunFieldSearchOptions(_ context.Context, runtime *common.RuntimeContext)
if params["limit"].(int) <= 0 {
params["limit"] = 30
}
if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" {
if keyword := strings.TrimSpace(fieldSearchOptionsKeyword(runtime)); keyword != "" {
params["query"] = keyword
}
return common.NewDryRunAPI().
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id/options").
GET(baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "fields", fieldRef, "options")).
Params(params).
Set("base_token", runtime.Str("base-token")).
Set("table_id", baseTableID(runtime)).
Set("field_id", runtime.Str("field-id"))
Set("field_id", fieldRef)
}
func validateFieldJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) {
@@ -118,17 +141,142 @@ func executeFieldList(runtime *common.RuntimeContext) error {
offset = 0
}
limit := common.ParseIntBounded(runtime, "limit", 1, 200)
fields, total, err := listAllFields(runtime, runtime.Str("base-token"), baseTableID(runtime), offset, limit)
baseToken := runtime.Str("base-token")
tableRef, err := resolveFieldListTableRefs(runtime, baseToken, []string{baseTableID(runtime)})
if err != nil {
return err
}
fields, total, err := listAllFields(runtime, baseToken, tableRef[0].id, offset, limit)
if err != nil {
return err
}
if total == 0 {
total = len(fields)
}
if runtime.Bool("compact") {
fields = compactFields(fields)
}
runtime.Out(map[string]interface{}{"fields": fields, "total": total}, nil)
return nil
}
func executeFieldListBatch(runtime *common.RuntimeContext) error {
offset := runtime.Int("offset")
if offset < 0 {
offset = 0
}
limit := common.ParseIntBounded(runtime, "limit", 1, 200)
baseToken := runtime.Str("base-token")
tableRefs, err := resolveFieldListTableRefs(runtime, baseToken, runtime.StrArray("table-id"))
if err != nil {
return err
}
results := make([]map[string]interface{}, 0, len(tableRefs))
for _, tableRef := range tableRefs {
fields, total, err := listAllFields(runtime, baseToken, tableRef.id, offset, limit)
if err != nil {
return err
}
if total == 0 {
total = len(fields)
}
if runtime.Bool("compact") {
fields = compactFields(fields)
}
result := map[string]interface{}{
"table_id": tableRef.id,
"fields": fields,
"total": total,
}
if tableRef.input != tableRef.id {
result["table_ref"] = tableRef.input
}
if tableRef.name != "" {
result["table_name"] = tableRef.name
}
results = append(results, result)
}
runtime.Out(map[string]interface{}{"tables": results, "total": len(results)}, nil)
return nil
}
func resolveFieldListTableRefs(runtime *common.RuntimeContext, baseToken string, refs []string) ([]fieldListTableRef, error) {
if len(refs) == 0 {
return nil, baseValidationErrorf("--table-id is required")
}
resolved := make([]fieldListTableRef, 0, len(refs))
needsTableList := false
for _, raw := range refs {
ref := strings.TrimSpace(raw)
if ref == "" {
return nil, baseValidationErrorf("--table-id must not be empty")
}
if !isBaseTableID(ref) {
needsTableList = true
}
resolved = append(resolved, fieldListTableRef{input: ref, id: ref})
}
if !needsTableList {
return resolved, nil
}
tables, err := listEveryTable(runtime, baseToken)
if err != nil {
return nil, err
}
for i, tableRef := range resolved {
if isBaseTableID(tableRef.input) {
continue
}
table, err := resolveTableRef(tables, tableRef.input)
if err != nil {
return nil, baseValidationErrorf("table %q not found; run +table-list to verify the table name or pass the tbl... ID", tableRef.input)
}
tableIDValue := tableID(table)
if tableIDValue == "" {
return nil, baseValidationErrorf("table %q resolved without a table ID; run +table-list and pass the tbl... ID", tableRef.input)
}
resolved[i].id = tableIDValue
resolved[i].name = tableNameFromMap(table)
}
return resolved, nil
}
func isBaseTableID(ref string) bool {
return strings.HasPrefix(strings.TrimSpace(ref), "tbl")
}
// compactFields projects each field to the keys an agent needs for selection
// (id / name / type / style, plus select option names), dropping formula
// expressions and lookup internals that bloat agent context. Opt-in via
// `--compact`; the default output keeps full field objects.
func compactFields(fields []map[string]interface{}) []map[string]interface{} {
keep := []string{"id", "name", "type", "is_primary", "ui_type", "description", "style"}
out := make([]map[string]interface{}, 0, len(fields))
for _, f := range fields {
c := map[string]interface{}{}
for _, k := range keep {
if v, ok := f[k]; ok {
c[k] = v
}
}
if opts, ok := f["options"].([]interface{}); ok && len(opts) > 0 {
names := make([]interface{}, 0, len(opts))
for _, o := range opts {
if om, ok := o.(map[string]interface{}); ok {
if name, ok := om["name"]; ok {
names = append(names, name)
continue
}
}
names = append(names, o)
}
c["options"] = names
}
out = append(out, c)
}
return out
}
func executeFieldGet(runtime *common.RuntimeContext) error {
baseToken := runtime.Str("base-token")
tableIDValue := baseTableID(runtime)
@@ -184,10 +332,25 @@ func executeFieldDelete(runtime *common.RuntimeContext) error {
return nil
}
func fieldSearchOptionsRef(runtime *common.RuntimeContext) string {
fieldRef := runtime.Str("field-id")
if strings.TrimSpace(fieldRef) == "" {
fieldRef = runtime.Str("field-name")
}
return fieldRef
}
func fieldSearchOptionsKeyword(runtime *common.RuntimeContext) string {
if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" {
return keyword
}
return strings.TrimSpace(runtime.Str("query"))
}
func executeFieldSearchOptions(runtime *common.RuntimeContext) error {
baseToken := runtime.Str("base-token")
tableIDValue := baseTableID(runtime)
fieldRef := runtime.Str("field-id")
fieldRef := fieldSearchOptionsRef(runtime)
params := map[string]interface{}{
"offset": runtime.Int("offset"),
"limit": runtime.Int("limit"),
@@ -195,7 +358,7 @@ func executeFieldSearchOptions(runtime *common.RuntimeContext) error {
if params["limit"].(int) <= 0 {
params["limit"] = 30
}
if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" {
if keyword := strings.TrimSpace(fieldSearchOptionsKeyword(runtime)); keyword != "" {
params["query"] = keyword
}
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef, "options"), params, nil)
@@ -210,7 +373,7 @@ func executeFieldSearchOptions(runtime *common.RuntimeContext) error {
runtime.Out(map[string]interface{}{
"field_id": fieldRef,
"field_name": fieldRef,
"keyword": strings.TrimSpace(runtime.Str("keyword")),
"keyword": fieldSearchOptionsKeyword(runtime),
"options": options,
"total": total,
}, nil)

View File

@@ -5,6 +5,7 @@ package base
import (
"context"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -19,8 +20,10 @@ var BaseFieldSearchOptions = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
fieldRefFlag(true),
fieldRefFlag(false),
{Name: "field-name", Hidden: true},
{Name: "keyword", Desc: "keyword for option query"},
{Name: "query", Hidden: true},
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
{Name: "limit", Type: "int", Default: "30", Desc: "pagination size, default 30"},
},
@@ -29,6 +32,15 @@ var BaseFieldSearchOptions = common.Shortcut{
"Use only for fields with options, such as select or multi-select fields.",
},
DryRun: dryRunFieldSearchOptions,
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(fieldSearchOptionsRef(runtime)) == "" {
return baseFlagErrorf("--field-id is required")
}
if strings.TrimSpace(runtime.Str("keyword")) != "" && strings.TrimSpace(runtime.Str("query")) != "" {
return baseFlagErrorf("--query is a deprecated alias for --keyword; use only one")
}
return nil
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeFieldSearchOptions(runtime)
},

View File

@@ -4,6 +4,7 @@
package base
import (
"context"
"encoding/json"
"os"
"reflect"
@@ -496,3 +497,61 @@ func TestCanonicalSelectAndCompareHelpers(t *testing.T) {
t.Fatalf("err=%v", err)
}
}
func TestNormalizePluralReferenceValues(t *testing.T) {
cases := []struct {
name string
in []string
want []string
}{
{"repeated single values", []string{"fldA", "fldB"}, []string{"fldA", "fldB"}},
{"json array", []string{`["fldA","fldB"]`}, []string{"fldA", "fldB"}},
{"comma separated ids", []string{"fldA, fldB"}, []string{"fldA", "fldB"}},
{"comma separated names", []string{"商品名称,SKU,单价"}, []string{"商品名称", "SKU", "单价"}},
{"trailing comma ignored", []string{"recA,recB,"}, []string{"recA", "recB"}},
{"fullwidth comma kept whole", []string{"销售额,单价"}, []string{"销售额,单价"}},
{"mixed forms", []string{`["fldA"]`, "fldB,fldC", "Name"}, []string{"fldA", "fldB", "fldC", "Name"}},
{"invalid json kept literal", []string{`[fldA`}, []string{`[fldA`}},
{"blank dropped", []string{" ", "fldA"}, []string{"fldA"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := normalizePluralReferenceValues(tc.in); !reflect.DeepEqual(got, tc.want) {
t.Fatalf("got=%v want=%v", got, tc.want)
}
})
}
}
func TestRecordFlagAliasMergeAndDedupe(t *testing.T) {
fieldRT := newBaseTestRuntimeWithArrays(nil, map[string][]string{
"field-id": {"fldA"},
"fields": {"fldA,fldB"},
}, nil, nil)
if got := recordFieldFlags(fieldRT); !reflect.DeepEqual(got, []string{"fldA", "fldB"}) {
t.Fatalf("field flags=%v", got)
}
recordRT := newBaseTestRuntimeWithArrays(nil, map[string][]string{
"record-id": {"recA"},
"record-ids": {`["recA","recB"]`},
}, nil, nil)
if got := recordIDFlags(recordRT); !reflect.DeepEqual(got, []string{"recA", "recB"}) {
t.Fatalf("record flags=%v", got)
}
}
func TestFieldSearchOptionsKeywordQueryAlias(t *testing.T) {
ctx := context.Background()
if err := BaseFieldSearchOptions.Validate(ctx, newBaseTestRuntime(
map[string]string{"field-id": "Status", "keyword": "A", "query": "B"}, nil, nil,
)); err == nil || !strings.Contains(err.Error(), "use only one") {
t.Fatalf("err=%v", err)
}
queryOnly := newBaseTestRuntime(map[string]string{"field-id": "Status", "query": "Do"}, nil, nil)
if err := BaseFieldSearchOptions.Validate(ctx, queryOnly); err != nil {
t.Fatalf("err=%v", err)
}
if got := fieldSearchOptionsKeyword(queryOnly); got != "Do" {
t.Fatalf("keyword=%q", got)
}
}

View File

@@ -21,7 +21,10 @@ var BaseRecordGet = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
{Name: "record-ids", Type: "string_array", Hidden: true},
{Name: "field-id", Type: "string_array", Desc: "field ID or name to project; repeat to keep only needed columns"},
{Name: "field-names", Type: "string_array", Hidden: true},
{Name: "fields", Type: "string_array", Hidden: true},
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
recordReadFormatFlag(),
},

View File

@@ -5,6 +5,7 @@ package base
import (
"context"
"strings"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
@@ -21,9 +22,14 @@ var BaseRecordList = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
recordListFieldRefFlag(),
{Name: "field-names", Type: "string_array", Hidden: true},
{Name: "fields", Type: "string_array", Hidden: true},
recordListViewRefFlag(),
recordFilterFlag(),
recordFilterAliasFlag(),
recordSortFlag(),
recordSortAliasFlag(),
{Name: "json", Hidden: true},
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
recordReadFormatFlag(),
@@ -45,6 +51,9 @@ var BaseRecordList = common.Shortcut{
if err := validateRecordReadFormat(runtime); err != nil {
return err
}
if strings.TrimSpace(runtime.Str("json")) != "" {
return baseFlagErrorf("+record-list does not support --json; use --filter-json for filters and --sort-json for sorting")
}
return validateRecordQueryOptions(runtime)
},
DryRun: dryRunRecordList,

View File

@@ -5,6 +5,7 @@ package base
import (
"context"
"encoding/json"
"net/url"
"strconv"
"strings"
@@ -45,11 +46,11 @@ func validateRecordSelection(runtime *common.RuntimeContext) error {
}
func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, error) {
recordIDs := runtime.StrArray("record-id")
fieldIDs := runtime.StrArray("field-id")
recordIDs := recordIDFlags(runtime)
fieldIDs := recordFieldFlags(runtime)
jsonRaw := strings.TrimSpace(runtime.Str("json"))
if len(recordIDs) > 0 && jsonRaw != "" {
return recordSelection{}, baseFlagErrorf("--record-id and --json are mutually exclusive")
return recordSelection{}, baseFlagErrorf("--record-id/--record-ids and --json are mutually exclusive")
}
if jsonRaw != "" {
pc := newParseCtx(runtime)
@@ -145,6 +146,73 @@ func normalizeRecordGetSelectFields(values interface{}) ([]string, error) {
})
}
func recordIDFlags(runtime *common.RuntimeContext) []string {
return mergeReferenceSources(
runtime.StrArray("record-id"),
normalizePluralReferenceValues(runtime.StrArray("record-ids")),
)
}
func recordFieldFlags(runtime *common.RuntimeContext) []string {
return mergeReferenceSources(
runtime.StrArray("field-id"),
normalizePluralReferenceValues(runtime.StrArray("field-names")),
normalizePluralReferenceValues(runtime.StrArray("fields")),
)
}
// mergeReferenceSources concatenates flag sources, dropping values from later
// sources that an earlier source already provided — so the same reference
// passed through both a canonical flag and its plural alias is sent only once.
// Duplicates inside a single source are kept on purpose: repeating a value on
// one flag is a user mistake that downstream validation should keep rejecting.
func mergeReferenceSources(sources ...[]string) []string {
var out []string
seenBefore := map[string]struct{}{}
for _, source := range sources {
for _, value := range source {
if _, ok := seenBefore[value]; ok {
continue
}
out = append(out, value)
}
for _, value := range source {
seenBefore[value] = struct{}{}
}
}
return out
}
// normalizePluralReferenceValues expands each raw value of a plural alias flag
// (--field-names / --fields / --record-ids) into individual references. Plural
// flags carry list semantics, so an ASCII comma is always a separator (eval
// traces show comma-joined values are exclusively lists, mostly field names);
// a JSON string array is also accepted. Names that contain a literal ASCII
// comma must use the singular flag (--field-id), which never splits. Fullwidth
// "" and "、" are untouched, so ordinary Chinese names are safe here too.
func normalizePluralReferenceValues(values []string) []string {
var out []string
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if strings.HasPrefix(value, "[") {
var parsed []string
if err := json.Unmarshal([]byte(value), &parsed); err == nil {
out = append(out, parsed...)
continue
}
}
for _, part := range strings.Split(value, ",") {
if part = strings.TrimSpace(part); part != "" {
out = append(out, part)
}
}
}
return out
}
func normalizeStringList(values interface{}, opts stringListNormalizeOptions) ([]string, error) {
var rawItems []interface{}
switch typed := values.(type) {
@@ -375,7 +443,7 @@ func validateRecordJSON(runtime *common.RuntimeContext) error {
}
func recordListFields(runtime *common.RuntimeContext) []string {
return runtime.StrArray("field-id")
return recordFieldFlags(runtime)
}
func executeRecordList(runtime *common.RuntimeContext) error {

View File

@@ -26,6 +26,10 @@ func recordFilterFlag() common.Flag {
}
}
func recordFilterAliasFlag() common.Flag {
return common.Flag{Name: "filter", Hidden: true, Input: []string{common.File}}
}
func recordSortFlag() common.Flag {
return common.Flag{
Name: recordSortJSONFlag,
@@ -34,6 +38,10 @@ func recordSortFlag() common.Flag {
}
}
func recordSortAliasFlag() common.Flag {
return common.Flag{Name: "sort", Hidden: true, Input: []string{common.File}}
}
func validateRecordQueryOptions(runtime *common.RuntimeContext) error {
if _, err := parseRecordFilterFlag(runtime); err != nil {
return err
@@ -43,7 +51,10 @@ func validateRecordQueryOptions(runtime *common.RuntimeContext) error {
}
func parseRecordFilterFlag(runtime *common.RuntimeContext) (interface{}, error) {
filterRaw := strings.TrimSpace(runtime.Str(recordFilterJSONFlag))
filterRaw, err := recordQueryFlagValue(runtime, recordFilterJSONFlag, "filter")
if err != nil {
return nil, err
}
if filterRaw == "" {
return nil, nil
}
@@ -52,7 +63,10 @@ func parseRecordFilterFlag(runtime *common.RuntimeContext) (interface{}, error)
}
func parseRecordSortFlag(runtime *common.RuntimeContext) ([]interface{}, error) {
sortRaw := strings.TrimSpace(runtime.Str(recordSortJSONFlag))
sortRaw, err := recordQueryFlagValue(runtime, recordSortJSONFlag, "sort")
if err != nil {
return nil, err
}
if sortRaw == "" {
return nil, nil
}
@@ -64,6 +78,18 @@ func parseRecordSortFlag(runtime *common.RuntimeContext) ([]interface{}, error)
return normalizeRecordSortValue(value, "--"+recordSortJSONFlag)
}
func recordQueryFlagValue(runtime *common.RuntimeContext, canonical string, alias string) (string, error) {
canonicalRaw := strings.TrimSpace(runtime.Str(canonical))
aliasRaw := strings.TrimSpace(runtime.Str(alias))
if canonicalRaw != "" && aliasRaw != "" {
return "", baseFlagErrorf("--%s is a deprecated alias for --%s; use only one", alias, canonical)
}
if canonicalRaw != "" {
return canonicalRaw, nil
}
return aliasRaw, nil
}
func normalizeRecordSortValue(value interface{}, label string) ([]interface{}, error) {
var sortConfig []interface{}
if parsed, ok := value.([]interface{}); ok {
@@ -167,7 +193,7 @@ func applyRecordQueryToBody(runtime *common.RuntimeContext, body map[string]inte
func recordSearchFlagBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body := map[string]interface{}{}
if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" {
if keyword := recordSearchKeyword(runtime); keyword != "" {
body["keyword"] = keyword
}
searchFields := runtime.StrArray("search-field")
@@ -217,6 +243,9 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
if err := validateRecordReadFormat(runtime); err != nil {
return err
}
if strings.TrimSpace(runtime.Str("keyword")) != "" && strings.TrimSpace(runtime.Str("query")) != "" {
return baseFlagErrorf("--query is a deprecated alias for --keyword; use only one")
}
jsonRaw := strings.TrimSpace(runtime.Str("json"))
if jsonRaw != "" {
if recordSearchHasJSONExclusiveFlagInputs(runtime) {
@@ -225,7 +254,7 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
_, err := recordSearchJSONBody(runtime)
return err
}
if strings.TrimSpace(runtime.Str("keyword")) == "" {
if recordSearchKeyword(runtime) == "" {
return baseFlagErrorf("--keyword is required unless --json is used")
}
if len(runtime.StrArray("search-field")) == 0 {
@@ -235,7 +264,7 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
}
func recordSearchHasJSONExclusiveFlagInputs(runtime *common.RuntimeContext) bool {
return strings.TrimSpace(runtime.Str("keyword")) != "" ||
return recordSearchKeyword(runtime) != "" ||
len(runtime.StrArray("search-field")) > 0 ||
len(recordListFields(runtime)) > 0 ||
runtime.Str("view-id") != "" ||
@@ -243,6 +272,13 @@ func recordSearchHasJSONExclusiveFlagInputs(runtime *common.RuntimeContext) bool
runtime.Changed("limit")
}
func recordSearchKeyword(runtime *common.RuntimeContext) string {
if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" {
return keyword
}
return strings.TrimSpace(runtime.Str("query"))
}
func formatRecordQueryPriorityTip() string {
return fmt.Sprintf("Query priority: --%s overrides --view-id's view filter JSON; --%s overrides --view-id's view sort config.", recordFilterJSONFlag, recordSortJSONFlag)
}

View File

@@ -22,11 +22,16 @@ var BaseRecordSearch = common.Shortcut{
tableRefFlag(true),
{Name: "json", Desc: `record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`},
{Name: "keyword", Desc: "keyword for record search; required unless --json is used"},
{Name: "query", Desc: "deprecated alias for --keyword", Hidden: true},
{Name: "search-field", Type: "string_array", Desc: "field ID or name to search; repeat for multiple fields; required unless --json is used"},
recordListFieldRefFlag(),
{Name: "field-names", Type: "string_array", Hidden: true},
{Name: "fields", Type: "string_array", Hidden: true},
recordListViewRefFlag(),
recordFilterFlag(),
recordFilterAliasFlag(),
recordSortFlag(),
recordSortAliasFlag(),
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
{Name: "limit", Type: "int", Default: "10", Desc: "pagination size, range 1-200"},
recordReadFormatFlag(),

View File

@@ -19,6 +19,7 @@ func Shortcuts() []common.Shortcut {
BaseTableUpdate,
BaseTableDelete,
BaseFieldList,
BaseFieldListBatch,
BaseFieldGet,
BaseFieldCreate,
BaseFieldUpdate,

View File

@@ -63,7 +63,8 @@ func executeTableList(runtime *common.RuntimeContext) error {
offset = 0
}
limit := common.ParseIntBounded(runtime, "limit", 1, 100)
tables, total, err := listAllTables(runtime, runtime.Str("base-token"), offset, limit)
baseToken := runtime.Str("base-token")
tables, total, err := listAllTables(runtime, baseToken, offset, limit)
if err != nil {
return err
}
@@ -186,6 +187,24 @@ func listEveryField(runtime *common.RuntimeContext, baseToken, tableID string) (
return items, nil
}
func listEveryTable(runtime *common.RuntimeContext, baseToken string) ([]map[string]interface{}, error) {
const pageLimit = 100
offset := 0
items := []map[string]interface{}{}
for {
batch, total, err := listAllTables(runtime, baseToken, offset, pageLimit)
if err != nil {
return nil, err
}
items = append(items, batch...)
if len(batch) == 0 || len(batch) < pageLimit || (total > 0 && len(items) >= total) {
break
}
offset += len(batch)
}
return items, nil
}
func listEveryView(runtime *common.RuntimeContext, baseToken, tableID string) ([]map[string]interface{}, error) {
const pageLimit = 100
offset := 0

View File

@@ -1,235 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//
// calendar +meeting — get meeting info for calendar events via mget_instance_relation_info
package calendar
import (
"context"
"fmt"
"io"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const meetingLogPrefix = "[calendar +meeting]"
var scopesCalendarMeeting = []string{
"calendar:calendar:read",
"calendar:calendar.event:read",
"vc:meeting.meetingevent:read",
}
// mgetInstanceRelationRequestBody is the request body for mget_instance_relation_info API.
type mgetInstanceRelationRequestBody struct {
InstanceIDs []string `json:"instance_ids"`
NeedMeetingInstanceIDs bool `json:"need_meeting_instance_ids"`
NeedMeetingNotes bool `json:"need_meeting_notes"`
NeedAIMeetingNotes bool `json:"need_ai_meeting_notes"`
}
// meetingInfoItem represents a single event's meeting info in the output.
type meetingInfoItem struct {
EventID string `json:"event_id"`
MeetingID string `json:"meeting_id,omitempty"`
MeetingNote string `json:"meeting_note,omitempty"`
Error string `json:"error,omitempty"`
Hint string `json:"hint,omitempty"`
}
// translateFailMsg converts API fail_msg to a user-friendly error message.
func translateFailMsg(failMsg string) string {
switch failMsg {
case "No Permission":
return "no read permission for this calendar event (not a participant of the event)"
case "Not Found":
return "event not found on the specified calendar (event ID may be incorrect or does not belong to this calendar)"
default:
return failMsg
}
}
// fetchEventMeetingInfo queries mget_instance_relation_info for a single event instance.
func fetchEventMeetingInfo(ctx context.Context, runtime *common.RuntimeContext, instanceID, calendarID string) *meetingInfoItem {
body := &mgetInstanceRelationRequestBody{
InstanceIDs: []string{instanceID},
NeedMeetingInstanceIDs: true,
NeedMeetingNotes: true,
NeedAIMeetingNotes: true,
}
data, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", validate.EncodePathSegment(calendarID)),
nil, body)
if err != nil {
return &meetingInfoItem{EventID: instanceID, Error: err.Error()}
}
// Check for failed instance IDs first
if failedIDs, _ := data["failed_instance_ids"].([]any); len(failedIDs) > 0 {
for _, raw := range failedIDs {
if failInfo, ok := raw.(map[string]any); ok {
if failID, _ := failInfo["instance_id"].(string); failID == instanceID {
failMsg, _ := failInfo["fail_msg"].(string)
return &meetingInfoItem{EventID: instanceID, Error: translateFailMsg(failMsg)}
}
}
}
}
infos, _ := data["instance_relation_infos"].([]any)
if len(infos) == 0 {
return &meetingInfoItem{EventID: instanceID, Error: "no event relation info found"}
}
info, _ := infos[0].(map[string]any)
result := &meetingInfoItem{EventID: instanceID}
// Extract meeting_id (return first if multiple) — API returns string
if rawIDs, _ := info["meeting_instance_ids"].([]any); len(rawIDs) > 0 {
if id, ok := rawIDs[0].(string); ok && id != "" {
result.MeetingID = id
}
}
// Extract meeting_note (return first if multiple)
if notes, _ := info["meeting_notes"].([]any); len(notes) > 0 {
if note, ok := notes[0].(string); ok && note != "" {
result.MeetingNote = note
}
}
// Add hints for empty resources (independent checks)
var emptyFields []string
if result.MeetingID == "" {
emptyFields = append(emptyFields, "meeting_id")
}
if result.MeetingNote == "" {
emptyFields = append(emptyFields, "meeting_note")
}
if len(emptyFields) > 0 {
result.Hint = fmt.Sprintf("%s not found for this event", strings.Join(emptyFields, ", "))
}
return result
}
// CalendarMeeting gets meeting info for calendar events.
var CalendarMeeting = common.Shortcut{
Service: "calendar",
Command: "+meeting",
Description: "Get meeting info for calendar events (meeting_id, meeting_note)",
Risk: "read",
Scopes: []string{"calendar:calendar.event:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "event-ids", Desc: "calendar event instance IDs, comma-separated for batch", Required: true},
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
return err
}
ids := common.SplitCSV(runtime.Str("event-ids"))
const maxBatchSize = 50
if len(ids) > maxBatchSize {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--event-ids: too many IDs (%d), maximum is %d", len(ids), maxBatchSize).WithParam("--event-ids")
}
// dynamic scope check
result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID))
if err == nil && result != nil && result.Scopes != "" {
if missing := auth.MissingScopes(result.Scopes, scopesCalendarMeeting); len(missing) > 0 {
return errs.NewPermissionError(errs.SubtypeMissingScope,
"missing required scope(s): %s", strings.Join(missing, ", ")).
WithHint("run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")).
WithMissingScopes(missing...).
WithIdentity(string(runtime.As()))
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
calendarID := runtime.Str("calendar-id")
if calendarID == "" {
calendarID = "<primary>"
}
return common.NewDryRunAPI().
POST(fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID)).
Set("event_ids", common.SplitCSV(runtime.Str("event-ids"))).
Set("calendar_id", calendarID)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
errOut := runtime.IO().ErrOut
instanceIDs := common.SplitCSV(runtime.Str("event-ids"))
calendarID := strings.TrimSpace(runtime.Str("calendar-id"))
if calendarID == "" {
calendarID = PrimaryCalendarIDStr
}
results := make([]*meetingInfoItem, 0, len(instanceIDs))
const batchDelay = 100 * time.Millisecond
fmt.Fprintf(errOut, "%s querying %d event_id(s)\n", meetingLogPrefix, len(instanceIDs))
for i, id := range instanceIDs {
if err := ctx.Err(); err != nil {
return err
}
if i > 0 {
time.Sleep(batchDelay)
}
fmt.Fprintf(errOut, "%s querying event_id=%s ...\n", meetingLogPrefix, id)
results = append(results, fetchEventMeetingInfo(ctx, runtime, id, calendarID))
}
successCount := 0
for _, r := range results {
if r.Error == "" {
successCount++
}
}
fmt.Fprintf(errOut, "%s done: %d total, %d succeeded, %d failed\n", meetingLogPrefix, len(results), successCount, len(results)-successCount)
if successCount == 0 && len(results) > 0 {
return runtime.OutPartialFailure(map[string]any{"meetings": results}, &output.Meta{Count: len(results)})
}
outData := map[string]any{"meetings": results}
runtime.OutFormat(outData, &output.Meta{Count: len(results)}, func(w io.Writer) {
if len(results) == 0 {
fmt.Fprintln(w, "No events.")
return
}
var rows []map[string]interface{}
for _, r := range results {
row := map[string]interface{}{"event_id": r.EventID}
if r.Error != "" {
row["status"] = "FAIL"
row["error"] = r.Error
} else {
row["status"] = "OK"
if r.MeetingID != "" {
row["meeting_id"] = r.MeetingID
}
if r.MeetingNote != "" {
row["meeting_note"] = r.MeetingNote
}
if r.Hint != "" {
row["hint"] = r.Hint
}
}
rows = append(rows, row)
}
output.PrintTable(w, rows)
fmt.Fprintf(w, "\n%d event(s), %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
})
return nil
},
}

View File

@@ -1,484 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
var calWarmOnce sync.Once
func calWarmTokenCache(t *testing.T) {
t.Helper()
calWarmOnce.Do(func() {
f, _, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/warm",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
})
s := common.Shortcut{
Service: "test",
Command: "+warm",
AuthTypes: []string{"bot"},
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
_, err := rctx.CallAPI("GET", "/open-apis/test/v1/warm", nil, nil)
return err
},
}
parent := &cobra.Command{Use: "test"}
s.Mount(parent, f)
parent.SetArgs([]string{"+warm"})
parent.SilenceErrors = true
parent.SilenceUsage = true
parent.Execute()
})
}
func calDefaultConfig() *core.CliConfig {
return &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
UserOpenId: "ou_testuser",
}
}
func calMountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
calWarmTokenCache(t)
parent := &cobra.Command{Use: "calendar"}
s.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
// ---------------------------------------------------------------------------
// calendar +meeting tests
// ---------------------------------------------------------------------------
func mgetInstanceRelationStub(calendarID, instanceID string, meetingIDs []string, meetingNotes []string, aiMeetingNotes []string) *httpmock.Stub {
infos := map[string]interface{}{
"instance_id": instanceID,
}
mIDs := make([]interface{}, len(meetingIDs))
for i, id := range meetingIDs {
mIDs[i] = id
}
infos["meeting_instance_ids"] = mIDs
if len(meetingNotes) > 0 {
notes := make([]interface{}, len(meetingNotes))
for i, n := range meetingNotes {
notes[i] = n
}
infos["meeting_notes"] = notes
}
if len(aiMeetingNotes) > 0 {
notes := make([]interface{}, len(aiMeetingNotes))
for i, n := range aiMeetingNotes {
notes[i] = n
}
infos["ai_meeting_notes"] = notes
}
return &httpmock.Stub{
Method: "POST",
URL: fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID),
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"instance_relation_infos": []interface{}{infos},
},
},
}
}
func mgetInstanceRelationFailedStub(calendarID, instanceID, failMsg string) *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID),
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"instance_relation_infos": []interface{}{},
"failed_instance_ids": []interface{}{
map[string]interface{}{
"instance_id": instanceID,
"fail_msg": failMsg,
},
},
},
},
}
}
func TestMeeting_Validation_MissingEventIDs(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected validation error for missing --event-ids")
}
}
func TestMeeting_Validation_BatchLimit(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
ids := make([]string, 51)
for i := range ids {
ids[i] = fmt.Sprintf("evt%d", i)
}
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", strings.Join(ids, ","), "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected batch limit error")
}
if !strings.Contains(err.Error(), "too many IDs") {
t.Errorf("expected 'too many IDs' error, got: %v", err)
}
}
func TestMeeting_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt001", "--dry-run", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "mget_instance_relation_info") {
t.Errorf("dry-run should show mget API path, got: %s", stdout.String())
}
}
func TestMeeting_Execute_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
reg.Register(mgetInstanceRelationStub("primary", "evt_m1", []string{"123456"}, []string{"doc_note1"}, []string{"doc_ai1"}))
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt_m1", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
data, _ := resp["data"].(map[string]any)
meetings, _ := data["meetings"].([]any)
if len(meetings) != 1 {
t.Fatalf("expected 1 meeting, got %d", len(meetings))
}
m, _ := meetings[0].(map[string]any)
if m["meeting_id"] != "123456" {
t.Errorf("meeting_id = %v, want 123456", m["meeting_id"])
}
if m["meeting_note"] != "doc_note1" {
t.Errorf("meeting_note = %v, want doc_note1", m["meeting_note"])
}
if _, hasAI := m["ai_meeting_note"]; hasAI {
t.Error("ai_meeting_note should not be present in output")
}
}
func TestMeeting_Execute_FailedInstance(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
reg.Register(mgetInstanceRelationFailedStub("primary", "evt_fail", "No Permission"))
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt_fail", "--as", "user"}, f, stdout)
if err == nil {
t.Fatal("expected partial failure error")
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
// Verify translated fail_msg appears in output
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err == nil {
data, _ := resp["data"].(map[string]any)
meetings, _ := data["meetings"].([]any)
if len(meetings) > 0 {
m, _ := meetings[0].(map[string]any)
if errMsg, _ := m["error"].(string); !strings.Contains(errMsg, "no read permission") {
t.Errorf("expected translated fail_msg, got: %v", errMsg)
}
}
}
}
func TestMeeting_Execute_NoMeeting(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
reg.Register(mgetInstanceRelationStub("primary", "evt_nomeet", []string{}, nil, nil))
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt_nomeet", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
data, _ := resp["data"].(map[string]any)
meetings, _ := data["meetings"].([]any)
if len(meetings) != 1 {
t.Fatalf("expected 1 meeting, got %d", len(meetings))
}
m, _ := meetings[0].(map[string]any)
if hint, _ := m["hint"].(string); !strings.Contains(hint, "meeting_id") {
t.Errorf("expected hint about meeting_id, got: %v", hint)
}
}
// ---------------------------------------------------------------------------
// calendar +search-event tests
// ---------------------------------------------------------------------------
func TestSearchEvent_Validation_InvalidTimeRange(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--start", "bad-format", "--end", "2026-04-27", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected validation error for invalid --start")
}
if !strings.Contains(err.Error(), "--start") {
t.Errorf("unexpected error: %v", err)
}
}
func TestSearchEvent_Validation_TimeRangeStartAfterEnd(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--start", "2026-04-27", "--end", "2026-04-20", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected validation error for start after end")
}
}
func TestSearchEvent_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--query", "周会", "--dry-run", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "search_event") {
t.Errorf("dry-run should show search_event API path, got: %s", stdout.String())
}
}
func TestSearchEvent_Execute_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/primary/events/search_event",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"display_info": "Q2 周会\n2026-04-23 15:00-16:00",
"meta_data": map[string]interface{}{
"event_id": "evt_search1",
"summary": "Q2 周会",
"start": map[string]interface{}{
"date_time": "2026-04-23T15:00:00+08:00",
"timezone": "Asia/Shanghai",
},
"end": map[string]interface{}{
"date_time": "2026-04-23T16:00:00+08:00",
"timezone": "Asia/Shanghai",
},
"is_all_day": false,
"app_link": "https://applink.feishu.cn/...",
},
},
},
"has_more": false,
"page_token": "",
},
},
})
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--query", "周会", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
data, _ := resp["data"].(map[string]any)
if data["calendar_id"] != "primary" {
t.Errorf("calendar_id = %v, want primary", data["calendar_id"])
}
items, _ := data["items"].([]any)
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
item, _ := items[0].(map[string]any)
if item["event_id"] != "evt_search1" {
t.Errorf("event_id = %v, want evt_search1", item["event_id"])
}
if item["summary"] != "Q2 周会" {
t.Errorf("summary = %v, want 'Q2 周会'", item["summary"])
}
}
func TestSearchEvent_Execute_Empty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/primary/events/search_event",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{},
"has_more": false,
},
},
})
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--query", "nonexistent", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ---------------------------------------------------------------------------
// Pure function tests
// ---------------------------------------------------------------------------
func TestParseSearchEventTimeRange(t *testing.T) {
tests := []struct {
name string
start string
end string
wantErr bool
}{
{"empty", "", "", false},
{"valid", "2026-04-20", "2026-04-27", false},
{"start only defaults end", "2026-04-20", "", false},
{"end only defaults start", "", "2026-04-27", false},
{"invalid start format", "not-a-date", "2026-04-27", true},
{"start after end", "2026-04-27", "2026-04-20", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("start", "", "")
cmd.Flags().String("end", "", "")
if tt.start != "" {
_ = cmd.Flags().Set("start", tt.start)
}
if tt.end != "" {
_ = cmd.Flags().Set("end", tt.end)
}
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
_, _, err := parseSearchEventTimeRange(runtime)
if (err != nil) != tt.wantErr {
t.Errorf("parseSearchEventTimeRange() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
t.Run("start only fills end with end-of-day", func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("start", "", "")
cmd.Flags().String("end", "", "")
_ = cmd.Flags().Set("start", "2026-04-20")
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
startRFC, endRFC, err := parseSearchEventTimeRange(runtime)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.HasPrefix(startRFC, "2026-04-20T00:00:00") {
t.Errorf("start = %s, want 2026-04-20T00:00:00...", startRFC)
}
if !strings.HasPrefix(endRFC, "2026-04-20T23:59:59") {
t.Errorf("end = %s, want 2026-04-20T23:59:59...", endRFC)
}
})
t.Run("end only fills start with start-of-day", func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("start", "", "")
cmd.Flags().String("end", "", "")
_ = cmd.Flags().Set("end", "2026-04-27")
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
startRFC, endRFC, err := parseSearchEventTimeRange(runtime)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.HasPrefix(startRFC, "2026-04-27T00:00:00") {
t.Errorf("start = %s, want 2026-04-27T00:00:00...", startRFC)
}
if !strings.HasPrefix(endRFC, "2026-04-27T23:59:59") {
t.Errorf("end = %s, want 2026-04-27T23:59:59...", endRFC)
}
})
}
func TestBuildSearchEventFilter(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("attendee-ids", "", "")
_ = cmd.Flags().Set("attendee-ids", "ou_user1,oc_chat1,omm_room1")
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
filter := buildSearchEventFilter(runtime, "", "")
if filter == nil {
t.Fatal("expected filter to be non-nil")
}
if len(filter.AttendeeUserIDs) != 1 || filter.AttendeeUserIDs[0] != "ou_user1" {
t.Errorf("attendee_user_ids = %v, want [ou_user1]", filter.AttendeeUserIDs)
}
if len(filter.AttendeeChatIDs) != 1 || filter.AttendeeChatIDs[0] != "oc_chat1" {
t.Errorf("attendee_chat_ids = %v, want [oc_chat1]", filter.AttendeeChatIDs)
}
if len(filter.MeetingRoomIDs) != 1 || filter.MeetingRoomIDs[0] != "omm_room1" {
t.Errorf("meeting_room_ids = %v, want [omm_room1]", filter.MeetingRoomIDs)
}
}
func TestBuildSearchEventFilter_Empty(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("attendee-ids", "", "")
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
filter := buildSearchEventFilter(runtime, "", "")
if filter != nil {
t.Errorf("expected nil for empty filter, got %v", filter)
}
}
func TestBuildSearchEventFilter_TimeRange(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("attendee-ids", "", "")
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
filter := buildSearchEventFilter(runtime, "2026-04-20T00:00:00+08:00", "2026-04-27T23:59:59+08:00")
if filter == nil {
t.Fatal("expected filter to be non-nil")
}
if filter.TimeRange == nil {
t.Fatal("expected time_range in filter")
}
if filter.TimeRange.StartTime != "2026-04-20T00:00:00+08:00" {
t.Errorf("start_time = %v, want 2026-04-20T00:00:00+08:00", filter.TimeRange.StartTime)
}
}

View File

@@ -66,8 +66,7 @@ type roomFindSlot struct {
type roomFindTimeSlot struct {
Start string `json:"start,omitempty"`
End string `json:"end,omitempty"`
MeetingRooms []*roomFindSuggestion `json:"meeting_rooms"`
Hint string `json:"hint,omitempty"`
MeetingRooms []*roomFindSuggestion `json:"meeting_rooms,omitempty"`
}
type roomFindOutput struct {
@@ -104,18 +103,11 @@ func collectRoomFindResults(slots []roomFindSlot, limit int, fetch func(roomFind
}
return
}
if suggestions == nil {
suggestions = []*roomFindSuggestion{}
}
ts := &roomFindTimeSlot{
out.TimeSlots = append(out.TimeSlots, &roomFindTimeSlot{
Start: slot.Start,
End: slot.End,
MeetingRooms: suggestions,
}
if len(suggestions) == 0 {
ts.Hint = "no meeting room matches the current filters for this slot"
}
out.TimeSlots = append(out.TimeSlots, ts)
})
}(slot)
}
wg.Wait()
@@ -382,10 +374,6 @@ var CalendarRoomFind = common.Shortcut{
}
for _, slot := range out.TimeSlots {
fmt.Fprintf(w, "%s - %s\n", slot.Start, slot.End)
if len(slot.MeetingRooms) == 0 {
fmt.Fprintf(w, "0 meeting room(s) found: %s\n", slot.Hint)
continue
}
var rows []map[string]interface{}
for _, room := range slot.MeetingRooms {
rows = append(rows, map[string]interface{}{
@@ -396,7 +384,6 @@ var CalendarRoomFind = common.Shortcut{
})
}
output.PrintTable(w, rows)
fmt.Fprintf(w, "%d meeting room(s) found\n", len(slot.MeetingRooms))
fmt.Fprintln(w)
}
})

View File

@@ -4,8 +4,6 @@
package calendar
import (
"encoding/json"
"strings"
"testing"
"time"
)
@@ -84,60 +82,3 @@ func TestCollectRoomFindResults_LimitsConcurrency(t *testing.T) {
t.Fatalf("expected %d time slots, got %d", len(slots), len(out.TimeSlots))
}
}
func TestCollectRoomFindResults_EmptySlotEmitsHintAndArray(t *testing.T) {
slots := []roomFindSlot{
{Start: "2026-03-27T14:00:00+08:00", End: "2026-03-27T15:00:00+08:00"},
{Start: "2026-03-27T15:00:00+08:00", End: "2026-03-27T16:00:00+08:00"},
}
out, err := collectRoomFindResults(slots, 2, func(slot roomFindSlot) ([]*roomFindSuggestion, error) {
if strings.HasPrefix(slot.Start, "2026-03-27T14") {
return []*roomFindSuggestion{{RoomID: "rm_1", RoomName: "Room A"}}, nil
}
return nil, nil
})
if err != nil {
t.Fatalf("collectRoomFindResults returned error: %v", err)
}
if len(out.TimeSlots) != 2 {
t.Fatalf("expected 2 time slots, got %d", len(out.TimeSlots))
}
for _, ts := range out.TimeSlots {
if ts.MeetingRooms == nil {
t.Fatalf("meeting_rooms should be non-nil for slot %s", ts.Start)
}
switch {
case strings.HasPrefix(ts.Start, "2026-03-27T14"):
if len(ts.MeetingRooms) != 1 {
t.Fatalf("expected 1 room for first slot, got %d", len(ts.MeetingRooms))
}
if ts.Hint != "" {
t.Fatalf("non-empty slot should not carry hint, got %q", ts.Hint)
}
case strings.HasPrefix(ts.Start, "2026-03-27T15"):
if len(ts.MeetingRooms) != 0 {
t.Fatalf("expected 0 rooms for empty slot, got %d", len(ts.MeetingRooms))
}
if ts.Hint == "" {
t.Fatal("empty slot should carry a hint explaining the filters")
}
}
}
emptySlot := out.TimeSlots[0]
if !strings.HasPrefix(emptySlot.Start, "2026-03-27T15") {
emptySlot = out.TimeSlots[1]
}
raw, err := json.Marshal(emptySlot)
if err != nil {
t.Fatalf("marshal empty slot: %v", err)
}
if !strings.Contains(string(raw), `"meeting_rooms":[]`) {
t.Fatalf("expected meeting_rooms:[] in JSON, got %s", raw)
}
if !strings.Contains(string(raw), `"hint"`) {
t.Fatalf("expected hint field in JSON, got %s", raw)
}
}

View File

@@ -1,351 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//
// calendar +search-event — search calendar events by keyword, time range, and attendees
package calendar
import (
"context"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const searchEventLogPrefix = "[calendar +search-event]"
const (
defaultSearchEventPageSize = 20
maxSearchEventPageSize = 30
)
var scopesSearchEvent = []string{
"calendar:calendar:read",
"calendar:calendar.event:read",
}
// searchEventTimeRange represents the time range filter for search_event API.
type searchEventTimeRange struct {
StartTime string `json:"start_time,omitempty"`
EndTime string `json:"end_time,omitempty"`
}
// searchEventFilter represents the filter object for the search_event API request.
type searchEventFilter struct {
AttendeeUserIDs []string `json:"attendee_user_ids,omitempty"`
AttendeeChatIDs []string `json:"attendee_chat_ids,omitempty"`
MeetingRoomIDs []string `json:"meeting_room_ids,omitempty"`
TimeRange *searchEventTimeRange `json:"time_range,omitempty"`
}
// searchEventRequestBody is the request body for the search_event API.
type searchEventRequestBody struct {
Query string `json:"query"`
Filter *searchEventFilter `json:"filter,omitempty"`
}
// searchEventTimeInfo represents start/end time info in the search result.
type searchEventTimeInfo struct {
Date string `json:"date,omitempty"`
DateTime string `json:"date_time,omitempty"`
Timezone string `json:"timezone,omitempty"`
}
// searchEventItem represents a single event in the search result output.
type searchEventItem struct {
EventID string `json:"event_id"`
Summary string `json:"summary"`
Start *searchEventTimeInfo `json:"start,omitempty"`
End *searchEventTimeInfo `json:"end,omitempty"`
IsAllDay bool `json:"is_all_day,omitempty"`
AppLink string `json:"app_link,omitempty"`
}
// searchEventOutput is the structured output for +search-event.
type searchEventOutput struct {
CalendarID string `json:"calendar_id"`
Items []searchEventItem `json:"items"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
}
// parseSearchEventTimeRange parses --start / --end into RFC3339 strings.
// When only one side is provided, the other defaults to the same day's
// boundary (start → end-of-day, end → start-of-day).
func parseSearchEventTimeRange(runtime *common.RuntimeContext) (string, string, error) {
startInput := strings.TrimSpace(runtime.Str("start"))
endInput := strings.TrimSpace(runtime.Str("end"))
if startInput == "" && endInput == "" {
return "", "", nil
}
var startSec, endSec int64
if startInput != "" {
ts, err := common.ParseTime(startInput)
if err != nil {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
}
startSec, _ = strconv.ParseInt(ts, 10, 64)
}
if endInput != "" {
ts, err := common.ParseTime(endInput, "end")
if err != nil {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
endSec, _ = strconv.ParseInt(ts, 10, 64)
}
if startInput == "" {
t := time.Unix(endSec, 0).In(time.Local)
startSec = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).Unix()
}
if endInput == "" {
t := time.Unix(startSec, 0).In(time.Local)
endSec = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, t.Location()).Unix()
}
if startSec > endSec {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start must be before --end").WithParam("--start")
}
return time.Unix(startSec, 0).Format(time.RFC3339), time.Unix(endSec, 0).Format(time.RFC3339), nil
}
// buildSearchEventFilter builds the filter object for the search_event API.
func buildSearchEventFilter(runtime *common.RuntimeContext, startTime, endTime string) *searchEventFilter {
attendeeIDs := common.SplitCSV(runtime.Str("attendee-ids"))
var userIDs, chatIDs, roomIDs []string
for _, id := range attendeeIDs {
switch {
case strings.HasPrefix(id, "ou_"):
userIDs = append(userIDs, id)
case strings.HasPrefix(id, "oc_"):
chatIDs = append(chatIDs, id)
case strings.HasPrefix(id, "omm_"):
roomIDs = append(roomIDs, id)
default:
userIDs = append(userIDs, id)
}
}
var tr *searchEventTimeRange
if startTime != "" || endTime != "" {
tr = &searchEventTimeRange{StartTime: startTime, EndTime: endTime}
}
if len(userIDs) == 0 && len(chatIDs) == 0 && len(roomIDs) == 0 && tr == nil {
return nil
}
return &searchEventFilter{
AttendeeUserIDs: userIDs,
AttendeeChatIDs: chatIDs,
MeetingRoomIDs: roomIDs,
TimeRange: tr,
}
}
// extractTimeInfo extracts time info from a meta_data start/end map.
func extractTimeInfo(m map[string]any) *searchEventTimeInfo {
if m == nil {
return nil
}
info := &searchEventTimeInfo{}
if v, ok := m["date"].(string); ok && v != "" {
info.Date = v
}
if v, ok := m["date_time"].(string); ok && v != "" {
info.DateTime = v
}
if v, ok := m["timezone"].(string); ok && v != "" {
info.Timezone = v
}
if info.Date == "" && info.DateTime == "" {
return nil
}
return info
}
// CalendarSearchEvent searches calendar events by keyword, time range, and attendees.
var CalendarSearchEvent = common.Shortcut{
Service: "calendar",
Command: "+search-event",
Description: "Search calendar events by keyword, time range, and attendees",
Risk: "read",
Scopes: []string{"calendar:calendar.event:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "query", Desc: "search keyword"},
{Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"},
{Name: "start", Desc: "search time range start (ISO 8601 or YYYY-MM-DD)"},
{Name: "end", Desc: "search time range end (ISO 8601 or YYYY-MM-DD)"},
{Name: "page-token", Desc: "page token for next page"},
{Name: "page-size", Default: "20", Desc: "page size, 1-30 (default 20)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
return err
}
if _, _, err := parseSearchEventTimeRange(runtime); err != nil {
return err
}
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", defaultSearchEventPageSize, 1, maxSearchEventPageSize); err != nil {
return err
}
// dynamic scope check
result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID))
if err == nil && result != nil && result.Scopes != "" {
if missing := auth.MissingScopes(result.Scopes, scopesSearchEvent); len(missing) > 0 {
return errs.NewPermissionError(errs.SubtypeMissingScope,
"missing required scope(s): %s", strings.Join(missing, ", ")).
WithHint("run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")).
WithMissingScopes(missing...).
WithIdentity(string(runtime.As()))
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
calendarID := runtime.Str("calendar-id")
if calendarID == "" {
calendarID = "<primary>"
}
return common.NewDryRunAPI().
POST(fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/search_event", calendarID)).
Set("calendar_id", calendarID)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
calendarID := strings.TrimSpace(runtime.Str("calendar-id"))
if calendarID == "" {
calendarID = PrimaryCalendarIDStr
}
startTime, endTime, err := parseSearchEventTimeRange(runtime)
if err != nil {
return err
}
// Build request body — always send query (even if empty)
body := &searchEventRequestBody{
Query: strings.TrimSpace(runtime.Str("query")),
}
if filter := buildSearchEventFilter(runtime, startTime, endTime); filter != nil {
body.Filter = filter
}
// Build query params
params := map[string]any{}
pageSize, _ := strconv.Atoi(strings.TrimSpace(runtime.Str("page-size")))
if pageSize <= 0 {
pageSize = defaultSearchEventPageSize
}
params["page_size"] = strconv.Itoa(pageSize)
if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" {
params["page_token"] = pt
}
data, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/search_event", validate.EncodePathSegment(calendarID)),
params, body)
if err != nil {
return err
}
if data == nil {
data = map[string]any{}
}
items := common.GetSlice(data, "items")
hasMore, _ := data["has_more"].(bool)
pageToken, _ := data["page_token"].(string)
// Transform items to structured output
outItems := make([]searchEventItem, 0, len(items))
for _, raw := range items {
item, _ := raw.(map[string]any)
if item == nil {
continue
}
meta, _ := item["meta_data"].(map[string]any)
out := searchEventItem{}
if meta != nil {
if v, ok := meta["event_id"].(string); ok {
out.EventID = v
}
if v, ok := meta["summary"].(string); ok {
out.Summary = v
}
if v, ok := meta["is_all_day"].(bool); ok {
out.IsAllDay = v
}
if v, ok := meta["app_link"].(string); ok {
out.AppLink = v
}
if start, ok := meta["start"].(map[string]any); ok {
out.Start = extractTimeInfo(start)
}
if end, ok := meta["end"].(map[string]any); ok {
out.End = extractTimeInfo(end)
}
}
outItems = append(outItems, out)
}
outData := searchEventOutput{
CalendarID: calendarID,
Items: outItems,
HasMore: hasMore,
PageToken: pageToken,
}
runtime.OutFormat(outData, &output.Meta{Count: len(outItems)}, func(w io.Writer) {
if len(outItems) == 0 {
fmt.Fprintln(w, "No events found.")
return
}
var rows []map[string]interface{}
for _, item := range outItems {
row := map[string]interface{}{
"event_id": item.EventID,
"summary": common.TruncateStr(item.Summary, 40),
}
if item.Start != nil {
if item.Start.DateTime != "" {
row["start"] = item.Start.DateTime
} else if item.Start.Date != "" {
row["start"] = item.Start.Date
}
}
if item.End != nil {
if item.End.DateTime != "" {
row["end"] = item.End.DateTime
} else if item.End.Date != "" {
row["end"] = item.End.Date
}
}
if item.IsAllDay {
row["is_all_day"] = true
}
rows = append(rows, row)
}
output.PrintTable(w, rows)
fmt.Fprintf(w, "\n%d event(s) found\n", len(outItems))
})
if hasMore && runtime.Format != "json" && runtime.Format != "" {
fmt.Fprintf(runtime.IO().Out, "\n(more available, page_token: %s)\n", pageToken)
}
return nil
},
}

View File

@@ -2234,10 +2234,10 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
// Shortcuts() registration test
// ---------------------------------------------------------------------------
func TestShortcuts_Returns9(t *testing.T) {
func TestShortcuts_Returns7(t *testing.T) {
shortcuts := Shortcuts()
if len(shortcuts) != 9 {
t.Fatalf("expected 9 shortcuts, got %d", len(shortcuts))
if len(shortcuts) != 7 {
t.Fatalf("expected 7 shortcuts, got %d", len(shortcuts))
}
names := map[string]bool{}

View File

@@ -15,7 +15,5 @@ func Shortcuts() []common.Shortcut {
CalendarRoomFind,
CalendarRsvp,
CalendarSuggestion,
CalendarMeeting,
CalendarSearchEvent,
}
}

View File

@@ -31,7 +31,10 @@ var DriveImport = common.Shortcut{
{Name: "type", Desc: "target document type (docx, sheet, bitable, slides)", Required: true},
{Name: "folder-token", Desc: "target folder token (omit for root folder; API accepts empty mount_key as root)"},
{Name: "name", Desc: "imported file name (default: local file name without extension)"},
{Name: "target-token", Desc: "existing token to import data into (only for type=bitable); when set, data is mounted into this bitable instead of creating a new one"},
{Name: "target-token", Desc: "existing token to import data into (only for type=bitable); verify the returned verification_token, not the import task token"},
},
Tips: []string{
"When --target-token is set, data is mounted into that existing Base; verify output.verification_token with lark-cli base +base-get.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveImportSpec(driveImportSpec{
@@ -139,6 +142,14 @@ var DriveImport = common.Shortcut{
if status.Extra != nil {
out["extra"] = status.Extra
}
if spec.TargetToken != "" {
out["target_token"] = spec.TargetToken
out["verification_token"] = spec.TargetToken
if u := common.BuildResourceURL(runtime.Config.Brand, "bitable", spec.TargetToken); u != "" {
out["verification_url"] = u
}
out["verify_hint"] = fmt.Sprintf("because --target-token was used, verify the existing target Base with: lark-cli base +base-get --base-token %s", spec.TargetToken)
}
if !ready {
nextCommand := driveImportTaskResultCommand(ticket)
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)

View File

@@ -762,3 +762,43 @@ func TestDriveImportFallbackURLForSlides(t *testing.T) {
t.Fatalf("data.url = %#v, want %q (slides fallback)", got, want)
}
}
func TestDriveImportTargetTokenOutputsVerificationToken(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveImportTestConfig("target-token"))
driveImportMockEnv(t, reg, "ticket_target", map[string]interface{}{
"token": "bascn_backend_result",
"type": "bitable",
"job_status": float64(0),
})
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Chdir: %v", err)
}
defer os.Chdir(origDir)
if err := os.WriteFile("snapshot.base", []byte("fake-base"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := mountAndRunDrive(t, DriveImport, []string{
"+import", "--file", "snapshot.base", "--type", "bitable", "--target-token", "bascn_target", "--as", "user",
}, f, stdout); err != nil {
t.Fatalf("import should succeed, got: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if got, want := data["token"], "bascn_backend_result"; got != want {
t.Fatalf("data.token = %#v, want backend result token %q", got, want)
}
if got, want := data["verification_token"], "bascn_target"; got != want {
t.Fatalf("data.verification_token = %#v, want target token %q", got, want)
}
if got, want := data["target_token"], "bascn_target"; got != want {
t.Fatalf("data.target_token = %#v, want target token %q", got, want)
}
hint, _ := data["verify_hint"].(string)
if !strings.Contains(hint, "lark-cli base +base-get --base-token bascn_target") {
t.Fatalf("verify_hint = %q, want target-token verification command", hint)
}
}

View File

@@ -1,285 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//
// minutes +detail — query minute details with selective artifact flags
package minutes
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const minutesDetailLogPrefix = "[minutes +detail]"
// Error codes from the minutes API.
const minutesDetailNoReadPermissionCode = 2091005
var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)
var scopesDetailMinuteTokens = []string{
"minutes:minutes.basic:read",
"minutes:minutes.artifacts:read",
}
// minuteDetailItem represents a single minute detail result.
type minuteDetailItem struct {
MinuteToken string `json:"minute_token"`
Title string `json:"title"`
NoteID string `json:"note_id"`
Artifacts map[string]any `json:"artifacts,omitempty"`
Error string `json:"error,omitempty"`
}
// fetchMinuteDetail queries a single minute's metadata and selected artifacts.
func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) *minuteDetailItem {
data, err := runtime.CallAPITyped(http.MethodGet,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
if err != nil {
result := &minuteDetailItem{MinuteToken: minuteToken}
if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken)
} else {
result.Error = fmt.Sprintf("failed to query minute: %v", err)
}
return result
}
minute, _ := data["minute"].(map[string]any)
if minute == nil {
return &minuteDetailItem{MinuteToken: minuteToken, Error: "minute not found"}
}
result := &minuteDetailItem{MinuteToken: minuteToken}
if v, ok := minute["title"].(string); ok && v != "" {
result.Title = v
}
if v, ok := minute["note_id"].(string); ok && v != "" {
result.NoteID = v
}
// Fetch artifacts selectively based on flags
needSummary := runtime.Bool("summary")
needTodo := runtime.Bool("todo")
needChapter := runtime.Bool("chapter")
needTranscript := runtime.Bool("transcript")
needKeyword := runtime.Bool("keyword")
if needSummary || needTodo || needChapter || needTranscript || needKeyword {
artData, err := runtime.CallAPITyped(http.MethodGet,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "%s failed to fetch artifacts for %s: %v\n", minutesDetailLogPrefix, minuteToken, err)
} else {
artifacts := make(map[string]any)
if needSummary {
if v, ok := artData["summary"].(string); ok && v != "" {
artifacts["summary"] = v
} else {
artifacts["summary"] = ""
}
}
if needTodo {
if v, ok := artData["minute_todos"].([]any); ok && len(v) > 0 {
artifacts["todos"] = v
} else {
artifacts["todos"] = []any{}
}
}
if needChapter {
if v, ok := artData["minute_chapters"].([]any); ok && len(v) > 0 {
artifacts["chapters"] = v
} else {
artifacts["chapters"] = []any{}
}
}
if needKeyword {
if v, ok := artData["keywords"].([]any); ok && len(v) > 0 {
artifacts["keywords"] = v
} else {
artifacts["keywords"] = []any{}
}
}
if needTranscript {
if v, ok := artData["transcript"].(string); ok && v != "" {
if path := saveDetailTranscript(runtime, minuteToken, result.Title, []byte(v)); path != "" {
artifacts["transcript_file"] = path
} else {
artifacts["transcript_file"] = ""
}
} else {
artifacts["transcript_file"] = ""
}
}
result.Artifacts = artifacts
}
}
return result
}
// saveDetailTranscript persists transcript bytes to the canonical artifact path.
func saveDetailTranscript(runtime *common.RuntimeContext, minuteToken, title string, content []byte) string {
errOut := runtime.IO().ErrOut
dirName := common.DefaultMinuteArtifactDir(minuteToken)
transcriptPath := filepath.Join(dirName, common.DefaultTranscriptFileName)
if !runtime.Bool("overwrite") {
if _, statErr := runtime.FileIO().Stat(transcriptPath); statErr == nil {
fmt.Fprintf(errOut, "%s transcript already exists: %s (use --overwrite to replace)\n", minutesDetailLogPrefix, transcriptPath)
return transcriptPath
}
}
fmt.Fprintf(errOut, "%s writing transcript: %s\n", minutesDetailLogPrefix, transcriptPath)
if _, err := runtime.FileIO().Save(transcriptPath, fileio.SaveOptions{}, bytes.NewReader(content)); err != nil {
fmt.Fprintf(errOut, "%s failed to write transcript: %v\n", minutesDetailLogPrefix, err)
return ""
}
return transcriptPath
}
// MinutesDetail queries minute details with selective artifact flags.
var MinutesDetail = common.Shortcut{
Service: "minutes",
Command: "+detail",
Description: "Query minute details with selective artifact flags (summary, todo, chapter, transcript, keyword)",
Risk: "read",
Scopes: []string{"minutes:minutes.basic:read", "minutes:minutes.artifacts:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "minute-tokens", Desc: "minute tokens, comma-separated for batch", Required: true},
{Name: "summary", Type: "bool", Desc: "include summary"},
{Name: "todo", Type: "bool", Desc: "include todos"},
{Name: "chapter", Type: "bool", Desc: "include chapters"},
{Name: "transcript", Type: "bool", Desc: "include transcript (saved to file)"},
{Name: "keyword", Type: "bool", Desc: "include keywords"},
{Name: "overwrite", Type: "bool", Desc: "overwrite existing transcript files"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
const maxBatchSize = 50
if len(tokens) > maxBatchSize {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-tokens: too many tokens (%d), maximum is %d", len(tokens), maxBatchSize).WithParam("--minute-tokens")
}
for _, token := range tokens {
if !validMinuteTokenDetail.MatchString(token) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid minute token %q: must contain only lowercase alphanumeric characters", token).WithParam("--minute-tokens")
}
}
// dynamic scope check
result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID))
if err == nil && result != nil && result.Scopes != "" {
if missing := auth.MissingScopes(result.Scopes, scopesDetailMinuteTokens); len(missing) > 0 {
return errs.NewPermissionError(errs.SubtypeMissingScope,
"missing required scope(s): %s", strings.Join(missing, ", ")).
WithHint("run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")).
WithMissingScopes(missing...).
WithIdentity(string(runtime.As()))
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
tokens := runtime.Str("minute-tokens")
d := common.NewDryRunAPI().
GET("/open-apis/minutes/v1/minutes/{minute_token}").
Set("minute_tokens", common.SplitCSV(tokens))
if runtime.Bool("summary") || runtime.Bool("todo") || runtime.Bool("chapter") || runtime.Bool("transcript") || runtime.Bool("keyword") {
d.GET("/open-apis/minutes/v1/minutes/{minute_token}/artifacts")
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
errOut := runtime.IO().ErrOut
minuteTokens := common.SplitCSV(runtime.Str("minute-tokens"))
results := make([]*minuteDetailItem, 0, len(minuteTokens))
const batchDelay = 100 * time.Millisecond
fmt.Fprintf(errOut, "%s querying %d minute_token(s)\n", minutesDetailLogPrefix, len(minuteTokens))
for i, token := range minuteTokens {
if err := ctx.Err(); err != nil {
return err
}
if i > 0 {
time.Sleep(batchDelay)
}
fmt.Fprintf(errOut, "%s querying minute_token=%s ...\n", minutesDetailLogPrefix, token)
results = append(results, fetchMinuteDetail(ctx, runtime, token))
}
successCount := 0
for _, r := range results {
if r.Error == "" {
successCount++
}
}
fmt.Fprintf(errOut, "%s done: %d total, %d succeeded, %d failed\n", minutesDetailLogPrefix, len(results), successCount, len(results)-successCount)
if successCount == 0 && len(results) > 0 {
return runtime.OutPartialFailure(map[string]any{"minutes": results}, &output.Meta{Count: len(results)})
}
outData := map[string]any{"minutes": results}
runtime.OutFormat(outData, &output.Meta{Count: len(results)}, func(w io.Writer) {
if len(results) == 0 {
fmt.Fprintln(w, "No minutes.")
return
}
var rows []map[string]interface{}
for _, r := range results {
row := map[string]interface{}{"minute_token": r.MinuteToken}
if r.Error != "" {
row["status"] = "FAIL"
row["error"] = r.Error
} else {
row["status"] = "OK"
row["title"] = r.Title
row["note_id"] = r.NoteID
if len(r.Artifacts) > 0 {
var parts []string
if _, ok := r.Artifacts["summary"]; ok {
parts = append(parts, "summary")
}
if _, ok := r.Artifacts["todos"]; ok {
parts = append(parts, "todo")
}
if _, ok := r.Artifacts["chapters"]; ok {
parts = append(parts, "chapter")
}
if _, ok := r.Artifacts["keywords"]; ok {
parts = append(parts, "keyword")
}
if _, ok := r.Artifacts["transcript_file"]; ok {
parts = append(parts, "transcript")
}
if len(parts) > 0 {
row["artifacts"] = strings.Join(parts, ", ")
}
}
}
rows = append(rows, row)
}
output.PrintTable(w, rows)
fmt.Fprintf(w, "\n%d minute(s), %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
})
return nil
},
}

View File

@@ -1,372 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"sync"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
var detailWarmOnce sync.Once
func detailWarmTokenCache(t *testing.T) {
t.Helper()
detailWarmOnce.Do(func() {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/warm",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
})
s := common.Shortcut{
Service: "test",
Command: "+warm",
AuthTypes: []string{"bot"},
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
_, err := rctx.CallAPI("GET", "/open-apis/test/v1/warm", nil, nil)
return err
},
}
parent := &cobra.Command{Use: "test"}
s.Mount(parent, f)
parent.SetArgs([]string{"+warm"})
parent.SilenceErrors = true
parent.SilenceUsage = true
parent.Execute()
})
}
func detailMountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
detailWarmTokenCache(t)
parent := &cobra.Command{Use: "minutes"}
s.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
func detailBotExec(t *testing.T, name string, f *cmdutil.Factory, fn func(context.Context, *common.RuntimeContext) error) error {
t.Helper()
detailWarmTokenCache(t)
s := common.Shortcut{
Service: "test",
Command: "+" + name,
AuthTypes: []string{"bot"},
HasFormat: true,
Execute: fn,
}
parent := &cobra.Command{Use: "minutes"}
s.Mount(parent, f)
parent.SetArgs([]string{"+" + name, "--format", "json"})
parent.SilenceErrors = true
parent.SilenceUsage = true
return parent.Execute()
}
// ---------------------------------------------------------------------------
// Validation tests
// ---------------------------------------------------------------------------
func detailMinuteGetStub(token, noteID, title string) *httpmock.Stub {
minute := map[string]interface{}{"title": title}
if noteID != "" {
minute["note_id"] = noteID
}
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/minutes/v1/minutes/" + token,
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"minute": minute},
},
}
}
func detailArtifactsStub(token, transcript string) *httpmock.Stub {
data := map[string]interface{}{
"summary": "Test summary content",
"minute_todos": []interface{}{map[string]interface{}{"content": "Buy milk"}},
"minute_chapters": []interface{}{map[string]interface{}{"title": "Intro", "summary_content": "Opening"}},
"keywords": []interface{}{"budget", "roadmap"},
}
if transcript != "" {
data["transcript"] = transcript
}
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/minutes/v1/minutes/" + token + "/artifacts",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": data,
},
}
}
func TestDetail_Validation_MissingMinuteTokens(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected validation error for missing --minute-tokens")
}
}
func TestDetail_Validation_InvalidToken(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "INVALID!", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected validation error for invalid token")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Param != "--minute-tokens" {
t.Errorf("Param = %q, want --minute-tokens", ve.Param)
}
}
func TestDetail_Validation_BatchLimit(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
tokens := make([]string, 51)
for i := range tokens {
tokens[i] = fmt.Sprintf("tok%d", i)
}
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", strings.Join(tokens, ","), "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected batch limit error")
}
if !strings.Contains(err.Error(), "too many tokens") {
t.Errorf("expected 'too many tokens' error, got: %v", err)
}
}
// ---------------------------------------------------------------------------
// DryRun tests
// ---------------------------------------------------------------------------
func TestDetail_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tok001", "--dry-run", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "/open-apis/minutes/v1/minutes/") {
t.Errorf("dry-run should show minutes API path, got: %s", stdout.String())
}
}
func TestDetail_DryRun_WithArtifactFlags(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tok001", "--summary", "--todo", "--dry-run", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "artifacts") {
t.Errorf("dry-run should show artifacts API path when artifact flags are set, got: %s", stdout.String())
}
}
// ---------------------------------------------------------------------------
// Execute tests with mocked HTTP
// ---------------------------------------------------------------------------
func TestDetail_Execute_BasicInfo(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(detailMinuteGetStub("tokbasic", "", "Test Meeting"))
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokbasic", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
data, _ := resp["data"].(map[string]any)
minutes, _ := data["minutes"].([]any)
if len(minutes) != 1 {
t.Fatalf("expected 1 minute, got %d", len(minutes))
}
m, _ := minutes[0].(map[string]any)
if m["minute_token"] != "tokbasic" {
t.Errorf("minute_token = %v, want tokbasic", m["minute_token"])
}
if m["title"] != "Test Meeting" {
t.Errorf("title = %v, want Test Meeting", m["title"])
}
if _, ok := m["note_id"]; ok {
t.Errorf("note_id should be omitted when minute has no note_id, got %v", m["note_id"])
}
}
func TestDetail_Execute_WithSummaryAndTodo(t *testing.T) {
chdirForDetailTest(t)
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(detailMinuteGetStub("tokart", "note_art", "Artifact Meeting"))
reg.Register(detailArtifactsStub("tokart", ""))
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokart", "--summary", "--todo", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
data, _ := resp["data"].(map[string]any)
minutes, _ := data["minutes"].([]any)
if len(minutes) != 1 {
t.Fatalf("expected 1 minute, got %d", len(minutes))
}
m, _ := minutes[0].(map[string]any)
if m["note_id"] != "note_art" {
t.Errorf("note_id = %v, want note_art", m["note_id"])
}
arts, _ := m["artifacts"].(map[string]any)
if arts == nil {
t.Fatal("expected artifacts to be present")
}
if _, ok := arts["summary"]; !ok {
t.Error("expected summary in artifacts")
}
if _, ok := arts["todos"]; !ok {
t.Error("expected todos in artifacts")
}
// chapter and keywords should NOT be present since flags not set
if _, ok := arts["chapters"]; ok {
t.Error("chapters should not be present when --chapter not set")
}
if _, ok := arts["keywords"]; ok {
t.Error("keywords should not be present when --keyword not set")
}
}
func TestDetail_Execute_NoArtifactFlags(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(detailMinuteGetStub("toknoart", "", "No Artifacts"))
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "toknoart", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
data, _ := resp["data"].(map[string]any)
minutes, _ := data["minutes"].([]any)
if len(minutes) != 1 {
t.Fatalf("expected 1 minute, got %d", len(minutes))
}
m, _ := minutes[0].(map[string]any)
if _, ok := m["artifacts"]; ok {
t.Error("artifacts should not be present when no artifact flags set")
}
}
func TestDetail_Execute_Transcript(t *testing.T) {
chdirForDetailTest(t)
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(detailMinuteGetStub("toktrans", "", "Transcript Meeting"))
reg.Register(detailArtifactsStub("toktrans", "speaker1: hello world\n"))
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "toktrans", "--transcript", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Check transcript file was saved
wantPath := "minutes/toktrans/transcript.txt"
data, err := os.ReadFile(wantPath)
if err != nil {
t.Fatalf("expected file at %s: %v", wantPath, err)
}
if string(data) != "speaker1: hello world\n" {
t.Errorf("content mismatch: %q", string(data))
}
}
func TestDetail_Execute_MinuteNotFound(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/minutes/v1/minutes/tokbad",
Body: map[string]interface{}{"code": 2091004, "msg": "not found"},
})
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokbad", "--as", "user"}, f, stdout)
if err == nil {
t.Fatal("expected partial failure error")
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
}
// ---------------------------------------------------------------------------
// Pure function tests
// ---------------------------------------------------------------------------
func TestValidMinuteTokenDetail(t *testing.T) {
tests := []struct {
token string
valid bool
}{
{"abc123", true},
{"obcnmgn1429t5xt9j82i1p3h", true},
{"INVALID!", false},
{"has-space", false},
{"", false},
}
for _, tt := range tests {
got := validMinuteTokenDetail.MatchString(tt.token)
if got != tt.valid {
t.Errorf("validMinuteTokenDetail(%q) = %v, want %v", tt.token, got, tt.valid)
}
}
}
// chdirForDetailTest switches cwd to a temp dir for the test.
func chdirForDetailTest(t *testing.T) string {
t.Helper()
orig, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
dir := t.TempDir()
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { os.Chdir(orig) })
return dir
}

View File

@@ -184,6 +184,12 @@ func minuteSearchAppLink(item map[string]interface{}) string {
return common.GetString(meta, "app_link")
}
// minuteSearchAvatar extracts the avatar URL from a search result item.
func minuteSearchAvatar(item map[string]interface{}) string {
meta := common.GetMap(item, "meta_data")
return common.GetString(meta, "avatar")
}
// buildMinuteSearchRows converts API items into pretty output rows.
func buildMinuteSearchRows(items []interface{}) []map[string]interface{} {
rows := make([]map[string]interface{}, 0, len(items))
@@ -197,27 +203,12 @@ func buildMinuteSearchRows(items []interface{}) []map[string]interface{} {
"display_info": common.TruncateStr(minuteSearchDisplayInfo(item), 40),
"description": common.TruncateStr(minuteSearchDescription(item), 40),
"app_link": common.TruncateStr(minuteSearchAppLink(item), 80),
"avatar": common.TruncateStr(minuteSearchAvatar(item), 80),
})
}
return rows
}
// stripAvatarFromItems removes meta_data.avatar from each search item in place
// so the structured output does not surface avatars to AI agents.
func stripAvatarFromItems(items []interface{}) {
for _, raw := range items {
item, _ := raw.(map[string]interface{})
if item == nil {
continue
}
meta, _ := item["meta_data"].(map[string]interface{})
if meta == nil {
continue
}
delete(meta, "avatar")
}
}
// MinutesSearch searches minutes by keyword, owners, participants, and time range.
var MinutesSearch = common.Shortcut{
Service: "minutes",
@@ -307,13 +298,13 @@ var MinutesSearch = common.Shortcut{
}
items := minuteSearchItems(data)
stripAvatarFromItems(items)
hasMore, _ := data["has_more"].(bool)
pageToken, _ := data["page_token"].(string)
rows := buildMinuteSearchRows(items)
outData := map[string]interface{}{
"items": items,
"total": data["total"],
"has_more": data["has_more"],
"page_token": data["page_token"],
}

View File

@@ -526,7 +526,7 @@ func TestMinutesSearchExecuteRendersRowsAndMoreHint(t *testing.T) {
}
out := stdout.String()
for _, want := range []string{"minute_1", "周会摘要", "周会纪要", "https://meetings.feishu.cn/minutes/obcn123", "next_token", "more available"} {
for _, want := range []string{"minute_1", "周会摘要", "周会纪要", "https://meetings.feishu.cn/minutes/obcn123", "https://p3-lark-file.byteimg.com/img/xxxx.jpg", "next_token", "more available"} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q, got: %s", want, out)
}
@@ -663,6 +663,7 @@ func TestMinuteSearchFieldExtractors(t *testing.T) {
"meta_data": map[string]interface{}{
"description": "周会纪要",
"app_link": "https://meetings.feishu.cn/minutes/obcn123",
"avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg",
},
}
@@ -678,6 +679,9 @@ func TestMinuteSearchFieldExtractors(t *testing.T) {
if got := minuteSearchAppLink(item); got != "https://meetings.feishu.cn/minutes/obcn123" {
t.Fatalf("minuteSearchAppLink() = %q", got)
}
if got := minuteSearchAvatar(item); got != "https://p3-lark-file.byteimg.com/img/xxxx.jpg" {
t.Fatalf("minuteSearchAvatar() = %q", got)
}
}
// TestMinuteSearchFieldExtractorsFallbacks verifies extractors keep working for alternate sample data.
@@ -690,6 +694,7 @@ func TestMinuteSearchFieldExtractorsFallbacks(t *testing.T) {
"meta_data": map[string]interface{}{
"description": "回退纪要",
"app_link": "https://meetings.feishu.cn/minutes/fallback",
"avatar": "https://p3-lark-file.byteimg.com/img/fallback.jpg",
},
}
@@ -702,6 +707,9 @@ func TestMinuteSearchFieldExtractorsFallbacks(t *testing.T) {
if got := minuteSearchAppLink(item); got != "https://meetings.feishu.cn/minutes/fallback" {
t.Fatalf("minuteSearchAppLink() = %q", got)
}
if got := minuteSearchAvatar(item); got != "https://p3-lark-file.byteimg.com/img/fallback.jpg" {
t.Fatalf("minuteSearchAvatar() = %q", got)
}
}
// TestMinuteSearchFieldExtractorsMissingMetaData verifies extractors fall back to empty values without metadata.
@@ -722,32 +730,7 @@ func TestMinuteSearchFieldExtractorsMissingMetaData(t *testing.T) {
if got := minuteSearchAppLink(item); got != "" {
t.Fatalf("minuteSearchAppLink() = %q, want empty", got)
}
}
// TestStripAvatarFromItems verifies the avatar field is removed from items in place.
func TestStripAvatarFromItems(t *testing.T) {
t.Parallel()
items := []interface{}{
map[string]interface{}{
"token": "minute_1",
"meta_data": map[string]interface{}{
"description": "周会纪要",
"avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg",
},
},
nil,
map[string]interface{}{"token": "minute_no_meta"},
}
stripAvatarFromItems(items)
first, _ := items[0].(map[string]interface{})
meta, _ := first["meta_data"].(map[string]interface{})
if _, ok := meta["avatar"]; ok {
t.Fatalf("avatar should be stripped, got meta = %v", meta)
}
if meta["description"] != "周会纪要" {
t.Fatalf("description should be preserved, got %v", meta["description"])
if got := minuteSearchAvatar(item); got != "" {
t.Fatalf("minuteSearchAvatar() = %q, want empty", got)
}
}

View File

@@ -16,6 +16,5 @@ func Shortcuts() []common.Shortcut {
MinutesTodo,
MinutesSpeakerReplace,
MinutesWordReplace,
MinutesDetail,
}
}

View File

@@ -78,7 +78,6 @@ func init() {
allShortcuts = append(allShortcuts, markdown.Shortcuts()...)
allShortcuts = append(allShortcuts, slides.Shortcuts()...)
allShortcuts = append(allShortcuts, minutes.Shortcuts()...)
allShortcuts = append(allShortcuts, note.Shortcuts()...)
allShortcuts = append(allShortcuts, task.Shortcuts()...)
allShortcuts = append(allShortcuts, vc.Shortcuts()...)
allShortcuts = append(allShortcuts, note.Shortcuts()...)

View File

@@ -11,7 +11,6 @@ func Shortcuts() []common.Shortcut {
VCSearch,
VCNotes,
VCRecording,
VCDetail,
VCMeetingJoin,
VCMeetingLeave,
VCMeetingEvents,

View File

@@ -1,216 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//
// vc +detail — get meeting details including note_id and minute_token
package vc
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const detailLogPrefix = "[vc +detail]"
var scopesDetailMeetingIDs = []string{
"vc:meeting.meetingevent:read",
"vc:record:readonly",
}
// meetingDetailItem represents a single meeting detail result.
type meetingDetailItem struct {
MeetingID string `json:"meeting_id"`
MeetingNo string `json:"meeting_no,omitempty"`
Topic string `json:"topic"`
StartTime string `json:"start_time,omitempty"`
EndTime string `json:"end_time,omitempty"`
NoteID string `json:"note_id,omitempty"`
MinuteToken string `json:"minute_token,omitempty"`
Error string `json:"error,omitempty"`
Hint string `json:"hint,omitempty"`
}
// fetchMeetingDetail queries meeting.get and recording API to return a
// consolidated view of meeting metadata, note_id, and minute_token.
// Error is only set when an API call actually fails; note_id and minute_token
// are always present (empty string when not available).
func fetchMeetingDetail(ctx context.Context, runtime *common.RuntimeContext, meetingID string) *meetingDetailItem {
result := &meetingDetailItem{MeetingID: meetingID}
// Step 1: query meeting detail
data, err := runtime.CallAPITyped(http.MethodGet,
fmt.Sprintf("/open-apis/vc/v1/meetings/%s", validate.EncodePathSegment(meetingID)),
map[string]interface{}{"with_participants": "false", "query_mode": "0"}, nil)
if err != nil {
result.Error = fmt.Sprintf("failed to query meeting detail: %v", err)
return result
}
meeting, _ := data["meeting"].(map[string]any)
if meeting == nil {
result.Error = "meeting not found in response"
return result
}
if v, ok := meeting["meeting_no"].(string); ok {
result.MeetingNo = v
}
if v, ok := meeting["topic"].(string); ok {
result.Topic = v
}
if v := common.FormatTime(meeting["start_time"]); v != "" {
result.StartTime = v
}
if v := common.FormatTime(meeting["end_time"]); v != "" {
result.EndTime = v
}
if v, ok := meeting["note_id"].(string); ok && v != "" {
result.NoteID = v
}
// Step 2: query minute_token via recording API
minuteToken, minuteHint, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
if minuteErr != nil {
// Recording API failed — surface the error but keep data from step 1
result.Error = fmt.Sprintf("failed to query minutes: %v", minuteErr)
minuteHint = ""
}
if minuteToken != "" {
result.MinuteToken = minuteToken
}
// Add hints for empty resources (not errors, just informational)
var emptyFields []string
if result.NoteID == "" {
emptyFields = append(emptyFields, "note_id")
}
if result.MinuteToken == "" && minuteErr == nil && minuteHint == "" {
emptyFields = append(emptyFields, "minute_token")
}
if len(emptyFields) > 0 {
result.Hint = fmt.Sprintf("%s not found for this meeting", strings.Join(emptyFields, ", "))
}
if minuteHint != "" {
if result.Hint != "" {
result.Hint += "; " + minuteHint
} else {
result.Hint = minuteHint
}
}
return result
}
// VCDetail gets meeting details including note_id and minute_token.
var VCDetail = common.Shortcut{
Service: "vc",
Command: "+detail",
Description: "Get meeting details including note_id and minute_token by meeting IDs",
Risk: "read",
Scopes: []string{"vc:meeting.meetingevent:read", "vc:record:readonly"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "meeting-ids", Desc: "meeting IDs, comma-separated for batch", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
ids := common.SplitCSV(runtime.Str("meeting-ids"))
const maxBatchSize = 50
if len(ids) > maxBatchSize {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--meeting-ids: too many IDs (%d), maximum is %d", len(ids), maxBatchSize).WithParam("--meeting-ids")
}
// dynamic scope check
result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID))
if err == nil && result != nil && result.Scopes != "" {
if missing := auth.MissingScopes(result.Scopes, scopesDetailMeetingIDs); len(missing) > 0 {
return errs.NewPermissionError(errs.SubtypeMissingScope,
"missing required scope(s): %s", strings.Join(missing, ", ")).
WithHint("run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")).
WithMissingScopes(missing...).
WithIdentity(string(runtime.As()))
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ids := runtime.Str("meeting-ids")
return common.NewDryRunAPI().
GET("/open-apis/vc/v1/meetings/{meeting_id}").
GET("/open-apis/vc/v1/meetings/{meeting_id}/recording").
Set("meeting_ids", common.SplitCSV(ids)).
Set("steps", "meeting.get → note_id + recording API → minute_token")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
errOut := runtime.IO().ErrOut
meetingIDs := common.SplitCSV(runtime.Str("meeting-ids"))
results := make([]*meetingDetailItem, 0, len(meetingIDs))
const batchDelay = 100 * time.Millisecond
fmt.Fprintf(errOut, "%s querying %d meeting_id(s)\n", detailLogPrefix, len(meetingIDs))
for i, id := range meetingIDs {
if err := ctx.Err(); err != nil {
return err
}
if i > 0 {
time.Sleep(batchDelay)
}
fmt.Fprintf(errOut, "%s querying meeting_id=%s ...\n", detailLogPrefix, sanitizeLogValue(id))
results = append(results, fetchMeetingDetail(ctx, runtime, id))
}
successCount := 0
for _, r := range results {
if r.Error == "" {
successCount++
}
}
fmt.Fprintf(errOut, "%s done: %d total, %d succeeded, %d failed\n", detailLogPrefix, len(results), successCount, len(results)-successCount)
if successCount == 0 && len(results) > 0 {
return runtime.OutPartialFailure(map[string]any{"meetings": results}, &output.Meta{Count: len(results)})
}
outData := map[string]any{"meetings": results}
runtime.OutFormat(outData, &output.Meta{Count: len(results)}, func(w io.Writer) {
if len(results) == 0 {
fmt.Fprintln(w, "No meetings.")
return
}
var rows []map[string]interface{}
for _, r := range results {
row := map[string]interface{}{"meeting_id": r.MeetingID}
if r.Error != "" {
row["status"] = "FAIL"
row["error"] = r.Error
} else {
row["status"] = "OK"
}
if r.NoteID != "" {
row["note_id"] = r.NoteID
}
if r.MinuteToken != "" {
row["minute_token"] = r.MinuteToken
}
row["topic"] = r.Topic
if r.Hint != "" {
row["hint"] = r.Hint
}
rows = append(rows, row)
}
output.PrintTable(w, rows)
fmt.Fprintf(w, "\n%d meeting(s), %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
})
return nil
},
}

View File

@@ -1,282 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// ---------------------------------------------------------------------------
// Validation tests
// ---------------------------------------------------------------------------
func TestDetail_Validation_MissingMeetingIDs(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCDetail, []string{"+detail", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected validation error for missing --meeting-ids")
}
if !strings.Contains(err.Error(), "meeting-ids") {
t.Errorf("unexpected error message: %v", err)
}
}
func TestDetail_Validation_BatchLimit(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
ids := make([]string, 51)
for i := range ids {
ids[i] = fmt.Sprintf("m%d", i)
}
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", strings.Join(ids, ","), "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected batch limit error")
}
if !strings.Contains(err.Error(), "too many IDs") {
t.Errorf("expected 'too many IDs' error, got: %v", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
}
}
// ---------------------------------------------------------------------------
// DryRun tests
// ---------------------------------------------------------------------------
func TestDetail_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m001", "--dry-run", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "/open-apis/vc/v1/meetings/") {
t.Errorf("dry-run should show meeting API path, got: %s", stdout.String())
}
if !strings.Contains(stdout.String(), "recording") {
t.Errorf("dry-run should show recording API path, got: %s", stdout.String())
}
}
// ---------------------------------------------------------------------------
// Execute tests with mocked HTTP
// ---------------------------------------------------------------------------
func TestDetail_Execute_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingGetStub("m_detail1", "note_001"))
reg.Register(recordingOKStub("m_detail1", "https://meetings.feishu.cn/minutes/obc_detail1"))
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m_detail1", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
data, _ := resp["data"].(map[string]any)
meetings, _ := data["meetings"].([]any)
if len(meetings) != 1 {
t.Fatalf("expected 1 meeting, got %d", len(meetings))
}
m, _ := meetings[0].(map[string]any)
if m["meeting_id"] != "m_detail1" {
t.Errorf("meeting_id = %v, want m_detail1", m["meeting_id"])
}
if m["note_id"] != "note_001" {
t.Errorf("note_id = %v, want note_001", m["note_id"])
}
if m["minute_token"] != "obc_detail1" {
t.Errorf("minute_token = %v, want obc_detail1", m["minute_token"])
}
}
func TestDetail_Execute_NoNoteNoMinute(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingGetStub("m_nonote", ""))
reg.Register(recordingErrStub("m_nonote", 121004, "not found"))
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m_nonote", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify hint is present for empty note_id and missing recording
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
data, _ := resp["data"].(map[string]any)
meetings, _ := data["meetings"].([]any)
m, _ := meetings[0].(map[string]any)
if hint, _ := m["hint"].(string); !strings.Contains(hint, "note_id") || !strings.Contains(hint, "no minute file for this meeting") {
t.Errorf("hint should mention note_id and minute file missing, got: %v", hint)
}
if errMsg, _ := m["error"].(string); errMsg != "" {
t.Errorf("error should be empty, got: %v", errMsg)
}
}
func TestDetail_Execute_MeetingNotFound(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/meetings/m_bad",
Body: map[string]interface{}{"code": 121004, "msg": "data not found"},
})
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m_bad", "--as", "user"}, f, stdout)
if err == nil {
t.Fatal("expected partial failure error")
}
}
func TestDetail_Execute_Batch(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
// m1 succeeds with note and minute
reg.Register(meetingGetStub("m_batch1", "note_b1"))
reg.Register(recordingOKStub("m_batch1", "https://meetings.feishu.cn/minutes/obc_b1"))
// m2 has no note_id but has minute
reg.Register(meetingGetStub("m_batch2", ""))
reg.Register(recordingOKStub("m_batch2", "https://meetings.feishu.cn/minutes/obc_b2"))
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m_batch1,m_batch2", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var resp map[string]any
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
data, _ := resp["data"].(map[string]any)
meetings, _ := data["meetings"].([]any)
if len(meetings) != 2 {
t.Fatalf("expected 2 meetings, got %d", len(meetings))
}
}
// ---------------------------------------------------------------------------
// Pure function tests
// ---------------------------------------------------------------------------
func TestFetchMeetingDetail_MeetingWithNoteAndMinute(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingGetStub("m_fn", "note_fn"))
reg.Register(recordingOKStub("m_fn", "https://meetings.feishu.cn/minutes/obc_fn"))
if err := botExec(t, "detail-fn", f, func(_ context.Context, rctx *common.RuntimeContext) error {
result := fetchMeetingDetail(context.Background(), rctx, "m_fn")
if result.MeetingID != "m_fn" {
t.Errorf("meeting_id = %v, want m_fn", result.MeetingID)
}
if result.NoteID != "note_fn" {
t.Errorf("note_id = %v, want note_fn", result.NoteID)
}
if result.MinuteToken != "obc_fn" {
t.Errorf("minute_token = %v, want obc_fn", result.MinuteToken)
}
if result.Error != "" {
t.Errorf("unexpected error: %v", result.Error)
}
return nil
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestFetchMeetingDetail_MeetingNotFound(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/meetings/m_nf",
Body: map[string]interface{}{"code": 121004, "msg": "data not found"},
})
if err := botExec(t, "detail-nf", f, func(_ context.Context, rctx *common.RuntimeContext) error {
result := fetchMeetingDetail(context.Background(), rctx, "m_nf")
if result.Error == "" {
t.Error("expected error for meeting not found")
}
// note_id and minute_token should still be present (empty)
if result.NoteID != "" {
t.Errorf("note_id = %q, want empty", result.NoteID)
}
if result.MinuteToken != "" {
t.Errorf("minute_token = %q, want empty", result.MinuteToken)
}
return nil
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestFetchMeetingDetail_RecordingFailsButNoteOK(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingGetStub("m_partial", "note_partial"))
reg.Register(recordingErrStub("m_partial", 121004, "not found"))
if err := botExec(t, "detail-partial", f, func(_ context.Context, rctx *common.RuntimeContext) error {
result := fetchMeetingDetail(context.Background(), rctx, "m_partial")
if result.NoteID != "note_partial" {
t.Errorf("note_id = %v, want note_partial", result.NoteID)
}
if result.MinuteToken != "" {
t.Errorf("minute_token = %q, want empty", result.MinuteToken)
}
if result.Error != "" {
t.Errorf("error = %q, want empty", result.Error)
}
if !strings.Contains(result.Hint, "no minute file for this meeting") {
t.Errorf("hint = %q, want contains 'no minute file for this meeting'", result.Hint)
}
return nil
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestFetchMeetingDetail_RecordingAPIErrorButNoteOK(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingGetStub("m_api_err", "note_apierr"))
reg.Register(recordingErrStub("m_api_err", 99999, "weird API error"))
if err := botExec(t, "detail-apierr", f, func(_ context.Context, rctx *common.RuntimeContext) error {
result := fetchMeetingDetail(context.Background(), rctx, "m_api_err")
if result.NoteID != "note_apierr" {
t.Errorf("note_id = %v, want note_apierr", result.NoteID)
}
if result.MinuteToken != "" {
t.Errorf("minute_token = %q, want empty", result.MinuteToken)
}
if !strings.Contains(result.Error, "failed to query minutes") || !strings.Contains(result.Error, "weird API error") {
t.Errorf("error = %q, want contains 'failed to query minutes' and 'weird API error'", result.Error)
}
if strings.Contains(result.Hint, "minute_token") {
t.Errorf("hint = %q, should not mention minute_token when there is an error", result.Hint)
}
return nil
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}

View File

@@ -818,7 +818,7 @@ func TestVCShortcuts_RegistersMeetingAgentCommands(t *testing.T) {
for _, shortcut := range got {
commands = append(commands, shortcut.Command)
}
want := []string{"+search", "+notes", "+recording", "+detail", "+meeting-join", "+meeting-leave", "+meeting-events"}
want := []string{"+search", "+notes", "+recording", "+meeting-join", "+meeting-leave", "+meeting-events"}
if !reflect.DeepEqual(commands, want) {
t.Fatalf("shortcut commands = %#v, want %#v", commands, want)
}

View File

@@ -263,35 +263,42 @@ func asStringSlice(v any) []string {
}
// fetchMeetingMinuteToken queries the recording API of a meeting and returns
// the associated minute_token (parsed from the recording URL), an optional
// hint for expected missing states, and an error for unexpected failures.
func fetchMeetingMinuteToken(runtime *common.RuntimeContext, meetingID string) (token, hint string, err error) {
data, apiErr := runtime.CallAPITyped(http.MethodGet,
// the associated minute_token (parsed from the recording URL) and an
// optional human-friendly error message. On success token is non-empty and
// errMsg is empty; on failure token is empty and errMsg describes the cause:
// - 121004: meeting has no minute file
// - 121005: caller has no permission for the meeting recording
// - 124002: recording / minute file is still being generated
//
// Other failures fall back to the raw API error description so Agents can
// still parse the underlying cause.
func fetchMeetingMinuteToken(runtime *common.RuntimeContext, meetingID string) (token, errMsg string) {
data, err := runtime.CallAPITyped(http.MethodGet,
fmt.Sprintf("/open-apis/vc/v1/meetings/%s/recording", validate.EncodePathSegment(meetingID)),
nil, nil)
if apiErr != nil {
if p, ok := errs.ProblemOf(apiErr); ok {
if err != nil {
if p, ok := errs.ProblemOf(err); ok {
switch p.Code {
case recordingNotFoundCode:
return "", "no minute file for this meeting", nil
return "", "no minute file for this meeting"
case recordingNoPermissionCode:
return "", "no permission to access this meeting's minute; ask the meeting owner to share the minute", nil
return "", "no permission to access this meeting's minute; ask the meeting owner to share the minute"
case recordingGeneratingCode:
return "", "minute file is still being generated; please retry later", nil
return "", "minute file is still being generated; please retry later"
}
}
return "", "", apiErr
return "", fmt.Sprintf("failed to query recording: %v", err)
}
recording, _ := data["recording"].(map[string]any)
if recording == nil {
return "", "no recording available for this meeting", nil
return "", "no recording available for this meeting"
}
recordingURL, _ := recording["url"].(string)
if t := extractMinuteToken(recordingURL); t != "" {
return t, "", nil
return t, ""
}
return "", "no minute_token found in recording URL", nil
return "", "no minute_token found in recording URL"
}
// fetchNoteByMeetingID queries notes via meeting_id and additionally fetches
@@ -314,7 +321,7 @@ func fetchNoteByMeetingID(ctx context.Context, runtime *common.RuntimeContext, m
// Always attempt to query the meeting's minute_token via the recording API,
// regardless of whether the meeting has a note_id, so callers always see
// minute state for follow-up calls (e.g. `vc +notes --minute-tokens=...`).
minuteToken, minuteHint, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
minuteToken, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
var result map[string]any
var noteErr string
@@ -333,13 +340,7 @@ func fetchNoteByMeetingID(ctx context.Context, runtime *common.RuntimeContext, m
if minuteToken != "" {
result["minute_token"] = minuteToken
}
var minuteErrMsg string
if minuteHint != "" {
minuteErrMsg = minuteHint
} else if minuteErr != nil {
minuteErrMsg = minuteErr.Error()
}
if combined := joinErrors(noteErr, minuteErrMsg); combined != "" {
if combined := joinErrors(noteErr, minuteErr); combined != "" {
result["error"] = combined
}
return result
@@ -537,7 +538,6 @@ var VCNotes = common.Shortcut{
Risk: "read",
Scopes: []string{"vc:note:read"}, // minimum scope; additional per-flag scopes checked in Validate
AuthTypes: []string{"user"},
Hidden: true, // hidden from --help; prefer vc +detail, minutes +detail, or note +detail
HasFormat: true,
Flags: []common.Flag{
{Name: "meeting-ids", Desc: "meeting IDs, comma-separated for batch"},

View File

@@ -792,15 +792,12 @@ func TestFetchMeetingMinuteToken_Success(t *testing.T) {
reg.Register(recordingOKStub("m_ok", "https://meetings.feishu.cn/minutes/obctoken_ok"))
if err := botExec(t, "fmmt-ok", f, func(_ context.Context, rctx *common.RuntimeContext) error {
token, hint, err := fetchMeetingMinuteToken(rctx, "m_ok")
token, msg := fetchMeetingMinuteToken(rctx, "m_ok")
if token != "obctoken_ok" {
t.Errorf("token = %q, want obctoken_ok", token)
}
if hint != "" {
t.Errorf("hint = %q, want empty", hint)
}
if err != nil {
t.Errorf("err = %v, want nil", err)
if msg != "" {
t.Errorf("errMsg = %q, want empty", msg)
}
return nil
}); err != nil {
@@ -826,15 +823,12 @@ func TestFetchMeetingMinuteToken_KnownErrorCodes(t *testing.T) {
reg.Register(recordingErrStub(tt.meetingID, tt.code, "err"))
if err := botExec(t, "fmmt-"+tt.meetingID, f, func(_ context.Context, rctx *common.RuntimeContext) error {
token, hint, err := fetchMeetingMinuteToken(rctx, tt.meetingID)
token, msg := fetchMeetingMinuteToken(rctx, tt.meetingID)
if token != "" {
t.Errorf("token = %q, want empty on error", token)
}
if !strings.Contains(hint, tt.wantMsg) {
t.Errorf("hint = %q, want contains %q", hint, tt.wantMsg)
}
if err != nil {
t.Errorf("err = %v, want nil", err)
if !strings.Contains(msg, tt.wantMsg) {
t.Errorf("errMsg = %q, want contains %q", msg, tt.wantMsg)
}
return nil
}); err != nil {
@@ -850,15 +844,12 @@ func TestFetchMeetingMinuteToken_GenericAPIError(t *testing.T) {
reg.Register(recordingErrStub("m_other", 99999, "weird"))
if err := botExec(t, "fmmt-generic", f, func(_ context.Context, rctx *common.RuntimeContext) error {
token, hint, err := fetchMeetingMinuteToken(rctx, "m_other")
token, msg := fetchMeetingMinuteToken(rctx, "m_other")
if token != "" {
t.Errorf("token = %q, want empty", token)
}
if hint != "" {
t.Errorf("hint = %q, want empty", hint)
}
if err == nil || !strings.Contains(err.Error(), "weird") {
t.Errorf("err = %v, want contains 'weird'", err)
if !strings.Contains(msg, "failed to query recording") {
t.Errorf("errMsg = %q, want contains 'failed to query recording'", msg)
}
return nil
}); err != nil {
@@ -875,15 +866,12 @@ func TestFetchMeetingMinuteToken_NoRecording(t *testing.T) {
}))
if err := botExec(t, "fmmt-norec", f, func(_ context.Context, rctx *common.RuntimeContext) error {
token, hint, err := fetchMeetingMinuteToken(rctx, "m_norec")
token, msg := fetchMeetingMinuteToken(rctx, "m_norec")
if token != "" {
t.Errorf("token = %q, want empty", token)
}
if err != nil {
t.Errorf("err = %v, want nil", err)
}
if !strings.Contains(hint, "no recording available") {
t.Errorf("hint = %q, want contains 'no recording available'", hint)
if !strings.Contains(msg, "no recording available") {
t.Errorf("errMsg = %q, want contains 'no recording available'", msg)
}
return nil
}); err != nil {
@@ -897,15 +885,12 @@ func TestFetchMeetingMinuteToken_URLWithoutToken(t *testing.T) {
reg.Register(recordingOKStub("m_notok", "https://example.com/no/minute/path"))
if err := botExec(t, "fmmt-notok", f, func(_ context.Context, rctx *common.RuntimeContext) error {
token, hint, err := fetchMeetingMinuteToken(rctx, "m_notok")
token, msg := fetchMeetingMinuteToken(rctx, "m_notok")
if token != "" {
t.Errorf("token = %q, want empty", token)
}
if err != nil {
t.Errorf("err = %v, want nil", err)
}
if !strings.Contains(hint, "no minute_token found") {
t.Errorf("hint = %q, want contains 'no minute_token found'", hint)
if !strings.Contains(msg, "no minute_token found") {
t.Errorf("errMsg = %q, want contains 'no minute_token found'", msg)
}
return nil
}); err != nil {
@@ -998,7 +983,7 @@ func TestNotes_MeetingPath_OnlyMinuteFails_PartialSuccess(t *testing.T) {
t.Errorf("note_doc_token = %v, want doc_main", got)
}
assertNoteFieldAbsent(t, note, "minute_token")
assertNoteError(t, note, "no permission to access this meeting's minute; ask the meeting owner to share the minute")
assertNoteError(t, note, "no permission to access this meeting's minute")
}
func TestNotes_MeetingPath_NoNote_ButMinuteOK(t *testing.T) {
@@ -1083,7 +1068,6 @@ func TestNotes_MeetingPath_NoteNoPermission_FriendlyHint(t *testing.T) {
assertNoteError(t, note,
"[121005]",
"no read permission for this meeting note",
"no permission to access this meeting's minute",
"; ", // note + minute causes joined with semicolon
)
}

View File

@@ -230,16 +230,9 @@ var VCSearch = common.Shortcut{
data = map[string]interface{}{}
}
items := common.GetSlice(data, "items")
// Strip avatar from meta_data — not useful for AI agents.
for _, raw := range items {
if m, ok := raw.(map[string]interface{}); ok {
if meta, ok := m["meta_data"].(map[string]interface{}); ok {
delete(meta, "avatar")
}
}
}
outData := map[string]interface{}{
"items": items,
"total": data["total"],
"has_more": data["has_more"],
"page_token": data["page_token"],
}

View File

@@ -1,71 +1,46 @@
## Core Concepts
- **Message**: A single message in a chat, identified by `message_id` (om_xxx). Supports types: text, post, image, file, audio, video, sticker, interactive (card), share_chat, share_user, merge_forward, etc.
- **Chat**: A group chat or P2P conversation, identified by `chat_id` (oc_xxx).
- **Thread**: A reply thread under a message, identified by `thread_id` (om_xxx or omt_xxx).
- **Reaction**: An emoji reaction on a message.
- **Flag**: A bookmark on a message or thread.
- **Feed Shortcut**: A chat pinned to the current user's feed sidebar, identified by `feed_card_id` (an `oc_xxx` open_chat_id for CHAT type).
- **Feed Group**: A tag that groups feed cards in the feed list, identified by `feed_group_id` (ofg_xxx). Members are feed cards, each identified by `feed_id` + `feed_type`. Two types: `normal` (members managed explicitly) and `rule` (members auto-derived from rules).
## Resource Relationships
```
Chat (oc_xxx)
├── Message (om_xxx)
│ ├── Thread (reply thread)
│ ├── Reaction (emoji)
│ └── Resource (image / file / video / audio)
└── Member (user / bot)
```
- **Message** `message_id` (om_xxx) · **Chat** `chat_id` (oc_xxx, group or P2P) · **Thread** `thread_id` (om_xxx / omt_xxx).
- **Flag** — bookmark on a message/thread (two layers, see below).
- **Feed Shortcut** `feed_card_id` (oc_xxx) — a chat pinned to the user's feed sidebar.
- **Feed Group** `feed_group_id` (ofg_xxx) — a tag grouping feed cards (`feed_id`+`feed_type`); `normal` (explicit) / `rule` (auto-derived).
## Important Notes
### Identity and Token Mapping
### Identity (user vs bot)
- `--as user` means **user identity** and uses `user_access_token`. Calls run as the authorized end user, so permissions depend on both the app scopes and that user's own access to the target chat/message/resource.
- `--as bot` means **bot identity** and uses `tenant_access_token`. Calls run as the app bot, so behavior depends on the bot's membership, app visibility, availability range, and bot-specific scopes.
- If an IM API says it supports both `user` and `bot`, the token type changes who the operator is. The same API can succeed with one identity and fail with the other because owner/admin status, chat membership, tenant boundary, or app availability are checked against the current caller.
- `--as user` (`user_access_token`): runs as the authorized user; permission = app scopes + that user's own access to the target.
- `--as bot` (`tenant_access_token`): runs as the app bot; depends on bot's chat membership, app visibility range, bot scopes.
- When an API supports both, the token decides *who* operates — owner/admin, membership, tenant, visibility are checked against the caller, so the same API can pass on one identity and fail on the other.
### Sender Name Resolution with Bot Identity
### Sender name resolution
When using bot identity (`--as bot`) to fetch messages (e.g. `+chat-messages-list`, `+threads-messages-list`, `+messages-mget`), sender names may not be resolved (shown as open_id instead of display name). This happens when the bot cannot access the user's contact info.
As **bot**, the sender may show as `open_id` (bot visibility range doesn't cover it); `--as user` gives real names.
**Root cause**: The bot's app visibility settings do not include the message sender, so the contact API returns no name.
```bash
lark-cli im +chat-messages-list --chat-id oc_xxx --as bot # BAD: sender = open_id
lark-cli im +chat-messages-list --chat-id oc_xxx --as user # GOOD: sender = real name
```
**Solution**: Check the app's visibility settings in the Lark Developer Console — ensure the app's visible range covers the users whose names need to be resolved. Alternatively, use `--as user` to fetch messages with user identity, which typically has broader contact access.
### Default message enrichment
### Default message enrichment (reactions / update_time)
The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+messages-search`, `+threads-messages-list`) automatically attach a `reactions` block and (for edited messages) `update_time` to each returned message — no separate `im.reactions.batch_query` call is needed. Pass `--no-reactions` to opt out. For the full contract (output shape, the `im:message.reactions:read` scope requirement, and the "missing field ≠ fetch failure" data rules), read [`references/lark-im-message-enrichment.md`](references/lark-im-message-enrichment.md).
### Opt-in resource auto-download (`--download-resources`)
`+chat-messages-list`, `+messages-mget`, and `+threads-messages-list` accept `--download-resources` (**off by default** — no `resources` block and no extra requests when omitted). When set, eligible message resources (image/file/audio/video/media + post-embedded; **stickers excluded**) are downloaded into `./lark-im-resources/` and each message gains a `resources` array of `{message_id, key, type, local_path, size_bytes}`. Downloads are deduped by `(message_id, file_key)`, run with bounded concurrency, and isolate single-resource failures (`error: true` + stderr warning). **Scope:** requires `im:message:readonly` (already declared by the listing commands — no extra scope); works under both user and bot identity. For one-off downloads use [`+messages-resources-download`](references/lark-im-messages-resources-download.md). Full contract: [`references/lark-im-message-enrichment.md`](references/lark-im-message-enrichment.md).
### Card Messages (Interactive)
Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr.
The four message-pulling shortcuts auto-attach `reactions` (+ `update_time` for edited messages) — no separate `reactions.batch_query` (needs `im:message.reactions:read`); `--no-reactions` opts out. `+chat-messages-list` / `+messages-mget` / `+threads-messages-list` also accept `--download-resources` (off by default) to download image/file/audio/video/media (stickers excluded) into `./lark-im-resources/`, adding a `resources` array per message; one-off via [`+messages-resources-download`](references/lark-im-messages-resources-download.md). Contract: [`references/lark-im-message-enrichment.md`](references/lark-im-message-enrichment.md).
### Flag Types
Flags support two layers:
- **Message-layer flag**: `(ItemTypeDefault, FlagTypeMessage)` — regular message bookmark
- **Feed-layer flag**: `(ItemTypeThread/ItemTypeMsgThread, FlagTypeFeed)` — thread as feed-layer bookmark
Item types for feed-layer flags:
- **ItemTypeThread** (4) = thread in a topic-style chat
- **ItemTypeMsgThread** (11) = thread in a regular chat
Two layers (item_type auto-detected from chat mode — rarely set by hand):
- **Message-layer** `(ItemTypeDefault, FlagTypeMessage)` — regular message bookmark.
- **Feed-layer** `(ItemType{Thread|MsgThread}, FlagTypeFeed)` — thread bookmarked at feed level:
- **ItemTypeThread** (4) = a topic in a topic-style chat (an entry in the group's Thread tab).
- **ItemTypeMsgThread** (11) = a reply thread under a single message in a regular group.
### Feed Shortcut
Feed shortcuts add chats to the **current user's** feed sidebar. They are distinct from flags:
Pins a chat to the **current user's** feed sidebar. Limits: **CHAT-type only** (oc_xxx); **user-identity only**; **10 per call** for create/remove; list uses opaque `page_token`.
- **Flag** = bookmark on a message/thread, scoped to the user's bookmark list.
- **Feed shortcut** = entry in the user's feed sidebar (currently only chats).
## 不在本 skill 范围
Key limits:
- Only **CHAT-type** (`feed_card_id` is `oc_xxx`) is exposed via OpenAPI; doc/app/subscription shortcuts exist internally but are not yet whitelisted.
- All three operations (create/remove/list) are **user-identity only** — they sign with `user_access_token`.
- Batch size is **10 per call** for create/remove; list is a one-page wrapper with opaque `page_token` pagination.
- 邮件 → [`lark-mail`](../lark-mail/SKILL.md)|日程/会议 → [`lark-calendar`](../lark-calendar/SKILL.md)|会议回放/纪要 → [`lark-vc`](../lark-vc/SKILL.md)
- 文档评论 → [`lark-drive`](../lark-drive/SKILL.md)IM 事件订阅 → [`lark-event`](../lark-event/SKILL.md)|姓名解析 open_id → [`lark-contact`](../lark-contact/SKILL.md)
群禁言 / 管理员 / 角色 / 解散 / 转让 / 群设置 等群治理 lark-cli im 暂无命令:如实告知“暂不支持”、勿臆造,引导用户到飞书客户端群设置手动操作(高风险写操作,勿擅自走原生 API 代执行)。

View File

@@ -30,12 +30,8 @@ lark-cli schema {{service}}.<resource>.<method> # 调用 API 前必须先查
lark-cli {{service}} <resource> <method> [flags] # 调用 API
```
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
{{schema_note_block}}
{{resource_sections}}
## 权限表
| 方法 | 所需 scope |
|------|-----------|
{{permission_rows}}
{{permission_block}}
{{/actions}}

View File

@@ -35,16 +35,44 @@ metadata:
- Base 命令必须先有 `base_token` 或可解析出的 Base URL。没有 token 时:用户要新建就用 `+base-create`;用户给标题/关键词就搜 `lark-cli drive +search --query "<base title>" --doc-types bitable --only-title --as user`;仍无法定位时,反问用户具体是哪一个 Base。
- 认证、初始化、scope、身份切换、权限不足恢复属于 `lark-shared`Base 文档只保留会影响 Base 路径选择的权限规则。
## 名词与概念
| 名词 | 含义 |
|---|---|
| Base / 多维表格 / Bitable | 同一个东西:`/base/{token}` 链接对应的整个文档容器token 即 `--base-token`Bitable 是曾用名,只出现在历史 API 和返回字段里 |
| Table数据表 | Base 内的一张数据表ID `tbl` 开头;列是 field行是 record |
| Field字段/ Record记录 | 表的列与行;字段 ID `fld` 开头,记录 ID `rec` 开头 |
| View视图 | 同一张 table 的一种展示配置(筛选/排序/分组等ID `viw` 开头 |
| Form表单 | 收集数据的入口,提交结果写入对应 table 的记录 |
| Workflow工作流 | Base 内的自动化流程ID `wkf` 开头,由 stepstrigger + action组成 |
| Dashboard仪表盘 | 数据可视化容器ID `blk` 开头(因为它本身是 Base 资源目录里的一个 block见下方歧义说明) |
| Chart图表/组件) | 又叫Dashboard block, 是 dashboard 内的单个可视化组件(柱状图/饼图/指标卡等), ID `cht` 开头 |
| Base block `+base-block-*`| Base 资源目录里的节点table/docx/dashboard/workflow/folder 在目录层面统称 block。 “这个 Base 里有哪些东西” → `+base-block-list`|
**`block` 是易混淆词同名不同义按命令域区分base-block 和 dashboard-block**
### Base 心智模型
- `base-block` 只负责资源目录管理,包括创建资源、移动到 folder、重命名和删除具体资源内容仍走 table/dashboard/workflow 命令。
- 新建 Base 时,强烈推荐一次性执行 `lark-cli base +base-create --name "<base>" --table-name "<table>" --fields '<field-json-array>'`,同时配置新 Base 里唯一一个初始数据表的 name 和 schema使用 `--fields` 前先读 [lark-base-field-json.md](references/lark-base-field-json.md) 或复用 `+field-create` 的字段 JSON 形状,不要猜字段属性。
- `+base-create` 不传 `--table-name``--fields` 时,会创建一个默认 schema 的初始数据表。
- 表、字段、视图、workflow、dashboard block 的名称和 ID 必须来自真实返回,不要凭用户口述猜。
- 存储字段可写;系统字段、`formula``lookup` 只读;附件字段走专用 attachment 命令。
- 一次性原始记录查询优先用 `+record-list` / `+record-search` 的 filter/sort聚合分析优先用 `+data-query`;需要长期显示在表中时,才新增 `formula` / `lookup` 字段。
- `formula` 适合常规计算、条件判断、文本/日期处理和长期派生指标;`lookup` 适合明确的跨表查找、筛选后取值或聚合引用。
- 写入、分析、公式、lookup、workflow、dashboard 前,先读取真实结构:表、字段、视图、关联表和 dashboard block 名称都以命令返回为准。
- 跨表场景必须读取目标表结构link 单元格中的关联 `record_id` 只是连接键,最终回答要回查并展示用户可读字段。
## 快速路由
| 用户目标 | 优先命令 | 何时读 reference |
|---|---|---|
| 查 Base 本体 | `+base-get` | 用返回确认 Base 名称、owner、权限和可继续操作的 token |
| 创建/复制 Base | `+base-create` / `+base-copy` | 新建时强烈推荐用 `--table-name` + `--fields` 同时配置新 Base 里唯一一个初始数据表的 name 和 schema写入后报告新 Base 标识和 `permission_grant` |
| 查看 Base 内资源目录 | `+base-block-list` | 想先了解一个 Base 里有哪些 table/docx/dashboard/workflow/folder 时优先用它;返回 ID 关系和 fewshot 看 `--help` |
| 查看 Base 内资源目录 | `+base-block-list` | 先判断 Base 里有什么(table/docx/dashboard/workflow/folder),再决定走哪类命令 |
| 管理 Base 内资源目录 | `+base-block-create/move/rename/delete` | 创建或整理 Base 直接管理的 folder/table/docx/dashboard/workflow资源内容继续用对应命令 |
| 管理数据表 | `+table-list/get/create/update/delete` | 处理 table 的列出、详情、创建、重命名和删除 |
| 列/查/删字段 | `+field-list/get/delete/search-options` | 写入前用 list/get 确认字段类型、选项、ID删除前确认目标字段 |
| 列/查/删字段 | `+field-list/get/delete/search-options` | 字段发现默认用 `+field-list --compact`;需要 formula/lookup 细节或完整字段 JSON 再用 `+field-get` / 不带 compact 的 list多表结构用 `+field-list-batch --compact --table-id <表1> --table-id <表2>` 一次取齐,不要逐表调用 |
| 创建/更新字段 | `+field-create` / `+field-update` | 必读 [lark-base-field-json.md](references/lark-base-field-json.md);公式读 [formula-field-guide.md](references/formula-field-guide.md)lookup 读 [lookup-field-guide.md](references/lookup-field-guide.md);命令细节读 [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md) |
| 读记录明细 | `+record-get` / `+record-list` / `+record-search` | 涉及筛选、排序、Top/Bottom N、聚合、多表关联、全局结论时读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) |
| 写记录 | `+record-upsert` / `+record-batch-create` / `+record-batch-update` | 必读 [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) 和 [lark-base-cell-value.md](references/lark-base-cell-value.md) |
@@ -58,24 +86,34 @@ metadata:
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md) |
| 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail` 读 [lark-base-form-detail.md](references/lark-base-form-detail.md);删除前确认目标表单 |
| 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 [lark-base-dashboard.md](references/lark-base-dashboard.md);组件 `data_config` 读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md);读取图表计算结果用 `+dashboard-block-get-data` |
| Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md)list/get/enable/disable 只处理 workflow ID 与启停状态 |
| 高级权限与角色 | `+advperm-*` / `+role-*` | 角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);角色 create/update 或解读完整配置再读权限 JSON SSOT [role-config.md](references/role-config.md);系统角色不可删除;关闭高级权限会影响自定义角色 |
| Workflow | `+workflow-*` | 读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md):它包含查询/启停/创建/修改的最短路径和常见 step 组合;只有创建/更新复杂 steps 时才继续读 schema 小文件list/get/enable/disable 不读 schema |
| 高级权限与角色 | `+advperm-*` / `+role-*` | 先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md)(含安全边界);权限 JSON 再读 [role-config.md](references/role-config.md) |
## Base 心智模型
## 注意事项
- Base 曾用名 Bitable返回字段、错误或旧文档里的 `bitable` 多为历史兼容,不代表应改走裸 API 或另一套命令。
- `+base-block-list` 是查看一个 Base 内资源目录的新入口:它列出这个 Base 直接管理的 `folder/table/docx/dashboard/workflow`,适合先判断 Base 里有什么,再决定走 table、dashboard、workflow 或 docx 命令。
- `base-block` 只负责资源目录管理,包括创建资源、移动到 folder、重命名和删除具体资源内容仍走 table/dashboard/workflow 命令。
- 新建 Base 时,强烈推荐一次性执行 `lark-cli base +base-create --name "<base>" --table-name "<table>" --fields '<field-json-array>'`,同时配置新 Base 里唯一一个初始数据表的 name 和 schema使用 `--fields` 前先读 [lark-base-field-json.md](references/lark-base-field-json.md) 或复用 `+field-create` 的字段 JSON 形状,不要猜字段属性。
- `+base-create` 不传 `--table-name``--fields` 时,会创建一个默认 schema 的初始数据表。
- 表、字段、视图、workflow、dashboard block 的名称和 ID 必须来自真实返回,不要凭用户口述猜。
- 存储字段可写;系统字段、`formula``lookup` 只读;附件字段走专用 attachment 命令。
- 一次性原始记录查询优先用 `+record-list` / `+record-search` 的 filter/sort聚合分析优先用 `+data-query`;需要长期显示在表中时,才新增 `formula` / `lookup` 字段。
- `formula` 适合常规计算、条件判断、文本/日期处理和长期派生指标;`lookup` 适合明确的跨表查找、筛选后取值或聚合引用。
- 写入、分析、公式、lookup、workflow、dashboard 前,先读取真实结构:表、字段、视图、关联表和 dashboard block 名称都以命令返回为准。
- 跨表场景必须读取目标表结构link 单元格中的关联 `record_id` 只是连接键,最终回答要回查并展示用户可读字段。
### 批量执行
## 身份与权限降级
能批量的操作尽量批量,不要一轮对话只处理一个对象。
- 优先用原生批量能力:多表字段 `+field-list-batch`;批量写记录 `+record-batch-create` / `+record-batch-update`;部分命令参数本身支持多值(如 `+record-delete --record-id` 可重复传、`+record-share-link-create --record-ids`),先看 `--help`
- 没有原生批量命令时,对多个对象做同类操作在**一条 Bash 命令**里用 shell 循环完成。
- 只读命令可用 `--jq` 收窄输出避免无关字段灌入上下文。脚本输出只打印计数、ID 和失败项,不要回显完整 payload 或原始返回
示例——一次取多个视图的配置:
```bash
for v in vewAAA vewBBB vewCCC; do
echo "== $v"
lark-cli base +view-get --base-token <base_token> --table-id <table_id> --view-id "$v" --as user
done
```
### 善用 help
- 参数不确定、要构造复杂 JSON、或命令带批量/隐藏选项时先看对应reference或 `--help`,不要猜参数名或 JSON 结构;`+table-list` / `+base-create` 这类参数显而易见的简单命令直接执行,报参数错误再查 help不要为它单花一轮。
- 需要看多个命令的 help 时,合并在一条 Bash 命令里一次看完。
### 身份与权限降级
- 默认显式使用 `--as user` 操作用户资源;只有用户明确要求应用身份时,才直接用 `--as bot`
- user 身份报 scope/授权不足,或错误中包含 `permission_violations` / `hint`,先转 `lark-shared` 做用户授权恢复,不要直接降级 bot。
@@ -83,35 +121,27 @@ metadata:
- `91403` 或明确不可访问错误不要循环换身份重试。
- `+base-create` / `+base-copy` 若用 bot 身份执行,关注返回中的 `permission_grant`,并把用户是否可打开新 Base 告知用户。
## 查询与统计规则
### 查询与统计
涉及查询、统计或判断结论时,先读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md),并遵守:
- 涉及筛选、排序、Top/Bottom N、聚合、分组、多表关联或任何全局结论时,先读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) 并按其 Hard Rules 执行。
- 两条红线随时生效:能由 Base 云端表达的筛选/排序/聚合不要拉原始记录到本地手工处理;`has_more=true` 等分页信号未消除前,不能基于当前页下全局结论。
1. `+record-list` 的默认页、固定 `--limit` 和本地 `jq` 只能证明已读取范围内的事实不能直接支撑全局最值、全量计数、Top/Bottom N、异常识别或分组结论。
2. 能由 Base 表达的筛选、排序、投影、聚合、分组和限制,应在 Base 云端查询能力中执行;不要先拉原始记录到本地上下文再手工筛选排序。
3. `has_more=true` 或等价分页信号表示当前结果不是全量;除非用户只要样例/前 N 条,不能基于该页回答全局问题。
4. 多表查询必须先确认关系字段和连接键link 单元格里的 `record_id` 是关系键,不是用户可读答案。
5. 最终答案必须能追溯到真实表、真实字段、查询范围、筛选/排序/聚合条件和必要的连接键。
6. 一次性原始记录查询优先用 `+record-list` / `+record-search` 的 filter/sort聚合分析优先用 `+data-query`;要把结果长期显示在表里,才考虑新增 `formula` / `lookup` 字段。
7. `+data-query` 可返回聚合结果或维度字段行,但维度行按字段组合去重且不返回 `record_id`;需要逐条记录、记录定位或完整行级字段时,再用 `+record-list` / `+record-search` / `+record-get` 回查。
### 写入前置
## 写入前置规则
- 写记录/字段前先读真实结构;表名、字段名、视图名必须来自真实返回,跨表场景还要读目标表结构。
- 复杂 JSON 按快速路由读对应 reference字段读 [lark-base-field-json.md](references/lark-base-field-json.md),记录读 [lark-base-cell-value.md](references/lark-base-cell-value.md)(写入红线:只写存储字段、批量上限、并发冲突等,见其顶层规则)。
- 删除、角色更新、字段更新等高风险操作遵循 CLI 的 confirmation gate目标不明确先用 get/list 消歧workflow/role 等复杂写操作创建后用 get 回读确认,必要时先 `--dry-run` 预演。
- 写记录前先读字段结构;只写存储字段。系统字段、附件字段、`formula``lookup` 不作为普通记录写入目标。
- 附件上传、下载、删除走专用 `+record-*-attachment` 命令。
- 写字段前先读 [lark-base-field-json.md](references/lark-base-field-json.md);涉及 `formula` / `lookup` 时必须读 [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md)。
- 表名、字段名、视图名、workflow 配置中的名称必须来自真实返回;跨表场景还要读取目标表结构。
- 删除、角色更新、字段更新等高风险操作遵循 CLI 的 confirmation gate目标不明确时先用 get/list 消歧。
- 批量写入单批最多 200 条;连续写同一表时串行执行,遇到 `1254291` 按短暂等待后重试处理。
- `+record-batch-update` 是“同值批量更新”:同一份 patch 应用到全部 `record_id_list`,不要拿它做逐行不同值映射。
- select/multiselect 写入未知选项可能触发平台新增选项;不是要新增时,先用 `+field-list``+field-search-options` 确认可选值。
### 表单与视图
## 表单与视图细节
- `+form-submit` 前必须先 `+form-detail`提交规则filter 隐藏题不填、附件写在 `attachments` 并带 `--base-token`)见 [lark-base-form-submit.md](references/lark-base-form-submit.md)。
- 视图配置先用对应 get 命令读现状,只替换要变更的部分;一次性筛选/排序先用 `+record-list` / `+record-search` 验证,再按需沉淀为持久视图。
- `+form-submit` 前必须先跑 `+form-detail`,读取 `questions[].type``required``filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`
- `+view-set-filter` 是唯一保留的 view referencesort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置
- 视图适合持久化、共享和 UI 复用;一次性筛选/排序可先用 `+record-list` / `+record-search` 的 filter/sort 验证结果,再按需要沉淀为持久视图
### Dashboard / Workflow / Role
- Dashboard 的复杂点是 block 的 `data_config`:创建/更新 block 前读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md),组件串行创建;布局/换图表类型/删除具名图表等操作要点见 [lark-base-dashboard.md](references/lark-base-dashboard.md) 的「执行要点」。`+dashboard-block-get-data` 只返回图表数据,元数据用 `+dashboard-block-get`
- Workflow 的复杂点是 `steps`:先读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md),用其中的最短路径和场景表完成查询/启停/常见创建修改;需要具体 step 字段再按需读 schema 小文件;创建后 `+workflow-get` 回读验证
- Role 的复杂点是权限 JSON先读 [lark-base-role-guide.md](references/lark-base-role-guide.md)(含安全边界),权限 JSON SSOT 读 [role-config.md](references/role-config.md);删除角色、关闭高级权限前确认目标和影响。
## Token 与链接
@@ -122,19 +152,10 @@ metadata:
| `/base/{token}?table={id}` | `table` 参数用于定位 Base 内对象:`tbl` 开头是数据表 `--table-id``blk` 开头是 dashboard ID`wkf` 开头是 workflow ID |
| `/base/{token}?view={id}` | `view` 参数用于定位表视图,提取为 `--view-id`;通常还需要确认 `table` 参数或先查表结构 |
| `/share/base/form/{shareToken}` | 表单分享链接;这是表单 share token`+form-detail` / `+form-submit --share-token <shareToken>` |
| `/share/base/view/{shareToken}` | 视图分享链接;具有分享权限语义,暂不支持用 CLI 直接访问,引导用户在浏览器或飞书客户端打开 |
| `/share/base/dashboard/{shareToken}` | 仪表盘分享链接;具有分享权限语义,暂不支持用 CLI 直接访问,引导用户在浏览器或飞书客户端打开 |
| `/record/{shareToken}` | 记录分享链接;暂不支持用 CLI 直接访问,引导用户在浏览器或飞书客户端打开。若用户想生成现有记录的分享链接,用 `+record-share-link-create --base-token <base_token> --table-id <table_id> --record-ids <record_id>` |
| `/base/workspace/{token}` | BaseApp / workspace 链接;暂不支持用 CLI 直接访问 |
| `/share/base/view/...` / `/share/base/dashboard/...` / `/record/...` / `/base/workspace/...` | 分享链接与 workspace 链接,暂不支持用 CLI 直接访问,引导用户在飞书客户端打开;要生成记录分享链接用 `+record-share-link-create` |
`wiki +node-get` 返回非 `bitable` 时,不继续使用 Base 命令:`docx` 转文档,`sheet` 转表格,其他云空间对象转对应 skill 或 drive。
## Dashboard / Workflow / Role
- Dashboard 的复杂点是 block 的 `data_config`,不是 list/get/create/delete 命令参数。创建或更新 block 前先读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md),组件必须串行创建;`+dashboard-arrange` 是服务端智能布局,只在用户明确要求重排/美化时执行。`+dashboard-block-get-data` 读取图表最终计算结果,不返回 block 名称、类型、布局或 `data_config`;需要元数据先用 `+dashboard-block-get`
- Workflow 的复杂点是 `steps` 结构。创建、更新或解释完整 workflow 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md)enable/disable/list 只需确认 workflow ID、当前启停状态和用户意图。
- Role 的复杂点是权限 JSON。角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md)`+role-create` 只支持自定义角色;`+role-update` 是 delta merge角色 create/update 或解读完整配置时读权限 JSON SSOT [role-config.md](references/role-config.md)。`+role-delete` 只适用于自定义角色,系统角色不可删除;删除角色和关闭高级权限前必须确认目标和影响。
## 常见恢复
| 错误 / 现象 | 恢复动作 |
@@ -149,18 +170,3 @@ metadata:
| `1254104` | 批量超过 200分批调用 |
| `1254291` | 并发写冲突,串行写入并在批次间短暂等待 |
| `91403` | 无权限访问该 Base`lark-shared` 权限流程处理,不要盲目重试 |
## 保留 Reference
- [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md):查询/统计/全局结论的选路 SOP
- [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md) / [lark-base-data-query.md](references/lark-base-data-query.md):聚合查询入口 fewshot 与 DSL SSOT
- [lark-base-cell-value.md](references/lark-base-cell-value.md):记录 CellValue 构造
- [lark-base-field-json.md](references/lark-base-field-json.md):字段 JSON 构造
- [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md):公式与 lookup 字段
- [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md):字段创建/更新命令级补充
- [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) / [lark-base-record-history-list.md](references/lark-base-record-history-list.md):记录写入 JSON 与历史返回解释
- [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md):视图筛选 JSON
- [lark-base-form-detail.md](references/lark-base-form-detail.md) / [lark-base-form-submit.md](references/lark-base-form-submit.md) / [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md):表单详情、提交和复杂 JSON
- [lark-base-dashboard.md](references/lark-base-dashboard.md) / [dashboard-block-data-config.md](references/dashboard-block-data-config.md) / [lark-base-dashboard-block-get-data.md](references/lark-base-dashboard-block-get-data.md):仪表盘、组件配置与图表结果协议
- [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) / [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md)workflow 入口与 steps JSON SSOT
- [lark-base-role-guide.md](references/lark-base-role-guide.md) / [role-config.md](references/role-config.md):角色入口与权限 JSON SSOT

View File

@@ -2,6 +2,8 @@
Block 的 `data_config` 字段因 `type` 不同而变化。本文档是 dashboard block `data_config` 的单一事实来源SSOT包含组件类型、字段结构、筛选格式、约束和可复制模板。
`data_config` 是 dashboard block 的数据源配置。先用 `+table-list` 拿表,再用 `+field-list-batch --table-id <表1> --table-id <表2>` **一次批量**拿到相关表字段(不要逐表多次 `+field-list`,每次多余调用都拉高 token表用 **name**,不是 table_id字段用 **field_name**
## 支持的组件类型(`type` 枚举)
| type 值 | 说明 |

View File

@@ -0,0 +1,115 @@
# Base Formula Examples and Requirement Translation
> 本文件是 [formula-field-guide.md](formula-field-guide.md) 的按需补充:完整示例与"自然语言需求 → 公式"的翻译规则。
## Section 13: Complete Examples
### Example 1: Employee sales summary
**Table structure** (from `+table-get`):
- Employees: EmployeeID (Text), Name (Text), Department (Text)
- Sales: ContractID (Number), SalespersonID (Text), Quantity (Number), Total (Number)
**Current table**: Employees
**Requirement**: For each employee, output "Sold XX orders" if they have sales records, otherwise "No sales records".
**Formula**:
```
IF(
[Sales].COUNTIF(CurrentValue.[SalespersonID] = [EmployeeID]) >= 1,
"Sold " & [Sales].COUNTIF(CurrentValue.[SalespersonID] = [EmployeeID]) & " orders",
"No sales records"
)
```
**Field JSON**:
```json
{
"type": "formula",
"name": "Sales Summary",
"expression": "IF([Sales].COUNTIF(CurrentValue.[SalespersonID] = [EmployeeID]) >= 1, \"Sold \" & [Sales].COUNTIF(CurrentValue.[SalespersonID] = [EmployeeID]) & \" orders\", \"No sales records\")"
}
```
**Explanation**: `[Sales].COUNTIF(...)` uses the entire Sales table as data range. CurrentValue represents each row in Sales, accessing `CurrentValue.[SalespersonID]` for that row's salesperson. `[EmployeeID]` refers to the current row in the Employees table (where the formula lives).
### Example 2: Chained cross-table access via link fields
**Table structure**:
- Orders: ID (`auto_number`), OrderItems (`link` [target: OrderItems, foreign key: ID])
- OrderItems: ID (`auto_number`), Product (`link` [target: Products, foreign key: ID])
- Products: ID (`auto_number`), ProductName (`text`)
**Current table**: Orders
**Requirement**: Deduplicate and comma-join all product names from linked order items.
**Formula**:
```
[OrderItems].[Product].[ProductName].UNIQUE().ARRAYJOIN(",")
```
**Field JSON**:
```json
{
"type": "formula",
"name": "Product List",
"expression": "[OrderItems].[Product].[ProductName].UNIQUE().ARRAYJOIN(\",\")"
}
```
**Explanation**: `[OrderItems]` gets linked order item records, `.[Product]` expands to each item's linked product, `.[ProductName]` gets all product names, `.UNIQUE()` deduplicates, `.ARRAYJOIN(",")` joins with commas.
### Example 3: Cross-table filter + sort
**Table structure**:
- Projects: ProjectName (Text), Status (Text), Owner (Text)
- Tasks: TaskName (Text), Project (Text), Priority (Number), DueDate (Date)
**Current table**: Projects
**Requirement**: Find the highest-priority (lowest number) task name for the current project.
**Formula**:
```
FIRST(
[Tasks].FILTER(CurrentValue.[Project] = [ProjectName]).SORTBY([Tasks].[Priority], TRUE).[TaskName]
)
```
**Field JSON**:
```json
{
"type": "formula",
"name": "Top Priority Task",
"expression": "FIRST([Tasks].FILTER(CurrentValue.[Project] = [ProjectName]).SORTBY([Tasks].[Priority], TRUE).[TaskName])"
}
```
**Explanation**: `[Tasks].FILTER(CurrentValue.[Project] = [ProjectName])` filters tasks belonging to the current project. `.SORTBY([Tasks].[Priority], TRUE)` sorts by priority ascending. `.[TaskName]` extracts task names. `FIRST(...)` gets the first one (highest priority).
---
## Section 14: Translating User Requirements to Formulas
When the user describes their formula need in natural language, follow these rules to convert it into a precise expression:
1. **Numbers must use precise values**: "less than 80%" → field value less than `0.8`. "above 1000" → `>= 1000`.
2. **Interval boundaries**: "above/below/within" = closed (inclusive); "less than/more than/outside" = open (exclusive).
3. **Branching logic** must be organized as an ordered list with a fallback branch. Each branch has a condition and output.
- Example: "return risk level for 1-3" → `IFS([Value] = 1, "low", [Value] = 2, "medium", [Value] = 3, "high")` with an `IFERROR` or trailing empty-string fallback.
4. **Multi-level branches must be flattened** to a single level. Nested if-else chains → flat IFS.
5. **Branch conditions must be mutually exclusive**. If the user's conditions overlap, rewrite to eliminate ambiguity.
6. **Reorder branches by logical priority** if the user's order is illogical (e.g., check specific conditions before catch-all).
---

View File

@@ -34,11 +34,11 @@ When creating a formula field, the Agent should:
This is the foundation of formula logic. You must determine this before writing any formula.
| Syntax | Meaning | Return type | Example |
| --------------------- | -------------------------------------------- | ---------------------- | -------------------------------------------- |
| `[Field]` | Value of this field in the current row | Scalar (single value) | `[Name]``"Alice"` |
| Syntax | Meaning | Return type | Example |
|---|---|---|---|
| `[Field]` | Value of this field in the current row | Scalar (single value) | `[Name]``"Alice"` |
| `[TableName].[Field]` | All values of this field in the target table | List (multiple values) | `[Employees].[Name]``["Alice","Bob",...]` |
| `[TableName]` | The target table (entire table) | Table reference | Used as data range for FILTER/COUNTIF etc. |
| `[TableName]` | The target table (entire table) | Table reference | Used as data range for FILTER/COUNTIF etc. |
**Rules**:
@@ -59,7 +59,7 @@ This is the foundation of formula logic. You must determine this before writing
### Field storage types
| Type | Description | Supported operations |
|------|-------------|----------------------|
|---|---|---|
| `number` | Stored as numeric value | Math operations, comparisons, auto-converts to string for concatenation |
| `text` | Stored as string | String operations; can participate in math if content is numeric, otherwise errors |
| `datetime` | Date object | Date functions, add/subtract with numbers; auto-converts to default format string when using `&` — use TEXT to format first for controlled output |
@@ -69,13 +69,13 @@ This is the foundation of formula logic. You must determine this before writing
### Implicit type conversion
| Scenario | Conversion rule |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------- |
| Number + Float | → Float |
| Date + Number | → Date (adds/subtracts days). Use `+`/`-` for whole days, use `DURATION()` for hour/minute/second precision |
| Date - Date | → Duration |
| Boolean compared with Number | Boolean auto-converts to number (TRUE=1, FALSE=0) |
| `&` concatenation | Both sides auto-convert to string |
| Scenario | Conversion rule |
|---|---|
| Number + Float | → Float |
| Date + Number | → Date (adds/subtracts days). Use `+`/`-` for whole days, use `DURATION()` for hour/minute/second precision |
| Date - Date | → Duration |
| Boolean compared with Number | Boolean auto-converts to number (TRUE=1, FALSE=0) |
| `&` concatenation | Both sides auto-convert to string |
### Type consistency in comparisons
@@ -97,12 +97,12 @@ When using comparison operators (`>`, `>=`, `<`, `<=`, `=`, `!=`), **both sides
### CurrentValue meaning in different contexts
| Data range type | CurrentValue represents | Access pattern | Example |
| ---------------------------- | ----------------------- | --------------------------- | --------------------------------------------------------- |
| Entire table `[TableName]` | A row in the table | `CurrentValue.[FieldName]` | `[Orders].FILTER(CurrentValue.[Amount] > 100).[Customer]` |
| Column `[TableName].[Field]` | A single field value | Use `CurrentValue` directly | `[Orders].[Amount].FILTER(CurrentValue > 100)` |
| Data range type | CurrentValue represents | Access pattern | Example |
|---|---|---|---|
| Entire table `[TableName]` | A row in the table | `CurrentValue.[FieldName]` | `[Orders].FILTER(CurrentValue.[Amount] > 100).[Customer]` |
| Column `[TableName].[Field]` | A single field value | Use `CurrentValue` directly | `[Orders].[Amount].FILTER(CurrentValue > 100)` |
| `select` (`multiple=true`) field `[Tags]` | One option | Use `CurrentValue` directly | `[Tags].FILTER(CurrentValue = "Important")` |
| LIST-generated list | One element | Use `CurrentValue` directly | `LIST(1,2,3).MAP(CurrentValue * 2)` |
| LIST-generated list | One element | Use `CurrentValue` directly | `LIST(1,2,3).MAP(CurrentValue * 2)` |
### Key rules
@@ -113,11 +113,11 @@ When using comparison operators (`>`, `>=`, `<`, `<=`, `=`, `!=`), **both sides
### Anti-patterns
| Wrong | Reason | Correct |
| ---------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `[Table].[Col].FILTER(CurrentValue.[Col] > 0)` | Data range is a column; CurrentValue is a scalar, cannot use `.` to access fields | `[Table].[Col].FILTER(CurrentValue > 0)` |
| `[Table].FILTER(CurrentValue > 100)` | Data range is a table; CurrentValue is a row, cannot compare directly | `[Table].FILTER(CurrentValue.[Amount] > 100).[Amount]` |
| `CurrentValue + 1` (at top level) | CurrentValue can only be used inside iteration functions | Use inside MAP/FILTER etc. |
| Wrong | Reason | Correct |
|---|---|---|
| `[Table].[Col].FILTER(CurrentValue.[Col] > 0)` | Data range is a column; CurrentValue is a scalar, cannot use `.` to access fields | `[Table].[Col].FILTER(CurrentValue > 0)` |
| `[Table].FILTER(CurrentValue > 100)` | Data range is a table; CurrentValue is a row, cannot compare directly | `[Table].FILTER(CurrentValue.[Amount] > 100).[Amount]` |
| `CurrentValue + 1` (at top level) | CurrentValue can only be used inside iteration functions | Use inside MAP/FILTER etc. |
---
@@ -125,12 +125,12 @@ When using comparison operators (`>`, `>=`, `<`, `<=`, `=`, `!=`), **both sides
Base formulas **only allow** the following operators. `like`, `in`, `<>`, `**`, `^` etc. are prohibited.
| Category | Operators | Description |
| ------------- | -------------------------- | -------------------------------------------------------------------------- |
| Arithmetic | `+` `-` `*` `/` `%` | Add, subtract, multiply, divide, modulo (`%` is equivalent to `MOD()`) |
| Comparison | `>` `>=` `<` `<=` `=` `!=` | Greater than, greater or equal, less than, less or equal, equal, not equal |
| Logical | `&&` `\|\|` | AND, OR |
| Concatenation | `&` | Text concatenation; non-text values auto-convert to string |
| Category | Operators | Description |
|---|---|---|
| Arithmetic | `+` `-` `*` `/` `%` | Add, subtract, multiply, divide, modulo (`%` is equivalent to `MOD()`) |
| Comparison | `>` `>=` `<` `<=` `=` `!=` | Greater than, greater or equal, less than, less or equal, equal, not equal |
| Logical | `&&` `\|\|` | AND, OR |
| Concatenation | `&` | Text concatenation; non-text values auto-convert to string |
**Important**:
@@ -174,10 +174,10 @@ Retrieves the target field values for all linked records as a list. Supports con
### Two calling styles
| Style | Format | Description |
| ---------- | ------------------ | ----------------------------------- |
| Functional | `FUNC(arg1, arg2)` | Works for all functions |
| Chained | `arg1.FUNC(arg2)` | Moves the first argument before `.` |
| Style | Format | Description |
|---|---|---|
| Functional | `FUNC(arg1, arg2)` | Works for all functions |
| Chained | `arg1.FUNC(arg2)` | Moves the first argument before `.` |
**Rules**:
@@ -228,175 +228,139 @@ After the result column, it's recommended to flatten with `.LISTCOMBINE()` first
---
## Section 8: Complete Function Reference
## Section 8: Function Reference (common functions)
> 本表覆盖常用函数(含评测与真实负载中 100% 出现过的函数)。三角/双曲/随机数/进制转换等罕见函数的签名在 [formula-functions-extended.md](formula-functions-extended.md),仅当用户明确要求这些函数时再读。
### 8.1 Logic functions
| Function | Signature | Return type | Description |
| ------------- | ------------------------------------------------------------------ | -------------------- | -------------------------------------------------------------------------------------------- |
| IF | `IF(condition, true_val, [false_val])` | Matches branch type | Returns true_val when TRUE, false_val otherwise; omitting false_val returns false (not null) |
| IFS | `IFS(cond1, val1, cond2, val2, ...)` | Matches branch type | Multi-condition branching; returns value for the first TRUE condition |
| SWITCH | `SWITCH(expr, match1, result1, [match2, result2, ...], [default])` | Matches branch type | Matches expression value and returns corresponding result |
| IFERROR | `IFERROR(expr, fallback)` | Matches branch type | Returns fallback when expression errors |
| IFBLANK | `IFBLANK(expr, fallback)` | Matches branch type | Returns fallback when expression is blank (blank = NULL/empty string/empty list) |
| AND | `AND(cond1, cond2, ...)` | Boolean | TRUE when all conditions are TRUE |
| OR | `OR(cond1, cond2, ...)` | Boolean | TRUE when any condition is TRUE |
| NOT | `NOT(condition)` | Boolean | Logical negation |
| ISBLANK | `ISBLANK(value)` | Boolean | Tests if blank (NULL/empty string/empty list are blank; 0 and FALSE are not) |
| ISNULL | `ISNULL(value)` | Boolean | Tests if NULL (only NULL is true; empty string is not) |
| ISERROR | `ISERROR(expr)` | Boolean | Tests if expression errors |
| ISNUMBER | `ISNUMBER(value)` | Boolean | Tests if value is a number |
| CONTAIN | `CONTAIN(search_range, value, ...)` | Boolean | Tests if a list or `select` (`multiple=true`) contains the value; **does NOT do text substring matching** |
| CONTAINSALL | `CONTAINSALL(search_range, value, ...)` | Boolean | Tests if a list or `select` (`multiple=true`) contains all specified values |
| CONTAINSONLY | `CONTAINSONLY(search_range, value, ...)` | Boolean | Tests if a list or `select` (`multiple=true`) contains only the specified values |
| TRUE | `TRUE()` | Boolean | Returns TRUE |
| FALSE | `FALSE()` | Boolean | Returns FALSE |
| RECORD_ID | `RECORD_ID()` | Text | Returns the current row's record ID |
| RANDOMBETWEEN | `RANDOMBETWEEN(min_int, max_int, [keep_updating])` | Number | Random integer in the specified range |
| RANDOMITEM | `RANDOMITEM(list, [keep_updating])` | Matches element type | Randomly picks one element from a list |
| Function | Signature | Return type | Description |
|---|---|---|---|
| IF | `IF(condition, true_val, [false_val])` | Matches branch type | Returns true_val when TRUE, false_val otherwise; omitting false_val returns false (not null) |
| IFS | `IFS(cond1, val1, cond2, val2, ...)` | Matches branch type | Multi-condition branching; returns value for the first TRUE condition |
| SWITCH | `SWITCH(expr, match1, result1, [match2, result2, ...], [default])` | Matches branch type | Matches expression value and returns corresponding result |
| IFERROR | `IFERROR(expr, fallback)` | Matches branch type | Returns fallback when expression errors |
| IFBLANK | `IFBLANK(expr, fallback)` | Matches branch type | Returns fallback when expression is blank (blank = NULL/empty string/empty list) |
| AND | `AND(cond1, cond2, ...)` | Boolean | TRUE when all conditions are TRUE |
| OR | `OR(cond1, cond2, ...)` | Boolean | TRUE when any condition is TRUE |
| NOT | `NOT(condition)` | Boolean | Logical negation |
| ISBLANK | `ISBLANK(value)` | Boolean | Tests if blank (NULL/empty string/empty list are blank; 0 and FALSE are not) |
| ISNULL | `ISNULL(value)` | Boolean | Tests if NULL (only NULL is true; empty string is not) |
| CONTAIN | `CONTAIN(search_range, value, ...)` | Boolean | Tests if a list or `select` (`multiple=true`) contains the value; **does NOT do text substring matching** |
| RECORD_ID | `RECORD_ID()` | Text | Returns the current row's record ID |
### 8.2 Numeric functions
| Function | Signature | Return type | Description |
| --- | --- | --- | --- |
| SUM | `SUM(val1, val2, ...)` | Number | Sum; accepts multiple values or a list |
| AVERAGE | `AVERAGE(val1, val2, ...)` | Number | Average |
| MAX | `MAX(val1, val2, ...)` | Number | Maximum |
| MIN | `MIN(val1, val2, ...)` | Number | Minimum |
| MEDIAN | `MEDIAN(val1, val2, ...)` | Number | Median |
| COUNTA | `COUNTA(val1, val2, ...)` | Number | Count of non-blank values |
| COUNTIF | `COUNTIF(data_range, condition)` | Number | Count matching items. Data range can be a **table** (CurrentValue is a row, use `CurrentValue.[Field]`) or a **column** (CurrentValue is a scalar value) |
| SUMIF | `SUMIF(data_range, condition)` | Number | Sum matching values. Data range **must be a numeric column** (e.g. `[Table].[NumField]`); CurrentValue is each value in that column (scalar), cannot use `CurrentValue.[Field]` to access other fields. For cross-field conditions, use FILTER+SUM instead |
| ROUND | `ROUND(number, digits)` | Number | Round. digits: 1=one decimal, 0=integer, -1=tens place |
| ROUNDUP | `ROUNDUP(number, digits)` | Number | Round away from zero. Same digits semantics as ROUND |
| ROUNDDOWN | `ROUNDDOWN(number, digits)` | Number | Round toward zero. Same digits semantics as ROUND |
| FLOOR | `FLOOR(number, [base])` | Number | Round down to nearest multiple of base (default 1) |
| CEILING | `CEILING(number, [base])` | Number | Round up to nearest multiple of base (default 1) |
| ABS | `ABS(number)` | Number | Absolute value |
| INT | `INT(number)` | Integer | Truncate to integer |
| MOD | `MOD(dividend, divisor)` | Number | Modulo |
| POWER | `POWER(base, exponent)` | Number | Exponentiation |
| QUOTIENT | `QUOTIENT(dividend, divisor)` | Number | Integer division |
| VALUE | `VALUE(text)` | Number | Convert text to number |
| ISODD | `ISODD(number)` | Boolean | Tests if number is odd |
| RANK | `RANK(value, search_range, [ascending])` | Number | Rank of value in range; default descending |
| SEQUENCE | `SEQUENCE(start, end, [step])` | List | Generate number sequence |
| PI | `PI()` | Number | Pi constant |
| SIN/COS/TAN/ASIN/ACOS/ATAN/ATAN2/SINH/COSH/TANH/ASINH/ACOSH/ATANH | `func(radians_or_value)` | Number | Trigonometric and hyperbolic functions; arguments in radians |
| Function | Signature | Return type | Description |
|---|---|---|---|
| SUM | `SUM(val1, val2, ...)` | Number | Sum; accepts multiple values or a list |
| AVERAGE | `AVERAGE(val1, val2, ...)` | Number | Average |
| MAX | `MAX(val1, val2, ...)` | Number | Maximum |
| MIN | `MIN(val1, val2, ...)` | Number | Minimum |
| COUNTA | `COUNTA(val1, val2, ...)` | Number | Count of non-blank values |
| COUNTIF | `COUNTIF(data_range, condition)` | Number | Count matching items. Data range can be a **table** (CurrentValue is a row, use `CurrentValue.[Field]`) or a **column** (CurrentValue is a scalar value) |
| SUMIF | `SUMIF(data_range, condition)` | Number | Sum matching values. Data range **must be a numeric column** (e.g. `[Table].[NumField]`); CurrentValue is each value in that column (scalar), cannot use `CurrentValue.[Field]` to access other fields. For cross-field conditions, use FILTER+SUM instead |
| ROUND | `ROUND(number, digits)` | Number | Round. digits: 1=one decimal, 0=integer, -1=tens place |
| ABS | `ABS(number)` | Number | Absolute value |
| INT | `INT(number)` | Integer | Truncate to integer |
| MOD | `MOD(dividend, divisor)` | Number | Modulo |
| VALUE | `VALUE(text)` | Number | Convert text to number |
### 8.3 Text functions
| Function | Signature | Return type | Description |
| --------------- | ---------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------- |
| CONCATENATE | `CONCATENATE(text1, text2, ...)` | Text | Concatenate multiple texts; supports lists as input |
| LEN | `LEN(text)` | Number | Character count |
| LEFT | `LEFT(text, [count])` | Text | Extract from left; default 1 |
| RIGHT | `RIGHT(text, [count])` | Text | Extract from right; default 1 |
| MID | `MID(text, start, count)` | Text | Extract from middle |
| FIND | `FIND(search_val, search_range, [start])` | Number | Find substring position (case-sensitive); returns -1 if not found |
| REPLACE | `REPLACE(text, start, count, new_text)` | Text | Replace by position |
| SUBSTITUTE | `SUBSTITUTE(text, old_text, new_text, [occurrence])` | Text | Replace by content; can specify which occurrence |
| UPPER | `UPPER(text)` | Text | Convert to uppercase |
| LOWER | `LOWER(text)` | Text | Convert to lowercase |
| TRIM | `TRIM(text)` | Text | Remove leading/trailing spaces |
| TEXT | `TEXT(value, format)` | Text | Format output. Date formats: `"YYYY-MM-DD"`, `"YYYY/MM/DD hh:mm:ss"`; number formats: `"00"`, `"000.00"` |
| CONTAINTEXT | `CONTAINTEXT(text, search_text)` | Boolean | Tests if text contains substring (text substring matching) |
| SPLIT | `SPLIT(text, delimiter)` | List | Split text by delimiter |
| TODATE | `TODATE(value)` | Date | Convert date string to date type |
| CHAR | `CHAR(number)` | Text | ASCII code to character |
| FORMAT | `FORMAT(template, [val1, val2, ...])` | Text | Template string formatting; use `{1}`, `{2}` as placeholders |
| HYPERLINK | `HYPERLINK(url, [display_text])` | Hyperlink | Create a hyperlink |
| ENCODEURL | `ENCODEURL(text)` | Text | URL encode |
| REGEXMATCH | `REGEXMATCH(text, regex)` | Boolean | Regex match test |
| REGEXEXTRACT | `REGEXEXTRACT(text, regex)` | List | Extract first match's capture groups |
| REGEXEXTRACTALL | `REGEXEXTRACTALL(text, regex)` | 2D List | Extract all matches |
| REGEXREPLACE | `REGEXREPLACE(text, regex, replacement)` | Text | Regex replace |
| Function | Signature | Return type | Description |
|---|---|---|---|
| CONCATENATE | `CONCATENATE(text1, text2, ...)` | Text | Concatenate multiple texts; supports lists as input |
| LEN | `LEN(text)` | Number | Character count |
| LEFT | `LEFT(text, [count])` | Text | Extract from left; default 1 |
| RIGHT | `RIGHT(text, [count])` | Text | Extract from right; default 1 |
| MID | `MID(text, start, count)` | Text | Extract from middle |
| REPLACE | `REPLACE(text, start, count, new_text)` | Text | Replace by position |
| SUBSTITUTE | `SUBSTITUTE(text, old_text, new_text, [occurrence])` | Text | Replace by content; can specify which occurrence |
| UPPER | `UPPER(text)` | Text | Convert to uppercase |
| LOWER | `LOWER(text)` | Text | Convert to lowercase |
| TRIM | `TRIM(text)` | Text | Remove leading/trailing spaces |
| TEXT | `TEXT(value, format)` | Text | Format output. Date formats: `"YYYY-MM-DD"`, `"YYYY/MM/DD hh:mm:ss"`; number formats: `"00"`, `"000.00"` |
| CONTAINTEXT | `CONTAINTEXT(text, search_text)` | Boolean | Tests if text contains substring (text substring matching) |
| SPLIT | `SPLIT(text, delimiter)` | List | Split text by delimiter |
### 8.4 Date functions
| Function | Signature | Return type | Description |
| ----------- | ----------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------- |
| NOW | `NOW()` | Date | Current date and time |
| TODAY | `TODAY()` | Date | Current date (midnight) |
| DATE | `DATE(year, month, day)` | Date | Construct a date |
| YEAR | `YEAR(date)` | Number | Extract year |
| MONTH | `MONTH(date)` | Number | Extract month |
| DAY | `DAY(date)` | Number | Extract day |
| HOUR | `HOUR(date)` | Number | Extract hour |
| MINUTE | `MINUTE(date)` | Number | Extract minute |
| SECOND | `SECOND(date)` | Number | Extract second |
| WEEKDAY | `WEEKDAY(date, [type])` | Number | Day of week |
| WEEKNUM | `WEEKNUM(date, [type])` | Number | Week number |
| DAYS | `DAYS(end_date, start_date)` | Number | Days between two dates (end - start), includes decimals. **Note parameter order: end date comes first** |
| DATEDIF | `DATEDIF(start_date, end_date, [unit])` | Number | Whole days/months/years between dates. Unit: `"D"`(default)/`"M"`/`"Y"`. **Start must be before end** |
| DURATION | `DURATION(days, [hours], [minutes], [seconds])` | Duration | Create a duration for date arithmetic |
| EDATE | `EDATE(date, months)` | Date | Date N months later |
| EOMONTH | `EOMONTH(date, [months])` | Date | End of month N months later; months default 0 |
| WORKDAY | `WORKDAY(start_date, days, [holidays])` | Date | Date N workdays later (skips weekends and holidays) |
| NETWORKDAYS | `NETWORKDAYS(start_date, end_date, [holidays])` | Number | Workdays between dates (inclusive) |
| Function | Signature | Return type | Description |
|---|---|---|---|
| NOW | `NOW()` | Date | Current date and time |
| TODAY | `TODAY()` | Date | Current date (midnight) |
| DATE | `DATE(year, month, day)` | Date | Construct a date |
| YEAR | `YEAR(date)` | Number | Extract year |
| MONTH | `MONTH(date)` | Number | Extract month |
| DAY | `DAY(date)` | Number | Extract day |
| HOUR | `HOUR(date)` | Number | Extract hour |
| MINUTE | `MINUTE(date)` | Number | Extract minute |
| SECOND | `SECOND(date)` | Number | Extract second |
| WEEKDAY | `WEEKDAY(date, [type])` | Number | Day of week |
| WEEKNUM | `WEEKNUM(date, [type])` | Number | Week number |
| DAYS | `DAYS(end_date, start_date)` | Number | Days between two dates (end - start), includes decimals. **Note parameter order: end date comes first** |
| DATEDIF | `DATEDIF(start_date, end_date, [unit])` | Number | Whole days/months/years between dates. Unit: `"D"`(default)/`"M"`/`"Y"`. **Start must be before end** |
| NETWORKDAYS | `NETWORKDAYS(start_date, end_date, [holidays])` | Number | Workdays between dates (inclusive) |
### 8.5 List functions
| Function | Signature | Return type | Description |
| --- | --- | --- | --- |
| LIST | `LIST(val1, val2, ...)` | List | Create a list |
| FIRST | `FIRST(list)` | Scalar | First element |
| LAST | `LAST(list)` | Scalar | Last element |
| NTH | `NTH(list, index)` | Scalar | Nth element (1-based) |
| FILTER | `[Table].FILTER(condition).[ResultCol]` or `[Table].[Col].FILTER(condition)` | List | Filter by condition. When data range is a table, result column is **required**; when it's a column/list, it's not needed. Use CurrentValue in conditions. Add `.LISTCOMBINE()` when result column is multi-value |
| MAP | `data_range.MAP(mapping_expr)` | List | Apply mapping to each element. Use CurrentValue in mapping |
| SORT | `SORT(list, [ascending])` | List | Sort; default ascending (TRUE) |
| SORTBY | `[Table].SORTBY([Table].[SortCol], [ascending]).[OutputCol]` | List | Sort by column then extract output column. **Chain-only, must include output column** |
| UNIQUE | `UNIQUE(list)` | List | Deduplicate |
| ARRAYJOIN | `ARRAYJOIN(list, [delimiter])` | Text | Join list elements as text; default comma-separated |
| LISTCOMBINE | `LISTCOMBINE(val1, [val2, ...])` or `list.LISTCOMBINE()` | List | Two uses: (1) merge values/lists into one list; (2) chained call to flatten 2D array (commonly used when FILTER result column is a multi-value field) |
| DISTANCE | `DISTANCE(location1, location2)` | Number | Distance between two geographic locations (km) |
| Function | Signature | Return type | Description |
|---|---|---|---|
| FIRST | `FIRST(list)` | Scalar | First element |
| LAST | `LAST(list)` | Scalar | Last element |
| FILTER | `[Table].FILTER(condition).[ResultCol]` or `[Table].[Col].FILTER(condition)` | List | Filter by condition. When data range is a table, result column is **required**; when it's a column/list, it's not needed. Use CurrentValue in conditions. Add `.LISTCOMBINE()` when result column is multi-value |
| MAP | `data_range.MAP(mapping_expr)` | List | Apply mapping to each element. Use CurrentValue in mapping |
| SORT | `SORT(list, [ascending])` | List | Sort; default ascending (TRUE) |
| SORTBY | `[Table].SORTBY([Table].[SortCol], [ascending]).[OutputCol]` | List | Sort by column then extract output column. **Chain-only, must include output column** |
| UNIQUE | `UNIQUE(list)` | List | Deduplicate |
| ARRAYJOIN | `ARRAYJOIN(list, [delimiter])` | Text | Join list elements as text; default comma-separated |
| LISTCOMBINE | `LISTCOMBINE(val1, [val2, ...])` or `list.LISTCOMBINE()` | List | Two uses: (1) merge values/lists into one list; (2) chained call to flatten 2D array (commonly used when FILTER result column is a multi-value field) |
---
## Section 9: Commonly Confused Functions
### CONTAIN vs CONTAINTEXT
| | CONTAIN | CONTAINTEXT |
| ----------- | -------------------------------------------------------------- | ---------------------------------------------------------- |
| Purpose | Tests if a **list / `select` (`multiple=true`)** contains a value | Tests if **text** contains a substring |
| Example | `[Tags].CONTAIN("Urgent")` | `[Notes].CONTAINTEXT("completed")` |
| | CONTAIN | CONTAINTEXT |
|---|---|---|
| Purpose | Tests if a **list / `select` (`multiple=true`)** contains a value | Tests if **text** contains a substring |
| Example | `[Tags].CONTAIN("Urgent")` | `[Notes].CONTAINTEXT("completed")` |
| Wrong usage | `CONTAIN([Notes], "completed")` — cannot do substring matching | `CONTAINTEXT([Tags], "Urgent")` — Tags is a list, not text |
### ISBLANK vs ISNULL
| | ISBLANK | ISNULL |
| ----------------- | ------- | ------ |
| NULL | TRUE | TRUE |
| `""` empty string | TRUE | FALSE |
| Empty list `[]` | TRUE | FALSE |
| `0` | FALSE | FALSE |
| `FALSE` | FALSE | FALSE |
| | ISBLANK | ISNULL |
|---|---|---|
| NULL | TRUE | TRUE |
| `""` empty string | TRUE | FALSE |
| Empty list `[]` | TRUE | FALSE |
| `0` | FALSE | FALSE |
| `FALSE` | FALSE | FALSE |
### DAYS vs DATEDIF
| | DAYS | DATEDIF |
| --------------- | ------------------------------------------------------------ | ----------------------------------------- |
| Parameter order | `DAYS(end, start)` — end first | `DATEDIF(start, end, unit)` — start first |
| Precision | Includes decimals (hours/minutes/seconds as fractional days) | Integer only (whole days/months/years) |
| Negative values | Returns negative when start is after end | **Errors** when start is after end |
| | DAYS | DATEDIF |
|---|---|---|
| Parameter order | `DAYS(end, start)` — end first | `DATEDIF(start, end, unit)` — start first |
| Precision | Includes decimals (hours/minutes/seconds as fractional days) | Integer only (whole days/months/years) |
| Negative values | Returns negative when start is after end | **Errors** when start is after end |
### SUM vs SUMIF
| | SUM | SUMIF |
| --------- | ---------------------------------------------- | -------------------------------------------------------------- |
| Purpose | Sum all values | Sum values **matching a condition** |
| Arguments | `SUM(val1, val2, ...)` or `SUM([Table].[Col])` | `SUMIF(data_range, condition)` with CurrentValue in condition |
| Example | `SUM([Orders].[Amount])` — sum all | `SUMIF([Orders].[Amount], CurrentValue > 100)` — sum only >100 |
| | SUM | SUMIF |
|---|---|---|
| Purpose | Sum all values | Sum values **matching a condition** |
| Arguments | `SUM(val1, val2, ...)` or `SUM([Table].[Col])` | `SUMIF(data_range, condition)` with CurrentValue in condition |
| Example | `SUM([Orders].[Amount])` — sum all | `SUMIF([Orders].[Amount], CurrentValue > 100)` — sum only >100 |
### FILTER+aggregation vs COUNTIF/SUMIF
| | FILTER+aggregation | COUNTIF/SUMIF |
| ----------- | ----------------------------------------------------- | ------------------------------------------------------------------------------ |
| Nature | Filter then aggregate (two steps) | One-step (syntactic sugar) |
| Equivalence | `[Table].FILTER(cond).[Col].LISTCOMBINE().SUM()` | `SUMIF([Table].[Col], cond)` (only when condition involves only column values) |
| When to use | Conditions span multiple fields, or multi-step needed | Conditions only involve column values (e.g. `CurrentValue > 100`) |
| | FILTER+aggregation | COUNTIF/SUMIF |
|---|---|---|
| Nature | Filter then aggregate (two steps) | One-step (syntactic sugar) |
| Equivalence | `[Table].FILTER(cond).[Col].LISTCOMBINE().SUM()` | `SUMIF([Table].[Col], cond)` (only when condition involves only column values) |
| When to use | Conditions span multiple fields, or multi-step needed | Conditions only involve column values (e.g. `CurrentValue > 100`) |
---
@@ -612,119 +576,11 @@ Reason: NOW, TODAY, PI and other zero-argument functions must include parenthese
---
## Section 13: Complete Examples
## Section 13: Examples
### Example 1: Employee sales summary
完整示例与"自然语言需求 → 公式"翻译规则按需读 [formula-examples.md](formula-examples.md)。
**Table structure** (from `+table-get`):
- Employees: EmployeeID (Text), Name (Text), Department (Text)
- Sales: ContractID (Number), SalespersonID (Text), Quantity (Number), Total (Number)
**Current table**: Employees
**Requirement**: For each employee, output "Sold XX orders" if they have sales records, otherwise "No sales records".
**Formula**:
```
IF(
[Sales].COUNTIF(CurrentValue.[SalespersonID] = [EmployeeID]) >= 1,
"Sold " & [Sales].COUNTIF(CurrentValue.[SalespersonID] = [EmployeeID]) & " orders",
"No sales records"
)
```
**Field JSON**:
```json
{
"type": "formula",
"name": "Sales Summary",
"expression": "IF([Sales].COUNTIF(CurrentValue.[SalespersonID] = [EmployeeID]) >= 1, \"Sold \" & [Sales].COUNTIF(CurrentValue.[SalespersonID] = [EmployeeID]) & \" orders\", \"No sales records\")"
}
```
**Explanation**: `[Sales].COUNTIF(...)` uses the entire Sales table as data range. CurrentValue represents each row in Sales, accessing `CurrentValue.[SalespersonID]` for that row's salesperson. `[EmployeeID]` refers to the current row in the Employees table (where the formula lives).
### Example 2: Chained cross-table access via link fields
**Table structure**:
- Orders: ID (`auto_number`), OrderItems (`link` [target: OrderItems, foreign key: ID])
- OrderItems: ID (`auto_number`), Product (`link` [target: Products, foreign key: ID])
- Products: ID (`auto_number`), ProductName (`text`)
**Current table**: Orders
**Requirement**: Deduplicate and comma-join all product names from linked order items.
**Formula**:
```
[OrderItems].[Product].[ProductName].UNIQUE().ARRAYJOIN(",")
```
**Field JSON**:
```json
{
"type": "formula",
"name": "Product List",
"expression": "[OrderItems].[Product].[ProductName].UNIQUE().ARRAYJOIN(\",\")"
}
```
**Explanation**: `[OrderItems]` gets linked order item records, `.[Product]` expands to each item's linked product, `.[ProductName]` gets all product names, `.UNIQUE()` deduplicates, `.ARRAYJOIN(",")` joins with commas.
### Example 3: Cross-table filter + sort
**Table structure**:
- Projects: ProjectName (Text), Status (Text), Owner (Text)
- Tasks: TaskName (Text), Project (Text), Priority (Number), DueDate (Date)
**Current table**: Projects
**Requirement**: Find the highest-priority (lowest number) task name for the current project.
**Formula**:
```
FIRST(
[Tasks].FILTER(CurrentValue.[Project] = [ProjectName]).SORTBY([Tasks].[Priority], TRUE).[TaskName]
)
```
**Field JSON**:
```json
{
"type": "formula",
"name": "Top Priority Task",
"expression": "FIRST([Tasks].FILTER(CurrentValue.[Project] = [ProjectName]).SORTBY([Tasks].[Priority], TRUE).[TaskName])"
}
```
**Explanation**: `[Tasks].FILTER(CurrentValue.[Project] = [ProjectName])` filters tasks belonging to the current project. `.SORTBY([Tasks].[Priority], TRUE)` sorts by priority ascending. `.[TaskName]` extracts task names. `FIRST(...)` gets the first one (highest priority).
---
## Section 14: Translating User Requirements to Formulas
When the user describes their formula need in natural language, follow these rules to convert it into a precise expression:
1. **Numbers must use precise values**: "less than 80%" → field value less than `0.8`. "above 1000" → `>= 1000`.
2. **Interval boundaries**: "above/below/within" = closed (inclusive); "less than/more than/outside" = open (exclusive).
3. **Branching logic** must be organized as an ordered list with a fallback branch. Each branch has a condition and output.
- Example: "return risk level for 1-3" → `IFS([Value] = 1, "low", [Value] = 2, "medium", [Value] = 3, "high")` with an `IFERROR` or trailing empty-string fallback.
4. **Multi-level branches must be flattened** to a single level. Nested if-else chains → flat IFS.
5. **Branch conditions must be mutually exclusive**. If the user's conditions overlap, rewrite to eliminate ambiguity.
6. **Reorder branches by logical priority** if the user's order is illogical (e.g., check specific conditions before catch-all).
---
## Section 15: Constraint Summary
## Section 14: Constraint Summary
- Request body must include `"type": "formula"` — this field is required
- Only use functions and operators listed in this document

View File

@@ -0,0 +1,66 @@
# Base Formula Functions — Extended (rare functions)
> 本文件是 [formula-field-guide.md](formula-field-guide.md) Section 8 的长尾补充:三角/双曲/随机数/进制/统计扩展等罕见函数。
> 表格列含义与主文档一致Function | Signature | Return type | Description。
## 8.1 Logic functions (extended)
| Function | Signature | Return type | Description |
|---|---|---|---|
| ISERROR | `ISERROR(expr)` | Boolean | Tests if expression errors |
| ISNUMBER | `ISNUMBER(value)` | Boolean | Tests if value is a number |
| CONTAINSALL | `CONTAINSALL(search_range, value, ...)` | Boolean | Tests if a list or `select` (`multiple=true`) contains all specified values |
| CONTAINSONLY | `CONTAINSONLY(search_range, value, ...)` | Boolean | Tests if a list or `select` (`multiple=true`) contains only the specified values |
| TRUE | `TRUE()` | Boolean | Returns TRUE |
| FALSE | `FALSE()` | Boolean | Returns FALSE |
| RANDOMBETWEEN | `RANDOMBETWEEN(min_int, max_int, [keep_updating])` | Number | Random integer in the specified range |
| RANDOMITEM | `RANDOMITEM(list, [keep_updating])` | Matches element type | Randomly picks one element from a list |
## 8.2 Numeric functions (extended)
| Function | Signature | Return type | Description |
|---|---|---|---|
| MEDIAN | `MEDIAN(val1, val2, ...)` | Number | Median |
| ROUNDUP | `ROUNDUP(number, digits)` | Number | Round away from zero. Same digits semantics as ROUND |
| ROUNDDOWN | `ROUNDDOWN(number, digits)` | Number | Round toward zero. Same digits semantics as ROUND |
| FLOOR | `FLOOR(number, [base])` | Number | Round down to nearest multiple of base (default 1) |
| CEILING | `CEILING(number, [base])` | Number | Round up to nearest multiple of base (default 1) |
| POWER | `POWER(base, exponent)` | Number | Exponentiation |
| QUOTIENT | `QUOTIENT(dividend, divisor)` | Number | Integer division |
| ISODD | `ISODD(number)` | Boolean | Tests if number is odd |
| RANK | `RANK(value, search_range, [ascending])` | Number | Rank of value in range; default descending |
| SEQUENCE | `SEQUENCE(start, end, [step])` | List | Generate number sequence |
| PI | `PI()` | Number | Pi constant |
| SIN/COS/TAN/ASIN/ACOS/ATAN/ATAN2/SINH/COSH/TANH/ASINH/ACOSH/ATANH | `func(radians_or_value)` | Number | Trigonometric and hyperbolic functions; arguments in radians |
## 8.3 Text functions (extended)
| Function | Signature | Return type | Description |
|---|---|---|---|
| FIND | `FIND(search_val, search_range, [start])` | Number | Find substring position (case-sensitive); returns -1 if not found |
| TODATE | `TODATE(value)` | Date | Convert date string to date type |
| CHAR | `CHAR(number)` | Text | ASCII code to character |
| FORMAT | `FORMAT(template, [val1, val2, ...])` | Text | Template string formatting; use `{1}`, `{2}` as placeholders |
| HYPERLINK | `HYPERLINK(url, [display_text])` | Hyperlink | Create a hyperlink |
| ENCODEURL | `ENCODEURL(text)` | Text | URL encode |
| REGEXMATCH | `REGEXMATCH(text, regex)` | Boolean | Regex match test |
| REGEXEXTRACT | `REGEXEXTRACT(text, regex)` | List | Extract first match's capture groups |
| REGEXEXTRACTALL | `REGEXEXTRACTALL(text, regex)` | 2D List | Extract all matches |
| REGEXREPLACE | `REGEXREPLACE(text, regex, replacement)` | Text | Regex replace |
## 8.4 Date functions (extended)
| Function | Signature | Return type | Description |
|---|---|---|---|
| DURATION | `DURATION(days, [hours], [minutes], [seconds])` | Duration | Create a duration for date arithmetic |
| EDATE | `EDATE(date, months)` | Date | Date N months later |
| EOMONTH | `EOMONTH(date, [months])` | Date | End of month N months later; months default 0 |
| WORKDAY | `WORKDAY(start_date, days, [holidays])` | Date | Date N workdays later (skips weekends and holidays) |
## 8.5 List functions (extended)
| Function | Signature | Return type | Description |
|---|---|---|---|
| LIST | `LIST(val1, val2, ...)` | List | Create a list |
| NTH | `NTH(list, index)` | Scalar | Nth element (1-based) |
| DISTANCE | `DISTANCE(location1, location2)` | Number | Distance between two geographic locations (km) |

View File

@@ -13,6 +13,9 @@
- 一次 payload 里同一字段只用一种 key字段名或字段 ID不要重复。
- 写入前先 `+field-list` 获取字段 `type/style/multiple`,再构造值。
- 需要清空字段时优先传 `null`(字段允许清空时)。
- 只写存储字段:系统字段、`formula``lookup` 只读;附件字段不走 CellValue`+record-upload-attachment` / `+record-download-attachment` / `+record-remove-attachment`
- 批量写入单批最多 200 条(超出报 `1254104`);同一张表串行写,遇 `1254291` 并发冲突短暂等待后重试。
- select/multiselect 写入未知选项会触发平台新增该选项;不是要新增时,先用 `+field-list``+field-search-options` 确认可选值。
## 2. 各类型 CellValue

View File

@@ -0,0 +1,238 @@
# Dashboard仪表盘/数据看板)模块指引
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
Dashboard 是 Base 中的数据可视化看板,可以把表格数据变成**组件**(图表、指标卡等)进行展示。
## 核心概念
- **Dashboard仪表盘**:容器,包含多个组件
- **Block组件**:仪表盘中的单个可视化元素(柱状图、折线图、饼图、指标卡等)
- **data_config**:组件的数据源配置(表名、字段、分组等)
## 能力速览
| 你想做什么 | 用这些命令 | 关键文档 |
|------|-----------|---------|
| 创建/删除/改名称 | `+dashboard-create/delete/update` | 本页下方「仪表盘管理」 |
| 在仪表盘里添加组件 | `+dashboard-block-create` | 先用 `+table-list` 拿表,再用 `+field-list-batch --table-id <表1> --table-id <表2>` 批量拿字段,再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 构造 `data_config` |
| 修改组件 | `+dashboard-block-update` | 先读 block 现状,再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 决定替换哪些顶层 key |
| 查看仪表盘有哪些组件 | `+dashboard-get``+dashboard-block-list` | 本页下方「查看仪表盘」 |
| 读取图表计算结果 | `+dashboard-block-get-data` | 返回图表最终数据协议;需要 block 元数据先用 `+dashboard-block-get` |
| 智能重排组件布局 | `+dashboard-arrange` | 只在用户明确要求重排时执行;无法指定精确位置 |
## 典型场景工作流
### 场景 1从 0 到 1 创建仪表盘
示例:搭建一个销售数据分析仪表盘
```bash
# 第 1 步:创建空白仪表盘
lark-cli base +dashboard-create --base-token xxx --name "销售数据分析"
# 记录返回的 dashboard_id
# 第 2 步:获取数据源信息
lark-cli base +table-list --base-token xxx # 先拿表名/table_id
lark-cli base +field-list-batch --base-token xxx --table-id tbl_a --table-id tbl_b # 再一次批量拿相关表字段
# 第 3 步:规划应该创建哪些组件(根据用户需求确定组件类型和数量)
# 例如:总销售额(指标卡)、月度趋势(折线图)、品类占比(饼图)
# 第 4 步:顺序创建每个组件(必须串行执行,不能并发)
# 重要:创建组件前,先确定 dashboard_id、组件 name/type 和真实表字段
# 再阅读 dashboard-block-data-config.md 了解 data_config 结构、组件类型和 filter 规则
# 第 1 个组件
lark-cli base +dashboard-block-create \
--base-token xxx \
--dashboard-id blk_xxx \
--name "总销售额" \
--type statistics \
--data-config '{"table_name":"订单表","series":[{"field_name":"金额","rollup":"SUM"}]}'
# 第 2 个组件(等上一个完成后再执行)
lark-cli base +dashboard-block-create \
--base-token xxx \
--dashboard-id blk_xxx \
--name "月度趋势" \
--type line \
--data-config '{"table_name":"订单表","series":[{"field_name":"金额","rollup":"SUM"}],"group_by":[{"field_name":"月份","mode":"integrated"}]}'
# 继续创建其他组件...
# 第 5 步:组件创建完成后,使用 arrange 命令智能重排布局(可选但推荐)
# 默认布局可能不够美观arrange 会根据组件数量和类型自动优化布局
lark-cli base +dashboard-arrange \
--base-token xxx \
--dashboard-id blk_xxx
```
### 场景 2在已有仪表盘上添加新组件
```bash
# 第 1 步:列出仪表盘,定位到当前仪表盘
lark-cli base +dashboard-list --base-token xxx
# 获取目标 dashboard_id
# 第 2 步:根据用户诉求规划组件类型和数据源
# 建议先查看当前仪表盘已有组件,避免重复创建,或作为参考
lark-cli base +dashboard-get --base-token xxx --dashboard-id blk_xxx
# 第 3 步:获取数据源信息
lark-cli base +table-list --base-token xxx # 先拿表名/table_id
lark-cli base +field-list-batch --base-token xxx --table-id tbl_a --table-id tbl_b # 再一次批量拿相关表字段
# 第 4 步:顺序创建每个新组件(必须串行执行,不能并发)
# 重要:先确定 dashboard_id、组件 name/type 和真实表字段
# 再阅读 dashboard-block-data-config.md 了解 data_config 结构
lark-cli base +dashboard-block-create \
--base-token xxx \
--dashboard-id blk_xxx \
--name "新组件名" \
--type column \
--data-config '{...}'
```
### 场景 3编辑已有组件
> [!IMPORTANT]
> `+dashboard-block-update` **不能修改组件的 `type`**(图表类型),只能更新 `name` 和 `data_config`。
> 如需更换组件类型,必须先删除再重新创建。
```bash
# 第 1 步:列出仪表盘,定位到当前仪表盘
lark-cli base +dashboard-list --base-token xxx
# 第 2 步:列出组件,获取到目标组件
lark-cli base +dashboard-block-list --base-token xxx --dashboard-id blk_xxx
# 获取目标 block_id
# 提示:查看已有组件可作为参考,或检查是否重复创建相似组件
# 第 3 步:获取组件当前详情
lark-cli base +dashboard-block-get --base-token xxx --dashboard-id blk_xxx --block-id chtxxxxxxxx
# 第 4 步:根据用户编辑诉求准备更新
# 如果编辑诉求涉及数据源变更,需要先获取数据源信息
lark-cli base +table-list --base-token xxx # 先拿表名/table_id
lark-cli base +field-list-batch --base-token xxx --table-id tbl_a --table-id tbl_b # 再一次批量拿相关表字段
# 第 5 步:执行更新
# 重要:先读取当前 block 的 name/type/data_config
# 再阅读 dashboard-block-data-config.md 了解 data_config 更新规则
lark-cli base +dashboard-block-update \
--base-token xxx \
--dashboard-id blk_xxx \
--block-id chtxxxxxxxx \
--data-config '{...}'
```
### 场景 4重排仪表盘布局
当用户明确要求对已有仪表盘进行布局重排或美化时使用。
> [!CAUTION]
> - 排列结果是**服务端智能推荐**,不一定完全符合用户预期
> - 无法指定具体位置(如"第一排放 A第二排放 B"),排列逻辑是**自适应**的
> - **不建议**在已有仪表盘上自动调用,除非用户明确要求
```bash
# 第 1 步:列出仪表盘,定位到目标仪表盘
lark-cli base +dashboard-list --base-token xxx
# 第 2 步:执行智能重排
lark-cli base +dashboard-arrange \
--base-token xxx \
--dashboard-id blk_xxx
```
### 场景 5读取仪表盘或组件现状
**选择查询方式:**
- 想看仪表盘整体结构(含主题、所有组件名称和类型)→ 用 **方式 A**
- 只想快速查看有哪些组件 → 用 **方式 B**
- 想看某个组件的详细 data_config 配置 → 用 **方式 C**
- 想看某个图表/指标卡实际算出来的数据 → 用 **方式 D**
```bash
# 第 1 步:列出仪表盘,定位到当前仪表盘
lark-cli base +dashboard-list --base-token xxx
# 第 2 步:根据用户诉求查看详情
# 方式 A查看仪表盘整体情况包含所有组件列表
lark-cli base +dashboard-get --base-token xxx --dashboard-id blk_xxx
# 方式 B列出所有组件
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
# 最后:把获取到的现状信息整理好告诉用户
```
## 组件类型选择
组件 `type` 决定展示形式:
| 用户想看什么 | 选什么 type | 说明 |
|-------------|------------|------|
| 数据趋势(时间变化) | line | 折线图组件 |
| 类别比较(谁高谁低) | column | 柱状图组件 |
| 占比分布(各部分比例) | pie | 饼图组件 |
| 单个关键指标 | statistics | 指标卡组件 |
| 富文本说明/标题/注释 | text | 文本组件(支持 Markdown |
详细组件类型和 data_config 完整规则:[dashboard-block-data-config.md](dashboard-block-data-config.md)
## 常见问题
**Q: 创建组件的命令和 data_config 怎么写?**
A:
1. 先确定 `dashboard_id`、组件 `name`、组件 `type` 和真实表字段
2. 再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 了解:
- 全部组件类型的可复制模板
- filter 筛选条件格式
- 字段类型与操作符对应表
**Q: 为什么组件创建失败了?**
A: 常见原因:
- `table_name` 用了 table_id 而不是表名(必须用表名称,如「订单表」)
- `series``count_all` 同时存在(必须二选一,互斥)
- 字段名拼写错误(必须用 `+field-list` 获取的真实字段名,禁止猜测)
- 组件创建并发执行(必须串行,等上一个完成再执行下一个)
**Q: 可以一次创建多个组件吗?**
A: 不可以,必须串行执行。等上一个 `+dashboard-block-create` 完成后再执行下一个。
**Q: 组件的 `type` 创建后能改吗?**
A: 不能。`+dashboard-block-update` 只能修改 `name``data_config`,不能修改 `type`
**Q: 更新组件的命令和 data_config 怎么写?**
A:
1. 先读取当前 block确认 `block_id`、当前 `type` 和已有 `data_config`
2. 再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 了解 data_config 结构
**data_config 更新策略(顶层 key merge**
- 只传入需要修改的顶层字段(如 `series``filter`
- 未传的顶层字段(如 `group_by`)自动保留原值
- 但每个传入的字段内部是**全量替换**(如传新 `filter` 会完整覆盖旧 `filter`
**Q: 查看已有组件有什么用?**
A: 在「添加新组件」或「编辑组件」前查看已有组件可以:
- 了解当前仪表盘已有哪些可视化
- 避免重复创建相似的组件
- 参考已有组件的 data_config 结构作为模板
**Q: 我想直接拿图表算好的结果给 AI 分析,应该用什么?**
A: 用 `+dashboard-block-get-data`。它返回图表协议 JSON常见字段包括 `dimensions``measures``main_data`,指标卡可能还有 `comparison_data``trend_data`),不返回 block 名称、类型、布局或 `data_config`;需要这些元数据时先用 `+dashboard-block-get`
## 写入前检查
- 创建 block 前必须知道 `base_token``dashboard_id`、组件 `name/type``data_config`
- 更新 block 前必须知道 `base_token``dashboard_id``block_id`,并读过当前 block。
- `data_config` 中使用表名和字段名,不使用 table_id / field_id名称必须来自 `+table-list` / `+field-list` 的真实返回。

View File

@@ -21,6 +21,14 @@ Dashboard 是 Base 中的数据可视化看板,可以把表格数据变成**
| 读取图表计算结果 | `+dashboard-block-get-data` | 返回图表最终数据协议;需要 block 元数据先用 `+dashboard-block-get` |
| 智能重排组件布局 | `+dashboard-arrange` | 只在用户明确要求重排时执行;无法指定精确位置 |
## 执行要点
- 创建/改图前先用 `+table-list` 拿表,再用 `+field-list-batch --table-id <表1> --table-id <表2>` 一次取多表字段,不要逐表多次 `+field-list`,多余调用会显著抬高 token。
- 布局/重排/撑满/排列美观直接用 `+dashboard-arrange`,不要尝试用 `+dashboard-block-update` 修改 layoutlayout 不是 `data_config`
- block 换图表类型或换数据源表(`table_name`)时,删除旧 block 后用 `+dashboard-block-create` 新建;`+dashboard-block-update` 只适合同一数据源内改 `series/filter/group_by/name`
- 删除具名图表:`+dashboard-list``+dashboard-block-list` 精确匹配名称 → `+dashboard-block-delete`;长 `block_id` 用变量传参,避免手抄截断。
- 完整 dashboard 用例(从需求到逐组件落地)按需读 [lark-base-dashboard-usecase.md](lark-base-dashboard-usecase.md)。
## 典型场景工作流
### 场景 1从 0 到 1 创建仪表盘

View File

@@ -397,6 +397,7 @@
默认值 / 约束:
- `style.rules` 是规则数组,数量 `1..9`
- `+field-update` 修改编号规则时,**默认会把新规则应用到已有记录**
- 默认规则:
```json

View File

@@ -1,46 +1,130 @@
# Workflow guide
本文档是 Workflow 的入口指南,帮助选择步骤组合、理解创建/更新边界,并引导到 steps JSON SSOT
本文档是 Workflow 的操作地图:先用它决定最短路径,再按需打开 schema 小文件。Guide 要一次读完后能完成大多数查询、启停和常见创建/修改schema 才是零件手册
> **配套文档**:
> - Workflow 的数据结构参考:[lark-base-workflow-schema.md](lark-base-workflow-schema.md)
> - 创建/更新时重点构造 `title`、`status` 和 `steps`;复杂度集中在 `steps[].type/data/next`
## 先判断任务类型
---
| 目标 | 最短路径 | 是否读 schema |
|---|---|---|
| 列出 workflow | `+workflow-list --base-token <base>`;需要筛选启停状态时用 `--status` | 不读 |
| 查看一个 workflow | 先 `+workflow-list` 后按标题本地匹配 `workflow_id`,再 `+workflow-get --workflow-id <wkf>` | 不读,除非要解释完整 `steps` |
| 启用/停用 workflow | `+workflow-list --status <enabled|disabled>` 定位,再 `+workflow-enable/disable` | 不读 |
| 创建简单 workflow | 读本 guide按下方场景表打开必要 step schema | 只读命中的 step |
| 修改 workflow | `+workflow-get` 取现状,保留无关字段,只改目标 step复杂 step 再读 schema | 只读被改的 step |
| 解释复杂 `steps` | 先用本 guide 的结构速记理解连线,再按 step type 打开 schema | 按需读 |
## 快速开始
不要默认看 `--help`。只有命令报错、参数名不确定、或要确认复杂写入参数时,才看当前命令的 help。
### 最简单的 Workflow
## 资源发现顺序
新增记录时发送消息通知:
1. 从用户链接提取 `base_token`
2. 需要知道文档内资源时用 `+base-block-list``+table-list`;不要两者都跑,除非一个结果不够。
3. 字段发现默认用 `+field-list --compact`只有需要公式、lookup 或完整字段配置时再 `+field-get`
4. 多表字段发现用 `+field-list-batch --compact --table-id <id1> --table-id <id2>`
5. workflow 定位用 `+workflow-list` 读取列表,再按 `title` 本地匹配;当前命令没有 `--title` flag。
## Workflow 结构速记
```json
{
"client_token": "1704067200",
"title": "新订单自动通知",
"client_token": "unique-create-token",
"title": "工作流标题",
"steps": [
{
"id": "trigger_1",
"id": "step_trigger",
"type": "AddRecordTrigger",
"title": "监控新订单",
"next": "action_1",
"title": "触发器",
"next": "step_action",
"data": {}
},
{
"id": "step_action",
"type": "LarkMessageAction",
"title": "动作",
"next": null,
"data": {}
}
]
}
```
- `id` 要稳定、可读,被 `next``children.links[].to` 引用。
- 普通 trigger/action 用 `next` 串联;最后一个节点 `next:null`
- `IfElseBranch` / `SwitchBranch` / `Loop``children.links` 表达分支或循环入口。
- Action 节点不要设置 `children`
- `ref` 引用前置 step 的输出,字段下钻通常是 `$.{stepId}.{fieldId}`;循环内当前项常用 `$.{loopStepId}.item.{fieldId}`
- `+workflow-create` 需要唯一 `client_token`;新 workflow 创建后默认 disabled用户需要启用时再调用 `+workflow-enable`
- `+workflow-update` 是完整替换;从 `+workflow-get` 返回中保留不想改的 `title/status/steps`
## Step 选型
创建/修改前先产出一个草图:列出全部节点 `id/type/next/children`,把会用到的 `type` 去重后,再一次性读取对应的 step md 文档。不要“读一个 step、想一轮、再读下一个 step”这会增加轮次和上下文重放。
| 用户说法 | 选型 |
|---|---|
| 新增记录时 | `AddRecordTrigger` |
| 记录被修改时 | `SetRecordTrigger` |
| 新增或修改都触发、或拿不准 | `ChangeRecordTrigger` |
| 每天/每周/每月/固定时间 | `TimerTrigger` |
| 日期字段到期提醒 | `ReminderTrigger` |
| 点击按钮 | `ButtonTrigger` |
| 收到群消息/私聊消息 | `LarkMessageTrigger` |
| 新增一条记录 | `AddRecordAction` |
| 更新当前或查找到的记录 | `SetRecordAction` |
| 查找多条记录再处理 | `FindRecordAction`,多条时接 `Loop` |
| 分两路判断 | `IfElseBranch` |
| 多档位/多类别判断 | `SwitchBranch` |
| 发送飞书消息 | `LarkMessageAction` |
| 调外部接口 | `HTTPClientAction` |
| 等待一段时间 | `Delay` |
| AI 生成文本 | `GenerateAiTextAction` |
用户描述"修改为 X **或** 新增 X 时"这类同条件多来源需求,是单个 `ChangeRecordTrigger` + `condition_list` 的典型场景,一条工作流即可表达,不要拆成 `AddRecordTrigger``SetRecordTrigger` 两条工作流。
## 常见场景
| 场景 | 推荐步骤 | 需要读的 schema |
|---|---|---|
| 新增记录后发通知 | `AddRecordTrigger -> LarkMessageAction` | `trigger-add-record.md`, `action-lark-message.md` |
| 记录变化后更新同一行字段 | `ChangeRecordTrigger -> SetRecordAction` | `trigger-change-record.md`, `action-set-record.md`; 条件复杂再读 common refs |
| 金额/状态分档处理 | `AddRecordTrigger -> SwitchBranch -> SetRecordAction...` | `trigger-add-record.md`, `branch-switch.md`, `action-set-record.md`, common conditions |
| 二选一判断 | `... -> IfElseBranch -> ...` | `branch-if-else.md`, common conditions |
| 定时汇总并逐人通知 | `TimerTrigger -> FindRecordAction -> Loop -> LarkMessageAction` | `trigger-timer.md`, `action-find-record.md`, `system-loop.md`, `action-lark-message.md`, common refs |
| 群消息触发后回复 | `LarkMessageTrigger -> FindRecordAction/Loop -> LarkMessageAction` | `trigger-lark-message.md`, `action-find-record.md`, `system-loop.md`, `action-lark-message.md` |
| 按钮触发外部系统 | `ButtonTrigger -> HTTPClientAction -> AddRecordAction` | `trigger-button.md`, `action-http-client.md`, `action-add-record.md` |
| 调用 AI 生成内容并写回 | `... -> GenerateAiTextAction -> SetRecordAction` | `action-generate-ai-text.md`, `action-set-record.md`, common refs |
Schema 入口:[lark-base-workflow-schema.md](lark-base-workflow-schema.md)。不要一次性打开所有 step 文件;先确定本次 workflow 的完整 step type 集合,再一次性打开这些文件。只有确定会写 `ref`、条件、字段值或节点输出引用时,才把 `common-types-and-refs.md` 加入同一批读取。
## 最小例子:新增记录后发送消息
只读 `trigger-add-record.md``action-lark-message.md` 即可。
```json
{
"client_token": "wf-unique-token",
"title": "新订单通知",
"steps": [
{
"id": "trig_new_order",
"type": "AddRecordTrigger",
"title": "新增订单时",
"next": "act_notify",
"data": {
"table_name": "订单表",
"watched_field_name": "订单号"
}
},
{
"id": "action_1",
"id": "act_notify",
"type": "LarkMessageAction",
"title": "发送通知",
"next": null,
"data": {
"receiver": [{ "value_type": "user", "value": {"id": "ou_xxxx", "name": "张三"} }],
"receiver": [{ "value_type": "user", "value": { "id": "ou_xxx" } }],
"send_to_everyone": false,
"title": [{ "value_type": "text", "value": "新订单提醒" }],
"content": [
{ "value_type": "text", "value": "收到新订单" }
],
"content": [{ "value_type": "text", "value": "收到新订单" }],
"btn_list": []
}
}
@@ -48,783 +132,27 @@
}
```
---
## 修改现有 workflow
## 场景速查表
1. `+workflow-list` 后按标题定位 `workflow_id`
2. `+workflow-get --workflow-id <wkf>` 获取完整定义。
3. 只修改目标 step保留其他 steps 的 `id/type/title/data/next/children`
4.`+workflow-update` 提交完整定义。
5. 若只启停,不走 update直接 `+workflow-enable/disable`
| 场景 | 步骤组合 | 示例 |
|------|---------|------|
| 新增触发+通知 | AddRecordTrigger → LarkMessageAction | [下方](#示例1-新增记录触发--发送消息) |
| 按钮点击+调用外部接口+写入日志 | ButtonTrigger → HTTPClientAction → AddRecordAction | [下方](#示例-6-按钮触发--调用外部接口--写入同步日志) |
| 定时+循环 | TimerTrigger → FindRecordAction → Loop → LarkMessageAction | [下方](#示例2-定时触发--查找记录--循环遍历--发送消息) |
| 条件判断 | ... → IfElseBranch → 分支处理 | [下方](#示例3-条件分支-ifelsebranch) |
| 多路分类 | ... → SwitchBranch → 多分支处理 | [下方](#示例4-多路分支-switchbranch) |
| 复杂组合 | 定时+查找+循环+分支+消息 | [下方](#示例5-组合场景-定时查找循环分支消息) |
## 常见错误
---
## 完整示例
### 示例 1: 新增记录触发 + 发送消息
**场景**: 当订单表新增记录时,发送飞书消息通知负责人。
```json
{
"client_token": "1704067201",
"title": "新订单自动通知",
"steps": [
{
"id": "step_trigger",
"type": "AddRecordTrigger",
"title": "新增订单时触发",
"next": "step_notify",
"data": {
"table_name": "订单表",
"watched_field_name": "订单号",
"condition_list": null
}
},
{
"id": "step_notify",
"type": "LarkMessageAction",
"title": "发送订单通知",
"next": null,
"data": {
"receiver": [{ "value_type": "ref", "value": "$.step_trigger.fldManager" }],
"send_to_everyone": false,
"title": [{ "value_type": "text", "value": "新订单提醒" }],
"content": [
{ "value_type": "text", "value": "客户 " },
{ "value_type": "ref", "value": "$.step_trigger.fldCustomer" },
{ "value_type": "text", "value": " 创建了新订单,金额:¥" },
{ "value_type": "ref", "value": "$.step_trigger.fldAmount" }
],
"btn_list": [
{
"text": "查看订单",
"btn_action": "openLink",
"link": [{ "value_type": "ref", "value": "$.step_trigger.recordLink" }]
}
]
}
}
]
}
```
**关键点**:
- `AddRecordTrigger` 监控 `table_name` 表的 `watched_field_name` 字段
- 使用 `ref` 引用触发器输出的字段值(注意是 fieldId不是字段名
- `recordLink` 是触发器内置输出,表示记录链接
---
### 示例 2: 定时触发 + 查找记录 + 循环遍历 + 发送消息
**场景**: 每天早上 9 点,查找所有待处理订单,给每个客户发送提醒。
```json
{
"client_token": "1704067202",
"title": "每日待处理订单提醒",
"steps": [
{
"id": "step_timer",
"type": "TimerTrigger",
"title": "每天早上9点触发",
"next": "step_find_orders",
"data": {
"rule": "DAILY",
"start_time": "2025-01-01 09:00",
"is_never_end": true
}
},
{
"id": "step_find_orders",
"type": "FindRecordAction",
"title": "查找所有待处理订单",
"next": "step_loop_customers",
"data": {
"table_name": "订单表",
"field_names": ["客户名称", "订单金额", "客户联系方式"],
"should_proceed_when_no_results": false,
"filter_info": {
"conjunction": "and",
"conditions": [
{
"field_name": "状态",
"operator": "is",
"value": [{ "value_type": "option", "value": { "name": "待处理" } }]
}
]
}
}
},
{
"id": "step_loop_customers",
"type": "Loop",
"title": "遍历每个订单",
"children": {
"links": [
{ "kind": "loop_start", "to": "step_send_reminder" }
]
},
"next": null,
"data": {
"loop_mode": "continue",
"max_loop_times": 100,
"data": [{
"value_type": "ref",
"value": "$.step_find_orders.fieldRecords"
}]
}
},
{
"id": "step_send_reminder",
"type": "LarkMessageAction",
"title": "发送催办消息",
"next": null,
"data": {
"receiver": [{
"value_type": "ref",
"value": "$.step_loop_customers.item.fldContact"
}],
"send_to_everyone": false,
"title": [{ "value_type": "text", "value": "订单处理提醒" }],
"content": [
{ "value_type": "text", "value": "您好,您的订单 " },
{ "value_type": "ref", "value": "$.step_loop_customers.item.fldName" },
{ "value_type": "text", "value": " 金额 ¥" },
{ "value_type": "ref", "value": "$.step_loop_customers.item.fldAmount" },
{ "value_type": "text", "value": " 正在处理中。" }
],
"btn_list": []
}
}
]
}
```
**关键点**:
- `Loop.data` 必须传入 `ref` 类型的数据源(通常是 FindRecordAction 的 `fieldRecords`
- `Loop.children.links` 必须包含 `kind: "loop_start"` 的链接指向循环体
- 循环体内用 `$.{loopStepId}.item.{fieldId}` 引用当前遍历记录的字段
- `$.{loopStepId}.index` 获取当前索引(从 0 开始)
---
### 示例 3: 条件分支IfElseBranch
**场景**: 根据订单金额判断,大额订单通知主管审批,小额订单自动通过。
```json
{
"client_token": "1704067203",
"title": "订单金额自动判断",
"steps": [
{
"id": "step_trigger",
"type": "AddRecordTrigger",
"title": "新增订单时触发",
"next": "step_check_amount",
"data": {
"table_name": "订单表",
"watched_field_name": "订单金额"
}
},
{
"id": "step_check_amount",
"type": "IfElseBranch",
"title": "判断是否为大额订单",
"children": {
"links": [
{ "kind": "if_true", "to": "step_notify_manager", "label": "high", "desc": "金额>=10000" },
{ "kind": "if_false", "to": "step_auto_approve", "label": "normal", "desc": "金额<10000" }
]
},
"next": "step_log",
"data": {
"condition": {
"conjunction": "or",
"conditions": [
{
"conjunction": "and",
"conditions": [
{
"left_value": { "value_type": "ref", "value": "$.step_trigger.fldAmount" },
"operator": "isGreaterEqual",
"right_value": [{ "value_type": "number", "value": 10000 }]
}
]
}
]
}
}
},
{
"id": "step_notify_manager",
"type": "LarkMessageAction",
"title": "通知主管审批大额订单",
"next": "step_log",
"data": {
"receiver": [{ "value_type": "user", "value": {"id": "ou_manager", "name": "主管"} }],
"send_to_everyone": false,
"title": [{ "value_type": "text", "value": "大额订单待审批" }],
"content": [
{ "value_type": "text", "value": "有大额订单 ¥" },
{ "value_type": "ref", "value": "$.step_trigger.fldAmount" },
{ "value_type": "text", "value": " 需要您审批" }
],
"btn_list": []
}
},
{
"id": "step_auto_approve",
"type": "SetRecordAction",
"title": "自动标记小额订单为已审核",
"next": "step_log",
"data": {
"table_name": "订单表",
"ref_info": { "step_id": "step_trigger" },
"field_values": [
{
"field_name": "审批状态",
"value": [{ "value_type": "option", "value": { "name": "已自动审核" } }]
}
]
}
},
{
"id": "step_log",
"type": "GenerateAiTextAction",
"title": "生成订单处理日志",
"next": null,
"data": {
"prompt": [
{ "value_type": "text", "value": "请生成订单处理日志,金额:" },
{ "value_type": "ref", "value": "$.step_trigger.fldAmount" }
]
}
}
]
}
```
**关键点**:
- `IfElseBranch.children.links` 必须包含 `if_true``if_false` 两个分支
- `next` 指向两个分支汇合后的步骤(可选,为 null 则分支结束)
- `condition` 使用 OrGroup 结构,支持 `(A and B) or (C and D)` 的复杂条件
- 分支内可以用 `ref_info` 引用触发记录,用 `filter_info` 批量筛选记录
---
### 示例 4: 多路分支SwitchBranch
**场景**: 根据订单优先级P0/P1/P2执行不同的处理流程。
```json
{
"client_token": "1704067204",
"title": "按优先级分类处理订单",
"steps": [
{
"id": "step_trigger",
"type": "AddRecordTrigger",
"title": "新增订单时触发",
"next": "step_classify",
"data": {
"table_name": "订单表",
"watched_field_name": "优先级"
}
},
{
"id": "step_classify",
"type": "SwitchBranch",
"title": "按优先级分类",
"children": {
"links": [
{ "kind": "case", "to": "step_p0_handler", "label": "p0", "desc": "P0-紧急" },
{ "kind": "case", "to": "step_p1_handler", "label": "p1", "desc": "P1-高优先级" },
{ "kind": "case", "to": "step_p2_handler", "label": "p2", "desc": "P2-普通" },
{ "kind": "case", "to": "step_other_handler", "label": "other", "desc": "其他" }
]
},
"next": null,
"data": {
"mode": "exclusive",
"no_match_action": "classifyToOther",
"child_branch_list": [
{
"name": "P0-紧急",
"condition": {
"conjunction": "or",
"conditions": [
{
"conjunction": "and",
"conditions": [
{
"left_value": { "value_type": "ref", "value": "$.step_trigger.fldPriority" },
"operator": "is",
"right_value": [{ "value_type": "option", "value": { "name": "P0" } }]
}
]
}
]
}
},
{
"name": "P1-高优先级",
"condition": {
"conjunction": "or",
"conditions": [
{
"conjunction": "and",
"conditions": [
{
"left_value": { "value_type": "ref", "value": "$.step_trigger.fldPriority" },
"operator": "is",
"right_value": [{ "value_type": "option", "value": { "name": "P1" } }]
}
]
}
]
}
},
{
"name": "P2-普通",
"condition": {
"conjunction": "or",
"conditions": [
{
"conjunction": "and",
"conditions": [
{
"left_value": { "value_type": "ref", "value": "$.step_trigger.fldPriority" },
"operator": "is",
"right_value": [{ "value_type": "option", "value": { "name": "P2" } }]
}
]
}
]
}
}
]
}
},
{
"id": "step_p0_handler",
"type": "LarkMessageAction",
"title": "P0紧急处理",
"next": null,
"data": {
"receiver": [{ "value_type": "user", "value": {"id": "ou_director", "name": "总监"} }],
"send_to_everyone": false,
"title": [{ "value_type": "text", "value": "🚨 P0 紧急订单" }],
"content": [{ "value_type": "text", "value": "有新的 P0 紧急订单需要立即处理" }],
"btn_list": []
}
},
{
"id": "step_p1_handler",
"type": "SetRecordAction",
"title": "标记高优先级",
"next": null,
"data": {
"table_name": "订单表",
"ref_info": { "step_id": "step_trigger" },
"field_values": [
{ "field_name": "处理状态", "value": [{ "value_type": "text", "value": "高优先级待处理" }] }
]
}
},
{
"id": "step_p2_handler",
"type": "Delay",
"title": "普通订单延迟处理",
"next": null,
"data": { "duration": 60 }
},
{
"id": "step_other_handler",
"type": "SetRecordAction",
"title": "标记其他订单",
"next": null,
"data": {
"table_name": "订单表",
"ref_info": { "step_id": "step_trigger" },
"field_values": [
{ "field_name": "处理状态", "value": [{ "value_type": "text", "value": "待分类" }] }
]
}
}
]
}
```
**关键点**:
- `SwitchBranch` 适合 3 路及以上的分支场景(少于 3 路用 `IfElseBranch` 更简洁)
- `children.links``kind: "case"``label` 对应 `child_branch_list` 中的条件
- `mode: "exclusive"` 表示排他执行(第一个匹配的分支执行后停止)
- `no_match_action: "classifyToOther"` 表示无匹配时走最后一个 `case`(兜底分支)
---
### 示例 5: 组合场景(定时+查找+循环+分支+消息)
**场景**: 每天早上 9 点,查找昨天的订单,按金额分级,给不同级别的销售发送不同的通知。
```json
{
"client_token": "1704067205",
"title": "每日订单分级通知",
"steps": [
{
"id": "step_timer",
"type": "TimerTrigger",
"title": "每天早上9点触发",
"next": "step_find_orders",
"data": {
"rule": "DAILY",
"start_time": "2025-01-01 09:00",
"is_never_end": true
}
},
{
"id": "step_find_orders",
"type": "FindRecordAction",
"title": "查找昨天所有订单",
"next": "step_loop",
"data": {
"table_name": "订单表",
"field_names": ["订单号", "客户名称", "金额", "销售负责人"],
"should_proceed_when_no_results": false,
"filter_info": {
"conjunction": "and",
"conditions": [
{ "field_name": "创建时间", "operator": "isGreaterEqual", "value": [{ "value_type": "date", "value": "yesterday" }] }
]
}
}
},
{
"id": "step_loop",
"type": "Loop",
"title": "遍历每个订单",
"children": {
"links": [
{ "kind": "loop_start", "to": "step_classify" }
]
},
"next": "step_summary",
"data": {
"loop_mode": "continue",
"max_loop_times": 500,
"data": [{ "value_type": "ref", "value": "$.step_find_orders.fieldRecords" }]
}
},
{
"id": "step_classify",
"type": "SwitchBranch",
"title": "按金额分类",
"children": {
"links": [
{ "kind": "case", "to": "step_vip_notify", "label": "vip", "desc": "VIP >= 10万" },
{ "kind": "case", "to": "step_normal_notify", "label": "normal", "desc": "普通 < 10万" }
]
},
"next": null,
"data": {
"mode": "exclusive",
"no_match_action": "fail",
"child_branch_list": [
{
"name": "VIP订单",
"condition": {
"conjunction": "or",
"conditions": [
{
"conjunction": "and",
"conditions": [
{
"left_value": { "value_type": "ref", "value": "$.step_loop.item.fldAmount" },
"operator": "isGreaterEqual",
"right_value": [{ "value_type": "number", "value": 100000 }]
}
]
}
]
}
},
{
"name": "普通订单",
"condition": {
"conjunction": "or",
"conditions": [
{
"conjunction": "and",
"conditions": [
{
"left_value": { "value_type": "ref", "value": "$.step_loop.item.fldAmount" },
"operator": "isLess",
"right_value": [{ "value_type": "number", "value": 100000 }]
}
]
}
]
}
}
]
}
},
{
"id": "step_vip_notify",
"type": "LarkMessageAction",
"title": "VIP订单通知",
"next": null,
"data": {
"receiver": [{ "value_type": "ref", "value": "$.step_loop.item.fldSales" }],
"send_to_everyone": false,
"title": [{ "value_type": "text", "value": "🌟 VIP大额订单" }],
"content": [
{ "value_type": "text", "value": "恭喜!您有一笔 VIP 订单 ¥" },
{ "value_type": "ref", "value": "$.step_loop.item.fldAmount" },
{ "value_type": "text", "value": ",客户:" },
{ "value_type": "ref", "value": "$.step_loop.item.fldCustomer" }
],
"btn_list": []
}
},
{
"id": "step_normal_notify",
"type": "LarkMessageAction",
"title": "普通订单通知",
"next": null,
"data": {
"receiver": [{ "value_type": "ref", "value": "$.step_loop.item.fldSales" }],
"send_to_everyone": false,
"title": [{ "value_type": "text", "value": "新订单通知" }],
"content": [
{ "value_type": "text", "value": "您有一笔新订单 ¥" },
{ "value_type": "ref", "value": "$.step_loop.item.fldAmount" }
],
"btn_list": []
}
},
{
"id": "step_summary",
"type": "GenerateAiTextAction",
"title": "生成日报",
"next": null,
"data": {
"prompt": [
{ "value_type": "text", "value": "请生成昨日订单处理日报" }
]
}
}
]
}
```
---
### 示例 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 构造要点
1. **数据源**: `Loop.data` 必须传入 `ref` 类型,通常是 `FindRecordAction``fieldRecords`
2. **循环体**: `children.links` 必须包含 `kind: "loop_start"` 指向循环体入口
3. **引用**: 循环体内用 `$.{loopStepId}.item.{fieldId}` 引用当前元素
4. **索引**: 用 `$.{loopStepId}.index` 获取当前索引(从 0 开始)
### 分支构造要点
1. **IfElseBranch**:
- 适合二元判断(是/否、大于/小于)
- `children.links` 必须包含 `if_true``if_false`
- 可以用 `next` 指向汇合点
2. **SwitchBranch**:
- 适合多路分类3路及以上
- `label` 对应 `child_branch_list` 中的条件顺序
- 建议加一个兜底分支(其他)
### 字段值构造
| 字段类型 | value_type | 示例 |
|---------|------------|------|
| 文本 | `text` | `{"value_type": "text", "value": "张三"}` |
| 数字 | `number` | `{"value_type": "number", "value": 100}` |
| 单选 | `option` | `{"value_type": "option", "value": {"name": "已完成"}}` |
| 人员 | `user` | `{"value_type": "user", "value": {"id": "ou_xxxx"}}` |
| 引用 | `ref` | `{"value_type": "ref", "value": "$.step_1.fldxxx"}` |
---
## 常见错误避免
### Top 10 高频错误
| # | 错误信息 | 原因 | 解决方案 |
|---|---------|------|---------|
| 1 | `path "xxx" does not exist in the output path tree` | ref 引用路径错误或 stepId 不存在 | 检查 stepId 是否在 steps 数组中;使用 fieldId 而非字段名;确保路径以 `$.` 开头 |
| 2 | `recordInfo.conditions must be non-empty` | `condition_list` 为空数组 `[]` | 改用 `null` 或省略该字段 |
| 3 | `At least one of filter info and ref info is required` | SetRecordAction/FindRecordAction 缺少定位条件 | 必须提供 `filter_info``ref_info` 之一 |
| 4 | `client token is empty` | 缺少 `client_token` | 每次请求传入唯一值(时间戳或随机字符串) |
| 5 | `valueType 'text' not allowed for fieldType '3'` | select 类型字段值格式错误 | 改用 `option` 类型 |
| 6 | `Undefined Step Type` | 使用了不支持的 StepType | 使用 `AddRecordTrigger` 而非 `CreateRecordTrigger` |
| 7 | `prompt references an unknown reference from step` | 引用的 stepId 不存在 | 确保引用的 step 在同一 workflow 的 steps 数组中 |
| 8 | `[2200] Internal Error` | 1. steps[].id 重复 2. next/children.links 引用了不存在的 step | 确保所有 step id 唯一;检查引用关系 |
| 9 | 工作流结构不完整 | Branch/Loop 节点缺少 `children` | 仅 BranchIfElseBranch/SwitchBranch和 Loop 节点需要 `children`Trigger/Action 节点无需设置 |
| 10 | 嵌套分支过于复杂 | 多层 IfElseBranch 嵌套 | 3+ 路分支用 SwitchBranch 替代嵌套 IfElseBranch |
### 其他常见错误
**1. condition_list 为空数组**
```json
// ❌ 错误
{ "condition_list": [] }
// ✅ 正确
{ "condition_list": null }
// 或省略该字段
```
**2. filter_info 和 ref_info 同时提供**
```json
// ❌ 错误
{ "filter_info": {...}, "ref_info": {...} }
// ✅ 正确(二选一)
{ "filter_info": {...}, "ref_info": null }
{ "filter_info": null, "ref_info": {...} }
```
**3. 使用字段名而非 fieldId**
```json
// ❌ 错误
{ "value": "$.step_1.客户名称" }
// ✅ 正确
{ "value": "$.step_1.fldXXXXXXXX" }
```
---
| 错误 | 处理 |
|---|---|
| 查询/启停也读 schema | 停下,直接用 `+workflow-list/get/enable/disable` |
| 为多个可能命令批量看 help | 只看当前报错或即将执行的一个命令 |
| 把字段名当 field ID 写入 ref | 先 `+field-list --compact`ref 下钻优先用 field ID |
| 分支/循环没有 `children.links` | 按 branch/loop schema 补 `if_true/if_false/case/loop_start` |
| SetRecordAction/FindRecordAction 缺定位条件 | 提供 `filter_info``ref_info` |
| HTTPClientAction 后续节点引用不到字段 | `response_type: "json"` 时填写 `response_value` 声明输出字段 |
| Loop 内引用错路径 | 用 `$.{loopStepId}.item.{fieldId}``$.{loopStepId}.index` |
## 参考
- [lark-base-workflow-schema.md](lark-base-workflow-schema.md) — 字段定义参考
- 创建/更新前先确认真实表名、字段名和目标 workflow ID`steps` 结构按 schema 构造,不凭自然语言猜 `type`
- [lark-base-workflow-schema.md](lark-base-workflow-schema.md)step type 路由和基础结构。
- [workflow-steps/common-types-and-refs.md](workflow-steps/common-types-and-refs.md)ValueInfo、ref、Condition、节点输出只有构造这些细节时才读。

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
# AddRecordAction
```json
{
"table_name": "订单表",
"field_values": [
{ "field_name": "客户名称", "value": [{ "value_type": "text", "value": "张三" }] },
{ "field_name": "金额", "value": [{ "value_type": "number", "value": 100 }] },
{ "field_name": "创建人", "value": [{ "value_type": "ref", "value": "$.trigger_1.fieldIdxxx" }] }
]
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `table_name` | 是 | 目标数据表名 |
| `field_values` | 是 | RecordFieldValue[] |
---
## 相关
- 返回 [Workflow schema index](../lark-base-workflow-schema.md)
- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md)

View File

@@ -0,0 +1,16 @@
# Delay
```json
{ "duration": 30 }
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `duration` | 是 | 延迟时长(分钟),范围 [1, 120] |
---
## 相关
- 返回 [Workflow schema index](../lark-base-workflow-schema.md)
- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md)

View File

@@ -0,0 +1,25 @@
# FindRecordAction
```json
{
"table_name": "客户表",
"field_names": ["客户名称", "联系方式", "等级"],
"should_proceed_when_no_results": true,
"filter_info": { /* RecordFilterInfo */ }
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `table_name` | 是 | 目标数据表名 |
| `field_names` | 是 | 要检索的字段名列表,至少一个 |
| `should_proceed_when_no_results` | 否 | 无结果时是否继续后续步骤,默认 `true` |
| `filter_info` | 否* | RecordFilterInfo`ref_info` 互斥) |
| `ref_info` | 否* | RefInfo`filter_info` 互斥) |
---
## 相关
- 返回 [Workflow schema index](../lark-base-workflow-schema.md)
- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md)

View File

@@ -0,0 +1,21 @@
# GenerateAiTextAction
```json
{
"prompt": [
{ "value_type": "text", "value": "请总结以下内容:" },
{ "value_type": "ref", "value": "$.step_1.fieldxxx" }
]
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `prompt` | 是 | TextRefItem[] 提示词,支持 `text` / `ref` |
---
## 相关
- 返回 [Workflow schema index](../lark-base-workflow-schema.md)
- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md)

View File

@@ -0,0 +1,48 @@
# 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` |
---
## 相关
- 返回 [Workflow schema index](../lark-base-workflow-schema.md)
- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md)

View File

@@ -0,0 +1,42 @@
# LarkMessageAction
```json
{
"receiver": [{ "value_type": "user", "value": {"id": "ou_xxxx"} }],
"send_to_everyone": false,
"title": [{ "value_type": "text", "value": "新订单通知" }],
"content": [
{ "value_type": "text", "value": "客户 " },
{ "value_type": "ref", "value": "$.trigger_1.fldCustomerName" },
{ "value_type": "text", "value": " 创建了新订单" }
],
"btn_list": [
{ "text": "查看详情", "btn_action": "openLink", "link": [{ "value_type": "text", "value": "https://example.com" }] }
]
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `receiver` | 是 | ValueInfo[] |
| `send_to_everyone` | 是 | 是否发送给所有人 |
| `title` | 否 | TextRefItem[] 消息标题 |
| `content` | 是 | TextRefItem[] 消息内容 |
| `btn_list` | 是 | 按钮列表,不需要时为空数组 |
`ButtonConfig`
| 字段 | 类型 | 说明 |
|------|------|------|
| `text` | string | 按钮文字 |
| `btn_action` | string | `addRecord` / `setRecord` / `openLink` |
| `link` | ValueInfo[] | 跳转链接(`openLink` 时使用) |
| `table_name` | string | 操作表名(`addRecord` 时使用) |
| `record_values` | RecordFieldValue[] | 记录赋值(`addRecord` / `setRecord` 时使用) |
---
## 相关
- 返回 [Workflow schema index](../lark-base-workflow-schema.md)
- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md)

View File

@@ -0,0 +1,28 @@
# SetRecordAction
```json
{
"table_name": "订单表",
"max_set_record_num": 10,
"field_values": [
{ "field_name": "状态", "value": [{ "value_type": "option", "value": { "id": "opt1", "name": "已完成" } }] }
],
"filter_info": { /* RecordFilterInfo */ },
"ref_info": { "step_id": "step_trigger" }
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `table_name` | 是 | 目标数据表名 |
| `max_set_record_num` | 否 | 最大更新记录数,默认 100范围 1-15000 |
| `field_values` | 是 | RecordFieldValue[] |
| `filter_info` | 否* | RecordFilterInfo 过滤条件(与 `ref_info` 互斥) |
| `ref_info` | 否* | RefInfo 引用前置步骤的记录(与 `filter_info` 互斥) |
---
## 相关
- 返回 [Workflow schema index](../lark-base-workflow-schema.md)
- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md)

View File

@@ -0,0 +1,36 @@
# IfElseBranch
`children.links` 包含 `if_true``if_false` 两条边,`next` 指向两个分支汇合后的后继节点。
**如果涉及到复杂的多分支场景(分支数目 >= 3时),你应该采用 SwitchBranch而不是嵌套的 IfElseBranch**
```json
{
"condition": {
"conjunction": "or",
"conditions": [
{
"conjunction": "and",
"conditions": [
{
"left_value": { "value_type": "ref", "value": "$.step_1.fieldxxx" },
"operator": "isGreater",
"right_value": [{ "value_type": "number", "value": 1000 }]
}
]
}
]
}
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `condition` | 是 | OrGroup 判断条件,结构为 `(A and B) or (C and D)` |
---
## 相关
- 返回 [Workflow schema index](../lark-base-workflow-schema.md)
- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md)

View File

@@ -0,0 +1,52 @@
# SwitchBranch
`children.links` 包含多个 `case` 边(`label` 建议用 `branch_1``branch_2`,语义写在 `desc`)。
```json
{
"mode": "exclusive",
"no_match_action": "classifyToOther",
"child_branch_list": [
{
"name": "高优先级",
"condition": {
"conjunction": "or",
"conditions": [
{
"conjunction": "and",
"conditions": [
{
"left_value": { "value_type": "ref", "value": "$.step_1.fieldxxx" },
"operator": "is",
"right_value": [{ "value_type": "text", "value": "P0" }]
}
]
}
]
}
}
]
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `mode` | 否 | 分支模式。`exclusive`:排他模式,仅执行一个满足条件的子分支;`parallel`:并行模式,执行所有满足条件的子分支。默认 `exclusive` |
| `no_match_action` | 否 | `mode=exclusive` 时使用,无匹配时的处理策略。`classifyToOther`:归类到其他分支;`fail`:报错终止。默认 `classifyToOther` |
| `fail_mode` | 否 | `mode=parallel` 时使用,部分分支出错时策略。`partialSuccess`:部分成功即继续;`fail`:任一失败即终止。默认 `partialSuccess` |
| `match_mode` | 否 | `mode=parallel` 时使用,所有分支不满足时策略。`noneMatchSkip`:跳过继续;`noneMatchFail`:报错终止。默认 `noneMatchSkip` |
| `child_branch_list` | 是 | BranchItem[]1-10 个条件分支 |
`BranchItem`
| 字段 | 类型 | 说明 |
|------|------|------|
| `name` | string | 分支名称 |
| `condition` | OrGroup | 分支条件 |
---
## 相关
- 返回 [Workflow schema index](../lark-base-workflow-schema.md)
- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md)

View File

@@ -0,0 +1,401 @@
# Workflow common types and refs
### ValueInfo
所有值的基础类型,通过 `value_type` 区分:
| value_type | value 类型 | 说明 | 示例 |
|------------|-----------|------|------|
| `text` | string | 文本 | `"张三"` |
| `number` | number | 数字 | `100` |
| `boolean` | boolean | 布尔值 | `true` |
| `date` | string | 日期,可以是具体时间字符串,或者相对时间值 | `"2025/01/01"``"2025/01/01 11:00"``"now"``"now 11:00"``"today"``"today 11:00"``"yesterday"``"yesterday 11:00"``"lastWeek"``"currentMonth"``"lastMonth"``"theLastWeek"``"theNextWeek"``"theLastMonth"``"theNextMonth"` |
| `option` | `{ id, name }` | 选项 | `{ "id": "opt1", "name": "已完成" }` |
| `link` | `{ text, link }` | 链接(含文字和 URL 文字和 URL 的格式可以是 ValueInfo 中的 text/ref 类型 | `{ "text": [{ "value_type": "text", "value": "查看详情" }], "link": [{ "value_type": "text", "value": "https://example.com" }] }``{ "text": [{ "value_type": "text", "value": "查看详情" }], "link": [{ "value_type": "ref", "value": "$.step_1.fldXXX" }] }` |
| `user` | `{ id, name }` | 用户 OpenID、名字 | `{ "id": "ou_xxxx", "name": "张三" }` |
| `group` | `{ id, name }` | 群 Chat ID、名字 | `{ "id": "oc_xxx", "name": "测试群" }` |
| `ref` | `string` | 引用前置节点输出的路径 | 参考 ref 引用变量详解 章节 |
> ⚠️ **所有涉及用户的 value 中的 id 统一使用 OpenID`ou_xxxx` 格式)**,由 CLI 层来完成转换
> ⚠️ **所有涉及群的 value 中的 id 统一使用 ChatID`oc_xxxx` 格式)**,由 CLI 层来完成转换
### ref 引用变量详解
`ref` 类型是工作流中节点间数据传递的核心机制。当 `value_type``ref` 时,`value` 指向前置节点的某个输出变量。本节详细描述每个节点可供引用的输出变量定义。
#### 引用路径格式
```
$.{stepId}
$.{stepId}.{pathId}
$.{stepId}.{pathId}.{childPathId}
$.{stepId}.{pathId}.{childPathId}.{grandChildPathId}
```
- `{stepId}`:前置节点的 `id`(即 WorkflowStep 中的 `id` 字段)
- `{pathId}`:节点输出的路径标识符
- 支持多层下钻,如引用字段的属性:`$.step_1.fldXXX.name`
---
#### 触发器节点输出
##### 记录触发器AddRecordTrigger / ChangeRecordTrigger / SetRecordTrigger / ReminderTrigger
这 4 个触发器的输出结构完全一致:
| pathId | 说明 | 引用示例 |
|--------|------|----------|
| `{fieldId}` | 字段id从配置表的所有字段或者指定字段id生成可下钻字段属性 | `$.{stepId}.{fieldId}` |
| `{fieldId}.fieldId` | 字段id属性 | `$.{stepId}.{fieldId}.fieldId` |
| `{fieldId}.fieldName` | 字段名属性 | `$.{stepId}.{fieldId}.fieldName` |
| `startTime` | 触发时间戳 | `$.{stepId}.startTime` |
| `recordId` | 记录 ID | `$.{stepId}.recordId` |
| `recordLink` | 记录链接 | `$.{stepId}.recordLink` |
| `recordCreatedUser` | 记录创建者 | `$.{stepId}.recordCreatedUser` |
| `recordCreatedTime` | 记录创建时间 | `$.{stepId}.recordCreatedTime` |
| `recordModifiedUser` | 最后修改者 | `$.{stepId}.recordModifiedUser` |
| `recordModifiedTime` | 最后修改时间 | `$.{stepId}.recordModifiedTime` |
**动态字段输出规则**
- 读取触发器所配置的数据表的所有字段
- 每个字段生成一条输出:`pathId` = fieldId
- 若字段为关联字段children 为关联表所有字段(单层下钻,不再递归)
- 每个字段可下钻特定的字段属性(见「字段属性下钻」)
**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 | 说明 | 引用示例 |
|--------|------|----------|
| `scheduleTime` | 定时触发时间 | `$.{stepId}.scheduleTime` |
##### LarkMessageTrigger飞书消息触发器
| pathId | 说明 | 引用示例 |
|--------|------|----------|
| `Sender` | 消息发送者 | `$.{stepId}.Sender` |
| `AtUser` | 消息中被@的用户 | `$.{stepId}.AtUser` |
| `SenderGroup` | 消息所在群(仅群聊场景) | `$.{stepId}.SenderGroup` |
| `MessageSendTime` | 消息发送时间 | `$.{stepId}.MessageSendTime` |
| `MessageContent` | 消息正文 | `$.{stepId}.MessageContent` |
| `MessageType` | 消息类型标识 | `$.{stepId}.MessageType` |
| `MessageID` | 消息唯一标识 | `$.{stepId}.MessageID` |
| `MessageLink` | 消息链接(仅群聊场景) | `$.{stepId}.MessageLink` |
| `ParentID` | 回复的消息 ID | `$.{stepId}.ParentID` |
| `ThreadID` | 所在话题消息 ID | `$.{stepId}.ThreadID` |
| `Attachments` | 消息中的附件 | `$.{stepId}.Attachments` |
条件限制:
- 若场景为单聊(`receive_scene = "Chat"`),则 `SenderGroup``MessageLink` 不可用
---
#### 操作节点输出
##### FindRecordAction查找记录
| pathId | 说明 | 引用示例|
|--------|------|-------|
| `fieldRecords` | 所有找到的记录的引用(可用于 Loop 遍历) | `$.{stepId}.fieldRecords`|
| `firstfieldsRecord` | 第一条匹配记录 | `$.{stepId}.firstfieldsRecord`|
| `firstfieldsRecord.{fieldId}` | 首条记录的字段值,可下钻字段属性 | `$.{stepId}.firstfieldsRecord.{fieldId}`|
| `firstfieldsRecord.recordId` | 记录 ID 数组 | `$.{stepId}.firstfieldsRecord.recordId`|
| `fields` | 查找到的所有记录某列值 | 不支持引用|
| `fields.{fieldId}` | 用户选择的字段 | `$.{stepId}.fields.{fieldId}`|
| `fields.{fieldId}.fieldId` | 用户选择的字段id数组 | `$.{stepId}.fields.{fieldId}.fieldId`|
| `fields.{fieldId}.fieldName` | 用户选择的字段名数组 | `$.{stepId}.fields.{fieldId}.fieldName`|
| `fields.recordId` | 记录 ID 数组 | `$.{stepId}.fields.recordId`|
| `recordNum` | 找到记录总数 | `$.{stepId}.recordNum`|
##### AddRecordAction新增记录
| pathId | 说明 | 引用示例 |
|--------|------|----------|
| `{fieldId}` | 用户配置的字段值,可下钻字段属性 | `$.{stepId}.{fieldId}` |
| `{fieldId}.fieldId` | 用户配置的字段id | `$.{stepId}.{fieldId}.fieldId` |
| `{fieldId}.fieldName` | 用户配置的字段名 | `$.{stepId}.{fieldId}.fieldName` |
| `recordId` | 新增的记录 ID | `$.{stepId}.recordId` |
| `recordLink` | 新增的记录 URL | `$.{stepId}.recordLink` |
##### SetRecordAction更新记录
| pathId | 说明 | 引用示例 |
|--------|------|----------|
| `{fieldId}` | 用户配置的字段值,可下钻字段属性 | `$.{stepId}.{fieldId}` |
| `{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 | 说明 | 引用示例 |
|--------|------|----------|
| (整体出参) | AI 生成的文本内容(不支持下钻,只能引用 `$.{stepId}` | `$.{stepId}` |
##### 无输出的操作节点
以下节点不产生任何可引用的输出数据:
- **Delay**(延时等待)
- **LarkMessageAction**(发送飞书消息)
---
#### 分支节点输出
以下分支节点均不产生任何可引用的输出数据:
- **IfElseBranch**(条件分支)
- **SwitchBranch**(多条件分支)
---
#### 系统节点输出
##### Loop循环
| pathId | 说明 | 引用示例 |
|--------|------|----------|
| `item` | 当前循环元素 | `$.{stepId}.item` |
| `index` | 从 0 开始的循环索引 | `$.{stepId}.index` |
**`item` 的类型推断规则**(由循环数据源决定):
**场景一:遍历组合记录** — 数据源为 `record` 类型时(如 FindRecordAction 的 `fieldRecords``item` 类型为 `record`,可向下选择具体字段:
| 说明 | 引用示例 |
|------|----------|
| 当前遍历的记录record | `$.{loopStepId}.item` |
| 记录的具体字段 | `$.{loopStepId}.item.{fieldId}` |
| 从 0 开始的索引number | `$.{loopStepId}.index` |
**场景二:遍历字段** — 数据源为某个多值类型字段时,比如附件字段、人员字段,`item` 继承该字段的类型并可继续下钻字段属性:
| 说明 | 引用示例 |
|------|----------|
| 当前遍历的元素(类型继承数据源字段类型,例如人员字段) | `$.{loopStepId}.item` |
| 用户姓名 | `$.{loopStepId}.item.name` |
| 从 0 开始的索引number | `$.{loopStepId}.index` |
---
#### 字段属性下钻
每个字段变量都可以进一步下钻选择字段的属性。所有字段至少支持 `fieldId``fieldName` 两个基础属性,部分字段还支持额外属性:
| 字段类型 | 属性名称 | 属性 pathId | 属性 pathType | 说明 |
|----------|---------|-------------|--------------|------|
| **所有字段(基础)** | 字段 ID | `fieldId` | `string` | 字段的唯一标识 |
| | 字段名称 | `fieldName` | `string` | 字段的显示名称 |
| **人员字段**`user` / `created_by` / `updated_by` | 姓名 | `name` | `string` | 用户姓名 |
| **日期字段**`datetime` / `created_at` / `updated_at` | 时间戳 | `timestamp` | `number` | 时间戳数值 |
| **附件字段**`attachment` | 文件名 | `fileName` | `string` | 附件文件名 |
| | 文件类型 | `fileType` | `string` | MIME 类型 |
| | 文件大小 | `size` | `number` | 文件字节数 |
| | 文件 Token | `fileToken` | `string` | 附件 token |
| **超链接文本字段**`text``style.type=url` | 文本 | `text` | `string` | 链接文本部分 |
| | 链接 | `link` | `string` | 链接 URL 部分 |
| **自动编号字段**`auto_number` | 序号 | `sequence` | `number` | 编号的纯数字序号 |
| **关联字段**`link` | 字段下钻 | `{fieldId}` | - | 可下钻到关联表的字段 |
> 其他字段类型(如 `text`、`number`、`checkbox`、`select`、`location`、`formula`、`lookup` 等)仅支持 `fieldId` 和 `fieldName` 两个基础属性。
下钻引用示例:
```
$.{stepId}.{fieldId} → 字段值本身
$.{stepId}.{fieldId}.fieldId → 字段 IDstring
$.{stepId}.{fieldId}.fieldName → 字段名称string
$.{stepId}.{fieldId}.name → 人员姓名列表array<string>,仅人员字段)
$.{stepId}.{fieldId}.unionId → 人员 unionId 列表array<string>,仅人员字段)
$.{stepId}.{fieldId}.timestamp → 时间戳array<number>,仅日期字段)
$.{stepId}.{fieldId}.fileName → 文件名列表array<string>,仅附件字段)
$.{stepId}.{fieldId}.fileToken → 文件 Token 列表array<string>,仅附件字段)
```
---
#### 节点输出能力总览
| 节点 | 类型 | 有输出 | 输出特性 |
|------|------|--------|---------|
| AddRecordTrigger | 触发器 | ✅ | 动态(表字段 + 记录属性) |
| ChangeRecordTrigger | 触发器 | ✅ | 动态(表字段 + 记录属性) |
| SetRecordTrigger | 触发器 | ✅ | 动态(表字段 + 记录属性) |
| ReminderTrigger | 触发器 | ✅ | 动态(表字段 + 记录属性) |
| ButtonTrigger | 触发器 | ✅ | 动态(表字段 + 记录属性buttonElement 仅基础触发属性) |
| TimerTrigger | 触发器 | ✅ | 静态(仅 scheduleTime |
| LarkMessageTrigger | 触发器 | ✅ | 静态(消息属性列表) |
| FindRecordAction | 动作 | ✅ | 动态(用户选择的字段) |
| AddRecordAction | 动作 | ✅ | 动态(用户配置的字段) |
| SetRecordAction | 动作 | ✅ | 动态(用户配置的字段) |
| HTTPClientAction | 动作 | ✅ | 动态(取决于用户配置的 HTTP 响应输出) |
| GenerateAiTextAction | 动作 | ✅ | 静态(单 string |
| Delay | 动作 | ❌ | 无输出 |
| LarkMessageAction | 动作 | ❌ | 无输出 |
| IfElseBranch | 分支 | ❌ | 无输出 |
| SwitchBranch | 分支 | ❌ | 无输出 |
| Loop | 系统 | ✅ | 动态(取决于数据源) |
---
### TextRefItem
文本与引用混排,用于消息内容等动态拼接场景:
```json
[
{ "value_type": "text", "value": "客户 " },
{ "value_type": "ref", "value": "$.step_1.fieldxxx" },
{ "value_type": "text", "value": " 创建了新订单" }
]
```
### RecordFieldValue
```json
{ "field_name": "客户名称", "value": [{ "value_type": "text", "value": "张三" }] }
```
### AndConditionTrigger 过滤条件)
```json
{
"conjunction": "and",
"conditions": [
{ "field_name": "状态", "operator": "is", "value": [{ "value_type": "text", "value": "进行中" }] }
]
}
```
### OrGroupBranch 分支条件)
```json
{
"conjunction": "or",
"conditions": [
{
"conjunction": "and",
"conditions": [
{
"left_value": { "value_type": "ref", "value": "$.step_1.fieldxxx" },
"operator": "isGreater",
"right_value": [{ "value_type": "number", "value": 1000 }]
}
]
}
]
}
```
**operator 可选值:** `is` / `isNot` / `containsAny` / `doesNotContainAny` / /`containsAll`/ `isEmpty` / `isNotEmpty` / `isGreater` / `isGreaterEqual` / `isLess` / `isLessEqual`
### RecordFilterInfo
** 由于 conjunction 只支持 and若需要实现 字段X 等于 A 或 B你可以使用 containsAny
```json
{
"conjunction": "and",
"conditions": [
{ "field_name": "状态", "operator": "is", "value": [{ "value_type": "text", "value": "进行中" }] }
]
}
```
### `select` 字段多值匹配
| 操作 | operator | 正确写法 |
|------|---------|---------|
| 等于单个值 | `is` | `[{"value_type": "option", "value": {"name": "L2"}}]` |
| 匹配多个值L2 或 L3 | `containsAny` | `[{"value_type": "option", "value": {"name": "L2"}}, {"value_type": "option", "value": {"name": "L3"}}]` |
> ⚠️ 不要用多个 `is` 条件(会被当作 OR无法实现 AND。推荐使用 `containsAny` 操作符匹配多个值。
> ⚠️ **Select 字段条件**`value_type` 必须为 `option``value` 对象可只传 `name`(如 `{"name": "L2"}`),无需提供选项 ID。
### RefInfo
```json
{ "step_id": "step_trigger" }
```
---
返回 [Workflow schema index](../lark-base-workflow-schema.md)。

View File

@@ -0,0 +1,26 @@
# Loop
`children.links` 包含 `loop_start` 边指向循环体入口,`next` 指向循环结束后的后继节点。
```json
{
"loop_mode": "continue",
"max_loop_times": 100,
"data": [{ "value_type": "ref", "value": "$.find_record_stepIdxxx.fieldRecords" }]
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `data` | 是 | ValueInfo[](仅支持 `ref` 类型),循环数据源,只能填一个 |
| `loop_mode` | 否 | 单次错误时是否继续:`end`(终止)/ `continue`(继续) |
| `max_loop_times` | 否 | 最大循环次数 |
---
---
## 相关
- 返回 [Workflow schema index](../lark-base-workflow-schema.md)
- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md)

View File

@@ -0,0 +1,24 @@
# AddRecordTrigger
```json
{
"table_name": "订单表",
"watched_field_name": "状态",
"trigger_control_list": ["pasteUpdate", "automationBatchUpdate"],
"condition_list": [] /* AndCondition */
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `table_name` | 是 | 监控的数据表名 |
| `watched_field_name` | 是 | 监控的字段名 |
| `trigger_control_list` | 否 | 触发控制,可选值:`pasteUpdate` / `automationBatchUpdate` / `syncUpdate` / `appendImport` / `openAPIBatchUpdate` |
| `condition_list` | 否 | 过滤条件数组,数组中每个元素为 AndCondition 结构,多个 AndCondition 之间为 OR 关系 |
---
## 相关
- 返回 [Workflow schema index](../lark-base-workflow-schema.md)
- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md)

View File

@@ -0,0 +1,22 @@
# ButtonTrigger
```json
{
"button_type": "buttonField",
"table_name": "审批表"
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `button_type` | 是 | 按钮类型:`buttonField`(表格里的按钮,可操作当前记录数据)/ `buttonElement`(仪表盘、应用页面上的按钮,可执行整体操作) |
| `table_name` | 否 | 绑定的数据表名,仅 `button_type=buttonField` 时填写 |
> `buttonField` 和 `buttonElement` 的输出能力不同详见下方「ButtonTrigger按钮触发器」输出说明。
---
## 相关
- 返回 [Workflow schema index](../lark-base-workflow-schema.md)
- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md)

View File

@@ -0,0 +1,24 @@
# ChangeRecordTrigger
记录满足条件时触发,**新增和修改都会触发**。"修改为 X 或新增 X 时执行动作"这类需求用本触发器 + `condition_list`,一条工作流即可表达,不要拆成 AddRecordTrigger 和 SetRecordTrigger 两条。
```json
{
"table_name": "任务表",
"trigger_control_list": [],
"condition": null
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `table_name` | 是 | 监控的数据表名 |
| `trigger_control_list` | 否 | 触发控制,可选值:`pasteUpdate` / `automationBatchUpdate` / `syncUpdate` / `appendImport` |
| `condition_list` | 否 | 过滤条件数组,数组中每个元素为 AndCondition 结构,多个 AndCondition 之间为 OR 关系 |
---
## 相关
- 返回 [Workflow schema index](../lark-base-workflow-schema.md)
- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md)

View File

@@ -0,0 +1,40 @@
# LarkMessageTrigger
```json
{
"receive_scene": "group",
"receiver": [{ "value_type": "group", "value": {"id": "oc_xxxx", "name": "测试群"} }],
"scope": "all",
"filter": {
"conjunction": "and",
"content_contains": ["关键词"],
"sender_contains": [{ "value_type": "user", "value": {"id": "ou_xxxx", "name": ""} }],
"is_new_message": true,
"is_message_contain_attachment": false
}
}
```
| 字段 | 必填 | 说明|
|------|------|---|
| `receive_scene` | 是 | 接收场景:`group`(群聊)/ `chat`(单聊)|
| `receiver` | 是 | 触发来源,支持 `user` / `group` / `ref`。在单聊场景下,该字段指“可以和机器人单聊的用户”;在群聊场景下,该字段指“接收信息的群组”|
| `scope` | 是 | 触发范围:`at`@提及/ `all`(所有消息)。该参数仅在群聊场景有效,单聊场景请勿指定该参数|
| `filter` | 是 | MessageFilter 消息过滤条件|
`MessageFilter`
| 字段 | 类型 | 说明 |
|------|------|----|
| `conjunction` | string | `and` 满足所有条件 / `or` 任一条件|
| `content_contains` | string[] | 关键词列表|
| `sender_contains` | ValueInfo[] | 筛选发送人(仅群聊+群组来源时生效,单聊场景请勿指定该参数)|
| `is_new_message` | boolean | 仅新话题消息(仅群聊时有效,单聊场景请勿指定该参数)|
| `is_message_contain_attachment` | boolean | 是否仅附件消息触发|
---
## 相关
- 返回 [Workflow schema index](../lark-base-workflow-schema.md)
- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md)

View File

@@ -0,0 +1,30 @@
# ReminderTrigger
```json
{
"table_name": "项目表",
"field_name": "截止日期",
"offset": 1,
"unit": "DAY",
"hour": 9,
"minute": 0,
"condition_list": null
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `table_name` | 是 | 数据表名 |
| `field_name` | 是 | 日期字段名(必须为 `datetime` / `created_at` / `formula` / `lookup` 类型) |
| `unit` | 是 | 偏移单位:`MINUTE` / `HOUR` / `DAY` / `WEEK` / `MONTH` |
| `offset` | 是 | 提前/延后的偏移量(正数=提前,负数=延后;范围由 `unit` 决定):`MINUTE` ∈ {0, 5, 15, 30, -5, -15, -30}`HOUR` ∈ [-6, -1] [1, 6]`DAY` ∈ [-7, 7]`WEEK` ∈ [-7, -1] [1, 7]`MONTH` ∈ [-7, -1] [1, 7] |
| `hour` | 是 | 触发小时 (0-23),默认 9 |
| `minute` | 是 | 触发分钟 (0-59),默认 0 |
| `condition_list` | 否 | 过滤条件数组,数组中每个元素为 AndCondition 结构,多个 AndCondition 之间为 OR 关系 |
---
## 相关
- 返回 [Workflow schema index](../lark-base-workflow-schema.md)
- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md)

View File

@@ -0,0 +1,38 @@
# SetRecordTrigger
```json
{
"table_name": "订单表",
"record_watch_conjunction": "and",
"record_watch_info": [ /* FieldCondition[] */ ],
"field_watch_info": [
{ "field_name": "状态", "operator": "is", "value": [{ "value_type": "text", "value": "已发货" }] }
],
"trigger_control_list": [],
"condition_list": null
}
```
| 字段 | 必填 | 说明 |
|------|----|------|
| `table_name` | 是 | 监控的数据表名 |
| `record_watch_conjunction` | 否 | 记录筛选组合方式:`and` / `or`,默认 `and` |
| `record_watch_info` | 否 | 记录级过滤条件(修改前值匹配),为空则监听全部 |
| `field_watch_info` | 是 | 字段级监控条件列表,至少一个 |
| `trigger_control_list` | 否 | 触发控制,可选值:`pasteUpdate` / `automationBatchUpdate` / `syncUpdate` / `appendImport` |
| `condition_list` | 否 | 过滤条件数组,数组中每个元素为 AndCondition 结构,多个 AndCondition 之间为 OR 关系 |
`FieldWatchItem`
| 字段 | 类型 | 说明 |
|------|------|------|
| `field_name` | string | 监听字段名称 |
| `operator` | string | 操作符(仅明确要求字段满足条件时填) |
| `value` | ValueInfo[] | 触发值 |
---
## 相关
- 返回 [Workflow schema index](../lark-base-workflow-schema.md)
- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md)

View File

@@ -0,0 +1,27 @@
# TimerTrigger
```json
{
"rule": "WEEKLY",
"start_time": "2025-01-01 09:00",
"sub_unit": [1, 3, 5],
"is_never_end": true
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `rule` | 是 | `NO_REPEAT` / `DAILY` / `WEEKLY` / `MONTHLY` / `YEARLY` / `WORKDAY` / `CUSTOM` |
| `start_time` | 否 | 开始时间,格式 `yyyy-MM-dd HH:mm` |
| `interval` | 否 | 自定义间隔 [1,30](仅 CUSTOM |
| `unit` | 否 | 自定义单位:`SECOND` / `MINUTE` / `HOUR` / `DAY` / `WEEK` / `MONTH` / `YEAR` |
| `sub_unit` | 否 | 子单位(`WEEKLY` 时为星期几数组 0-6`MONTHLY` 时为几号数组 1-31 |
| `end_time` | 否 | 结束时间 |
| `is_never_end` | 否 | 是否永不结束 |
---
## 相关
- 返回 [Workflow schema index](../lark-base-workflow-schema.md)
- ValueInfo、ref、Condition、RecordFilterInfo 等公共结构见 [common-types-and-refs.md](common-types-and-refs.md)

View File

@@ -31,8 +31,6 @@ lark-cli calendar +agenda --as user
| Shortcut | 说明 |
|----------|------|
| [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) |
| [`+search-event`](references/lark-calendar-search-event.md) | 按关键词、时间范围和参会人搜索日程 |
| [`+meeting`](references/lark-calendar-meeting.md) | 通过日程事件 ID 获取关联的视频会议信息meeting_id、meeting_note |
| [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人ISO 8601 时间) |
| [`+update`](references/lark-calendar-update.md) | 更新既有日程字段,或独立增量添加/移除参会人和会议室 |
| [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和 RSVP 状态 |
@@ -66,9 +64,6 @@ lark-cli calendar +agenda --as user
|----------|--------|
| 查询过去的会议("昨天的会议""上周的会" | [`../lark-vc/SKILL.md`](../lark-vc/SKILL.md)(会议数据含即时会议,仅查日程会遗漏) |
| 查询日历/日程或未来时间的会议 | 本 skill |
| 按关键词搜索日程 | 本 skill`+search-event` |
| 从日程获取关联的视频会议 ID 或用户绑定的会议纪要文档 | 本 skill`+meeting` |
| 从日程进一步拿 AI 智能纪要 / 逐字稿 / 妙记产物 | 先 `+meeting``meeting_id`,再 [`vc +detail`](../lark-vc/references/lark-vc-detail.md) → [`note +detail`](../lark-note/references/lark-note-detail.md) / [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md) |
| 预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
## 任务类型分流

View File

@@ -1,40 +0,0 @@
# calendar +meeting
通过日程 ID`event_id` 获取关联的视频会议信息(`meeting_id``meeting_note`)。只读。
## 命令
```bash
# 单个 / 批量(逗号分隔,最多 50 个)
lark-cli calendar +meeting --event-ids <event_id1>,<event_id2>
# 默认使用主日历,需要时显式传 --calendar-id
lark-cli calendar +meeting --event-ids <event_id> --calendar-id <calendar_id>
```
## 输出字段
| 字段 | 说明 |
|------|------|
| `event_id` | 日程 ID |
| `meeting_id` | 关联的视频会议 ID |
| `meeting_note` | 用户主动绑定到日程的纪要文档 Token`MeetingNotes`,由用户在日程页手动添加;)。**与会中产生的 AI 智能纪要 `note_doc_token` 是两份不同文档**,要拿 AI 纪要请继续走 `vc +detail``note +detail`。 |
## 下游链路
`calendar +meeting` 只把日程 ID 翻译为 `meeting_id` / `meeting_note`要拿会中产生的产物AI 智能纪要、逐字稿、妙记)需继续调用:
```bash
# 1. meeting_id → note_id + minute_token同一会议两份产物可能各自为空
lark-cli vc +detail --meeting-ids <meeting_id>
# 2a. note_id → 纪要文档 tokennote_doc_token / verbatim_doc_token / shared_doc_tokens
lark-cli note +detail --note-id <note_id>
# 2b. minute_token → 妙记 AI 产物(按需获取,不传不返回任何 AI 内容)
lark-cli minutes +detail --minute-tokens <minute_token> --summary --todo --chapter --keyword --transcript
# 3. 任意文档 tokenmeeting_note / note_doc_token / verbatim_doc_token / shared_doc_token→ 正文
lark-cli docs +fetch --api-version v2 --doc <doc_token> --doc-format markdown
```

View File

@@ -1,29 +0,0 @@
# calendar +search-event
按关键词、时间范围和参会人搜索日历日程。只读。
## 命令
```bash
# 按关键词
lark-cli calendar +search-event --query "周会"
# 按时间范围ISO 8601 或 YYYY-MM-DD
lark-cli calendar +search-event --start "2026-04-20T00:00:00+08:00" --end "2026-04-27T23:59:59+08:00"
# 按参会人(自动识别 ou_ 用户 / oc_ 群聊 / omm_ 会议室前缀)
lark-cli calendar +search-event --attendee-ids "ou_user1,oc_chat1,omm_room1"
# 组合
lark-cli calendar +search-event --query "周会" --start 2026-04-20 --end 2026-04-27 --attendee-ids "ou_user1"
```
## 输出字段
`items` 列表每条返回 `event_id` / `summary` / `start` / `end` / `is_all_day` / `app_link`;外层有 `has_more``page_token`。**仅返回基础字段,要拿日程详情用 `calendar events get`。**
## 注意事项
- 分页:`has_more=true` 时持续用 `page_token` 翻页直到 false不要遗漏`page-size` 最大 30。
- 已结束的会议优先用 `vc +search`——日历不收录"即时会议",只查日程会漏。

View File

@@ -48,6 +48,7 @@ lark-cli drive +import --file ./data.csv --type bitable --folder-token <FOLDER_T
# 导入数据到已有的多维表格(不新建,数据挂载到目标多维表格中)
lark-cli drive +import --file ./data.xlsx --type bitable --target-token <BASE_TOKEN>
# 成功后验证 <BASE_TOKEN>;不要拿返回中的导入任务 token 当作 Base token 复核
# 预览底层调用链(上传 -> 创建任务 -> 轮询)
lark-cli drive +import --file ./README.md --type docx --dry-run
@@ -72,7 +73,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
2. 调用 `import_tasks` 接口发起导入任务,自动根据本地文件提取扩展名并构造挂载点(`mount_point`)参数
3. 自动轮询查询导入任务状态;如果在内置轮询窗口内完成,则直接返回导入结果;如果仍未完成,则返回 `ticket`、当前状态和后续查询命令
- **默认根目录行为**:不传 `--folder-token`shortcut 会保留空的 `point.mount_key`Lark Import API 会将其视为"导入到调用者根目录"。
- **导入到已有 bitable**:当 `--type bitable` 且传了 `--target-token` 时,请求 body 中会增加一个 `token` 字段指向目标多维表格的 tokenpoint 挂载点逻辑不变。数据会挂载到该已有多维表格中,而非创建新文档。
- **导入到已有 bitable**:当 `--type bitable` 且传了 `--target-token` 时,请求 body 中会增加一个 `token` 字段指向目标多维表格。数据会挂载到该已有多维表格中,而非创建新文档;完成后用输出的 `verification_token`(即传入的 `--target-token`)复核,不要改用返回的导入任务 `token` 做 Base 查询
### 支持的文件类型转换

View File

@@ -147,7 +147,7 @@ Lark-defined semantic tags (**not** JSON Schema's standard `format`). Common val
| Topic | Reference | Coverage |
|------------|------------------------------------------------------------------------------|---|
| IM | [`references/lark-event-im.md`](references/lark-event-im.md) | Catalog of 11 IM EventKeys + shape notes (flat vs V2 envelope) + `im.message.receive_v1` field gotchas (`sender_id` is open_id only; `.content` is plain text except for `interactive` cards) + common jq recipes (filter by chat_type / message_type / sender) |
| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | Catalog of 2 VC EventKeys (`vc.meeting.participant_meeting_ended_v1`, `vc.note.generated_v1`) + field reference + source type semantics (meeting only) |
| Minutes | [`references/lark-event-minutes.md`](references/lark-event-minutes.md) | Catalog of 1 Minutes EventKey (`minutes.minute.generated_v1`) + field reference + source type semantics (meeting only) |
| Whiteboard | [`references/lark-event-whiteboard.md`](references/lark-event-whiteboard.md) | Catalog of 1 Board EventKey (`board.whiteboard.updated_v1`) + per-whiteboard subscription model (requires `-p whiteboard_id=<token>`) + payload field reference (whiteboard_id / operator_ids triple-id) |
| IM | [`references/lark-event-im.md`](references/lark-event-im.md) | jq recipes (filter by chat_type / message_type / sender) + payload gotchas (`.content` is pre-rendered text — don't `fromjson`; `sender_id` is open_id; flat vs `.event` envelope) |
| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | jq recipes (meeting-ended / note / transcript) + behavior gotchas (time conversion, note_source meeting-only, recording batches). All VC keys need `--as user` |
| Minutes | [`references/lark-event-minutes.md`](references/lark-event-minutes.md) | jq recipes + enrichment gotcha (`title` may be empty on detail-API failure; `minute_source` meeting-only). `--as user` |
| Whiteboard | [`references/lark-event-whiteboard.md`](references/lark-event-whiteboard.md) | jq recipes + subscription gotcha (**required** `-p whiteboard_id`, needs manage access or 403). `--as user\|bot` |

View File

@@ -2,85 +2,45 @@
> **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage).
>
> **Heads-up for AI agents**: this key's `.content` is **NOT** the raw OAPI payload shape your training data may suggest. `lark-cli` runs a Process hook (`convertlib`) that flattens the V2 envelope and **pre-renders** `.content` to human-readable text for `text` / `post` / `image` / `file` / `audio` / etc. Only `interactive` (cards) keeps the raw JSON string. Don't blindly `fromjson`.
> **The catalog lives in the CLI, not here.** `lark-cli event list` lists all 11 IM EventKeys; `lark-cli event schema <key>` gives any key's fields / types / enums. This file only covers what the schema can't: payload-shape gotchas and ready-to-use jq recipes.
## Key catalog (11)
## Shape: flat vs enveloped
| EventKey | Purpose |
|---|---|
| `im.message.receive_v1` | Receive IM messages |
| `im.message.message_read_v1` | User read a bot's **p2p** message (group messages don't fire this) |
| `im.message.reaction.created_v1` | Reaction added to a message |
| `im.message.reaction.deleted_v1` | Reaction removed from a message |
| `im.chat.updated_v1` | Chat settings changed (owner, avatar, name, permissions, etc.) |
| `im.chat.disbanded_v1` | Chat disbanded |
| `im.chat.member.bot.added_v1` | Bot added to a chat |
| `im.chat.member.bot.deleted_v1` | Bot removed from a chat |
| `im.chat.member.user.added_v1` | User joined a chat (including topic chats) |
| `im.chat.member.user.deleted_v1` | User left voluntarily **or** was removed |
| `im.chat.member.user.withdrawn_v1` | Pending chat invite withdrawn (inviter canceled; user never actually joined) |
`im.message.receive_v1` is the only **flat** key (fields at `.xxx`). The other 10 IM keys are **V2-enveloped** — fields live at `.event.xxx` (e.g. `.event.chat_id`). `event schema <key>` confirms it (its Output Schema nests everything under `event`).
> **Shape**: `im.message.receive_v1` is the only flat key (fields at `.xxx`); the other 10 are V2-enveloped (fields at `.event.xxx`).
## `.content` is pre-rendered — do NOT blindly `fromjson` (`im.message.receive_v1`)
## Gotchas (`im.message.receive_v1`)
`lark-cli` runs a Process hook that **pre-renders `.content` to human-readable text** for every `message_type` except `interactive` (`@mentions` resolved to display names). Only `interactive` (cards) keeps the raw JSON string.
**sender_id is open_id only**: the event payload carries no display name. Call the contact API separately if you need the sender's name.
**`.content` shape depends on `message_type`** (this key uses a flat Custom schema; see [`events/im/message_receive.go`](../../../events/im/message_receive.go)):
| message_type | `.content` shape | How to read |
| message_type | `.content` | How to read |
|---|---|---|
| `text` / `post` / `image` / `file` / `audio` / `sticker` / `share_chat` / `share_user` / `media` / `system` | Human-readable text (convertlib-processed; `@mentions` resolved to display names) | Use `.content` directly |
| `interactive` (card) | Raw card JSON string (structured actions can't be losslessly flattened) | `.content \| fromjson` to get the card object |
| everything except `interactive` | plain text | use `.content` directly |
| `interactive` (card) | raw card JSON string | `.content \| fromjson` |
**Do not blindly `fromjson`** — for non-interactive messages it fails with `jq: fromjson cannot be applied to "hello"` because `.content` isn't JSON-encoded.
Applying `fromjson` to a non-interactive message errors per event (`jq: fromjson cannot be applied to "hello"`) and the consumer **silently drops** it — looks alive, emits nothing.
**`sender_id` is `open_id` only** — the payload carries no display name; resolve via the contact API if you need one.
## jq recipes (`im.message.receive_v1`)
> Default = no `--jq` (stream every message). Use these only when asked to narrow the stream.
```bash
# text: .content is plain text — no fromjson needed
lark-cli event consume im.message.receive_v1 --as bot \
--jq 'select(.message_type=="text") | .content'
# interactive: .content is a JSON string — fromjson to parse
lark-cli event consume im.message.receive_v1 --as bot \
--jq 'select(.message_type=="interactive") | .content | fromjson'
```
## On-demand filter recipes
> **Default = no `--jq`.** Run `lark-cli event consume im.message.receive_v1 --as bot` to see every message. The recipes below are only for cases where the user has asked to narrow the stream.
### 1. Filter by chat type (p2p vs group)
`chat_type` is an enum with values `p2p` / `group`.
```bash
# p2p only (direct messages)
lark-cli event consume im.message.receive_v1 --as bot \
--jq 'select(.chat_type=="p2p") | {from: .sender_id, msg: .content}'
# group only
# group chats only (chat_type enum: p2p | group)
lark-cli event consume im.message.receive_v1 --as bot \
--jq 'select(.chat_type=="group") | {chat: .chat_id, from: .sender_id, msg: .content}'
```
### 2. Filter by message type
```bash
# text only — content is plain human-readable text
# text messages only — .content is plain text
lark-cli event consume im.message.receive_v1 --as bot \
--jq 'select(.message_type=="text") | .content'
# interactive (card) only — parse the card body
# interactive cards only — parse the card body
lark-cli event consume im.message.receive_v1 --as bot \
--jq 'select(.message_type=="interactive") | .content | fromjson'
# one sender's messages only
lark-cli event consume im.message.receive_v1 --as bot \
--jq 'select(.sender_id=="ou_xxxx") | {msg_id: .message_id, text: .content}'
```
### 3. Filter by sender (only one user's messages)
```bash
# example: only messages from the given open_id
lark-cli event consume im.message.receive_v1 --as bot\
--jq 'select(.sender_id=="ou_xxxxxxxxxxxxxxxxxxxxxxxxxx") | {msg_id: .message_id, text: .content}'
```
Get your own open_id via `lark-cli contact +get-user --as user`; other users' via `lark-cli contact +search-user`.
Get your own open_id via `lark-cli contact +get-user --as user`; others' via `lark-cli contact +search-user`.

Some files were not shown because too many files have changed in this diff Show More