mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
2 Commits
eval/skill
...
feat/apps-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4c313c8f1 | ||
|
|
a2c820643d |
20
CHANGELOG.md
20
CHANGELOG.md
@@ -2,25 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.53] - 2026-06-12
|
||||
|
||||
### Features
|
||||
|
||||
- **auth**: Revoke user tokens server-side on `auth logout` (#1434)
|
||||
- **auth**: Add `--json` flag support to auth subcommands (#1431)
|
||||
- **token**: Mint TAT via unified OAuth v3 Token Endpoint (#1408)
|
||||
- **note**: Split note into a dedicated domain with `+detail` and `+transcript` flows (#1345, #1417, #1435)
|
||||
- **im**: Unify sort flags into `--sort` field and `--order` direction (#1302)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **apps**: Read release error_logs from `data.error_logs` in `+release-get` (#1436)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Optimize whiteboard skill (#1371)
|
||||
- **skills**: Optimize okr skill (#1368)
|
||||
|
||||
## [v1.0.52] - 2026-06-11
|
||||
|
||||
### Features
|
||||
@@ -1149,7 +1130,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
|
||||
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
|
||||
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51
|
||||
[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50
|
||||
|
||||
@@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
|
||||
// (not backed by from_meta service specs). Descriptions are now centralized in
|
||||
// service_descriptions.json.
|
||||
func getShortcutOnlyDomainNames() []string {
|
||||
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
|
||||
return []string{"base", "contact", "docs", "markdown", "apps"}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -215,12 +214,6 @@ func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) {
|
||||
if !slices.Contains(getShortcutOnlyDomainNames(), "note") {
|
||||
t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectScopesForDomains(t *testing.T) {
|
||||
projects := registry.ListFromMetaProjects()
|
||||
if len(projects) == 0 {
|
||||
|
||||
@@ -72,28 +72,11 @@ func authLogoutRun(opts *LogoutOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
httpClient, httpErr := f.HttpClient()
|
||||
appSecret, secretErr := core.ResolveSecretInput(app.AppSecret, f.Keychain)
|
||||
|
||||
for _, user := range app.Users {
|
||||
if httpErr == nil && secretErr == nil {
|
||||
if token := larkauth.GetStoredToken(app.AppId, user.UserOpenId); token != nil {
|
||||
revokeToken := token.RefreshToken
|
||||
tokenTypeHint := "refresh_token"
|
||||
if revokeToken == "" {
|
||||
revokeToken = token.AccessToken
|
||||
tokenTypeHint = "access_token"
|
||||
}
|
||||
if revokeToken != "" {
|
||||
_ = larkauth.RevokeToken(httpClient, app.AppId, appSecret, app.Brand, revokeToken, tokenTypeHint)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := larkauth.RemoveStoredToken(app.AppId, user.UserOpenId); err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Warning: failed to remove token for %s: %v\n", user.UserOpenId, err)
|
||||
}
|
||||
}
|
||||
|
||||
app.Users = []core.AppUser{}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
|
||||
@@ -5,14 +5,12 @@ package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
@@ -147,210 +145,3 @@ func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) {
|
||||
t.Errorf("stderr = %q, want success text", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "cli_test",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "cli_test",
|
||||
UserOpenId: "ou_user",
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "user-refresh-token",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathOAuthRevoke,
|
||||
Body: map[string]interface{}{"code": 0},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
values, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return values.Get("client_id") == "cli_test" &&
|
||||
values.Get("client_secret") == "secret" &&
|
||||
values.Get("token") == "user-refresh-token" &&
|
||||
values.Get("token_type_hint") == "refresh_token"
|
||||
},
|
||||
})
|
||||
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
if got := stderr.String(); !strings.Contains(got, "Logged out") {
|
||||
t.Fatalf("stderr = %q, want Logged out", got)
|
||||
}
|
||||
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||
t.Fatalf("expected stored token removed, got %#v", got)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
|
||||
t.Fatalf("expected users cleared, got %#v", saved.Apps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "cli_test",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "cli_test",
|
||||
UserOpenId: "ou_user",
|
||||
AccessToken: "user-access-token",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathOAuthRevoke,
|
||||
Body: map[string]interface{}{"code": 0},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
values, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return values.Get("client_id") == "cli_test" &&
|
||||
values.Get("client_secret") == "secret" &&
|
||||
values.Get("token") == "user-access-token" &&
|
||||
values.Get("token_type_hint") == "access_token"
|
||||
},
|
||||
})
|
||||
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
if got := stderr.String(); !strings.Contains(got, "Logged out") {
|
||||
t.Fatalf("stderr = %q, want Logged out", got)
|
||||
}
|
||||
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||
t.Fatalf("expected stored token removed, got %#v", got)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
|
||||
t.Fatalf("expected users cleared, got %#v", saved.Apps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "cli_test",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "cli_test",
|
||||
UserOpenId: "ou_user",
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "user-refresh-token",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathOAuthRevoke,
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{"error": "server_error"},
|
||||
})
|
||||
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
gotErr := stderr.String()
|
||||
if strings.Contains(gotErr, "failed to revoke token for ou_user") {
|
||||
t.Fatalf("stderr = %q, want no revoke warning", gotErr)
|
||||
}
|
||||
if !strings.Contains(gotErr, "Logged out") {
|
||||
t.Fatalf("stderr = %q, want Logged out", gotErr)
|
||||
}
|
||||
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||
t.Fatalf("expected stored token removed, got %#v", got)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
|
||||
t.Fatalf("expected users cleared, got %#v", saved.Apps)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,16 +33,15 @@ const probeTimeout = 3 * time.Second
|
||||
//
|
||||
// 1. A TAT request using the just-saved credentials. credential.FetchTAT
|
||||
// returns a typed errs.* error (via the shared classifyTATResponseCode)
|
||||
// only when the unified Token Endpoint deterministically rejected the
|
||||
// credentials — an OAuth2 invalid_client / unauthorized_client classified as
|
||||
// CategoryConfig / SubtypeInvalidClient, or whatever codemeta maps. That
|
||||
// typed error is propagated so the root dispatcher renders the canonical
|
||||
// envelope and `config init` exits non-zero — identical to how every other
|
||||
// token-resolving command reports the same bad credentials. Ambiguous
|
||||
// failures (transport errors, transient 5xx/server_error, JSON parse errors,
|
||||
// timeouts) come back as raw untyped errors and are swallowed (return nil),
|
||||
// so valid configurations are never disturbed by upstream noise.
|
||||
// errs.IsTyped is the discriminator.
|
||||
// only when the server deterministically rejected the credentials — a
|
||||
// non-zero TAT body code, classified as CategoryConfig / SubtypeInvalidClient
|
||||
// (10003 / 10014) or whatever codemeta maps. That typed error is propagated
|
||||
// so the root dispatcher renders the canonical envelope and `config init`
|
||||
// exits non-zero — identical to how every other token-resolving command
|
||||
// reports the same bad credentials. Ambiguous failures (transport errors,
|
||||
// HTTP non-200, JSON parse errors, timeouts) come back as raw untyped
|
||||
// errors and are swallowed (return nil), so valid configurations are never
|
||||
// disturbed by upstream noise. errs.IsTyped is the discriminator.
|
||||
//
|
||||
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
|
||||
// that call (success, server error, timeout, parse failure) is always
|
||||
|
||||
@@ -31,10 +31,10 @@ type fakeRT struct {
|
||||
|
||||
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.HasSuffix(req.URL.Path, "/oauth/v3/token"):
|
||||
case strings.HasSuffix(req.URL.Path, "/auth/v3/tenant_access_token/internal"):
|
||||
f.tatCalls++
|
||||
if f.tatHandler == nil {
|
||||
return jsonResp(200, `{"code":0,"access_token":"t-ok","token_type":"Bearer"}`), nil
|
||||
return jsonResp(200, `{"code":0,"tenant_access_token":"t-ok"}`), nil
|
||||
}
|
||||
return f.tatHandler(req)
|
||||
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
|
||||
@@ -84,15 +84,14 @@ func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.B
|
||||
}
|
||||
|
||||
// assertConfigRejection asserts runProbe propagated a deterministic credential
|
||||
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient). This
|
||||
// is the same typed error every other token-resolving command returns for the
|
||||
// same bad credentials, and nothing is written to stderr (the root dispatcher
|
||||
// renders the envelope). The numeric code is not asserted: the unified v3 Token
|
||||
// Endpoint reports invalid_client via the OAuth2 error string, not a Lark code.
|
||||
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient) with
|
||||
// the expected upstream code. This is the same typed error every other
|
||||
// token-resolving command returns for the same bad credentials, and nothing is
|
||||
// written to stderr (the root dispatcher renders the envelope).
|
||||
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCode int) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected *errs.ConfigError, got nil")
|
||||
t.Fatalf("expected *errs.ConfigError (code %d), got nil", wantCode)
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
@@ -104,6 +103,9 @@ func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != wantCode {
|
||||
t.Errorf("Code = %d, want %d", cfgErr.Code, wantCode)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
|
||||
}
|
||||
@@ -121,13 +123,11 @@ func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||
}
|
||||
}
|
||||
|
||||
// invalid_client (bad / non-existent app_id or wrong secret) → the v3 Token
|
||||
// Endpoint returns HTTP 400 with the OAuth2 error → ConfigError/InvalidClient,
|
||||
// propagated. The probe endpoint must not be called when TAT fails.
|
||||
func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
|
||||
// 10003 (bad / non-existent app_id) → ConfigError/InvalidClient, propagated.
|
||||
func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(400, `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`), nil
|
||||
return jsonResp(200, `{"code":10003,"msg":"invalid param"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
@@ -137,27 +137,28 @@ func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
|
||||
if rt.probeCalls != 0 {
|
||||
t.Error("probe endpoint must not be called when TAT fails")
|
||||
}
|
||||
assertConfigRejection(t, err, errBuf)
|
||||
assertConfigRejection(t, err, errBuf, 10003)
|
||||
}
|
||||
|
||||
// unauthorized_client is treated as the same credential rejection, propagated.
|
||||
func TestRunProbe_TATUnauthorizedClient_ReturnsConfigError(t *testing.T) {
|
||||
// 10014 (real app_id + wrong secret) → ConfigError/InvalidClient via codemeta —
|
||||
// the most common real-world rejection, propagated.
|
||||
func TestRunProbe_TATCode10014_ReturnsConfigError(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(401, `{"error":"unauthorized_client","error_description":"client not authorized"}`), nil
|
||||
return jsonResp(200, `{"code":10014,"msg":"app secret invalid"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf, 10014)
|
||||
}
|
||||
|
||||
// Any other deterministic client-side OAuth error (e.g. invalid_scope) falls
|
||||
// back to *errs.APIError via BuildAPIError — still typed, so the probe surfaces
|
||||
// it rather than swallowing — but is not a credential (ConfigError) rejection.
|
||||
func TestRunProbe_TATOtherClientError_Propagates(t *testing.T) {
|
||||
// Any non-zero body code is a deterministic rejection and propagates (typed).
|
||||
// An unrecognized code falls back to *errs.APIError via BuildAPIError — still
|
||||
// typed, so the probe still surfaces it rather than swallowing.
|
||||
func TestRunProbe_TATUnknownBodyCode_Propagates(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(400, `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`), nil
|
||||
return jsonResp(200, `{"code":99999,"msg":"future-unknown"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
|
||||
@@ -47,7 +47,6 @@ type DeviceFlowResult struct {
|
||||
// OAuthEndpoints contains the OAuth endpoint URLs.
|
||||
type OAuthEndpoints struct {
|
||||
DeviceAuthorization string
|
||||
Revoke string
|
||||
Token string
|
||||
}
|
||||
|
||||
@@ -56,7 +55,6 @@ func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints {
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
return OAuthEndpoints{
|
||||
DeviceAuthorization: ep.Accounts + PathDeviceAuthorization,
|
||||
Revoke: ep.Accounts + PathOAuthRevoke,
|
||||
Token: ep.Open + PathOAuthTokenV2,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,6 @@ func TestResolveOAuthEndpoints_Feishu(t *testing.T) {
|
||||
if ep.DeviceAuthorization != "https://accounts.feishu.cn/oauth/v1/device_authorization" {
|
||||
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
|
||||
}
|
||||
if ep.Revoke != "https://accounts.feishu.cn/oauth/v1/revoke" {
|
||||
t.Errorf("Revoke = %q", ep.Revoke)
|
||||
}
|
||||
if ep.Token != "https://open.feishu.cn/open-apis/authen/v2/oauth/token" {
|
||||
t.Errorf("Token = %q", ep.Token)
|
||||
}
|
||||
@@ -45,9 +42,6 @@ func TestResolveOAuthEndpoints_Lark(t *testing.T) {
|
||||
if ep.DeviceAuthorization != "https://accounts.larksuite.com/oauth/v1/device_authorization" {
|
||||
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
|
||||
}
|
||||
if ep.Revoke != "https://accounts.larksuite.com/oauth/v1/revoke" {
|
||||
t.Errorf("Revoke = %q", ep.Revoke)
|
||||
}
|
||||
if ep.Token != "https://open.larksuite.com/open-apis/authen/v2/oauth/token" {
|
||||
t.Errorf("Token = %q", ep.Token)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ package auth
|
||||
const (
|
||||
// PathDeviceAuthorization is the endpoint for device authorization.
|
||||
PathDeviceAuthorization = "/oauth/v1/device_authorization"
|
||||
// PathOAuthRevoke is the endpoint for revoking an OAuth token.
|
||||
PathOAuthRevoke = "/oauth/v1/revoke"
|
||||
// PathAppRegistration is the endpoint for application registration.
|
||||
PathAppRegistration = "/oauth/v1/app/registration"
|
||||
// PathOAuthTokenV2 is the endpoint for requesting an OAuth token (v2).
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// RevokeToken revokes a previously issued OAuth token.
|
||||
func RevokeToken(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, token, tokenTypeHint string) error {
|
||||
endpoints := ResolveOAuthEndpoints(brand)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("client_id", appId)
|
||||
form.Set("client_secret", appSecret)
|
||||
form.Set("token", token)
|
||||
if tokenTypeHint != "" {
|
||||
form.Set("token_type_hint", tokenTypeHint)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, endpoints.Revoke, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "token revoke request creation failed: %v", err).WithCause(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "token revoke transport error: %v", err).WithCause(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
logHTTPResponse(resp)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "token revoke read error: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return revokeHTTPStatusError(resp.StatusCode, body)
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if code := getInt(data, "code", 0); code != 0 {
|
||||
msg := getStr(data, "msg")
|
||||
if msg == "" {
|
||||
msg = getStr(data, "message")
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "unknown error"
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "token revoke failed [%d]: %s", code, msg).
|
||||
WithCode(code).
|
||||
WithCause(errors.New(msg))
|
||||
}
|
||||
|
||||
if errStr := getStr(data, "error"); errStr != "" {
|
||||
msg := getStr(data, "error_description")
|
||||
if msg == "" {
|
||||
msg = errStr
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "token revoke failed: %s", msg).
|
||||
WithCause(errors.New(msg))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func revokeHTTPStatusError(status int, body []byte) error {
|
||||
msg := formatOAuthErrorBody(body)
|
||||
cause := errors.New(strings.TrimSpace(string(body)))
|
||||
if strings.TrimSpace(string(body)) == "" {
|
||||
cause = errors.New(msg)
|
||||
}
|
||||
if status >= http.StatusInternalServerError {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkServer, "token revoke failed: HTTP %d: %s", status, msg).
|
||||
WithCode(status).
|
||||
WithRetryable().
|
||||
WithCause(cause)
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if status == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "token revoke failed: HTTP %d: %s", status, msg).
|
||||
WithCode(status).
|
||||
WithCause(cause)
|
||||
}
|
||||
|
||||
func formatOAuthErrorBody(body []byte) string {
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if trimmed == "" {
|
||||
return "empty response"
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
if msg := getStr(data, "error_description"); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if msg := getStr(data, "msg"); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if msg := getStr(data, "message"); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if msg := getStr(data, "error"); msg != "" {
|
||||
return msg
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
type revokeRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn revokeRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return fn(req)
|
||||
}
|
||||
|
||||
type errReadCloser struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (r errReadCloser) Read(_ []byte) (int, error) {
|
||||
return 0, r.err
|
||||
}
|
||||
|
||||
func (r errReadCloser) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRevokeToken_PostsExpectedForm(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathOAuthRevoke,
|
||||
Body: map[string]interface{}{"code": 0},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
values, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return values.Get("client_id") == "cli_a" &&
|
||||
values.Get("client_secret") == "secret_b" &&
|
||||
values.Get("token") == "user-access-token" &&
|
||||
values.Get("token_type_hint") == "access_token"
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err != nil {
|
||||
t.Fatalf("RevokeToken() error = %v", err)
|
||||
}
|
||||
if got := stub.CapturedHeaders.Get("Content-Type"); got != "application/x-www-form-urlencoded" {
|
||||
t.Fatalf("Content-Type = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_DoFailureReturnsTypedNetworkError(t *testing.T) {
|
||||
sentinel := errors.New("transport down")
|
||||
httpClient := &http.Client{
|
||||
Transport: revokeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, sentinel
|
||||
}),
|
||||
}
|
||||
|
||||
err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("problem = %#v, want network/transport", p)
|
||||
}
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("expected cause %v to be preserved, got %v", sentinel, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_ReportsHTTPError(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathOAuthRevoke,
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{"error": "invalid_token"},
|
||||
})
|
||||
|
||||
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Code != 400 {
|
||||
t.Fatalf("problem = %#v, want api error with HTTP 400", p)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid_token") {
|
||||
t.Fatalf("expected invalid_token error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_ReportsOAuthCodeErrorAsTypedAPIError(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathOAuthRevoke,
|
||||
Body: map[string]interface{}{
|
||||
"code": 12345,
|
||||
"msg": "invalid revoke state",
|
||||
},
|
||||
})
|
||||
|
||||
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Code != 12345 {
|
||||
t.Fatalf("problem = %#v, want api error with code 12345", p)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid revoke state") {
|
||||
t.Fatalf("expected oauth error message, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_ReportsOAuthErrorFieldAsTypedAPIError(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathOAuthRevoke,
|
||||
Body: map[string]interface{}{
|
||||
"error": "invalid_token",
|
||||
"error_description": "token already expired",
|
||||
},
|
||||
})
|
||||
|
||||
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Fatalf("problem = %#v, want api error", p)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "token already expired") {
|
||||
t.Fatalf("expected oauth error_description, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_ReadFailureReturnsTypedInternalError(t *testing.T) {
|
||||
sentinel := errors.New("read failed")
|
||||
httpClient := &http.Client{
|
||||
Transport: revokeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: errReadCloser{err: sentinel},
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("problem = %#v, want internal/invalid_response", p)
|
||||
}
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("expected cause %v to be preserved, got %v", sentinel, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "token revoke read error") {
|
||||
t.Fatalf("expected read error message, got %v", err)
|
||||
}
|
||||
if _, ok := err.(*errs.InternalError); !ok {
|
||||
t.Fatalf("expected *errs.InternalError, got %T", err)
|
||||
}
|
||||
}
|
||||
@@ -22,12 +22,6 @@ func ParseBrand(value string) LarkBrand {
|
||||
return BrandFeishu
|
||||
}
|
||||
|
||||
// OAuthTokenV3Path is the unified OAuth 2.0 Token Endpoint path on the accounts
|
||||
// domain. It serves every grant type (client_credentials for TAT,
|
||||
// authorization_code / device_code / refresh_token for UAT) and replaces the
|
||||
// legacy per-token endpoints (e.g. /open-apis/auth/v3/tenant_access_token/internal).
|
||||
const OAuthTokenV3Path = "/oauth/v3/token"
|
||||
|
||||
// Endpoints holds resolved endpoint URLs for different Lark services.
|
||||
type Endpoints struct {
|
||||
Open string // e.g. "https://open.feishu.cn"
|
||||
|
||||
@@ -42,11 +42,6 @@ func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) {
|
||||
if ep.Open != "https://open.feishu.cn" {
|
||||
t.Errorf("Open = %q, want feishu.cn for empty brand", ep.Open)
|
||||
}
|
||||
// The unified OAuth v3 Token Endpoint mints TAT on the accounts domain;
|
||||
// pin the default-brand host so a stray non-production domain revert is caught.
|
||||
if ep.Accounts != "https://accounts.feishu.cn" {
|
||||
t.Errorf("Accounts = %q, want accounts.feishu.cn for empty brand", ep.Accounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenBaseURL(t *testing.T) {
|
||||
|
||||
@@ -19,44 +19,33 @@ import (
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
)
|
||||
|
||||
// classifyTATResponseCode wraps a deterministic (non-transient) failure from the
|
||||
// unified Token Endpoint into the canonical typed errs.* error. The v3 endpoint
|
||||
// reports failures using the OAuth 2.0 model — an `error` string plus an
|
||||
// optional numeric `code` — instead of the legacy `{code, msg}` shape.
|
||||
// classifyTATResponseCode wraps a non-zero TAT endpoint response code into the
|
||||
// canonical typed error. The TAT mint endpoint reports invalid credentials
|
||||
// with two distinct codes:
|
||||
//
|
||||
// invalid_client / unauthorized_client mean the configured app_id/app_secret
|
||||
// cannot mint a token; from the user's perspective that is the same actionable
|
||||
// CategoryConfig/InvalidClient failure the legacy 10003/10014 codes produced.
|
||||
// Every other deterministic error falls through to BuildAPIError, which still
|
||||
// yields a typed error so probe callers (errs.IsTyped) surface it rather than
|
||||
// swallowing it. Transient/server-side failures (5xx / server_error) are
|
||||
// filtered out by FetchTAT before this is called, so they stay untyped.
|
||||
func classifyTATResponseCode(code int, oauthErr, errDesc, brand, appID string) error {
|
||||
msg := errDesc
|
||||
if msg == "" {
|
||||
msg = oauthErr
|
||||
}
|
||||
switch oauthErr {
|
||||
case "invalid_client", "unauthorized_client":
|
||||
// - 10003: bad app_id format or non-existent app_id ("invalid param")
|
||||
// - 10014: invalid app_secret ("app secret invalid")
|
||||
//
|
||||
// Both surface as CategoryConfig/InvalidClient from the user's perspective —
|
||||
// the configured credentials cannot mint a tenant access token. 10014 is
|
||||
// globally mapped in codemeta (TAT-mint-specific variant of OAuth 99991543).
|
||||
// 10003 is NOT globally mapped because in other Lark endpoints it carries
|
||||
// unrelated semantics (e.g. task API uses 10003 for permission denied), so
|
||||
// the override stays local to this TAT call site instead of leaking into the
|
||||
// shared codemeta table.
|
||||
func classifyTATResponseCode(code int, msg, brand, appID string) error {
|
||||
if code == 10003 {
|
||||
return errs.NewConfigError(errs.SubtypeInvalidClient, "%s", msg).
|
||||
WithCode(code).
|
||||
WithHint("%s", errclass.ConfigHint(errs.SubtypeInvalidClient))
|
||||
}
|
||||
if err := errclass.BuildAPIError(map[string]any{
|
||||
return errclass.BuildAPIError(map[string]any{
|
||||
"code": code,
|
||||
"msg": msg,
|
||||
}, errclass.ClassifyContext{
|
||||
Brand: brand,
|
||||
AppID: appID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
// BuildAPIError returns nil for code 0 (Feishu's success convention), but this
|
||||
// function is only reached once FetchTAT has ruled out success — a non-credential
|
||||
// OAuth error (e.g. invalid_scope) can arrive with code 0 and is still a
|
||||
// deterministic rejection. Back it with a typed APIError so callers never receive
|
||||
// the ("", nil) "empty token, no error" pair.
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "%s", msg).WithCode(code)
|
||||
})
|
||||
}
|
||||
|
||||
// DefaultAccountProvider resolves account from config.json via keychain.
|
||||
@@ -157,8 +146,8 @@ func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, er
|
||||
return &TokenResult{Token: token, Scopes: scopes}, nil
|
||||
}
|
||||
|
||||
// resolveTAT resolves a tenant access token. The result is cached after the first
|
||||
// call via sync.Once — only the context from the first call is used.
|
||||
// resolveTAT resolves a tenant access token. Result is cached after first call.
|
||||
// NOTE: Uses sync.Once — only the context from the first call is used.
|
||||
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) {
|
||||
p.tatOnce.Do(func() {
|
||||
p.tatResult, p.tatErr = p.doResolveTAT(ctx)
|
||||
|
||||
@@ -19,16 +19,18 @@ func TestDefaultAccountProvider_Implements(t *testing.T) {
|
||||
var _ DefaultAccountResolver = &DefaultAccountProvider{}
|
||||
}
|
||||
|
||||
// TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient pins that the
|
||||
// unified Token Endpoint's OAuth2 invalid_client error surfaces as
|
||||
// CategoryConfig/InvalidClient — the configured app_id/app_secret cannot mint a
|
||||
// tenant access token, the same actionable failure the legacy 10003/10014 codes
|
||||
// produced. The numeric code is intentionally not asserted: the v3 endpoint may
|
||||
// return invalid_client with no Lark code (code defaults to 0).
|
||||
func TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient(t *testing.T) {
|
||||
err := classifyTATResponseCode(0, "invalid_client", "client authentication failed", "feishu", "cli_app_x")
|
||||
// TestClassifyTATResponseCode_10003_MapsToInvalidClient pins that the TAT
|
||||
// endpoint's "invalid param" code surfaces as CategoryConfig/InvalidClient.
|
||||
// Reason: a bad or non-existent app_id triggers 10003 on the TAT mint endpoint,
|
||||
// which from the user's perspective is the same actionable failure as 10014
|
||||
// ("app secret invalid") — both mean the configured credentials cannot mint a
|
||||
// tenant access token. The global codemeta intentionally does not map 10003
|
||||
// because in other Lark endpoints 10003 carries unrelated semantics (e.g. task
|
||||
// API uses it for permission denied), so the override is local to this site.
|
||||
func TestClassifyTATResponseCode_10003_MapsToInvalidClient(t *testing.T) {
|
||||
err := classifyTATResponseCode(10003, "invalid param", "feishu", "cli_app_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for invalid_client")
|
||||
t.Fatal("expected non-nil error for code=10003")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
@@ -40,16 +42,22 @@ func TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient(t *testing.T)
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != 10003 {
|
||||
t.Errorf("Code = %d, want 10003", cfgErr.Code)
|
||||
}
|
||||
if cfgErr.Hint == "" {
|
||||
t.Error("Hint must be non-empty so the user gets a recovery action")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient pins that
|
||||
// unauthorized_client is treated as the same credential failure as
|
||||
// invalid_client.
|
||||
func TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient(t *testing.T) {
|
||||
err := classifyTATResponseCode(0, "unauthorized_client", "client not authorized", "feishu", "cli_app_x")
|
||||
// TestClassifyTATResponseCode_10014_RoutesViaCodeMeta pins that 10014 still
|
||||
// goes through the global BuildAPIError path (codemeta entry) so the override
|
||||
// for 10003 does not regress the existing mapping.
|
||||
func TestClassifyTATResponseCode_10014_RoutesViaCodeMeta(t *testing.T) {
|
||||
err := classifyTATResponseCode(10014, "app secret invalid", "feishu", "cli_app_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for code=10014")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
|
||||
@@ -57,38 +65,21 @@ func TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient(t *testi
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyTATResponseCode_OtherErrorFallsThrough pins that OAuth errors
|
||||
// outside the credential set fall through to the generic BuildAPIError fallback
|
||||
// — still typed, but not a ConfigError. The mapping is narrow and intentional.
|
||||
func TestClassifyTATResponseCode_OtherErrorFallsThrough(t *testing.T) {
|
||||
err := classifyTATResponseCode(20068, "invalid_scope", "unauthorized scope", "feishu", "cli_app_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for invalid_scope")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
t.Fatalf("invalid_scope must not be classified as ConfigError, got %T", err)
|
||||
if cfgErr.Code != 10014 {
|
||||
t.Errorf("Code = %d, want 10014", cfgErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped pins the code-0
|
||||
// backstop: a non-credential OAuth error (e.g. invalid_scope) that arrives with no
|
||||
// numeric code (code 0) must still produce a non-nil typed error. BuildAPIError
|
||||
// returns nil for code 0 (Feishu's success convention); without the backstop,
|
||||
// FetchTAT would surface this deterministic rejection as ("", nil) — an empty token
|
||||
// with no error.
|
||||
func TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped(t *testing.T) {
|
||||
err := classifyTATResponseCode(0, "invalid_scope", "the requested scope is not granted", "feishu", "cli_app_x")
|
||||
// TestClassifyTATResponseCode_UnknownCodeFallsThrough pins that codes outside
|
||||
// the credential set fall through to the generic BuildAPIError fallback
|
||||
// (CategoryAPI/SubtypeUnknown) — the override is narrow and intentional.
|
||||
func TestClassifyTATResponseCode_UnknownCodeFallsThrough(t *testing.T) {
|
||||
err := classifyTATResponseCode(99999999, "some unknown failure", "feishu", "cli_app_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for code-0 invalid_scope (must not be swallowed as success)")
|
||||
}
|
||||
if !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
t.Fatal("expected non-nil error for unmapped code")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
t.Fatalf("code-0 invalid_scope must not be a ConfigError, got %T", err)
|
||||
t.Fatalf("unmapped code must not be classified as ConfigError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,47 +4,46 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// FetchTAT performs a single HTTP POST to mint a tenant access token via the
|
||||
// unified OAuth 2.0 Token Endpoint ({accounts}/oauth/v3/token) using the
|
||||
// client_credentials grant with client_secret_post authentication. It does not
|
||||
// read configuration or keychain, so callers that already hold plaintext
|
||||
// credentials (e.g. the post-`config init` probe) can validate them without a
|
||||
// second keychain round-trip.
|
||||
// FetchTAT performs a single HTTP POST to mint a tenant access token with the
|
||||
// given credentials. It does not read configuration or keychain, so callers
|
||||
// that already hold plaintext credentials (e.g. the post-`config init` probe)
|
||||
// can validate them without a second keychain round-trip.
|
||||
//
|
||||
// A deterministic client-side rejection (e.g. invalid_client) returns the
|
||||
// canonical typed error from classifyTATResponseCode — the SAME classification
|
||||
// doResolveTAT (and thus every token-resolving command) produces, so callers
|
||||
// see one consistent envelope. Transport failures, unreadable/unparseable
|
||||
// bodies, and transient server-side failures (5xx / server_error) are returned
|
||||
// raw (untyped), leaving them ambiguous; a caller can use errs.IsTyped to tell a
|
||||
// deterministic credential rejection apart from upstream/transport noise.
|
||||
// A non-zero TAT response code means the server inspected the payload and
|
||||
// rejected the credentials; FetchTAT returns the canonical typed error from
|
||||
// classifyTATResponseCode — the SAME classification doResolveTAT (and thus
|
||||
// every token-resolving command) produces, so callers see one consistent
|
||||
// envelope (CategoryConfig / SubtypeInvalidClient for 10003 / 10014, etc.).
|
||||
// Transport, HTTP-status and JSON-parse failures are returned raw (untyped),
|
||||
// leaving them ambiguous; a caller can use errs.IsTyped to tell a deterministic
|
||||
// credential rejection apart from upstream/transport noise.
|
||||
//
|
||||
// The caller owns the context timeout.
|
||||
func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID, appSecret string) (string, error) {
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
endpoint := ep.Accounts + core.OAuthTokenV3Path
|
||||
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "client_credentials")
|
||||
form.Set("client_id", appID)
|
||||
form.Set("client_secret", appSecret)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"app_id": appID,
|
||||
"app_secret": appSecret,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal TAT request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -52,51 +51,20 @@ func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read TAT response: %w", err)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
AccessToken string `json:"access_token"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
Msg string `json:"msg"`
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
TenantAccessToken string `json:"tenant_access_token"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
// An unparseable body is ambiguous (covers non-JSON error pages and
|
||||
// truncated payloads); stay untyped so probe callers treat it as noise.
|
||||
return "", fmt.Errorf("failed to parse TAT response (HTTP %d): %w", resp.StatusCode, err)
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse TAT response: %w", err)
|
||||
}
|
||||
|
||||
if result.Code == 0 && result.AccessToken != "" {
|
||||
return result.AccessToken, nil
|
||||
if result.Code != 0 {
|
||||
return "", classifyTATResponseCode(result.Code, result.Msg, string(brand), appID)
|
||||
}
|
||||
|
||||
// Transient/server-side failures stay untyped so probe callers stay silent and
|
||||
// retryers can back off; only deterministic client rejections are typed. Covers
|
||||
// 5xx, HTTP 429 rate-limit, and the OAuth transient error strings (server_error,
|
||||
// temporarily_unavailable, slow_down) — matching the legacy "non-2xx is noise"
|
||||
// behavior so a rate-limited probe is not surfaced as a hard credential error.
|
||||
if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests ||
|
||||
result.Error == "server_error" || result.Error == "temporarily_unavailable" ||
|
||||
result.Error == "slow_down" {
|
||||
return "", fmt.Errorf("TAT endpoint transient failure (HTTP %d, code=%d, error=%q): %s",
|
||||
resp.StatusCode, result.Code, result.Error, result.ErrorDescription)
|
||||
}
|
||||
|
||||
// A 2xx with neither token nor error is a malformed success — ambiguous, untyped.
|
||||
if result.Code == 0 && result.Error == "" {
|
||||
return "", fmt.Errorf("TAT response missing access_token (HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Prefer the OAuth error_description; fall back to the legacy Lark `msg` so a
|
||||
// gateway-level {code, msg} response (carrying no OAuth fields) still yields a
|
||||
// non-empty typed message instead of a bare "API error: [code]".
|
||||
desc := result.ErrorDescription
|
||||
if desc == "" {
|
||||
desc = result.Msg
|
||||
}
|
||||
return "", classifyTATResponseCode(result.Code, result.Error, desc, string(brand), appID)
|
||||
return result.TenantAccessToken, nil
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error)
|
||||
func TestFetchTAT_Success(t *testing.T) {
|
||||
rt := &stubRoundTripper{
|
||||
respCode: 200,
|
||||
respBody: `{"code":0,"access_token":"t-abc","token_type":"Bearer","expires_in":7200}`,
|
||||
respBody: `{"code":0,"tenant_access_token":"t-abc","msg":"ok"}`,
|
||||
}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
@@ -55,33 +55,24 @@ func TestFetchTAT_Success(t *testing.T) {
|
||||
if token != "t-abc" {
|
||||
t.Errorf("token = %q, want t-abc", token)
|
||||
}
|
||||
if rt.gotReq.URL.String() != "https://accounts.feishu.cn/oauth/v3/token" {
|
||||
if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" {
|
||||
t.Errorf("url = %s", rt.gotReq.URL.String())
|
||||
}
|
||||
if ct := rt.gotReq.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" {
|
||||
t.Errorf("Content-Type = %q, want application/x-www-form-urlencoded", ct)
|
||||
}
|
||||
// client_secret_post: grant_type + client_id + client_secret in the form body.
|
||||
for _, want := range []string{"grant_type=client_credentials", "client_id=cli_app", "client_secret=secret_x"} {
|
||||
if !strings.Contains(rt.gotBody, want) {
|
||||
t.Errorf("request body missing %q: %s", want, rt.gotBody)
|
||||
}
|
||||
if !strings.Contains(rt.gotBody, `"app_id":"cli_app"`) || !strings.Contains(rt.gotBody, `"app_secret":"secret_x"`) {
|
||||
t.Errorf("request body missing credentials: %s", rt.gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
// invalid_client (wrong app_id/app_secret on the client_credentials grant) is a
|
||||
// deterministic client-side rejection that FetchTAT routes to
|
||||
// 10003 (bad / non-existent app_id, "invalid param") is classified locally by
|
||||
// classifyTATResponseCode as CategoryConfig / SubtypeInvalidClient — the same
|
||||
// typed error doResolveTAT (and thus every token-resolving command) returns.
|
||||
// The v3 endpoint reports it as HTTP 400 with the OAuth2 error body (wrong
|
||||
// secret → code 20002, unknown app → code 20048).
|
||||
func TestFetchTAT_InvalidClient_ConfigInvalidClient(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 400, respBody: `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`}
|
||||
func TestFetchTAT_Code10003_ConfigInvalidClient(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10003,"msg":"invalid param"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid_client")
|
||||
t.Fatal("expected error for code 10003")
|
||||
}
|
||||
if token != "" {
|
||||
t.Errorf("token = %q, want empty", token)
|
||||
@@ -96,115 +87,52 @@ func TestFetchTAT_InvalidClient_ConfigInvalidClient(t *testing.T) {
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != 10003 {
|
||||
t.Errorf("Code = %d, want 10003", cfgErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Any other deterministic client-side OAuth error (e.g. invalid_scope) still
|
||||
// yields a typed error (errs.IsTyped) via BuildAPIError — so a probe caller
|
||||
// surfaces it rather than silently swallowing it — but is NOT classified as a
|
||||
// credential (invalid_client) problem.
|
||||
func TestFetchTAT_OtherClientError_Typed(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 400, respBody: `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`}
|
||||
// 10014 ("app secret invalid") — the most common real-world rejection (real
|
||||
// app_id + wrong secret) — is globally mapped in codemeta to
|
||||
// CategoryConfig / SubtypeInvalidClient via BuildAPIError.
|
||||
func TestFetchTAT_Code10014_ConfigInvalidClient(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10014,"msg":"app secret invalid"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid_scope")
|
||||
}
|
||||
if !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
t.Errorf("invalid_scope must not be classified as ConfigError/InvalidClient, got %T", err)
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error not *errs.ConfigError: %T %v", err, err)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient || cfgErr.Code != 10014 {
|
||||
t.Errorf("got Subtype=%q Code=%d, want invalid_client/10014", cfgErr.Subtype, cfgErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A deterministic OAuth error that arrives WITHOUT a numeric code (code defaults to
|
||||
// 0) must still surface as a non-nil typed error — never the ("", nil) success pair.
|
||||
// Guards the code-0 backstop in classifyTATResponseCode: BuildAPIError returns nil
|
||||
// for code 0, which would otherwise swallow this rejection into an empty-token success.
|
||||
func TestFetchTAT_OtherClientError_CodeZero_Typed(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 400, respBody: `{"error":"invalid_scope","error_description":"the requested scope is not granted"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
tok, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for code-0 invalid_scope (must not return empty token + nil error)")
|
||||
}
|
||||
if tok != "" {
|
||||
t.Errorf("token = %q, want empty", tok)
|
||||
}
|
||||
if !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A gateway-style {code, msg} error (no OAuth error / error_description fields)
|
||||
// must still surface its msg on the typed error, not degrade to a generic
|
||||
// "API error: [code]". Guards the legacy-msg fallback in FetchTAT.
|
||||
func TestFetchTAT_LarkStyleMsg_FallsBackOnTypedError(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 400, respBody: `{"code":99999,"msg":"app ticket invalid"}`}
|
||||
// Any non-zero body code is a deterministic server-side rejection, so it
|
||||
// always yields a typed error (errs.IsTyped). An unrecognized code falls back
|
||||
// to CategoryAPI / SubtypeUnknown via BuildAPIError — still typed, so a probe
|
||||
// caller still surfaces it rather than silently swallowing.
|
||||
func TestFetchTAT_UnknownBodyCode_Typed(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":99999,"msg":"future-unknown"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for {code, msg} response")
|
||||
t.Fatal("expected error for code 99999")
|
||||
}
|
||||
if !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "app ticket invalid") {
|
||||
t.Errorf("typed error must carry the Lark msg, got: %v", err)
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Errorf("unknown code should fall back to *errs.APIError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Transient server-side failures (5xx / server_error) are NOT deterministic
|
||||
// credential rejections — they must stay UNTYPED so a probe caller treats them
|
||||
// as upstream noise and stays silent (and retryers can back off).
|
||||
func TestFetchTAT_ServerError_Untyped(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 500, respBody: `{"code":20050,"error":"server_error","error_description":"please retry"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for server_error")
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("server_error must be UNTYPED (transient), got typed %T %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Rate-limiting is transient, not a deterministic credential rejection — an HTTP
|
||||
// 429 (even with a parseable OAuth body) and the OAuth slow_down error must both
|
||||
// stay UNTYPED so a rate-limited probe stays silent and retryers can back off.
|
||||
func TestFetchTAT_RateLimit_Untyped(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
code int
|
||||
body string
|
||||
}{
|
||||
{"http 429", 429, `{"code":99991400,"error":"too_many_requests","error_description":"rate limit exceeded"}`},
|
||||
{"oauth slow_down", 200, `{"error":"slow_down","error_description":"polling too fast"}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: tc.code, respBody: tc.body}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for rate-limit")
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("rate-limit must be UNTYPED (transient), got typed %T %v", err, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Non-2xx HTTP with a non-JSON body is ambiguous (not a structured OAuth
|
||||
// rejection) — it must stay UNTYPED so a probe caller treats it as upstream
|
||||
// noise and stays silent.
|
||||
// Non-2xx HTTP is ambiguous (not a payload-level credential rejection) — it
|
||||
// must stay UNTYPED so a probe caller treats it as upstream noise and stays
|
||||
// silent.
|
||||
func TestFetchTAT_HTTPNon200_Untyped(t *testing.T) {
|
||||
for _, code := range []int{401, 403, 500, 503} {
|
||||
rt := &stubRoundTripper{respCode: code, respBody: `whatever`}
|
||||
@@ -254,12 +182,12 @@ func TestFetchTAT_BrandRouting(t *testing.T) {
|
||||
brand core.LarkBrand
|
||||
wantURL string
|
||||
}{
|
||||
{core.BrandFeishu, "https://accounts.feishu.cn/oauth/v3/token"},
|
||||
{core.BrandLark, "https://accounts.larksuite.com/oauth/v3/token"},
|
||||
{core.BrandFeishu, "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"},
|
||||
{core.BrandLark, "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(string(tc.brand), func(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"access_token":"t","token_type":"Bearer"}`}
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"tenant_access_token":"t"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
if _, err := FetchTAT(context.Background(), hc, tc.brand, "a", "b"); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -65,7 +65,7 @@ var codeMeta = map[int]CodeMeta{
|
||||
|
||||
// CategoryConfig
|
||||
99991543: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // RFC 6749 §5.2 — app_id / app_secret incorrect (Open API)
|
||||
10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // legacy TAT endpoint — "app secret invalid" (pre-v3 variant of 99991543; CLI now reports invalid_client)
|
||||
10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // TAT endpoint — "app secret invalid" (TAT-mint variant of 99991543)
|
||||
|
||||
// CategoryPolicy
|
||||
21000: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeChallengeRequired},
|
||||
|
||||
@@ -35,12 +35,9 @@ const (
|
||||
LarkErrAppNotInUse = 99991662 // app is disabled in this tenant
|
||||
LarkErrAppUnauthorized = 99991673 // app status unavailable; check installation
|
||||
|
||||
// "Wrong app credentials" code from the LEGACY TAT endpoint
|
||||
// (/open-apis/auth/v3/tenant_access_token/internal returns 10014, "app secret
|
||||
// invalid", instead of 99991543). Since the OAuth v3 migration the CLI mints
|
||||
// TAT via accounts/oauth/v3/token and reports this as the OAuth invalid_client
|
||||
// error, so it no longer emits 10014 itself; the constant + codemeta mapping
|
||||
// are retained as a defensive fallback should 10014 still arrive.
|
||||
// TAT-endpoint variant of the "wrong app credentials" condition.
|
||||
// /open-apis/auth/v3/tenant_access_token/internal returns code 10014
|
||||
// ("app secret invalid") instead of 99991543 when the secret is wrong.
|
||||
LarkErrTATInvalidSecret = 10014
|
||||
|
||||
// Rate limit.
|
||||
|
||||
@@ -47,10 +47,6 @@
|
||||
"en": { "title": "Minutes", "description": "Minutes content and metadata retrieval" },
|
||||
"zh": { "title": "妙记", "description": "妙记信息获取、内容查询" }
|
||||
},
|
||||
"note": {
|
||||
"en": { "title": "Note", "description": "Meeting note detail and unified transcript retrieval" },
|
||||
"zh": { "title": "会议纪要", "description": "会议纪要详情与 unified 逐字稿查询" }
|
||||
},
|
||||
"sheets": {
|
||||
"en": { "title": "Sheets", "description": "Spreadsheet operations" },
|
||||
"zh": { "title": "电子表格", "description": "电子表格操作" }
|
||||
|
||||
@@ -7,10 +7,7 @@
|
||||
// carrying their own copy.
|
||||
package suggest
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
import "sort"
|
||||
|
||||
// Levenshtein computes the classic edit distance between two strings. It is
|
||||
// rune-aware, so it is correct for multi-byte input.
|
||||
@@ -54,29 +51,22 @@ 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
|
||||
contain bool
|
||||
prefix int
|
||||
dist int
|
||||
name string
|
||||
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)
|
||||
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})
|
||||
// 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})
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -104,21 +94,6 @@ 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
|
||||
|
||||
@@ -25,11 +25,9 @@ var migratedCommonHelperPaths = []string{
|
||||
"shortcuts/doc/",
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/event/",
|
||||
"shortcuts/im/",
|
||||
"shortcuts/mail/",
|
||||
"shortcuts/markdown/",
|
||||
"shortcuts/minutes/",
|
||||
"shortcuts/note/",
|
||||
"shortcuts/okr/",
|
||||
"shortcuts/sheets/",
|
||||
"shortcuts/slides/",
|
||||
|
||||
@@ -26,11 +26,9 @@ var migratedEnvelopePaths = []string{
|
||||
"shortcuts/doc/",
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/event/",
|
||||
"shortcuts/im/",
|
||||
"shortcuts/mail/",
|
||||
"shortcuts/markdown/",
|
||||
"shortcuts/minutes/",
|
||||
"shortcuts/note/",
|
||||
"shortcuts/okr/",
|
||||
"shortcuts/sheets/",
|
||||
"shortcuts/slides/",
|
||||
@@ -38,6 +36,7 @@ var migratedEnvelopePaths = []string{
|
||||
"shortcuts/vc/",
|
||||
"shortcuts/whiteboard/",
|
||||
"shortcuts/wiki/",
|
||||
"shortcuts/im/",
|
||||
}
|
||||
|
||||
// legacyOutputImportPath is the import path of the package that declares the
|
||||
|
||||
@@ -953,7 +953,6 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
|
||||
paths := []string{
|
||||
"shortcuts/doc/docs_fetch_v2.go",
|
||||
"shortcuts/drive/drive_search.go",
|
||||
"shortcuts/im/im_messages_send.go",
|
||||
"shortcuts/mail/mail_send.go",
|
||||
"shortcuts/markdown/markdown_fetch.go",
|
||||
"shortcuts/okr/okr_progress_create.go",
|
||||
@@ -989,18 +988,6 @@ common.` + helper + `()
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigratedCommonHelperPaths_CoverMigratedEnvelopePaths(t *testing.T) {
|
||||
commonPaths := make(map[string]struct{}, len(migratedCommonHelperPaths))
|
||||
for _, path := range migratedCommonHelperPaths {
|
||||
commonPaths[path] = struct{}{}
|
||||
}
|
||||
for _, path := range migratedEnvelopePaths {
|
||||
if _, ok := commonPaths[path]; !ok {
|
||||
t.Fatalf("migratedEnvelopePaths contains %q but migratedCommonHelperPaths does not", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_RejectsDangerousCharsOnCalendarPath(t *testing.T) {
|
||||
src := `package calendar
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.53",
|
||||
"version": "1.0.52",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -13,12 +13,12 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsAccessScopeGet reads the current access scope configuration of an app.
|
||||
// AppsAccessScopeGet reads the current access scope configuration of a Miaoda app.
|
||||
// 响应原样透传服务端契约(字符串 scope 枚举 All/Tenant/Range + 拆分的 users/departments/chats 数组)。
|
||||
var AppsAccessScopeGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+access-scope-get",
|
||||
Description: "Get app access scope configuration",
|
||||
Description: "Get Miaoda 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 app access scope")
|
||||
Desc("Get Miaoda 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 app access scope (specific / public / tenant)",
|
||||
Description: "Set Miaoda 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 app access scope")
|
||||
Desc("Set Miaoda 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 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 Miaoda apps"
|
||||
|
||||
// AppsCreate creates a new app.
|
||||
// AppsCreate creates a new Miaoda app.
|
||||
var AppsCreate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+create",
|
||||
Description: "Create a new app",
|
||||
Description: "Create a new Miaoda 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 an app").
|
||||
Desc("Create a Miaoda 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 an app(拆分单库为 dev/online 多环境)。
|
||||
// AppsDBEnvCreate creates a DB environment for a Miaoda 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: "app id", Required: true},
|
||||
{Name: "app-id", Desc: "Miaoda 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 app DB environment").
|
||||
Desc("Create Miaoda 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 an app database.
|
||||
// AppsDBExecute executes SQL against a Miaoda 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 an app database",
|
||||
Description: "Execute SQL (SELECT / DML / DDL) against a Miaoda 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: "app id", Required: true},
|
||||
{Name: "app-id", Desc: "Miaoda 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 app database").
|
||||
Desc("Execute SQL on Miaoda 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: "app id", Required: true},
|
||||
{Name: "app-id", Desc: "Miaoda 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 app db table schema").
|
||||
Desc("Get Miaoda 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 an app's database.
|
||||
// AppsDBTableList lists tables in a Miaoda 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 an app database (cursor pagination)",
|
||||
Description: "List tables in a Miaoda 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: "app id", Required: true},
|
||||
{Name: "app-id", Desc: "Miaoda 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 app db tables").
|
||||
Desc("List Miaoda 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 an app (single multipart POST returns the access URL)",
|
||||
Description: "Publish HTML to a Miaoda 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: "app ID", Required: true},
|
||||
{Name: "app-id", Desc: "Miaoda 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("index.html is the app entrypoint; for a directory put index.html at the root, or pass a single file named 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")
|
||||
}
|
||||
|
||||
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 app config"
|
||||
commitMsgUpgrade = "chore: initialize app repository"
|
||||
commitMsgAppConfig = "chore: initialize miaoda app config"
|
||||
commitMsgUpgrade = "chore: initialize miaoda 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 an app's code and local development environment.
|
||||
// AppsInit initializes a Miaoda app's code and local development environment.
|
||||
var AppsInit = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+init",
|
||||
Description: "Initialize an app's code and local development environment",
|
||||
Description: "Initialize a Miaoda 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: "app ID"},
|
||||
{Name: "app-id", Desc: "Miaoda 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 app code (credential-init, clone, checkout, npx code-init, optional commit/push)").
|
||||
Desc("Initialize Miaoda 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 app
|
||||
// isAlreadyInitialized reports whether dir is an already-initialized Miaoda 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 app repo -> skip clone/scaffold/commit, but still refresh
|
||||
// initialized Miaoda 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 app config (.spark/.agent) — skipping
|
||||
// commits — app project code, then Miaoda 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 "app config" group (.spark/ and .agent/). It returns the exact
|
||||
// and the "Miaoda 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 app-config group: the .spark or
|
||||
// isConfigPath reports whether p is the Miaoda 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 app config"}
|
||||
want := []string{"chore: initialize app project code", "chore: initialize miaoda 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 app config" {
|
||||
if len(msgs) != 1 || msgs[0] != "chore: initialize miaoda 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 app repository" {
|
||||
if len(msgs) != 1 || msgs[0] != "chore: initialize miaoda 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 apps visible to the calling user (cursor pagination).
|
||||
// AppsList lists Miaoda 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 apps visible to the calling user (cursor pagination)",
|
||||
Description: "List Miaoda 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 apps").
|
||||
Desc("List Miaoda 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 an app.
|
||||
// AppsReleaseCreate creates a release for a Miaoda app.
|
||||
var AppsReleaseCreate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+release-create",
|
||||
Description: "Create a release for an app (returns release_id for status polling)",
|
||||
Description: "Create a release for a Miaoda 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: "app ID", Required: true},
|
||||
{Name: "app-id", Desc: "Miaoda 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: "app ID", Required: true},
|
||||
{Name: "app-id", Desc: "Miaoda 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 {
|
||||
@@ -57,9 +57,6 @@ var AppsReleaseGet = common.Shortcut{
|
||||
out := data
|
||||
if release, ok := data["release"].(map[string]interface{}); ok {
|
||||
out = release
|
||||
if el, ok := data["error_logs"]; ok {
|
||||
out["error_logs"] = el
|
||||
}
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "release_id: %v\nstatus: %v\ncreated_at: %v\nupdated_at: %v\n",
|
||||
|
||||
@@ -134,15 +134,13 @@ func TestAppsReleaseGetPrettyFailedErrorLogs(t *testing.T) {
|
||||
URL: "/open-apis/spark/v1/apps/app_x/releases/6",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{
|
||||
"release": map[string]interface{}{
|
||||
"release_id": "6", "status": "failed",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000050",
|
||||
},
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "6", "status": "failed",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000050",
|
||||
"error_logs": []interface{}{
|
||||
map[string]interface{}{"step": "build", "error_log": "compile error"},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
@@ -202,13 +200,11 @@ func TestAppsReleaseGetPrettyFailedEmptyLogs(t *testing.T) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/9",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{
|
||||
"release": map[string]interface{}{
|
||||
"release_id": "9", "status": "failed",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000050",
|
||||
},
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "9", "status": "failed",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000050",
|
||||
"error_logs": []interface{}{},
|
||||
}},
|
||||
}}},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
@@ -218,69 +214,6 @@ func TestAppsReleaseGetPrettyFailedEmptyLogs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetJSONErrorLogsPassthrough(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "6")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/6",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{
|
||||
"release": map[string]interface{}{
|
||||
"release_id": "6", "status": "failed",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000050",
|
||||
},
|
||||
"error_logs": []interface{}{
|
||||
map[string]interface{}{"step": "build", "error_log": "compile error"},
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v\nraw: %s", err, stdoutBuf.String())
|
||||
}
|
||||
logs, ok := env.Data["error_logs"].([]interface{})
|
||||
if !ok || len(logs) != 1 {
|
||||
t.Fatalf("JSON must passthrough data.error_logs, got: %v", env.Data["error_logs"])
|
||||
}
|
||||
first, _ := logs[0].(map[string]interface{})
|
||||
if first["step"] != "build" || first["error_log"] != "compile error" {
|
||||
t.Errorf("error_logs content mismatch: %v", logs[0])
|
||||
}
|
||||
// flattened release fields must still be present alongside error_logs
|
||||
if env.Data["release_id"] != "6" || env.Data["status"] != "failed" {
|
||||
t.Errorf("flattened release fields missing: %v", env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetJSONNoErrorLogsKeyWhenAbsent(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "5")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/5",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "5", "status": "finished",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000001",
|
||||
}}},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
var env struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v\nraw: %s", err, stdoutBuf.String())
|
||||
}
|
||||
if _, present := env.Data["error_logs"]; present {
|
||||
t.Errorf("error_logs key must be absent when API omits it, got: %v", env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetPrettyCommitID(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "10")
|
||||
rctx.Format = "pretty"
|
||||
|
||||
@@ -14,11 +14,11 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsReleaseList lists an app's release history (most recent first).
|
||||
// AppsReleaseList lists a Miaoda app's release history (most recent first).
|
||||
var AppsReleaseList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+release-list",
|
||||
Description: "List an app's release history (most recent first)",
|
||||
Description: "List a Miaoda 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: "app ID", Required: true},
|
||||
{Name: "app-id", Desc: "Miaoda 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 app.
|
||||
// AppsSessionCreate creates a new session under an existing Miaoda app.
|
||||
var AppsSessionCreate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+session-create",
|
||||
Description: "Create a session under an app",
|
||||
Description: "Create a session under a Miaoda 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 an app")
|
||||
Desc("Create a session under a Miaoda 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 an app (cursor pagination, single page).
|
||||
// AppsSessionList lists sessions under a Miaoda app (cursor pagination, single page).
|
||||
var AppsSessionList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+session-list",
|
||||
Description: "List sessions under an app (cursor pagination)",
|
||||
Description: "List sessions under a Miaoda 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 an app").
|
||||
Desc("List sessions under a Miaoda 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 an app's name / description.
|
||||
// AppsUpdate partially updates a Miaoda app's name / description.
|
||||
var AppsUpdate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+update",
|
||||
Description: "Partially update an app (only provided fields are sent)",
|
||||
Description: "Partially update a Miaoda 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 an app").
|
||||
Desc("Update a Miaoda 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 apps domain.
|
||||
// apiBasePath is the registered OAPI prefix for the Miaoda 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 app id. The app_/cli_ format rule is taught in
|
||||
// the correct Miaoda 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 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 Miaoda 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 an app repository",
|
||||
Description: "Initialize Git credentials and a URL-scoped Git helper for a Miaoda 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: "app ID", Required: true},
|
||||
{Name: "app-id", Desc: "Miaoda 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 an app Git repository PAT").
|
||||
Desc("Issue a Miaoda 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 app Git credential", err)
|
||||
return gitCredentialLocalError("Initialize local Miaoda 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 an app repository",
|
||||
Description: "Remove local Git credentials and the URL-scoped Git helper for a Miaoda 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: "app ID", Required: true},
|
||||
{Name: "app-id", Desc: "Miaoda 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 app Git credential", err)
|
||||
return gitCredentialLocalError("Remove local Miaoda 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 app repositories",
|
||||
Description: "List local Git credentials for Miaoda 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 app Git credentials", err)
|
||||
return gitCredentialLocalError("List local Miaoda 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 app repositories",
|
||||
Short: "Git credential helper for Miaoda 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", "", "app ID")
|
||||
cmd.Flags().String("app-id", "", "Miaoda 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 app Git credential: response missing gitURL")
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing gitURL")
|
||||
}
|
||||
if issued.PAT == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response missing token")
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda 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 app Git credential: empty response body")
|
||||
"Issue Miaoda 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 app Git credential: %s", message).WithCode(int(code))
|
||||
baseErr := errs.NewAPIError(errs.SubtypeUnknown, "Issue Miaoda 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 app Git credentials", plain)
|
||||
wrapped := gitCredentialLocalError("List local Miaoda 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 app Git credential: empty credential")
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: empty credential")
|
||||
}
|
||||
if issued.AppID != "" && 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)
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response app_id %q does not match requested app_id %q", issued.AppID, appID)
|
||||
}
|
||||
if normalizedURL == "" {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response missing gitURL")
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing gitURL")
|
||||
}
|
||||
if strings.TrimSpace(issued.PAT) == "" {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response missing token")
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda Git credential: response missing token")
|
||||
}
|
||||
if issued.ExpiresAt <= now {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response expiredTime must be in the future")
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue Miaoda 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
|
||||
// app storage directory.
|
||||
// Miaoda 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
|
||||
// OAPI business error codes returned by the Miaoda
|
||||
// /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 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 Miaoda app URL https://miaoda.feishu.cn/app/app_xxx after /app/, or take the app_xxx string directly)"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -21,7 +20,6 @@ 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{
|
||||
@@ -30,9 +28,6 @@ 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,14 +73,6 @@ 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,129 +1066,6 @@ 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{
|
||||
@@ -1557,48 +1434,6 @@ 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()
|
||||
@@ -1690,29 +1525,20 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
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.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.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list fields alias works in dry-run", func(t *testing.T) {
|
||||
t.Run("list legacy fields flag rejected in dry-run", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name", "--dry-run"}, factory, stdout); err != nil {
|
||||
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") {
|
||||
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,23 +59,6 @@ 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)
|
||||
@@ -152,7 +135,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-list-batch", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
|
||||
"+field-list", "+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",
|
||||
@@ -1105,22 +1088,6 @@ 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,7 +19,6 @@ var BaseDashboardBlockGetData = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "dashboard-id", Hidden: true},
|
||||
blockIDFlag(true),
|
||||
},
|
||||
Tips: []string{
|
||||
|
||||
@@ -21,7 +21,6 @@ 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 {
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
// 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,12 +10,6 @@ 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 {
|
||||
@@ -29,22 +23,6 @@ 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").
|
||||
@@ -83,7 +61,6 @@ 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"),
|
||||
@@ -91,15 +68,15 @@ func dryRunFieldSearchOptions(_ context.Context, runtime *common.RuntimeContext)
|
||||
if params["limit"].(int) <= 0 {
|
||||
params["limit"] = 30
|
||||
}
|
||||
if keyword := strings.TrimSpace(fieldSearchOptionsKeyword(runtime)); keyword != "" {
|
||||
if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" {
|
||||
params["query"] = keyword
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
GET(baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "fields", fieldRef, "options")).
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id/options").
|
||||
Params(params).
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime)).
|
||||
Set("field_id", fieldRef)
|
||||
Set("field_id", runtime.Str("field-id"))
|
||||
}
|
||||
|
||||
func validateFieldJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
@@ -141,142 +118,17 @@ func executeFieldList(runtime *common.RuntimeContext) error {
|
||||
offset = 0
|
||||
}
|
||||
limit := common.ParseIntBounded(runtime, "limit", 1, 200)
|
||||
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)
|
||||
fields, total, err := listAllFields(runtime, runtime.Str("base-token"), baseTableID(runtime), 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)
|
||||
@@ -332,25 +184,10 @@ 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 := fieldSearchOptionsRef(runtime)
|
||||
fieldRef := runtime.Str("field-id")
|
||||
params := map[string]interface{}{
|
||||
"offset": runtime.Int("offset"),
|
||||
"limit": runtime.Int("limit"),
|
||||
@@ -358,7 +195,7 @@ func executeFieldSearchOptions(runtime *common.RuntimeContext) error {
|
||||
if params["limit"].(int) <= 0 {
|
||||
params["limit"] = 30
|
||||
}
|
||||
if keyword := strings.TrimSpace(fieldSearchOptionsKeyword(runtime)); keyword != "" {
|
||||
if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" {
|
||||
params["query"] = keyword
|
||||
}
|
||||
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef, "options"), params, nil)
|
||||
@@ -373,7 +210,7 @@ func executeFieldSearchOptions(runtime *common.RuntimeContext) error {
|
||||
runtime.Out(map[string]interface{}{
|
||||
"field_id": fieldRef,
|
||||
"field_name": fieldRef,
|
||||
"keyword": fieldSearchOptionsKeyword(runtime),
|
||||
"keyword": strings.TrimSpace(runtime.Str("keyword")),
|
||||
"options": options,
|
||||
"total": total,
|
||||
}, nil)
|
||||
|
||||
@@ -5,7 +5,6 @@ package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -20,10 +19,8 @@ var BaseFieldSearchOptions = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
fieldRefFlag(false),
|
||||
{Name: "field-name", Hidden: true},
|
||||
fieldRefFlag(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"},
|
||||
},
|
||||
@@ -32,15 +29,6 @@ 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,7 +4,6 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
@@ -497,61 +496,3 @@ 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,10 +21,7 @@ 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,7 +5,6 @@ package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -22,14 +21,9 @@ 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(),
|
||||
@@ -51,9 +45,6 @@ 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,7 +5,6 @@ package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -46,11 +45,11 @@ func validateRecordSelection(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, error) {
|
||||
recordIDs := recordIDFlags(runtime)
|
||||
fieldIDs := recordFieldFlags(runtime)
|
||||
recordIDs := runtime.StrArray("record-id")
|
||||
fieldIDs := runtime.StrArray("field-id")
|
||||
jsonRaw := strings.TrimSpace(runtime.Str("json"))
|
||||
if len(recordIDs) > 0 && jsonRaw != "" {
|
||||
return recordSelection{}, baseFlagErrorf("--record-id/--record-ids and --json are mutually exclusive")
|
||||
return recordSelection{}, baseFlagErrorf("--record-id and --json are mutually exclusive")
|
||||
}
|
||||
if jsonRaw != "" {
|
||||
pc := newParseCtx(runtime)
|
||||
@@ -146,73 +145,6 @@ 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) {
|
||||
@@ -443,7 +375,7 @@ func validateRecordJSON(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func recordListFields(runtime *common.RuntimeContext) []string {
|
||||
return recordFieldFlags(runtime)
|
||||
return runtime.StrArray("field-id")
|
||||
}
|
||||
|
||||
func executeRecordList(runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -26,10 +26,6 @@ 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,
|
||||
@@ -38,10 +34,6 @@ 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
|
||||
@@ -51,10 +43,7 @@ func validateRecordQueryOptions(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func parseRecordFilterFlag(runtime *common.RuntimeContext) (interface{}, error) {
|
||||
filterRaw, err := recordQueryFlagValue(runtime, recordFilterJSONFlag, "filter")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filterRaw := strings.TrimSpace(runtime.Str(recordFilterJSONFlag))
|
||||
if filterRaw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -63,10 +52,7 @@ func parseRecordFilterFlag(runtime *common.RuntimeContext) (interface{}, error)
|
||||
}
|
||||
|
||||
func parseRecordSortFlag(runtime *common.RuntimeContext) ([]interface{}, error) {
|
||||
sortRaw, err := recordQueryFlagValue(runtime, recordSortJSONFlag, "sort")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sortRaw := strings.TrimSpace(runtime.Str(recordSortJSONFlag))
|
||||
if sortRaw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -78,18 +64,6 @@ 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 {
|
||||
@@ -193,7 +167,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 := recordSearchKeyword(runtime); keyword != "" {
|
||||
if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" {
|
||||
body["keyword"] = keyword
|
||||
}
|
||||
searchFields := runtime.StrArray("search-field")
|
||||
@@ -243,9 +217,6 @@ 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) {
|
||||
@@ -254,7 +225,7 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
|
||||
_, err := recordSearchJSONBody(runtime)
|
||||
return err
|
||||
}
|
||||
if recordSearchKeyword(runtime) == "" {
|
||||
if strings.TrimSpace(runtime.Str("keyword")) == "" {
|
||||
return baseFlagErrorf("--keyword is required unless --json is used")
|
||||
}
|
||||
if len(runtime.StrArray("search-field")) == 0 {
|
||||
@@ -264,7 +235,7 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func recordSearchHasJSONExclusiveFlagInputs(runtime *common.RuntimeContext) bool {
|
||||
return recordSearchKeyword(runtime) != "" ||
|
||||
return strings.TrimSpace(runtime.Str("keyword")) != "" ||
|
||||
len(runtime.StrArray("search-field")) > 0 ||
|
||||
len(recordListFields(runtime)) > 0 ||
|
||||
runtime.Str("view-id") != "" ||
|
||||
@@ -272,13 +243,6 @@ 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,16 +22,11 @@ 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,7 +19,6 @@ func Shortcuts() []common.Shortcut {
|
||||
BaseTableUpdate,
|
||||
BaseTableDelete,
|
||||
BaseFieldList,
|
||||
BaseFieldListBatch,
|
||||
BaseFieldGet,
|
||||
BaseFieldCreate,
|
||||
BaseFieldUpdate,
|
||||
|
||||
@@ -63,8 +63,7 @@ func executeTableList(runtime *common.RuntimeContext) error {
|
||||
offset = 0
|
||||
}
|
||||
limit := common.ParseIntBounded(runtime, "limit", 1, 100)
|
||||
baseToken := runtime.Str("base-token")
|
||||
tables, total, err := listAllTables(runtime, baseToken, offset, limit)
|
||||
tables, total, err := listAllTables(runtime, runtime.Str("base-token"), offset, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -187,24 +186,6 @@ 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,10 +31,7 @@ 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); 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.",
|
||||
{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"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveImportSpec(driveImportSpec{
|
||||
@@ -142,14 +139,6 @@ 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,43 +762,3 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,19 +9,19 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// MailMessage is the `+message` shortcut: fetch full content of one email
|
||||
// by one message ID (normalized body + attachments / inline metadata).
|
||||
// MailMessage is the `+message` shortcut: fetch full content of a single
|
||||
// email by message ID (normalized body + attachments / inline metadata).
|
||||
var MailMessage = common.Shortcut{
|
||||
Service: "mail",
|
||||
Command: "+message",
|
||||
Description: "Use only when reading full content for one email by one message ID. For multiple message IDs, use mail +messages; do not loop mail +message. Returns normalized body content plus attachments metadata, including inline images.",
|
||||
Description: "Use when reading full content for a single email by message ID. Returns normalized body content plus attachments metadata, including inline images.",
|
||||
Risk: "read",
|
||||
Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
|
||||
{Name: "message-id", Desc: "Required. Single email message ID only. For multiple IDs, use mail +messages --message-ids.", Required: true},
|
||||
{Name: "message-id", Desc: "Required. Email message ID", Required: true},
|
||||
{Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"},
|
||||
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
|
||||
},
|
||||
|
||||
@@ -18,19 +18,19 @@ type mailMessagesOutput struct {
|
||||
}
|
||||
|
||||
// MailMessages is the `+messages` shortcut: batch-fetch full content for
|
||||
// multiple message IDs, chunking requests into batches of 20 while preserving
|
||||
// request order.
|
||||
// multiple message IDs, chunking backend calls into batches of 20 while
|
||||
// preserving request order.
|
||||
var MailMessages = common.Shortcut{
|
||||
Service: "mail",
|
||||
Command: "+messages",
|
||||
Description: "Use when reading full content for multiple emails by message ID. You may pass more than 20 IDs; the CLI handles them in batches of 20 and merges output while preserving request order.",
|
||||
Description: "Use when reading full content for multiple emails by message ID. Prefer this shortcut over calling raw mail user_mailbox.messages batch_get directly, because it base64url-decodes body fields and returns normalized per-message output that is easier to consume.",
|
||||
Risk: "read",
|
||||
Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
|
||||
{Name: "message-ids", Desc: `Required. Comma-separated email message IDs. You may pass more than 20 IDs; the CLI handles them in batches of 20 and merges output. Example: "<id1>,<id2>,<id3>"`, Required: true},
|
||||
{Name: "message-ids", Desc: `Required. Comma-separated email message IDs. Example: "id1,id2,id3"`, Required: true},
|
||||
{Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"},
|
||||
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
|
||||
},
|
||||
@@ -52,7 +52,7 @@ var MailMessages = common.Shortcut{
|
||||
body["message_ids"] = messageIDs
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Fetch multiple emails; execution chunks every 20 IDs and merges output").
|
||||
Desc("Fetch multiple emails via messages.batch_get (auto-chunked in batches of 20 IDs during execution)").
|
||||
POST(mailboxPath(mailboxID, "messages", "batch_get")).
|
||||
Body(body)
|
||||
},
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestMailMessageHelpClarifiesSingleMessageOnly(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
|
||||
err := runMountedMailShortcutWithCobraOutput(t, MailMessage, []string{"+message", "-h"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("help returned error: %v", err)
|
||||
}
|
||||
|
||||
help := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Use only when reading full content for one email by one message ID",
|
||||
"For multiple message IDs, use mail +messages; do not loop mail +message",
|
||||
"Single email message ID only",
|
||||
"mail +messages --message-ids",
|
||||
} {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("help missing %q\n%s", want, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailMessagesHelpClarifiesBatchGetChunkingAndLimits(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
|
||||
err := runMountedMailShortcutWithCobraOutput(t, MailMessages, []string{"+messages", "-h"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("help returned error: %v", err)
|
||||
}
|
||||
|
||||
help := stdout.String()
|
||||
for _, want := range []string{
|
||||
"multiple emails by message ID",
|
||||
"handles them in batches of 20 and merges output",
|
||||
"Comma-separated email message IDs",
|
||||
"You may pass more than 20 IDs",
|
||||
} {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("help missing %q\n%s", want, help)
|
||||
}
|
||||
}
|
||||
for _, disallowed := range []string{"messages.batch_get", "OAPI Meta", "gateway config", "50 IDs", "50 个"} {
|
||||
if strings.Contains(help, disallowed) {
|
||||
t.Fatalf("help must not expose internal wording %q\n%s", disallowed, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailMessagesDryRunMentionsBatchGetChunkingAndMerge(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
messageIDs := []string{
|
||||
validMessageIDForTest("dry-run-1"),
|
||||
validMessageIDForTest("dry-run-2"),
|
||||
}
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessages, []string{
|
||||
"+messages", "--message-ids", strings.Join(messageIDs, ","), "--dry-run", "--format", "json",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run returned error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"chunks every 20 IDs",
|
||||
"merges output",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run missing %q\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailTriageTableHintRoutesSingleAndMultipleReads(t *testing.T) {
|
||||
f, stdout, stderr, reg := mailShortcutTestFactory(t)
|
||||
registerTriageReadHintStubs(reg)
|
||||
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{
|
||||
"+triage", "--max", "1",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("triage returned error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
errOut := stderr.String()
|
||||
for _, want := range []string{
|
||||
"tip: read full content:",
|
||||
"single message use mail +message --message-id <id>",
|
||||
"multiple messages use mail +messages --message-ids <id1>,<id2>,<id3>",
|
||||
} {
|
||||
if !strings.Contains(errOut, want) {
|
||||
t.Fatalf("stderr missing %q\n%s", want, errOut)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailTriageJSONDoesNotEmitReadHint(t *testing.T) {
|
||||
f, stdout, stderr, reg := mailShortcutTestFactory(t)
|
||||
registerTriageReadHintStubs(reg)
|
||||
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{
|
||||
"+triage", "--format", "json", "--max", "1",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("triage returned error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
if strings.Contains(stderr.String(), "tip: read full content:") {
|
||||
t.Fatalf("json output must not emit table read hint\nstderr=%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailMessagesExecuteChunksTwentyOneIDsIntoTwoBatchGetCalls(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/messages/batch_get",
|
||||
Reusable: true,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"messages": []interface{}{}},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
ids := make([]string, 21)
|
||||
for i := range ids {
|
||||
ids[i] = validMessageIDForTest(fmt.Sprintf("batch-%02d", i+1))
|
||||
}
|
||||
err := runMountedMailShortcut(t, MailMessages, []string{
|
||||
"+messages", "--message-ids", strings.Join(ids, ","),
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("messages returned error: %v", err)
|
||||
}
|
||||
|
||||
if got := len(stub.CapturedBodies); got != 2 {
|
||||
t.Fatalf("expected 2 batch_get calls, got %d", got)
|
||||
}
|
||||
assertBatchGetMessageIDCount(t, stub.CapturedBodies[0], 20)
|
||||
assertBatchGetMessageIDCount(t, stub.CapturedBodies[1], 1)
|
||||
}
|
||||
|
||||
func registerTriageReadHintStubs(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/user_mailboxes/me/messages",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{"msg_1"},
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/messages/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"messages": []interface{}{
|
||||
map[string]interface{}{
|
||||
"message_id": "msg_1",
|
||||
"subject": "Quarterly update",
|
||||
"date": "Thu, 04 Jun 2026 10:00:00 +0800",
|
||||
"from": map[string]interface{}{"name": "Alice", "mail_address": "alice@example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func assertBatchGetMessageIDCount(t *testing.T, body []byte, want int) {
|
||||
t.Helper()
|
||||
var payload struct {
|
||||
MessageIDs []string `json:"message_ids"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("unmarshal batch_get body: %v\n%s", err, string(body))
|
||||
}
|
||||
if got := len(payload.MessageIDs); got != want {
|
||||
t.Fatalf("message_ids count mismatch: got %d want %d body=%s", got, want, string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func runMountedMailShortcutWithCobraOutput(t *testing.T, shortcut common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "test"}
|
||||
parent.SetOut(stdout)
|
||||
parent.SetErr(stdout)
|
||||
shortcut.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
stdout.Reset()
|
||||
return parent.Execute()
|
||||
}
|
||||
@@ -322,10 +322,9 @@ var MailTriage = common.Shortcut{
|
||||
fmt.Fprintln(runtime.IO().ErrOut, hint.String())
|
||||
}
|
||||
if mailbox != "me" {
|
||||
quotedMailbox := shellQuote(mailbox)
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message use mail +message --mailbox "+quotedMailbox+" --message-id <id>; multiple messages use mail +messages --mailbox "+quotedMailbox+" --message-ids <id1>,<id2>,<id3>")
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "tip: use mail +message --mailbox "+shellQuote(mailbox)+" --message-id <id> to read full content")
|
||||
} else {
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message use mail +message --message-id <id>; multiple messages use mail +messages --message-ids <id1>,<id2>,<id3>")
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "tip: use mail +message --message-id <id> to read full content")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package note owns the Note domain: querying note detail and the unified
|
||||
// transcript by a known note_id. The vc domain locates a
|
||||
// note_id from meeting context and delegates note-detail parsing here, so the
|
||||
// parsing logic lives in exactly one place.
|
||||
package note
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// NoNoteReadPermissionCode is returned when the caller lacks read permission
|
||||
// for the requested note.
|
||||
const NoNoteReadPermissionCode = 121005
|
||||
|
||||
// ErrEmptyDetail identifies note detail responses that do not contain a note
|
||||
// object. Callers should use errors.Is instead of matching the display message.
|
||||
var ErrEmptyDetail = errors.New("note detail is empty")
|
||||
|
||||
// artifact_type enum from the note detail API.
|
||||
const (
|
||||
artifactTypeMainDoc = 1 // main note document
|
||||
artifactTypeVerbatim = 2 // verbatim transcript
|
||||
)
|
||||
|
||||
// note_display_type enum (i32) from the note detail API. Surfaced to callers as
|
||||
// a stable string so Agents route on a name, not a magic number.
|
||||
const (
|
||||
displayTypeNormal = 1
|
||||
displayTypeUnified = 2
|
||||
)
|
||||
|
||||
// Detail is the parsed note detail shared by `note +detail` and `vc +notes`.
|
||||
type Detail struct {
|
||||
NoteID string
|
||||
CreatorID string
|
||||
CreateTime string
|
||||
DisplayType string // unknown | normal | unified
|
||||
NoteDocToken string
|
||||
VerbatimDocToken string
|
||||
SharedDocTokens []string
|
||||
}
|
||||
|
||||
// FetchDetail queries GET /open-apis/vc/v1/notes/{note_id} and parses the note
|
||||
// object. API errors are returned as typed errs.* values so callers can enrich
|
||||
// user guidance without downgrading the envelope.
|
||||
func FetchDetail(_ context.Context, runtime *common.RuntimeContext, noteID string) (*Detail, error) {
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
noteObj, _ := data["note"].(map[string]any)
|
||||
if noteObj == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "note detail is empty").WithCause(ErrEmptyDetail)
|
||||
}
|
||||
noteDoc, verbatimDoc := extractArtifactTokens(common.GetSlice(noteObj, "artifacts"))
|
||||
return &Detail{
|
||||
NoteID: noteID,
|
||||
CreatorID: common.GetString(noteObj, "creator_id"),
|
||||
CreateTime: common.FormatTime(noteObj["create_time"]),
|
||||
DisplayType: displayTypeString(displayTypeValue(noteObj)),
|
||||
NoteDocToken: noteDoc,
|
||||
VerbatimDocToken: verbatimDoc,
|
||||
SharedDocTokens: extractDocTokens(common.GetSlice(noteObj, "references")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ToMap renders the detail as the field map consumed by `vc +notes`, keeping
|
||||
// the historical key set (shared_doc_tokens omitted when empty) and adding the
|
||||
// note_id / note_display_type fields.
|
||||
func (d *Detail) ToMap() map[string]any {
|
||||
m := map[string]any{
|
||||
"note_id": d.NoteID,
|
||||
"note_display_type": d.DisplayType,
|
||||
"creator_id": d.CreatorID,
|
||||
"create_time": d.CreateTime,
|
||||
"note_doc_token": d.NoteDocToken,
|
||||
"verbatim_doc_token": d.VerbatimDocToken,
|
||||
}
|
||||
if len(d.SharedDocTokens) > 0 {
|
||||
m["shared_doc_tokens"] = d.SharedDocTokens
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// displayTypeValue reads the display-type field, tolerating either the
|
||||
// documented note_display_type key or a bare display_type fallback.
|
||||
func displayTypeValue(note map[string]any) any {
|
||||
if v, ok := note["note_display_type"]; ok {
|
||||
return v
|
||||
}
|
||||
return note["display_type"]
|
||||
}
|
||||
|
||||
func displayTypeString(v any) string {
|
||||
switch parseLooseInt(v) {
|
||||
case displayTypeNormal:
|
||||
return "normal"
|
||||
case displayTypeUnified:
|
||||
return "unified"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// extractArtifactTokens picks main-doc and verbatim-doc tokens from artifacts.
|
||||
func extractArtifactTokens(artifacts []any) (noteDoc, verbatimDoc string) {
|
||||
for _, a := range artifacts {
|
||||
artifact, _ := a.(map[string]any)
|
||||
if artifact == nil {
|
||||
continue
|
||||
}
|
||||
docToken, _ := artifact["doc_token"].(string)
|
||||
switch parseLooseInt(artifact["artifact_type"]) {
|
||||
case artifactTypeMainDoc:
|
||||
noteDoc = docToken
|
||||
case artifactTypeVerbatim:
|
||||
verbatimDoc = docToken
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// extractDocTokens collects doc_token values from a list of reference objects.
|
||||
func extractDocTokens(refs []any) []string {
|
||||
var tokens []string
|
||||
for _, s := range refs {
|
||||
source, _ := s.(map[string]any)
|
||||
if source == nil {
|
||||
continue
|
||||
}
|
||||
if docToken, _ := source["doc_token"].(string); docToken != "" {
|
||||
tokens = append(tokens, docToken)
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
// parseLooseInt extracts an int from the varying JSON number representations
|
||||
// DoAPIJSON may yield (json.Number, float64, or int).
|
||||
func parseLooseInt(v any) int {
|
||||
switch n := v.(type) {
|
||||
case json.Number:
|
||||
i, _ := n.Int64()
|
||||
return int(i)
|
||||
case float64:
|
||||
// Reject fractional values: truncating 1.9 to 1 would silently coerce
|
||||
// a malformed enum into a valid one.
|
||||
if n != float64(int64(n)) {
|
||||
return 0
|
||||
}
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// parseLooseCursorID extracts a positive cursor as a string. String cursors are
|
||||
// preferred because large JSON numbers lose precision when decoded into any.
|
||||
func parseLooseCursorID(v any) (string, bool) {
|
||||
switch n := v.(type) {
|
||||
case string:
|
||||
s := strings.TrimSpace(n)
|
||||
if s == "" || s == "0" {
|
||||
return "", false
|
||||
}
|
||||
return s, true
|
||||
case json.Number:
|
||||
i, err := n.Int64()
|
||||
if err != nil || i <= 0 {
|
||||
return "", false
|
||||
}
|
||||
return strconv.FormatInt(i, 10), true
|
||||
case float64:
|
||||
// encoding/json decodes numbers in map[string]any as float64. Accept
|
||||
// only values that can round-trip safely as an integer cursor.
|
||||
const maxSafeJSONInteger = 1<<53 - 1
|
||||
if n <= 0 || n != float64(int64(n)) || n > maxSafeJSONInteger {
|
||||
return "", false
|
||||
}
|
||||
return strconv.FormatInt(int64(n), 10), true
|
||||
case int64:
|
||||
if n <= 0 {
|
||||
return "", false
|
||||
}
|
||||
return strconv.FormatInt(n, 10), true
|
||||
case int:
|
||||
if n <= 0 {
|
||||
return "", false
|
||||
}
|
||||
return strconv.Itoa(n), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// note +detail — get note metadata and document tokens by a known note_id.
|
||||
|
||||
package note
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// NoteDetail queries note metadata, display type and document tokens by note_id.
|
||||
var NoteDetail = common.Shortcut{
|
||||
Service: "note",
|
||||
Command: "+detail",
|
||||
Description: "Get note detail (display type, document tokens) by note_id",
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:note:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "note-id", Desc: "note ID", Required: true},
|
||||
},
|
||||
Validate: func(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
noteID := strings.TrimSpace(runtime.Str("note-id"))
|
||||
if noteID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--note-id is required").WithParam("--note-id")
|
||||
}
|
||||
if err := validate.ResourceName(noteID, "--note-id"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--note-id").WithCause(err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
noteID := strings.TrimSpace(runtime.Str("note-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
noteID := strings.TrimSpace(runtime.Str("note-id"))
|
||||
detail, err := FetchDetail(ctx, runtime, noteID)
|
||||
if err != nil {
|
||||
return mapNoteError(err)
|
||||
}
|
||||
runtime.OutFormat(map[string]any{"note": detail.ToMap()}, nil, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// mapNoteError surfaces the no-permission case explicitly and passes through
|
||||
// any other typed API error unchanged.
|
||||
func mapNoteError(err error) error {
|
||||
if problem, ok := errs.ProblemOf(err); ok && problem.Code == NoNoteReadPermissionCode {
|
||||
message := strings.TrimSpace(problem.Message)
|
||||
if message == "" {
|
||||
message = "no read permission for this note"
|
||||
} else if !strings.Contains(message, "no read permission for this note") {
|
||||
message = fmt.Sprintf("no read permission for this note: %s", message)
|
||||
}
|
||||
var permErr *errs.PermissionError
|
||||
if errors.As(err, &permErr) {
|
||||
mapped := *permErr
|
||||
mapped.Problem.Message = message
|
||||
if mapped.Problem.Hint == "" {
|
||||
mapped.Problem.Hint = "Ask the note owner to grant read permission, then retry"
|
||||
}
|
||||
mapped.Cause = err
|
||||
return &mapped
|
||||
}
|
||||
mappedProblem := *problem
|
||||
mappedProblem.Category = errs.CategoryAuthorization
|
||||
mappedProblem.Subtype = errs.SubtypePermissionDenied
|
||||
mappedProblem.Message = message
|
||||
if mappedProblem.Hint == "" {
|
||||
mappedProblem.Hint = "Ask the note owner to grant read permission, then retry"
|
||||
}
|
||||
return &errs.PermissionError{Problem: mappedProblem, Cause: err}
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package note
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// These tests were relocated from shortcuts/vc/vc_notes_test.go together with
|
||||
// the note-detail parsing helpers they cover.
|
||||
|
||||
func TestParseLooseInt(t *testing.T) {
|
||||
tests := []struct {
|
||||
input any
|
||||
want int
|
||||
}{
|
||||
{float64(1), 1},
|
||||
{float64(2), 2},
|
||||
{float64(1.9), 0},
|
||||
{json.Number("3"), 3},
|
||||
{"unknown", 0},
|
||||
{nil, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := parseLooseInt(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("parseLooseInt(%v) = %d, want %d", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLooseCursorID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in any
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{name: "string", in: "7648924766078847940", want: "7648924766078847940", ok: true},
|
||||
{name: "trim string", in: " 123 ", want: "123", ok: true},
|
||||
{name: "empty string", in: "", ok: false},
|
||||
{name: "zero string", in: "0", ok: false},
|
||||
{name: "json number", in: json.Number("123"), want: "123", ok: true},
|
||||
{name: "float safe integer", in: float64(123), want: "123", ok: true},
|
||||
{name: "float unsafe integer", in: float64(1<<53 + 1), ok: false},
|
||||
{name: "float fractional", in: float64(1.5), ok: false},
|
||||
{name: "negative", in: -1, ok: false},
|
||||
{name: "nil", in: nil, ok: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := parseLooseCursorID(tt.in)
|
||||
if got != tt.want || ok != tt.ok {
|
||||
t.Fatalf("parseLooseCursorID(%v) = (%q, %v), want (%q, %v)", tt.in, got, ok, tt.want, tt.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractArtifactTokens(t *testing.T) {
|
||||
artifacts := []any{
|
||||
map[string]any{"doc_token": "main_doc", "artifact_type": float64(1)},
|
||||
map[string]any{"doc_token": "verbatim_doc", "artifact_type": float64(2)},
|
||||
map[string]any{"doc_token": "unknown_doc", "artifact_type": float64(99)},
|
||||
nil,
|
||||
}
|
||||
noteDoc, verbatimDoc := extractArtifactTokens(artifacts)
|
||||
if noteDoc != "main_doc" {
|
||||
t.Errorf("noteDoc = %q, want %q", noteDoc, "main_doc")
|
||||
}
|
||||
if verbatimDoc != "verbatim_doc" {
|
||||
t.Errorf("verbatimDoc = %q, want %q", verbatimDoc, "verbatim_doc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractArtifactTokens_Empty(t *testing.T) {
|
||||
noteDoc, verbatimDoc := extractArtifactTokens(nil)
|
||||
if noteDoc != "" || verbatimDoc != "" {
|
||||
t.Errorf("expected empty tokens for nil input, got %q, %q", noteDoc, verbatimDoc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDocTokens(t *testing.T) {
|
||||
refs := []any{
|
||||
map[string]any{"doc_token": "shared1"},
|
||||
map[string]any{"doc_token": "shared2"},
|
||||
map[string]any{"doc_token": ""},
|
||||
map[string]any{},
|
||||
nil,
|
||||
}
|
||||
tokens := extractDocTokens(refs)
|
||||
if len(tokens) != 2 || tokens[0] != "shared1" || tokens[1] != "shared2" {
|
||||
t.Errorf("extractDocTokens = %v, want [shared1 shared2]", tokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDocTokens_Empty(t *testing.T) {
|
||||
tokens := extractDocTokens(nil)
|
||||
if tokens != nil {
|
||||
t.Errorf("expected nil for nil input, got %v", tokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailToMap(t *testing.T) {
|
||||
detail := &Detail{
|
||||
NoteID: "note_1",
|
||||
CreatorID: "creator_1",
|
||||
CreateTime: "2026-06-09 12:00:00",
|
||||
DisplayType: "unified",
|
||||
NoteDocToken: "note_doc",
|
||||
VerbatimDocToken: "verbatim_doc",
|
||||
SharedDocTokens: []string{"shared_1", "shared_2"},
|
||||
}
|
||||
|
||||
got := detail.ToMap()
|
||||
want := map[string]any{
|
||||
"note_id": "note_1",
|
||||
"creator_id": "creator_1",
|
||||
"create_time": "2026-06-09 12:00:00",
|
||||
"note_display_type": "unified",
|
||||
"note_doc_token": "note_doc",
|
||||
"verbatim_doc_token": "verbatim_doc",
|
||||
"shared_doc_tokens": []string{"shared_1", "shared_2"},
|
||||
}
|
||||
for key, wantValue := range want {
|
||||
gotValue, ok := got[key]
|
||||
if !ok {
|
||||
t.Fatalf("ToMap missing key %q in %#v", key, got)
|
||||
}
|
||||
if !valuesEqual(gotValue, wantValue) {
|
||||
t.Fatalf("ToMap[%q] = %#v, want %#v", key, gotValue, wantValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailToMap_OmitsEmptySharedDocTokens(t *testing.T) {
|
||||
got := (&Detail{NoteID: "note_1"}).ToMap()
|
||||
if _, ok := got["shared_doc_tokens"]; ok {
|
||||
t.Fatalf("ToMap should omit empty shared_doc_tokens, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapNoteError_NoReadPermission(t *testing.T) {
|
||||
err := &errs.PermissionError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryAuthorization,
|
||||
Subtype: errs.SubtypePermissionDenied,
|
||||
Code: NoNoteReadPermissionCode,
|
||||
Message: "upstream permission denied",
|
||||
LogID: "log_1",
|
||||
},
|
||||
MissingScopes: []string{"vc:note:read"},
|
||||
Identity: "user",
|
||||
}
|
||||
|
||||
got := mapNoteError(err)
|
||||
problem, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("mapNoteError returned %T, want typed problem", got)
|
||||
}
|
||||
if problem.Code != NoNoteReadPermissionCode {
|
||||
t.Fatalf("mapped code = %d, want %d", problem.Code, NoNoteReadPermissionCode)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "no read permission for this note") || !strings.Contains(problem.Message, "upstream permission denied") {
|
||||
t.Fatalf("mapped message = %q, want note permission guidance with upstream message", problem.Message)
|
||||
}
|
||||
if !errors.Is(got, err) {
|
||||
t.Fatal("mapped error should preserve the original typed error as cause")
|
||||
}
|
||||
originalProblem, _ := errs.ProblemOf(err)
|
||||
if originalProblem.Message != "upstream permission denied" {
|
||||
t.Fatalf("original message was mutated to %q", originalProblem.Message)
|
||||
}
|
||||
var gotPerm *errs.PermissionError
|
||||
if !errors.As(got, &gotPerm) {
|
||||
t.Fatalf("mapped error = %T, want PermissionError", got)
|
||||
}
|
||||
if gotPerm.LogID != "log_1" {
|
||||
t.Fatalf("LogID = %q, want preserved log_1", gotPerm.LogID)
|
||||
}
|
||||
if len(gotPerm.MissingScopes) != 1 || gotPerm.MissingScopes[0] != "vc:note:read" {
|
||||
t.Fatalf("MissingScopes = %#v, want preserved vc:note:read", gotPerm.MissingScopes)
|
||||
}
|
||||
if gotPerm.Identity != "user" {
|
||||
t.Fatalf("Identity = %q, want preserved user", gotPerm.Identity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapNoteError_NormalizesNonPermissionTypedError(t *testing.T) {
|
||||
err := &errs.APIError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryAPI,
|
||||
Subtype: errs.SubtypeUnknown,
|
||||
Code: NoNoteReadPermissionCode,
|
||||
Message: "upstream api error",
|
||||
LogID: "log_2",
|
||||
},
|
||||
}
|
||||
|
||||
got := mapNoteError(err)
|
||||
var gotPerm *errs.PermissionError
|
||||
if !errors.As(got, &gotPerm) {
|
||||
t.Fatalf("mapped error = %T, want PermissionError", got)
|
||||
}
|
||||
if gotPerm.Category != errs.CategoryAuthorization || gotPerm.Subtype != errs.SubtypePermissionDenied {
|
||||
t.Fatalf("mapped category/subtype = %q/%q, want authorization/permission_denied", gotPerm.Category, gotPerm.Subtype)
|
||||
}
|
||||
if !strings.Contains(gotPerm.Message, "no read permission for this note") || !strings.Contains(gotPerm.Message, "upstream api error") {
|
||||
t.Fatalf("mapped message = %q, want note permission guidance with upstream message", gotPerm.Message)
|
||||
}
|
||||
if gotPerm.Hint == "" {
|
||||
t.Fatal("mapped hint should not be empty")
|
||||
}
|
||||
if gotPerm.LogID != "log_2" {
|
||||
t.Fatalf("LogID = %q, want preserved log_2", gotPerm.LogID)
|
||||
}
|
||||
if !errors.Is(got, err) {
|
||||
t.Fatal("mapped error should preserve the original typed error as cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapNoteError_Passthrough(t *testing.T) {
|
||||
err := errors.New("boom")
|
||||
if got := mapNoteError(err); got != err {
|
||||
t.Fatalf("mapNoteError passthrough = %v, want original", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteDetailEmptyDetailPreservesSentinelCause(t *testing.T) {
|
||||
factory, stdout, _, reg := noteShortcutTestFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/note_empty_detail",
|
||||
Body: map[string]any{
|
||||
"code": 0,
|
||||
"data": map[string]any{},
|
||||
},
|
||||
})
|
||||
|
||||
err := runNoteShortcut(t, NoteDetail, []string{"+detail", "--note-id", "note_empty_detail", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected empty detail to fail")
|
||||
}
|
||||
if !errors.Is(err, ErrEmptyDetail) {
|
||||
t.Fatalf("errors.Is(ErrEmptyDetail) = false for %T: %v", err, err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("category/subtype = %v/%v, want Internal/InvalidResponse", problem.Category, problem.Subtype)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcuts(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
if len(shortcuts) != 2 {
|
||||
t.Fatalf("Shortcuts len = %d, want 2", len(shortcuts))
|
||||
}
|
||||
if shortcuts[0].Command != "+detail" || shortcuts[1].Command != "+transcript" {
|
||||
t.Fatalf("Shortcuts commands = %q, %q", shortcuts[0].Command, shortcuts[1].Command)
|
||||
}
|
||||
}
|
||||
|
||||
func valuesEqual(a, b any) bool {
|
||||
ab, _ := json.Marshal(a)
|
||||
bb, _ := json.Marshal(b)
|
||||
return string(ab) == string(bb)
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// note +transcript — fetch the unified note transcript by a
|
||||
// known note_id. The API is paginated; the CLI walks all pages internally,
|
||||
// concatenates the content and saves the whole transcript to a local file.
|
||||
|
||||
package note
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
transcriptFormatMarkdown = "markdown"
|
||||
transcriptFormatPlainText = "plain_text"
|
||||
|
||||
logPrefix = "[note +transcript]"
|
||||
|
||||
// maxTranscriptPages bounds the pagination loop so a misbehaving has_more
|
||||
// can never spin forever. transcriptPageSize reduces round trips; full
|
||||
// transcript correctness still depends on has_more/cursor pagination.
|
||||
maxTranscriptPages = 500
|
||||
transcriptPageSize = 200
|
||||
|
||||
// pageDelay throttles successive page requests to stay gentle on the
|
||||
// downstream, matching the batch cadence used by `vc +notes`.
|
||||
pageDelay = 100 * time.Millisecond
|
||||
|
||||
// noteArtifactSubdir is the default top-level directory for note-scoped
|
||||
// artifacts (parallel to the "minutes" layout used by minute artifacts).
|
||||
noteArtifactSubdir = "notes"
|
||||
)
|
||||
|
||||
// NoteTranscript fetches the full unified transcript and saves it to a file.
|
||||
var NoteTranscript = common.Shortcut{
|
||||
Service: "note",
|
||||
Command: "+transcript",
|
||||
Description: "Fetch the unified note transcript and save it to a file",
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:note:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "note-id", Desc: "note ID", Required: true},
|
||||
{Name: "transcript-format", Desc: "transcript content format", Default: transcriptFormatMarkdown, Enum: []string{transcriptFormatMarkdown, transcriptFormatPlainText}},
|
||||
{Name: "locale", Desc: "transcript locale, e.g. zh_cn, en_us, ja_jp (default follows profile language or brand)"},
|
||||
{Name: "output", Desc: "output file path (default: ./notes/{note_id}/unified_transcript.{md,txt})"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite an existing output file"},
|
||||
},
|
||||
Validate: func(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
noteID := strings.TrimSpace(runtime.Str("note-id"))
|
||||
if noteID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--note-id is required").WithParam("--note-id")
|
||||
}
|
||||
if err := validate.ResourceName(noteID, "--note-id"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--note-id").WithCause(err)
|
||||
}
|
||||
if out := strings.TrimSpace(runtime.Str("output")); out != "" {
|
||||
if err := common.ValidateSafePathTyped(runtime.FileIO(), out); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
noteID := strings.TrimSpace(runtime.Str("note-id"))
|
||||
transcriptFormat := runtime.Str("transcript-format")
|
||||
locale := resolveTranscriptLocale(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID))).
|
||||
Desc("[1] Check note_display_type and verbatim_doc_token before transcript fetch").
|
||||
GET(fmt.Sprintf("/open-apis/vc/v1/notes/%s/unified_note_transcript", validate.EncodePathSegment(noteID))).
|
||||
Desc("[2] Fetch unified note transcript pages; subsequent pages add cursor_id internally").
|
||||
Params(map[string]interface{}{
|
||||
"format": transcriptFormat,
|
||||
"page_size": transcriptPageSize,
|
||||
"locale": locale,
|
||||
}).
|
||||
Set("transcript_format", transcriptFormat).
|
||||
Set("locale", locale).
|
||||
Set("note", "CLI first checks note_display_type via note detail, then paginates internally (cursor_id) and saves the full unified transcript to a file")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
noteID := strings.TrimSpace(runtime.Str("note-id"))
|
||||
transcriptFormat := runtime.Str("transcript-format")
|
||||
locale := resolveTranscriptLocale(runtime)
|
||||
|
||||
outPath := strings.TrimSpace(runtime.Str("output"))
|
||||
if outPath == "" {
|
||||
outPath = defaultTranscriptPath(noteID, transcriptFormat)
|
||||
}
|
||||
if !runtime.Bool("overwrite") {
|
||||
if _, statErr := runtime.FileIO().Stat(outPath); statErr == nil {
|
||||
precondition := errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s", outPath).
|
||||
WithHint("Pass --overwrite to replace the existing file")
|
||||
if strings.TrimSpace(runtime.Str("output")) != "" {
|
||||
precondition = precondition.WithParam("--output")
|
||||
}
|
||||
return precondition
|
||||
}
|
||||
}
|
||||
|
||||
if err := ensureUnifiedNote(ctx, runtime, noteID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
content, err := fetchUnifiedTranscript(ctx, runtime, noteID, transcriptFormat, locale)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
saved, err := runtime.FileIO().Save(outPath, fileio.SaveOptions{}, bytes.NewReader(content))
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
resolved, rerr := runtime.FileIO().ResolvePath(outPath)
|
||||
if rerr != nil || resolved == "" {
|
||||
resolved = outPath
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]any{
|
||||
"note_id": noteID,
|
||||
"transcript_format": transcriptFormat,
|
||||
"transcript_file": resolved,
|
||||
"size_bytes": saved.Size(),
|
||||
}, nil, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func ensureUnifiedNote(ctx context.Context, runtime *common.RuntimeContext, noteID string) error {
|
||||
detail, err := FetchDetail(ctx, runtime, noteID)
|
||||
if err != nil {
|
||||
return mapNoteError(err)
|
||||
}
|
||||
if detail.DisplayType != "unified" {
|
||||
if detail.VerbatimDocToken != "" {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "note %s is not a unified note (note_display_type=%s, verbatim_doc_token=%s)", noteID, detail.DisplayType, detail.VerbatimDocToken).
|
||||
WithHint("Use docs +fetch --api-version v2 --doc %s for normal note transcripts", detail.VerbatimDocToken)
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "note %s is not a unified note (note_display_type=%s, verbatim_doc_token=)", noteID, detail.DisplayType).
|
||||
WithHint("Use note +detail to inspect document tokens")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchUnifiedTranscript walks every page of the unified transcript and returns
|
||||
// the concatenated content. Any page error fails the whole call: a partial
|
||||
// transcript is misleading, so we prefer an explicit error over silent loss.
|
||||
func fetchUnifiedTranscript(ctx context.Context, runtime *common.RuntimeContext, noteID, transcriptFormat, locale string) ([]byte, error) {
|
||||
errOut := runtime.IO().ErrOut
|
||||
apiPath := fmt.Sprintf("/open-apis/vc/v1/notes/%s/unified_note_transcript", validate.EncodePathSegment(noteID))
|
||||
|
||||
var buf bytes.Buffer
|
||||
var cursor string
|
||||
seenCursors := map[string]bool{}
|
||||
for page := 1; ; page++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, transcriptContextError(err)
|
||||
}
|
||||
if page > maxTranscriptPages {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "transcript exceeded %d pages; aborting to avoid an unbounded loop", maxTranscriptPages)
|
||||
}
|
||||
|
||||
query := larkcore.QueryParams{
|
||||
"format": []string{transcriptFormat},
|
||||
"locale": []string{locale},
|
||||
"page_size": []string{strconv.Itoa(transcriptPageSize)},
|
||||
}
|
||||
if cursor != "" {
|
||||
query["cursor_id"] = []string{cursor}
|
||||
}
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodGet, apiPath, query, nil)
|
||||
if err != nil {
|
||||
return nil, mapNoteError(err)
|
||||
}
|
||||
|
||||
if transcript, _ := data["transcript"].(map[string]any); transcript != nil {
|
||||
if chunk, _ := transcript[transcriptFormat].(string); chunk != "" {
|
||||
buf.WriteString(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
hasMore, _ := data["has_more"].(bool)
|
||||
if !hasMore {
|
||||
break
|
||||
}
|
||||
next, ok := parseLooseCursorID(data["next_cursor_id"])
|
||||
if !ok || next == cursor || seenCursors[next] {
|
||||
fmt.Fprintf(errOut, "%s has_more set but cursor did not advance at page %d\n", logPrefix, page)
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "transcript pagination cursor did not advance at page %d; aborting to avoid saving a partial transcript", page)
|
||||
}
|
||||
seenCursors[cursor] = true
|
||||
cursor = next
|
||||
timer := time.NewTimer(pageDelay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return nil, transcriptContextError(ctx.Err())
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
|
||||
if buf.Len() == 0 {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "transcript is empty for note %s in %s format; aborting to avoid saving an empty transcript", noteID, transcriptFormat)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func transcriptContextError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
subtype := errs.SubtypeNetworkTransport
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
subtype = errs.SubtypeNetworkTimeout
|
||||
}
|
||||
return errs.NewNetworkError(subtype, "transcript fetch interrupted: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
// defaultTranscriptPath builds the default save path for a note transcript.
|
||||
func defaultTranscriptPath(noteID, transcriptFormat string) string {
|
||||
name := "unified_transcript.md"
|
||||
if transcriptFormat == transcriptFormatPlainText {
|
||||
name = "unified_transcript.txt"
|
||||
}
|
||||
return filepath.Join(noteArtifactSubdir, noteID, name)
|
||||
}
|
||||
|
||||
func resolveTranscriptLocale(runtime *common.RuntimeContext) string {
|
||||
if explicit := strings.TrimSpace(runtime.Str("locale")); explicit != "" {
|
||||
return explicit
|
||||
}
|
||||
if lang := runtime.Lang(); lang != "" {
|
||||
return string(lang)
|
||||
}
|
||||
if runtime.Config.Brand == core.BrandLark {
|
||||
return string(i18n.LangEnUS)
|
||||
}
|
||||
return string(i18n.LangZhCN)
|
||||
}
|
||||
@@ -1,438 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package note
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestNoteTranscriptRequiresUnifiedNote(t *testing.T) {
|
||||
factory, stdout, _, reg := noteShortcutTestFactory(t)
|
||||
reg.Register(noteDetailStub("note_normal", displayTypeNormal))
|
||||
|
||||
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_normal", "--output", "out.md", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected non-unified note to fail")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, "not a unified note") || !strings.Contains(got, "note_display_type=normal") || !strings.Contains(got, "verbatim_doc_token=doc_verbatim") {
|
||||
t.Fatalf("err = %q, want non-unified message", got)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("subtype = %v, want FailedPrecondition", problem.Subtype)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "docs +fetch --api-version v2 --doc doc_verbatim") {
|
||||
t.Fatalf("hint = %q, want docs +fetch guidance", problem.Hint)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteTranscriptFetchesUnifiedNote(t *testing.T) {
|
||||
factory, stdout, _, reg := noteShortcutTestFactory(t)
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
reg.Register(noteDetailStub("note_unified", displayTypeUnified))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/note_unified/unified_note_transcript?format=markdown&locale=zh_cn&page_size=200",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"transcript": map[string]interface{}{
|
||||
"markdown": "# transcript\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_unified", "--as", "user"}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(dir, "notes", "note_unified", "unified_transcript.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile transcript err=%v", err)
|
||||
}
|
||||
if string(content) != "# transcript\n" {
|
||||
t.Fatalf("transcript = %q, want %q", string(content), "# transcript\n")
|
||||
}
|
||||
data := decodeNoteEnvelope(t, stdout)
|
||||
if data["note_id"] != "note_unified" || data["size_bytes"] != float64(len(content)) {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteTranscriptFormatFlagDoesNotShadowOutputFormat(t *testing.T) {
|
||||
factory, stdout, _, reg := noteShortcutTestFactory(t)
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
reg.Register(noteDetailStub("note_plain", displayTypeUnified))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/note_plain/unified_note_transcript?format=plain_text&locale=zh_cn&page_size=200",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"transcript": map[string]interface{}{
|
||||
"plain_text": "plain transcript\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runNoteShortcut(t, NoteTranscript, []string{
|
||||
"+transcript",
|
||||
"--note-id", "note_plain",
|
||||
"--transcript-format", "plain_text",
|
||||
"--format", "json",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(dir, "notes", "note_plain", "unified_transcript.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile transcript err=%v", err)
|
||||
}
|
||||
if string(content) != "plain transcript\n" {
|
||||
t.Fatalf("transcript = %q, want plain transcript", string(content))
|
||||
}
|
||||
data := decodeNoteEnvelope(t, stdout)
|
||||
if data["transcript_format"] != "plain_text" {
|
||||
t.Fatalf("transcript_format = %#v, want plain_text; output=%s", data["transcript_format"], stdout.String())
|
||||
}
|
||||
if _, ok := data["format"]; ok {
|
||||
t.Fatalf("output should not expose ambiguous format field: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteTranscriptPassesLocaleThrough(t *testing.T) {
|
||||
factory, stdout, _, reg := noteShortcutTestFactory(t)
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
reg.Register(noteDetailStub("note_locale", displayTypeUnified))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/note_locale/unified_note_transcript?format=markdown&locale=en_us&page_size=200",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"transcript": map[string]interface{}{
|
||||
"markdown": "# en transcript\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_locale", "--locale", "en_us", "--as", "user"}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(dir, "notes", "note_locale", "unified_transcript.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile transcript err=%v", err)
|
||||
}
|
||||
if string(content) != "# en transcript\n" {
|
||||
t.Fatalf("transcript = %q, want en transcript", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteTranscriptDefaultsLocaleFromLarkBrand(t *testing.T) {
|
||||
config := &core.CliConfig{
|
||||
AppID: "test-app-lark-locale",
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandLark,
|
||||
UserOpenId: "ou_testuser",
|
||||
}
|
||||
factory, stdout, _, reg := noteShortcutTestFactoryWithConfig(t, config)
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
reg.Register(noteDetailStub("note_lark", displayTypeUnified))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/note_lark/unified_note_transcript?format=markdown&locale=en_us&page_size=200",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"transcript": map[string]interface{}{
|
||||
"markdown": "# en transcript\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_lark", "--as", "user"}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteTranscriptRejectsExistingOutputBeforeFetch(t *testing.T) {
|
||||
factory, stdout, _, _ := noteShortcutTestFactory(t)
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
outPath := filepath.Join("notes", "note_exists", "unified_transcript.md")
|
||||
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
|
||||
t.Fatalf("MkdirAll err=%v", err)
|
||||
}
|
||||
if err := os.WriteFile(outPath, []byte("old"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile err=%v", err)
|
||||
}
|
||||
|
||||
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_exists", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected existing output to fail")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, "output file already exists") {
|
||||
t.Fatalf("err = %q, want existing output error", got)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("err = %T, want ValidationError", err)
|
||||
}
|
||||
if validationErr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("subtype = %v, want FailedPrecondition", validationErr.Subtype)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "--overwrite") {
|
||||
t.Fatalf("hint = %q, want --overwrite guidance", validationErr.Hint)
|
||||
}
|
||||
// The CLI picked the default path itself, so no input param is at fault.
|
||||
if validationErr.Param != "" {
|
||||
t.Fatalf("param = %q, want empty for default output path", validationErr.Param)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteTranscriptRejectsEmptyTranscript(t *testing.T) {
|
||||
factory, stdout, _, reg := noteShortcutTestFactory(t)
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
reg.Register(noteDetailStub("note_empty", displayTypeUnified))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/note_empty/unified_note_transcript?format=markdown&locale=zh_cn&page_size=200",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"transcript": map[string]interface{}{
|
||||
"markdown": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_empty", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected empty transcript to fail")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, "transcript is empty") || !strings.Contains(got, "note_empty") {
|
||||
t.Fatalf("err = %q, want empty transcript error", got)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("category/subtype = %v/%v, want Internal/InvalidResponse", problem.Category, problem.Subtype)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(dir, "notes", "note_empty", "unified_transcript.md")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("transcript file should not exist, statErr=%v", statErr)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteTranscriptRejectsCursorCycle(t *testing.T) {
|
||||
factory, stdout, _, reg := noteShortcutTestFactory(t)
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
reg.Register(noteDetailStub("note_cycle", displayTypeUnified))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/note_cycle/unified_note_transcript?format=markdown&locale=zh_cn&page_size=200",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": true,
|
||||
"next_cursor_id": "A",
|
||||
"transcript": map[string]interface{}{
|
||||
"markdown": "page1\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "cursor_id=A",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": true,
|
||||
"next_cursor_id": "B",
|
||||
"transcript": map[string]interface{}{
|
||||
"markdown": "page2\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "cursor_id=B",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": true,
|
||||
"next_cursor_id": "A",
|
||||
"transcript": map[string]interface{}{
|
||||
"markdown": "page3\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_cycle", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected cursor cycle to fail")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, "pagination cursor did not advance") {
|
||||
t.Fatalf("err = %q, want cursor advance error", got)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("category/subtype = %v/%v, want Internal/InvalidResponse", problem.Category, problem.Subtype)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(dir, "notes", "note_cycle", "unified_transcript.md")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("transcript file should not exist, statErr=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscriptContextErrorPreservesCause(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
subtype errs.Subtype
|
||||
}{
|
||||
{
|
||||
name: "canceled",
|
||||
err: context.Canceled,
|
||||
subtype: errs.SubtypeNetworkTransport,
|
||||
},
|
||||
{
|
||||
name: "deadline",
|
||||
err: context.DeadlineExceeded,
|
||||
subtype: errs.SubtypeNetworkTimeout,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := transcriptContextError(tt.err)
|
||||
if !errors.Is(err, tt.err) {
|
||||
t.Fatalf("errors.Is(%v) = false", tt.err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryNetwork || problem.Subtype != tt.subtype {
|
||||
t.Fatalf("category/subtype = %v/%v, want Network/%v", problem.Category, problem.Subtype, tt.subtype)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func noteShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
config := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ReplaceAll(strings.ToLower(t.Name()), "/", "-"),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_testuser",
|
||||
}
|
||||
return noteShortcutTestFactoryWithConfig(t, config)
|
||||
}
|
||||
|
||||
func noteShortcutTestFactoryWithConfig(t *testing.T, config *core.CliConfig) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
return cmdutil.TestFactory(t, config)
|
||||
}
|
||||
|
||||
func runNoteShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "note"}
|
||||
shortcut.Mount(parent, factory)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
stdout.Reset()
|
||||
if stderr, ok := factory.IOStreams.ErrOut.(*bytes.Buffer); ok {
|
||||
stderr.Reset()
|
||||
}
|
||||
return parent.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
func noteDetailStub(noteID string, displayType int) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/" + noteID,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"note": map[string]interface{}{
|
||||
"note_display_type": displayType,
|
||||
"artifacts": []interface{}{
|
||||
map[string]interface{}{"artifact_type": artifactTypeVerbatim, "doc_token": "doc_verbatim"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func decodeNoteEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if data, _ := envelope["data"].(map[string]interface{}); data != nil {
|
||||
return data
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package note
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
// Shortcuts returns all note-domain shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
NoteDetail,
|
||||
NoteTranscript,
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,6 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/mail"
|
||||
"github.com/larksuite/cli/shortcuts/markdown"
|
||||
"github.com/larksuite/cli/shortcuts/minutes"
|
||||
"github.com/larksuite/cli/shortcuts/note"
|
||||
"github.com/larksuite/cli/shortcuts/sheets"
|
||||
sheetsbackward "github.com/larksuite/cli/shortcuts/sheets/backward"
|
||||
"github.com/larksuite/cli/shortcuts/slides"
|
||||
@@ -80,7 +79,6 @@ func init() {
|
||||
allShortcuts = append(allShortcuts, minutes.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, task.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, vc.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, note.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, whiteboard.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, wiki.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, okr.Shortcuts()...)
|
||||
|
||||
@@ -13,6 +13,7 @@ package vc
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -29,7 +30,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/larksuite/cli/shortcuts/note"
|
||||
)
|
||||
|
||||
// per-flag additional scope requirements for +notes (vc:note:read is checked by framework)
|
||||
@@ -51,6 +51,12 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
// artifact type enum from note detail API
|
||||
const (
|
||||
artifactTypeMainDoc = 1 // main note document
|
||||
artifactTypeVerbatim = 2 // verbatim transcript
|
||||
)
|
||||
|
||||
const logPrefix = "[vc +notes]"
|
||||
|
||||
const (
|
||||
@@ -60,6 +66,9 @@ const (
|
||||
recordingNotFoundCode = 121004 // 该会议没有妙记文件
|
||||
recordingNoPermissionCode = 121005 // 非会议参与者无权查看
|
||||
recordingGeneratingCode = 124002 // 录制/妙记文件仍在生成中
|
||||
|
||||
// note detail API specific error code.
|
||||
noteNoPermissionCode = 121005 // 调用者没有该纪要的阅读权限
|
||||
)
|
||||
|
||||
func minutesReadError(err error, minuteToken string) error {
|
||||
@@ -212,7 +221,7 @@ func fetchNoteByCalendarEventID(ctx context.Context, runtime *common.RuntimeCont
|
||||
// success means note detail was retrieved, regardless of whether the
|
||||
// recording API (minute_token) call succeeded — minute_token failures
|
||||
// surface as part of the merged `error` string for downstream visibility.
|
||||
if noteID, _ := noteResult["note_id"].(string); noteID != "" {
|
||||
if _, ok := noteResult["note_doc_token"].(string); ok {
|
||||
for k, v := range noteResult {
|
||||
result[k] = v
|
||||
}
|
||||
@@ -360,13 +369,11 @@ func joinErrors(msgs ...string) string {
|
||||
|
||||
// hasNotesPayload reports whether a result map carries any usable note or
|
||||
// minute payload, irrespective of partial failures surfaced via `error`.
|
||||
// note_id counts: it is the routing key for `note +detail` / `note +transcript`,
|
||||
// so a detail hit without doc tokens is still an actionable result.
|
||||
func hasNotesPayload(m map[string]any) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
for _, k := range []string{"note_id", "note_doc_token", "verbatim_doc_token", "minute_token", "meeting_notes", "shared_doc_tokens", "artifacts"} {
|
||||
for _, k := range []string{"note_doc_token", "verbatim_doc_token", "minute_token", "meeting_notes", "shared_doc_tokens", "artifacts"} {
|
||||
if v, ok := m[k]; ok && v != nil && v != "" {
|
||||
return true
|
||||
}
|
||||
@@ -512,22 +519,84 @@ func saveTranscriptToFile(runtime *common.RuntimeContext, minuteToken, title str
|
||||
return transcriptPath
|
||||
}
|
||||
|
||||
// fetchNoteDetail retrieves note fields via note_id by delegating to the note
|
||||
// domain (the canonical owner of note-detail parsing) and adapting the typed
|
||||
// result into the historical map shape `vc +notes` merges into its output. The
|
||||
// new note_id / note_display_type fields ride along via Detail.ToMap.
|
||||
func fetchNoteDetail(ctx context.Context, runtime *common.RuntimeContext, noteID string) map[string]any {
|
||||
detail, err := note.FetchDetail(ctx, runtime, noteID)
|
||||
if err != nil {
|
||||
if problem, ok := errs.ProblemOf(err); ok && problem.Code == note.NoNoteReadPermissionCode {
|
||||
return map[string]any{"error": fmt.Sprintf("[%v]: no read permission for this meeting note", problem.Code)}
|
||||
// parseArtifactType extracts artifact_type as int from varying JSON number representations.
|
||||
func parseArtifactType(v any) int {
|
||||
switch n := v.(type) {
|
||||
case json.Number:
|
||||
i, _ := n.Int64()
|
||||
return int(i)
|
||||
case float64:
|
||||
return int(n)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// extractArtifactTokens picks main-doc and verbatim-doc tokens from the artifacts list.
|
||||
func extractArtifactTokens(artifacts []any) (noteDoc, verbatimDoc string) {
|
||||
for _, a := range artifacts {
|
||||
artifact, _ := a.(map[string]any)
|
||||
if artifact == nil {
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, note.ErrEmptyDetail) {
|
||||
return map[string]any{"error": note.ErrEmptyDetail.Error()}
|
||||
docToken, _ := artifact["doc_token"].(string)
|
||||
switch parseArtifactType(artifact["artifact_type"]) {
|
||||
case artifactTypeMainDoc:
|
||||
noteDoc = docToken
|
||||
case artifactTypeVerbatim:
|
||||
verbatimDoc = docToken
|
||||
default:
|
||||
// ignore unknown artifact types
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// extractDocTokens collects doc_token values from a list of reference objects.
|
||||
func extractDocTokens(refs []any) []string {
|
||||
var tokens []string
|
||||
for _, s := range refs {
|
||||
source, _ := s.(map[string]any)
|
||||
if source == nil {
|
||||
continue
|
||||
}
|
||||
if docToken, _ := source["doc_token"].(string); docToken != "" {
|
||||
tokens = append(tokens, docToken)
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
// fetchNoteDetail retrieves note document tokens via note_id.
|
||||
func fetchNoteDetail(_ context.Context, runtime *common.RuntimeContext, noteID string) map[string]any {
|
||||
data, err := runtime.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)), nil, nil)
|
||||
if err != nil {
|
||||
if p, ok := errs.ProblemOf(err); ok && p.Code == noteNoPermissionCode {
|
||||
return map[string]any{"error": fmt.Sprintf("[%v]: no read permission for this meeting note", p.Code)}
|
||||
}
|
||||
return map[string]any{"error": fmt.Sprintf("failed to query note detail: %v", err)}
|
||||
}
|
||||
return detail.ToMap()
|
||||
|
||||
note, _ := data["note"].(map[string]any)
|
||||
if note == nil {
|
||||
return map[string]any{"error": "note detail is empty"}
|
||||
}
|
||||
|
||||
creatorID, _ := note["creator_id"].(string)
|
||||
createTime := common.FormatTime(note["create_time"])
|
||||
noteDocToken, verbatimDocToken := extractArtifactTokens(common.GetSlice(note, "artifacts"))
|
||||
sharedDocTokens := extractDocTokens(common.GetSlice(note, "references"))
|
||||
|
||||
result := map[string]any{
|
||||
"creator_id": creatorID,
|
||||
"create_time": createTime,
|
||||
"note_doc_token": noteDocToken,
|
||||
"verbatim_doc_token": verbatimDocToken,
|
||||
}
|
||||
if len(sharedDocTokens) > 0 {
|
||||
result["shared_doc_tokens"] = sharedDocTokens
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// VCNotes queries meeting notes via meeting-ids, minute-tokens, or calendar-event-ids.
|
||||
@@ -706,12 +775,6 @@ var VCNotes = common.Shortcut{
|
||||
id, _ = m["calendar_event_id"].(string)
|
||||
}
|
||||
row := map[string]interface{}{"id": id}
|
||||
if v, _ := m["note_id"].(string); v != "" {
|
||||
row["note_id"] = v
|
||||
}
|
||||
if v, _ := m["note_display_type"].(string); v != "" {
|
||||
row["note_display_type"] = v
|
||||
}
|
||||
if errMsg, _ := m["error"].(string); errMsg != "" {
|
||||
row["status"] = "FAIL"
|
||||
row["error"] = errMsg
|
||||
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/larksuite/cli/shortcuts/note"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -120,21 +119,6 @@ func noteDetailStub(noteID string) *httpmock.Stub {
|
||||
}
|
||||
}
|
||||
|
||||
func noteDetailDisplayOnlyStub(noteID string, displayType int) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/" + noteID,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"note": map[string]interface{}{
|
||||
"note_display_type": displayType,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func artifactsStub(token, transcript string) *httpmock.Stub {
|
||||
data := map[string]interface{}{
|
||||
"summary": "Test summary content",
|
||||
@@ -194,9 +178,68 @@ func TestSanitizeDirName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Note-detail parsing helpers (parseArtifactType/extractArtifactTokens/
|
||||
// extractDocTokens) moved to the note domain; their tests live in
|
||||
// shortcuts/note/note_test.go.
|
||||
func TestParseArtifactType(t *testing.T) {
|
||||
tests := []struct {
|
||||
input any
|
||||
want int
|
||||
}{
|
||||
{float64(1), 1},
|
||||
{float64(2), 2},
|
||||
{json.Number("3"), 3},
|
||||
{"unknown", 0},
|
||||
{nil, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := parseArtifactType(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("parseArtifactType(%v) = %d, want %d", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractArtifactTokens(t *testing.T) {
|
||||
artifacts := []any{
|
||||
map[string]any{"doc_token": "main_doc", "artifact_type": float64(1)},
|
||||
map[string]any{"doc_token": "verbatim_doc", "artifact_type": float64(2)},
|
||||
map[string]any{"doc_token": "unknown_doc", "artifact_type": float64(99)},
|
||||
nil,
|
||||
}
|
||||
noteDoc, verbatimDoc := extractArtifactTokens(artifacts)
|
||||
if noteDoc != "main_doc" {
|
||||
t.Errorf("noteDoc = %q, want %q", noteDoc, "main_doc")
|
||||
}
|
||||
if verbatimDoc != "verbatim_doc" {
|
||||
t.Errorf("verbatimDoc = %q, want %q", verbatimDoc, "verbatim_doc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractArtifactTokens_Empty(t *testing.T) {
|
||||
noteDoc, verbatimDoc := extractArtifactTokens(nil)
|
||||
if noteDoc != "" || verbatimDoc != "" {
|
||||
t.Errorf("expected empty tokens for nil input, got %q, %q", noteDoc, verbatimDoc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDocTokens(t *testing.T) {
|
||||
refs := []any{
|
||||
map[string]any{"doc_token": "shared1"},
|
||||
map[string]any{"doc_token": "shared2"},
|
||||
map[string]any{"doc_token": ""},
|
||||
map[string]any{},
|
||||
nil,
|
||||
}
|
||||
tokens := extractDocTokens(refs)
|
||||
if len(tokens) != 2 || tokens[0] != "shared1" || tokens[1] != "shared2" {
|
||||
t.Errorf("extractDocTokens = %v, want [shared1 shared2]", tokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDocTokens_Empty(t *testing.T) {
|
||||
tokens := extractDocTokens(nil)
|
||||
if tokens != nil {
|
||||
t.Errorf("expected nil for nil input, got %v", tokens)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration tests: +notes with mocked HTTP
|
||||
@@ -319,6 +362,25 @@ func TestNotes_BatchLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArtifactType_AllBranches(t *testing.T) {
|
||||
// cover json.Number branch
|
||||
if got := parseArtifactType(json.Number("1")); got != 1 {
|
||||
t.Errorf("json.Number: got %d, want 1", got)
|
||||
}
|
||||
// cover float64 branch
|
||||
if got := parseArtifactType(float64(2)); got != 2 {
|
||||
t.Errorf("float64: got %d, want 2", got)
|
||||
}
|
||||
// cover default branch
|
||||
if got := parseArtifactType("str"); got != 0 {
|
||||
t.Errorf("default: got %d, want 0", got)
|
||||
}
|
||||
// cover nil
|
||||
if got := parseArtifactType(nil); got != 0 {
|
||||
t.Errorf("nil: got %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests for new calendar-to-notes functions
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -533,33 +595,6 @@ func TestNotes_CalendarPath_FallbackWhenMeetingChainFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotes_CalendarPath_KeepsNoteIDOnlyDetail(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
calID := "cal_test"
|
||||
reg.Register(primaryCalendarStub(calID))
|
||||
reg.Register(calendarRelationStub(calID, "evt_note_only", []string{"m_note_only"}, nil))
|
||||
reg.Register(meetingGetStub("m_note_only", "note_only"))
|
||||
reg.Register(noteDetailDisplayOnlyStub("note_only", 2))
|
||||
reg.Register(recordingErrStub("m_note_only", 121004, "not found"))
|
||||
|
||||
err := mountAndRun(t, VCNotes, []string{"+notes", "--calendar-event-ids", "evt_note_only", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
note := extractFirstNote(t, stdout)
|
||||
if got := note["note_id"]; got != "note_only" {
|
||||
t.Fatalf("note_id = %v, want note_only; note=%#v", got, note)
|
||||
}
|
||||
if got := note["note_display_type"]; got != "unified" {
|
||||
t.Fatalf("note_display_type = %v, want unified; note=%#v", got, note)
|
||||
}
|
||||
if got := note["calendar_event_id"]; got != "evt_note_only" {
|
||||
t.Fatalf("calendar_event_id = %v, want evt_note_only; note=%#v", got, note)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotes_CalendarPath_NeedNotes_RequestBody(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
@@ -613,26 +648,6 @@ func TestNotes_CalendarPath_NeedNotes_RequestBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotes_TableOutputIncludesNoteRoutingFields(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(meetingGetStub("m_table", "note_table"))
|
||||
reg.Register(noteDetailDisplayOnlyStub("note_table", 2))
|
||||
reg.Register(recordingErrStub("m_table", 121004, "not found"))
|
||||
|
||||
err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m_table", "--format", "table", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "note_table") {
|
||||
t.Fatalf("table output missing note_id:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "unified") {
|
||||
t.Fatalf("table output missing note_display_type:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transcript path layout tests (unified ./minutes/{token}/ default)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -741,9 +756,7 @@ func TestHasNotesPayload(t *testing.T) {
|
||||
{"nil", nil, false},
|
||||
{"empty", map[string]any{}, false},
|
||||
{"only meta", map[string]any{"meeting_id": "m1", "error": "fail"}, false},
|
||||
{"empty values", map[string]any{"note_doc_token": "", "minute_token": "", "note_id": ""}, false},
|
||||
{"only note_id", map[string]any{"note_id": "note1"}, true},
|
||||
{"note_id with display type", map[string]any{"note_id": "note1", "note_display_type": "unified", "note_doc_token": ""}, true},
|
||||
{"empty values", map[string]any{"note_doc_token": "", "minute_token": ""}, false},
|
||||
{"has note_doc_token", map[string]any{"note_doc_token": "doc1"}, true},
|
||||
{"has verbatim_doc_token", map[string]any{"verbatim_doc_token": "v1"}, true},
|
||||
{"has minute_token", map[string]any{"minute_token": "obc"}, true},
|
||||
@@ -1253,7 +1266,7 @@ func TestFetchNoteDetail_NoteNoPermission_ProblemOf(t *testing.T) {
|
||||
|
||||
// meeting.get returns note_id, note detail returns 121005
|
||||
reg.Register(meetingGetStub("m_noteperm2", "note_perm2"))
|
||||
reg.Register(noteDetailErrStub("note_perm2", note.NoNoteReadPermissionCode, "no permission"))
|
||||
reg.Register(noteDetailErrStub("note_perm2", noteNoPermissionCode, "no permission"))
|
||||
reg.Register(recordingOKStub("m_noteperm2", "https://meetings.feishu.cn/minutes/obcpermtest"))
|
||||
|
||||
// note fails but minute_token succeeds → partial success (hasNotesPayload=true)
|
||||
@@ -1273,29 +1286,6 @@ func TestFetchNoteDetail_NoteNoPermission_ProblemOf(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchNoteDetail_EmptyDetailKeepsLegacyError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/note_empty_detail",
|
||||
Body: map[string]any{
|
||||
"code": 0,
|
||||
"data": map[string]any{},
|
||||
},
|
||||
})
|
||||
|
||||
if err := botExec(t, "empty-note-detail", f, func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
got := fetchNoteDetail(ctx, rctx, "note_empty_detail")
|
||||
if got["error"] != "note detail is empty" {
|
||||
t.Fatalf("error = %#v, want legacy empty-detail text", got["error"])
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotes_AllFailed_OutPartialFailure pins that when every item in the batch
|
||||
// fails (successCount == 0), Execute returns *output.PartialFailureError with
|
||||
// ExitAPI code, and stdout still carries the ok:false envelope with notes data.
|
||||
|
||||
@@ -1,46 +1,71 @@
|
||||
## 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).
|
||||
- **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 (user vs bot)
|
||||
### Identity and Token Mapping
|
||||
|
||||
- `--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.
|
||||
- `--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
|
||||
### Sender Name Resolution with Bot Identity
|
||||
|
||||
As **bot**, the sender may show as `open_id` (bot visibility range doesn't cover it); `--as user` gives real names.
|
||||
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.
|
||||
|
||||
```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
|
||||
```
|
||||
**Root cause**: The bot's app visibility settings do not include the message sender, so the contact API returns no name.
|
||||
|
||||
### Default message enrichment
|
||||
**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.
|
||||
|
||||
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).
|
||||
### 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
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
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`.
|
||||
Feed shortcuts add chats to the **current user's** feed sidebar. They are distinct from flags:
|
||||
|
||||
## 不在本 skill 范围
|
||||
- **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).
|
||||
|
||||
- 邮件 → [`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 代执行)。
|
||||
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.
|
||||
|
||||
@@ -30,8 +30,12 @@ lark-cli schema {{service}}.<resource>.<method> # 调用 API 前必须先查
|
||||
lark-cli {{service}} <resource> <method> [flags] # 调用 API
|
||||
```
|
||||
|
||||
{{schema_note_block}}
|
||||
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
|
||||
{{resource_sections}}
|
||||
{{permission_block}}
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
{{permission_rows}}
|
||||
{{/actions}}
|
||||
|
||||
@@ -35,44 +35,16 @@ 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),再决定走哪类命令 |
|
||||
| 查看 Base 内资源目录 | `+base-block-list` | 想先了解一个 Base 里有哪些 table/docx/dashboard/workflow/folder 时优先用它;返回 ID 关系和 fewshot 看 `--help` |
|
||||
| 管理 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` | 字段发现默认用 `+field-list --compact`;需要 formula/lookup 细节或完整字段 JSON 再用 `+field-get` / 不带 compact 的 list;多表结构用 `+field-list-batch --compact --table-id <表1> --table-id <表2>` 一次取齐,不要逐表调用 |
|
||||
| 列/查/删字段 | `+field-list/get/delete/search-options` | 写入前用 list/get 确认字段类型、选项、ID;删除前确认目标字段 |
|
||||
| 创建/更新字段 | `+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) |
|
||||
@@ -86,34 +58,24 @@ 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-*` | 先读入口 [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) |
|
||||
| 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);系统角色不可删除;关闭高级权限会影响自定义角色 |
|
||||
|
||||
## 注意事项
|
||||
## 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。
|
||||
@@ -121,27 +83,35 @@ done
|
||||
- `91403` 或明确不可访问错误不要循环换身份重试。
|
||||
- `+base-create` / `+base-copy` 若用 bot 身份执行,关注返回中的 `permission_grant`,并把用户是否可打开新 Base 告知用户。
|
||||
|
||||
### 查询与统计
|
||||
## 查询与统计规则
|
||||
|
||||
- 涉及筛选、排序、Top/Bottom N、聚合、分组、多表关联或任何全局结论时,先读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) 并按其 Hard Rules 执行。
|
||||
- 两条红线随时生效:能由 Base 云端表达的筛选/排序/聚合不要拉原始记录到本地手工处理;`has_more=true` 等分页信号未消除前,不能基于当前页下全局结论。
|
||||
涉及查询、统计或判断结论时,先阅读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md),并遵守:
|
||||
|
||||
### 写入前置
|
||||
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` 验证,再按需沉淀为持久视图。
|
||||
## 表单与视图细节
|
||||
|
||||
### 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);删除角色、关闭高级权限前确认目标和影响。
|
||||
- `+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 验证结果,再按需要沉淀为持久视图。
|
||||
|
||||
## Token 与链接
|
||||
|
||||
@@ -152,10 +122,19 @@ done
|
||||
| `/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/...` / `/share/base/dashboard/...` / `/record/...` / `/base/workspace/...` | 分享链接与 workspace 链接,暂不支持用 CLI 直接访问,引导用户在飞书客户端打开;要生成记录分享链接用 `+record-share-link-create` |
|
||||
| `/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 直接访问 |
|
||||
|
||||
`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` 只适用于自定义角色,系统角色不可删除;删除角色和关闭高级权限前必须确认目标和影响。
|
||||
|
||||
## 常见恢复
|
||||
|
||||
| 错误 / 现象 | 恢复动作 |
|
||||
@@ -170,3 +149,18 @@ done
|
||||
| `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,8 +2,6 @@
|
||||
|
||||
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 值 | 说明 |
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
# 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,139 +228,175 @@ After the result column, it's recommended to flatten with `.LISTCOMBINE()` first
|
||||
|
||||
---
|
||||
|
||||
## Section 8: Function Reference (common functions)
|
||||
|
||||
> 本表覆盖常用函数(含评测与真实负载中 100% 出现过的函数)。三角/双曲/随机数/进制转换等罕见函数的签名在 [formula-functions-extended.md](formula-functions-extended.md),仅当用户明确要求这些函数时再读。
|
||||
## Section 8: Complete Function Reference
|
||||
|
||||
### 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) |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
### 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
### 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
### 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** |
|
||||
| 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** |
|
||||
| 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) |
|
||||
|
||||
### 8.5 List functions
|
||||
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
## 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`) |
|
||||
|
||||
---
|
||||
|
||||
@@ -576,11 +612,119 @@ Reason: NOW, TODAY, PI and other zero-argument functions must include parenthese
|
||||
|
||||
---
|
||||
|
||||
## Section 13: Examples
|
||||
## Section 13: Complete Examples
|
||||
|
||||
完整示例与"自然语言需求 → 公式"翻译规则按需读 [formula-examples.md](formula-examples.md)。
|
||||
### Example 1: Employee sales summary
|
||||
|
||||
## Section 14: Constraint 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).
|
||||
|
||||
---
|
||||
|
||||
## Section 15: Constraint Summary
|
||||
|
||||
- Request body must include `"type": "formula"` — this field is required
|
||||
- Only use functions and operators listed in this document
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
# 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,9 +13,6 @@
|
||||
- 一次 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
|
||||
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
# 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,14 +21,6 @@ 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,7 +397,6 @@
|
||||
|
||||
默认值 / 约束:
|
||||
- `style.rules` 是规则数组,数量 `1..9`
|
||||
- `+field-update` 修改编号规则时,**默认会把新规则应用到已有记录**
|
||||
- 默认规则:
|
||||
|
||||
```json
|
||||
|
||||
@@ -1,130 +1,46 @@
|
||||
# Workflow guide
|
||||
|
||||
本文档是 Workflow 的操作地图:先用它决定最短路径,再按需打开 schema 小文件。Guide 要一次读完后能完成大多数查询、启停和常见创建/修改;schema 才是零件手册。
|
||||
本文档是 Workflow 的入口指南,帮助选择步骤组合、理解创建/更新边界,并引导到 steps JSON SSOT。
|
||||
|
||||
## 先判断任务类型
|
||||
> **配套文档**:
|
||||
> - 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": "unique-create-token",
|
||||
"title": "工作流标题",
|
||||
"client_token": "1704067200",
|
||||
"title": "新订单自动通知",
|
||||
"steps": [
|
||||
{
|
||||
"id": "step_trigger",
|
||||
"id": "trigger_1",
|
||||
"type": "AddRecordTrigger",
|
||||
"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",
|
||||
"title": "监控新订单",
|
||||
"next": "action_1",
|
||||
"data": {
|
||||
"table_name": "订单表",
|
||||
"watched_field_name": "订单号"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "act_notify",
|
||||
"id": "action_1",
|
||||
"type": "LarkMessageAction",
|
||||
"title": "发送通知",
|
||||
"next": null,
|
||||
"data": {
|
||||
"receiver": [{ "value_type": "user", "value": { "id": "ou_xxx" } }],
|
||||
"receiver": [{ "value_type": "user", "value": {"id": "ou_xxxx", "name": "张三"} }],
|
||||
"send_to_everyone": false,
|
||||
"title": [{ "value_type": "text", "value": "新订单提醒" }],
|
||||
"content": [{ "value_type": "text", "value": "收到新订单" }],
|
||||
"content": [
|
||||
{ "value_type": "text", "value": "收到新订单" }
|
||||
],
|
||||
"btn_list": []
|
||||
}
|
||||
}
|
||||
@@ -132,27 +48,783 @@ Schema 入口:[lark-base-workflow-schema.md](lark-base-workflow-schema.md)。
|
||||
}
|
||||
```
|
||||
|
||||
## 修改现有 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-组合场景-定时查找循环分支消息) |
|
||||
|
||||
| 错误 | 处理 |
|
||||
|---|---|
|
||||
| 查询/启停也读 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` |
|
||||
---
|
||||
|
||||
## 完整示例
|
||||
|
||||
### 示例 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" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 参考
|
||||
|
||||
- [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、节点输出;只有构造这些细节时才读。
|
||||
- [lark-base-workflow-schema.md](lark-base-workflow-schema.md) — 字段定义参考
|
||||
- 创建/更新前先确认真实表名、字段名和目标 workflow ID;`steps` 结构按 schema 构造,不凭自然语言猜 `type`
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,24 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,16 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,25 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,21 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,48 +0,0 @@
|
||||
# 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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user