Compare commits

...

10 Commits

Author SHA1 Message Date
liangshuo-1
09d5c5d99b docs(lark-shared): restructure into prioritized rules + on-demand references
Rewrite the always-loaded SKILL.md from a 168-line monolith into a slim
core: a positioning line plus 7 mental-model "通用准则" ordered by agent
attention priority (silent/proactive rules first; loud-triggered and
low-frequency ones last), and a short routing list. Mechanics and edge
cases move into on-demand references/ (loaded only when relevant).

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

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

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

Change-Id: I2dff478ecdc05a13f2d750944f637ed2374961e7
2026-06-13 17:43:27 +08:00
liangshuo-1
e1af7e3018 chore: release v1.0.53 (#1443)
]
2026-06-12 20:03:08 +08:00
bubbmon233
693e299589 docs(mail): clarify message read shortcuts (#1261)
* docs(mail): clarify message read shortcuts

Update mail read shortcut help, docs, and triage guidance so single-message and multi-message reads are routed to the right commands.

Add focused tests for help text, dry-run copy, triage stderr hints, and batch_get chunking behavior.

sprint: S1

* docs(mail): align batch_get limit with gateway config

* docs(mail): use shell-safe batch message id examples

* docs(mail): trim batch_get pagination wording

* docs(mail): use placeholder style for message ids

* docs(mail): hide batch_get internals from help
2026-06-12 19:52:36 +08:00
Yuxuan Zhao
69f335be7c test(calendar): drop flaky calendar list e2e checks (#1441) 2026-06-12 19:00:09 +08:00
JackZhao10086
d1a0926dd6 feat/revoke token (#1434) 2026-06-12 17:49:33 +08:00
syh-cpdsss
008bdda861 docs(whiteboard): optimize whiteboard skill (#1371)
* docs(whiteboard): optimize whiteboard skill

Change-Id: Iabcbe9f4e309ae9f467ceec265320cea6cdfa81b

* fix: PR issue

Change-Id: I96d99037b3ba74a3ea9964991b67cdf15fb985be
2026-06-12 17:46:55 +08:00
syh-cpdsss
f1da8c274b docs(okr): optimize okr skill (#1368)
Change-Id: I095a3a7a935e4f84459d1be24015f59cd9e324a6
2026-06-12 17:46:27 +08:00
AlbertSun
842be3fdc5 feat(token): mint TAT via unified OAuth v3 Token Endpoint (#1408) 2026-06-12 17:44:07 +08:00
raistlin042
1cd7a88597 fix: read release error_logs from data.error_logs in apps +release-get (#1436) 2026-06-12 16:58:47 +08:00
max
7c64e63b9d feat(note): clarify note ownership with dedicated detail and transcript flows (#1435)
* feat: split note domain

* fix: address note transcript review comments

* fix: stabilize empty note detail detection
2026-06-12 16:30:41 +08:00
79 changed files with 3256 additions and 774 deletions

View File

@@ -2,6 +2,25 @@
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
@@ -1130,6 +1149,7 @@ 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

View File

@@ -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"}
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
}

View File

@@ -9,6 +9,7 @@ import (
"errors"
"io"
"net/http"
"slices"
"sort"
"strings"
"testing"
@@ -214,6 +215,12 @@ 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 {

View File

@@ -72,11 +72,28 @@ 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)

View File

@@ -5,12 +5,14 @@ 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"
)
@@ -145,3 +147,210 @@ 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)
}
}

View File

@@ -33,15 +33,16 @@ 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 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.
// 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.
//
// 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

View File

@@ -31,10 +31,10 @@ type fakeRT struct {
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
switch {
case strings.HasSuffix(req.URL.Path, "/auth/v3/tenant_access_token/internal"):
case strings.HasSuffix(req.URL.Path, "/oauth/v3/token"):
f.tatCalls++
if f.tatHandler == nil {
return jsonResp(200, `{"code":0,"tenant_access_token":"t-ok"}`), nil
return jsonResp(200, `{"code":0,"access_token":"t-ok","token_type":"Bearer"}`), nil
}
return f.tatHandler(req)
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
@@ -84,14 +84,15 @@ 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) 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) {
// 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) {
t.Helper()
if err == nil {
t.Fatalf("expected *errs.ConfigError (code %d), got nil", wantCode)
t.Fatal("expected *errs.ConfigError, got nil")
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
@@ -103,9 +104,6 @@ func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCo
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())
}
@@ -123,11 +121,13 @@ func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
}
}
// 10003 (bad / non-existent app_id) → ConfigError/InvalidClient, propagated.
func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
// 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) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":10003,"msg":"invalid param"}`), nil
return jsonResp(400, `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
@@ -137,28 +137,27 @@ func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
if rt.probeCalls != 0 {
t.Error("probe endpoint must not be called when TAT fails")
}
assertConfigRejection(t, err, errBuf, 10003)
assertConfigRejection(t, err, errBuf)
}
// 10014 (real app_id + wrong secret) → ConfigError/InvalidClient via codemeta —
// the most common real-world rejection, propagated.
func TestRunProbe_TATCode10014_ReturnsConfigError(t *testing.T) {
// unauthorized_client is treated as the same credential rejection, propagated.
func TestRunProbe_TATUnauthorizedClient_ReturnsConfigError(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":10014,"msg":"app secret invalid"}`), nil
return jsonResp(401, `{"error":"unauthorized_client","error_description":"client not authorized"}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf, 10014)
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
// 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) {
// 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) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":99999,"msg":"future-unknown"}`), nil
return jsonResp(400, `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`), nil
},
}
f, errBuf := fakeFactory(t, rt)

View File

@@ -47,6 +47,7 @@ type DeviceFlowResult struct {
// OAuthEndpoints contains the OAuth endpoint URLs.
type OAuthEndpoints struct {
DeviceAuthorization string
Revoke string
Token string
}
@@ -55,6 +56,7 @@ func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints {
ep := core.ResolveEndpoints(brand)
return OAuthEndpoints{
DeviceAuthorization: ep.Accounts + PathDeviceAuthorization,
Revoke: ep.Accounts + PathOAuthRevoke,
Token: ep.Open + PathOAuthTokenV2,
}
}

View File

@@ -31,6 +31,9 @@ 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)
}
@@ -42,6 +45,9 @@ 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)
}

View File

@@ -7,6 +7,8 @@ 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).

131
internal/auth/revoke.go Normal file
View File

@@ -0,0 +1,131 @@
// 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
}

View File

@@ -0,0 +1,207 @@
// 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)
}
}

View File

@@ -22,6 +22,12 @@ 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"

View File

@@ -42,6 +42,11 @@ 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) {

View File

@@ -19,33 +19,44 @@ import (
extcred "github.com/larksuite/cli/extension/credential"
)
// 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:
// 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.
//
// - 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 {
// 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":
return errs.NewConfigError(errs.SubtypeInvalidClient, "%s", msg).
WithCode(code).
WithHint("%s", errclass.ConfigHint(errs.SubtypeInvalidClient))
}
return errclass.BuildAPIError(map[string]any{
if err := 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.
@@ -146,8 +157,8 @@ func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, er
return &TokenResult{Token: token, Scopes: scopes}, nil
}
// 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.
// 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.
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) {
p.tatOnce.Do(func() {
p.tatResult, p.tatErr = p.doResolveTAT(ctx)

View File

@@ -19,18 +19,16 @@ func TestDefaultAccountProvider_Implements(t *testing.T) {
var _ DefaultAccountResolver = &DefaultAccountProvider{}
}
// 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")
// 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")
if err == nil {
t.Fatal("expected non-nil error for code=10003")
t.Fatal("expected non-nil error for invalid_client")
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
@@ -42,22 +40,16 @@ func TestClassifyTATResponseCode_10003_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_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")
}
// 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")
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
@@ -65,21 +57,38 @@ func TestClassifyTATResponseCode_10014_RoutesViaCodeMeta(t *testing.T) {
if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
}
if cfgErr.Code != 10014 {
t.Errorf("Code = %d, want 10014", cfgErr.Code)
}
}
// 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")
// 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 unmapped code")
t.Fatal("expected non-nil error for invalid_scope")
}
var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) {
t.Fatalf("unmapped code must not be classified as ConfigError, got %T", err)
t.Fatalf("invalid_scope must not be classified as ConfigError, got %T", err)
}
}
// 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")
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)
}
var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) {
t.Fatalf("code-0 invalid_scope must not be a ConfigError, got %T", err)
}
}

View File

@@ -4,46 +4,47 @@
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 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.
// 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.
//
// 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.
// 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.
//
// 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)
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
endpoint := ep.Accounts + core.OAuthTokenV3Path
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))
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()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
@@ -51,20 +52,51 @@ func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", fmt.Errorf("failed to read TAT response: %w", err)
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
TenantAccessToken string `json:"tenant_access_token"`
Code int `json:"code"`
AccessToken string `json:"access_token"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
Msg string `json:"msg"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to parse TAT response: %w", err)
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 result.Code != 0 {
return "", classifyTATResponseCode(result.Code, result.Msg, string(brand), appID)
if result.Code == 0 && result.AccessToken != "" {
return result.AccessToken, nil
}
return result.TenantAccessToken, nil
// 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)
}

View File

