mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
8 Commits
v1.0.53
...
eval/skill
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbd729757b | ||
|
|
5a806febc8 | ||
|
|
aaced42956 | ||
|
|
a55bd76966 | ||
|
|
c15cb120a1 | ||
|
|
93e4fc7af0 | ||
|
|
feed8fb884 | ||
|
|
0fbfe68726 |
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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_commands,CLI 永远带 ?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))
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}/tables(cursor 分页),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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`"
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -19,6 +19,7 @@ var BaseDashboardBlockGetData = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "dashboard-id", Hidden: true},
|
||||
blockIDFlag(true),
|
||||
},
|
||||
Tips: []string{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
30
shortcuts/base/field_list_batch.go
Normal file
30
shortcuts/base/field_list_batch.go
Normal 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)
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -19,6 +19,7 @@ func Shortcuts() []common.Shortcut {
|
||||
BaseTableUpdate,
|
||||
BaseTableDelete,
|
||||
BaseFieldList,
|
||||
BaseFieldListBatch,
|
||||
BaseFieldGet,
|
||||
BaseFieldCreate,
|
||||
BaseFieldUpdate,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 代执行)。
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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` 开头,由 steps(trigger + 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 reference;sort/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
|
||||
|
||||
@@ -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 值 | 说明 |
|
||||
|
||||
115
skills/lark-base/references/formula-examples.md
Normal file
115
skills/lark-base/references/formula-examples.md
Normal 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).
|
||||
|
||||
---
|
||||
@@ -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
|
||||
|
||||
66
skills/lark-base/references/formula-functions-extended.md
Normal file
66
skills/lark-base/references/formula-functions-extended.md
Normal 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) |
|
||||
@@ -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
|
||||
|
||||
|
||||
238
skills/lark-base/references/lark-base-dashboard-usecase.md
Normal file
238
skills/lark-base/references/lark-base-dashboard-usecase.md
Normal 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` 的真实返回。
|
||||
@@ -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` 修改 layout,layout 不是 `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 创建仪表盘
|
||||
|
||||
@@ -397,6 +397,7 @@
|
||||
|
||||
默认值 / 约束:
|
||||
- `style.rules` 是规则数组,数量 `1..9`
|
||||
- `+field-update` 修改编号规则时,**默认会把新规则应用到已有记录**
|
||||
- 默认规则:
|
||||
|
||||
```json
|
||||
|
||||
@@ -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` | 仅 Branch(IfElseBranch/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
@@ -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)
|
||||
16
skills/lark-base/references/workflow-steps/action-delay.md
Normal file
16
skills/lark-base/references/workflow-steps/action-delay.md
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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` | 否 | string,JSON 字符串形式的响应结果示例;仅当 `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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
36
skills/lark-base/references/workflow-steps/branch-if-else.md
Normal file
36
skills/lark-base/references/workflow-steps/branch-if-else.md
Normal 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)
|
||||
52
skills/lark-base/references/workflow-steps/branch-switch.md
Normal file
52
skills/lark-base/references/workflow-steps/branch-switch.md
Normal 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)
|
||||
@@ -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` |
|
||||
|
||||
##### HTTPClientAction(HTTP 请求)
|
||||
|
||||
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`
|
||||
|
||||
##### GenerateAiTextAction(AI 生成文本)
|
||||
|
||||
| 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 → 字段 ID(string)
|
||||
$.{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": "张三" }] }
|
||||
```
|
||||
|
||||
### AndCondition(Trigger 过滤条件)
|
||||
|
||||
```json
|
||||
{
|
||||
"conjunction": "and",
|
||||
"conditions": [
|
||||
{ "field_name": "状态", "operator": "is", "value": [{ "value_type": "text", "value": "进行中" }] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### OrGroup(Branch 分支条件)
|
||||
|
||||
```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)。
|
||||
26
skills/lark-base/references/workflow-steps/system-loop.md
Normal file
26
skills/lark-base/references/workflow-steps/system-loop.md
Normal 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)
|
||||
@@ -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)
|
||||
22
skills/lark-base/references/workflow-steps/trigger-button.md
Normal file
22
skills/lark-base/references/workflow-steps/trigger-button.md
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
27
skills/lark-base/references/workflow-steps/trigger-timer.md
Normal file
27
skills/lark-base/references/workflow-steps/trigger-timer.md
Normal 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)
|
||||
@@ -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` 字段指向目标多维表格的 token,point 挂载点逻辑不变。数据会挂载到该已有多维表格中,而非创建新文档。
|
||||
- **导入到已有 bitable**:当 `--type bitable` 且传了 `--target-token` 时,请求 body 中会增加一个 `token` 字段指向目标多维表格。数据会挂载到该已有多维表格中,而非创建新文档;完成后用输出的 `verification_token`(即传入的 `--target-token`)复核,不要改用返回的导入任务 `token` 做 Base 查询。
|
||||
|
||||
### 支持的文件类型转换
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -1,54 +1,25 @@
|
||||
# Minutes Events
|
||||
|
||||
> **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage).
|
||||
>
|
||||
> Catalog & fields live in the CLI: `event list` (the one key `minutes.minute.generated_v1`) and `event schema minutes.minute.generated_v1`. This file only covers what the schema can't: enrichment behavior and recipes. **Requires `--as user`.** Flat output (fields at `.xxx`).
|
||||
|
||||
## Key catalog (1)
|
||||
## Enrichment & degradation (the gotcha)
|
||||
|
||||
| EventKey | Purpose |
|
||||
|---|---|
|
||||
| `minutes.minute.generated_v1` | A minute (妙记) has been generated |
|
||||
The Process hook calls the minutes detail API to enrich `title`. **If that call fails, `title` is left empty** — the base fields (`type`, `event_id`, `timestamp`, `minute_token`, `minute_source`) are always present. So filter on `.title != ""` if you only want enriched events.
|
||||
|
||||
This key uses a **Custom schema** (flat output at `.xxx`) and carries a **PreConsume hook** that auto-subscribes / unsubscribes via OAPI on first / last consumer.
|
||||
`minute_source` comes from the payload directly (survives enrichment failure) and is **only present when the minute originates from a meeting** (`source_type == "meeting"`); for recording / local-upload sources it is absent.
|
||||
|
||||
## Scopes & auth
|
||||
|
||||
| EventKey | Scope | Auth |
|
||||
|---|---|---|
|
||||
| `minutes.minute.generated_v1` | `minutes:minutes.basic:read` | user |
|
||||
|
||||
Requires `--as user`.
|
||||
|
||||
## `minutes.minute.generated_v1`
|
||||
|
||||
### Output fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `type` | string | Event type; always `minutes.minute.generated_v1` |
|
||||
| `event_id` | string | Globally unique event ID; safe for deduplication |
|
||||
| `timestamp` | string (timestamp_ms) | Event delivery time (ms timestamp string) |
|
||||
| `minute_token` | string | Minute token |
|
||||
| `title` | string | Minute title (enriched via detail API) |
|
||||
| `minute_source` | object | Minute source metadata; only present when the source is a meeting |
|
||||
| `minute_source.source_type` | string | Source type; only present when the source is a meeting (value: `meeting`) |
|
||||
| `minute_source.source_entity_id` | string | Source entity ID (meeting ID); only present when the source is a meeting |
|
||||
|
||||
### Enrichment & degradation
|
||||
|
||||
The Process hook calls `GET /open-apis/minutes/v1/minutes/{minute_token}` to enrich `title`. If the detail API fails, this field is left empty — the base fields (`type`, `event_id`, `timestamp`, `minute_token`, `minute_source`) are always present.
|
||||
|
||||
`minute_source` is populated from the event payload directly (not the detail API), so it survives enrichment failures. Note: `minute_source` is only present when the minute originates from a meeting; for other sources (e.g. recording, local upload) this field is absent.
|
||||
|
||||
### Example
|
||||
## jq recipes
|
||||
|
||||
```bash
|
||||
lark-cli event consume minutes.minute.generated_v1 --as user
|
||||
|
||||
# Project title and token only (skip events where enrichment failed)
|
||||
# title + token, skipping events where enrichment failed
|
||||
lark-cli event consume minutes.minute.generated_v1 --as user \
|
||||
--jq 'select(.title != "") | {minute_token, title}'
|
||||
|
||||
# Filter by source type
|
||||
# meeting-sourced minutes only
|
||||
lark-cli event consume minutes.minute.generated_v1 --as user \
|
||||
--jq 'select(.minute_source.source_type == "meeting") | {minute_token, title}'
|
||||
```
|
||||
|
||||
@@ -1,94 +1,27 @@
|
||||
# VC Events
|
||||
|
||||
> **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage).
|
||||
>
|
||||
> Catalog & fields live in the CLI: `event list` shows the VC keys (meeting-ended, note-generated, recording started / ended / transcript-generated); `event schema <key>` shows each one's fields. This file only covers what the schema can't: behavior gotchas and recipes. **All VC keys require `--as user`.** Flat output (fields at `.xxx`).
|
||||
|
||||
## Key catalog (2)
|
||||
## Behavior gotchas
|
||||
|
||||
| EventKey | Purpose |
|
||||
|---|---|
|
||||
| `vc.meeting.participant_meeting_ended_v1` | A meeting the current user participates in has ended |
|
||||
| `vc.note.generated_v1` | A note has been generated (meeting, recording, upload, etc.) |
|
||||
- **`participant_meeting_ended_v1`**: `start_time` / `end_time` are **not** raw unix seconds — the Process hook converts them to local-timezone RFC3339. If the raw value is empty/non-numeric, the field is left empty. No detail API call; all fields come from the payload.
|
||||
- **`note.generated_v1`**: fires for meetings *and* recordings/uploads. `note_token` / `verbatim_token` may be empty if detail isn't ready yet. `note_source` (and `note_source.source_entity_id` = meeting ID) is **only present when `source_type == "meeting"`**.
|
||||
- **`recording.*`**: only fire on Feishu-connected software. `recording_started`/`recording_ended` share `unique_key` (pairs a start with its end). `recording_transcript_generated` carries `transcript_items` as an **array, delivered in batches** — expect multiple events per recording.
|
||||
|
||||
Both keys use a **Custom schema** (flat output) and carry a **PreConsume hook** that auto-subscribes / unsubscribes via OAPI on first / last consumer. Both require `--as user`.
|
||||
|
||||
## Scopes & auth
|
||||
|
||||
| EventKey | Scope | Auth |
|
||||
|---|---|---|
|
||||
| `vc.meeting.participant_meeting_ended_v1` | `vc:meeting.meetingevent:read` | user |
|
||||
| `vc.note.generated_v1` | `vc:note:read` | user |
|
||||
|
||||
---
|
||||
|
||||
## `vc.meeting.participant_meeting_ended_v1`
|
||||
|
||||
### Output fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `type` | string | Event type; always `vc.meeting.participant_meeting_ended_v1` |
|
||||
| `event_id` | string | Globally unique event ID; safe for deduplication |
|
||||
| `timestamp` | string (timestamp_ms) | Event delivery time (ms timestamp string) |
|
||||
| `meeting_id` | string | Meeting ID |
|
||||
| `topic` | string | Meeting topic |
|
||||
| `meeting_no` | string | Meeting number |
|
||||
| `start_time` | string | Meeting start time in RFC3339, converted to the local timezone |
|
||||
| `end_time` | string | Meeting end time in RFC3339, converted to the local timezone |
|
||||
| `calendar_event_id` | string | Calendar event ID associated with the meeting |
|
||||
|
||||
### Gotchas
|
||||
|
||||
- `start_time` / `end_time` are **not** the raw unix-seconds from OAPI — the Process hook converts them to local-timezone RFC3339. If the raw value is empty or non-numeric, the field is left empty.
|
||||
- No detail API call is made; all fields come from the event payload itself.
|
||||
|
||||
### Example
|
||||
## jq recipes
|
||||
|
||||
```bash
|
||||
lark-cli event consume vc.meeting.participant_meeting_ended_v1 --as user
|
||||
|
||||
# Project meeting topic and end time only
|
||||
# meeting ended: topic + end time
|
||||
lark-cli event consume vc.meeting.participant_meeting_ended_v1 --as user \
|
||||
--jq '{meeting: .meeting_id, topic: .topic, ended: .end_time}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `vc.note.generated_v1`
|
||||
|
||||
Fires when a note is generated — not just from meetings, but also from realtime recordings and local file uploads.
|
||||
|
||||
### Output fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `type` | string | Event type; always `vc.note.generated_v1` |
|
||||
| `event_id` | string | Globally unique event ID; safe for deduplication |
|
||||
| `timestamp` | string (timestamp_ms) | Event delivery time (ms timestamp string) |
|
||||
| `note_id` | string | Note ID |
|
||||
| `note_token` | string | Note document token; may be empty if detail is not yet available |
|
||||
| `verbatim_token` | string | Verbatim document token; may be empty if detail is not yet available |
|
||||
| `note_source` | object | Source metadata; only present when source is a meeting |
|
||||
| `note_source.source_type` | string | Source type; only present when source is a meeting (value: `meeting`) |
|
||||
| `note_source.source_entity_id` | string | Source entity ID (meeting ID); only present when source is a meeting |
|
||||
|
||||
### Source type semantics
|
||||
|
||||
| `source_type` | Trigger |
|
||||
|---|---|
|
||||
| `meeting` | Note generated from a meeting |
|
||||
|
||||
`note_source` (and its sub-fields) are only populated when `source_type` is `meeting`. For other sources the field is absent.
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
lark-cli event consume vc.note.generated_v1 --as user
|
||||
|
||||
# Only notes with enriched tokens, skip incomplete ones
|
||||
# notes: meeting-sourced only, with enriched tokens
|
||||
lark-cli event consume vc.note.generated_v1 --as user \
|
||||
--jq 'select(.note_token != "") | {note_id, note_token, verbatim_token}'
|
||||
--jq 'select(.note_source.source_type == "meeting" and .note_token != "") | {note_id, note_token, meeting_id: .note_source.source_entity_id}'
|
||||
|
||||
# Filter to meeting-sourced notes only
|
||||
lark-cli event consume vc.note.generated_v1 --as user \
|
||||
--jq 'select(.note_source.source_type == "meeting") | {note_id, meeting_id: .note_source.source_entity_id}'
|
||||
# recording transcript: stream speaker + text per line
|
||||
lark-cli event consume vc.recording.recording_transcript_generated_v1 --as user \
|
||||
--jq '.transcript_items[] | {speaker: .speaker_name, text}'
|
||||
```
|
||||
|
||||
@@ -1,67 +1,27 @@
|
||||
# Whiteboard Events
|
||||
|
||||
> **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage).
|
||||
>
|
||||
> One key: `board.whiteboard.updated_v1` (run `event schema` for fields). Supports `--as user` **or** `--as bot`. Output is V2-enveloped — fields at `.event.xxx`.
|
||||
|
||||
## Key catalog (1)
|
||||
## Per-whiteboard subscription (the gotcha)
|
||||
|
||||
| EventKey | Purpose |
|
||||
|---|---|
|
||||
| `board.whiteboard.updated_v1` | A whiteboard has been edited |
|
||||
Unlike global keys, this one subscribes **per whiteboard**. **Required param: `-p whiteboard_id=<whiteboard_token>`** — omitting it fails param validation up-front (`required param "whiteboard_id" missing ...`) before any subscription.
|
||||
|
||||
This key uses a **Native schema** (V2 envelope; output rooted at `.event`) and carries a **PreConsume hook** that auto-subscribes / unsubscribes via OAPI on first / last consumer.
|
||||
- Get the token via the docs OAPI [list document blocks](https://open.feishu.cn/document/ukTMukTMukTM/uUDN04SN0QjL1QDN/document-docx/docx-v1/document-block/list): the block with `block_type=43` is a whiteboard; its `block.token` is the whiteboard token.
|
||||
- The caller must have **manage** access to that whiteboard, otherwise the subscribe OAPI returns 403 and `event consume` exits with an auth error **before** listening.
|
||||
- `.event.operator_ids` is an **array** — multiple collaborators editing in one tick collapse into a single event with multiple entries.
|
||||
|
||||
## Scopes & auth
|
||||
|
||||
| EventKey | Scope | Auth |
|
||||
|---|---|---|
|
||||
| `board.whiteboard.updated_v1` | `board:whiteboard:node:read` | user, bot |
|
||||
|
||||
Supports `--as user` or `--as bot`. The caller must have **manage** access to the target whiteboard, otherwise the subscribe OAPI returns 403 and `event consume` exits with an auth error before listening.
|
||||
|
||||
## `board.whiteboard.updated_v1`
|
||||
|
||||
### Per-whiteboard subscription
|
||||
|
||||
Unlike global event keys (e.g. minutes / im), this key subscribes **per whiteboard**: `event consume` calls `POST /open-apis/board/v1/whiteboards/{whiteboard_id}/subscribe` on startup with the `whiteboard_id` you pass via `-p`. **Required parameter**: `-p whiteboard_id=<whiteboard_token>`. Missing this param fails param validation up-front with `required param "whiteboard_id" missing for EventKey board.whiteboard.updated_v1` before any subscription happens.
|
||||
|
||||
Whiteboard token can be obtained via the docs OAPI [list document blocks](https://open.feishu.cn/document/ukTMukTMukTM/uUDN04SN0QjL1QDN/document-docx/docx-v1/document-block/list): the block whose `block_type=43` is a whiteboard, and `block.token` is the whiteboard token.
|
||||
|
||||
### Output fields (V2 envelope; root path `.event`)
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `.event.whiteboard_id` | string (kind=whiteboard_id) | Whiteboard token |
|
||||
| `.event.operator_ids[].open_id` | string (kind=open_id) | Editor's open_id (`ou_` prefix) |
|
||||
| `.event.operator_ids[].union_id` | string (kind=union_id) | Editor's union_id |
|
||||
| `.event.operator_ids[].user_id` | string (kind=user_id) | Editor's user_id (only present when the caller's app has the user_id-related contact scope granted by the OAPI side) |
|
||||
|
||||
`operator_ids` is an array — multi-user collaborative editing within one tick collapses into a single event with multiple entries.
|
||||
|
||||
### Subscription lifecycle
|
||||
|
||||
| Phase | Behavior |
|
||||
|---|---|
|
||||
| Startup | `event consume` calls `subscribe` OAPI; on success stderr emits `[event] consuming as ...`, `[event] running pre-consume setup...`, `[event] listening for events (key=board.whiteboard.updated_v1)...`, then the AI-facing ready marker `[event] ready event_key=board.whiteboard.updated_v1` |
|
||||
| Running | Edits to the whiteboard stream as NDJSON to stdout |
|
||||
| Graceful exit (Ctrl+C / SIGTERM / `--max-events` / `--timeout` / stdin EOF) | `event consume` calls `unsubscribe` OAPI |
|
||||
| `kill -9` | **Skips unsubscribe → server-side subscription leaks**, may cause `subscription already exists` or duplicate delivery on next consume. See SKILL.md "Never `kill -9`". |
|
||||
|
||||
### Example
|
||||
## jq recipes
|
||||
|
||||
```bash
|
||||
# Stream every edit on whiteboard <token> until Ctrl+C
|
||||
lark-cli event consume board.whiteboard.updated_v1 \
|
||||
-p whiteboard_id=<whiteboard_token> \
|
||||
--as user
|
||||
# stream every edit on the whiteboard until Ctrl+C
|
||||
lark-cli event consume board.whiteboard.updated_v1 -p whiteboard_id=<whiteboard_token> --as user
|
||||
|
||||
# Sample one event for payload inspection
|
||||
lark-cli event consume board.whiteboard.updated_v1 \
|
||||
-p whiteboard_id=<whiteboard_token> \
|
||||
--as user --max-events 1 --timeout 2m
|
||||
# sample one event to inspect the payload
|
||||
lark-cli event consume board.whiteboard.updated_v1 -p whiteboard_id=<whiteboard_token> --as user --max-events 1 --timeout 2m
|
||||
|
||||
# Project to "edit summary": who edited which whiteboard
|
||||
lark-cli event consume board.whiteboard.updated_v1 \
|
||||
-p whiteboard_id=<whiteboard_token> \
|
||||
--as user \
|
||||
--jq '{whiteboard: .event.whiteboard_id, editors: (.event.operator_ids | map(.open_id))}'
|
||||
# edit summary: who edited which whiteboard
|
||||
lark-cli event consume board.whiteboard.updated_v1 -p whiteboard_id=<whiteboard_token> --as user \
|
||||
--jq '{whiteboard: .event.whiteboard_id, editors: (.event.operator_ids | map(.open_id))}'
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-im
|
||||
version: 1.0.0
|
||||
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复、发送应用内/短信/电话加急。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据、管理 Feed 置顶(添加/移除/查询置顶会话)、管理标签数据时使用。"
|
||||
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复、发送应用内/短信/电话加急。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据、管理 Feed 置顶、管理标签数据时使用。不负责收发邮件(→ lark-mail)、日程与会议安排(→ lark-calendar)、会议回放与纪要(→ lark-vc)、IM 事件订阅(→ lark-event)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -12,104 +12,79 @@ metadata:
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
## 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)
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
### Identity and Token Mapping
|
||||
|
||||
- `--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.
|
||||
|
||||
### Sender Name Resolution with Bot Identity
|
||||
|
||||
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.
|
||||
|
||||
**Root cause**: The bot's app visibility settings do not include the message sender, so the contact API returns no 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 (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.
|
||||
|
||||
### 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
|
||||
|
||||
### Feed Shortcut
|
||||
|
||||
Feed shortcuts add chats to the current user's feed sidebar. They are distinct from flags:
|
||||
|
||||
- **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).
|
||||
|
||||
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.
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager |
|
||||
| [`+chat-list`](references/lark-im-chat-list.md) | List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only) |
|
||||
| [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination |
|
||||
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only) |
|
||||
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description |
|
||||
| [`+messages-mget`](references/lark-im-messages-mget.md) | Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies |
|
||||
| [`+messages-reply`](references/lark-im-messages-reply.md) | Reply to a message (supports thread replies); user/bot; supports text/markdown/post/media replies, reply-in-thread, idempotency key |
|
||||
| [`+messages-resources-download`](references/lark-im-messages-resources-download.md) | Download images/files from a message; user/bot; supports automatic chunked download for large files (8MB chunks), auto-detects file extension from Content-Type |
|
||||
| [`+messages-search`](references/lark-im-messages-search.md) | Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, supports auto-pagination via `--page-all` / `--page-limit`, enriches results via batched mget and chats batch_query |
|
||||
| [`+messages-send`](references/lark-im-messages-send.md) | Send a message to a chat or direct message; user/bot; sends to chat-id or user-id with text/markdown/post/media, supports idempotency key |
|
||||
| [`+threads-messages-list`](references/lark-im-threads-messages-list.md) | List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination |
|
||||
| [`+flag-create`](references/lark-im-flag-create.md) | Create a bookmark on a message; user-only; defaults to message-layer flag; use --flag-type feed for feed-layer flag (item_type auto-detected from chat mode) |
|
||||
| [`+flag-cancel`](references/lark-im-flag-cancel.md) | Cancel (remove) a bookmark. When no --flag-type is given, best-effort double-cancel: removes message layer and (when chat_type is determinable) feed layer |
|
||||
| [`+flag-list`](references/lark-im-flag-list.md) | List bookmarks; user-only; auto-enriches feed-type thread entries with message content; supports `--page-all` auto-pagination |
|
||||
| [`+feed-shortcut-create`](references/lark-im-feed-shortcut-create.md) | Add chats to the user's feed shortcuts; user-only; oc_xxx chat IDs only; batch up to 10 per call; `--head`/`--tail` controls insertion order; partial failures return an `ok:false` ledger |
|
||||
| [`+feed-shortcut-remove`](references/lark-im-feed-shortcut-remove.md) | Remove chats from the user's feed shortcuts; user-only; batch up to 10 per call; removing an absent shortcut is idempotent success; real per-item failures return an `ok:false` ledger |
|
||||
| [`+feed-shortcut-list`](references/lark-im-feed-shortcut-list.md) | List one page of the user's feed shortcuts; user-only; omit `--page-token` for the first page; default output enriches CHAT entries under `detail`; pass `--no-detail` to skip the extra lookup and `im:chat:read` scope |
|
||||
| [`+feed-group-list`](references/lark-im-feed-group-list.md) | List the caller's feed groups (tags); user-only; supports `--page-all` auto-pagination |
|
||||
| [`+feed-group-list-item`](references/lark-im-feed-group-list-item.md) | List feed cards in a feed group (tag); user-only; enriches each item with chat_name resolved from feed_id; supports --page-all auto-pagination |
|
||||
| [`+feed-group-query-item`](references/lark-im-feed-group-query-item.md) | Look up specific feed cards in a feed group (tag) by ID; user-only; enriches each item with chat_name resolved from feed_id |
|
||||
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat |
|
||||
| [`+chat-list`](references/lark-im-chat-list.md) | List chats the current user/bot is a member of |
|
||||
| [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation |
|
||||
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by --query keyword and/or --member-ids |
|
||||
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description |
|
||||
| [`+messages-mget`](references/lark-im-messages-mget.md) | Batch get messages by IDs |
|
||||
| [`+messages-reply`](references/lark-im-messages-reply.md) | Reply to a message (supports thread replies) |
|
||||
| [`+messages-resources-download`](references/lark-im-messages-resources-download.md) | Download images/files from a message |
|
||||
| [`+messages-search`](references/lark-im-messages-search.md) | Search messages across chats (supports keyword, sender, time range filters) with user identity |
|
||||
| [`+messages-send`](references/lark-im-messages-send.md) | Send a message to a chat or direct message |
|
||||
| [`+threads-messages-list`](references/lark-im-threads-messages-list.md) | List messages in a thread |
|
||||
| [`+flag-create`](references/lark-im-flag-create.md) | Create a bookmark on a message |
|
||||
| [`+flag-cancel`](references/lark-im-flag-cancel.md) | Cancel (remove) a bookmark |
|
||||
| [`+flag-list`](references/lark-im-flag-list.md) | List bookmarks |
|
||||
| [`+feed-shortcut-create`](references/lark-im-feed-shortcut-create.md) | Add chats to the user's feed shortcuts |
|
||||
| [`+feed-shortcut-remove`](references/lark-im-feed-shortcut-remove.md) | Remove chats from the user's feed shortcuts |
|
||||
| [`+feed-shortcut-list`](references/lark-im-feed-shortcut-list.md) | List one page of the user's feed shortcuts |
|
||||
| [`+feed-group-list`](references/lark-im-feed-group-list.md) | List the caller's feed groups (tags) |
|
||||
| [`+feed-group-list-item`](references/lark-im-feed-group-list-item.md) | List feed cards in a feed group (tag) |
|
||||
| [`+feed-group-query-item`](references/lark-im-feed-group-query-item.md) | Look up specific feed cards in a feed group (tag) by ID |
|
||||
|
||||
## Core Concepts
|
||||
|
||||
- **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 (user vs bot)
|
||||
|
||||
- `--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
|
||||
|
||||
As **bot**, the sender may show as `open_id` (bot visibility range doesn't cover it); `--as user` gives real names.
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
### Default message enrichment
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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`.
|
||||
|
||||
## 不在本 skill 范围
|
||||
|
||||
- 邮件 → [`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 暂无命令(管理员见 `chat.managers`、发言权限/全员禁言见 `chat.moderation`、个人静音见 `chat.user_setting`):其余如实告知“暂不支持”、勿臆造,引导用户到飞书客户端群设置手动操作(高风险写操作,勿擅自走原生 API 代执行)。
|
||||
|
||||
## API Resources
|
||||
|
||||
@@ -118,114 +93,47 @@ lark-cli schema im.<resource>.<method> # 调用 API 前必须先查看参数
|
||||
lark-cli im <resource> <method> [flags] # 调用 API
|
||||
```
|
||||
|
||||
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
|
||||
### chats
|
||||
|
||||
- `create` — 创建群。Identity: `bot` only (`tenant_access_token`).
|
||||
- `get` — 获取群信息。Identity: supports `user` and `bot`; the caller must be in the target chat to get full details, and must belong to the same tenant for internal chats.
|
||||
- `link` — 获取群分享链接。Identity: supports `user` and `bot`; the caller must be in the target chat, must be an owner or admin when chat sharing is restricted to owners/admins, and must belong to the same tenant for internal chats.
|
||||
- `update` — 更新群信息。Identity: supports `user` and `bot`.
|
||||
`create`(bot) · `get`(user/bot) · `link`(user/bot) · `update`(user/bot)
|
||||
|
||||
### chat.members
|
||||
|
||||
- `bots` — 获取群内机器人列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
|
||||
- `create` — 将用户或机器人拉入群聊。Identity: supports `user` and `bot`; the caller must be in the target chat; for `bot` calls, added users must be within the app's availability; for internal chats the operator must belong to the same tenant; if only owners/admins can add members, the caller must be an owner/admin, or a chat-creator bot with `im:chat:operate_as_owner`.
|
||||
- `delete` — 将用户或机器人移出群聊。Identity: supports `user` and `bot`; only group owner, admin, or creator bot can remove others; max 50 users or 5 bots per request.
|
||||
- `get` — 获取群成员列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
|
||||
`bots`(user/bot) · `create`(user/bot) · `delete`(user/bot) · `get`(user/bot)
|
||||
|
||||
### chat.user_setting
|
||||
|
||||
- `batch_query` — 批量查询当前用户在群内的个人偏好设置 (e.g. `is_muted` mutes normal messages, `is_mute_at_all` mutes @all messages); up to 10 chats per request. Identity: `user` only (`user_access_token`); the caller must be in each target chat.
|
||||
- `batch_update` — 批量更新当前用户在群内的个人偏好设置 (e.g. `is_muted` mutes normal messages, `is_mute_at_all` mutes @all messages); up to 10 chats per request. Identity: `user` only (`user_access_token`); the caller must be in each target chat.
|
||||
`batch_query`(user) · `batch_update`(user)
|
||||
|
||||
### chat.managers
|
||||
|
||||
- `add_managers` — 指定群管理员。Identity: supports `user` and `bot`; only the group owner can add managers; max 10 managers per chat (20 for super-large chats), and at most 5 bots per request.
|
||||
- `delete_managers` — 删除群管理员。Identity: supports `user` and `bot`; only the group owner can remove managers; max 50 users or 5 bots per request.
|
||||
`add_managers`(user/bot) · `delete_managers`(user/bot)
|
||||
|
||||
### chat.moderation
|
||||
|
||||
- `get` — 获取群成员发言权限。Identity: supports `user` and `bot`; the caller must be in the target chat and belong to the same tenant.
|
||||
- `update` — 更新群发言权限。Identity: supports `user` and `bot`; only the group owner (or creator bot with `im:chat:operate_as_owner`) can update; the caller must be in the chat.
|
||||
`get`(user/bot) · `update`(user/bot)
|
||||
|
||||
### messages
|
||||
|
||||
- `delete` — 撤回消息。Identity: supports `user` and `bot`; for `bot` calls, the bot must be in the chat to revoke group messages; to revoke another user's group message, the bot must be the owner, an admin, or the creator; for user P2P recalls, the target user must be within the bot's availability.
|
||||
- `forward` — 转发消息。Identity: supports `user` and `bot`.
|
||||
- `merge_forward` — 合并转发消息。Identity: `bot` only (`tenant_access_token`).
|
||||
- `read_users` — 查询消息已读信息。Identity: `bot` only (`tenant_access_token`); the bot must be in the chat, and can only query read status for messages it sent within the last 7 days.
|
||||
- `urgent_app` — 发送应用内加急。Identity: `bot` only (`tenant_access_token`); the bot must be the message sender and must be in the conversation that contains the message.
|
||||
- `urgent_phone` — 发送电话加急。Identity: `bot` only (`tenant_access_token`); the bot must be the message sender and must be in the conversation that contains the message.
|
||||
- `urgent_sms` — 发送短信加急。Identity: `bot` only (`tenant_access_token`); the bot must be the message sender and must be in the conversation that contains the message.
|
||||
`delete`(user/bot) · `forward`(user/bot) · `merge_forward`(bot) · `read_users`(bot) · `urgent_app`(bot) · `urgent_phone`(bot) · `urgent_sms`(bot)
|
||||
|
||||
### reactions
|
||||
|
||||
- `batch_query` — 批量获取消息表情。Identity: supports `user` and `bot`.[Must-read](references/lark-im-reactions.md)
|
||||
- `create` — 添加消息表情回复。Identity: supports `user` and `bot`; the caller must be in the conversation that contains the message.[Must-read](references/lark-im-reactions.md)
|
||||
- `delete` — 删除消息表情回复。Identity: supports `user` and `bot`; the caller must be in the conversation that contains the message, and can only delete reactions added by itself.[Must-read](references/lark-im-reactions.md)
|
||||
- `list` — 获取消息表情回复。Identity: supports `user` and `bot`; the caller must be in the conversation that contains the message.[Must-read](references/lark-im-reactions.md)
|
||||
`batch_query`(user/bot) · `create`(user/bot) · `delete`(user/bot) · `list`(user/bot)
|
||||
|
||||
### threads
|
||||
|
||||
- `forward` — 转发话题。Identity: supports `user` and `bot`.
|
||||
`forward`(user/bot)
|
||||
|
||||
### images
|
||||
|
||||
- `create` — 上传图片。Identity: `bot` only (`tenant_access_token`).
|
||||
`create`(bot)
|
||||
|
||||
### pins
|
||||
|
||||
- `create` — Pin 消息。Identity: supports `user` and `bot`.
|
||||
- `delete` — 移除 Pin 消息。Identity: supports `user` and `bot`.
|
||||
- `list` — 获取群内 Pin 消息。Identity: supports `user` and `bot`.
|
||||
`create`(user/bot) · `delete`(user/bot) · `list`(user/bot)
|
||||
|
||||
### feed.groups
|
||||
|
||||
- `batch_add_item` — Batch add feed cards to a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
|
||||
- `batch_query` — Batch query feed groups. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
|
||||
- `batch_remove_item` — Batch remove feed cards from a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
|
||||
- `create` — Create a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
|
||||
- `delete` — Delete a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
|
||||
- `update` — Update a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md)
|
||||
`batch_add_item`(user) · `batch_query`(user) · `batch_remove_item`(user) · `create`(user) · `delete`(user) · `update`(user)
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `chats.create` | `im:chat:create` |
|
||||
| `chats.get` | `im:chat:read` |
|
||||
| `chats.link` | `im:chat:read` |
|
||||
| `chats.update` | `im:chat:update` |
|
||||
| `chat.members.bots` | `im:chat.members:read` |
|
||||
| `chat.members.create` | `im:chat.members:write_only` |
|
||||
| `chat.members.delete` | `im:chat.members:write_only` |
|
||||
| `chat.members.get` | `im:chat.members:read` |
|
||||
| `chat.user_setting.batch_query` | `im:chat.user_setting:read` |
|
||||
| `chat.user_setting.batch_update` | `im:chat.user_setting:write` |
|
||||
| `chat.managers.add_managers` | `im:chat.managers:write_only` |
|
||||
| `chat.managers.delete_managers` | `im:chat.managers:write_only` |
|
||||
| `chat.moderation.get` | `im:chat.moderation:read` |
|
||||
| `chat.moderation.update` | `im:chat:moderation:write_only` |
|
||||
| `messages.delete` | `im:message:recall` |
|
||||
| `messages.forward` | `im:message` |
|
||||
| `messages.merge_forward` | `im:message` |
|
||||
| `messages.read_users` | `im:message:readonly` |
|
||||
| `messages.urgent_app` | `im:message.urgent` |
|
||||
| `messages.urgent_phone` | `im:message.urgent:phone` |
|
||||
| `messages.urgent_sms` | `im:message.urgent:sms` |
|
||||
| `reactions.batch_query` | `im:message.reactions:read` |
|
||||
| `reactions.create` | `im:message.reactions:write_only` |
|
||||
| `reactions.delete` | `im:message.reactions:write_only` |
|
||||
| `reactions.list` | `im:message.reactions:read` |
|
||||
| `threads.forward` | `im:message` |
|
||||
| `images.create` | `im:resource` |
|
||||
| `pins.create` | `im:message.pins:write_only` |
|
||||
| `pins.delete` | `im:message.pins:write_only` |
|
||||
| `pins.list` | `im:message.pins:read` |
|
||||
| `feed.groups.batch_add_item` | `im:feed_group_v1:write` |
|
||||
| `feed.groups.batch_query` | `im:feed_group_v1:read` |
|
||||
| `feed.groups.batch_remove_item` | `im:feed_group_v1:write` |
|
||||
| `feed.groups.create` | `im:feed_group_v1:write` |
|
||||
| `feed.groups.delete` | `im:feed_group_v1:write` |
|
||||
| `feed.groups.update` | `im:feed_group_v1:write` |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-mail
|
||||
version: 1.0.0
|
||||
description: "飞书邮箱 — draft, compose, send, reply, forward, read, and search emails; manage drafts, folders, labels, contacts, attachments, and mail rules. Use when user mentions 起草邮件, 写一封邮件, 拟邮件, 草稿, 发通知邮件, 发送邮件, 发邮件, 回复邮件, 转发邮件, 查看邮件, 看邮件, 读邮件, 搜索邮件, 查邮件, 收件箱, 邮件会话, 编辑草稿, 管理草稿, 下载附件, 邮件文件夹, 邮件标签, 邮件联系人, 监听新邮件, 收信规则, 邮件规则, draft, compose, send email, reply, forward, inbox, mail thread, mail rules."
|
||||
description: "飞书邮箱:搜索/阅读邮件、写草稿、发送/回复/转发、附件、模板、签名、收信规则、已读回执。Use for mail, inbox, draft, send, reply, forward, thread, template, rule, receipt. Do not use for non-mail docs/sheets/calendar, auth setup, pure contact lookup, or IM/chat tasks unless sharing a mail card."
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -10,9 +10,9 @@ metadata:
|
||||
|
||||
# mail (v1)
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
不要预先读取 `../lark-shared/SKILL.md`;只有认证、profile 切换、权限恢复或 `_notice` 处理时再读取。
|
||||
|
||||
**CRITICAL - 编辑邮件内容前 MUST 先用 Read 工具读取 [references/lark-mail-html.md](references/lark-mail-html.md),其中包含邮件书写规范**
|
||||
不要预先读取 `references/lark-mail-html.md`;简单 HTML 正文可直接写,复杂 HTML、本地图片或不确定安全性时再读取或运行 `+lint-html`。
|
||||
|
||||
## 核心概念
|
||||
|
||||
@@ -114,9 +114,9 @@ metadata:
|
||||
- 若用户需要,再继续帮他修改草稿或执行发送
|
||||
- 若本次产出了草稿且不是直接发信,则优先展示草稿打开链接;若当前输出没有链接,则静默处理
|
||||
|
||||
### CRITICAL — 首次使用任何命令前先查 `-h`
|
||||
### 参数不确定时先查 `-h`
|
||||
|
||||
无论是 Shortcut(`+triage`、`+send` 等)还是原生 API,**首次调用前必须先运行 `-h` 查看可用参数**,不要猜测参数名称:
|
||||
已有明确示例或已确认 flag 时可直接执行;参数、资源名或 raw API 结构不确定时,先运行 `-h` 查看可用参数,不要猜测参数名称:
|
||||
|
||||
```bash
|
||||
# Shortcut
|
||||
@@ -127,7 +127,7 @@ lark-cli mail +send -h
|
||||
lark-cli mail user_mailbox.messages -h
|
||||
```
|
||||
|
||||
`-h` 输出即可用 flag 的权威来源。reference 文档中的参数表可辅助理解语义,但实际 flag 名称以 `-h` 为准。
|
||||
`-h` 输出是可用 flag 的权威来源。reference 文档可辅助理解语义,但实际 flag 名称以 `-h` 为准。
|
||||
|
||||
### 收件人搜索:查找邮箱地址
|
||||
|
||||
@@ -238,89 +238,9 @@ lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox
|
||||
|
||||
**取消后邮件会变回草稿**,可继续编辑或在之后重新发送。
|
||||
|
||||
### 撤回邮件
|
||||
### 低频操作
|
||||
|
||||
发送成功后,若响应中包含 `recall_available: true`,说明该邮件支持撤回(24 小时内已投递的邮件)。
|
||||
|
||||
**撤回操作:**
|
||||
```bash
|
||||
lark-cli mail user_mailbox.sent_messages recall --as user \
|
||||
--params '{"user_mailbox_id":"me","message_id":"<message_id>"}'
|
||||
```
|
||||
|
||||
- 返回 `recall_status: available` 表示撤回请求已受理(异步执行)
|
||||
- 返回 `recall_status: unavailable` 表示不可撤回,`recall_restriction_reason` 说明原因
|
||||
|
||||
**查询撤回进度:**
|
||||
```bash
|
||||
lark-cli mail user_mailbox.sent_messages get_recall_detail --as user \
|
||||
--params '{"user_mailbox_id":"me","message_id":"<message_id>"}'
|
||||
```
|
||||
|
||||
- `recall_status: in_progress` — 撤回进行中,可稍后再查
|
||||
- `recall_status: done` — 撤回完成,查看 `recall_result`(`all_success` / `all_fail` / `some_fail`)和每个收件人的详情
|
||||
|
||||
**注意:** 撤回是异步操作,`recall` 返回成功仅表示请求已受理,实际结果需通过 `get_recall_detail` 查询。若响应中无 `recall_available` 字段,说明该邮件或应用不支持撤回,不要主动提及撤回。
|
||||
|
||||
### 分享邮件到 IM
|
||||
|
||||
将邮件以卡片形式分享到飞书群聊或个人会话。
|
||||
|
||||
**依赖 Scope:** `mail:user_mailbox.message:readonly`、`im:message`、`im:message.send_as_user`
|
||||
|
||||
1. 分享单封邮件到群聊(默认 `--receive-id-type chat_id`):
|
||||
```bash
|
||||
lark-cli mail +share-to-chat --message-id <邮件ID> --receive-id oc_xxx
|
||||
```
|
||||
|
||||
2. 分享整个会话到群聊:
|
||||
```bash
|
||||
lark-cli mail +share-to-chat --thread-id <会话ID> --receive-id oc_xxx
|
||||
```
|
||||
|
||||
3. 通过邮箱分享给个人:
|
||||
```bash
|
||||
lark-cli mail +share-to-chat --message-id <邮件ID> --receive-id user@example.com --receive-id-type email
|
||||
```
|
||||
|
||||
4. 如果不知道群聊 ID,先搜索:
|
||||
```bash
|
||||
lark-cli im +chat-search --query "群名关键词"
|
||||
```
|
||||
从结果中获取 `chat_id`,然后执行分享。
|
||||
|
||||
**注意:**
|
||||
- 分享需要用户在目标会话中有发消息权限
|
||||
- 需要同时授权 mail 和 im 两个域的 scope
|
||||
- 分享的卡片包含邮件摘要信息,收件人可点击查看
|
||||
|
||||
### 发送日程邀请邮件
|
||||
|
||||
在邮件中嵌入日程邀请(`text/calendar`),收件人收信后可直接接受或拒绝日程。`To`/`Cc` 收件人自动成为参会人(ATTENDEE),发件人自动成为组织者(ORGANIZER)。
|
||||
|
||||
```bash
|
||||
# 发送带日程邀请的新邮件(先保存草稿,确认后发送)
|
||||
lark-cli mail +send --as user \
|
||||
--to alice@example.com --cc bob@example.com \
|
||||
--subject '产品评审' \
|
||||
--body '<p>请参加本次产品评审会议。</p>' \
|
||||
--event-summary '产品评审' \
|
||||
--event-start '2026-05-10T14:00+08:00' \
|
||||
--event-end '2026-05-10T15:00+08:00' \
|
||||
--event-location '5F 大会议室' \
|
||||
--confirm-send
|
||||
```
|
||||
|
||||
**参数说明:**
|
||||
- `--event-summary`:日程标题,设置此参数即开启日程邀请模式,需同时设置 `--event-start` 和 `--event-end`
|
||||
- `--event-start` / `--event-end`:ISO 8601 格式时间,如 `2026-05-10T14:00+08:00`
|
||||
- `--event-location`:可选,日程地点
|
||||
|
||||
**约束:**
|
||||
- `--event-*` 与 `--send-time`(定时发送)互斥,不可同时使用
|
||||
- `Bcc` 收件人不会成为日程参会人;如果邮件同时包含 Bcc 和日程,后端在发送时会拒绝该请求
|
||||
|
||||
读取含日程邀请的邮件时,`calendar_event` 字段包含日程详情(`method`、`summary`、`start`、`end`、`organizer`、`attendees` 等)。
|
||||
撤回邮件用 `user_mailbox.sent_messages recall/get_recall_detail`,分享邮件用 `+share-to-chat`,日程邀请用 `+send --event-*`。这些低频流程先查对应命令 `-h` 或 schema,不在入口保留长示例。
|
||||
|
||||
### 正文格式:优先使用 HTML
|
||||
|
||||
@@ -341,7 +261,7 @@ lark-cli mail +reply --message-id <id> --body '收到,谢谢'
|
||||
|
||||
## 邮件书写规范
|
||||
|
||||
- 写信时**必须**遵守 [邮件 HTML 写法规范](references/lark-mail-html.md) — **CRITICAL** 飞书邮箱已验证的最纯净美观写法集合
|
||||
- 复杂 HTML、本地图片或安全不确定时再读取 [邮件 HTML 写法规范](references/lark-mail-html.md);简单正文直接使用常规 `<p>` / `<ul><li>` 即可
|
||||
- [`+lint-html` 用法](references/lark-mail-lint-html.md) — 创建草稿前自检 / 修复 HTML 输出
|
||||
- **官方模板库** [`assets/templates/`](assets/templates/) — 提供部分场景模板,可供参考
|
||||
|
||||
@@ -376,6 +296,11 @@ lark-cli mail +messages --message-ids <id1>,<id2>,<id3> --html=false
|
||||
|
||||
**套用模板(5 个发信 shortcut)**:`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` 均支持 `--template-id <id>`。`--template-id` 必须是**十进制整数字符串**。
|
||||
|
||||
**创建模板后立即发信 checklist**:
|
||||
1. `+template-create --as user --name <name> --subject <subject> --template-content <html>`,捕获真实 `template_id`
|
||||
2. 用户要求发送时不要停在模板或草稿:`+send --as user --to <email> --template-id <template_id> --confirm-send`;只有需要覆盖模板主题时再传 `--subject`
|
||||
3. 返回 `message_id` 后调用 `user_mailbox.messages send_status` 汇报投递状态
|
||||
|
||||
合并规则(与 `lark/desktop` 对齐):
|
||||
|
||||
| # | 场景 | 合并策略 |
|
||||
@@ -394,7 +319,7 @@ lark-cli mail +messages --message-ids <id1>,<id2>,<id3> --html=false
|
||||
|
||||
## 原生 API 调用规则
|
||||
|
||||
没有 Shortcut 覆盖的操作才使用原生 API。调用步骤以本节为准(API Resources 章节的 resource/method 列表可辅助查阅)。
|
||||
没有 Shortcut 覆盖的操作才使用原生 API。调用步骤以本节为准;资源和 method 用 `lark-cli mail -h` / `lark-cli mail <resource> -h` 发现,不在入口保留完整资源表。
|
||||
|
||||
### Step 1 — 用 `-h` 确定要调用的 API(必须,不可跳过)
|
||||
|
||||
@@ -463,6 +388,25 @@ lark-cli mail user_mailbox.folders create \
|
||||
- `user_mailbox_id` 几乎所有邮箱 API 都需要,一般传 `"me"` 代表当前用户
|
||||
- 列表接口支持 `--page-all` 自动翻页,无需手动处理 `page_token`
|
||||
|
||||
### 收信规则速查:主题包含文本 → 添加标签
|
||||
|
||||
只用真实 `label_id` / `rule_id`,不要猜。用户在同一请求中要求创建、验证、删除规则时,视为本流程已授权,可使用 `--yes` 通过 CLI 确认门。
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.labels list --as user \
|
||||
--params '{"user_mailbox_id":"me","page_size":20}' --page-all
|
||||
|
||||
lark-cli mail user_mailbox.rules create --as user --yes \
|
||||
--params '{"user_mailbox_id":"me"}' \
|
||||
--data '{"name":"<rule_name>","is_enable":true,"ignore_the_rest_of_rules":false,"condition":{"match_type":1,"items":[{"type":2,"operator":2,"input":"<subject_text>"}]},"action":{"items":[{"type":2,"input":"<label_id>"}]}}'
|
||||
|
||||
lark-cli mail user_mailbox.rules list --as user --params '{"user_mailbox_id":"me"}'
|
||||
lark-cli mail user_mailbox.rules delete --as user --yes \
|
||||
--params '{"user_mailbox_id":"me","rule_id":"<rule_id>"}'
|
||||
```
|
||||
|
||||
Quick codes above: condition `type=2` = subject, `operator=2` = contains, action `type=2` = add label.
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli mail +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
@@ -488,175 +432,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli mail +<verb> [flags]`)
|
||||
| [`+template-update`](references/lark-mail-template-update.md) | Update an existing mail template. Supports --inspect (read-only projection), --print-patch-template (prints a JSON skeleton for --patch-file), and flat flags (--set-subject / --set-name / etc). Internally it GETs the template, applies the patch, rewrites <img> local paths to cid: refs, and PUTs a full-replace update (no optimistic locking: last-write-wins). |
|
||||
| [`+lint-html`](references/lark-mail-lint-html.md) | Lint mail HTML body for compatibility / safety / Feishu-native rules. Returns warnings/errors and (default) auto-fixed HTML. Read-only: no draft, no API call. Use this BEFORE creating a draft to preview what the writing-path lint would change, or as a CI gate for static HTML templates. |
|
||||
|
||||
## API Resources
|
||||
## Raw API Discovery
|
||||
|
||||
```bash
|
||||
lark-cli schema mail.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
lark-cli mail <resource> <method> [flags] # 调用 API
|
||||
```
|
||||
|
||||
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
|
||||
### multi_entity
|
||||
|
||||
- `search` — 适用于写信联系人搜索
|
||||
|
||||
### user_mailboxes
|
||||
|
||||
- `accessible_mailboxes` — 列出可访问的邮箱
|
||||
- `profile` — 获取用户邮箱信息
|
||||
- `search` — 搜索邮件
|
||||
|
||||
### user_mailbox.drafts
|
||||
|
||||
- `cancel_scheduled_send` — 取消定时发送
|
||||
- `create` — 创建草稿
|
||||
- `delete` — 删除草稿
|
||||
- `get` — 获取草稿内容
|
||||
- `list` — 列出草稿列表
|
||||
- `send` — 发送草稿
|
||||
- `update` — 更新草稿
|
||||
|
||||
### user_mailbox.event
|
||||
|
||||
- `subscribe` — 订阅事件
|
||||
- `subscription` — 获取订阅状态
|
||||
- `unsubscribe` — 取消订阅
|
||||
|
||||
### user_mailbox.folders
|
||||
|
||||
- `create` — 创建邮箱文件夹
|
||||
- `delete` — 删除邮箱文件夹
|
||||
- `get` — 获取邮箱文件夹信息
|
||||
- `list` — 列出邮箱文件夹
|
||||
- `patch` — 修改邮箱文件夹
|
||||
|
||||
### user_mailbox.labels
|
||||
|
||||
- `create` — 创建标签
|
||||
- `delete` — 删除标签
|
||||
- `get` — 获取标签信息
|
||||
- `list` — 列出标签
|
||||
- `patch` — 更新标签
|
||||
|
||||
### user_mailbox.mail_contacts
|
||||
|
||||
- `create` — 创建邮箱联系人
|
||||
- `delete` — 删除邮箱联系人
|
||||
- `list` — 列出邮箱联系人
|
||||
- `patch` — 修改邮箱联系人信息
|
||||
|
||||
### user_mailbox.message.attachments
|
||||
|
||||
- `download_url` — 获取附件下载链接
|
||||
|
||||
### user_mailbox.messages
|
||||
|
||||
- `batch_get` — 批量获取邮件详情
|
||||
- `batch_modify` — 批量修改邮件
|
||||
- `batch_trash` — 批量删除邮件
|
||||
- `get` — 获取邮件详情
|
||||
- `list` — 列出邮件
|
||||
- `modify` — 修改邮件
|
||||
- `send_status` — 查询邮件发送状态
|
||||
- `trash` — 删除邮件
|
||||
|
||||
### user_mailbox.rules
|
||||
|
||||
- `create` — 创建收信规则
|
||||
- `delete` — 删除收信规则
|
||||
- `list` — 列出收信规则
|
||||
- `reorder` — 对收信规则进行排序
|
||||
- `update` — 更新收信规则
|
||||
|
||||
### user_mailbox.sent_messages
|
||||
|
||||
- `get_recall_detail` — 查询邮件撤回进度
|
||||
- `recall` — 撤回已发送的邮件
|
||||
|
||||
### user_mailbox.settings
|
||||
|
||||
- `send_as` — 列出可发信邮箱
|
||||
|
||||
### user_mailbox.template.attachments
|
||||
|
||||
- `download_url` — 获取模板附件下载链接
|
||||
|
||||
### user_mailbox.templates
|
||||
|
||||
- `create` — 创建个人邮件模板
|
||||
- `delete` — 删除指定邮件模板
|
||||
- `get` — 获取指定邮件模板详情
|
||||
- `list` — 列出指定邮箱下的全部个人邮件模板(不分页,仅返回 id 与 name)
|
||||
- `update` — 全量替换指定邮件模板内容
|
||||
|
||||
### user_mailbox.threads
|
||||
|
||||
- `batch_modify` — 批量修改邮件会话
|
||||
- `batch_trash` — 批量删除邮件会话
|
||||
- `get` — 获取邮件会话详情
|
||||
- `list` — 列出邮件会话
|
||||
- `modify` — 修改邮件会话
|
||||
- `trash` — 删除邮件会话
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `multi_entity.search` | `mail:user_mailbox:readonly` |
|
||||
| `user_mailboxes.accessible_mailboxes` | `mail:user_mailbox:readonly` |
|
||||
| `user_mailboxes.profile` | `mail:user_mailbox:readonly` |
|
||||
| `user_mailboxes.search` | `mail:user_mailbox.message:readonly` |
|
||||
| `user_mailbox.drafts.cancel_scheduled_send` | `mail:user_mailbox.message:send` |
|
||||
| `user_mailbox.drafts.create` | `mail:user_mailbox.message:modify` |
|
||||
| `user_mailbox.drafts.delete` | `mail:user_mailbox.message:modify` |
|
||||
| `user_mailbox.drafts.get` | `mail:user_mailbox.message:readonly` |
|
||||
| `user_mailbox.drafts.list` | `mail:user_mailbox.message:readonly` |
|
||||
| `user_mailbox.drafts.send` | `mail:user_mailbox.message:send` |
|
||||
| `user_mailbox.drafts.update` | `mail:user_mailbox.message:modify` |
|
||||
| `user_mailbox.event.subscribe` | `mail:event` |
|
||||
| `user_mailbox.event.subscription` | `mail:event` |
|
||||
| `user_mailbox.event.unsubscribe` | `mail:event` |
|
||||
| `user_mailbox.folders.create` | `mail:user_mailbox.folder:write` |
|
||||
| `user_mailbox.folders.delete` | `mail:user_mailbox.folder:write` |
|
||||
| `user_mailbox.folders.get` | `mail:user_mailbox.folder:read` |
|
||||
| `user_mailbox.folders.list` | `mail:user_mailbox.folder:read` |
|
||||
| `user_mailbox.folders.patch` | `mail:user_mailbox.folder:write` |
|
||||
| `user_mailbox.labels.create` | `mail:user_mailbox.message:modify` |
|
||||
| `user_mailbox.labels.delete` | `mail:user_mailbox.message:modify` |
|
||||
| `user_mailbox.labels.get` | `mail:user_mailbox.message:modify` |
|
||||
| `user_mailbox.labels.list` | `mail:user_mailbox.message:modify` |
|
||||
| `user_mailbox.labels.patch` | `mail:user_mailbox.message:modify` |
|
||||
| `user_mailbox.mail_contacts.create` | `mail:user_mailbox.mail_contact:write` |
|
||||
| `user_mailbox.mail_contacts.delete` | `mail:user_mailbox.mail_contact:write` |
|
||||
| `user_mailbox.mail_contacts.list` | `mail:user_mailbox.mail_contact:read` |
|
||||
| `user_mailbox.mail_contacts.patch` | `mail:user_mailbox.mail_contact:write` |
|
||||
| `user_mailbox.message.attachments.download_url` | `mail:user_mailbox.message.body:read` |
|
||||
| `user_mailbox.messages.batch_get` | `mail:user_mailbox.message:readonly` |
|
||||
| `user_mailbox.messages.batch_modify` | `mail:user_mailbox.message:modify` |
|
||||
| `user_mailbox.messages.batch_trash` | `mail:user_mailbox.message:modify` |
|
||||
| `user_mailbox.messages.get` | `mail:user_mailbox.message:readonly` |
|
||||
| `user_mailbox.messages.list` | `mail:user_mailbox.message:readonly` |
|
||||
| `user_mailbox.messages.modify` | `mail:user_mailbox.message:modify` |
|
||||
| `user_mailbox.messages.send_status` | `mail:user_mailbox.message:readonly` |
|
||||
| `user_mailbox.messages.trash` | `mail:user_mailbox.message:modify` |
|
||||
| `user_mailbox.rules.create` | `mail:user_mailbox.rule:write` |
|
||||
| `user_mailbox.rules.delete` | `mail:user_mailbox.rule:write` |
|
||||
| `user_mailbox.rules.list` | `mail:user_mailbox.rule:read` |
|
||||
| `user_mailbox.rules.reorder` | `mail:user_mailbox.rule:write` |
|
||||
| `user_mailbox.rules.update` | `mail:user_mailbox.rule:write` |
|
||||
| `user_mailbox.sent_messages.get_recall_detail` | `mail:user_mailbox.message:readonly` |
|
||||
| `user_mailbox.sent_messages.recall` | `mail:user_mailbox.message:modify` |
|
||||
| `user_mailbox.settings.send_as` | `mail:user_mailbox:readonly` |
|
||||
| `user_mailbox.template.attachments.download_url` | `mail:user_mailbox.message:readonly` |
|
||||
| `user_mailbox.templates.create` | `mail:user_mailbox.message:modify` |
|
||||
| `user_mailbox.templates.delete` | `mail:user_mailbox.message:modify` |
|
||||
| `user_mailbox.templates.get` | `mail:user_mailbox.message:modify` |
|
||||
| `user_mailbox.templates.list` | `mail:user_mailbox.message:modify` |
|
||||
| `user_mailbox.templates.update` | `mail:user_mailbox.message:modify` |
|
||||
| `user_mailbox.threads.batch_modify` | `mail:user_mailbox.message:modify` |
|
||||
| `user_mailbox.threads.batch_trash` | `mail:user_mailbox.message:modify` |
|
||||
| `user_mailbox.threads.get` | `mail:user_mailbox.message:readonly` |
|
||||
| `user_mailbox.threads.list` | `mail:user_mailbox.message:readonly` |
|
||||
| `user_mailbox.threads.modify` | `mail:user_mailbox.message:modify` |
|
||||
| `user_mailbox.threads.trash` | `mail:user_mailbox.message:modify` |
|
||||
调用未覆盖的原生 API 时,先用 `lark-cli mail -h` 和 `lark-cli mail <resource> -h` 找 resource/method,再用 `lark-cli schema mail.<resource>.<method>` 获取 `--params` / `--data` 结构。不要把完整资源表或 scope 表保留在常驻上下文;权限失败时按错误 hint 或 `lark-shared` 处理。
|
||||
|
||||
@@ -1,168 +1,31 @@
|
||||
---
|
||||
name: lark-shared
|
||||
version: 1.0.0
|
||||
description: "Use when first setting up lark-cli, running auth login, switching user/bot identity (--as), handling permission denied or scope errors, needing to update lark-cli, or seeing _notice in JSON output."
|
||||
version: 1.1.0
|
||||
description: "lark-cli 通用规则:user/bot 身份、认证授权、安全与高风险确认门禁。当首次配置 lark-cli、需要 auth login、遇到权限或 scope 错误、命令以退出码 10 要求确认、或输出包含 _notice 升级提示时使用。"
|
||||
---
|
||||
|
||||
# lark-cli 共享规则
|
||||
|
||||
本技能指导你如何通过lark-cli操作飞书资源, 以及有哪些注意事项。
|
||||
所有 lark-* skill 共享的底座:lark-cli 的身份、认证、安全与高风险操作通用规则。
|
||||
|
||||
## 配置初始化
|
||||
## 通用准则
|
||||
|
||||
首次使用需运行 `lark-cli config init` 完成应用配置。
|
||||
1. **调用前先懂用法**:执行 shortcut 前先读对应 reference 或跑 `-h` 弄懂用法,别猜 flag 盲调。
|
||||
|
||||
当你帮用户初始化配置时,使用background方式使用下面的命令发起配置应用流程,启动后读取输出,从中提取授权链接并发给用户。
|
||||
2. **身份决定你代表谁操作**:`--as user` 代表用户本人(能看到、也能操作其日历 / 云空间 / 邮箱等个人资源),`--as bot` 代表应用自己(只涉及 bot 的资源,发消息、建文档都归 bot)。用 `--as bot` 碰用户资源**可能静默返空**而非报错,别误判成"没有数据"。身份模型与权限恢复 → [`references/lark-shared-identity-and-permissions.md`](references/lark-shared-identity-and-permissions.md)。
|
||||
|
||||
**URL 转发规则**:当命令输出 `verification_url`、`verification_uri_complete`、`console_url` 等 URL 字段时:**必须生成二维码**:你必须调用 `lark-cli auth qrcode` 将 URL 转为二维码并展示给用户,这是必须步骤,不要跳过。优先生成 PNG 二维码(--output);仅当用户明确要求时才使用 ASCII(--ascii)。**URL 输出规则**:将 URL 视为不可修改的 opaque string,不要做任何修改(包括 URL 编码/解码、添加空格或标点、重新拼接 query),二维码和链接请一起展示给用户。
|
||||
3. **代表用户发起 `auth login` 授权时绝不阻塞**:走 split-flow(发起后交还控制权、下一轮再完成),别在同一轮阻塞等授权。完整步骤 **执行前必读** → [`references/lark-shared-auth-split-flow.md`](references/lark-shared-auth-split-flow.md)。
|
||||
|
||||
```bash
|
||||
# 发起配置(该命令会阻塞直到用户打开链接并完成操作或过期)
|
||||
lark-cli config init --new
|
||||
```
|
||||
4. **授权 / 配置类 URL 必须配二维码**:用 `lark-cli auth qrcode` 生成、URL 在前二维码在后,URL 原样不改写。
|
||||
|
||||
## 认证
|
||||
5. **退出码 10 是高风险确认门禁,不是错误**:停下、取得用户**显式同意**后才按 `hint` 重试,**绝不**静默加确认 flag 绕过。机制 → [`references/lark-shared-high-risk-approval.md`](references/lark-shared-high-risk-approval.md)。
|
||||
|
||||
### 身份类型
|
||||
6. **路径参数只接受 cwd 相对路径**:绝对路径会被拒(`unsafe file path`),规划时就用相对路径。
|
||||
|
||||
两种身份类型,通过 `--as` 切换:
|
||||
7. **不输出密钥明文**(appSecret、accessToken)。
|
||||
|
||||
| 身份 | 标识 | 获取方式 | 适用场景 |
|
||||
|------|------|---------|---------|
|
||||
| user 用户身份 | `--as user` | `lark-cli auth login` 等 | 访问用户自己的资源(日历、云空间/云盘/云存储等) |
|
||||
| bot 应用身份 | `--as bot` | 自动,只需 appId + appSecret | 应用级操作,访问bot自己的资源 |
|
||||
## 其他场景
|
||||
|
||||
### 身份选择原则
|
||||
|
||||
输出的 `[identity: bot/user]` 代表当前身份。bot 与 user 表现差异很大,需确认身份符合目标需求:
|
||||
|
||||
- **Bot 看不到用户资源**:无法访问用户的日历、云空间(云盘/云存储)文档、邮箱等个人资源。例如 `--as bot` 查日程返回 bot 自己的(空)日历
|
||||
- **Bot 无法代表用户操作**:发消息以应用名义发送,创建文档归属 bot
|
||||
- **Bot 权限**:只需在飞书开发者后台开通 scope,无需 `auth login`
|
||||
- **User 权限**:后台开通 scope + 用户通过 `auth login` 授权,两层都要满足
|
||||
|
||||
|
||||
### 权限不足处理
|
||||
|
||||
遇到权限相关错误时,**根据当前身份类型采取不同解决方案**。
|
||||
|
||||
错误响应中包含关键信息:
|
||||
- `permission_violations`:列出缺失的 scope (N选1)
|
||||
- `console_url`:飞书开发者后台的权限配置链接
|
||||
- `hint`:建议的修复命令
|
||||
|
||||
#### Bot 身份(`--as bot`)
|
||||
|
||||
将错误中的 `console_url` 原样提供给用户,引导去后台开通 scope。**禁止**对 bot 执行 `auth login`。
|
||||
|
||||
#### User 身份(`--as user`)
|
||||
|
||||
```bash
|
||||
lark-cli auth login --domain <domain> # 按业务域授权
|
||||
lark-cli auth login --scope "<missing_scope>" # 按具体 scope 授权(推荐,符合最小权限原则)
|
||||
```
|
||||
|
||||
**规则**:auth login 必须指定范围(`--domain` 或 `--scope`)。多次 login 的 scope 会累积(增量授权)。
|
||||
|
||||
#### Agent 代理发起认证(推荐)
|
||||
|
||||
当你作为 AI agent 需要帮用户完成认证时,优先使用 split-flow,避免在同一轮对话中阻塞等待用户授权:
|
||||
|
||||
```bash
|
||||
# 发起授权(立即返回 device_code 和 verification_url)
|
||||
lark-cli auth login --scope "calendar:calendar:readonly" --no-wait --json
|
||||
```
|
||||
|
||||
拿到 `verification_url` 后,将它原样作为本轮最终消息发给用户,并结束本轮/交还控制权。不要在同一轮中展示 URL 后立刻执行 `--device-code` 阻塞轮询;在不透传中间输出的 agent harness 里,这会导致用户永远看不到 URL。
|
||||
|
||||
用户回复已完成授权后,再在后续步骤执行:
|
||||
|
||||
```bash
|
||||
lark-cli auth login --device-code <device_code>
|
||||
```
|
||||
|
||||
**Split-Flow 完整步骤**:
|
||||
|
||||
**第一步:发起授权(当前轮)**
|
||||
|
||||
1. 执行 `lark-cli auth login --scope "xxx" --no-wait --json`(必须加 `--no-wait --json`)
|
||||
2. 从 JSON 输出中提取 `verification_url` 和 `device_code`
|
||||
3. 生成二维码:`lark-cli auth qrcode <verification_url> --output "xxx"`
|
||||
4. 将 URL 和二维码展示给用户(先 URL,后二维码)
|
||||
5. **结束本轮对话前,必须明确告知用户**:"请完成授权后,回来告诉我已授权完成,我会帮你完成后续步骤"
|
||||
|
||||
**第二步:完成授权(后续轮)**
|
||||
|
||||
1. 等待用户回复"已完成授权"
|
||||
2. **由你(AI agent)亲自执行**:`lark-cli auth login --device-code <device_code>`
|
||||
3. 此命令会轮询授权状态并完成登录
|
||||
4. 如果返回授权成功,流程结束
|
||||
|
||||
**关键规则**:
|
||||
|
||||
- **你必须亲自执行 `--device-code` 命令**,不要指示用户自行执行
|
||||
- **不要在同一轮中展示 URL 后立刻执行 `--device-code`**,这会导致用户看不到 URL
|
||||
- **禁止缓存 `verification_url` 或 `device_code`**:每次需要授权时,必须重新执行 `lark-cli auth login --no-wait --json` 生成新的链接。不要将授权链接和 device code 存入上下文供后续复用
|
||||
|
||||
## 更新检查
|
||||
|
||||
lark-cli 命令执行后,如果检测到新版本,JSON 输出中会包含 `_notice.update` 字段(含 `message`、`command` 等)。
|
||||
|
||||
**当你在输出中看到 `_notice.update` 时,完成用户当前请求后,主动提议帮用户更新**:
|
||||
|
||||
1. 告知用户当前版本和最新版本号
|
||||
2. 提议执行更新(同时更新 CLI 和 Skills):
|
||||
```bash
|
||||
lark-cli update
|
||||
```
|
||||
3. 更新完成后提醒用户:**退出并重新打开 AI Agent** 以加载最新 Skills
|
||||
|
||||
**重要**:始终使用 `lark-cli update` 更新,它会同时更新 CLI 和 AI Skills。
|
||||
|
||||
**规则**:不要静默忽略更新提示。即使当前任务与更新无关,也应在完成用户请求后补充告知。
|
||||
|
||||
## 安全规则
|
||||
|
||||
- **禁止输出密钥**(appSecret、accessToken)到终端明文。
|
||||
- **写入/删除操作前必须确认用户意图**。
|
||||
- 用 `--dry-run` 预览危险请求。
|
||||
- **文件路径只接受相对路径**:`--file`、`--output`、`--output-dir`、`@file` 等路径参数只接受 cwd 下的相对路径,传绝对路径会报 `unsafe file path`。数据输入(`@file`、大 JSON)优先用 stdin 传入,避免路径和转义问题。
|
||||
|
||||
## 高风险操作的审批协议(exit 10)
|
||||
|
||||
lark-cli 对高风险写操作(`risk: "high-risk-write"`)有强制确认门禁。当你不带 `--yes` 调用这类命令时,CLI 会退出码 `10`、并在 stderr 返回如下结构化 envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"type": "confirmation_required",
|
||||
"message": "drive +delete requires confirmation",
|
||||
"hint": "add --yes to confirm",
|
||||
"risk": {
|
||||
"level": "high-risk-write",
|
||||
"action": "drive +delete"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**遇到这种情况,不要当普通错误放弃。** 按以下流程处理:
|
||||
|
||||
1. **识别**:看到子进程 exit code = `10` 且 stderr JSON 里 `error.type == "confirmation_required"`
|
||||
2. **向用户确认**:把 `error.risk.action` 和关键参数展示给用户,明确告知"这是高风险操作",等待用户显式同意
|
||||
3. **用户同意** → 在你**原始 argv 的末尾追加 `--yes`** 后重试
|
||||
4. **用户拒绝** → 终止流程,不要擅自改写参数或跳过门禁
|
||||
|
||||
**绝对不允许**:
|
||||
- 看到 exit 10 就默认加 `--yes` 静默重试(这等于禁用门禁)
|
||||
- 把 `confirmation_required` 当网络错误/权限错误处理
|
||||
- 在用户没明确同意的前提下追加 `--yes` 重试
|
||||
- 用 `sh -c` 等 shell 方式拼接命令重试——用 `exec.Command(argv...)` 参数数组形式,避免 shell 解析把用户参数当作语法
|
||||
|
||||
提前预判:想先让用户 review 危险操作的具体请求,调用时加 `--dry-run`——它不触发门禁,会打印完整请求详情(URL / body / params),你可以把这个预览给用户看过再去真正执行。
|
||||
|
||||
### 如何识别一条命令是高风险
|
||||
|
||||
- shortcut:`lark-cli <service> +<cmd> --help` 顶部会显示 `Risk: high-risk-write`
|
||||
- service 命令:`lark-cli schema <service>.<resource>.<method> --format json` 的返回值里 `"risk": "high-risk-write"`
|
||||
- 首次配置 lark-cli(`config init`)→ [`references/lark-shared-config-init.md`](references/lark-shared-config-init.md)
|
||||
- 拿到 `/wiki/` 链接或 wiki token → [`references/lark-wiki-token-routing.md`](references/lark-wiki-token-routing.md)
|
||||
- 输出含 `_notice`(升级 / skills 落后 / 废弃命令提示)→ [`references/lark-shared-update-notice.md`](references/lark-shared-update-notice.md)
|
||||
|
||||
18
skills/lark-shared/references/lark-shared-auth-split-flow.md
Normal file
18
skills/lark-shared/references/lark-shared-auth-split-flow.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Agent 代理发起授权(split-flow)
|
||||
|
||||
帮用户完成 user 身份授权。背景:如果运行环境只把最终消息发给用户、不显示中间命令输出,阻塞式 `auth login` 会让用户永远看不到授权链接,所以把"发起"和"完成"拆到两轮。
|
||||
|
||||
## 第一步:发起(当前轮)
|
||||
|
||||
1. 执行 `lark-cli auth login --scope "<scope>" --no-wait --json`,从输出提取 `verification_url` 和 `device_code`。
|
||||
2. 把 `verification_url` 按正文准则配二维码展示给用户(生成二维码、URL 在前、原样不改写)。
|
||||
3. 明确告知用户"完成授权后回来告诉我",然后交还控制权。**不要**在同一轮接着执行 `--device-code` 阻塞轮询——否则用户看不到链接。
|
||||
|
||||
## 第二步:完成(后续轮)
|
||||
|
||||
等用户回复已授权,**由你(agent)亲自执行** `lark-cli auth login --device-code <device_code>`(别让用户自己跑)。该命令轮询授权状态并完成登录,成功即结束。
|
||||
|
||||
## 规则
|
||||
|
||||
- **禁止缓存 `verification_url` / `device_code`**:每次授权都重新 `--no-wait` 发起拿新值,不要存旧值复用。
|
||||
- **范围必须显式指定**:`--scope`(推荐,最小权限)或 `--domain`;多次 login 的 scope 累积(增量授权)。`--exclude` 排除特定 scope,`--recommend` 只请求可自动批准的 scope。
|
||||
11
skills/lark-shared/references/lark-shared-config-init.md
Normal file
11
skills/lark-shared/references/lark-shared-config-init.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 首次配置 lark-cli
|
||||
|
||||
首次使用需运行 `lark-cli config init --new` 完成应用配置。
|
||||
|
||||
**注意:`config init` 是阻塞命令,没有 `--no-wait`,不要套用 `auth login` 的 split-flow。** 它会一直阻塞到用户在浏览器完成配置或过期。帮用户初始化时,用 background 方式执行命令,启动后读取输出,从中提取授权链接发给用户:
|
||||
|
||||
```bash
|
||||
lark-cli config init --new
|
||||
```
|
||||
|
||||
输出里的授权 URL 按正文准则处理(生成二维码、URL 原样不改写)。
|
||||
@@ -0,0 +1,58 @@
|
||||
# 确认门禁 envelope 参考(exit 10)
|
||||
|
||||
处理协议见 SKILL.md 正文准则。本文讲报错 JSON 的两种形态、字段位置,以及重试 / 预览的两个坑。
|
||||
|
||||
## 可靠信号是退出码 10,不是 type 字符串
|
||||
|
||||
仓库正从扁平式迁往 typed 式,过渡期两种并存——扁平式仍是 shortcut / service 命令的当前形态(多数高风险命令),typed 式是已迁移命令(如 `config bind`)的新形态。**别认 `type` 字符串(迁移中会变),认退出码 10**:
|
||||
|
||||
**扁平式:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"type": "confirmation_required",
|
||||
"message": "drive +delete requires confirmation",
|
||||
"hint": "add --yes to confirm",
|
||||
"risk": { "level": "high-risk-write", "action": "drive +delete" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**typed 式:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"type": "confirmation",
|
||||
"subtype": "confirmation_required",
|
||||
"risk": "high-risk-write",
|
||||
"action": "config bind --force",
|
||||
"hint": "若用户确认切换,附加 --force 重新运行:`lark-cli config bind --identity user-default --force`"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
识别条件:exit code = 10,且 `error` 命中任一形态——`type == "confirmation_required"`(扁平),或 `type == "confirmation" && subtype == "confirmation_required"`(typed)。只判 `type == "confirmation_required"` 会漏掉 typed 式。
|
||||
|
||||
## 字段位置速查
|
||||
|
||||
| 信息 | 扁平式 | typed 式 |
|
||||
|------|--------|----------|
|
||||
| 操作名 | `error.risk.action` | `error.action` |
|
||||
| 风险级别 | `error.risk.level`(`risk` 是对象) | `error.risk`(字符串) |
|
||||
| 确认 flag | `error.hint` | `error.hint` |
|
||||
|
||||
取操作名:typed 式看 `error.action`,没有再看扁平式的 `error.risk.action`(哪个有用哪个)。`hint` 是给你看的自然语言提示,里面写明了该加哪个确认 flag(扁平式如 "add --yes to confirm" → `--yes`;`config bind` 的 hint 提示 `--force`)。**提取那个 flag 加到你自己的原始命令上**,别照抄 hint 里的完整示例命令——示例不含用户的原始参数,照抄会丢参数。
|
||||
|
||||
## 先预览再执行(可选,不触发门禁)
|
||||
|
||||
想让用户先 review 危险请求,调用时加 `--dry-run`:它不触发确认门禁,会打印完整请求(URL / body / params),可把预览给用户看过再真正执行。
|
||||
|
||||
## 如何预判一条命令是高风险
|
||||
|
||||
- shortcut:`lark-cli <service> +<cmd> --help` 顶部显示 `Risk: high-risk-write`。
|
||||
- service 命令:`lark-cli schema <service> <resource> <method> --format json` 返回值里 `"risk": "high-risk-write"`(schema 同时注入 `yes` 布尔字段标记需确认)。
|
||||
- 注意:标注 `high-risk-write` ≠ 一定走 exit-10 门禁(如 `lark-cli update` 有 risk 标注但没有 `--yes` flag、不走该门禁)。以**实际 exit 10 + envelope** 为准,不要臆造 `--yes`。
|
||||
@@ -0,0 +1,27 @@
|
||||
# 身份与权限
|
||||
|
||||
基本心智模型——`--as` 代表谁操作、`--as bot` 碰用户资源可能静默返空——见 SKILL.md 正文准则。本文补充:身份怎么获得、授权分几层、权限不足时怎么恢复。
|
||||
|
||||
## 获取方式与授权层级
|
||||
|
||||
- **user 身份**(`--as user`):用户通过 `lark-cli auth login` 授权获得。要能访问,需**两层都满足**——后台开通对应 scope + 用户 auth login 授权。
|
||||
- **bot 身份**(`--as bot`):自动,只需 appId + appSecret;只需后台开通 scope,无需 auth login。
|
||||
|
||||
输出里的 `[identity: bot/user]` 是当前身份。
|
||||
|
||||
## bot 碰用户资源的失败形态
|
||||
|
||||
因命令而异:有的静默返回空结果(如查日程落到 bot 自己的空日历),有的明确报"未登录 / 越权"。**无论哪种,都别把 bot 的结果当成用户的真实数据。**
|
||||
|
||||
## 权限 / scope 不足恢复
|
||||
|
||||
错误响应中的关键字段:
|
||||
|
||||
- 缺失的 scope:`permission_violations`(原始 API 错误块,元素形如 `{subject: "<scope>"}`)或 `missing_scopes`(CLI 结构化错误,已抽好的 scope 字符串数组)。
|
||||
- `console_url`:飞书开发者后台的权限配置链接。
|
||||
- `hint`:建议的修复命令。
|
||||
|
||||
按身份分流:
|
||||
|
||||
- **Bot 身份**:把 `console_url` 提供给用户(按正文准则配二维码转发),引导去后台开通 scope。**禁止**对 bot 执行 `auth login`,也不要因为 user 报错就降级到 bot 重试。
|
||||
- **User 身份**:补授权用 `lark-cli auth login --scope "<missing_scope>"`(推荐,最小权限)或 `--domain <domain>`;必须指定其一,多次 login 的 scope 会累积(增量授权)。作为 agent 代发起时走 split-flow,见 [`lark-shared-auth-split-flow.md`](lark-shared-auth-split-flow.md)。
|
||||
@@ -0,0 +1,9 @@
|
||||
# 升级提示(_notice)
|
||||
|
||||
命令执行后 JSON 输出可能包含 `_notice`,其下三种通知的处置都是升级:
|
||||
|
||||
- `update`:CLI 有新版本(字段 `current` / `latest` / `message` / `command`)。
|
||||
- `skills`:内置 AI Skills 落后于 CLI(字段 `current` / `target`)。
|
||||
- `deprecated_command`:本次用了已废弃的命令别名(`replacement` 为新命令名)。
|
||||
|
||||
看到任一通知都**不要静默忽略**,即使与当前任务无关:完成用户当前请求后告知情况,主动提议执行 `lark-cli update`(同时更新 CLI 和 AI Skills;加 `--check` 可只检查不安装)。更新完成后提醒用户**退出并重新打开 AI Agent** 以加载最新 Skills。
|
||||
@@ -16,6 +16,8 @@ metadata:
|
||||
> **任务清单搜索技巧**:任务清单也遵循同样的判断逻辑。先区分用户是否**特地指定使用搜索 skill**,以及是否真的提供了**清单查询关键字**(例如清单名称、关键词、片段描述)。如果用户特地指定使用搜索 skill,或明确给出了清单查询关键字,则优先使用 `+tasklist-search`。如果用户没有特地指定使用搜索 skill,且意图里没有查询关键字,只有范围条件(例如“由我创建的任务清单”“今年以来创建的清单”),并且使用搜索或原生列取清单都能达到目的时,应优先使用原生 `tasklists.list` 接口列取清单(先 `schema task.tasklists.list`,再 `lark-cli task tasklists list --as user ...`),再按 `creator`、`created_at` 等字段做本地筛选和分页控制。
|
||||
> **意图区分补充**:像“搜索飞书中今年以来我关注的任务”这类表达,虽然字面带有“搜索”,但如果没有真正的查询关键字,且本质是在限定“与我相关 + 时间范围”,则应优先走 `+get-related-tasks`;像“搜索飞书中由我创建的任务清单”这类表达,如果没有清单关键字,且本质是在限定“清单范围 + 创建者”,则应优先走原生 `tasklists.list` 后筛选,而不是直接走搜索型 shortcut。
|
||||
> **用户身份识别**:在用户身份(user identity)场景下,如果用户提到了“我”(例如“分配给我”、“由我创建”),请默认获取当前登录用户的 `open_id` 作为对应的参数值。
|
||||
> **清单范围内定位任务优先**:当用户同时给出明确任务清单和任务名称(例如“把 A 清单里的 B 标记完成/更新/查看”)时,先定位清单,再调用原生 `tasklists.tasks` 在该清单内按 `summary` 精确匹配任务;不要直接用任务名称做全局 `task +search`。只有清单任务列表没有唯一命中时,才可用全局搜索兜底;兜底候选必须再 `tasks.get` 验证其 `tasklists[].tasklist_guid` 包含目标清单 GUID 后才能执行写操作。清单内或验证后的候选仍有多个同名任务时,必须让用户确认,不能默认选择第一项。
|
||||
> **清单范围内解析负责人优先**:当用户同时给出明确任务清单和负责人姓名(例如“在 A 清单里给全名为张三的用户创建待办”)时,先定位清单,再从该清单的 `owner` / `creator` / `members` 候选用户中批量回填姓名并做精确匹配;只有清单候选内没有唯一命中时,才 fallback 到全局 `contact +search-user --query`。全局搜索命中多个同名用户且下一步是创建/分配任务时,必须让用户确认,不能默认选择第一项。
|
||||
> **术语理解 — 待办 disambiguation(必读)**:
|
||||
> - 用户提到「待办 / todo / 任务」时,**先判断归属**,不要默认走本 skill。
|
||||
> - **走 [lark-minutes](../lark-minutes/SKILL.md) 的 `minutes +todo`**(禁止本 skill):上下文含 **妙记 / 会议纪要 / minute_token / 妙记 URL**(`/minutes/`);或「在某某妙记里新建/修改待办」「妙记 AI 待办」「会议录制里的待办」。
|
||||
@@ -127,42 +129,3 @@ lark-cli task <resource> <method> [flags] # 调用 API
|
||||
### agent_task_step_info
|
||||
|
||||
- `append_task_steps` — 写入任务记录。
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `tasks.create` | `task:task:write` |
|
||||
| `tasks.delete` | `task:task:write` |
|
||||
| `tasks.get` | `task:task:read` |
|
||||
| `tasks.list` | `task:task:read` |
|
||||
| `tasks.patch` | `task:task:write` |
|
||||
| `tasklists.add_members` | `task:tasklist:write` |
|
||||
| `tasklists.create` | `task:tasklist:write` |
|
||||
| `tasklists.delete` | `task:tasklist:write` |
|
||||
| `tasklists.get` | `task:tasklist:read` |
|
||||
| `tasklists.list` | `task:tasklist:read` |
|
||||
| `tasklists.patch` | `task:tasklist:write` |
|
||||
| `tasklists.remove_members` | `task:tasklist:write` |
|
||||
| `tasklists.tasks` | `task:tasklist:read` |
|
||||
| `subtasks.create` | `task:task:write` |
|
||||
| `subtasks.list` | `task:task:read` |
|
||||
| `members.add` | `task:task:write` |
|
||||
| `members.remove` | `task:task:write` |
|
||||
| `sections.create` | `task:section:write` |
|
||||
| `sections.delete` | `task:section:write` |
|
||||
| `sections.get` | `task:section:read` |
|
||||
| `sections.list` | `task:section:read` |
|
||||
| `sections.patch` | `task:section:write` |
|
||||
| `sections.tasks` | `task:section:read` |
|
||||
| `custom_fields.create` | `task:custom_field:write` |
|
||||
| `custom_fields.get` | `task:custom_field:read` |
|
||||
| `custom_fields.patch` | `task:custom_field:write` |
|
||||
| `custom_fields.list` | `task:custom_field:read` |
|
||||
| `custom_fields.add` | `task:custom_field:write` |
|
||||
| `custom_fields.remove` | `task:custom_field:write` |
|
||||
| `custom_field_options.create` | `task:custom_field:write` |
|
||||
| `custom_field_options.patch` | `task:custom_field:write` |
|
||||
| `agent.update_agent_profile` | `task:task:write` |
|
||||
| `agent.register_agent` | `task:task:write` |
|
||||
| `agent_task_step_info.append_task_steps` | `task:task:write` |
|
||||
|
||||
@@ -20,8 +20,15 @@ lark-cli task +complete --task-id "<task_guid>"
|
||||
## Workflow
|
||||
|
||||
1. Confirm the task to complete.
|
||||
2. Execute the command.
|
||||
3. Report success.
|
||||
2. If the user gives both a tasklist and a task name, resolve the task inside that tasklist before using global task search:
|
||||
- Locate the tasklist first. If `+tasklist-search` has no recall, use `tasklists.list` pagination and exact local name matching.
|
||||
- Read incomplete tasks from the list with `lark-cli task tasklists tasks --as user --params '{"tasklist_guid":"<tasklist_guid>","completed":false,"page_size":100,"user_id_type":"open_id"}'`.
|
||||
- Match `summary` exactly against the requested task name. If exactly one incomplete task matches, use that task `guid`.
|
||||
- If the incomplete list has no unique exact match, global `lark-cli task +search --query "<task name>" --as user` may be used only as a fallback. For every fallback candidate, call `tasks.get` and keep only candidates whose `tasklists[].tasklist_guid` contains the target tasklist GUID.
|
||||
- If more than one in-scope task still matches, ask the user to disambiguate before completing; do not choose the first result.
|
||||
3. Execute `lark-cli task +complete --task-id "<task_guid>"`.
|
||||
4. Verify completion by reading the same tasklist's incomplete tasks again with `completed:false`; confirm the completed task's `guid` or exact `summary` is absent. Optionally read `completed:true` or `tasks.get` to show the task is now done.
|
||||
5. Report success.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **Write Operation** -- You must confirm the user's intent before executing.
|
||||
|
||||
@@ -45,8 +45,14 @@ lark-cli task +create --summary "Test Task" --dry-run
|
||||
|
||||
1. Confirm with the user: task summary, due date, assignee, and tasklist if necessary.
|
||||
- **Crucial Rule for Assignee**: If the user explicitly or implicitly says "create a task for me" (给我创建一个任务), or "help me create a task" (帮我新建/创建一个任务), you MUST assign the task to the current logged-in user. You can get the current user's `open_id` by executing `lark-cli auth status` (it already outputs JSON by default, so do not add `--json`) or `lark-cli contact +get-user` first, extracting `.identities.user.openId` (from `auth status`) or `.data.user.open_id` (from `contact +get-user`), and then passing it to the `--assignee` parameter.
|
||||
2. Execute `lark-cli task +create --summary "..." ...`
|
||||
3. Report the result: task ID and summary.
|
||||
2. If the user provides both a specific tasklist and an assignee name, resolve the assignee within the tasklist context before global contact search:
|
||||
- Locate the tasklist and collect candidate user IDs from `owner`, `creator`, and `members` (ignore non-user members such as chats).
|
||||
- Batch hydrate those IDs with `lark-cli contact +search-user --user-ids "<ou_a,ou_b,...>" --as user`.
|
||||
- Prefer an exact full-name match (`localized_name` equals the requested name). If exactly one user matches, use that `open_id`.
|
||||
- If there is no exact match in the tasklist candidates, fallback to `lark-cli contact +search-user --query "<name>" --exclude-external-users --as user`.
|
||||
- If global search returns multiple exact same-name users, ask the user to confirm by email or department before creating the task; do not choose the first result.
|
||||
3. Execute `lark-cli task +create --summary "..." ...`
|
||||
4. Report the result: task ID and summary.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **Write Operation** -- You must confirm the user's intent before executing.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
- `TestAppsAccessScopeSetDryRun`: CLI input `specific`/`public`/`tenant` -> server enum `Range`/`All`/`Tenant`; `apply_config.approvers` shape; four mutex rejection paths.
|
||||
- `TestAppsAccessScopeGetDryRun`: URL shape; no body/params on GET; `--app-id` required.
|
||||
- `TestAppsHTMLPublishDryRun`: walker manifest for directory + single file; hidden files intentionally included (design decision); empty dir / missing `index.html` produce envelope `validation_error` field (dry-run exits 0 advisory, not blocking); both required-flag rejections.
|
||||
- `TestAppsGitCredentialInitDryRun`: URL shape for issuing a Miaoda Git PAT; no body; `app_id` query metadata included.
|
||||
- `TestAppsGitCredentialInitDryRun`: URL shape for issuing an app Git PAT; no body; `app_id` query metadata included.
|
||||
- `TestAppsGitCredentialListLocalE2E`: local-only command scans every app storage directory and reports repository URL and status without exposing PAT or expiry details.
|
||||
- `TestAppsGitCredentialRemoveLocalE2E`: local cleanup command removes app-scoped metadata under an isolated config dir.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user