@@ -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,"tenant_access_token":"t-abc","msg":"ok"}`,
respBody: `{"code":0,"access_token":"t-abc","token_type":"Bearer","expires_in":7200}`,
}
hc := &http.Client{Transport: rt}
@@ -55,24 +55,33 @@ func TestFetchTAT_Success(t *testing.T) {
if token != "t-abc" {
t.Errorf("token = %q, want t-abc", token)
}
if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" {
if rt.gotReq.URL.String() != "https://accounts.feishu.cn/oauth/v3/token" {
t.Errorf("url = %s", rt.gotReq.URL.String())
}
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)
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)
}
}
}
// 10003 (bad / non-existent app_id, "invalid param") is classified locally by
// invalid_client (wrong app_id/app_secret on the client_credentials grant) is a
// deterministic client-side rejection that FetchTAT routes to
// classifyTATResponseCode as CategoryConfig / SubtypeInvalidClient — the same
// typed error doResolveTAT (and thus every token-resolving command) returns.
func TestFetchTAT_Code10003_ConfigInvalidClient(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10003,"msg":"invalid param"}`}
// 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}`}
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 code 10003")
t.Fatal("expected error for invalid_client")
}
if token != "" {
t.Errorf("token = %q, want empty", token)
@@ -87,52 +96,115 @@ func TestFetchTAT_Code10003_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)
}
}
// 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")
var cfgErr *errs.ConfigError
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)
}
}
// 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"}`}
// 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"}`}
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 99999")
t.Fatal("expected error for invalid_scope")
}
if !errs.IsTyped(err) {
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Errorf("unknown code should fall back to *errs.APIError, got %T", err)
var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) {
t.Errorf("invalid_scope must not be classified as ConfigError/InvalidClient, got %T", err)
}
}
// 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.
// 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"}`}
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")
}
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)
}
}
// 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.
func TestFetchTAT_HTTPNon200_Untyped(t *testing.T) {
for _, code := range []int{401, 403, 500, 503} {
rt := &stubRoundTripper{respCode: code, respBody: `whatever`}
@@ -182,12 +254,12 @@ func TestFetchTAT_BrandRouting(t *testing.T) {
brand core.LarkBrand
wantURL string
}{
{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"},
{core.BrandFeishu, "https://accounts.feishu.cn/oauth/v3/token"},
{core.BrandLark, "https://accounts.larksuite.com/oauth/v3/token"},
}
for _, tc := range tests {
t.Run(string(tc.brand), func(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"tenant_access_token":"t"}`}
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"access_token":"t","token_type":"Bearer"}`}
hc := &http.Client{Transport: rt}
if _, err := FetchTAT(context.Background(), hc, tc.brand, "a", "b"); err != nil {
t.Fatal(err)

View File

@@ -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}, // TAT endpoint — "app secret invalid" (TAT-mint variant of 99991543)
10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // legacy TAT endpoint — "app secret invalid" (pre-v3 variant of 99991543; CLI now reports invalid_client)
// CategoryPolicy
21000: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeChallengeRequired},

View File

@@ -35,9 +35,12 @@ const (
LarkErrAppNotInUse = 99991662 // app is disabled in this tenant
LarkErrAppUnauthorized = 99991673 // app status unavailable; check installation
// 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.
// "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.
LarkErrTATInvalidSecret = 10014
// Rate limit.

View File

@@ -47,6 +47,10 @@
"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": "电子表格操作" }

View File

@@ -25,9 +25,11 @@ 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/",

View File

@@ -26,9 +26,11 @@ 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/",
@@ -36,7 +38,6 @@ var migratedEnvelopePaths = []string{
"shortcuts/vc/",
"shortcuts/whiteboard/",
"shortcuts/wiki/",
"shortcuts/im/",
}
// legacyOutputImportPath is the import path of the package that declares the

View File

@@ -953,6 +953,7 @@ 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",
@@ -988,6 +989,18 @@ 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

View File

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

View File

@@ -57,6 +57,9 @@ 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",

View File

@@ -134,13 +134,15 @@ 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 {
@@ -200,11 +202,13 @@ 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)
@@ -214,6 +218,69 @@ 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"

View File

@@ -9,19 +9,19 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// MailMessage is the `+message` shortcut: fetch full content of a single
// email by message ID (normalized body + attachments / inline metadata).
// MailMessage is the `+message` shortcut: fetch full content of one email
// by one message ID (normalized body + attachments / inline metadata).
var MailMessage = common.Shortcut{
Service: "mail",
Command: "+message",
Description: "Use when reading full content for a single email by message ID. Returns normalized body content plus attachments metadata, including inline images.",
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.",
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. Email message ID", Required: true},
{Name: "message-id", Desc: "Required. Single email message ID only. For multiple IDs, use mail +messages --message-ids.", 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)"},
},

View File

@@ -18,19 +18,19 @@ type mailMessagesOutput struct {
}
// MailMessages is the `+messages` shortcut: batch-fetch full content for
// multiple message IDs, chunking backend calls into batches of 20 while
// preserving request order.
// multiple message IDs, chunking requests 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. 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.",
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.",
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. Example: "id1,id2,id3"`, Required: true},
{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: "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 via messages.batch_get (auto-chunked in batches of 20 IDs during execution)").
Desc("Fetch multiple emails; execution chunks every 20 IDs and merges output").
POST(mailboxPath(mailboxID, "messages", "batch_get")).
Body(body)
},

View File

@@ -0,0 +1,220 @@
// 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()
}

View File

@@ -322,9 +322,10 @@ var MailTriage = common.Shortcut{
fmt.Fprintln(runtime.IO().ErrOut, hint.String())
}
if mailbox != "me" {
fmt.Fprintln(runtime.IO().ErrOut, "tip: use mail +message --mailbox "+shellQuote(mailbox)+" --message-id <id> to read full content")
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>")
} else {
fmt.Fprintln(runtime.IO().ErrOut, "tip: use mail +message --message-id <id> to read full content")
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>")
}
}
return nil

209
shortcuts/note/note.go Normal file
View File

@@ -0,0 +1,209 @@
// 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
}
}

View File

@@ -0,0 +1,86 @@
// 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
}

280
shortcuts/note/note_test.go Normal file
View File

@@ -0,0 +1,280 @@
// 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)
}

View File

@@ -0,0 +1,258 @@
// 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)
}

View File

@@ -0,0 +1,438 @@
// 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
}

View File

@@ -0,0 +1,14 @@
// 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,
}
}

View File

@@ -29,6 +29,7 @@ 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"
@@ -79,6 +80,7 @@ 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()...)

View File

@@ -13,7 +13,6 @@ package vc
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -30,6 +29,7 @@ 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,12 +51,6 @@ var (
}
)
// artifact type enum from note detail API
const (
artifactTypeMainDoc = 1 // main note document
artifactTypeVerbatim = 2 // verbatim transcript
)
const logPrefix = "[vc +notes]"
const (
@@ -66,9 +60,6 @@ const (
recordingNotFoundCode = 121004 // 该会议没有妙记文件
recordingNoPermissionCode = 121005 // 非会议参与者无权查看
recordingGeneratingCode = 124002 // 录制/妙记文件仍在生成中
// note detail API specific error code.
noteNoPermissionCode = 121005 // 调用者没有该纪要的阅读权限
)
func minutesReadError(err error, minuteToken string) error {
@@ -221,7 +212,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 _, ok := noteResult["note_doc_token"].(string); ok {
if noteID, _ := noteResult["note_id"].(string); noteID != "" {
for k, v := range noteResult {
result[k] = v
}
@@ -369,11 +360,13 @@ 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_doc_token", "verbatim_doc_token", "minute_token", "meeting_notes", "shared_doc_tokens", "artifacts"} {
for _, k := range []string{"note_id", "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
}
@@ -519,84 +512,22 @@ func saveTranscriptToFile(runtime *common.RuntimeContext, minuteToken, title str
return transcriptPath
}
// 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
}
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)
// 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 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)}
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)}
}
if errors.Is(err, note.ErrEmptyDetail) {
return map[string]any{"error": note.ErrEmptyDetail.Error()}
}
return map[string]any{"error": fmt.Sprintf("failed to query note detail: %v", err)}
}
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
return detail.ToMap()
}
// VCNotes queries meeting notes via meeting-ids, minute-tokens, or calendar-event-ids.
@@ -775,6 +706,12 @@ 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

View File

@@ -23,6 +23,7 @@ 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"
)
// ---------------------------------------------------------------------------
@@ -119,6 +120,21 @@ 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",
@@ -178,68 +194,9 @@ func TestSanitizeDirName(t *testing.T) {
}
}
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)
}
}
// Note-detail parsing helpers (parseArtifactType/extractArtifactTokens/
// extractDocTokens) moved to the note domain; their tests live in
// shortcuts/note/note_test.go.
// ---------------------------------------------------------------------------
// Integration tests: +notes with mocked HTTP
@@ -362,25 +319,6 @@ 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
// ---------------------------------------------------------------------------
@@ -595,6 +533,33 @@ 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)
@@ -648,6 +613,26 @@ 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)
// ---------------------------------------------------------------------------
@@ -756,7 +741,9 @@ 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": ""}, 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},
{"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},
@@ -1266,7 +1253,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", noteNoPermissionCode, "no permission"))
reg.Register(noteDetailErrStub("note_perm2", note.NoNoteReadPermissionCode, "no permission"))
reg.Register(recordingOKStub("m_noteperm2", "https://meetings.feishu.cn/minutes/obcpermtest"))
// note fails but minute_token succeeds → partial success (hasNotesPayload=true)
@@ -1286,6 +1273,29 @@ 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.

View File

@@ -56,6 +56,7 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
| `<bitable token="..." table-id="...">` | `token` -> app_token, `table-id` | [`lark-base`](../lark-base/SKILL.md) |
| `<cite type="doc" file-type="sheets" token="..." sheet-id="...">` | 同 `<sheet>` | [`lark-sheets`](../lark-sheets/SKILL.md) |
| `<cite type="doc" file-type="bitable" token="..." table-id="...">` | 同 `<bitable>` | [`lark-base`](../lark-base/SKILL.md) |
| `<vc-transcribe-tab vc-node-id="...">` | `vc-node-id` -> note_id | [`lark-note`](../lark-note/SKILL.md):先 `note +detail --note-id <vc-node-id>` |
| `<synced_reference src-token="..." src-block-id="...">` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch --api-version v2` 读取 src-token 文档,定位 block |
## Shortcuts推荐优先使用

View File

@@ -97,7 +97,7 @@ metadata:
1. **确认身份** — 首次操作邮箱前先调用 `lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"me"}'` 获取当前用户的真实邮箱地址(`primary_email_address`),不要通过系统用户名猜测。后续判断"发件人是否为用户本人"时以此地址为准。
2. **浏览**`+triage` 查看收件箱摘要,获取 `message_id` / `thread_id`
3. **阅读**`+message` 读单封邮件`+thread` 读整个会话
3. **阅读**`+message` 读单封邮件;已有多个 `message_id` 时用 `+messages` 批量读取,不要循环调用 `+message``+thread` 读整个会话
4. **回复**`+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
5. **转发**`+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
6. **新邮件**`+send` 存草稿(默认),加 `--confirm-send` 发送
@@ -347,7 +347,7 @@ lark-cli mail +reply --message-id <id> --body '收到,谢谢'
### 读取邮件:按需控制返回内容
`+message`、`+messages`、`+thread` 默认返回 HTML 正文(`--html=true`)。仅需确认操作结果(如验证标记已读、移动文件夹是否成功)时,用 `--html=false` 跳过 HTML 正文,只返回纯文本,显著减少 token 消耗。
`+message`、`+messages`、`+thread` 默认返回 HTML 正文(`--html=true`)。`+message` 只适合单个 `message_id`;多个已知 `message_id` 请一次性传给 `+messages --message-ids <id1>,<id2>,<id3>`。仅需确认操作结果(如验证标记已读、移动文件夹是否成功)时,用 `--html=false` 跳过 HTML 正文,只返回纯文本,显著减少 token 消耗。
输出默认为结构化 JSON可直接读取无需额外编码转换。
@@ -357,6 +357,9 @@ lark-cli mail +message --message-id <id> --html=false
# ✅ 需要阅读完整内容:保持默认
lark-cli mail +message --message-id <id>
# ✅ 已有多个 message_id批量读取避免循环调用 +message
lark-cli mail +messages --message-ids <id1>,<id2>,<id3> --html=false
```
### 邮件模板(`+template-create` / `+template-update` / `--template-id`
@@ -466,8 +469,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli mail +<verb> [flags]`
| Shortcut | 说明 |
|----------|------|
| [`+message`](references/lark-mail-message.md) | Use when reading full content for a single email by message ID. Returns normalized body content plus attachments metadata, including inline images. |
| [`+messages`](references/lark-mail-messages.md) | 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. |
| [`+message`](references/lark-mail-message.md) | 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`. |
| [`+messages`](references/lark-mail-messages.md) | Use when reading full content for multiple emails by message ID. Accepts comma-separated message IDs; CLI handles more than 20 IDs in batches and merges output. |
| [`+thread`](references/lark-mail-thread.md) | Use when querying a full mail conversation/thread by thread ID. Returns all messages in chronological order, including replies and drafts, with body content and attachments metadata, including inline images. |
| [`+triage`](references/lark-mail-triage.md) | List mail summaries (date/from/subject/message_id). Use --query for full-text search, --filter for exact-match conditions. |
| [`+watch`](references/lark-mail-watch.md) | Watch for incoming mail events via WebSocket (requires scope mail:event and bot event mail.user_mailbox.event.message_received_v1 added). Run with --print-output-schema to see per-format field reference before parsing output. |
@@ -657,4 +660,3 @@ lark-cli mail <resource> <method> [flags] # 调用 API
| `user_mailbox.threads.list` | `mail:user_mailbox.message:readonly` |
| `user_mailbox.threads.modify` | `mail:user_mailbox.message:modify` |
| `user_mailbox.threads.trash` | `mail:user_mailbox.message:modify` |

View File

@@ -4,6 +4,8 @@
读取指定邮件的完整内容,包括邮件头、正文(纯文本 + 可选 HTML以及统一的 `attachments` 列表(涵盖普通附件和内嵌图片)。
`mail +message` 只适合读取一封邮件、一个 `message_id`。如果手上已有多个 `message_id`,请使用 `mail +messages --message-ids <id1>,<id2>,<id3>`;不要循环调用 `mail +message`
CLI 分两阶段构建最终 JSON
- 安全的邮件元数据字段直接透传
- 正文、附件和辅助字段由 shortcut 派生
@@ -34,7 +36,7 @@ lark-cli mail +message --message-id <message-id> --dry-run
| 参数 | 必填 | 默认值 | 说明 |
|------|------|--------|------|
| `--message-id <id>` | 是 | — | 邮件 ID |
| `--message-id <id>` | 是 | — | 单个邮件 ID;多个 ID 使用 `mail +messages --message-ids` |
| `--mailbox <email>` | 否 | 当前用户 | 邮箱地址(`user_mailbox_id` |
| `--html` | 否 | true | 是否返回 HTML 正文(`false` 仅返回纯文本,减少带宽) |
| `--format <mode>` | 否 | json | 输出格式:`json`(默认)/ `pretty` / `table` / `ndjson` / `csv` |
@@ -155,6 +157,7 @@ lark-cli mail +message --message-id <message-id> --dry-run
## 注意事项
- **JSON 输出可直接使用** — 默认输出合法 UTF-8 JSON可直接读取无需额外编码转换。
- **单封读取专用** — `mail +message` 只接收一个 `message_id`。多个 ID 使用 `mail +messages --message-ids <id1>,<id2>,<id3>`,避免逐封循环调用。
- JSON 输出中 `body_html` 里的 `<` / `>` 可能显示为 `\u003c` / `\u003e`JSON 安全转义,内容不变,`jq -r` 可还原)。
- `mail +message` 默认不再获取附件/图片下载 URL。这样可以保持邮件详情读取更轻量调用方可按需单独请求 URL。
- 查看原始 HTML

View File

@@ -4,16 +4,16 @@
通过传入逗号分隔的 `message_id` 列表,一次性读取多封邮件的完整内容。
超过 20 个 ID 可以直接传入 CLICLI 会按 20 个 ID 自动拆批并合并输出,不需要手动拆批,也不要逐封循环调用 `+message`
本 shortcut 是 `mail +message` 的批量版本。每个返回的 `messages[]` 项使用与 `+message` 相同的归一化结构:安全元数据字段直接透传,正文和辅助字段由 shortcut 派生。
优先使用本 shortcut 而非原生 `mail user_mailbox.messages batch_get` API,因为:
优先使用本 shortcut因为
- 正文字段已 base64url 解码
- 每条邮件的输出结构已归一化
- 不可用的 message ID 会被显式列出
本 skill 对应 shortcut `lark-cli mail +messages`,内部步骤:
1. `POST /open-apis/mail/v1/user_mailboxes/{mailbox}/messages/batch_get` — 批量获取邮件
2. 对每条返回的邮件使用与 `+message` 相同的规则归一化输出
本 skill 对应 shortcut `lark-cli mail +messages`;每条返回的邮件使用与 `+message` 相同的规则归一化输出。
## 命令
@@ -38,7 +38,7 @@ lark-cli mail +messages --message-ids <id1>,<id2> --dry-run
| 参数 | 必填 | 默认值 | 说明 |
|------|------|--------|------|
| `--message-ids <id1,id2,...>` | 是 | — | 逗号分隔的邮件 ID 列表 |
| `--message-ids <id1>,<id2>,<id3>` | 是 | — | 逗号分隔的邮件 ID 列表;超过 20 个 ID 时 CLI 自动按 20 拆批并合并输出 |
| `--mailbox <email>` | 否 | 当前用户 | 邮箱地址(`user_mailbox_id` |
| `--html` | 否 | true | 是否返回 HTML 正文(`false` 仅返回纯文本,减少带宽) |
| `--format <mode>` | 否 | json | 输出格式:`json`(默认)/ `pretty` / `table` / `ndjson` / `csv` |
@@ -74,7 +74,7 @@ lark-cli mail +messages --message-ids <id1>,<id2> --dry-run
- **JSON 输出可直接使用**,可直接读取,无需额外编码转换。
- 只需读取一封邮件时请使用 `+message`
- `--message-ids` 无硬性上限shortcut 内部会自动将大列表拆分为多次批量 API 调用
- CLI 每 20 个 ID 拆成一次调用并合并输出,不需要为大列表手动拆请求
- JSON 输出中 `messages[].body_html` 里的 `<` / `>` 可能显示为 `\u003c` / `\u003e`JSON 安全转义,内容不变,`jq -r` 可还原)。
- `mail +messages` 仅返回附件元数据。如后续步骤需要下载 URL请针对特定的 `message_id``attachment_ids` 调用原生附件 URL API。
-`+message` 一样,普通附件和内嵌图片都出现在 `messages[].attachments[]` 中,使用同一个 `user_mailbox.message.attachments download_url` API。

View File

@@ -112,13 +112,13 @@ lark-cli mail +triage --page-size 10
```text
15 message(s)
next page: mail +triage --query '合同审批' --page-token 'search:abc123...'
tip: use mail +message --message-id <id> to read full content
tip: read full content: single message use mail +message --message-id <id>; multiple messages use mail +messages --message-ids <id1>,<id2>,<id3>
```
公共邮箱场景下,`--mailbox` 会自动出现在续页和 tip 中:
```text
next page: mail +triage --mailbox 'shared@example.com' --query '合同审批' --page-token 'search:abc123...'
tip: use mail +message --mailbox 'shared@example.com' --message-id <id> to read full content
tip: read full content: single message use mail +message --mailbox 'shared@example.com' --message-id <id>; multiple messages use mail +messages --mailbox 'shared@example.com' --message-ids <id1>,<id2>,<id3>
```
### 搜索分页注意事项

View File

@@ -1,7 +1,7 @@
---
name: lark-minutes
version: 1.0.0
description: "飞书妙记:搜索妙记列表、查看妙记基础信息、下载妙记音视频文件、上传音视频生成妙记、更新妙记标题、替换说话人。当需要获取、操作或者生成妙记时使用。也支持将本地音视频文件转成纪要和逐字稿(优先使用本 skill不要用 ffmpeg/whisper 本地转写)。不负责:获取会议关联妙记、纪要/逐字稿内容获取走 lark-vc"
description: "飞书妙记:搜索妙记列表、查看妙记基础信息、下载妙记音视频文件、上传音视频生成妙记、更新妙记标题、替换说话人。当需要获取、操作或者生成妙记时使用。也支持将本地音视频文件转成纪要和逐字稿(优先使用本 skill不要用 ffmpeg/whisper 本地转写)。不负责:获取会议关联妙记,或仅按自然语言标题定位纪要"
metadata:
requires:
bins: ["lark-cli"]
@@ -45,6 +45,7 @@ metadata:
| "重命名妙记/改妙记标题" | 本 skill`+update` |
| "替换说话人/把 A 的发言改成 B" | 本 skill`+speaker-replace` |
| "这个妙记的逐字稿/总结/待办/章节" | [lark-vc](../lark-vc/SKILL.md)`vc +notes --minute-tokens` |
| "xx 纪要的逐字稿/原始记录/谁说了什么" 且没有 `minute_token` / 妙记 URL / 本地音视频文件 | 不走本 skill路由到 [lark-drive](../lark-drive/SKILL.md) / [lark-doc](../lark-doc/SKILL.md),必要时再到 [lark-note](../lark-note/SKILL.md) |
| "把音视频文件转成纪要/逐字稿/文字稿" | 先本 skill`+upload`),再 [lark-vc](../lark-vc/SKILL.md)`vc +notes --minute-tokens` |
| 用户同时提到"会议/开会"和"妙记" | 先 [lark-vc](../lark-vc/SKILL.md)`+search``+recording`),再本 skill |
@@ -179,6 +180,10 @@ Minutes (妙记) ← minute_token 标识
> - 用户说"重命名妙记 / 改妙记标题 / 修改妙记名字" → `minutes +update`
> - 用户说"替换说话人 / 把 A 的发言改成 B / 重新归属发言人" → `minutes +speaker-replace`
> - 用户说"批量替换逐字稿关键词" → `minutes +word-replace`
>
> **Note 域边界(禁止规则)**`minute_token` 是妙记文件标识,**不是** `note_id`。
> - 不要把 `minute_token` 传给 `note +detail` 或 `note +transcript`。
> - 已有 `minute_token` 且要读取纪要产物时,先走 [lark-vc](../lark-vc/SKILL.md);只有自然语言纪要标题时不要从 Minutes 反查。
## Shortcuts推荐优先使用
@@ -218,6 +223,7 @@ lark-cli minutes <resource> <method> [flags]
## 不在本 skill 范围
- 纪要/逐字稿/总结/待办/章节内容获取 → [lark-vc](../lark-vc/SKILL.md)`vc +notes --minute-tokens`
- 已有 `minute_token`纪要/逐字稿/总结/待办/章节内容获取 → [lark-vc](../lark-vc/SKILL.md)`vc +notes --minute-tokens`
- 只有自然语言纪要标题的逐字稿查询 → 文档搜索 / Docx 正文读取;有显式 `vc-node-id` 才进入 [lark-note](../lark-note/SKILL.md)
- 搜索历史会议记录 → [lark-vc](../lark-vc/SKILL.md)
- 查询未来的会议日程 → [lark-calendar](../lark-calendar/SKILL.md)

57
skills/lark-note/SKILL.md Normal file
View File

@@ -0,0 +1,57 @@
---
name: lark-note
version: 1.0.0
description: "飞书会议纪要Note直查已知 note_id 时查询纪要详情、展示类型、关联文档 token并读取 unified 原始逐字记录。当用户已持有 note_id或从文档显式 vc-node-id 获得 note_id 时使用。不负责会议/日程/妙记定位、文档标题搜索或 Docx 正文读取。"
metadata:
requires:
bins: ["lark-cli"]
cliHelp: "lark-cli note --help"
---
# note (v1)
身份:仅使用 `--as user`。使用前阅读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)。
Note 域只接受显式 `note_id`:用户直接提供,或 `docs +fetch --api-version v2` 返回的 `<vc-transcribe-tab vc-node-id="...">` 中的 `vc-node-id`。不要从 `doc_token`、标题、正文或 backlink 反推 `note_id`
## 命令路由
| 用户表达 / 上下文 | 路由 |
|---------|------|
| 已知 `note_id`,查纪要类型 / 文档 token | `note +detail --note-id NOTE_ID` |
| `docs +fetch --api-version v2` 返回 `<vc-transcribe-tab vc-node-id="...">` | 取 `vc-node-id` 作为 `NOTE_ID`,先 `note +detail --note-id NOTE_ID` |
| 已知 `note_id`,读纪要正文 | `note +detail``docs +fetch --api-version v2 --doc <note_doc_token>` |
| 已知 `note_id`,查 unified 原始记录 / 逐字稿 | `note +transcript --note-id NOTE_ID` |
| 只有自然语言纪要标题,用户要逐字稿 / 原始记录 / 谁说了什么 | 不进本 skill先走文档搜索与 `docs +fetch`,拿到 `vc-node-id` 后再回来 |
## `note_display_type` 路由
| `note +detail` 结果 | 用户要逐字稿 / 原始记录时 |
|------|---------------|
| `normal` + `verbatim_doc_token` 非空 | `docs +fetch --api-version v2 --doc <verbatim_doc_token>` |
| `unknown` + `verbatim_doc_token` 非空 | 先按独立文档处理;不要猜成 unified |
| `unknown` + 无逐字稿 token | 停止重试并说明无法确定逐字稿入口 |
| `unified` | `note +transcript --note-id <note_id>` |
判别键是 `note_display_type`,不是 `verbatim_doc_token` 是否为空unified 纪要也可能返回非空 `verbatim_doc_token`
## 关键字段
- `note_id`Note 域唯一入口。
- `note_display_type``unknown` / `normal` / `unified`
- `note_doc_token`:纪要正文文档,正文读取交给 [lark-doc](../lark-doc/SKILL.md)。
- `verbatim_doc_token`普通纪要逐字稿文档unified 逐字稿不按这个 token 路由。
## 不在本 Skill 范围
- 通过 `meeting_id` / `calendar_event_id` / `minute_token` 定位纪要 → [lark-vc](../lark-vc/SKILL.md)。
- 自然语言纪要标题搜索 → [lark-drive](../lark-drive/SKILL.md) / [lark-doc](../lark-doc/SKILL.md)。
- Docx 正文读取 → [lark-doc](../lark-doc/SKILL.md)。
- 妙记基础信息与媒体文件 → [lark-minutes](../lark-minutes/SKILL.md)。
## Shortcuts
| Shortcut | 何时读 reference |
|----------|------|
| [`+detail`](references/lark-note-detail.md) | 需要解释输出字段或根据展示类型继续路由 |
| [`+transcript`](references/lark-note-transcript.md) | 需要拉取 unified 原始记录或处理本地输出文件 |

View File

@@ -0,0 +1,24 @@
# note +detail
`note +detail` 只做一件事:按显式 `note_id` 返回纪要展示类型和相关文档 token。
```bash
lark-cli note +detail --note-id NOTE_ID --format json
```
## `note_id` 来源
- 可以来自用户直接给出的 `note_id`
- 如果入口是文档,先由 [lark-doc](../../lark-doc/SKILL.md) 读取 Docx只有 `<vc-transcribe-tab vc-node-id="...">``vc-node-id` 可以作为 `note_id`
- 没有 `vc-node-id` 时,不要从 `doc_token`、标题、正文或 backlink 反推 `note_id`
## 输出后的路由
| detail 字段 | 后续动作 |
|---------|---------|
| `note_doc_token` | 读纪要正文 / 总结 / 待办 / 章节:`docs +fetch --api-version v2 --doc <note_doc_token>` |
| `note_display_type=normal` + `verbatim_doc_token` | 读逐字稿:`docs +fetch --api-version v2 --doc <verbatim_doc_token>` |
| `note_display_type=unknown` + `verbatim_doc_token` | 先按普通独立逐字稿文档读取;不要猜成 unified |
| `note_display_type=unified` | 读逐字稿 / 原始记录:转 [`note +transcript`](lark-note-transcript.md) |
判别键是 `note_display_type`。即使 unified 纪要返回了非空 `verbatim_doc_token`,逐字稿仍按 unified 路由。

View File

@@ -0,0 +1,23 @@
# note +transcript
只在 `note +detail``vc +notes` 已确认 `note_display_type=unified` 时使用。普通纪要逐字稿是独立 Docx 文档,应回到 [lark-doc](../../lark-doc/SKILL.md) 读取 `verbatim_doc_token`
```bash
lark-cli note +transcript --note-id NOTE_ID
```
## 行为契约
- CLI 会先校验该 Note 是否为 `unified`;不是 unified 时不拉取 transcript。
- CLI 内部自动翻页并拼接完整内容;任一页失败时整体报错,不保存半截 transcript。
- 默认保存到 `./notes/{note_id}/unified_transcript.md``--transcript-format plain_text` 时保存为 `.txt`
- 目标文件已存在时会失败;用户明确要覆盖时才加 `--overwrite`
## 何时不要用
| 场景 | 正确路由 |
|------|---------|
| 只有纪要文档标题 | 先文档搜索,再 `docs +fetch --api-version v2`;有 `vc-node-id` 才回 Note 域 |
| 只有 Docx URL / `doc_token` | 先 `docs +fetch --api-version v2`;不要从 `doc_token` 反推 `note_id` |
| `note_display_type=normal` | `docs +fetch --api-version v2 --doc <verbatim_doc_token>` |
| `note_display_type=unknown``verbatim_doc_token` 非空 | 先按独立逐字稿文档读取 |

View File

@@ -1,7 +1,7 @@
---
name: lark-okr
version: 1.0.0
description: "飞书 OKR管理目标与关键结果。查看和编辑 OKR 周期、目标Objective、关键结果Key Result、对齐关系、量化指标和进展记录。当用户需要查看或创建 OKR、管理目标和关键结果、查看对齐关系时使用。"
description: "飞书 OKR管理目标与关键结果。查看和编辑 OKR 周期、目标、关键结果、对齐关系、量化指标和进展记录。当用户需要查看或创建 OKR、管理目标和关键结果、查看对齐关系时使用。不负责待办任务管理lark-task、日程/会议安排lark-calendar、绩效评估"
metadata:
requires:
bins: [ "lark-cli" ]
@@ -12,6 +12,8 @@ metadata:
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
**身份**OKR 操作默认使用 `--as user`(查看当前用户/上下级的 OKR 时)。也支持 `--as bot` 查看他人 OKR需相应权限
## Shortcuts推荐优先使用
Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`)。有 Shortcut 的操作优先使用。
@@ -20,7 +22,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`
|--------------------------------------------------------------|--------------------------|
| [`+cycle-list`](references/lark-okr-cycle-list.md) | 获取特定用户的 OKR 周期列表,可以按时间筛选 |
| [`+cycle-detail`](references/lark-okr-cycle-detail.md) | 获取特定 OKR 中所有目标和关键结果的内容 |
| [`+progress-list`](references/lark-okr-progress-list.md) | 获取目标或关键结果的所有进展记录列表 |
| [`+progress-list`](references/lark-okr-progress-list.md) | 获取目标或关键结果的所有进展记录列表 |
| [`+progress-get`](references/lark-okr-progress-get.md) | 根据 ID 获取单条 OKR 进展记录 |
| [`+progress-create`](references/lark-okr-progress-create.md) | 为目标或关键结果创建进展记录 |
| [`+progress-update`](references/lark-okr-progress-update.md) | 更新指定 ID 的进展记录内容 |
@@ -35,13 +37,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`
## API Resources
```bash
lark-cli schema okr.<resource>.<method> # 调用 API 前必须先查看参数结构
lark-cli okr <resource> <method> [flags] # 调用 API
```
> **重要**:使用原生 API 时,**必须**先运行 `schema` 查看 `--data` / `--params` 参数结构,**不要**猜测字段格式!
### alignments
- `delete` — 删除对齐关系
@@ -55,9 +50,13 @@ lark-cli okr <resource> <method> [flags] # 调用 API
- `list` — 批量获取用户周期
- `objectives_position` — 更新用户周期下全部目标的位置
- 请求中必须同时修改对应周期下全部目标的位置,且不允许位置重叠,否则会参数校验失败
- 请求中必须携带对应周期下全部目标的 ID否则会参数校验失败。以传入的目标ID顺序重新排列目标
- `objectives_weight` — 更新用户周期下全部目标的权重
- 请求中必须同时修改对应周期下全部目标的权重,且所有权重值的和必须等于 1 ,否则会参数校验失败。
- 请求中必须同时修改对应周期下全部目标的权重,且所有权重值的和必须等于 1 ,否则会参数校验失败。例如周期下有 2 个目标时:
- 正确指令示例如下:
``` bash
lark-cli okr cycles objectives_weight --params '{"cycle_id": "7000000000000000001"}' --data '{"objective_weights": [{"objective_id": "7000000000000000002", "weight": 0.7}, {"objective_id": "7000000000000000003", "weight": 0.3}]}' --as user
```
### cycle.objectives
@@ -83,9 +82,9 @@ lark-cli okr <resource> <method> [flags] # 调用 API
- `delete` — 删除目标
- `get` — 获取目标
- `key_results_position` — 更新全部关键结果的位置
- 请求中必须同时修改对应目标下全部关键结果的位置,且不允许位置重叠,否则会参数校验失败。
- 请求中必须携带对应周期下全部关键结果的 ID否则会参数校验失败。以传入的关键结果ID顺序重新排列关键结果。
- `key_results_weight` — 更新全部关键结果的权重
- 请求中必须同时修改对应目标下全部关键结果的权重,且所有权重值的和必须等于 1 ,否则会参数校验失败。
- 类似 `objectives_weight`, 请求中必须同时修改对应目标下全部关键结果的权重,且所有权重值的和必须等于 1 ,否则会参数校验失败。
- `patch` — 更新目标
### objective.alignments
@@ -103,31 +102,10 @@ lark-cli okr <resource> <method> [flags] # 调用 API
- `create` — 创建关键结果
- `list` — 批量获取目标下的关键结果
## 权限表
## 不在本 skill 范围
- 待办任务管理 → 使用 [`lark-task`](../lark-task/SKILL.md)
- 日程安排 → 使用 [`lark-calendar`](../lark-calendar/SKILL.md)
- 绩效评估 → 使用 [`lark-openapi-explorer`](../lark-openapi-explorer/SKILL.md) 查找原生接口
| 方法 | 所需 scope |
|-----------------------------------|-----------------------------|
| `alignments.delete` | `okr:okr.content:writeonly` |
| `alignments.get` | `okr:okr.content:readonly` |
| `categories.list` | `okr:okr.setting:read` |
| `cycles.list` | `okr:okr.period:readonly` |
| `cycles.objectives_position` | `okr:okr.content:writeonly` |
| `cycles.objectives_weight` | `okr:okr.content:writeonly` |
| `cycle.objectives.create` | `okr:okr.content:writeonly` |
| `cycle.objectives.list` | `okr:okr.content:readonly` |
| `indicators.patch` | `okr:okr.content:writeonly` |
| `key_results.delete` | `okr:okr.content:writeonly` |
| `key_results.get` | `okr:okr.content:readonly` |
| `key_results.patch` | `okr:okr.content:writeonly` |
| `key_result.indicators.list` | `okr:okr.content:readonly` |
| `objectives.delete` | `okr:okr.content:writeonly` |
| `objectives.get` | `okr:okr.content:readonly` |
| `objectives.key_results_position` | `okr:okr.content:writeonly` |
| `objectives.key_results_weight` | `okr:okr.content:writeonly` |
| `objectives.patch` | `okr:okr.content:writeonly` |
| `objective.alignments.create` | `okr:okr.content:writeonly` |
| `objective.alignments.list` | `okr:okr.content:readonly` |
| `objective.indicators.list` | `okr:okr.content:readonly` |
| `objective.key_results.create` | `okr:okr.content:writeonly` |
| `objective.key_results.list` | `okr:okr.content:readonly` |

View File

@@ -1,168 +1,31 @@
---
name: lark-shared
version: 1.0.0
description: "Use when first setting up lark-cli, running auth login, switching user/bot identity (--as), handling permission denied or scope errors, needing to update lark-cli, or seeing _notice in JSON output."
version: 1.1.0
description: "lark-cli 通用规则user/bot 身份、认证授权、安全与高风险确认门禁。当首次配置 lark-cli、需要 auth login、遇到权限或 scope 错误、命令以退出码 10 要求确认、或输出包含 _notice 升级提示时使用。"
---
# lark-cli 共享规则
本技能指导你如何通过lark-cli操作飞书资源, 以及有哪些注意事项
所有 lark-* skill 共享的底座lark-cli 的身份、认证、安全与高风险操作通用规则
## 配置初始化
## 通用准则
首次使用需运行 `lark-cli config init` 完成应用配置
1. **调用前先懂用法**:执行 shortcut 前先读对应 reference 或跑 `-h` 弄懂用法,别猜 flag 盲调
当你帮用户初始化配置时使用background方式使用下面的命令发起配置应用流程启动后读取输出从中提取授权链接并发给用户
2. **身份决定你代表谁操作**`--as user` 代表用户本人(能看到、也能操作其日历 / 云空间 / 邮箱等个人资源),`--as bot` 代表应用自己(只涉及 bot 的资源,发消息、建文档都归 bot。用 `--as bot` 碰用户资源**可能静默返空**而非报错,别误判成"没有数据"。身份模型与权限恢复 → [`references/lark-shared-identity-and-permissions.md`](references/lark-shared-identity-and-permissions.md)
**URL 转发规则**:当命令输出 `verification_url``verification_uri_complete``console_url` 等 URL 字段时:**必须生成二维码**:你必须调用 `lark-cli auth qrcode` 将 URL 转为二维码并展示给用户,这是必须步骤,不要跳过。优先生成 PNG 二维码(--output仅当用户明确要求时才使用 ASCII--ascii。**URL 输出规则**:将 URL 视为不可修改的 opaque string不要做任何修改包括 URL 编码/解码、添加空格或标点、重新拼接 query二维码和链接请一起展示给用户
3. **代表用户发起 `auth login` 授权时绝不阻塞**:走 split-flow发起后交还控制权、下一轮再完成别在同一轮阻塞等授权。完整步骤 **执行前必读** → [`references/lark-shared-auth-split-flow.md`](references/lark-shared-auth-split-flow.md)
```bash
# 发起配置(该命令会阻塞直到用户打开链接并完成操作或过期)
lark-cli config init --new
```
4. **授权 / 配置类 URL 必须配二维码**:用 `lark-cli auth qrcode` 生成、URL 在前二维码在后URL 原样不改写。
## 认证
5. **退出码 10 是高风险确认门禁,不是错误**:停下、取得用户**显式同意**后才按 `hint` 重试,**绝不**静默加确认 flag 绕过。机制 → [`references/lark-shared-high-risk-approval.md`](references/lark-shared-high-risk-approval.md)。
### 身份类型
6. **路径参数只接受 cwd 相对路径**:绝对路径会被拒(`unsafe file path`),规划时就用相对路径。
两种身份类型,通过 `--as` 切换:
7. **不输出密钥明文**appSecret、accessToken
| 身份 | 标识 | 获取方式 | 适用场景 |
|------|------|---------|---------|
| user 用户身份 | `--as user` | `lark-cli auth login` 等 | 访问用户自己的资源(日历、云空间/云盘/云存储等) |
| bot 应用身份 | `--as bot` | 自动,只需 appId + appSecret | 应用级操作,访问bot自己的资源 |
## 其他场景
### 身份选择原则
输出 `[identity: bot/user]` 代表当前身份。bot 与 user 表现差异很大,需确认身份符合目标需求:
- **Bot 看不到用户资源**:无法访问用户的日历、云空间(云盘/云存储)文档、邮箱等个人资源。例如 `--as bot` 查日程返回 bot 自己的(空)日历
- **Bot 无法代表用户操作**:发消息以应用名义发送,创建文档归属 bot
- **Bot 权限**:只需在飞书开发者后台开通 scope无需 `auth login`
- **User 权限**:后台开通 scope + 用户通过 `auth login` 授权,两层都要满足
### 权限不足处理
遇到权限相关错误时,**根据当前身份类型采取不同解决方案**。
错误响应中包含关键信息:
- `permission_violations`:列出缺失的 scope (N选1)
- `console_url`:飞书开发者后台的权限配置链接
- `hint`:建议的修复命令
#### Bot 身份(`--as bot`
将错误中的 `console_url` 原样提供给用户,引导去后台开通 scope。**禁止**对 bot 执行 `auth login`
#### User 身份(`--as user`
```bash
lark-cli auth login --domain <domain> # 按业务域授权
lark-cli auth login --scope "<missing_scope>" # 按具体 scope 授权(推荐,符合最小权限原则)
```
**规则**auth login 必须指定范围(`--domain``--scope`)。多次 login 的 scope 会累积(增量授权)。
#### Agent 代理发起认证(推荐)
当你作为 AI agent 需要帮用户完成认证时,优先使用 split-flow避免在同一轮对话中阻塞等待用户授权
```bash
# 发起授权(立即返回 device_code 和 verification_url
lark-cli auth login --scope "calendar:calendar:readonly" --no-wait --json
```
拿到 `verification_url` 后,将它原样作为本轮最终消息发给用户,并结束本轮/交还控制权。不要在同一轮中展示 URL 后立刻执行 `--device-code` 阻塞轮询;在不透传中间输出的 agent harness 里,这会导致用户永远看不到 URL。
用户回复已完成授权后,再在后续步骤执行:
```bash
lark-cli auth login --device-code <device_code>
```
**Split-Flow 完整步骤**
**第一步:发起授权(当前轮)**
1. 执行 `lark-cli auth login --scope "xxx" --no-wait --json`(必须加 `--no-wait --json`
2. 从 JSON 输出中提取 `verification_url``device_code`
3. 生成二维码:`lark-cli auth qrcode <verification_url> --output "xxx"`
4. 将 URL 和二维码展示给用户(先 URL后二维码
5. **结束本轮对话前,必须明确告知用户**"请完成授权后,回来告诉我已授权完成,我会帮你完成后续步骤"
**第二步:完成授权(后续轮)**
1. 等待用户回复"已完成授权"
2. **由你AI agent亲自执行**`lark-cli auth login --device-code <device_code>`
3. 此命令会轮询授权状态并完成登录
4. 如果返回授权成功,流程结束
**关键规则**
- **你必须亲自执行 `--device-code` 命令**,不要指示用户自行执行
- **不要在同一轮中展示 URL 后立刻执行 `--device-code`**,这会导致用户看不到 URL
- **禁止缓存 `verification_url``device_code`**:每次需要授权时,必须重新执行 `lark-cli auth login --no-wait --json` 生成新的链接。不要将授权链接和 device code 存入上下文供后续复用
## 更新检查
lark-cli 命令执行后如果检测到新版本JSON 输出中会包含 `_notice.update` 字段(含 `message``command` 等)。
**当你在输出中看到 `_notice.update` 时,完成用户当前请求后,主动提议帮用户更新**
1. 告知用户当前版本和最新版本号
2. 提议执行更新(同时更新 CLI 和 Skills
```bash
lark-cli update
```
3. 更新完成后提醒用户:**退出并重新打开 AI Agent** 以加载最新 Skills
**重要**:始终使用 `lark-cli update` 更新,它会同时更新 CLI 和 AI Skills。
**规则**:不要静默忽略更新提示。即使当前任务与更新无关,也应在完成用户请求后补充告知。
## 安全规则
- **禁止输出密钥**appSecret、accessToken到终端明文。
- **写入/删除操作前必须确认用户意图**。
- 用 `--dry-run` 预览危险请求。
- **文件路径只接受相对路径**`--file`、`--output`、`--output-dir`、`@file` 等路径参数只接受 cwd 下的相对路径,传绝对路径会报 `unsafe file path`。数据输入(`@file`、大 JSON优先用 stdin 传入,避免路径和转义问题。
## 高风险操作的审批协议exit 10
lark-cli 对高风险写操作(`risk: "high-risk-write"`)有强制确认门禁。当你不带 `--yes` 调用这类命令时CLI 会退出码 `10`、并在 stderr 返回如下结构化 envelope
```json
{
"ok": false,
"error": {
"type": "confirmation_required",
"message": "drive +delete requires confirmation",
"hint": "add --yes to confirm",
"risk": {
"level": "high-risk-write",
"action": "drive +delete"
}
}
}
```
**遇到这种情况,不要当普通错误放弃。** 按以下流程处理:
1. **识别**:看到子进程 exit code = `10` 且 stderr JSON 里 `error.type == "confirmation_required"`
2. **向用户确认**:把 `error.risk.action` 和关键参数展示给用户,明确告知"这是高风险操作",等待用户显式同意
3. **用户同意** → 在你**原始 argv 的末尾追加 `--yes`** 后重试
4. **用户拒绝** → 终止流程,不要擅自改写参数或跳过门禁
**绝对不允许**
- 看到 exit 10 就默认加 `--yes` 静默重试(这等于禁用门禁)
- 把 `confirmation_required` 当网络错误/权限错误处理
- 在用户没明确同意的前提下追加 `--yes` 重试
- 用 `sh -c` 等 shell 方式拼接命令重试——用 `exec.Command(argv...)` 参数数组形式,避免 shell 解析把用户参数当作语法
提前预判:想先让用户 review 危险操作的具体请求,调用时加 `--dry-run`——它不触发门禁会打印完整请求详情URL / body / params你可以把这个预览给用户看过再去真正执行。
### 如何识别一条命令是高风险
- shortcut`lark-cli <service> +<cmd> --help` 顶部会显示 `Risk: high-risk-write`
- service 命令:`lark-cli schema <service>.<resource>.<method> --format json` 的返回值里 `"risk": "high-risk-write"`
- 首次配置 lark-cli`config init`)→ [`references/lark-shared-config-init.md`](references/lark-shared-config-init.md)
- 拿到 `/wiki/` 链接或 wiki token → [`references/lark-wiki-token-routing.md`](references/lark-wiki-token-routing.md)
- 输出 `_notice`(升级 / skills 落后 / 废弃命令提示)→ [`references/lark-shared-update-notice.md`](references/lark-shared-update-notice.md)

View File

@@ -0,0 +1,18 @@
# Agent 代理发起授权split-flow
帮用户完成 user 身份授权。背景:如果运行环境只把最终消息发给用户、不显示中间命令输出,阻塞式 `auth login` 会让用户永远看不到授权链接,所以把"发起"和"完成"拆到两轮。
## 第一步:发起(当前轮)
1. 执行 `lark-cli auth login --scope "<scope>" --no-wait --json`,从输出提取 `verification_url``device_code`
2.`verification_url` 按正文准则配二维码展示给用户生成二维码、URL 在前、原样不改写)。
3. 明确告知用户"完成授权后回来告诉我",然后交还控制权。**不要**在同一轮接着执行 `--device-code` 阻塞轮询——否则用户看不到链接。
## 第二步:完成(后续轮)
等用户回复已授权,**由你agent亲自执行** `lark-cli auth login --device-code <device_code>`(别让用户自己跑)。该命令轮询授权状态并完成登录,成功即结束。
## 规则
- **禁止缓存 `verification_url` / `device_code`**:每次授权都重新 `--no-wait` 发起拿新值,不要存旧值复用。
- **范围必须显式指定**`--scope`(推荐,最小权限)或 `--domain`;多次 login 的 scope 累积(增量授权)。`--exclude` 排除特定 scope`--recommend` 只请求可自动批准的 scope。

View File

@@ -0,0 +1,11 @@
# 首次配置 lark-cli
首次使用需运行 `lark-cli config init --new` 完成应用配置。
**注意:`config init` 是阻塞命令,没有 `--no-wait`,不要套用 `auth login` 的 split-flow。** 它会一直阻塞到用户在浏览器完成配置或过期。帮用户初始化时,用 background 方式执行命令,启动后读取输出,从中提取授权链接发给用户:
```bash
lark-cli config init --new
```
输出里的授权 URL 按正文准则处理生成二维码、URL 原样不改写)。

View File

@@ -0,0 +1,58 @@
# 确认门禁 envelope 参考exit 10
处理协议见 SKILL.md 正文准则。本文讲报错 JSON 的两种形态、字段位置,以及重试 / 预览的两个坑。
## 可靠信号是退出码 10不是 type 字符串
仓库正从扁平式迁往 typed 式,过渡期两种并存——扁平式仍是 shortcut / service 命令的当前形态多数高风险命令typed 式是已迁移命令(如 `config bind`)的新形态。**别认 `type` 字符串(迁移中会变),认退出码 10**
**扁平式:**
```json
{
"ok": false,
"error": {
"type": "confirmation_required",
"message": "drive +delete requires confirmation",
"hint": "add --yes to confirm",
"risk": { "level": "high-risk-write", "action": "drive +delete" }
}
}
```
**typed 式:**
```json
{
"ok": false,
"error": {
"type": "confirmation",
"subtype": "confirmation_required",
"risk": "high-risk-write",
"action": "config bind --force",
"hint": "若用户确认切换,附加 --force 重新运行:`lark-cli config bind --identity user-default --force`"
}
}
```
识别条件exit code = 10`error` 命中任一形态——`type == "confirmation_required"`(扁平),或 `type == "confirmation" && subtype == "confirmation_required"`typed。只判 `type == "confirmation_required"` 会漏掉 typed 式。
## 字段位置速查
| 信息 | 扁平式 | typed 式 |
|------|--------|----------|
| 操作名 | `error.risk.action` | `error.action` |
| 风险级别 | `error.risk.level``risk` 是对象) | `error.risk`(字符串) |
| 确认 flag | `error.hint` | `error.hint` |
取操作名typed 式看 `error.action`,没有再看扁平式的 `error.risk.action`(哪个有用哪个)。`hint` 是给你看的自然语言提示,里面写明了该加哪个确认 flag扁平式如 "add --yes to confirm" → `--yes``config bind` 的 hint 提示 `--force`)。**提取那个 flag 加到你自己的原始命令上**,别照抄 hint 里的完整示例命令——示例不含用户的原始参数,照抄会丢参数。
## 先预览再执行(可选,不触发门禁)
想让用户先 review 危险请求,调用时加 `--dry-run`它不触发确认门禁会打印完整请求URL / body / params可把预览给用户看过再真正执行。
## 如何预判一条命令是高风险
- shortcut`lark-cli <service> +<cmd> --help` 顶部显示 `Risk: high-risk-write`
- service 命令:`lark-cli schema <service> <resource> <method> --format json` 返回值里 `"risk": "high-risk-write"`schema 同时注入 `yes` 布尔字段标记需确认)。
- 注意:标注 `high-risk-write` ≠ 一定走 exit-10 门禁(如 `lark-cli update` 有 risk 标注但没有 `--yes` flag、不走该门禁。以**实际 exit 10 + envelope** 为准,不要臆造 `--yes`

View File

@@ -0,0 +1,27 @@
# 身份与权限
基本心智模型——`--as` 代表谁操作、`--as bot` 碰用户资源可能静默返空——见 SKILL.md 正文准则。本文补充:身份怎么获得、授权分几层、权限不足时怎么恢复。
## 获取方式与授权层级
- **user 身份**`--as user`):用户通过 `lark-cli auth login` 授权获得。要能访问,需**两层都满足**——后台开通对应 scope + 用户 auth login 授权。
- **bot 身份**`--as bot`):自动,只需 appId + appSecret只需后台开通 scope无需 auth login。
输出里的 `[identity: bot/user]` 是当前身份。
## bot 碰用户资源的失败形态
因命令而异:有的静默返回空结果(如查日程落到 bot 自己的空日历),有的明确报"未登录 / 越权"。**无论哪种,都别把 bot 的结果当成用户的真实数据。**
## 权限 / scope 不足恢复
错误响应中的关键字段:
- 缺失的 scope`permission_violations`(原始 API 错误块,元素形如 `{subject: "<scope>"}`)或 `missing_scopes`CLI 结构化错误,已抽好的 scope 字符串数组)。
- `console_url`:飞书开发者后台的权限配置链接。
- `hint`:建议的修复命令。
按身份分流:
- **Bot 身份**:把 `console_url` 提供给用户(按正文准则配二维码转发),引导去后台开通 scope。**禁止**对 bot 执行 `auth login`,也不要因为 user 报错就降级到 bot 重试。
- **User 身份**:补授权用 `lark-cli auth login --scope "<missing_scope>"`(推荐,最小权限)或 `--domain <domain>`;必须指定其一,多次 login 的 scope 会累积(增量授权)。作为 agent 代发起时走 split-flow见 [`lark-shared-auth-split-flow.md`](lark-shared-auth-split-flow.md)。

View File

@@ -0,0 +1,9 @@
# 升级提示_notice
命令执行后 JSON 输出可能包含 `_notice`,其下三种通知的处置都是升级:
- `update`CLI 有新版本(字段 `current` / `latest` / `message` / `command`)。
- `skills`:内置 AI Skills 落后于 CLI字段 `current` / `target`)。
- `deprecated_command`:本次用了已废弃的命令别名(`replacement` 为新命令名)。
看到任一通知都**不要静默忽略**,即使与当前任务无关:完成用户当前请求后告知情况,主动提议执行 `lark-cli update`(同时更新 CLI 和 AI Skills`--check` 可只检查不安装)。更新完成后提醒用户**退出并重新打开 AI Agent** 以加载最新 Skills。

View File

@@ -47,14 +47,15 @@ lark-cli vc +search --query "站会" --start-time ...
| 查"昨天的会议""上周的会""已结束的会议" | 本 skill`+search`,含即时会议) |
| 查日历/日程或未来时间的会议 | [lark-calendar](../lark-calendar/SKILL.md) |
| 查"今天有哪些会议" | `vc +search`(已结束)+ lark-calendar未开始合并展示 |
| 只按自然语言标题查"xx 纪要的逐字稿 / 原始记录 / 谁说了什么" | 先到 [lark-drive](../lark-drive/SKILL.md) / [lark-doc](../lark-doc/SKILL.md);仅在已拿到 `note_id` / `vc-node-id` 后再到 [lark-note](../lark-note/SKILL.md) |
| Agent 真实入会/离会、会中实时事件 | [lark-vc-agent](../lark-vc-agent/SKILL.md) |
| 本地音视频文件转纪要/逐字稿 | 先走 [lark-minutes](../lark-minutes/SKILL.md) 上传,再回 `vc +notes --minute-tokens` |
## 核心概念
- **视频会议Meeting**:飞书视频会议实例,通过 meeting_id 标识。已结束的会议支持通过关键词、时间段、参会人、组织者、会议室等条件搜索。
- **会议纪要Note**:视频会议结束后生成的结构化文档,包含纪要文档(总结+待办)和逐字稿文档。
- **妙记Minutes**:来源于飞书视频会议的录制产物或用户上传的音视频文件,包含总结、待办、章节和文字记录,通过 minute_token 标识。
- **视频会议Meeting**:飞书视频会议实例,通过 meeting_id 标识。已结束的会议支持通过关键词、时间段、参会人、组织者、会议室等条件搜索(见 `+search`
- **会议纪要Note**:视频会议结束后生成的结构化文档,通过 `note_id` 标识,包含纪要文档(总结待办)和逐字稿文档。`note_display_type` 区分**普通纪要(`normal`**和 **unified 纪要**;已知 `note_id` 的直查与 unified 原始记录请用 [lark-note](../lark-note/SKILL.md)。
- **妙记Minutes**:来源于飞书视频会议的录制产物或用户上传的音视频文件,支持视频/音频的转写,包含总结、待办、章节和文字记录,通过 minute_token 标识。
- **纪要文档MainDoc**AI 智能纪要的主文档,包含 AI 生成的总结和待办,对应 `note_doc_token`
- **用户会议纪要MeetingNotes**:用户主动绑定到会议的纪要文档,对应 `meeting_notes`。仅通过 `--calendar-event-ids` 路径返回。
- **逐字稿VerbatimDoc**:会议的逐句文字记录,包含说话人和时间戳。
@@ -63,13 +64,15 @@ lark-cli vc +search --query "站会" --start-time ...
| 用户意图 | 必须读取的产物 | 禁止 |
|---------|-------------|------|
| 提炼/总结/重新总结/整理会议内容/回顾会议 | 逐字稿(`verbatim_doc_token`或妙记文字记录Transcript基于原始对话独立分析 | 禁止直接搬运 AI 纪要(`note_doc_token`)的总结作为最终输出 |
| 提炼/总结/重新总结/整理会议内容/回顾会议 | 原始对话记录(按下方逐字稿路由取得或妙记文字记录Transcript基于原始对话独立分析 | 禁止直接搬运 AI 纪要(`note_doc_token`)的总结作为最终输出 |
| 查看待办/章节 | AI 纪要(`note_doc_token`)或妙记产物 — AI 待办更友好(含提出人和负责人),章节按话题划分更结构化 | — |
| 查看纪要链接/文档地址 | 仅返回文档链接,无需读取内容 | — |
| 直接看 AI 总结结果 | AI 纪要(`note_doc_token` | — |
| 谁说了什么/完整发言记录 | 逐字稿(`verbatim_doc_token` | — |
| 谁说了什么/完整发言记录 | 原始对话记录(按下方逐字稿路由取得 | — |
> **为什么"提炼/总结"必须从逐字稿出发?** AI 纪要是模型对会议的二次压缩,可能遗漏讨论细节、争论过程和隐含决策。用户要求"提炼"或"重新总结"时,期望的是基于原始对话的独立分析,而非对 AI 产物的重新排版
> **逐字稿路由**:先看 `vc +notes` 返回的 `note_display_type`,不要只看 `verbatim_doc_token` 是否为空。具体路由以 [`+notes`](references/lark-vc-notes.md) 和 [lark-note](../lark-note/SKILL.md) 为准
>
> **为什么"提炼/总结"必须从原始对话记录出发?** AI 纪要是模型对会议的二次压缩,可能遗漏讨论细节、争论过程和隐含决策。用户要求"提炼"或"重新总结"时,期望的是基于原始对话的独立分析,而非对 AI 产物的重新排版。
## 核心场景
@@ -77,6 +80,7 @@ lark-cli vc +search --query "站会" --start-time ...
1. 仅支持搜索已结束的会议,对于还未开始的未来会议,需要使用 lark-calendar 技能。
2. 仅支持使用关键词、时间段、参会人、组织者、会议室等筛选条件搜索会议记录,对于不支持的筛选条件,需要提示用户。
3. 搜索结果存在多条数据时,务必注意分页数据获取,不要遗漏任何会议记录。
4. 只有自然语言纪要标题、没有会议线索时,不要把标题当会议关键词;按上方意图路由切到文档搜索。
### 2. 整理会议纪要
@@ -99,7 +103,7 @@ lark-cli docs +media-download --type whiteboard --token <whiteboard_token> --out
> **纪要相关文档 — 根据用户意图选择:**
> - `note_doc_token` → **AI 智能纪要**AI 总结 + 待办)
> - `meeting_notes` → **用户绑定的会议纪要**(用户主动关联到会议的文档,仅 `--calendar-event-ids` 路径返回)
> - `verbatim_doc_token` → **逐字稿**(完整的逐句文字记录,含说话人和时间戳)— 用户说"逐字稿""完整记录""谁说了什么"时用这个
> - 用户说"逐字稿""完整记录""谁说了什么"时 → 按 `note_display_type` 路由,详见 [`+notes`](references/lark-vc-notes.md)
> - 用户说"纪要""总结""纪要内容"时,应同时返回 `note_doc_token` 和 `meeting_notes`(如有)
> - 用户意图不明确时,应展示所有文档链接让用户选择,而不是替用户决定
> - 如果用户提供的是**本地音视频文件**并说"转纪要""转逐字稿",不要直接从 `vc +notes` 开始;应先用 [minutes +upload](../lark-minutes/references/lark-minutes-upload.md) 生成 `minute_url`,再提取 `minute_token` 调用 `vc +notes --minute-tokens`
@@ -133,18 +137,19 @@ lark-cli vc meeting get --params '{"meeting_id":"<meeting_id>","with_participant
| 用户意图 | 推荐命令 | 所在 skill |
|---------|---------|--------|
| 参会人快照(谁参加过、何时入/离会,任意时点)| `vc meeting get --with-participants` | 本 skill |
| 已结束会议的发言内容 | `vc +notes``verbatim_doc_token``docs +fetch --api-version v2` | 本 skill |
| 已结束会议的发言内容 | `vc +notes`,再按 `note_display_type` 路由 | 本 skill / [`lark-note`](../lark-note/SKILL.md) |
| **进行中会议**的实时事件流(转写、聊天、共享、会中加入/离开)| `vc +meeting-events` | [`lark-vc-agent`](../lark-vc-agent/SKILL.md) |
| **Agent 真实入会 / 离会** | `vc +meeting-join` / `vc +meeting-leave` | [`lark-vc-agent`](../lark-vc-agent/SKILL.md) |
## 资源关系
```
```text
Meeting (视频会议)
├── Note (会议纪要)
├── Note (会议纪要) ← note_id 标识note_display_type: normal / unified
│ ├── MainDoc (AI 智能纪要文档, note_doc_token)
│ ├── MeetingNotes (用户绑定的会议纪要文档, meeting_notes)
│ ├── VerbatimDoc (逐字稿, verbatim_doc_token)
│ ├── VerbatimDoc (逐字稿, verbatim_doc_token) ← normal 路径
│ ├── UnifiedTranscript (unified 原始记录) ← unified 路径note +transcriptlark-note
│ └── SharedDoc (会中共享文档)
└── Minutes (妙记) ← minute_token 标识,+recording 从 meeting_id 获取
├── Transcript (文字记录)
@@ -154,6 +159,13 @@ Meeting (视频会议)
└── Keywords (推荐关键词)
```
> **妙记边界**`+notes` 负责纪要内容、逐字稿和 AI 产物;妙记基础信息请优先看 [`+recording`](references/lark-vc-recording.md) 与 [lark-minutes](../lark-minutes/SKILL.md)。
>
> **Note 域边界**`vc +notes` 是从**会议线索**`meeting_id` / `calendar_event_id` / `minute_token`)定位纪要的入口,返回 `note_id` 和 `note_display_type`。
> - 已有 `note_id` → [lark-note](../lark-note/SKILL.md)。
> - 已有 `doc_token` 且目标是读正文 → [lark-doc](../lark-doc/SKILL.md)。
> - 只有自然语言纪要标题 → 文档搜索 / Docx 正文读取;有显式 `vc-node-id` 才进入 [lark-note](../lark-note/SKILL.md)。
## API Resources
```bash
@@ -180,5 +192,6 @@ lark-cli vc meeting get --params '{"meeting_id": "<meeting_id>", "with_participa
- 查询未来的会议日程 → [lark-calendar](../lark-calendar/SKILL.md)
- Agent 真实入会/离会、会中实时事件 → [lark-vc-agent](../lark-vc-agent/SKILL.md)
- 只有纪要文档标题的逐字稿查询 → 文档搜索 / Docx 正文读取;有显式 `vc-node-id` 才进入 [lark-note](../lark-note/SKILL.md)
- 本地音视频文件转纪要/逐字稿 → [lark-minutes](../lark-minutes/SKILL.md)(上传后回 `vc +notes`
- 妙记搜索/下载/上传/重命名/替换说话人 → [lark-minutes](../lark-minutes/SKILL.md)

View File

@@ -77,17 +77,34 @@ lark-cli vc +notes --meeting-ids 69xxxxxxxxxxxxx28 --dry-run
|------|------|
| `meeting_id` | 会议 ID`--meeting-ids` / `--calendar-event-ids` 路径) |
| `minute_token` | **会议对应的妙记 Token**`--meeting-ids` / `--calendar-event-ids` 路径自动通过录制 API 反查并附加)|
| `note_id` | **纪要 ID** — 用于继续进入 Note 域(`note +detail` / `note +transcript` |
| `note_display_type` | **纪要展示类型**`unknown` / `normal` / `unified`,区分普通纪要和 unified 纪要 |
| `note_doc_token` | **AI 智能纪要**文档 Token — AI 生成的总结、待办、章节 |
| `meeting_notes` | **用户绑定的会议纪要**文档 Token 列表 — 用户主动关联到会议的文档(仅 `--calendar-event-ids` 路径返回) |
| `verbatim_doc_token` | **逐字稿**文档 Token — 完整的逐句文字记录,含说话人和时间戳 |
| `verbatim_doc_token` | **逐字稿**文档 Token — 完整的逐句文字记录,含说话人和时间戳unified 纪要的逐字稿请改用 `note +transcript` |
| `shared_doc_tokens` | 会中共享文档 Token 列表 |
| `creator_id` | 创建者 ID |
| `create_time` | 创建时间(格式化) |
> **选择哪个 token** 用户说"会议纪要""总结""待办""纪要内容" → 返回 `note_doc_token` 和 `meeting_notes`(如有)。用户说"逐字稿""完整记录""谁说了什么" → 用 `verbatim_doc_token`。意图不明确时,展示所有文档链接让用户选择。
> **选择哪个 token** 用户说"会议纪要""总结""待办""纪要内容" → 返回 `note_doc_token` 和 `meeting_notes`(如有)。用户说"逐字稿""完整记录""谁说了什么" → 见下方「按 `note_display_type` 路由逐字稿」。意图不明确时,展示所有文档链接让用户选择。
>
> 📌 不确定该返回哪个 token参见 [`vc-domain-boundaries.md`](vc-domain-boundaries.md) 的产物链路对比表,了解 AI 总结链路 vs 录制链路的区别。
### 按 `note_display_type` 路由逐字稿 / 原始记录
逐字稿走哪条路由由 `note_display_type` 决定,**不要只看 `verbatim_doc_token` 是否为空**
| 字段 / 条件 | Agent 动作 |
|------------|-----------|
| 用户要纪要正文 / 总结 / 待办 / 章节 | `docs +fetch --api-version v2 --doc <note_doc_token>` |
| `note_display_type=normal` + 用户要逐字稿 | `docs +fetch --api-version v2 --doc <verbatim_doc_token>` |
| `note_display_type=unknown` + `verbatim_doc_token` 非空 + 用户要逐字稿 | `docs +fetch --api-version v2 --doc <verbatim_doc_token>`;不要猜成 unified |
| `note_display_type=unknown` + 无可用逐字稿 token | 先 `note +detail --note-id <note_id>` 复核,再按返回的展示类型路由 |
| `note_display_type=unified` + 用户要逐字稿 / 原始记录 | `note +transcript --note-id <note_id>` → 切到 [lark-note](../../lark-note/SKILL.md) |
| `minute_token` 存在 + 用户要音视频媒体 | `minutes +download --minute-tokens <minute_token>` |
> **`unified` 纪要的逐字稿不是独立文档**,必须用 `note +transcript` 按 `note_id` 拉取,输出更结构化。即使 unified 也返回了非空 `verbatim_doc_token`,仍以 `note_display_type` 为准。
### minute-tokens 路径的 AI 产物
通过 `--minute-tokens` 查询时,返回的 `artifacts` 字段包含 AI 内置产物:

View File

@@ -27,7 +27,7 @@
| 产物 | Token 字段 | 本质 | 说明 |
|------|-----------|------|------|
| 智能纪要 | `note_doc_token` | 飞书文档 | AI 生成的会议总结与待办 |
| 逐字稿 | `verbatim_doc_token` | 飞书文档 | 完整的逐句发言记录(含说话人、时间戳) |
| 逐字稿 | `verbatim_doc_token` | 飞书文档 | 完整的逐句发言记录(含说话人、时间戳)**仅 `note_display_type=normal` 时是可读的独立文档**`unified` 纪要的逐字稿用 `note +transcript --note-id <note_id>` 拉取(见下方 [Note 域](#note-域) |
| 共享文档 | `shared_doc_token` | 飞书文档 | 会中投屏共享的文档信息 |
此外,还存在**用户会议纪要MeetingNotes**,对应 `meeting_notes` 字段。这是用户主动绑定到会议的纪要文档,通常用于会前记录会议相关内容,与智能纪要文档相互独立。仅通过 `+notes --calendar-event-ids` 路径返回。
@@ -58,7 +58,7 @@
#### 逐字稿与文字记录的格式
智能纪要的逐字稿(`verbatim_doc_token`和妙记的文字记录Transcript都记录了用户原始对话内容格式一致
智能纪要的逐字稿(`normal` 纪要的 `verbatim_doc_token` 文档、`unified` 纪要的 `note +transcript` 输出和妙记的文字记录Transcript都记录了用户原始对话内容格式一致
```
发言人名称 相对时间戳
@@ -81,6 +81,8 @@
根据关键字、组织者、参与人、会议室等条件搜索会议,获取会议列表。
> **不要把纪要标题当会议线索:** 如果用户说“查询 xx 纪要的逐字稿 / 原始记录 / 谁说了什么”,且没有 `meeting_id`、`calendar_event_id`、会议号、参会人或时间范围,先用 `drive +search --query <标题>` 搜索纪要文档,拿到 Docx URL/token 后再 `docs +fetch --api-version v2`。若返回 `<vc-transcribe-tab vc-node-id="...">`,提取 `note_id` 后进入 Note 域判断 `normal` / `unified`;若没有该 block但有“文字记录/逐字稿” Docx 链接,直接用 `docs +fetch --api-version v2` 读取该链接。
```bash
lark-cli vc +search --start "<YYYY-MM-DD>" --end "<YYYY-MM-DD>" --format json
```
@@ -96,8 +98,9 @@ lark-cli vc +notes --meeting-ids '<meeting_id1>,<meeting_id2>'
```
可获取会议的所有产物信息,包括:
- 纪要标识(`note_id`)与展示类型(`note_display_type``unknown` / `normal` / `unified`)— 决定逐字稿走哪条路由
- 智能纪要(`note_doc_token`)— AI 生成的总结和待办信息
- 逐字稿(`verbatim_doc_token`)— 完整的会中发言记录
- 逐字稿(`verbatim_doc_token`)— 完整的会中发言记录(仅 `normal` 纪要可直接读取该文档)
- 共享文档(`shared_doc_token`)— 会中投屏共享的文档
- 妙记 Token`minute_token`)— 如存在录制产物则返回
@@ -111,25 +114,39 @@ lark-cli vc +notes --minute-tokens '<minute_token1>,<minute_token2>'
可获取妙记的总结、待办、章节、文字记录等信息。详细用法请阅读 [`lark-vc-notes.md`](lark-vc-notes.md)。
#### Step 3: Doc 域拉取文档内容
#### Step 3: 按 `note_display_type` 拉取正文 / 逐字稿
智能纪要和逐字稿都是飞书文档,使用 `docs +fetch` 读取正文内容
智能纪要`note_doc_token`是飞书文档,使用 `docs +fetch --api-version v2` 读取正文内容;**逐字稿的读取方式由 `note_display_type` 决定**
```bash
lark-cli docs +fetch --api-version v2 --doc <doc_token> --doc-format markdown
# 纪要正文(两种展示类型都适用)
lark-cli docs +fetch --api-version v2 --doc <note_doc_token> --doc-format markdown
# note_display_type=normal逐字稿是独立文档
lark-cli docs +fetch --api-version v2 --doc <verbatim_doc_token> --doc-format markdown
# note_display_type=unified逐字稿不是独立文档按 note_id 拉取
lark-cli note +transcript --note-id <note_id>
```
详细用法请参考 [lark-doc](../../lark-doc/SKILL.md) skill。
详细用法请参考 [lark-doc](../../lark-doc/SKILL.md) 与 [lark-note](../../lark-note/SKILL.md) skill。
#### Step 4: 判断用户需要的产物内容
- 根据用户诉求(总结/待办/章节/完整发言记录等),选择合适的产物进行分析和信息提取
- 如果两种产物都不存在或没有权限,需如实告知用户
## Note 域
- VC 只负责从 `meeting_id` / `calendar_event_id` / `minute_token` 定位会议产物和 `note_id`
- 已知 `note_id` 后切到 [lark-note](../../lark-note/SKILL.md);逐字稿路由以 `lark-note``note_display_type` 规则为准。
- 只有自然语言纪要标题时,先走文档搜索与 `docs +fetch --api-version v2`;只有 `<vc-transcribe-tab vc-node-id="...">``vc-node-id` 可以进入 Note 域。
- `doc_token` / Docx URL 不是 `note_id`。没有 `vc-node-id` 时不要反推 Note继续按 Doc 域读取正文或正文中明确给出的逐字稿文档。
## Doc 域
- **lark-doc skill** 负责飞书云文档管理,包括获取文档元信息、读取文档内容、创建和编辑文档等操作。
- **会议产物的文档本质**:智能纪要(`note_doc_token`逐字稿(`verbatim_doc_token`)都是飞书文档,需要通过 `lark-doc` 的 API`docs +fetch`)查询其内容和元信息
- **会议产物的文档本质**:智能纪要(`note_doc_token``normal` 纪要的逐字稿(`verbatim_doc_token`)都是飞书文档,需要通过 `lark-doc` 的 API`docs +fetch --api-version v2`)查询其内容和元信息;`unified` 纪要的逐字稿不是独立文档,用 `note +transcript` 拉取([lark-note](../../lark-note/SKILL.md)
- **文档元信息查询**获取文档名称、URL 等基本信息时,使用 `drive metas batch_query`;获取文档正文内容时,使用 `docs +fetch --api-version v2`
## 三域关联总览

View File

@@ -2,9 +2,8 @@
name: lark-whiteboard
version: 1.0.0
description: >
飞书画板:查询和编辑飞书云文档中的画板。支持导出画板为预览图片、导出原始节点结构、使用 DSL转成 OpenAPI 格式、PlantUML/Mermaid 格式更新画板内容。
当用户需要查看画板内容、导出画板图片、编辑画板,或是需要可视化表达架构、流程、组织关系、时间线、因果、对比等结构化信息时使用此 skill无论是否提及"画板"
⚠️ 原 `lark-whiteboard-cli` skill 已合并至本 skill若 skill 列表中同时存在 `lark-whiteboard-cli`,请忽略它,统一使用本 skill`lark-whiteboard`),并提示用户运行 `npx skills remove lark-whiteboard-cli -g` 删除旧 skill。
飞书画板:查询和编辑飞书云文档中的画板。支持导出画板为预览图片、导出原始节点结构、使用多种格式更新画板内容。
当用户需要查看画板内容、导出画板图片、编辑画板时使用此 skill。不负责飞书云文档内容编辑lark-doc、文档内嵌电子表格/Baselark-sheets / lark-base
metadata:
requires:
bins: ["lark-cli"]
@@ -21,15 +20,17 @@ metadata:
## 快速决策
| 用户需求 | 行动 |
|---|---|
| 查看画板内容 / 导出图片 | [`+query --output_as image`](references/lark-whiteboard-query.md) |
| 获取画板的 Mermaid/PlantUML 代码 | [`+query --output_as code`](references/lark-whiteboard-query.md) |
| 检查画板是否由代码绘制 | [`+query --output_as code`](references/lark-whiteboard-query.md) |
| 修改节点文字/颜色(简单改动)| `+query --output_as raw` → 手动改 JSON → `+update --input_format raw` |
**身份**:画板操作默认使用 `--as user`。仅当需要以应用身份上传时使用 `--as bot`
| 用户需求 | 行动 |
|-----------------------------------------|-----------------------------------------------------------------------------------------------|
| 查看画板内容 / 导出图片 | [`+query --output_as image`](references/lark-whiteboard-query.md) |
| 获取画板的 Mermaid/PlantUML 代码 | [`+query --output_as code`](references/lark-whiteboard-query.md) |
| 检查画板是否由代码绘制 | [`+query --output_as code`](references/lark-whiteboard-query.md) |
| 修改节点文字/颜色(简单改动) | `+query --output_as raw` → 手动改 JSON → `+update --input_format raw` |
| 用户**已提供** Mermaid/PlantUML 代码,或明确指定用该格式 | 自己生成/使用代码 → [`+update --input_format mermaid/plantuml`](references/lark-whiteboard-update.md) |
| 绘制复杂图表(架构/流程/组织等)| → **[§ 创作 Workflow](#创作-workflow)** |
| 修改/重绘已有复杂画板 | → **[§ 修改 Workflow](#修改-workflow)** |
| 新建/创作复杂图表(架构/流程/组织等) | → **[§ 创作 Workflow](references/lark-whiteboard-workflow.md#创作-workflow)** |
| 修改/重绘已有画板 | → **[§ 修改 Workflow](references/lark-whiteboard-workflow.md#修改-workflow)** |
## Shortcuts
@@ -40,93 +41,7 @@ metadata:
---
## 创作 Workflow
> 此 workflow 用于**独立创作一个画板**。
> 需要在文档中批量创建多个画板时,由 lark-doc 负责调度,见 `lark-doc` 技能的 `references/lark-doc-whiteboard.md`。
**Step 1获取 board_token**
| 用户给了什么 | 怎么获取 |
|---|---|
| 直接给了 whiteboard token`wbcnXXX`| 直接使用 |
| 文档 URL 或 doc_id文档中已有画板 | `lark-cli docs +fetch --api-version v2 --doc <URL> --as user`,从返回的 `<whiteboard token="xxx"/>` 提取 |
| 文档 URL 或 doc_id需要新建画板 | `lark-cli docs +update --api-version v2 --doc <doc_id> --command append --content '<whiteboard type="blank"></whiteboard>' --as user`,从响应 `data.new_blocks[0].block_token` 取得(`block_type == "whiteboard"` 的那条;参数详见 lark-doc SKILL.md|
**Step 2渲染 & 写入**
→ 进入 **[§ 渲染 & 写入画板](#渲染--写入画板)** 章节,按流程完成后直接返回结果给用户。
---
## 修改 Workflow
**Step 1获取 board_token**(同创作 Workflow Step 1
**Step 2判断修改策略**
```
+query --output_as code
├─ 返回 Mermaid/PlantUML 代码
│ → 在原代码上修改 → +update --input_format mermaid/plantuml
├─ 无代码DSL 或其他方式绘制的画板)
│ ├─ 只改文字/颜色 → +query --output_as raw → 手动改 JSON → +update --input_format raw
│ └─ 重绘/结构调整 → +query --output_as image → 看图后进入 [§ 渲染 & 写入画板]
└─ 用户有明确要求 → 以用户要求优先
```
---
## 渲染 & 写入画板
### 渲染路由
**先自报身份**:按训练来源,你属于哪一家?`Claude` / `Gemini` / `GPT` / `GLM` / `Doubao 或 Seed` / `Other`。身份决定 SVG / DSL 分流(不同家族对这两种表达语言的能力不均衡),不要跳过这一步。
**然后按图表类型 × 身份选路径**,读对应文件按其完整 workflow 执行(含读 scene 指南、生成内容、渲染审查、交付):
| 图表类型 | 身份 | 路径 |
|------------------------|-------------------------------------|------------------------------------------|
| 思维导图、流程图、时序图、类图、饼图、甘特图 | 任何身份 | [`routes/mermaid.md`](routes/mermaid.md) |
| 其他图表 | `Claude` / `Gemini` / `GPT` / `GLM` | [`routes/svg.md`](routes/svg.md) |
| 其他图表 | `Doubao` / `Seed` / `Other` | [`routes/dsl.md`](routes/dsl.md) |
> **⚠️ SVG 路径失败回退**:走 `routes/svg.md` 时,碰到以下情况之一 → **丢弃当前 SVG改读 `routes/dsl.md` 从零重画,不要逐行修补**
> - 渲染命令直接报错(语法级崩溃,不是 `--check` 的 warn/error
> - 两轮改写仍无法消除 `--check` 的 `text-overflow` error
> - 目测 PNG 视觉严重错乱(文字大面积溢出、元素重叠压住关键信息、布局整体崩溃)
>
> SVG 源码修补常常引入新 bug换 DSL 从零重画往往更稳。这是 SVG 路径自由发挥的硬兜底,不要侵入 `routes/svg.md` 的创作流程。
### 产物规范
产物目录:`./diagrams/YYYY-MM-DDTHHMMSS/`(本地时间,不含冒号和时区后缀)。如用户指定路径,以用户为准。
目录内固定文件名:
```
diagram.svg ← SVG 源码SVG 路径)
diagram.mmd ← Mermaid 源码Mermaid 路径)
diagram.json ← DSL 源文件DSL 路径) / OpenAPI JSONSVG 路径从 diagram.svg 导出)
diagram.gen.cjs ← 坐标计算脚本(仅 DSL 脚本构建方式)
diagram.png ← 渲染结果
```
### 写入画板
> 关于 --overwrite
> 画板更新命令中,若不携带 --overwrite flag则是增量更新画板内容若画板内已有内容的话新增内容可能会和已有内容重叠导致问题。
> 因此,若需要整体更新画板内容,需携带 --overwrite flag 覆盖式更新。
```bash
npx -y @larksuite/whiteboard-cli@^0.2.11 -i <产物文件> --to openapi --format json \
| lark-cli whiteboard +update \
--whiteboard-token <Token> \
--source - --input_format raw \
--idempotent-token <10+字符唯一串> \
--as user \
--overwrite
```
> `--idempotent-token` 最少 10 字符,建议用时间戳+标识拼接(如 `1744800000-board-1`),避免重试导致重复写入。
> 如需应用身份上传,将 `--as user` 替换为 `--as bot`。
## 不在本 skill 范围
- 文档内容编辑 → lark-doc [lark-doc](../lark-doc/SKILL.md)
- 在文档中创建画板 → [lark-doc-whiteboard.md](../lark-doc/references/lark-doc-whiteboard.md)
- 表格 / Base 操作 → [lark-sheets](../lark-sheets/SKILL.md) / [lark-base](../lark-base/SKILL.md)

View File

@@ -3,7 +3,7 @@
## 布局决策
> 不要靠关键词猜布局。先分析信息结构,再决定布局策略。
> 本文件负责说明通用布局原则与骨架模板;字段语义看 `references/schema.md`,完整场景范式看各 `scenes/*.md`
> 本文件负责说明通用布局原则与骨架模板;字段语义看 `elements/schema.md`,完整场景范式看各 `scenes/*.md`
总原则:**先定主布局,再定子布局。**

View File

@@ -1,6 +1,6 @@
# DSL Schema
> 本文件只说明 **DSL 里能写什么**节点类型、字段、枚举值、硬约束。布局策略、组合方法、Dagre/Flex 心智模型统一放在 `references/layout.md`
> 本文件只说明 **DSL 里能写什么**节点类型、字段、枚举值、硬约束。布局策略、组合方法、Dagre/Flex 心智模型统一放在 `elements/layout.md`
> `?` 表示该字段在 schema 层是 optional若需要稳定产出再参考对应 scene 或 layout 文件中的最佳实践。
**📝 布局引擎核心法则**
@@ -138,7 +138,7 @@ interface WBDocument {
> - 图片必须上传到**目标画板**,跨画板的 token 不可用
> - 同一画板内所有 image 节点应使用统一的 width/height保持视觉一致
> - 图片宽高比推荐 3:2如 240×160避免变形
> - 详细上传流程见 [`references/image.md`](image.md)
> - 详细上传流程见 [`elements/image.md`](../elements/image.md)
### Text纯文本节点

View File

@@ -0,0 +1,92 @@
# 画板创作/修改工作流
## 创作 Workflow
> 此 workflow 用于**独立创作一个画板**。
> 需要在文档中批量创建多个画板时,由 lark-doc 负责调度,见 `lark-doc` 技能的 `references/lark-doc-whiteboard.md`。
**Step 1获取 board_token**
| 用户给了什么 | 怎么获取 |
|---|---|
| 直接给了 whiteboard token`wbcnXXX`| 直接使用 |
| 文档 URL 或 doc_id文档中已有画板 | `lark-cli docs +fetch --api-version v2 --doc <URL> --as user`,从返回的 `<whiteboard token="xxx"/>` 提取 |
| 文档 URL 或 doc_id需要新建画板 | `lark-cli docs +update --api-version v2 --doc <doc_id> --command append --content '<whiteboard type="blank"></whiteboard>' --as user`,从响应 `data.new_blocks[0].block_token` 取得(`block_type == "whiteboard"` 的那条;参数详见 lark-doc SKILL.md|
**Step 2渲染 & 写入**
→ 进入 **[§ 渲染 & 写入画板](#渲染--写入画板)** 章节,按流程完成后直接返回结果给用户。
---
## 修改 Workflow
**Step 1获取 board_token**(同创作 Workflow Step 1
**Step 2判断修改策略**
```
+query --output_as code
├─ 返回 Mermaid/PlantUML 代码
│ → 在原代码上修改 → +update --input_format mermaid/plantuml
├─ 无代码DSL 或其他方式绘制的画板)
│ ├─ 只改文字/颜色 → +query --output_as raw → 手动改 JSON → +update --input_format raw
│ └─ 重绘/结构调整 → +query --output_as image → 看图后进入 [§ 渲染 & 写入画板]
└─ 用户有明确要求 → 以用户要求优先
```
---
## 渲染 & 写入画板
### 渲染路由
**先自报身份**:按训练来源,你属于哪一家?`Claude` / `Gemini` / `GPT` / `GLM` / `Doubao 或 Seed` / `Other`。身份决定 SVG / DSL 分流(不同家族对这两种表达语言的能力不均衡),不要跳过这一步。
**然后按图表类型 × 身份选路径**,读对应文件按其完整 workflow 执行(含读 scene 指南、生成内容、渲染审查、交付):
| 图表类型 | 身份 | 路径 |
|--------------------|-------------------------------------|------------------------------------------------|
| 思维导图、时序图、类图、饼图、甘特图 | 任何身份 | [`../routes/mermaid.md`](../routes/mermaid.md) |
| 其他图表 | `Claude` / `Gemini` / `GPT` / `GLM` | [`../routes/svg.md`](../routes/svg.md) |
| 其他图表 | `Doubao` / `Seed` / `Other` | [`../routes/dsl.md`](../routes/dsl.md) |
> **⚠️ SVG 路径失败回退**:走 `routes/svg.md` 时,碰到以下情况之一 → **丢弃当前 SVG改读 `routes/dsl.md` 从零重画,不要逐行修补**
> - 渲染命令直接报错(语法级崩溃,不是 `--check` 的 warn/error
> - 两轮改写仍无法消除 `--check` 的 `text-overflow` error
> - 目测 PNG 视觉严重错乱(文字大面积溢出、元素重叠压住关键信息、布局整体崩溃)
>
> SVG 源码修补常常引入新 bug换 DSL 从零重画往往更稳。这是 SVG 路径自由发挥的硬兜底,不要侵入 `routes/svg.md` 的创作流程。
### 产物规范
产物目录:`./diagrams/YYYY-MM-DDTHHMMSS/`(本地时间,不含冒号和时区后缀)。如用户指定路径,以用户为准。
目录内固定文件名:
```
diagram.svg ← SVG 源码SVG 路径)
diagram.mmd ← Mermaid 源码Mermaid 路径)
diagram.json ← DSL 源文件DSL 路径) / OpenAPI JSONSVG 路径从 diagram.svg 导出)
diagram.gen.cjs ← 坐标计算脚本(仅 DSL 脚本构建方式)
diagram.png ← 渲染结果
```
### 写入画板
> 关于 --overwrite
> 画板更新命令中,若不携带 --overwrite flag则是增量更新画板内容若画板内已有内容的话新增内容可能会和已有内容重叠导致问题。
> 因此,若需要整体更新画板内容,需携带 --overwrite flag 覆盖式更新。
```bash
npx -y @larksuite/whiteboard-cli@^0.2.11 -i <产物文件> --to openapi --format json \
| lark-cli whiteboard +update \
--whiteboard-token <Token> \
--source - --input_format raw \
--idempotent-token <10+字符唯一串> \
--as user \
--overwrite
```
> `--idempotent-token` 最少 10 字符,建议用时间戳+标识拼接(如 `1744800000-board-1`),避免重试导致重复写入。
> 如需应用身份上传,将 `--as user` 替换为 `--as bot`。

View File

@@ -8,7 +8,7 @@
Step 1: 路由 & 读取知识
- 读对应 scene 指南 — 了解结构特征和布局策略
- 确定布局策略(见下方快速判断)和构建方式
- 读 references/ 核心模块 — 语法、布局、配色、排版、连线
- 读 elements/ 核心模块 — 语法、布局、配色、排版、连线
Step 2: 生成完整 DSL含颜色
- 按 content.md 规划信息量和分组
@@ -37,7 +37,7 @@ Step 3: 渲染 & 审查 → 交付
- 交付:向用户报告 board_token 写入成功
```
**布局策略快速判断**(详见 `references/layout.md`
**布局策略快速判断**(详见 `elements/layout.md`
先定**主布局**,再定子布局:**结构化信息**优先用 Flex**关系链路**优先用 Dagre**灵活定位**用绝对布局。
@@ -47,14 +47,14 @@ Step 3: 渲染 & 审查 → 交付
### 核心参考(必读)
| 模块 | 文件 | 说明 |
| -------- | -------------------------- | ------------------------------- |
| DSL 语法 | `references/schema.md` | 节点类型、属性、尺寸值 |
| 内容规划 | `references/content.md` | 信息提取、密度决策、连线预判 |
| 布局系统 | `references/layout.md` | 网格方法论、Flex 映射、间距规则 |
| 排版规则 | `references/typography.md` | 字号层级、对齐、行距 |
| 连线系统 | `references/connectors.md` | 拓扑规划、锚点选择 |
| 配色系统 | `references/style.md` | 多色板、视觉层级 |
| 模块 | 文件 | 说明 |
| -------- |----------------------------| ------------------------------- |
| DSL 语法 | `elements/schema.md` | 节点类型、属性、尺寸值 |
| 内容规划 | `elements/content.md` | 信息提取、密度决策、连线预判 |
| 布局系统 | `elements/layout.md` | 网格方法论、Flex 映射、间距规则 |
| 排版规则 | `elements/typography.md` | 字号层级、对齐、行距 |
| 连线系统 | `elements/connectors.md` | 拓扑规划、锚点选择 |
| 配色系统 | `elements/style.md` | 多色板、视觉层级 |
### 场景指南(按类型选读一个)
@@ -73,7 +73,7 @@ Step 3: 渲染 & 审查 → 交付
| 循环/飞轮图 | `scenes/flywheel.md` | 增长飞轮、闭环链路 |
| 里程碑 | `scenes/milestone.md` | 时间线、版本演进 |
| 流程图 | `scenes/flowchart.md` | 业务流、状态机、带条件判断的链路 |
| 图片展示 | `scenes/photo-showcase.md` | 用户显式要求图片/配图/插图时(需先完成 `references/image.md` 的图片准备) |
| 图片展示 | `scenes/photo-showcase.md` | 用户显式要求图片/配图/插图时(需先完成 `elements/image.md` 的图片准备) |
## 渲染前自查

View File

@@ -2,7 +2,7 @@
适用于:各种业务流转图、决策树、审批流、时序控制逻辑、带条件判断的链路、系统架构拓扑等。
通用字段语义详见 `references/schema.md`,通用布局原则详见 `references/layout.md`;本文件只描述流程图场景下的选型边界与范式。
通用字段语义详见 `elements/schema.md`,通用布局原则详见 `elements/layout.md`;本文件只描述流程图场景下的选型边界与范式。
> [!IMPORTANT]
> **流程图必须走 DSL 路径,不再使用 Mermaid**

View File

@@ -7,7 +7,7 @@
| 中间格式 | JSONWBDocument | Mermaid 文本(.mmd 文件) |
| 布局控制 | 精确控制x/y 坐标、Flex | 由 parser-kit 自动布局 |
| 视觉定制 | 完全可控(颜色、字号、圆角等) | 有限Mermaid 语法) |
| 参考模块 | references/ + 对应 scene | 仅本文件 |
| 参考模块 | elements/ + 对应 scene | 仅本文件 |
## 适用条件

View File

@@ -4,7 +4,7 @@
> **注意**:仅当用户明确说了「图片/配图/插图/照片」等词时才进入本场景。单纯说"旅行路线图"、"产品展示"等不触发。
> **前置条件**:进入本场景前,必须已完成 [`references/image.md`](../references/image.md) 的 Step 0图片准备拿到所有 media token。
> **前置条件**:进入本场景前,必须已完成 [`elements/image.md`](../elements/image.md) 的 Step 0图片准备拿到所有 media token。
## Content 约束

View File

@@ -28,7 +28,7 @@
1. **网格对齐是第一优先级**:跨泳道同一阶段必须严格对齐(水平对齐 x垂直对齐 y。对齐通过“共享阶段标尺stage ruler / stage slots”实现不靠肉眼估算也不靠逐节点随意手写坐标
2. **只生成真实节点**:为保证跨泳道阶段严格对齐,所有阶段统一保留透明的 **stage cell**;仅在真实阶段的 cell 内生成卡片节点,并按阶段索引映射到对应槽位
3. **泳道底色**:为了增强层级感同时保持界面整洁,**强烈建议所有泳道容器统一使用极浅灰色背景**(如 `fillColor: "#F8F9FA"``"#FCFCFC"`)。边框使用浅灰色细虚线(`borderDash: "dashed"`, `borderWidth: 1`, `borderColor: "#DEE0E3"`)以明确边界。
4. **步骤卡片**:使用 `rect`。为建立清晰的视觉层级,卡片**必须填充浅色背景**(参考 `references/style.md` 中的浅色板,如极浅的主题色),边框使用对应的主题主色(`borderWidth: 1-2`),文字使用深色(如 `#1F2329`)以确保可读性。统一圆角;宽高以可读为先,避免过窄导致换行过多
4. **步骤卡片**:使用 `rect`。为建立清晰的视觉层级,卡片**必须填充浅色背景**(参考 `elements/style.md` 中的浅色板,如极浅的主题色),边框使用对应的主题主色(`borderWidth: 1-2`),文字使用深色(如 `#1F2329`)以确保可读性。统一圆角;宽高以可读为先,避免过窄导致换行过多
5. **间距**:只要存在 connector 连线,卡片之间的主轴间距必须满足 `gap >= 40`
### 子节点对齐
@@ -151,16 +151,16 @@
- **泳道背景**:所有泳道容器统一使用极浅灰色(如 `fillColor: "#F8F9FA"``"#FCFCFC"`),以增强物理容器的层级感,并突出内部的彩色卡片。
- **泳道边框**:所有泳道外层容器统一使用浅灰色细虚线(`borderColor: "#DEE0E3"`, `borderWidth: 1`, `borderDash: "dashed"`)。
- **泳道标题**:按 `references/style.md` 经典色板为每条泳道分配不同的主题色,泳道 title 的 `textColor` 使用该主题色。
- **泳道标题**:按 `elements/style.md` 经典色板为每条泳道分配不同的主题色,泳道 title 的 `textColor` 使用该主题色。
- **内容节点rect**:采用“浅色底 + 主题色边框”策略。`fillColor` 使用与该泳道主题色对应的极浅色(如浅蓝、浅紫等),`borderColor` 使用对应的主题色,文字 `textColor` 统一使用深色 `#1F2329`
- **连线connector**:连线颜色固定为灰色 `#BBBFC4`,不随泳道颜色变化。当连线带有文字(`label`)时,为防止文字压在边框上难以阅读,必须为连线文字设置纯白背景(`labelFillColor: "#FFFFFF"`)遮挡底纹。
提醒:避免创建“虚拟 frame”`references/schema.md` 的说明。lane 外层必须具有可见属性以避免在编译时被跳过。
提醒:避免创建“虚拟 frame”`elements/schema.md` 的说明。lane 外层必须具有可见属性以避免在编译时被跳过。
## 连线规则(强制参考 connectors.md
泳道图中所有连线的选择与写法必须严格遵循 `references/connectors.md`,尤其是:
泳道图中所有连线的选择与写法必须严格遵循 `elements/connectors.md`,尤其是:
- `connector` 必须放在 `WBDocument.nodes` 顶层,不能嵌套在 `children`
- 默认优先使用自动绕线:`lineShape: "polyline"` / `"rightAngle"`,且不写 `waypoints`
- 未指定 `lineShape` 时默认使用 `"rightAngle"`
@@ -171,11 +171,11 @@
泳道图语境下的落地约束:
- **默认不写锚点**,交给引擎自动推断;只有需要强制“左→右推进 / 上→下推进”时才写
- 需要表达“异步/事件流/推送”(如 SSE/Chunk使用 `lineStyle: "dashed"` 并配合 `label` 说明语义;其他参数仍按 connectors.md
- 避免连接“仅用于布局且可能被优化掉的虚拟 frame”尽量连接具体步骤卡片的节点 id参考 `references/schema.md` 的虚拟 frame 陷阱)
- 避免连接“仅用于布局且可能被优化掉的虚拟 frame”尽量连接具体步骤卡片的节点 id参考 `elements/schema.md` 的虚拟 frame 陷阱)
## 骨架示例
> 示例展示布局的结构与对齐方法;实际节点的样式满足当前布局规则的前提下参考 `references/style.md`
> 示例展示布局的结构与对齐方法;实际节点的样式满足当前布局规则的前提下参考 `elements/style.md`
- 水平泳道示例:

View File

@@ -74,8 +74,11 @@ lark-cli vc +notes --meeting-ids "id1,id2,...,idN"
- 根据上一步搜集到的 `meeting-id` 查询会议纪要。
- 单次最多查询 50 个纪要信息,超过 50 个需分批调用。
- 部分会议返回 `no notes available`,在最终输出中标注"无纪要"
- 记录每个会议的 `note_doc_token`(纪要文档 Token`verbatim_doc_token`(逐字稿文档 Token
- 记录每个会议的 `note_id`(纪要 ID`note_display_type`(展示类型:`unknown` / `normal` / `unified`)、`note_doc_token`(纪要文档 Token`verbatim_doc_token`(逐字稿文档 Token
> **逐字稿路由按 `note_display_type` 决定**(详见 [vc-domain-boundaries.md](../lark-vc/references/vc-domain-boundaries.md) 的 Note 域):
> - `normal`:逐字稿是独立文档,链接/正文走 `verbatim_doc_token`。
> - `unified`:逐字稿**不是独立文档**,没有可分享的逐字稿文档链接;需要逐字稿内容时用 `note +transcript --note-id <note_id>`[lark-note](../lark-note/SKILL.md))拉取到本地,报告中标注"unified 纪要"即可。
2. 获取纪要文档和逐字稿文档链接
```bash
@@ -83,6 +86,7 @@ lark-cli vc +notes --meeting-ids "id1,id2,...,idN"
lark-cli schema drive.metas.batch_query
# 批量获取纪要文档与逐字稿链接: 一次最多查询 10 个文档
# 仅对 note_doc_token 与 normal 纪要的 verbatim_doc_token 查询链接
lark-cli drive metas batch_query --data '{"request_docs": [{"doc_type": "docx", "doc_token": "<doc_token>"}], "with_url": true}'
```
@@ -90,7 +94,7 @@ lark-cli drive metas batch_query --data '{"request_docs": [{"doc_type": "docx",
根据时间跨度选择输出格式:
- **单日汇总**"今天"/"昨天"):用"今日会议概览"标题,逐会议列出会议时间、主题、纪要链接、逐字稿链接。
- **单日汇总**"今天"/"昨天"):用"今日会议概览"标题,逐会议列出会议时间、主题、纪要链接、逐字稿链接`unified` 纪要无逐字稿链接,标注"unified 纪要,逐字稿需 `note +transcript` 拉取"
- **多日/周报**"这周"/"过去 7 天"等):用"会议纪要周报"标题,含概览统计、逐会议详情。
### Step 5: 生成文档(可选,用户要求时)
@@ -107,4 +111,5 @@ lark-cli docs +update --api-version v2 --doc "<url_or_token>" --command append -
- [lark-shared](../lark-shared/SKILL.md) — 认证、权限(必读)
- [lark-vc](../lark-vc/SKILL.md) — `+search``+notes` 详细用法
- [lark-note](../lark-note/SKILL.md) — `note +detail``note +transcript`unified 纪要逐字稿)
- [lark-doc](../lark-doc/SKILL.md) — `+fetch``+create``+update` 详细用法

View File

@@ -28,17 +28,6 @@ func TestCalendar_ManageCalendar(t *testing.T) {
var createdCalendarID string
var deletedCalendar bool
t.Run("list calendars as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "list"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
require.NotEmpty(t, gjson.Get(result.Stdout, "data.calendar_list").Array(), "stdout:\n%s", result.Stdout)
})
t.Run("get primary calendar as bot", func(t *testing.T) {
primaryCalendarID := getPrimaryCalendarID(t, ctx)
require.NotEmpty(t, primaryCalendarID)
@@ -97,13 +86,6 @@ func TestCalendar_ManageCalendar(t *testing.T) {
assert.Equal(t, calendarDescription, gjson.Get(result.Stdout, "data.description").String())
})
t.Run("find created calendar in list as bot", func(t *testing.T) {
require.NotEmpty(t, createdCalendarID)
calendar := findCalendarByID(t, ctx, createdCalendarID)
assert.Equal(t, createdCalendarID, calendar.Get("calendar_id").String())
assert.Equal(t, calendarSummary, calendar.Get("summary").String())
})
t.Run("update calendar as bot", func(t *testing.T) {
require.NotEmpty(t, createdCalendarID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{

View File

@@ -2,15 +2,15 @@
## Metrics
- Denominator: 23 leaf commands
- Covered: 12
- Coverage: 52.2%
- Covered: 11
- Coverage: 47.8%
## Summary
- TestCalendar_ViewAgenda: proves the user shortcut `calendar +agenda`; key `t.Run(...)` proof points are `view today agenda as user`, `view agenda with date range as user`, and `view agenda with pretty format as user`.
- TestCalendar_PersonalEventWorkflowAsUser: proves a self-contained user event workflow across `calendar calendars primary`, `calendar +create`, `calendar events get`, and `calendar +agenda`; key `t.Run(...)` proof points are `get primary calendar as user`, `create personal event with shortcut as user`, `get created event as user`, and `find created event in agenda as user`.
- TestCalendar_RSVPWorkflowAsUser: proves the user shortcuts `calendar +freebusy` and `calendar +rsvp`; key `t.Run(...)` proof points are `query freebusy as user`, `reply tentative as user`, `verify tentative freebusy as user`, `reply accept as user`, and `verify accepted freebusy as user`.
- TestCalendar_CreateEvent: proves `calendar +create`, `calendar events get`, and `calendar events delete`; key `t.Run(...)` proof points are `create event with shortcut as bot`, `verify event created as bot`, and `delete event as bot`.
- TestCalendar_ManageCalendar: proves `calendar calendars primary`, `calendar calendars create`, `calendar calendars get`, `calendar calendars list`, and `calendar calendars patch`; key `t.Run(...)` proof points are `get primary calendar as bot`, `create calendar as bot`, `get created calendar as bot`, `find created calendar in list as bot`, and `update calendar as bot`.
- TestCalendar_ManageCalendar: proves `calendar calendars primary`, `calendar calendars create`, `calendar calendars get`, and `calendar calendars patch`; key `t.Run(...)` proof points are `get primary calendar as bot`, `create calendar as bot`, `get created calendar as bot`, and `update calendar as bot`.
- Cleanup note: `calendar calendars delete` is part of the calendar lifecycle workflow and is counted as covered because the workflow proves the full shared-calendar lifecycle.
- Blocked area: direct `event.attendees *` APIs, `calendar calendars search`, `calendar events create|instance_view|patch|search`, `calendar freebusys list`, and planning shortcuts `calendar +room-find` / `calendar +suggestion` still need deterministic workflows; the planning shortcuts currently depend on live tenant availability and room inventory, so they remain uncovered.
@@ -27,7 +27,7 @@
| ✓ | calendar calendars create | api | calendar_manage_calendar_test.go::TestCalendar_ManageCalendar/create calendar as bot | `summary`; `description` in `--data` | |
| ✓ | calendar calendars delete | api | calendar_manage_calendar_test.go::TestCalendar_ManageCalendar/delete calendar as bot | `calendar_id` in `--params` | |
| ✓ | calendar calendars get | api | calendar_manage_calendar_test.go::TestCalendar_ManageCalendar/get created calendar as bot; calendar_manage_calendar_test.go::TestCalendar_ManageCalendar/verify updated calendar as bot | `calendar_id` in `--params` | |
| | calendar calendars list | api | calendar_manage_calendar_test.go::TestCalendar_ManageCalendar/list calendars as bot; calendar_manage_calendar_test.go::TestCalendar_ManageCalendar/find created calendar in list as bot | none | |
| | calendar calendars list | api | | none | removed from the live workflow because tenant history made list latency non-deterministic |
| ✓ | calendar calendars patch | api | calendar_manage_calendar_test.go::TestCalendar_ManageCalendar/update calendar as bot | `calendar_id` in `--params`; `summary` in `--data` | |
| ✓ | calendar calendars primary | api | calendar_manage_calendar_test.go::TestCalendar_ManageCalendar/get primary calendar as bot; calendar_personal_event_workflow_test.go::TestCalendar_PersonalEventWorkflowAsUser/get primary calendar as user | none | bot and user primary calendar lookup |
| ✕ | calendar calendars search | api | | none | no search workflow yet |

View File

@@ -62,47 +62,6 @@ func getCurrentUserOpenIDForCalendar(t *testing.T, ctx context.Context) string {
return openID
}
func findCalendarByID(t *testing.T, ctx context.Context, calendarID string) gjson.Result {
t.Helper()
require.NotEmpty(t, calendarID, "calendar ID is required")
pageToken := ""
seenPageTokens := map[string]struct{}{}
for {
params := map[string]any{
"page_size": 50,
}
if pageToken != "" {
if _, seen := seenPageTokens[pageToken]; seen {
t.Fatalf("calendar list pagination loop detected for calendar %q, repeated page_token %q", calendarID, pageToken)
}
seenPageTokens[pageToken] = struct{}{}
params["page_token"] = pageToken
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "list"},
DefaultAs: "bot",
Params: params,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
calendar := gjson.Get(result.Stdout, `data.calendar_list.#(calendar_id=="`+calendarID+`")`)
if calendar.Exists() {
return calendar
}
hasMore := gjson.Get(result.Stdout, "data.has_more").Bool()
pageToken = gjson.Get(result.Stdout, "data.page_token").String()
if !hasMore || pageToken == "" {
t.Fatalf("calendar %q not found in listed pages, last stdout:\n%s", calendarID, result.Stdout)
}
}
}
func unixSecondsRFC3339(t time.Time) string {
return strconv.FormatInt(t.Unix(), 10)
}

View File

@@ -0,0 +1,21 @@
# Note CLI E2E Coverage
## Metrics
- Denominator: 2 leaf commands
- Dry-run covered: 2
- Dry-run coverage: 100.0%
- Live covered: 0
- Live coverage: 0.0%
Live E2E is intentionally not counted yet because both commands require meeting-generated note artifacts; stable create/use/cleanup fixtures are not available in this test suite.
## Summary
- TestNoteDetailDryRun: dry-run coverage for `note +detail`; asserts the detail request method and `/open-apis/vc/v1/notes/{note_id}` URL without calling live APIs.
- TestNoteTranscriptDryRun: dry-run coverage for `note +transcript`; asserts the two-step request shape (`note detail` precheck, then `unified_note_transcript`), transcript query parameters, and that `--transcript-format` coexists with the global `--format` output flag.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| dry-run ✓ / live ✕ | note +detail | shortcut | note_dryrun_test.go::TestNoteDetailDryRun | `--note-id`; user identity | live note fixtures depend on meeting-generated artifacts |
| dry-run ✓ / live ✕ | note +transcript | shortcut | note_dryrun_test.go::TestNoteTranscriptDryRun | `--note-id`; `--transcript-format`; `--format json`; transcript API `format/page_size/locale` params | live unified-note fixtures depend on generated VC note artifacts |

View File

@@ -0,0 +1,97 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package note
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestNoteDetailDryRun(t *testing.T) {
setNoteDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"note", "+detail",
"--note-id", "note_dryrun",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method=%q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/vc/v1/notes/note_dryrun" {
t.Fatalf("url=%q, want note detail endpoint\nstdout:\n%s", got, out)
}
}
func TestNoteTranscriptDryRun(t *testing.T) {
setNoteDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"note", "+transcript",
"--note-id", "note_dryrun",
"--transcript-format", "plain_text",
"--dry-run",
},
DefaultAs: "user",
Format: "json",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.#").Int(); got != 2 {
t.Fatalf("api count=%d, want 2\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("detail method=%q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/vc/v1/notes/note_dryrun" {
t.Fatalf("detail url=%q, want note detail endpoint\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.method").String(); got != "GET" {
t.Fatalf("transcript method=%q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/vc/v1/notes/note_dryrun/unified_note_transcript" {
t.Fatalf("transcript url=%q, want unified transcript endpoint\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.params.format").String(); got != "plain_text" {
t.Fatalf("transcript API format=%q, want plain_text\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.params.page_size").Int(); got != 200 {
t.Fatalf("page_size=%d, want 200\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.params.locale").String(); got != "zh_cn" {
t.Fatalf("locale=%q, want zh_cn\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "transcript_format").String(); got != "plain_text" {
t.Fatalf("transcript_format=%q, want plain_text\nstdout:\n%s", got, out)
}
}
func setNoteDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "note_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "note_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}