mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
5 Commits
feat/vc_ev
...
docs/lark-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6568abcff4 | ||
|
|
f4e7abc33e | ||
|
|
748c9aaa1e | ||
|
|
7193ca575c | ||
|
|
cff1f28316 |
20
CHANGELOG.md
20
CHANGELOG.md
@@ -2,25 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.53] - 2026-06-12
|
||||
|
||||
### Features
|
||||
|
||||
- **auth**: Revoke user tokens server-side on `auth logout` (#1434)
|
||||
- **auth**: Add `--json` flag support to auth subcommands (#1431)
|
||||
- **token**: Mint TAT via unified OAuth v3 Token Endpoint (#1408)
|
||||
- **note**: Split note into a dedicated domain with `+detail` and `+transcript` flows (#1345, #1417, #1435)
|
||||
- **im**: Unify sort flags into `--sort` field and `--order` direction (#1302)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **apps**: Read release error_logs from `data.error_logs` in `+release-get` (#1436)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Optimize whiteboard skill (#1371)
|
||||
- **skills**: Optimize okr skill (#1368)
|
||||
|
||||
## [v1.0.52] - 2026-06-11
|
||||
|
||||
### Features
|
||||
@@ -1149,7 +1130,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
|
||||
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
|
||||
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51
|
||||
[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50
|
||||
|
||||
@@ -91,29 +91,6 @@ func TestAuthCheckCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *CheckOptions
|
||||
cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
@@ -132,27 +109,6 @@ func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *LogoutOptions
|
||||
cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
@@ -170,27 +126,6 @@ func TestAuthListCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *ListOptions
|
||||
cmd := NewCmdAuthList(f, func(opts *ListOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Error("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -210,29 +145,6 @@ func TestAuthStatusCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *StatusOptions
|
||||
cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Error("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -355,32 +267,6 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *ScopesOptions
|
||||
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--format", "pretty", "--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
if gotOpts.Format != "json" {
|
||||
t.Errorf("expected format json, got %s", gotOpts.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
type CheckOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Scope string
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthCheck creates the auth check subcommand.
|
||||
@@ -38,7 +37,6 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmd.MarkFlagRequired("scope")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
// ListOptions holds all inputs for auth list.
|
||||
type ListOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthList creates the auth list subcommand.
|
||||
@@ -35,7 +34,6 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
|
||||
return authListRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
@@ -46,14 +44,6 @@ func authListRun(opts *ListOptions) error {
|
||||
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"users": []map[string]interface{}{},
|
||||
"reason": "not_configured",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
// auth list is a read-only probe; the "configured but no users"
|
||||
// branch below already returns exit 0 with a stderr hint, so we
|
||||
// keep the same contract here. We still want the hint to be
|
||||
@@ -71,14 +61,6 @@ func authListRun(opts *ListOptions) error {
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil || len(app.Users) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"users": []map[string]interface{}{},
|
||||
"reason": "not_logged_in",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -35,33 +34,6 @@ func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
users, ok := payload["users"].([]any)
|
||||
if !ok || len(users) != 0 {
|
||||
t.Errorf("stdout.users = %v, want empty array", payload["users"])
|
||||
}
|
||||
if payload["reason"] != "not_configured" {
|
||||
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
|
||||
// reason this hint exists workspace-aware in the first place: an AI agent
|
||||
// in OpenClaw / Hermes that probes auth list before binding gets routed to
|
||||
@@ -85,48 +57,3 @@ func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T)
|
||||
t.Errorf("agent hint must not mention config init: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_JSONMode_NoLoggedInUsers_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, nil)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
users, ok := payload["users"].([]any)
|
||||
if !ok || len(users) != 0 {
|
||||
t.Errorf("stdout.users = %v, want empty array", payload["users"])
|
||||
}
|
||||
if payload["reason"] != "not_logged_in" {
|
||||
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_DefaultMode_NoLoggedInUsers_KeepsTextOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, nil)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
|
||||
}
|
||||
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "No logged-in users") {
|
||||
t.Errorf("stderr = %q, want no-users hint", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
|
||||
// (not backed by from_meta service specs). Descriptions are now centralized in
|
||||
// service_descriptions.json.
|
||||
func getShortcutOnlyDomainNames() []string {
|
||||
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
|
||||
return []string{"base", "contact", "docs", "markdown", "apps"}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -215,12 +214,6 @@ func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) {
|
||||
if !slices.Contains(getShortcutOnlyDomainNames(), "note") {
|
||||
t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectScopesForDomains(t *testing.T) {
|
||||
projects := registry.ListFromMetaProjects()
|
||||
if len(projects) == 0 {
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
// LogoutOptions holds all inputs for auth logout.
|
||||
type LogoutOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthLogout creates the auth logout subcommand.
|
||||
@@ -35,7 +34,6 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
|
||||
return authLogoutRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
@@ -46,65 +44,25 @@ func authLogoutRun(opts *LogoutOptions) error {
|
||||
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"loggedOut": false,
|
||||
"reason": "not_configured",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil || len(app.Users) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"loggedOut": false,
|
||||
"reason": "not_logged_in",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
|
||||
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)
|
||||
}
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"loggedOut": true,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func writeLogoutConfig(t *testing.T, users []core.AppUser) {
|
||||
t.Helper()
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "test-app",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("test-secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: users,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
if payload["loggedOut"] != false {
|
||||
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
|
||||
}
|
||||
if payload["reason"] != "not_configured" {
|
||||
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_JSONMode_NotLoggedIn_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, nil)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
if payload["loggedOut"] != false {
|
||||
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
|
||||
}
|
||||
if payload["reason"] != "not_logged_in" {
|
||||
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "test-app",
|
||||
UserOpenId: "ou_user",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
if payload["loggedOut"] != true {
|
||||
t.Errorf("stdout.loggedOut = %v, want true", payload["loggedOut"])
|
||||
}
|
||||
if _, hasReason := payload["reason"]; hasReason {
|
||||
t.Errorf("stdout.reason must be absent on success, got %v", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "test-app",
|
||||
UserOpenId: "ou_user",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Logged out") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ type ScopesOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Ctx context.Context
|
||||
Format string
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthScopes creates the auth scopes subcommand.
|
||||
@@ -31,9 +30,6 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
||||
Short: "Query scopes enabled for the app",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Ctx = cmd.Context()
|
||||
if opts.JSON {
|
||||
opts.Format = "json"
|
||||
}
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
@@ -42,7 +38,6 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
type StatusOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Verify bool
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthStatus creates the auth status subcommand.
|
||||
@@ -36,7 +35,6 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -33,16 +33,15 @@ const probeTimeout = 3 * time.Second
|
||||
//
|
||||
// 1. A TAT request using the just-saved credentials. credential.FetchTAT
|
||||
// returns a typed errs.* error (via the shared classifyTATResponseCode)
|
||||
// only when the unified Token Endpoint deterministically rejected the
|
||||
// credentials — an OAuth2 invalid_client / unauthorized_client classified as
|
||||
// CategoryConfig / SubtypeInvalidClient, or whatever codemeta maps. That
|
||||
// typed error is propagated so the root dispatcher renders the canonical
|
||||
// envelope and `config init` exits non-zero — identical to how every other
|
||||
// token-resolving command reports the same bad credentials. Ambiguous
|
||||
// failures (transport errors, transient 5xx/server_error, JSON parse errors,
|
||||
// timeouts) come back as raw untyped errors and are swallowed (return nil),
|
||||
// so valid configurations are never disturbed by upstream noise.
|
||||
// errs.IsTyped is the discriminator.
|
||||
// only when the server deterministically rejected the credentials — a
|
||||
// non-zero TAT body code, classified as CategoryConfig / SubtypeInvalidClient
|
||||
// (10003 / 10014) or whatever codemeta maps. That typed error is propagated
|
||||
// so the root dispatcher renders the canonical envelope and `config init`
|
||||
// exits non-zero — identical to how every other token-resolving command
|
||||
// reports the same bad credentials. Ambiguous failures (transport errors,
|
||||
// HTTP non-200, JSON parse errors, timeouts) come back as raw untyped
|
||||
// errors and are swallowed (return nil), so valid configurations are never
|
||||
// disturbed by upstream noise. errs.IsTyped is the discriminator.
|
||||
//
|
||||
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
|
||||
// that call (success, server error, timeout, parse failure) is always
|
||||
|
||||
@@ -31,10 +31,10 @@ type fakeRT struct {
|
||||
|
||||
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.HasSuffix(req.URL.Path, "/oauth/v3/token"):
|
||||
case strings.HasSuffix(req.URL.Path, "/auth/v3/tenant_access_token/internal"):
|
||||
f.tatCalls++
|
||||
if f.tatHandler == nil {
|
||||
return jsonResp(200, `{"code":0,"access_token":"t-ok","token_type":"Bearer"}`), nil
|
||||
return jsonResp(200, `{"code":0,"tenant_access_token":"t-ok"}`), nil
|
||||
}
|
||||
return f.tatHandler(req)
|
||||
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
|
||||
@@ -84,15 +84,14 @@ func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.B
|
||||
}
|
||||
|
||||
// assertConfigRejection asserts runProbe propagated a deterministic credential
|
||||
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient). This
|
||||
// is the same typed error every other token-resolving command returns for the
|
||||
// same bad credentials, and nothing is written to stderr (the root dispatcher
|
||||
// renders the envelope). The numeric code is not asserted: the unified v3 Token
|
||||
// Endpoint reports invalid_client via the OAuth2 error string, not a Lark code.
|
||||
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient) with
|
||||
// the expected upstream code. This is the same typed error every other
|
||||
// token-resolving command returns for the same bad credentials, and nothing is
|
||||
// written to stderr (the root dispatcher renders the envelope).
|
||||
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCode int) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected *errs.ConfigError, got nil")
|
||||
t.Fatalf("expected *errs.ConfigError (code %d), got nil", wantCode)
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
@@ -104,6 +103,9 @@ func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != wantCode {
|
||||
t.Errorf("Code = %d, want %d", cfgErr.Code, wantCode)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
|
||||
}
|
||||
@@ -121,13 +123,11 @@ func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||
}
|
||||
}
|
||||
|
||||
// invalid_client (bad / non-existent app_id or wrong secret) → the v3 Token
|
||||
// Endpoint returns HTTP 400 with the OAuth2 error → ConfigError/InvalidClient,
|
||||
// propagated. The probe endpoint must not be called when TAT fails.
|
||||
func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
|
||||
// 10003 (bad / non-existent app_id) → ConfigError/InvalidClient, propagated.
|
||||
func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(400, `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`), nil
|
||||
return jsonResp(200, `{"code":10003,"msg":"invalid param"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
@@ -137,27 +137,28 @@ func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
|
||||
if rt.probeCalls != 0 {
|
||||
t.Error("probe endpoint must not be called when TAT fails")
|
||||
}
|
||||
assertConfigRejection(t, err, errBuf)
|
||||
assertConfigRejection(t, err, errBuf, 10003)
|
||||
}
|
||||
|
||||
// unauthorized_client is treated as the same credential rejection, propagated.
|
||||
func TestRunProbe_TATUnauthorizedClient_ReturnsConfigError(t *testing.T) {
|
||||
// 10014 (real app_id + wrong secret) → ConfigError/InvalidClient via codemeta —
|
||||
// the most common real-world rejection, propagated.
|
||||
func TestRunProbe_TATCode10014_ReturnsConfigError(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(401, `{"error":"unauthorized_client","error_description":"client not authorized"}`), nil
|
||||
return jsonResp(200, `{"code":10014,"msg":"app secret invalid"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf, 10014)
|
||||
}
|
||||
|
||||
// Any other deterministic client-side OAuth error (e.g. invalid_scope) falls
|
||||
// back to *errs.APIError via BuildAPIError — still typed, so the probe surfaces
|
||||
// it rather than swallowing — but is not a credential (ConfigError) rejection.
|
||||
func TestRunProbe_TATOtherClientError_Propagates(t *testing.T) {
|
||||
// Any non-zero body code is a deterministic rejection and propagates (typed).
|
||||
// An unrecognized code falls back to *errs.APIError via BuildAPIError — still
|
||||
// typed, so the probe still surfaces it rather than swallowing.
|
||||
func TestRunProbe_TATUnknownBodyCode_Propagates(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(400, `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`), nil
|
||||
return jsonResp(200, `{"code":99999,"msg":"future-unknown"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
|
||||
@@ -47,7 +47,6 @@ type DeviceFlowResult struct {
|
||||
// OAuthEndpoints contains the OAuth endpoint URLs.
|
||||
type OAuthEndpoints struct {
|
||||
DeviceAuthorization string
|
||||
Revoke string
|
||||
Token string
|
||||
}
|
||||
|
||||
@@ -56,7 +55,6 @@ func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints {
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
return OAuthEndpoints{
|
||||
DeviceAuthorization: ep.Accounts + PathDeviceAuthorization,
|
||||
Revoke: ep.Accounts + PathOAuthRevoke,
|
||||
Token: ep.Open + PathOAuthTokenV2,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,6 @@ func TestResolveOAuthEndpoints_Feishu(t *testing.T) {
|
||||
if ep.DeviceAuthorization != "https://accounts.feishu.cn/oauth/v1/device_authorization" {
|
||||
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
|
||||
}
|
||||
if ep.Revoke != "https://accounts.feishu.cn/oauth/v1/revoke" {
|
||||
t.Errorf("Revoke = %q", ep.Revoke)
|
||||
}
|
||||
if ep.Token != "https://open.feishu.cn/open-apis/authen/v2/oauth/token" {
|
||||
t.Errorf("Token = %q", ep.Token)
|
||||
}
|
||||
@@ -45,9 +42,6 @@ func TestResolveOAuthEndpoints_Lark(t *testing.T) {
|
||||
if ep.DeviceAuthorization != "https://accounts.larksuite.com/oauth/v1/device_authorization" {
|
||||
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
|
||||
}
|
||||
if ep.Revoke != "https://accounts.larksuite.com/oauth/v1/revoke" {
|
||||
t.Errorf("Revoke = %q", ep.Revoke)
|
||||
}
|
||||
if ep.Token != "https://open.larksuite.com/open-apis/authen/v2/oauth/token" {
|
||||
t.Errorf("Token = %q", ep.Token)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ package auth
|
||||
const (
|
||||
// PathDeviceAuthorization is the endpoint for device authorization.
|
||||
PathDeviceAuthorization = "/oauth/v1/device_authorization"
|
||||
// PathOAuthRevoke is the endpoint for revoking an OAuth token.
|
||||
PathOAuthRevoke = "/oauth/v1/revoke"
|
||||
// PathAppRegistration is the endpoint for application registration.
|
||||
PathAppRegistration = "/oauth/v1/app/registration"
|
||||
// PathOAuthTokenV2 is the endpoint for requesting an OAuth token (v2).
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// RevokeToken revokes a previously issued OAuth token.
|
||||
func RevokeToken(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, token, tokenTypeHint string) error {
|
||||
endpoints := ResolveOAuthEndpoints(brand)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("client_id", appId)
|
||||
form.Set("client_secret", appSecret)
|
||||
form.Set("token", token)
|
||||
if tokenTypeHint != "" {
|
||||
form.Set("token_type_hint", tokenTypeHint)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, endpoints.Revoke, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "token revoke request creation failed: %v", err).WithCause(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "token revoke transport error: %v", err).WithCause(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
logHTTPResponse(resp)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "token revoke read error: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return revokeHTTPStatusError(resp.StatusCode, body)
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if code := getInt(data, "code", 0); code != 0 {
|
||||
msg := getStr(data, "msg")
|
||||
if msg == "" {
|
||||
msg = getStr(data, "message")
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "unknown error"
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "token revoke failed [%d]: %s", code, msg).
|
||||
WithCode(code).
|
||||
WithCause(errors.New(msg))
|
||||
}
|
||||
|
||||
if errStr := getStr(data, "error"); errStr != "" {
|
||||
msg := getStr(data, "error_description")
|
||||
if msg == "" {
|
||||
msg = errStr
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "token revoke failed: %s", msg).
|
||||
WithCause(errors.New(msg))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func revokeHTTPStatusError(status int, body []byte) error {
|
||||
msg := formatOAuthErrorBody(body)
|
||||
cause := errors.New(strings.TrimSpace(string(body)))
|
||||
if strings.TrimSpace(string(body)) == "" {
|
||||
cause = errors.New(msg)
|
||||
}
|
||||
if status >= http.StatusInternalServerError {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkServer, "token revoke failed: HTTP %d: %s", status, msg).
|
||||
WithCode(status).
|
||||
WithRetryable().
|
||||
WithCause(cause)
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if status == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "token revoke failed: HTTP %d: %s", status, msg).
|
||||
WithCode(status).
|
||||
WithCause(cause)
|
||||
}
|
||||
|
||||
func formatOAuthErrorBody(body []byte) string {
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if trimmed == "" {
|
||||
return "empty response"
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
if msg := getStr(data, "error_description"); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if msg := getStr(data, "msg"); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if msg := getStr(data, "message"); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if msg := getStr(data, "error"); msg != "" {
|
||||
return msg
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
type revokeRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn revokeRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return fn(req)
|
||||
}
|
||||
|
||||
type errReadCloser struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (r errReadCloser) Read(_ []byte) (int, error) {
|
||||
return 0, r.err
|
||||
}
|
||||
|
||||
func (r errReadCloser) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRevokeToken_PostsExpectedForm(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathOAuthRevoke,
|
||||
Body: map[string]interface{}{"code": 0},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
values, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return values.Get("client_id") == "cli_a" &&
|
||||
values.Get("client_secret") == "secret_b" &&
|
||||
values.Get("token") == "user-access-token" &&
|
||||
values.Get("token_type_hint") == "access_token"
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err != nil {
|
||||
t.Fatalf("RevokeToken() error = %v", err)
|
||||
}
|
||||
if got := stub.CapturedHeaders.Get("Content-Type"); got != "application/x-www-form-urlencoded" {
|
||||
t.Fatalf("Content-Type = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_DoFailureReturnsTypedNetworkError(t *testing.T) {
|
||||
sentinel := errors.New("transport down")
|
||||
httpClient := &http.Client{
|
||||
Transport: revokeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, sentinel
|
||||
}),
|
||||
}
|
||||
|
||||
err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("problem = %#v, want network/transport", p)
|
||||
}
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("expected cause %v to be preserved, got %v", sentinel, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_ReportsHTTPError(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathOAuthRevoke,
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{"error": "invalid_token"},
|
||||
})
|
||||
|
||||
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Code != 400 {
|
||||
t.Fatalf("problem = %#v, want api error with HTTP 400", p)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid_token") {
|
||||
t.Fatalf("expected invalid_token error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_ReportsOAuthCodeErrorAsTypedAPIError(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathOAuthRevoke,
|
||||
Body: map[string]interface{}{
|
||||
"code": 12345,
|
||||
"msg": "invalid revoke state",
|
||||
},
|
||||
})
|
||||
|
||||
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Code != 12345 {
|
||||
t.Fatalf("problem = %#v, want api error with code 12345", p)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid revoke state") {
|
||||
t.Fatalf("expected oauth error message, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_ReportsOAuthErrorFieldAsTypedAPIError(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathOAuthRevoke,
|
||||
Body: map[string]interface{}{
|
||||
"error": "invalid_token",
|
||||
"error_description": "token already expired",
|
||||
},
|
||||
})
|
||||
|
||||
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Fatalf("problem = %#v, want api error", p)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "token already expired") {
|
||||
t.Fatalf("expected oauth error_description, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_ReadFailureReturnsTypedInternalError(t *testing.T) {
|
||||
sentinel := errors.New("read failed")
|
||||
httpClient := &http.Client{
|
||||
Transport: revokeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: errReadCloser{err: sentinel},
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("problem = %#v, want internal/invalid_response", p)
|
||||
}
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("expected cause %v to be preserved, got %v", sentinel, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "token revoke read error") {
|
||||
t.Fatalf("expected read error message, got %v", err)
|
||||
}
|
||||
if _, ok := err.(*errs.InternalError); !ok {
|
||||
t.Fatalf("expected *errs.InternalError, got %T", err)
|
||||
}
|
||||
}
|
||||
@@ -22,12 +22,6 @@ func ParseBrand(value string) LarkBrand {
|
||||
return BrandFeishu
|
||||
}
|
||||
|
||||
// OAuthTokenV3Path is the unified OAuth 2.0 Token Endpoint path on the accounts
|
||||
// domain. It serves every grant type (client_credentials for TAT,
|
||||
// authorization_code / device_code / refresh_token for UAT) and replaces the
|
||||
// legacy per-token endpoints (e.g. /open-apis/auth/v3/tenant_access_token/internal).
|
||||
const OAuthTokenV3Path = "/oauth/v3/token"
|
||||
|
||||
// Endpoints holds resolved endpoint URLs for different Lark services.
|
||||
type Endpoints struct {
|
||||
Open string // e.g. "https://open.feishu.cn"
|
||||
|
||||
@@ -42,11 +42,6 @@ func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) {
|
||||
if ep.Open != "https://open.feishu.cn" {
|
||||
t.Errorf("Open = %q, want feishu.cn for empty brand", ep.Open)
|
||||
}
|
||||
// The unified OAuth v3 Token Endpoint mints TAT on the accounts domain;
|
||||
// pin the default-brand host so a stray non-production domain revert is caught.
|
||||
if ep.Accounts != "https://accounts.feishu.cn" {
|
||||
t.Errorf("Accounts = %q, want accounts.feishu.cn for empty brand", ep.Accounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenBaseURL(t *testing.T) {
|
||||
|
||||
@@ -19,44 +19,33 @@ import (
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
)
|
||||
|
||||
// classifyTATResponseCode wraps a deterministic (non-transient) failure from the
|
||||
// unified Token Endpoint into the canonical typed errs.* error. The v3 endpoint
|
||||
// reports failures using the OAuth 2.0 model — an `error` string plus an
|
||||
// optional numeric `code` — instead of the legacy `{code, msg}` shape.
|
||||
// classifyTATResponseCode wraps a non-zero TAT endpoint response code into the
|
||||
// canonical typed error. The TAT mint endpoint reports invalid credentials
|
||||
// with two distinct codes:
|
||||
//
|
||||
// invalid_client / unauthorized_client mean the configured app_id/app_secret
|
||||
// cannot mint a token; from the user's perspective that is the same actionable
|
||||
// CategoryConfig/InvalidClient failure the legacy 10003/10014 codes produced.
|
||||
// Every other deterministic error falls through to BuildAPIError, which still
|
||||
// yields a typed error so probe callers (errs.IsTyped) surface it rather than
|
||||
// swallowing it. Transient/server-side failures (5xx / server_error) are
|
||||
// filtered out by FetchTAT before this is called, so they stay untyped.
|
||||
func classifyTATResponseCode(code int, oauthErr, errDesc, brand, appID string) error {
|
||||
msg := errDesc
|
||||
if msg == "" {
|
||||
msg = oauthErr
|
||||
}
|
||||
switch oauthErr {
|
||||
case "invalid_client", "unauthorized_client":
|
||||
// - 10003: bad app_id format or non-existent app_id ("invalid param")
|
||||
// - 10014: invalid app_secret ("app secret invalid")
|
||||
//
|
||||
// Both surface as CategoryConfig/InvalidClient from the user's perspective —
|
||||
// the configured credentials cannot mint a tenant access token. 10014 is
|
||||
// globally mapped in codemeta (TAT-mint-specific variant of OAuth 99991543).
|
||||
// 10003 is NOT globally mapped because in other Lark endpoints it carries
|
||||
// unrelated semantics (e.g. task API uses 10003 for permission denied), so
|
||||
// the override stays local to this TAT call site instead of leaking into the
|
||||
// shared codemeta table.
|
||||
func classifyTATResponseCode(code int, msg, brand, appID string) error {
|
||||
if code == 10003 {
|
||||
return errs.NewConfigError(errs.SubtypeInvalidClient, "%s", msg).
|
||||
WithCode(code).
|
||||
WithHint("%s", errclass.ConfigHint(errs.SubtypeInvalidClient))
|
||||
}
|
||||
if err := errclass.BuildAPIError(map[string]any{
|
||||
return errclass.BuildAPIError(map[string]any{
|
||||
"code": code,
|
||||
"msg": msg,
|
||||
}, errclass.ClassifyContext{
|
||||
Brand: brand,
|
||||
AppID: appID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
// BuildAPIError returns nil for code 0 (Feishu's success convention), but this
|
||||
// function is only reached once FetchTAT has ruled out success — a non-credential
|
||||
// OAuth error (e.g. invalid_scope) can arrive with code 0 and is still a
|
||||
// deterministic rejection. Back it with a typed APIError so callers never receive
|
||||
// the ("", nil) "empty token, no error" pair.
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "%s", msg).WithCode(code)
|
||||
})
|
||||
}
|
||||
|
||||
// DefaultAccountProvider resolves account from config.json via keychain.
|
||||
@@ -157,8 +146,8 @@ func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, er
|
||||
return &TokenResult{Token: token, Scopes: scopes}, nil
|
||||
}
|
||||
|
||||
// resolveTAT resolves a tenant access token. The result is cached after the first
|
||||
// call via sync.Once — only the context from the first call is used.
|
||||
// resolveTAT resolves a tenant access token. Result is cached after first call.
|
||||
// NOTE: Uses sync.Once — only the context from the first call is used.
|
||||
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) {
|
||||
p.tatOnce.Do(func() {
|
||||
p.tatResult, p.tatErr = p.doResolveTAT(ctx)
|
||||
|
||||
@@ -19,16 +19,18 @@ func TestDefaultAccountProvider_Implements(t *testing.T) {
|
||||
var _ DefaultAccountResolver = &DefaultAccountProvider{}
|
||||
}
|
||||
|
||||
// TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient pins that the
|
||||
// unified Token Endpoint's OAuth2 invalid_client error surfaces as
|
||||
// CategoryConfig/InvalidClient — the configured app_id/app_secret cannot mint a
|
||||
// tenant access token, the same actionable failure the legacy 10003/10014 codes
|
||||
// produced. The numeric code is intentionally not asserted: the v3 endpoint may
|
||||
// return invalid_client with no Lark code (code defaults to 0).
|
||||
func TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient(t *testing.T) {
|
||||
err := classifyTATResponseCode(0, "invalid_client", "client authentication failed", "feishu", "cli_app_x")
|
||||
// TestClassifyTATResponseCode_10003_MapsToInvalidClient pins that the TAT
|
||||
// endpoint's "invalid param" code surfaces as CategoryConfig/InvalidClient.
|
||||
// Reason: a bad or non-existent app_id triggers 10003 on the TAT mint endpoint,
|
||||
// which from the user's perspective is the same actionable failure as 10014
|
||||
// ("app secret invalid") — both mean the configured credentials cannot mint a
|
||||
// tenant access token. The global codemeta intentionally does not map 10003
|
||||
// because in other Lark endpoints 10003 carries unrelated semantics (e.g. task
|
||||
// API uses it for permission denied), so the override is local to this site.
|
||||
func TestClassifyTATResponseCode_10003_MapsToInvalidClient(t *testing.T) {
|
||||
err := classifyTATResponseCode(10003, "invalid param", "feishu", "cli_app_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for invalid_client")
|
||||
t.Fatal("expected non-nil error for code=10003")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
@@ -40,16 +42,22 @@ func TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient(t *testing.T)
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != 10003 {
|
||||
t.Errorf("Code = %d, want 10003", cfgErr.Code)
|
||||
}
|
||||
if cfgErr.Hint == "" {
|
||||
t.Error("Hint must be non-empty so the user gets a recovery action")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient pins that
|
||||
// unauthorized_client is treated as the same credential failure as
|
||||
// invalid_client.
|
||||
func TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient(t *testing.T) {
|
||||
err := classifyTATResponseCode(0, "unauthorized_client", "client not authorized", "feishu", "cli_app_x")
|
||||
// TestClassifyTATResponseCode_10014_RoutesViaCodeMeta pins that 10014 still
|
||||
// goes through the global BuildAPIError path (codemeta entry) so the override
|
||||
// for 10003 does not regress the existing mapping.
|
||||
func TestClassifyTATResponseCode_10014_RoutesViaCodeMeta(t *testing.T) {
|
||||
err := classifyTATResponseCode(10014, "app secret invalid", "feishu", "cli_app_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for code=10014")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
|
||||
@@ -57,38 +65,21 @@ func TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient(t *testi
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyTATResponseCode_OtherErrorFallsThrough pins that OAuth errors
|
||||
// outside the credential set fall through to the generic BuildAPIError fallback
|
||||
// — still typed, but not a ConfigError. The mapping is narrow and intentional.
|
||||
func TestClassifyTATResponseCode_OtherErrorFallsThrough(t *testing.T) {
|
||||
err := classifyTATResponseCode(20068, "invalid_scope", "unauthorized scope", "feishu", "cli_app_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for invalid_scope")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
t.Fatalf("invalid_scope must not be classified as ConfigError, got %T", err)
|
||||
if cfgErr.Code != 10014 {
|
||||
t.Errorf("Code = %d, want 10014", cfgErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped pins the code-0
|
||||
// backstop: a non-credential OAuth error (e.g. invalid_scope) that arrives with no
|
||||
// numeric code (code 0) must still produce a non-nil typed error. BuildAPIError
|
||||
// returns nil for code 0 (Feishu's success convention); without the backstop,
|
||||
// FetchTAT would surface this deterministic rejection as ("", nil) — an empty token
|
||||
// with no error.
|
||||
func TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped(t *testing.T) {
|
||||
err := classifyTATResponseCode(0, "invalid_scope", "the requested scope is not granted", "feishu", "cli_app_x")
|
||||
// TestClassifyTATResponseCode_UnknownCodeFallsThrough pins that codes outside
|
||||
// the credential set fall through to the generic BuildAPIError fallback
|
||||
// (CategoryAPI/SubtypeUnknown) — the override is narrow and intentional.
|
||||
func TestClassifyTATResponseCode_UnknownCodeFallsThrough(t *testing.T) {
|
||||
err := classifyTATResponseCode(99999999, "some unknown failure", "feishu", "cli_app_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for code-0 invalid_scope (must not be swallowed as success)")
|
||||
}
|
||||
if !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
t.Fatal("expected non-nil error for unmapped code")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
t.Fatalf("code-0 invalid_scope must not be a ConfigError, got %T", err)
|
||||
t.Fatalf("unmapped code must not be classified as ConfigError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,47 +4,46 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// FetchTAT performs a single HTTP POST to mint a tenant access token via the
|
||||
// unified OAuth 2.0 Token Endpoint ({accounts}/oauth/v3/token) using the
|
||||
// client_credentials grant with client_secret_post authentication. It does not
|
||||
// read configuration or keychain, so callers that already hold plaintext
|
||||
// credentials (e.g. the post-`config init` probe) can validate them without a
|
||||
// second keychain round-trip.
|
||||
// FetchTAT performs a single HTTP POST to mint a tenant access token with the
|
||||
// given credentials. It does not read configuration or keychain, so callers
|
||||
// that already hold plaintext credentials (e.g. the post-`config init` probe)
|
||||
// can validate them without a second keychain round-trip.
|
||||
//
|
||||
// A deterministic client-side rejection (e.g. invalid_client) returns the
|
||||
// canonical typed error from classifyTATResponseCode — the SAME classification
|
||||
// doResolveTAT (and thus every token-resolving command) produces, so callers
|
||||
// see one consistent envelope. Transport failures, unreadable/unparseable
|
||||
// bodies, and transient server-side failures (5xx / server_error) are returned
|
||||
// raw (untyped), leaving them ambiguous; a caller can use errs.IsTyped to tell a
|
||||
// deterministic credential rejection apart from upstream/transport noise.
|
||||
// A non-zero TAT response code means the server inspected the payload and
|
||||
// rejected the credentials; FetchTAT returns the canonical typed error from
|
||||
// classifyTATResponseCode — the SAME classification doResolveTAT (and thus
|
||||
// every token-resolving command) produces, so callers see one consistent
|
||||
// envelope (CategoryConfig / SubtypeInvalidClient for 10003 / 10014, etc.).
|
||||
// Transport, HTTP-status and JSON-parse failures are returned raw (untyped),
|
||||
// leaving them ambiguous; a caller can use errs.IsTyped to tell a deterministic
|
||||
// credential rejection apart from upstream/transport noise.
|
||||
//
|
||||
// The caller owns the context timeout.
|
||||
func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID, appSecret string) (string, error) {
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
endpoint := ep.Accounts + core.OAuthTokenV3Path
|
||||
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "client_credentials")
|
||||
form.Set("client_id", appID)
|
||||
form.Set("client_secret", appSecret)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"app_id": appID,
|
||||
"app_secret": appSecret,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal TAT request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -52,51 +51,20 @@ func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read TAT response: %w", err)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
AccessToken string `json:"access_token"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
Msg string `json:"msg"`
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
TenantAccessToken string `json:"tenant_access_token"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
// An unparseable body is ambiguous (covers non-JSON error pages and
|
||||
// truncated payloads); stay untyped so probe callers treat it as noise.
|
||||
return "", fmt.Errorf("failed to parse TAT response (HTTP %d): %w", resp.StatusCode, err)
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse TAT response: %w", err)
|
||||
}
|
||||
|
||||
if result.Code == 0 && result.AccessToken != "" {
|
||||
return result.AccessToken, nil
|
||||
if result.Code != 0 {
|
||||
return "", classifyTATResponseCode(result.Code, result.Msg, string(brand), appID)
|
||||
}
|
||||
|
||||
// Transient/server-side failures stay untyped so probe callers stay silent and
|
||||
// retryers can back off; only deterministic client rejections are typed. Covers
|
||||
// 5xx, HTTP 429 rate-limit, and the OAuth transient error strings (server_error,
|
||||
// temporarily_unavailable, slow_down) — matching the legacy "non-2xx is noise"
|
||||
// behavior so a rate-limited probe is not surfaced as a hard credential error.
|
||||
if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests ||
|
||||
result.Error == "server_error" || result.Error == "temporarily_unavailable" ||
|
||||
result.Error == "slow_down" {
|
||||
return "", fmt.Errorf("TAT endpoint transient failure (HTTP %d, code=%d, error=%q): %s",
|
||||
resp.StatusCode, result.Code, result.Error, result.ErrorDescription)
|
||||
}
|
||||
|
||||
// A 2xx with neither token nor error is a malformed success — ambiguous, untyped.
|
||||
if result.Code == 0 && result.Error == "" {
|
||||
return "", fmt.Errorf("TAT response missing access_token (HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Prefer the OAuth error_description; fall back to the legacy Lark `msg` so a
|
||||
// gateway-level {code, msg} response (carrying no OAuth fields) still yields a
|
||||
// non-empty typed message instead of a bare "API error: [code]".
|
||||
desc := result.ErrorDescription
|
||||
if desc == "" {
|
||||
desc = result.Msg
|
||||
}
|
||||
return "", classifyTATResponseCode(result.Code, result.Error, desc, string(brand), appID)
|
||||
return result.TenantAccessToken, nil
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error)
|
||||
func TestFetchTAT_Success(t *testing.T) {
|
||||
rt := &stubRoundTripper{
|
||||
respCode: 200,
|
||||
respBody: `{"code":0,"access_token":"t-abc","token_type":"Bearer","expires_in":7200}`,
|
||||
respBody: `{"code":0,"tenant_access_token":"t-abc","msg":"ok"}`,
|
||||
}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
@@ -55,33 +55,24 @@ func TestFetchTAT_Success(t *testing.T) {
|
||||
if token != "t-abc" {
|
||||
t.Errorf("token = %q, want t-abc", token)
|
||||
}
|
||||
if rt.gotReq.URL.String() != "https://accounts.feishu.cn/oauth/v3/token" {
|
||||
if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" {
|
||||
t.Errorf("url = %s", rt.gotReq.URL.String())
|
||||
}
|
||||
if ct := rt.gotReq.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" {
|
||||
t.Errorf("Content-Type = %q, want application/x-www-form-urlencoded", ct)
|
||||
}
|
||||
// client_secret_post: grant_type + client_id + client_secret in the form body.
|
||||
for _, want := range []string{"grant_type=client_credentials", "client_id=cli_app", "client_secret=secret_x"} {
|
||||
if !strings.Contains(rt.gotBody, want) {
|
||||
t.Errorf("request body missing %q: %s", want, rt.gotBody)
|
||||
}
|
||||
if !strings.Contains(rt.gotBody, `"app_id":"cli_app"`) || !strings.Contains(rt.gotBody, `"app_secret":"secret_x"`) {
|
||||
t.Errorf("request body missing credentials: %s", rt.gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
// invalid_client (wrong app_id/app_secret on the client_credentials grant) is a
|
||||
// deterministic client-side rejection that FetchTAT routes to
|
||||
// 10003 (bad / non-existent app_id, "invalid param") is classified locally by
|
||||
// classifyTATResponseCode as CategoryConfig / SubtypeInvalidClient — the same
|
||||
// typed error doResolveTAT (and thus every token-resolving command) returns.
|
||||
// The v3 endpoint reports it as HTTP 400 with the OAuth2 error body (wrong
|
||||
// secret → code 20002, unknown app → code 20048).
|
||||
func TestFetchTAT_InvalidClient_ConfigInvalidClient(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 400, respBody: `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`}
|
||||
func TestFetchTAT_Code10003_ConfigInvalidClient(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10003,"msg":"invalid param"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid_client")
|
||||
t.Fatal("expected error for code 10003")
|
||||
}
|
||||
if token != "" {
|
||||
t.Errorf("token = %q, want empty", token)
|
||||
@@ -96,115 +87,52 @@ func TestFetchTAT_InvalidClient_ConfigInvalidClient(t *testing.T) {
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != 10003 {
|
||||
t.Errorf("Code = %d, want 10003", cfgErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Any other deterministic client-side OAuth error (e.g. invalid_scope) still
|
||||
// yields a typed error (errs.IsTyped) via BuildAPIError — so a probe caller
|
||||
// surfaces it rather than silently swallowing it — but is NOT classified as a
|
||||
// credential (invalid_client) problem.
|
||||
func TestFetchTAT_OtherClientError_Typed(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 400, respBody: `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`}
|
||||
// 10014 ("app secret invalid") — the most common real-world rejection (real
|
||||
// app_id + wrong secret) — is globally mapped in codemeta to
|
||||
// CategoryConfig / SubtypeInvalidClient via BuildAPIError.
|
||||
func TestFetchTAT_Code10014_ConfigInvalidClient(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10014,"msg":"app secret invalid"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid_scope")
|
||||
}
|
||||
if !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
t.Errorf("invalid_scope must not be classified as ConfigError/InvalidClient, got %T", err)
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error not *errs.ConfigError: %T %v", err, err)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient || cfgErr.Code != 10014 {
|
||||
t.Errorf("got Subtype=%q Code=%d, want invalid_client/10014", cfgErr.Subtype, cfgErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A deterministic OAuth error that arrives WITHOUT a numeric code (code defaults to
|
||||
// 0) must still surface as a non-nil typed error — never the ("", nil) success pair.
|
||||
// Guards the code-0 backstop in classifyTATResponseCode: BuildAPIError returns nil
|
||||
// for code 0, which would otherwise swallow this rejection into an empty-token success.
|
||||
func TestFetchTAT_OtherClientError_CodeZero_Typed(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 400, respBody: `{"error":"invalid_scope","error_description":"the requested scope is not granted"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
tok, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for code-0 invalid_scope (must not return empty token + nil error)")
|
||||
}
|
||||
if tok != "" {
|
||||
t.Errorf("token = %q, want empty", tok)
|
||||
}
|
||||
if !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A gateway-style {code, msg} error (no OAuth error / error_description fields)
|
||||
// must still surface its msg on the typed error, not degrade to a generic
|
||||
// "API error: [code]". Guards the legacy-msg fallback in FetchTAT.
|
||||
func TestFetchTAT_LarkStyleMsg_FallsBackOnTypedError(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 400, respBody: `{"code":99999,"msg":"app ticket invalid"}`}
|
||||
// Any non-zero body code is a deterministic server-side rejection, so it
|
||||
// always yields a typed error (errs.IsTyped). An unrecognized code falls back
|
||||
// to CategoryAPI / SubtypeUnknown via BuildAPIError — still typed, so a probe
|
||||
// caller still surfaces it rather than silently swallowing.
|
||||
func TestFetchTAT_UnknownBodyCode_Typed(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":99999,"msg":"future-unknown"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for {code, msg} response")
|
||||
t.Fatal("expected error for code 99999")
|
||||
}
|
||||
if !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "app ticket invalid") {
|
||||
t.Errorf("typed error must carry the Lark msg, got: %v", err)
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Errorf("unknown code should fall back to *errs.APIError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Transient server-side failures (5xx / server_error) are NOT deterministic
|
||||
// credential rejections — they must stay UNTYPED so a probe caller treats them
|
||||
// as upstream noise and stays silent (and retryers can back off).
|
||||
func TestFetchTAT_ServerError_Untyped(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 500, respBody: `{"code":20050,"error":"server_error","error_description":"please retry"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for server_error")
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("server_error must be UNTYPED (transient), got typed %T %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Rate-limiting is transient, not a deterministic credential rejection — an HTTP
|
||||
// 429 (even with a parseable OAuth body) and the OAuth slow_down error must both
|
||||
// stay UNTYPED so a rate-limited probe stays silent and retryers can back off.
|
||||
func TestFetchTAT_RateLimit_Untyped(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
code int
|
||||
body string
|
||||
}{
|
||||
{"http 429", 429, `{"code":99991400,"error":"too_many_requests","error_description":"rate limit exceeded"}`},
|
||||
{"oauth slow_down", 200, `{"error":"slow_down","error_description":"polling too fast"}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: tc.code, respBody: tc.body}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for rate-limit")
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("rate-limit must be UNTYPED (transient), got typed %T %v", err, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Non-2xx HTTP with a non-JSON body is ambiguous (not a structured OAuth
|
||||
// rejection) — it must stay UNTYPED so a probe caller treats it as upstream
|
||||
// noise and stays silent.
|
||||
// Non-2xx HTTP is ambiguous (not a payload-level credential rejection) — it
|
||||
// must stay UNTYPED so a probe caller treats it as upstream noise and stays
|
||||
// silent.
|
||||
func TestFetchTAT_HTTPNon200_Untyped(t *testing.T) {
|
||||
for _, code := range []int{401, 403, 500, 503} {
|
||||
rt := &stubRoundTripper{respCode: code, respBody: `whatever`}
|
||||
@@ -254,12 +182,12 @@ func TestFetchTAT_BrandRouting(t *testing.T) {
|
||||
brand core.LarkBrand
|
||||
wantURL string
|
||||
}{
|
||||
{core.BrandFeishu, "https://accounts.feishu.cn/oauth/v3/token"},
|
||||
{core.BrandLark, "https://accounts.larksuite.com/oauth/v3/token"},
|
||||
{core.BrandFeishu, "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"},
|
||||
{core.BrandLark, "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(string(tc.brand), func(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"access_token":"t","token_type":"Bearer"}`}
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"tenant_access_token":"t"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
if _, err := FetchTAT(context.Background(), hc, tc.brand, "a", "b"); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -65,7 +65,7 @@ var codeMeta = map[int]CodeMeta{
|
||||
|
||||
// CategoryConfig
|
||||
99991543: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // RFC 6749 §5.2 — app_id / app_secret incorrect (Open API)
|
||||
10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // legacy TAT endpoint — "app secret invalid" (pre-v3 variant of 99991543; CLI now reports invalid_client)
|
||||
10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // TAT endpoint — "app secret invalid" (TAT-mint variant of 99991543)
|
||||
|
||||
// CategoryPolicy
|
||||
21000: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeChallengeRequired},
|
||||
|
||||
@@ -35,12 +35,9 @@ const (
|
||||
LarkErrAppNotInUse = 99991662 // app is disabled in this tenant
|
||||
LarkErrAppUnauthorized = 99991673 // app status unavailable; check installation
|
||||
|
||||
// "Wrong app credentials" code from the LEGACY TAT endpoint
|
||||
// (/open-apis/auth/v3/tenant_access_token/internal returns 10014, "app secret
|
||||
// invalid", instead of 99991543). Since the OAuth v3 migration the CLI mints
|
||||
// TAT via accounts/oauth/v3/token and reports this as the OAuth invalid_client
|
||||
// error, so it no longer emits 10014 itself; the constant + codemeta mapping
|
||||
// are retained as a defensive fallback should 10014 still arrive.
|
||||
// TAT-endpoint variant of the "wrong app credentials" condition.
|
||||
// /open-apis/auth/v3/tenant_access_token/internal returns code 10014
|
||||
// ("app secret invalid") instead of 99991543 when the secret is wrong.
|
||||
LarkErrTATInvalidSecret = 10014
|
||||
|
||||
// Rate limit.
|
||||
|
||||
@@ -47,10 +47,6 @@
|
||||
"en": { "title": "Minutes", "description": "Minutes content and metadata retrieval" },
|
||||
"zh": { "title": "妙记", "description": "妙记信息获取、内容查询" }
|
||||
},
|
||||
"note": {
|
||||
"en": { "title": "Note", "description": "Meeting note detail and unified transcript retrieval" },
|
||||
"zh": { "title": "会议纪要", "description": "会议纪要详情与 unified 逐字稿查询" }
|
||||
},
|
||||
"sheets": {
|
||||
"en": { "title": "Sheets", "description": "Spreadsheet operations" },
|
||||
"zh": { "title": "电子表格", "description": "电子表格操作" }
|
||||
|
||||
@@ -25,11 +25,9 @@ var migratedCommonHelperPaths = []string{
|
||||
"shortcuts/doc/",
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/event/",
|
||||
"shortcuts/im/",
|
||||
"shortcuts/mail/",
|
||||
"shortcuts/markdown/",
|
||||
"shortcuts/minutes/",
|
||||
"shortcuts/note/",
|
||||
"shortcuts/okr/",
|
||||
"shortcuts/sheets/",
|
||||
"shortcuts/slides/",
|
||||
|
||||
@@ -26,11 +26,9 @@ var migratedEnvelopePaths = []string{
|
||||
"shortcuts/doc/",
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/event/",
|
||||
"shortcuts/im/",
|
||||
"shortcuts/mail/",
|
||||
"shortcuts/markdown/",
|
||||
"shortcuts/minutes/",
|
||||
"shortcuts/note/",
|
||||
"shortcuts/okr/",
|
||||
"shortcuts/sheets/",
|
||||
"shortcuts/slides/",
|
||||
@@ -38,6 +36,7 @@ var migratedEnvelopePaths = []string{
|
||||
"shortcuts/vc/",
|
||||
"shortcuts/whiteboard/",
|
||||
"shortcuts/wiki/",
|
||||
"shortcuts/im/",
|
||||
}
|
||||
|
||||
// legacyOutputImportPath is the import path of the package that declares the
|
||||
|
||||
@@ -953,7 +953,6 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
|
||||
paths := []string{
|
||||
"shortcuts/doc/docs_fetch_v2.go",
|
||||
"shortcuts/drive/drive_search.go",
|
||||
"shortcuts/im/im_messages_send.go",
|
||||
"shortcuts/mail/mail_send.go",
|
||||
"shortcuts/markdown/markdown_fetch.go",
|
||||
"shortcuts/okr/okr_progress_create.go",
|
||||
@@ -989,18 +988,6 @@ common.` + helper + `()
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigratedCommonHelperPaths_CoverMigratedEnvelopePaths(t *testing.T) {
|
||||
commonPaths := make(map[string]struct{}, len(migratedCommonHelperPaths))
|
||||
for _, path := range migratedCommonHelperPaths {
|
||||
commonPaths[path] = struct{}{}
|
||||
}
|
||||
for _, path := range migratedEnvelopePaths {
|
||||
if _, ok := commonPaths[path]; !ok {
|
||||
t.Fatalf("migratedEnvelopePaths contains %q but migratedCommonHelperPaths does not", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_RejectsDangerousCharsOnCalendarPath(t *testing.T) {
|
||||
src := `package calendar
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.53",
|
||||
"version": "1.0.52",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -57,9 +57,6 @@ var AppsReleaseGet = common.Shortcut{
|
||||
out := data
|
||||
if release, ok := data["release"].(map[string]interface{}); ok {
|
||||
out = release
|
||||
if el, ok := data["error_logs"]; ok {
|
||||
out["error_logs"] = el
|
||||
}
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "release_id: %v\nstatus: %v\ncreated_at: %v\nupdated_at: %v\n",
|
||||
|
||||
@@ -134,15 +134,13 @@ func TestAppsReleaseGetPrettyFailedErrorLogs(t *testing.T) {
|
||||
URL: "/open-apis/spark/v1/apps/app_x/releases/6",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{
|
||||
"release": map[string]interface{}{
|
||||
"release_id": "6", "status": "failed",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000050",
|
||||
},
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "6", "status": "failed",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000050",
|
||||
"error_logs": []interface{}{
|
||||
map[string]interface{}{"step": "build", "error_log": "compile error"},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
@@ -202,13 +200,11 @@ func TestAppsReleaseGetPrettyFailedEmptyLogs(t *testing.T) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/9",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{
|
||||
"release": map[string]interface{}{
|
||||
"release_id": "9", "status": "failed",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000050",
|
||||
},
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "9", "status": "failed",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000050",
|
||||
"error_logs": []interface{}{},
|
||||
}},
|
||||
}}},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
@@ -218,69 +214,6 @@ func TestAppsReleaseGetPrettyFailedEmptyLogs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetJSONErrorLogsPassthrough(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "6")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/6",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{
|
||||
"release": map[string]interface{}{
|
||||
"release_id": "6", "status": "failed",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000050",
|
||||
},
|
||||
"error_logs": []interface{}{
|
||||
map[string]interface{}{"step": "build", "error_log": "compile error"},
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v\nraw: %s", err, stdoutBuf.String())
|
||||
}
|
||||
logs, ok := env.Data["error_logs"].([]interface{})
|
||||
if !ok || len(logs) != 1 {
|
||||
t.Fatalf("JSON must passthrough data.error_logs, got: %v", env.Data["error_logs"])
|
||||
}
|
||||
first, _ := logs[0].(map[string]interface{})
|
||||
if first["step"] != "build" || first["error_log"] != "compile error" {
|
||||
t.Errorf("error_logs content mismatch: %v", logs[0])
|
||||
}
|
||||
// flattened release fields must still be present alongside error_logs
|
||||
if env.Data["release_id"] != "6" || env.Data["status"] != "failed" {
|
||||
t.Errorf("flattened release fields missing: %v", env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetJSONNoErrorLogsKeyWhenAbsent(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "5")
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/5",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "",
|
||||
"data": map[string]interface{}{"release": map[string]interface{}{
|
||||
"release_id": "5", "status": "finished",
|
||||
"created_at": "1700000000000", "updated_at": "1700000000001",
|
||||
}}},
|
||||
})
|
||||
if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
var env struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v\nraw: %s", err, stdoutBuf.String())
|
||||
}
|
||||
if _, present := env.Data["error_logs"]; present {
|
||||
t.Errorf("error_logs key must be absent when API omits it, got: %v", env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsReleaseGetPrettyCommitID(t *testing.T) {
|
||||
rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "10")
|
||||
rctx.Format = "pretty"
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// calendar +meeting — get meeting info for calendar events via mget_instance_relation_info
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const meetingLogPrefix = "[calendar +meeting]"
|
||||
|
||||
var scopesCalendarMeeting = []string{
|
||||
"calendar:calendar:read",
|
||||
"calendar:calendar.event:read",
|
||||
"vc:meeting.meetingevent:read",
|
||||
}
|
||||
|
||||
// mgetInstanceRelationRequestBody is the request body for mget_instance_relation_info API.
|
||||
type mgetInstanceRelationRequestBody struct {
|
||||
InstanceIDs []string `json:"instance_ids"`
|
||||
NeedMeetingInstanceIDs bool `json:"need_meeting_instance_ids"`
|
||||
NeedMeetingNotes bool `json:"need_meeting_notes"`
|
||||
NeedAIMeetingNotes bool `json:"need_ai_meeting_notes"`
|
||||
}
|
||||
|
||||
// meetingInfoItem represents a single event's meeting info in the output.
|
||||
type meetingInfoItem struct {
|
||||
EventID string `json:"event_id"`
|
||||
MeetingID string `json:"meeting_id,omitempty"`
|
||||
MeetingNote string `json:"meeting_note,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
// translateFailMsg converts API fail_msg to a user-friendly error message.
|
||||
func translateFailMsg(failMsg string) string {
|
||||
switch failMsg {
|
||||
case "No Permission":
|
||||
return "no read permission for this calendar event (not a participant of the event)"
|
||||
case "Not Found":
|
||||
return "event not found on the specified calendar (event ID may be incorrect or does not belong to this calendar)"
|
||||
default:
|
||||
return failMsg
|
||||
}
|
||||
}
|
||||
|
||||
// fetchEventMeetingInfo queries mget_instance_relation_info for a single event instance.
|
||||
func fetchEventMeetingInfo(ctx context.Context, runtime *common.RuntimeContext, instanceID, calendarID string) *meetingInfoItem {
|
||||
body := &mgetInstanceRelationRequestBody{
|
||||
InstanceIDs: []string{instanceID},
|
||||
NeedMeetingInstanceIDs: true,
|
||||
NeedMeetingNotes: true,
|
||||
NeedAIMeetingNotes: true,
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("POST",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", validate.EncodePathSegment(calendarID)),
|
||||
nil, body)
|
||||
if err != nil {
|
||||
return &meetingInfoItem{EventID: instanceID, Error: err.Error()}
|
||||
}
|
||||
|
||||
// Check for failed instance IDs first
|
||||
if failedIDs, _ := data["failed_instance_ids"].([]any); len(failedIDs) > 0 {
|
||||
for _, raw := range failedIDs {
|
||||
if failInfo, ok := raw.(map[string]any); ok {
|
||||
if failID, _ := failInfo["instance_id"].(string); failID == instanceID {
|
||||
failMsg, _ := failInfo["fail_msg"].(string)
|
||||
return &meetingInfoItem{EventID: instanceID, Error: translateFailMsg(failMsg)}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
infos, _ := data["instance_relation_infos"].([]any)
|
||||
if len(infos) == 0 {
|
||||
return &meetingInfoItem{EventID: instanceID, Error: "no event relation info found"}
|
||||
}
|
||||
|
||||
info, _ := infos[0].(map[string]any)
|
||||
result := &meetingInfoItem{EventID: instanceID}
|
||||
|
||||
// Extract meeting_id (return first if multiple) — API returns string
|
||||
if rawIDs, _ := info["meeting_instance_ids"].([]any); len(rawIDs) > 0 {
|
||||
if id, ok := rawIDs[0].(string); ok && id != "" {
|
||||
result.MeetingID = id
|
||||
}
|
||||
}
|
||||
|
||||
// Extract meeting_note (return first if multiple)
|
||||
if notes, _ := info["meeting_notes"].([]any); len(notes) > 0 {
|
||||
if note, ok := notes[0].(string); ok && note != "" {
|
||||
result.MeetingNote = note
|
||||
}
|
||||
}
|
||||
|
||||
// Add hints for empty resources (independent checks)
|
||||
var emptyFields []string
|
||||
if result.MeetingID == "" {
|
||||
emptyFields = append(emptyFields, "meeting_id")
|
||||
}
|
||||
if result.MeetingNote == "" {
|
||||
emptyFields = append(emptyFields, "meeting_note")
|
||||
}
|
||||
if len(emptyFields) > 0 {
|
||||
result.Hint = fmt.Sprintf("%s not found for this event", strings.Join(emptyFields, ", "))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// CalendarMeeting gets meeting info for calendar events.
|
||||
var CalendarMeeting = common.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+meeting",
|
||||
Description: "Get meeting info for calendar events (meeting_id, meeting_note)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"calendar:calendar.event:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "event-ids", Desc: "calendar event instance IDs, comma-separated for batch", Required: true},
|
||||
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
ids := common.SplitCSV(runtime.Str("event-ids"))
|
||||
const maxBatchSize = 50
|
||||
if len(ids) > maxBatchSize {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--event-ids: too many IDs (%d), maximum is %d", len(ids), maxBatchSize).WithParam("--event-ids")
|
||||
}
|
||||
// dynamic scope check
|
||||
result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID))
|
||||
if err == nil && result != nil && result.Scopes != "" {
|
||||
if missing := auth.MissingScopes(result.Scopes, scopesCalendarMeeting); len(missing) > 0 {
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"missing required scope(s): %s", strings.Join(missing, ", ")).
|
||||
WithHint("run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")).
|
||||
WithMissingScopes(missing...).
|
||||
WithIdentity(string(runtime.As()))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
calendarID := runtime.Str("calendar-id")
|
||||
if calendarID == "" {
|
||||
calendarID = "<primary>"
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST(fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID)).
|
||||
Set("event_ids", common.SplitCSV(runtime.Str("event-ids"))).
|
||||
Set("calendar_id", calendarID)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
errOut := runtime.IO().ErrOut
|
||||
instanceIDs := common.SplitCSV(runtime.Str("event-ids"))
|
||||
calendarID := strings.TrimSpace(runtime.Str("calendar-id"))
|
||||
if calendarID == "" {
|
||||
calendarID = PrimaryCalendarIDStr
|
||||
}
|
||||
|
||||
results := make([]*meetingInfoItem, 0, len(instanceIDs))
|
||||
const batchDelay = 100 * time.Millisecond
|
||||
fmt.Fprintf(errOut, "%s querying %d event_id(s)\n", meetingLogPrefix, len(instanceIDs))
|
||||
for i, id := range instanceIDs {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if i > 0 {
|
||||
time.Sleep(batchDelay)
|
||||
}
|
||||
fmt.Fprintf(errOut, "%s querying event_id=%s ...\n", meetingLogPrefix, id)
|
||||
results = append(results, fetchEventMeetingInfo(ctx, runtime, id, calendarID))
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
for _, r := range results {
|
||||
if r.Error == "" {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(errOut, "%s done: %d total, %d succeeded, %d failed\n", meetingLogPrefix, len(results), successCount, len(results)-successCount)
|
||||
|
||||
if successCount == 0 && len(results) > 0 {
|
||||
return runtime.OutPartialFailure(map[string]any{"meetings": results}, &output.Meta{Count: len(results)})
|
||||
}
|
||||
|
||||
outData := map[string]any{"meetings": results}
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(results)}, func(w io.Writer) {
|
||||
if len(results) == 0 {
|
||||
fmt.Fprintln(w, "No events.")
|
||||
return
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
for _, r := range results {
|
||||
row := map[string]interface{}{"event_id": r.EventID}
|
||||
if r.Error != "" {
|
||||
row["status"] = "FAIL"
|
||||
row["error"] = r.Error
|
||||
} else {
|
||||
row["status"] = "OK"
|
||||
if r.MeetingID != "" {
|
||||
row["meeting_id"] = r.MeetingID
|
||||
}
|
||||
if r.MeetingNote != "" {
|
||||
row["meeting_note"] = r.MeetingNote
|
||||
}
|
||||
if r.Hint != "" {
|
||||
row["hint"] = r.Hint
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
fmt.Fprintf(w, "\n%d event(s), %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,484 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var calWarmOnce sync.Once
|
||||
|
||||
func calWarmTokenCache(t *testing.T) {
|
||||
t.Helper()
|
||||
calWarmOnce.Do(func() {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/warm",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
||||
})
|
||||
s := common.Shortcut{
|
||||
Service: "test",
|
||||
Command: "+warm",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := rctx.CallAPI("GET", "/open-apis/test/v1/warm", nil, nil)
|
||||
return err
|
||||
},
|
||||
}
|
||||
parent := &cobra.Command{Use: "test"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+warm"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
parent.Execute()
|
||||
})
|
||||
}
|
||||
|
||||
func calDefaultConfig() *core.CliConfig {
|
||||
return &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_testuser",
|
||||
}
|
||||
}
|
||||
|
||||
func calMountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
calWarmTokenCache(t)
|
||||
parent := &cobra.Command{Use: "calendar"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// calendar +meeting tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func mgetInstanceRelationStub(calendarID, instanceID string, meetingIDs []string, meetingNotes []string, aiMeetingNotes []string) *httpmock.Stub {
|
||||
infos := map[string]interface{}{
|
||||
"instance_id": instanceID,
|
||||
}
|
||||
mIDs := make([]interface{}, len(meetingIDs))
|
||||
for i, id := range meetingIDs {
|
||||
mIDs[i] = id
|
||||
}
|
||||
infos["meeting_instance_ids"] = mIDs
|
||||
if len(meetingNotes) > 0 {
|
||||
notes := make([]interface{}, len(meetingNotes))
|
||||
for i, n := range meetingNotes {
|
||||
notes[i] = n
|
||||
}
|
||||
infos["meeting_notes"] = notes
|
||||
}
|
||||
if len(aiMeetingNotes) > 0 {
|
||||
notes := make([]interface{}, len(aiMeetingNotes))
|
||||
for i, n := range aiMeetingNotes {
|
||||
notes[i] = n
|
||||
}
|
||||
infos["ai_meeting_notes"] = notes
|
||||
}
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID),
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"instance_relation_infos": []interface{}{infos},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mgetInstanceRelationFailedStub(calendarID, instanceID, failMsg string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", calendarID),
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"instance_relation_infos": []interface{}{},
|
||||
"failed_instance_ids": []interface{}{
|
||||
map[string]interface{}{
|
||||
"instance_id": instanceID,
|
||||
"fail_msg": failMsg,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeeting_Validation_MissingEventIDs(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for missing --event-ids")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeeting_Validation_BatchLimit(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
ids := make([]string, 51)
|
||||
for i := range ids {
|
||||
ids[i] = fmt.Sprintf("evt%d", i)
|
||||
}
|
||||
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", strings.Join(ids, ","), "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected batch limit error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "too many IDs") {
|
||||
t.Errorf("expected 'too many IDs' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeeting_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt001", "--dry-run", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "mget_instance_relation_info") {
|
||||
t.Errorf("dry-run should show mget API path, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeeting_Execute_Success(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
reg.Register(mgetInstanceRelationStub("primary", "evt_m1", []string{"123456"}, []string{"doc_note1"}, []string{"doc_ai1"}))
|
||||
|
||||
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt_m1", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse output: %v", err)
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
meetings, _ := data["meetings"].([]any)
|
||||
if len(meetings) != 1 {
|
||||
t.Fatalf("expected 1 meeting, got %d", len(meetings))
|
||||
}
|
||||
m, _ := meetings[0].(map[string]any)
|
||||
if m["meeting_id"] != "123456" {
|
||||
t.Errorf("meeting_id = %v, want 123456", m["meeting_id"])
|
||||
}
|
||||
if m["meeting_note"] != "doc_note1" {
|
||||
t.Errorf("meeting_note = %v, want doc_note1", m["meeting_note"])
|
||||
}
|
||||
if _, hasAI := m["ai_meeting_note"]; hasAI {
|
||||
t.Error("ai_meeting_note should not be present in output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeeting_Execute_FailedInstance(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
reg.Register(mgetInstanceRelationFailedStub("primary", "evt_fail", "No Permission"))
|
||||
|
||||
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt_fail", "--as", "user"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial failure error")
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
// Verify translated fail_msg appears in output
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err == nil {
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
meetings, _ := data["meetings"].([]any)
|
||||
if len(meetings) > 0 {
|
||||
m, _ := meetings[0].(map[string]any)
|
||||
if errMsg, _ := m["error"].(string); !strings.Contains(errMsg, "no read permission") {
|
||||
t.Errorf("expected translated fail_msg, got: %v", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeeting_Execute_NoMeeting(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
reg.Register(mgetInstanceRelationStub("primary", "evt_nomeet", []string{}, nil, nil))
|
||||
|
||||
err := calMountAndRun(t, CalendarMeeting, []string{"+meeting", "--event-ids", "evt_nomeet", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse output: %v", err)
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
meetings, _ := data["meetings"].([]any)
|
||||
if len(meetings) != 1 {
|
||||
t.Fatalf("expected 1 meeting, got %d", len(meetings))
|
||||
}
|
||||
m, _ := meetings[0].(map[string]any)
|
||||
if hint, _ := m["hint"].(string); !strings.Contains(hint, "meeting_id") {
|
||||
t.Errorf("expected hint about meeting_id, got: %v", hint)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// calendar +search-event tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSearchEvent_Validation_InvalidTimeRange(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--start", "bad-format", "--end", "2026-04-27", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for invalid --start")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--start") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchEvent_Validation_TimeRangeStartAfterEnd(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--start", "2026-04-27", "--end", "2026-04-20", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for start after end")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchEvent_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--query", "周会", "--dry-run", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "search_event") {
|
||||
t.Errorf("dry-run should show search_event API path, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchEvent_Execute_Success(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/primary/events/search_event",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"display_info": "Q2 周会\n2026-04-23 15:00-16:00",
|
||||
"meta_data": map[string]interface{}{
|
||||
"event_id": "evt_search1",
|
||||
"summary": "Q2 周会",
|
||||
"start": map[string]interface{}{
|
||||
"date_time": "2026-04-23T15:00:00+08:00",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
"end": map[string]interface{}{
|
||||
"date_time": "2026-04-23T16:00:00+08:00",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
"is_all_day": false,
|
||||
"app_link": "https://applink.feishu.cn/...",
|
||||
},
|
||||
},
|
||||
},
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--query", "周会", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse output: %v", err)
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
if data["calendar_id"] != "primary" {
|
||||
t.Errorf("calendar_id = %v, want primary", data["calendar_id"])
|
||||
}
|
||||
items, _ := data["items"].([]any)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 item, got %d", len(items))
|
||||
}
|
||||
item, _ := items[0].(map[string]any)
|
||||
if item["event_id"] != "evt_search1" {
|
||||
t.Errorf("event_id = %v, want evt_search1", item["event_id"])
|
||||
}
|
||||
if item["summary"] != "Q2 周会" {
|
||||
t.Errorf("summary = %v, want 'Q2 周会'", item["summary"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchEvent_Execute_Empty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, calDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/primary/events/search_event",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := calMountAndRun(t, CalendarSearchEvent, []string{"+search-event", "--query", "nonexistent", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure function tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParseSearchEventTimeRange(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
start string
|
||||
end string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", "", "", false},
|
||||
{"valid", "2026-04-20", "2026-04-27", false},
|
||||
{"start only defaults end", "2026-04-20", "", false},
|
||||
{"end only defaults start", "", "2026-04-27", false},
|
||||
{"invalid start format", "not-a-date", "2026-04-27", true},
|
||||
{"start after end", "2026-04-27", "2026-04-20", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("start", "", "")
|
||||
cmd.Flags().String("end", "", "")
|
||||
if tt.start != "" {
|
||||
_ = cmd.Flags().Set("start", tt.start)
|
||||
}
|
||||
if tt.end != "" {
|
||||
_ = cmd.Flags().Set("end", tt.end)
|
||||
}
|
||||
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
|
||||
_, _, err := parseSearchEventTimeRange(runtime)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseSearchEventTimeRange() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("start only fills end with end-of-day", func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("start", "", "")
|
||||
cmd.Flags().String("end", "", "")
|
||||
_ = cmd.Flags().Set("start", "2026-04-20")
|
||||
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
|
||||
startRFC, endRFC, err := parseSearchEventTimeRange(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(startRFC, "2026-04-20T00:00:00") {
|
||||
t.Errorf("start = %s, want 2026-04-20T00:00:00...", startRFC)
|
||||
}
|
||||
if !strings.HasPrefix(endRFC, "2026-04-20T23:59:59") {
|
||||
t.Errorf("end = %s, want 2026-04-20T23:59:59...", endRFC)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("end only fills start with start-of-day", func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("start", "", "")
|
||||
cmd.Flags().String("end", "", "")
|
||||
_ = cmd.Flags().Set("end", "2026-04-27")
|
||||
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
|
||||
startRFC, endRFC, err := parseSearchEventTimeRange(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(startRFC, "2026-04-27T00:00:00") {
|
||||
t.Errorf("start = %s, want 2026-04-27T00:00:00...", startRFC)
|
||||
}
|
||||
if !strings.HasPrefix(endRFC, "2026-04-27T23:59:59") {
|
||||
t.Errorf("end = %s, want 2026-04-27T23:59:59...", endRFC)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildSearchEventFilter(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("attendee-ids", "", "")
|
||||
_ = cmd.Flags().Set("attendee-ids", "ou_user1,oc_chat1,omm_room1")
|
||||
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
|
||||
|
||||
filter := buildSearchEventFilter(runtime, "", "")
|
||||
if filter == nil {
|
||||
t.Fatal("expected filter to be non-nil")
|
||||
}
|
||||
if len(filter.AttendeeUserIDs) != 1 || filter.AttendeeUserIDs[0] != "ou_user1" {
|
||||
t.Errorf("attendee_user_ids = %v, want [ou_user1]", filter.AttendeeUserIDs)
|
||||
}
|
||||
if len(filter.AttendeeChatIDs) != 1 || filter.AttendeeChatIDs[0] != "oc_chat1" {
|
||||
t.Errorf("attendee_chat_ids = %v, want [oc_chat1]", filter.AttendeeChatIDs)
|
||||
}
|
||||
if len(filter.MeetingRoomIDs) != 1 || filter.MeetingRoomIDs[0] != "omm_room1" {
|
||||
t.Errorf("meeting_room_ids = %v, want [omm_room1]", filter.MeetingRoomIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSearchEventFilter_Empty(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("attendee-ids", "", "")
|
||||
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
|
||||
|
||||
filter := buildSearchEventFilter(runtime, "", "")
|
||||
if filter != nil {
|
||||
t.Errorf("expected nil for empty filter, got %v", filter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSearchEventFilter_TimeRange(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("attendee-ids", "", "")
|
||||
runtime := common.TestNewRuntimeContext(cmd, calDefaultConfig())
|
||||
|
||||
filter := buildSearchEventFilter(runtime, "2026-04-20T00:00:00+08:00", "2026-04-27T23:59:59+08:00")
|
||||
if filter == nil {
|
||||
t.Fatal("expected filter to be non-nil")
|
||||
}
|
||||
if filter.TimeRange == nil {
|
||||
t.Fatal("expected time_range in filter")
|
||||
}
|
||||
if filter.TimeRange.StartTime != "2026-04-20T00:00:00+08:00" {
|
||||
t.Errorf("start_time = %v, want 2026-04-20T00:00:00+08:00", filter.TimeRange.StartTime)
|
||||
}
|
||||
}
|
||||
@@ -66,8 +66,7 @@ type roomFindSlot struct {
|
||||
type roomFindTimeSlot struct {
|
||||
Start string `json:"start,omitempty"`
|
||||
End string `json:"end,omitempty"`
|
||||
MeetingRooms []*roomFindSuggestion `json:"meeting_rooms"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
MeetingRooms []*roomFindSuggestion `json:"meeting_rooms,omitempty"`
|
||||
}
|
||||
|
||||
type roomFindOutput struct {
|
||||
@@ -104,18 +103,11 @@ func collectRoomFindResults(slots []roomFindSlot, limit int, fetch func(roomFind
|
||||
}
|
||||
return
|
||||
}
|
||||
if suggestions == nil {
|
||||
suggestions = []*roomFindSuggestion{}
|
||||
}
|
||||
ts := &roomFindTimeSlot{
|
||||
out.TimeSlots = append(out.TimeSlots, &roomFindTimeSlot{
|
||||
Start: slot.Start,
|
||||
End: slot.End,
|
||||
MeetingRooms: suggestions,
|
||||
}
|
||||
if len(suggestions) == 0 {
|
||||
ts.Hint = "no meeting room matches the current filters for this slot"
|
||||
}
|
||||
out.TimeSlots = append(out.TimeSlots, ts)
|
||||
})
|
||||
}(slot)
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -382,10 +374,6 @@ var CalendarRoomFind = common.Shortcut{
|
||||
}
|
||||
for _, slot := range out.TimeSlots {
|
||||
fmt.Fprintf(w, "%s - %s\n", slot.Start, slot.End)
|
||||
if len(slot.MeetingRooms) == 0 {
|
||||
fmt.Fprintf(w, "0 meeting room(s) found: %s\n", slot.Hint)
|
||||
continue
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
for _, room := range slot.MeetingRooms {
|
||||
rows = append(rows, map[string]interface{}{
|
||||
@@ -396,7 +384,6 @@ var CalendarRoomFind = common.Shortcut{
|
||||
})
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
fmt.Fprintf(w, "%d meeting room(s) found\n", len(slot.MeetingRooms))
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -84,60 +82,3 @@ func TestCollectRoomFindResults_LimitsConcurrency(t *testing.T) {
|
||||
t.Fatalf("expected %d time slots, got %d", len(slots), len(out.TimeSlots))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectRoomFindResults_EmptySlotEmitsHintAndArray(t *testing.T) {
|
||||
slots := []roomFindSlot{
|
||||
{Start: "2026-03-27T14:00:00+08:00", End: "2026-03-27T15:00:00+08:00"},
|
||||
{Start: "2026-03-27T15:00:00+08:00", End: "2026-03-27T16:00:00+08:00"},
|
||||
}
|
||||
|
||||
out, err := collectRoomFindResults(slots, 2, func(slot roomFindSlot) ([]*roomFindSuggestion, error) {
|
||||
if strings.HasPrefix(slot.Start, "2026-03-27T14") {
|
||||
return []*roomFindSuggestion{{RoomID: "rm_1", RoomName: "Room A"}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("collectRoomFindResults returned error: %v", err)
|
||||
}
|
||||
if len(out.TimeSlots) != 2 {
|
||||
t.Fatalf("expected 2 time slots, got %d", len(out.TimeSlots))
|
||||
}
|
||||
|
||||
for _, ts := range out.TimeSlots {
|
||||
if ts.MeetingRooms == nil {
|
||||
t.Fatalf("meeting_rooms should be non-nil for slot %s", ts.Start)
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(ts.Start, "2026-03-27T14"):
|
||||
if len(ts.MeetingRooms) != 1 {
|
||||
t.Fatalf("expected 1 room for first slot, got %d", len(ts.MeetingRooms))
|
||||
}
|
||||
if ts.Hint != "" {
|
||||
t.Fatalf("non-empty slot should not carry hint, got %q", ts.Hint)
|
||||
}
|
||||
case strings.HasPrefix(ts.Start, "2026-03-27T15"):
|
||||
if len(ts.MeetingRooms) != 0 {
|
||||
t.Fatalf("expected 0 rooms for empty slot, got %d", len(ts.MeetingRooms))
|
||||
}
|
||||
if ts.Hint == "" {
|
||||
t.Fatal("empty slot should carry a hint explaining the filters")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emptySlot := out.TimeSlots[0]
|
||||
if !strings.HasPrefix(emptySlot.Start, "2026-03-27T15") {
|
||||
emptySlot = out.TimeSlots[1]
|
||||
}
|
||||
raw, err := json.Marshal(emptySlot)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal empty slot: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"meeting_rooms":[]`) {
|
||||
t.Fatalf("expected meeting_rooms:[] in JSON, got %s", raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"hint"`) {
|
||||
t.Fatalf("expected hint field in JSON, got %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// calendar +search-event — search calendar events by keyword, time range, and attendees
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const searchEventLogPrefix = "[calendar +search-event]"
|
||||
|
||||
const (
|
||||
defaultSearchEventPageSize = 20
|
||||
maxSearchEventPageSize = 30
|
||||
)
|
||||
|
||||
var scopesSearchEvent = []string{
|
||||
"calendar:calendar:read",
|
||||
"calendar:calendar.event:read",
|
||||
}
|
||||
|
||||
// searchEventTimeRange represents the time range filter for search_event API.
|
||||
type searchEventTimeRange struct {
|
||||
StartTime string `json:"start_time,omitempty"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
}
|
||||
|
||||
// searchEventFilter represents the filter object for the search_event API request.
|
||||
type searchEventFilter struct {
|
||||
AttendeeUserIDs []string `json:"attendee_user_ids,omitempty"`
|
||||
AttendeeChatIDs []string `json:"attendee_chat_ids,omitempty"`
|
||||
MeetingRoomIDs []string `json:"meeting_room_ids,omitempty"`
|
||||
TimeRange *searchEventTimeRange `json:"time_range,omitempty"`
|
||||
}
|
||||
|
||||
// searchEventRequestBody is the request body for the search_event API.
|
||||
type searchEventRequestBody struct {
|
||||
Query string `json:"query"`
|
||||
Filter *searchEventFilter `json:"filter,omitempty"`
|
||||
}
|
||||
|
||||
// searchEventTimeInfo represents start/end time info in the search result.
|
||||
type searchEventTimeInfo struct {
|
||||
Date string `json:"date,omitempty"`
|
||||
DateTime string `json:"date_time,omitempty"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
}
|
||||
|
||||
// searchEventItem represents a single event in the search result output.
|
||||
type searchEventItem struct {
|
||||
EventID string `json:"event_id"`
|
||||
Summary string `json:"summary"`
|
||||
Start *searchEventTimeInfo `json:"start,omitempty"`
|
||||
End *searchEventTimeInfo `json:"end,omitempty"`
|
||||
IsAllDay bool `json:"is_all_day,omitempty"`
|
||||
AppLink string `json:"app_link,omitempty"`
|
||||
}
|
||||
|
||||
// searchEventOutput is the structured output for +search-event.
|
||||
type searchEventOutput struct {
|
||||
CalendarID string `json:"calendar_id"`
|
||||
Items []searchEventItem `json:"items"`
|
||||
HasMore bool `json:"has_more"`
|
||||
PageToken string `json:"page_token"`
|
||||
}
|
||||
|
||||
// parseSearchEventTimeRange parses --start / --end into RFC3339 strings.
|
||||
// When only one side is provided, the other defaults to the same day's
|
||||
// boundary (start → end-of-day, end → start-of-day).
|
||||
func parseSearchEventTimeRange(runtime *common.RuntimeContext) (string, string, error) {
|
||||
startInput := strings.TrimSpace(runtime.Str("start"))
|
||||
endInput := strings.TrimSpace(runtime.Str("end"))
|
||||
if startInput == "" && endInput == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
var startSec, endSec int64
|
||||
|
||||
if startInput != "" {
|
||||
ts, err := common.ParseTime(startInput)
|
||||
if err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
|
||||
}
|
||||
startSec, _ = strconv.ParseInt(ts, 10, 64)
|
||||
}
|
||||
if endInput != "" {
|
||||
ts, err := common.ParseTime(endInput, "end")
|
||||
if err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||
}
|
||||
endSec, _ = strconv.ParseInt(ts, 10, 64)
|
||||
}
|
||||
|
||||
if startInput == "" {
|
||||
t := time.Unix(endSec, 0).In(time.Local)
|
||||
startSec = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).Unix()
|
||||
}
|
||||
if endInput == "" {
|
||||
t := time.Unix(startSec, 0).In(time.Local)
|
||||
endSec = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, t.Location()).Unix()
|
||||
}
|
||||
|
||||
if startSec > endSec {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start must be before --end").WithParam("--start")
|
||||
}
|
||||
|
||||
return time.Unix(startSec, 0).Format(time.RFC3339), time.Unix(endSec, 0).Format(time.RFC3339), nil
|
||||
}
|
||||
|
||||
// buildSearchEventFilter builds the filter object for the search_event API.
|
||||
func buildSearchEventFilter(runtime *common.RuntimeContext, startTime, endTime string) *searchEventFilter {
|
||||
attendeeIDs := common.SplitCSV(runtime.Str("attendee-ids"))
|
||||
|
||||
var userIDs, chatIDs, roomIDs []string
|
||||
for _, id := range attendeeIDs {
|
||||
switch {
|
||||
case strings.HasPrefix(id, "ou_"):
|
||||
userIDs = append(userIDs, id)
|
||||
case strings.HasPrefix(id, "oc_"):
|
||||
chatIDs = append(chatIDs, id)
|
||||
case strings.HasPrefix(id, "omm_"):
|
||||
roomIDs = append(roomIDs, id)
|
||||
default:
|
||||
userIDs = append(userIDs, id)
|
||||
}
|
||||
}
|
||||
|
||||
var tr *searchEventTimeRange
|
||||
if startTime != "" || endTime != "" {
|
||||
tr = &searchEventTimeRange{StartTime: startTime, EndTime: endTime}
|
||||
}
|
||||
|
||||
if len(userIDs) == 0 && len(chatIDs) == 0 && len(roomIDs) == 0 && tr == nil {
|
||||
return nil
|
||||
}
|
||||
return &searchEventFilter{
|
||||
AttendeeUserIDs: userIDs,
|
||||
AttendeeChatIDs: chatIDs,
|
||||
MeetingRoomIDs: roomIDs,
|
||||
TimeRange: tr,
|
||||
}
|
||||
}
|
||||
|
||||
// extractTimeInfo extracts time info from a meta_data start/end map.
|
||||
func extractTimeInfo(m map[string]any) *searchEventTimeInfo {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
info := &searchEventTimeInfo{}
|
||||
if v, ok := m["date"].(string); ok && v != "" {
|
||||
info.Date = v
|
||||
}
|
||||
if v, ok := m["date_time"].(string); ok && v != "" {
|
||||
info.DateTime = v
|
||||
}
|
||||
if v, ok := m["timezone"].(string); ok && v != "" {
|
||||
info.Timezone = v
|
||||
}
|
||||
if info.Date == "" && info.DateTime == "" {
|
||||
return nil
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// CalendarSearchEvent searches calendar events by keyword, time range, and attendees.
|
||||
var CalendarSearchEvent = common.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+search-event",
|
||||
Description: "Search calendar events by keyword, time range, and attendees",
|
||||
Risk: "read",
|
||||
Scopes: []string{"calendar:calendar.event:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||
{Name: "query", Desc: "search keyword"},
|
||||
{Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"},
|
||||
{Name: "start", Desc: "search time range start (ISO 8601 or YYYY-MM-DD)"},
|
||||
{Name: "end", Desc: "search time range end (ISO 8601 or YYYY-MM-DD)"},
|
||||
{Name: "page-token", Desc: "page token for next page"},
|
||||
{Name: "page-size", Default: "20", Desc: "page size, 1-30 (default 20)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := parseSearchEventTimeRange(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", defaultSearchEventPageSize, 1, maxSearchEventPageSize); err != nil {
|
||||
return err
|
||||
}
|
||||
// dynamic scope check
|
||||
result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID))
|
||||
if err == nil && result != nil && result.Scopes != "" {
|
||||
if missing := auth.MissingScopes(result.Scopes, scopesSearchEvent); len(missing) > 0 {
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"missing required scope(s): %s", strings.Join(missing, ", ")).
|
||||
WithHint("run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")).
|
||||
WithMissingScopes(missing...).
|
||||
WithIdentity(string(runtime.As()))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
calendarID := runtime.Str("calendar-id")
|
||||
if calendarID == "" {
|
||||
calendarID = "<primary>"
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST(fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/search_event", calendarID)).
|
||||
Set("calendar_id", calendarID)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
calendarID := strings.TrimSpace(runtime.Str("calendar-id"))
|
||||
if calendarID == "" {
|
||||
calendarID = PrimaryCalendarIDStr
|
||||
}
|
||||
|
||||
startTime, endTime, err := parseSearchEventTimeRange(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build request body — always send query (even if empty)
|
||||
body := &searchEventRequestBody{
|
||||
Query: strings.TrimSpace(runtime.Str("query")),
|
||||
}
|
||||
if filter := buildSearchEventFilter(runtime, startTime, endTime); filter != nil {
|
||||
body.Filter = filter
|
||||
}
|
||||
|
||||
// Build query params
|
||||
params := map[string]any{}
|
||||
pageSize, _ := strconv.Atoi(strings.TrimSpace(runtime.Str("page-size")))
|
||||
if pageSize <= 0 {
|
||||
pageSize = defaultSearchEventPageSize
|
||||
}
|
||||
params["page_size"] = strconv.Itoa(pageSize)
|
||||
if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" {
|
||||
params["page_token"] = pt
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("POST",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/search_event", validate.EncodePathSegment(calendarID)),
|
||||
params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]any{}
|
||||
}
|
||||
|
||||
items := common.GetSlice(data, "items")
|
||||
hasMore, _ := data["has_more"].(bool)
|
||||
pageToken, _ := data["page_token"].(string)
|
||||
|
||||
// Transform items to structured output
|
||||
outItems := make([]searchEventItem, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]any)
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
meta, _ := item["meta_data"].(map[string]any)
|
||||
out := searchEventItem{}
|
||||
if meta != nil {
|
||||
if v, ok := meta["event_id"].(string); ok {
|
||||
out.EventID = v
|
||||
}
|
||||
if v, ok := meta["summary"].(string); ok {
|
||||
out.Summary = v
|
||||
}
|
||||
if v, ok := meta["is_all_day"].(bool); ok {
|
||||
out.IsAllDay = v
|
||||
}
|
||||
if v, ok := meta["app_link"].(string); ok {
|
||||
out.AppLink = v
|
||||
}
|
||||
if start, ok := meta["start"].(map[string]any); ok {
|
||||
out.Start = extractTimeInfo(start)
|
||||
}
|
||||
if end, ok := meta["end"].(map[string]any); ok {
|
||||
out.End = extractTimeInfo(end)
|
||||
}
|
||||
}
|
||||
outItems = append(outItems, out)
|
||||
}
|
||||
|
||||
outData := searchEventOutput{
|
||||
CalendarID: calendarID,
|
||||
Items: outItems,
|
||||
HasMore: hasMore,
|
||||
PageToken: pageToken,
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(outItems)}, func(w io.Writer) {
|
||||
if len(outItems) == 0 {
|
||||
fmt.Fprintln(w, "No events found.")
|
||||
return
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
for _, item := range outItems {
|
||||
row := map[string]interface{}{
|
||||
"event_id": item.EventID,
|
||||
"summary": common.TruncateStr(item.Summary, 40),
|
||||
}
|
||||
if item.Start != nil {
|
||||
if item.Start.DateTime != "" {
|
||||
row["start"] = item.Start.DateTime
|
||||
} else if item.Start.Date != "" {
|
||||
row["start"] = item.Start.Date
|
||||
}
|
||||
}
|
||||
if item.End != nil {
|
||||
if item.End.DateTime != "" {
|
||||
row["end"] = item.End.DateTime
|
||||
} else if item.End.Date != "" {
|
||||
row["end"] = item.End.Date
|
||||
}
|
||||
}
|
||||
if item.IsAllDay {
|
||||
row["is_all_day"] = true
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
fmt.Fprintf(w, "\n%d event(s) found\n", len(outItems))
|
||||
})
|
||||
|
||||
if hasMore && runtime.Format != "json" && runtime.Format != "" {
|
||||
fmt.Fprintf(runtime.IO().Out, "\n(more available, page_token: %s)\n", pageToken)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -2234,10 +2234,10 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
|
||||
// Shortcuts() registration test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestShortcuts_Returns9(t *testing.T) {
|
||||
func TestShortcuts_Returns7(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
if len(shortcuts) != 9 {
|
||||
t.Fatalf("expected 9 shortcuts, got %d", len(shortcuts))
|
||||
if len(shortcuts) != 7 {
|
||||
t.Fatalf("expected 7 shortcuts, got %d", len(shortcuts))
|
||||
}
|
||||
|
||||
names := map[string]bool{}
|
||||
|
||||
@@ -15,7 +15,5 @@ func Shortcuts() []common.Shortcut {
|
||||
CalendarRoomFind,
|
||||
CalendarRsvp,
|
||||
CalendarSuggestion,
|
||||
CalendarMeeting,
|
||||
CalendarSearchEvent,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -841,7 +841,7 @@ func TestShortcutDryRunShapes(t *testing.T) {
|
||||
"page-size": "10",
|
||||
}, nil)
|
||||
got := mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"container_id":"omt_123"`) || !strings.Contains(got, `"sort_type":"ByCreateTimeDesc"`) || !strings.Contains(got, `"page_size":"10"`) {
|
||||
if !strings.Contains(got, `"container_id":"omt_123"`) || !strings.Contains(got, `"sort_type":"ByCreateTimeDesc"`) || !strings.Contains(got, `"page_size":10`) {
|
||||
t.Fatalf("ImThreadsMessagesList.DryRun() = %s", got)
|
||||
}
|
||||
})
|
||||
@@ -901,7 +901,7 @@ func TestShortcutDryRunShapes(t *testing.T) {
|
||||
t.Run("ImChatList dry run includes endpoint and params", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"user-id-type": "open_id",
|
||||
"sort": "create_time",
|
||||
"sort-type": "ByCreateTimeAsc",
|
||||
}, nil)
|
||||
got := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) {
|
||||
|
||||
@@ -48,8 +48,7 @@ var ImChatList = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "ID type for owner_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
|
||||
{Name: "sort", Default: "create_time", Desc: "sort field: create_time (ascending) | active_time (descending)", Enum: []string{"create_time", "active_time"}},
|
||||
{Name: "sort-type", Hidden: true, Desc: "alias of --sort (hidden)", Enum: []string{"ByCreateTimeAsc", "ByActiveTimeDesc"}},
|
||||
{Name: "sort-type", Default: "ByCreateTimeAsc", Desc: "sort order", Enum: []string{"ByCreateTimeAsc", "ByActiveTimeDesc"}},
|
||||
{Name: "types", Type: "string_slice", Desc: "chat types to include (group, p2p); omit = groups only (backward compatible); p2p requires user identity"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
@@ -267,16 +266,9 @@ func resolveTypes(runtime *common.RuntimeContext) (string, bool, error) {
|
||||
// CSV string already normalized + bot-stripped by resolveTypes; pass "" to
|
||||
// omit the types query param entirely (backward compatible default).
|
||||
func buildChatListParams(runtime *common.RuntimeContext, effectiveTypes string) map[string]interface{} {
|
||||
sortType := map[string]string{
|
||||
"create_time": "ByCreateTimeAsc",
|
||||
"active_time": "ByActiveTimeDesc",
|
||||
}[runtime.Str("sort")]
|
||||
if old, ok := aliasFlagValue(runtime, "sort-type", "sort"); ok {
|
||||
sortType = old // old value is already the upstream enum -> pass through
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"user_id_type": runtime.Str("user-id-type"),
|
||||
"sort_type": sortType,
|
||||
"sort_type": runtime.Str("sort-type"),
|
||||
}
|
||||
if n := runtime.Int("page-size"); n > 0 {
|
||||
params["page_size"] = n
|
||||
|
||||
@@ -611,85 +611,3 @@ func TestImChatList_Execute_UserMuteFiltersP2p(t *testing.T) {
|
||||
t.Fatalf("remaining chat = %v; want oc_g", parsed.Data.Chats[0]["chat_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatList_SortMapping(t *testing.T) {
|
||||
cases := []struct{ sort, want string }{
|
||||
{"create_time", "ByCreateTimeAsc"},
|
||||
{"active_time", "ByActiveTimeDesc"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.sort, func(t *testing.T) {
|
||||
rt := newChatListTestRuntimeContext(t, map[string]string{"sort": c.sort}, nil)
|
||||
got := buildChatListParams(rt, "")
|
||||
if got["sort_type"] != c.want {
|
||||
t.Fatalf("sort=%s -> sort_type=%v, want %s", c.sort, got["sort_type"], c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatList_SortAliasParity proves the hidden --sort-type alias maps to the
|
||||
// exact same upstream request as the equivalent new --sort value (byte-equal).
|
||||
func TestChatList_SortAliasParity(t *testing.T) {
|
||||
pairs := []struct{ newVal, oldVal string }{
|
||||
{"create_time", "ByCreateTimeAsc"},
|
||||
{"active_time", "ByActiveTimeDesc"},
|
||||
}
|
||||
for _, p := range pairs {
|
||||
t.Run(p.newVal, func(t *testing.T) {
|
||||
newRT := newChatListTestRuntimeContext(t, map[string]string{"sort": p.newVal}, nil)
|
||||
oldRT := newChatListTestRuntimeContext(t, map[string]string{"sort-type": p.oldVal}, nil)
|
||||
a := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), newRT))
|
||||
b := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), oldRT))
|
||||
if a != b {
|
||||
t.Fatalf("alias parity broken:\n new=%s\n old=%s", a, b)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatList_SortNewWins: both flags set -> new wins, old ignored, no error.
|
||||
func TestChatList_SortNewWins(t *testing.T) {
|
||||
rt := newChatListTestRuntimeContext(t, map[string]string{
|
||||
"sort": "active_time",
|
||||
"sort-type": "ByCreateTimeAsc",
|
||||
}, nil)
|
||||
got := buildChatListParams(rt, "")
|
||||
if got["sort_type"] != "ByActiveTimeDesc" {
|
||||
t.Fatalf("new should win: sort_type=%v, want ByActiveTimeDesc", got["sort_type"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatList_SortFlagSurface asserts the declared flag structure.
|
||||
func TestChatList_SortFlagSurface(t *testing.T) {
|
||||
var sortFlag, aliasFlag *common.Flag
|
||||
for i := range ImChatList.Flags {
|
||||
switch ImChatList.Flags[i].Name {
|
||||
case "sort":
|
||||
sortFlag = &ImChatList.Flags[i]
|
||||
case "sort-type":
|
||||
aliasFlag = &ImChatList.Flags[i]
|
||||
}
|
||||
}
|
||||
if sortFlag == nil || aliasFlag == nil {
|
||||
t.Fatalf("expected both --sort and --sort-type flags declared")
|
||||
}
|
||||
if sortFlag.Default != "create_time" {
|
||||
t.Errorf("--sort Default = %q, want create_time", sortFlag.Default)
|
||||
}
|
||||
if got := strings.Join(sortFlag.Enum, ","); got != "create_time,active_time" {
|
||||
t.Errorf("--sort Enum = %q, want create_time,active_time", got)
|
||||
}
|
||||
if !strings.Contains(sortFlag.Desc, "create_time") || !strings.Contains(sortFlag.Desc, "active_time") {
|
||||
t.Errorf("--sort Desc must document both fields/directions: %q", sortFlag.Desc)
|
||||
}
|
||||
if !aliasFlag.Hidden {
|
||||
t.Errorf("--sort-type must be Hidden")
|
||||
}
|
||||
if got := strings.Join(aliasFlag.Enum, ","); got != "ByCreateTimeAsc,ByActiveTimeDesc" {
|
||||
t.Errorf("--sort-type Enum = %q, want ByCreateTimeAsc,ByActiveTimeDesc", got)
|
||||
}
|
||||
if aliasFlag.Default != "" {
|
||||
t.Errorf("--sort-type (hidden alias) must not carry a Default, got %q", aliasFlag.Default)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,7 @@ var ImChatMessageList = common.Shortcut{
|
||||
{Name: "user-id", Desc: "(required, mutually exclusive with --chat-id; user identity only) user open_id (ou_xxx)"},
|
||||
{Name: "start", Desc: "start time (ISO 8601)"},
|
||||
{Name: "end", Desc: "end time (ISO 8601)"},
|
||||
{Name: "order", Default: "desc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}},
|
||||
{Name: "sort", Hidden: true, Desc: "alias of --order (hidden)", Enum: []string{"asc", "desc"}},
|
||||
{Name: "sort", Default: "desc", Desc: "sort order", Enum: []string{"asc", "desc"}},
|
||||
{Name: "page-size", Default: "50", Desc: "page size (1-50)"},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
|
||||
@@ -210,11 +209,7 @@ func buildChatMessageListParams(sortFlag, pageSizeStr, chatId string) larkcore.Q
|
||||
}
|
||||
|
||||
func buildChatMessageListRequest(runtime *common.RuntimeContext, chatId string) (larkcore.QueryParams, error) {
|
||||
dir := runtime.Str("order")
|
||||
if old, ok := aliasFlagValue(runtime, "sort", "order"); ok {
|
||||
dir = old // old value is asc/desc -> must go through the same map, never pass through
|
||||
}
|
||||
params := buildChatMessageListParams(dir, runtime.Str("page-size"), chatId)
|
||||
params := buildChatMessageListParams(runtime.Str("sort"), runtime.Str("page-size"), chatId)
|
||||
|
||||
if startFlag := runtime.Str("start"); startFlag != "" {
|
||||
startTime, err := common.ParseTime(startFlag)
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// newMsgListTestRT registers chat-id (so the builder has a container) plus the
|
||||
// sort flags under test; only flags present in stringFlags are "set" (Changed).
|
||||
func newMsgListTestRT(t *testing.T, stringFlags map[string]string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
if stringFlags == nil {
|
||||
stringFlags = map[string]string{}
|
||||
}
|
||||
if _, ok := stringFlags["chat-id"]; !ok {
|
||||
stringFlags["chat-id"] = "oc_test"
|
||||
}
|
||||
return newChatListTestRuntimeContext(t, stringFlags, nil)
|
||||
}
|
||||
|
||||
func TestChatMessagesList_OrderMapping(t *testing.T) {
|
||||
cases := []struct{ order, want string }{
|
||||
{"asc", "ByCreateTimeAsc"},
|
||||
{"desc", "ByCreateTimeDesc"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.order, func(t *testing.T) {
|
||||
rt := newMsgListTestRT(t, map[string]string{"order": c.order})
|
||||
params, err := buildChatMessageListRequest(rt, "oc_test")
|
||||
if err != nil {
|
||||
t.Fatalf("buildChatMessageListRequest() error = %v", err)
|
||||
}
|
||||
if got := params["sort_type"][0]; got != c.want {
|
||||
t.Fatalf("order=%s -> sort_type=%s, want %s", c.order, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatMessagesList_OrderAliasParity: hidden --sort alias (asc/desc) must map
|
||||
// through the SAME table as --order (NOT pass through), producing identical upstream.
|
||||
func TestChatMessagesList_OrderAliasParity(t *testing.T) {
|
||||
for _, dir := range []string{"asc", "desc"} {
|
||||
t.Run(dir, func(t *testing.T) {
|
||||
newRT := newMsgListTestRT(t, map[string]string{"order": dir})
|
||||
oldRT := newMsgListTestRT(t, map[string]string{"sort": dir})
|
||||
a := mustMarshalDryRun(t, ImChatMessageList.DryRun(context.Background(), newRT))
|
||||
b := mustMarshalDryRun(t, ImChatMessageList.DryRun(context.Background(), oldRT))
|
||||
if a != b {
|
||||
t.Fatalf("alias parity broken:\n new=%s\n old=%s", a, b)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessagesList_OrderNewWins(t *testing.T) {
|
||||
rt := newMsgListTestRT(t, map[string]string{"order": "asc", "sort": "desc"})
|
||||
params, err := buildChatMessageListRequest(rt, "oc_test")
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if got := params["sort_type"][0]; got != "ByCreateTimeAsc" {
|
||||
t.Fatalf("new should win: sort_type=%s, want ByCreateTimeAsc", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessagesList_OrderFlagSurface(t *testing.T) {
|
||||
var orderFlag, aliasFlag *common.Flag
|
||||
for i := range ImChatMessageList.Flags {
|
||||
switch ImChatMessageList.Flags[i].Name {
|
||||
case "order":
|
||||
orderFlag = &ImChatMessageList.Flags[i]
|
||||
case "sort":
|
||||
aliasFlag = &ImChatMessageList.Flags[i]
|
||||
}
|
||||
}
|
||||
if orderFlag == nil || aliasFlag == nil {
|
||||
t.Fatalf("expected both --order and --sort flags declared")
|
||||
}
|
||||
if orderFlag.Default != "desc" {
|
||||
t.Errorf("--order Default = %q, want desc", orderFlag.Default)
|
||||
}
|
||||
if got := strings.Join(orderFlag.Enum, ","); got != "asc,desc" {
|
||||
t.Errorf("--order Enum = %q, want asc,desc", got)
|
||||
}
|
||||
if !aliasFlag.Hidden {
|
||||
t.Errorf("--sort must be Hidden")
|
||||
}
|
||||
if got := strings.Join(aliasFlag.Enum, ","); got != "asc,desc" {
|
||||
t.Errorf("--sort (alias) Enum = %q, want asc,desc", got)
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,7 @@ var ImChatSearch = common.Shortcut{
|
||||
{Name: "member-ids", Desc: "filter by member open_ids, comma-separated"},
|
||||
{Name: "is-manager", Type: "bool", Desc: "only show chats you created or manage"},
|
||||
{Name: "disable-search-by-user", Type: "bool", Desc: "disable search-by-member-name (default: search by member name first, then group name)"},
|
||||
{Name: "sort", Desc: "sort field (always descending): create_time | update_time | member_count", Enum: []string{"create_time", "update_time", "member_count"}},
|
||||
{Name: "sort-by", Hidden: true, Desc: "alias of --sort (hidden)", Enum: []string{"create_time_desc", "update_time_desc", "member_count_desc"}},
|
||||
{Name: "sort-by", Desc: "sort field (descending)", Enum: []string{"create_time_desc", "update_time_desc", "member_count_desc"}},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
{Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"},
|
||||
@@ -210,8 +209,8 @@ var ImChatSearch = common.Shortcut{
|
||||
// buildSearchChatBody builds the JSON request body for POST /im/v2/chats/search
|
||||
// from the runtime flag values. The query string is normalized via
|
||||
// normalizeChatSearchQuery (hyphenated terms get quoted). The "filter" object
|
||||
// is omitted when no filter flags are set; "sorter" is omitted when --sort
|
||||
// (and its hidden alias --sort-by) is unset.
|
||||
// is omitted when no filter flags are set; "sorter" is omitted when --sort-by
|
||||
// is empty.
|
||||
func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
|
||||
@@ -257,18 +256,9 @@ func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{}
|
||||
body["filter"] = filter
|
||||
}
|
||||
|
||||
// Build sorter (always descending). --sort maps field -> field_desc; the hidden
|
||||
// --sort-by alias is already the upstream value (pass-through). Omitted when unset.
|
||||
sorter := map[string]string{
|
||||
"create_time": "create_time_desc",
|
||||
"update_time": "update_time_desc",
|
||||
"member_count": "member_count_desc",
|
||||
}[runtime.Str("sort")]
|
||||
if old, ok := aliasFlagValue(runtime, "sort-by", "sort"); ok {
|
||||
sorter = old
|
||||
}
|
||||
if sorter != "" {
|
||||
body["sorter"] = sorter
|
||||
// Build sorters (always descending)
|
||||
if sortBy := runtime.Str("sort-by"); sortBy != "" {
|
||||
body["sorter"] = sortBy
|
||||
}
|
||||
|
||||
return body
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newSearchTestRT(t *testing.T, stringFlags map[string]string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
if stringFlags == nil {
|
||||
stringFlags = map[string]string{}
|
||||
}
|
||||
if _, ok := stringFlags["query"]; !ok {
|
||||
stringFlags["query"] = "team"
|
||||
}
|
||||
return newChatListTestRuntimeContext(t, stringFlags, nil)
|
||||
}
|
||||
|
||||
func TestChatSearch_SortMapping(t *testing.T) {
|
||||
cases := []struct{ sort, want string }{
|
||||
{"create_time", "create_time_desc"},
|
||||
{"update_time", "update_time_desc"},
|
||||
{"member_count", "member_count_desc"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.sort, func(t *testing.T) {
|
||||
rt := newSearchTestRT(t, map[string]string{"sort": c.sort})
|
||||
body := buildSearchChatBody(rt)
|
||||
if body["sorter"] != c.want {
|
||||
t.Fatalf("sort=%s -> sorter=%v, want %s", c.sort, body["sorter"], c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatSearch_SortOmittedWhenUnset: no --sort and no --sort-by -> sorter omitted.
|
||||
func TestChatSearch_SortOmittedWhenUnset(t *testing.T) {
|
||||
rt := newSearchTestRT(t, nil)
|
||||
body := buildSearchChatBody(rt)
|
||||
if _, present := body["sorter"]; present {
|
||||
t.Fatalf("sorter should be omitted when neither --sort nor --sort-by set")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatSearch_SortAliasParity: hidden --sort-by value is already the upstream
|
||||
// sorter (pass-through), so it must equal the mapped new --sort body.
|
||||
func TestChatSearch_SortAliasParity(t *testing.T) {
|
||||
pairs := []struct{ newVal, oldVal string }{
|
||||
{"create_time", "create_time_desc"},
|
||||
{"update_time", "update_time_desc"},
|
||||
{"member_count", "member_count_desc"},
|
||||
}
|
||||
for _, p := range pairs {
|
||||
t.Run(p.newVal, func(t *testing.T) {
|
||||
newBody := buildSearchChatBody(newSearchTestRT(t, map[string]string{"sort": p.newVal}))
|
||||
oldBody := buildSearchChatBody(newSearchTestRT(t, map[string]string{"sort-by": p.oldVal}))
|
||||
if newBody["sorter"] != oldBody["sorter"] {
|
||||
t.Fatalf("alias parity: new sorter=%v, old sorter=%v", newBody["sorter"], oldBody["sorter"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatSearch_SortNewWins(t *testing.T) {
|
||||
rt := newSearchTestRT(t, map[string]string{"sort": "member_count", "sort-by": "create_time_desc"})
|
||||
body := buildSearchChatBody(rt)
|
||||
if body["sorter"] != "member_count_desc" {
|
||||
t.Fatalf("new should win: sorter=%v, want member_count_desc", body["sorter"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatSearch_SortFlagSurface(t *testing.T) {
|
||||
var sortFlag, aliasFlag *common.Flag
|
||||
for i := range ImChatSearch.Flags {
|
||||
switch ImChatSearch.Flags[i].Name {
|
||||
case "sort":
|
||||
sortFlag = &ImChatSearch.Flags[i]
|
||||
case "sort-by":
|
||||
aliasFlag = &ImChatSearch.Flags[i]
|
||||
}
|
||||
}
|
||||
if sortFlag == nil || aliasFlag == nil {
|
||||
t.Fatalf("expected both --sort and --sort-by flags declared")
|
||||
}
|
||||
if sortFlag.Default != "" {
|
||||
t.Errorf("--sort must have no default (sorter omitted when unset), got %q", sortFlag.Default)
|
||||
}
|
||||
if got := strings.Join(sortFlag.Enum, ","); got != "create_time,update_time,member_count" {
|
||||
t.Errorf("--sort Enum = %q", got)
|
||||
}
|
||||
if !aliasFlag.Hidden {
|
||||
t.Errorf("--sort-by must be Hidden")
|
||||
}
|
||||
if got := strings.Join(aliasFlag.Enum, ","); got != "create_time_desc,update_time_desc,member_count_desc" {
|
||||
t.Errorf("--sort-by Enum = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,7 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "thread", Desc: "thread ID (om_xxx or omt_xxx)", Required: true},
|
||||
{Name: "order", Default: "asc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}},
|
||||
{Name: "sort", Hidden: true, Desc: "alias of --order (hidden)", Enum: []string{"asc", "desc"}},
|
||||
{Name: "sort", Default: "asc", Desc: "sort order", Enum: []string{"asc", "desc"}},
|
||||
{Name: "page-size", Default: "50", Desc: "page size (1-500)"},
|
||||
{Name: "page-token", Desc: "page token"},
|
||||
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
|
||||
@@ -40,10 +39,15 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
threadFlag := runtime.Str("thread")
|
||||
dir := resolveThreadsOrder(runtime)
|
||||
sortFlag := runtime.Str("sort")
|
||||
pageSizeStr := runtime.Str("page-size")
|
||||
pageToken := runtime.Str("page-token")
|
||||
|
||||
sortType := "ByCreateTimeAsc"
|
||||
if sortFlag == "desc" {
|
||||
sortType = "ByCreateTimeDesc"
|
||||
}
|
||||
|
||||
pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
|
||||
|
||||
d := common.NewDryRunAPI()
|
||||
@@ -53,12 +57,21 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
containerID = "<resolved_thread_id>"
|
||||
}
|
||||
|
||||
params := buildThreadsMessagesListParams(dir, containerID, pageSize, pageToken)
|
||||
params := map[string]interface{}{
|
||||
"container_id_type": "thread",
|
||||
"container_id": containerID,
|
||||
"sort_type": sortType,
|
||||
"page_size": pageSize,
|
||||
"card_msg_content_type": "raw_card_content",
|
||||
}
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
|
||||
d = d.
|
||||
GET("/open-apis/im/v1/messages").
|
||||
Params(toDryParams(params)).
|
||||
Set("thread", threadFlag).Set("order", dir).Set("page_size", pageSizeStr)
|
||||
Params(params).
|
||||
Set("thread", threadFlag).Set("sort", sortFlag).Set("page_size", pageSizeStr)
|
||||
if !runtime.Bool("no-reactions") {
|
||||
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
|
||||
Desc("Reaction enrichment: queries returned thread messages in batches of up to 20. Pass --no-reactions to skip.")
|
||||
@@ -84,12 +97,26 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := resolveThreadsOrder(runtime)
|
||||
sortFlag := runtime.Str("sort")
|
||||
pageToken := runtime.Str("page-token")
|
||||
|
||||
sortType := "ByCreateTimeAsc"
|
||||
if sortFlag == "desc" {
|
||||
sortType = "ByCreateTimeDesc"
|
||||
}
|
||||
|
||||
pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
|
||||
|
||||
params := buildThreadsMessagesListParams(dir, threadId, pageSize, pageToken)
|
||||
params := map[string][]string{
|
||||
"container_id_type": []string{"thread"},
|
||||
"container_id": []string{threadId},
|
||||
"sort_type": []string{sortType},
|
||||
"page_size": []string{strconv.Itoa(pageSize)},
|
||||
"card_msg_content_type": []string{"raw_card_content"},
|
||||
}
|
||||
if pageToken != "" {
|
||||
params["page_token"] = []string{pageToken}
|
||||
}
|
||||
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil)
|
||||
if err != nil {
|
||||
@@ -161,45 +188,3 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildThreadsMessagesListParams builds the upstream query params shared by
|
||||
// DryRun and Execute, so the asc/desc -> sort_type mapping lives in exactly one
|
||||
// place (precondition for the dry-run == real alias-parity test).
|
||||
func buildThreadsMessagesListParams(dir, containerID string, pageSize int, pageToken string) map[string][]string {
|
||||
sortType := "ByCreateTimeAsc"
|
||||
if dir == "desc" {
|
||||
sortType = "ByCreateTimeDesc"
|
||||
}
|
||||
params := map[string][]string{
|
||||
"container_id_type": {"thread"},
|
||||
"container_id": {containerID},
|
||||
"sort_type": {sortType},
|
||||
"page_size": {strconv.Itoa(pageSize)},
|
||||
"card_msg_content_type": {"raw_card_content"},
|
||||
}
|
||||
if pageToken != "" {
|
||||
params["page_token"] = []string{pageToken}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// resolveThreadsOrder picks --order, falling back to the hidden --sort alias.
|
||||
func resolveThreadsOrder(runtime *common.RuntimeContext) string {
|
||||
dir := runtime.Str("order")
|
||||
if old, ok := aliasFlagValue(runtime, "sort", "order"); ok {
|
||||
dir = old
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// toDryParams flattens single-valued query params to scalars for dry-run preview,
|
||||
// matching the historical dry-run JSON shape.
|
||||
func toDryParams(p map[string][]string) map[string]interface{} {
|
||||
out := make(map[string]interface{}, len(p))
|
||||
for k, v := range p {
|
||||
if len(v) > 0 {
|
||||
out[k] = v[0]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newThreadsTestRT(t *testing.T, stringFlags map[string]string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
if stringFlags == nil {
|
||||
stringFlags = map[string]string{}
|
||||
}
|
||||
if _, ok := stringFlags["thread"]; !ok {
|
||||
stringFlags["thread"] = "omt_test"
|
||||
}
|
||||
return newChatListTestRuntimeContext(t, stringFlags, nil)
|
||||
}
|
||||
|
||||
func TestThreadsMessagesList_OrderMapping(t *testing.T) {
|
||||
cases := []struct{ order, want string }{
|
||||
{"asc", "ByCreateTimeAsc"},
|
||||
{"desc", "ByCreateTimeDesc"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.order, func(t *testing.T) {
|
||||
got := buildThreadsMessagesListParams(c.order, "omt_test", 50, "")
|
||||
if v := got["sort_type"][0]; v != c.want {
|
||||
t.Fatalf("order=%s -> sort_type=%s, want %s", c.order, v, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestThreadsMessagesList_OrderAliasParity proves DryRun(--sort dir) == DryRun(--order dir).
|
||||
// This is the test the refactor exists to make meaningful (single shared mapping).
|
||||
func TestThreadsMessagesList_OrderAliasParity(t *testing.T) {
|
||||
for _, dir := range []string{"asc", "desc"} {
|
||||
t.Run(dir, func(t *testing.T) {
|
||||
newRT := newThreadsTestRT(t, map[string]string{"order": dir})
|
||||
oldRT := newThreadsTestRT(t, map[string]string{"sort": dir})
|
||||
a := mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), newRT))
|
||||
b := mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), oldRT))
|
||||
if a != b {
|
||||
t.Fatalf("alias parity broken:\n new=%s\n old=%s", a, b)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreadsMessagesList_OrderFlagSurface(t *testing.T) {
|
||||
var orderFlag, aliasFlag *common.Flag
|
||||
for i := range ImThreadsMessagesList.Flags {
|
||||
switch ImThreadsMessagesList.Flags[i].Name {
|
||||
case "order":
|
||||
orderFlag = &ImThreadsMessagesList.Flags[i]
|
||||
case "sort":
|
||||
aliasFlag = &ImThreadsMessagesList.Flags[i]
|
||||
}
|
||||
}
|
||||
if orderFlag == nil || aliasFlag == nil {
|
||||
t.Fatalf("expected both --order and --sort flags declared")
|
||||
}
|
||||
if orderFlag.Default != "asc" {
|
||||
t.Errorf("--order Default = %q, want asc", orderFlag.Default)
|
||||
}
|
||||
if got := strings.Join(orderFlag.Enum, ","); got != "asc,desc" {
|
||||
t.Errorf("--order Enum = %q, want asc,desc", got)
|
||||
}
|
||||
if !aliasFlag.Hidden {
|
||||
t.Errorf("--sort must be Hidden")
|
||||
}
|
||||
if aliasFlag.Default != "" {
|
||||
t.Errorf("--sort (hidden alias) must not carry a Default, got %q", aliasFlag.Default)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
// aliasFlagValue handles a renamed sort flag whose old name is kept as a silent
|
||||
// alias. It returns (oldValue, true) only when the old flag was explicitly used
|
||||
// and the new one was not; otherwise ("", false) — meaning "no old flag, or both
|
||||
// given (new wins), so use the new-flag logic". Pure function, no IO: callable
|
||||
// from DryRun, Execute, and minimal test fixtures alike. Never prints anything.
|
||||
func aliasFlagValue(rt *common.RuntimeContext, oldName, newName string) (string, bool) {
|
||||
if rt.Changed(oldName) && !rt.Changed(newName) {
|
||||
return rt.Str(oldName), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// newAliasTestRT registers a new flag (with a default) and an old flag, then
|
||||
// sets only the flags present in `set` — so Changed() reflects exactly which
|
||||
// flags were "passed on the command line".
|
||||
func newAliasTestRT(t *testing.T, newName, newDefault, oldName string, set map[string]string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String(newName, newDefault, "")
|
||||
cmd.Flags().String(oldName, "", "")
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags() error = %v", err)
|
||||
}
|
||||
for k, v := range set {
|
||||
if err := cmd.Flags().Set(k, v); err != nil {
|
||||
t.Fatalf("Set(%q) error = %v", k, err)
|
||||
}
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
func TestAliasFlagValue(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
set map[string]string
|
||||
wantVal string
|
||||
wantOK bool
|
||||
}{
|
||||
{"only old set", map[string]string{"sort-type": "ByActiveTimeDesc"}, "ByActiveTimeDesc", true},
|
||||
{"neither set", nil, "", false},
|
||||
{"only new set", map[string]string{"sort": "active_time"}, "", false},
|
||||
{"both set new wins", map[string]string{"sort": "active_time", "sort-type": "ByCreateTimeAsc"}, "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
rt := newAliasTestRT(t, "sort", "create_time", "sort-type", c.set)
|
||||
gotVal, gotOK := aliasFlagValue(rt, "sort-type", "sort")
|
||||
if gotVal != c.wantVal || gotOK != c.wantOK {
|
||||
t.Fatalf("aliasFlagValue() = (%q, %v), want (%q, %v)", gotVal, gotOK, c.wantVal, c.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,19 +9,19 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// MailMessage is the `+message` shortcut: fetch full content of one email
|
||||
// by one message ID (normalized body + attachments / inline metadata).
|
||||
// MailMessage is the `+message` shortcut: fetch full content of a single
|
||||
// email by message ID (normalized body + attachments / inline metadata).
|
||||
var MailMessage = common.Shortcut{
|
||||
Service: "mail",
|
||||
Command: "+message",
|
||||
Description: "Use only when reading full content for one email by one message ID. For multiple message IDs, use mail +messages; do not loop mail +message. Returns normalized body content plus attachments metadata, including inline images.",
|
||||
Description: "Use when reading full content for a single email by message ID. Returns normalized body content plus attachments metadata, including inline images.",
|
||||
Risk: "read",
|
||||
Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
|
||||
{Name: "message-id", Desc: "Required. Single email message ID only. For multiple IDs, use mail +messages --message-ids.", Required: true},
|
||||
{Name: "message-id", Desc: "Required. Email message ID", Required: true},
|
||||
{Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"},
|
||||
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
|
||||
},
|
||||
|
||||
@@ -18,19 +18,19 @@ type mailMessagesOutput struct {
|
||||
}
|
||||
|
||||
// MailMessages is the `+messages` shortcut: batch-fetch full content for
|
||||
// multiple message IDs, chunking requests into batches of 20 while preserving
|
||||
// request order.
|
||||
// multiple message IDs, chunking backend calls into batches of 20 while
|
||||
// preserving request order.
|
||||
var MailMessages = common.Shortcut{
|
||||
Service: "mail",
|
||||
Command: "+messages",
|
||||
Description: "Use when reading full content for multiple emails by message ID. You may pass more than 20 IDs; the CLI handles them in batches of 20 and merges output while preserving request order.",
|
||||
Description: "Use when reading full content for multiple emails by message ID. Prefer this shortcut over calling raw mail user_mailbox.messages batch_get directly, because it base64url-decodes body fields and returns normalized per-message output that is easier to consume.",
|
||||
Risk: "read",
|
||||
Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
|
||||
{Name: "message-ids", Desc: `Required. Comma-separated email message IDs. You may pass more than 20 IDs; the CLI handles them in batches of 20 and merges output. Example: "<id1>,<id2>,<id3>"`, Required: true},
|
||||
{Name: "message-ids", Desc: `Required. Comma-separated email message IDs. Example: "id1,id2,id3"`, Required: true},
|
||||
{Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"},
|
||||
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
|
||||
},
|
||||
@@ -52,7 +52,7 @@ var MailMessages = common.Shortcut{
|
||||
body["message_ids"] = messageIDs
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Fetch multiple emails; execution chunks every 20 IDs and merges output").
|
||||
Desc("Fetch multiple emails via messages.batch_get (auto-chunked in batches of 20 IDs during execution)").
|
||||
POST(mailboxPath(mailboxID, "messages", "batch_get")).
|
||||
Body(body)
|
||||
},
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestMailMessageHelpClarifiesSingleMessageOnly(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
|
||||
err := runMountedMailShortcutWithCobraOutput(t, MailMessage, []string{"+message", "-h"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("help returned error: %v", err)
|
||||
}
|
||||
|
||||
help := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Use only when reading full content for one email by one message ID",
|
||||
"For multiple message IDs, use mail +messages; do not loop mail +message",
|
||||
"Single email message ID only",
|
||||
"mail +messages --message-ids",
|
||||
} {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("help missing %q\n%s", want, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailMessagesHelpClarifiesBatchGetChunkingAndLimits(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
|
||||
err := runMountedMailShortcutWithCobraOutput(t, MailMessages, []string{"+messages", "-h"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("help returned error: %v", err)
|
||||
}
|
||||
|
||||
help := stdout.String()
|
||||
for _, want := range []string{
|
||||
"multiple emails by message ID",
|
||||
"handles them in batches of 20 and merges output",
|
||||
"Comma-separated email message IDs",
|
||||
"You may pass more than 20 IDs",
|
||||
} {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("help missing %q\n%s", want, help)
|
||||
}
|
||||
}
|
||||
for _, disallowed := range []string{"messages.batch_get", "OAPI Meta", "gateway config", "50 IDs", "50 个"} {
|
||||
if strings.Contains(help, disallowed) {
|
||||
t.Fatalf("help must not expose internal wording %q\n%s", disallowed, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailMessagesDryRunMentionsBatchGetChunkingAndMerge(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
messageIDs := []string{
|
||||
validMessageIDForTest("dry-run-1"),
|
||||
validMessageIDForTest("dry-run-2"),
|
||||
}
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessages, []string{
|
||||
"+messages", "--message-ids", strings.Join(messageIDs, ","), "--dry-run", "--format", "json",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run returned error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"chunks every 20 IDs",
|
||||
"merges output",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run missing %q\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailTriageTableHintRoutesSingleAndMultipleReads(t *testing.T) {
|
||||
f, stdout, stderr, reg := mailShortcutTestFactory(t)
|
||||
registerTriageReadHintStubs(reg)
|
||||
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{
|
||||
"+triage", "--max", "1",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("triage returned error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
errOut := stderr.String()
|
||||
for _, want := range []string{
|
||||
"tip: read full content:",
|
||||
"single message use mail +message --message-id <id>",
|
||||
"multiple messages use mail +messages --message-ids <id1>,<id2>,<id3>",
|
||||
} {
|
||||
if !strings.Contains(errOut, want) {
|
||||
t.Fatalf("stderr missing %q\n%s", want, errOut)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailTriageJSONDoesNotEmitReadHint(t *testing.T) {
|
||||
f, stdout, stderr, reg := mailShortcutTestFactory(t)
|
||||
registerTriageReadHintStubs(reg)
|
||||
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{
|
||||
"+triage", "--format", "json", "--max", "1",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("triage returned error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
if strings.Contains(stderr.String(), "tip: read full content:") {
|
||||
t.Fatalf("json output must not emit table read hint\nstderr=%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailMessagesExecuteChunksTwentyOneIDsIntoTwoBatchGetCalls(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/messages/batch_get",
|
||||
Reusable: true,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"messages": []interface{}{}},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
ids := make([]string, 21)
|
||||
for i := range ids {
|
||||
ids[i] = validMessageIDForTest(fmt.Sprintf("batch-%02d", i+1))
|
||||
}
|
||||
err := runMountedMailShortcut(t, MailMessages, []string{
|
||||
"+messages", "--message-ids", strings.Join(ids, ","),
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("messages returned error: %v", err)
|
||||
}
|
||||
|
||||
if got := len(stub.CapturedBodies); got != 2 {
|
||||
t.Fatalf("expected 2 batch_get calls, got %d", got)
|
||||
}
|
||||
assertBatchGetMessageIDCount(t, stub.CapturedBodies[0], 20)
|
||||
assertBatchGetMessageIDCount(t, stub.CapturedBodies[1], 1)
|
||||
}
|
||||
|
||||
func registerTriageReadHintStubs(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/user_mailboxes/me/messages",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{"msg_1"},
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/messages/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"messages": []interface{}{
|
||||
map[string]interface{}{
|
||||
"message_id": "msg_1",
|
||||
"subject": "Quarterly update",
|
||||
"date": "Thu, 04 Jun 2026 10:00:00 +0800",
|
||||
"from": map[string]interface{}{"name": "Alice", "mail_address": "alice@example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func assertBatchGetMessageIDCount(t *testing.T, body []byte, want int) {
|
||||
t.Helper()
|
||||
var payload struct {
|
||||
MessageIDs []string `json:"message_ids"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("unmarshal batch_get body: %v\n%s", err, string(body))
|
||||
}
|
||||
if got := len(payload.MessageIDs); got != want {
|
||||
t.Fatalf("message_ids count mismatch: got %d want %d body=%s", got, want, string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func runMountedMailShortcutWithCobraOutput(t *testing.T, shortcut common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "test"}
|
||||
parent.SetOut(stdout)
|
||||
parent.SetErr(stdout)
|
||||
shortcut.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
stdout.Reset()
|
||||
return parent.Execute()
|
||||
}
|
||||
@@ -322,10 +322,9 @@ var MailTriage = common.Shortcut{
|
||||
fmt.Fprintln(runtime.IO().ErrOut, hint.String())
|
||||
}
|
||||
if mailbox != "me" {
|
||||
quotedMailbox := shellQuote(mailbox)
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message use mail +message --mailbox "+quotedMailbox+" --message-id <id>; multiple messages use mail +messages --mailbox "+quotedMailbox+" --message-ids <id1>,<id2>,<id3>")
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "tip: use mail +message --mailbox "+shellQuote(mailbox)+" --message-id <id> to read full content")
|
||||
} else {
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message use mail +message --message-id <id>; multiple messages use mail +messages --message-ids <id1>,<id2>,<id3>")
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "tip: use mail +message --message-id <id> to read full content")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// minutes +detail — query minute details with selective artifact flags
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const minutesDetailLogPrefix = "[minutes +detail]"
|
||||
|
||||
// Error codes from the minutes API.
|
||||
const minutesDetailNoReadPermissionCode = 2091005
|
||||
|
||||
var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)
|
||||
|
||||
var scopesDetailMinuteTokens = []string{
|
||||
"minutes:minutes.basic:read",
|
||||
"minutes:minutes.artifacts:read",
|
||||
}
|
||||
|
||||
// minuteDetailItem represents a single minute detail result.
|
||||
type minuteDetailItem struct {
|
||||
MinuteToken string `json:"minute_token"`
|
||||
Title string `json:"title"`
|
||||
NoteID string `json:"note_id"`
|
||||
Artifacts map[string]any `json:"artifacts,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// fetchMinuteDetail queries a single minute's metadata and selected artifacts.
|
||||
func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) *minuteDetailItem {
|
||||
data, err := runtime.CallAPITyped(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||
if err != nil {
|
||||
result := &minuteDetailItem{MinuteToken: minuteToken}
|
||||
if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
|
||||
result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken)
|
||||
} else {
|
||||
result.Error = fmt.Sprintf("failed to query minute: %v", err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
minute, _ := data["minute"].(map[string]any)
|
||||
if minute == nil {
|
||||
return &minuteDetailItem{MinuteToken: minuteToken, Error: "minute not found"}
|
||||
}
|
||||
|
||||
result := &minuteDetailItem{MinuteToken: minuteToken}
|
||||
if v, ok := minute["title"].(string); ok && v != "" {
|
||||
result.Title = v
|
||||
}
|
||||
if v, ok := minute["note_id"].(string); ok && v != "" {
|
||||
result.NoteID = v
|
||||
}
|
||||
|
||||
// Fetch artifacts selectively based on flags
|
||||
needSummary := runtime.Bool("summary")
|
||||
needTodo := runtime.Bool("todo")
|
||||
needChapter := runtime.Bool("chapter")
|
||||
needTranscript := runtime.Bool("transcript")
|
||||
needKeyword := runtime.Bool("keyword")
|
||||
|
||||
if needSummary || needTodo || needChapter || needTranscript || needKeyword {
|
||||
artData, err := runtime.CallAPITyped(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "%s failed to fetch artifacts for %s: %v\n", minutesDetailLogPrefix, minuteToken, err)
|
||||
} else {
|
||||
artifacts := make(map[string]any)
|
||||
if needSummary {
|
||||
if v, ok := artData["summary"].(string); ok && v != "" {
|
||||
artifacts["summary"] = v
|
||||
} else {
|
||||
artifacts["summary"] = ""
|
||||
}
|
||||
}
|
||||
if needTodo {
|
||||
if v, ok := artData["minute_todos"].([]any); ok && len(v) > 0 {
|
||||
artifacts["todos"] = v
|
||||
} else {
|
||||
artifacts["todos"] = []any{}
|
||||
}
|
||||
}
|
||||
if needChapter {
|
||||
if v, ok := artData["minute_chapters"].([]any); ok && len(v) > 0 {
|
||||
artifacts["chapters"] = v
|
||||
} else {
|
||||
artifacts["chapters"] = []any{}
|
||||
}
|
||||
}
|
||||
if needKeyword {
|
||||
if v, ok := artData["keywords"].([]any); ok && len(v) > 0 {
|
||||
artifacts["keywords"] = v
|
||||
} else {
|
||||
artifacts["keywords"] = []any{}
|
||||
}
|
||||
}
|
||||
if needTranscript {
|
||||
if v, ok := artData["transcript"].(string); ok && v != "" {
|
||||
if path := saveDetailTranscript(runtime, minuteToken, result.Title, []byte(v)); path != "" {
|
||||
artifacts["transcript_file"] = path
|
||||
} else {
|
||||
artifacts["transcript_file"] = ""
|
||||
}
|
||||
} else {
|
||||
artifacts["transcript_file"] = ""
|
||||
}
|
||||
}
|
||||
result.Artifacts = artifacts
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// saveDetailTranscript persists transcript bytes to the canonical artifact path.
|
||||
func saveDetailTranscript(runtime *common.RuntimeContext, minuteToken, title string, content []byte) string {
|
||||
errOut := runtime.IO().ErrOut
|
||||
dirName := common.DefaultMinuteArtifactDir(minuteToken)
|
||||
transcriptPath := filepath.Join(dirName, common.DefaultTranscriptFileName)
|
||||
|
||||
if !runtime.Bool("overwrite") {
|
||||
if _, statErr := runtime.FileIO().Stat(transcriptPath); statErr == nil {
|
||||
fmt.Fprintf(errOut, "%s transcript already exists: %s (use --overwrite to replace)\n", minutesDetailLogPrefix, transcriptPath)
|
||||
return transcriptPath
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(errOut, "%s writing transcript: %s\n", minutesDetailLogPrefix, transcriptPath)
|
||||
if _, err := runtime.FileIO().Save(transcriptPath, fileio.SaveOptions{}, bytes.NewReader(content)); err != nil {
|
||||
fmt.Fprintf(errOut, "%s failed to write transcript: %v\n", minutesDetailLogPrefix, err)
|
||||
return ""
|
||||
}
|
||||
return transcriptPath
|
||||
}
|
||||
|
||||
// MinutesDetail queries minute details with selective artifact flags.
|
||||
var MinutesDetail = common.Shortcut{
|
||||
Service: "minutes",
|
||||
Command: "+detail",
|
||||
Description: "Query minute details with selective artifact flags (summary, todo, chapter, transcript, keyword)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"minutes:minutes.basic:read", "minutes:minutes.artifacts:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "minute-tokens", Desc: "minute tokens, comma-separated for batch", Required: true},
|
||||
{Name: "summary", Type: "bool", Desc: "include summary"},
|
||||
{Name: "todo", Type: "bool", Desc: "include todos"},
|
||||
{Name: "chapter", Type: "bool", Desc: "include chapters"},
|
||||
{Name: "transcript", Type: "bool", Desc: "include transcript (saved to file)"},
|
||||
{Name: "keyword", Type: "bool", Desc: "include keywords"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing transcript files"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
|
||||
const maxBatchSize = 50
|
||||
if len(tokens) > maxBatchSize {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-tokens: too many tokens (%d), maximum is %d", len(tokens), maxBatchSize).WithParam("--minute-tokens")
|
||||
}
|
||||
for _, token := range tokens {
|
||||
if !validMinuteTokenDetail.MatchString(token) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid minute token %q: must contain only lowercase alphanumeric characters", token).WithParam("--minute-tokens")
|
||||
}
|
||||
}
|
||||
// dynamic scope check
|
||||
result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID))
|
||||
if err == nil && result != nil && result.Scopes != "" {
|
||||
if missing := auth.MissingScopes(result.Scopes, scopesDetailMinuteTokens); len(missing) > 0 {
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"missing required scope(s): %s", strings.Join(missing, ", ")).
|
||||
WithHint("run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")).
|
||||
WithMissingScopes(missing...).
|
||||
WithIdentity(string(runtime.As()))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
tokens := runtime.Str("minute-tokens")
|
||||
d := common.NewDryRunAPI().
|
||||
GET("/open-apis/minutes/v1/minutes/{minute_token}").
|
||||
Set("minute_tokens", common.SplitCSV(tokens))
|
||||
|
||||
if runtime.Bool("summary") || runtime.Bool("todo") || runtime.Bool("chapter") || runtime.Bool("transcript") || runtime.Bool("keyword") {
|
||||
d.GET("/open-apis/minutes/v1/minutes/{minute_token}/artifacts")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
errOut := runtime.IO().ErrOut
|
||||
minuteTokens := common.SplitCSV(runtime.Str("minute-tokens"))
|
||||
results := make([]*minuteDetailItem, 0, len(minuteTokens))
|
||||
|
||||
const batchDelay = 100 * time.Millisecond
|
||||
fmt.Fprintf(errOut, "%s querying %d minute_token(s)\n", minutesDetailLogPrefix, len(minuteTokens))
|
||||
for i, token := range minuteTokens {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if i > 0 {
|
||||
time.Sleep(batchDelay)
|
||||
}
|
||||
fmt.Fprintf(errOut, "%s querying minute_token=%s ...\n", minutesDetailLogPrefix, token)
|
||||
results = append(results, fetchMinuteDetail(ctx, runtime, token))
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
for _, r := range results {
|
||||
if r.Error == "" {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(errOut, "%s done: %d total, %d succeeded, %d failed\n", minutesDetailLogPrefix, len(results), successCount, len(results)-successCount)
|
||||
|
||||
if successCount == 0 && len(results) > 0 {
|
||||
return runtime.OutPartialFailure(map[string]any{"minutes": results}, &output.Meta{Count: len(results)})
|
||||
}
|
||||
|
||||
outData := map[string]any{"minutes": results}
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(results)}, func(w io.Writer) {
|
||||
if len(results) == 0 {
|
||||
fmt.Fprintln(w, "No minutes.")
|
||||
return
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
for _, r := range results {
|
||||
row := map[string]interface{}{"minute_token": r.MinuteToken}
|
||||
if r.Error != "" {
|
||||
row["status"] = "FAIL"
|
||||
row["error"] = r.Error
|
||||
} else {
|
||||
row["status"] = "OK"
|
||||
row["title"] = r.Title
|
||||
row["note_id"] = r.NoteID
|
||||
if len(r.Artifacts) > 0 {
|
||||
var parts []string
|
||||
if _, ok := r.Artifacts["summary"]; ok {
|
||||
parts = append(parts, "summary")
|
||||
}
|
||||
if _, ok := r.Artifacts["todos"]; ok {
|
||||
parts = append(parts, "todo")
|
||||
}
|
||||
if _, ok := r.Artifacts["chapters"]; ok {
|
||||
parts = append(parts, "chapter")
|
||||
}
|
||||
if _, ok := r.Artifacts["keywords"]; ok {
|
||||
parts = append(parts, "keyword")
|
||||
}
|
||||
if _, ok := r.Artifacts["transcript_file"]; ok {
|
||||
parts = append(parts, "transcript")
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
row["artifacts"] = strings.Join(parts, ", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
fmt.Fprintf(w, "\n%d minute(s), %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,372 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var detailWarmOnce sync.Once
|
||||
|
||||
func detailWarmTokenCache(t *testing.T) {
|
||||
t.Helper()
|
||||
detailWarmOnce.Do(func() {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/warm",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
||||
})
|
||||
s := common.Shortcut{
|
||||
Service: "test",
|
||||
Command: "+warm",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := rctx.CallAPI("GET", "/open-apis/test/v1/warm", nil, nil)
|
||||
return err
|
||||
},
|
||||
}
|
||||
parent := &cobra.Command{Use: "test"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+warm"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
parent.Execute()
|
||||
})
|
||||
}
|
||||
|
||||
func detailMountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
detailWarmTokenCache(t)
|
||||
parent := &cobra.Command{Use: "minutes"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
func detailBotExec(t *testing.T, name string, f *cmdutil.Factory, fn func(context.Context, *common.RuntimeContext) error) error {
|
||||
t.Helper()
|
||||
detailWarmTokenCache(t)
|
||||
s := common.Shortcut{
|
||||
Service: "test",
|
||||
Command: "+" + name,
|
||||
AuthTypes: []string{"bot"},
|
||||
HasFormat: true,
|
||||
Execute: fn,
|
||||
}
|
||||
parent := &cobra.Command{Use: "minutes"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+" + name, "--format", "json"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func detailMinuteGetStub(token, noteID, title string) *httpmock.Stub {
|
||||
minute := map[string]interface{}{"title": title}
|
||||
if noteID != "" {
|
||||
minute["note_id"] = noteID
|
||||
}
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/minutes/v1/minutes/" + token,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"minute": minute},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func detailArtifactsStub(token, transcript string) *httpmock.Stub {
|
||||
data := map[string]interface{}{
|
||||
"summary": "Test summary content",
|
||||
"minute_todos": []interface{}{map[string]interface{}{"content": "Buy milk"}},
|
||||
"minute_chapters": []interface{}{map[string]interface{}{"title": "Intro", "summary_content": "Opening"}},
|
||||
"keywords": []interface{}{"budget", "roadmap"},
|
||||
}
|
||||
if transcript != "" {
|
||||
data["transcript"] = transcript
|
||||
}
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/minutes/v1/minutes/" + token + "/artifacts",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": data,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Validation_MissingMinuteTokens(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for missing --minute-tokens")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Validation_InvalidToken(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "INVALID!", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for invalid token")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Param != "--minute-tokens" {
|
||||
t.Errorf("Param = %q, want --minute-tokens", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Validation_BatchLimit(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
tokens := make([]string, 51)
|
||||
for i := range tokens {
|
||||
tokens[i] = fmt.Sprintf("tok%d", i)
|
||||
}
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", strings.Join(tokens, ","), "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected batch limit error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "too many tokens") {
|
||||
t.Errorf("expected 'too many tokens' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DryRun tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDetail_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tok001", "--dry-run", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "/open-apis/minutes/v1/minutes/") {
|
||||
t.Errorf("dry-run should show minutes API path, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_DryRun_WithArtifactFlags(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tok001", "--summary", "--todo", "--dry-run", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "artifacts") {
|
||||
t.Errorf("dry-run should show artifacts API path when artifact flags are set, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execute tests with mocked HTTP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDetail_Execute_BasicInfo(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailMinuteGetStub("tokbasic", "", "Test Meeting"))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokbasic", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse output: %v", err)
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
minutes, _ := data["minutes"].([]any)
|
||||
if len(minutes) != 1 {
|
||||
t.Fatalf("expected 1 minute, got %d", len(minutes))
|
||||
}
|
||||
m, _ := minutes[0].(map[string]any)
|
||||
if m["minute_token"] != "tokbasic" {
|
||||
t.Errorf("minute_token = %v, want tokbasic", m["minute_token"])
|
||||
}
|
||||
if m["title"] != "Test Meeting" {
|
||||
t.Errorf("title = %v, want Test Meeting", m["title"])
|
||||
}
|
||||
if _, ok := m["note_id"]; ok {
|
||||
t.Errorf("note_id should be omitted when minute has no note_id, got %v", m["note_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Execute_WithSummaryAndTodo(t *testing.T) {
|
||||
chdirForDetailTest(t)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailMinuteGetStub("tokart", "note_art", "Artifact Meeting"))
|
||||
reg.Register(detailArtifactsStub("tokart", ""))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokart", "--summary", "--todo", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse output: %v", err)
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
minutes, _ := data["minutes"].([]any)
|
||||
if len(minutes) != 1 {
|
||||
t.Fatalf("expected 1 minute, got %d", len(minutes))
|
||||
}
|
||||
m, _ := minutes[0].(map[string]any)
|
||||
if m["note_id"] != "note_art" {
|
||||
t.Errorf("note_id = %v, want note_art", m["note_id"])
|
||||
}
|
||||
arts, _ := m["artifacts"].(map[string]any)
|
||||
if arts == nil {
|
||||
t.Fatal("expected artifacts to be present")
|
||||
}
|
||||
if _, ok := arts["summary"]; !ok {
|
||||
t.Error("expected summary in artifacts")
|
||||
}
|
||||
if _, ok := arts["todos"]; !ok {
|
||||
t.Error("expected todos in artifacts")
|
||||
}
|
||||
// chapter and keywords should NOT be present since flags not set
|
||||
if _, ok := arts["chapters"]; ok {
|
||||
t.Error("chapters should not be present when --chapter not set")
|
||||
}
|
||||
if _, ok := arts["keywords"]; ok {
|
||||
t.Error("keywords should not be present when --keyword not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Execute_NoArtifactFlags(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailMinuteGetStub("toknoart", "", "No Artifacts"))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "toknoart", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse output: %v", err)
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
minutes, _ := data["minutes"].([]any)
|
||||
if len(minutes) != 1 {
|
||||
t.Fatalf("expected 1 minute, got %d", len(minutes))
|
||||
}
|
||||
m, _ := minutes[0].(map[string]any)
|
||||
if _, ok := m["artifacts"]; ok {
|
||||
t.Error("artifacts should not be present when no artifact flags set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Execute_Transcript(t *testing.T) {
|
||||
chdirForDetailTest(t)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(detailMinuteGetStub("toktrans", "", "Transcript Meeting"))
|
||||
reg.Register(detailArtifactsStub("toktrans", "speaker1: hello world\n"))
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "toktrans", "--transcript", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Check transcript file was saved
|
||||
wantPath := "minutes/toktrans/transcript.txt"
|
||||
data, err := os.ReadFile(wantPath)
|
||||
if err != nil {
|
||||
t.Fatalf("expected file at %s: %v", wantPath, err)
|
||||
}
|
||||
if string(data) != "speaker1: hello world\n" {
|
||||
t.Errorf("content mismatch: %q", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Execute_MinuteNotFound(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/minutes/v1/minutes/tokbad",
|
||||
Body: map[string]interface{}{"code": 2091004, "msg": "not found"},
|
||||
})
|
||||
|
||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokbad", "--as", "user"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial failure error")
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure function tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestValidMinuteTokenDetail(t *testing.T) {
|
||||
tests := []struct {
|
||||
token string
|
||||
valid bool
|
||||
}{
|
||||
{"abc123", true},
|
||||
{"obcnmgn1429t5xt9j82i1p3h", true},
|
||||
{"INVALID!", false},
|
||||
{"has-space", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := validMinuteTokenDetail.MatchString(tt.token)
|
||||
if got != tt.valid {
|
||||
t.Errorf("validMinuteTokenDetail(%q) = %v, want %v", tt.token, got, tt.valid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// chdirForDetailTest switches cwd to a temp dir for the test.
|
||||
func chdirForDetailTest(t *testing.T) string {
|
||||
t.Helper()
|
||||
orig, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(orig) })
|
||||
return dir
|
||||
}
|
||||
@@ -184,6 +184,12 @@ func minuteSearchAppLink(item map[string]interface{}) string {
|
||||
return common.GetString(meta, "app_link")
|
||||
}
|
||||
|
||||
// minuteSearchAvatar extracts the avatar URL from a search result item.
|
||||
func minuteSearchAvatar(item map[string]interface{}) string {
|
||||
meta := common.GetMap(item, "meta_data")
|
||||
return common.GetString(meta, "avatar")
|
||||
}
|
||||
|
||||
// buildMinuteSearchRows converts API items into pretty output rows.
|
||||
func buildMinuteSearchRows(items []interface{}) []map[string]interface{} {
|
||||
rows := make([]map[string]interface{}, 0, len(items))
|
||||
@@ -197,27 +203,12 @@ func buildMinuteSearchRows(items []interface{}) []map[string]interface{} {
|
||||
"display_info": common.TruncateStr(minuteSearchDisplayInfo(item), 40),
|
||||
"description": common.TruncateStr(minuteSearchDescription(item), 40),
|
||||
"app_link": common.TruncateStr(minuteSearchAppLink(item), 80),
|
||||
"avatar": common.TruncateStr(minuteSearchAvatar(item), 80),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// stripAvatarFromItems removes meta_data.avatar from each search item in place
|
||||
// so the structured output does not surface avatars to AI agents.
|
||||
func stripAvatarFromItems(items []interface{}) {
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
meta, _ := item["meta_data"].(map[string]interface{})
|
||||
if meta == nil {
|
||||
continue
|
||||
}
|
||||
delete(meta, "avatar")
|
||||
}
|
||||
}
|
||||
|
||||
// MinutesSearch searches minutes by keyword, owners, participants, and time range.
|
||||
var MinutesSearch = common.Shortcut{
|
||||
Service: "minutes",
|
||||
@@ -307,13 +298,13 @@ var MinutesSearch = common.Shortcut{
|
||||
}
|
||||
|
||||
items := minuteSearchItems(data)
|
||||
stripAvatarFromItems(items)
|
||||
hasMore, _ := data["has_more"].(bool)
|
||||
pageToken, _ := data["page_token"].(string)
|
||||
rows := buildMinuteSearchRows(items)
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"items": items,
|
||||
"total": data["total"],
|
||||
"has_more": data["has_more"],
|
||||
"page_token": data["page_token"],
|
||||
}
|
||||
|
||||
@@ -526,7 +526,7 @@ func TestMinutesSearchExecuteRendersRowsAndMoreHint(t *testing.T) {
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"minute_1", "周会摘要", "周会纪要", "https://meetings.feishu.cn/minutes/obcn123", "next_token", "more available"} {
|
||||
for _, want := range []string{"minute_1", "周会摘要", "周会纪要", "https://meetings.feishu.cn/minutes/obcn123", "https://p3-lark-file.byteimg.com/img/xxxx.jpg", "next_token", "more available"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output missing %q, got: %s", want, out)
|
||||
}
|
||||
@@ -663,6 +663,7 @@ func TestMinuteSearchFieldExtractors(t *testing.T) {
|
||||
"meta_data": map[string]interface{}{
|
||||
"description": "周会纪要",
|
||||
"app_link": "https://meetings.feishu.cn/minutes/obcn123",
|
||||
"avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -678,6 +679,9 @@ func TestMinuteSearchFieldExtractors(t *testing.T) {
|
||||
if got := minuteSearchAppLink(item); got != "https://meetings.feishu.cn/minutes/obcn123" {
|
||||
t.Fatalf("minuteSearchAppLink() = %q", got)
|
||||
}
|
||||
if got := minuteSearchAvatar(item); got != "https://p3-lark-file.byteimg.com/img/xxxx.jpg" {
|
||||
t.Fatalf("minuteSearchAvatar() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinuteSearchFieldExtractorsFallbacks verifies extractors keep working for alternate sample data.
|
||||
@@ -690,6 +694,7 @@ func TestMinuteSearchFieldExtractorsFallbacks(t *testing.T) {
|
||||
"meta_data": map[string]interface{}{
|
||||
"description": "回退纪要",
|
||||
"app_link": "https://meetings.feishu.cn/minutes/fallback",
|
||||
"avatar": "https://p3-lark-file.byteimg.com/img/fallback.jpg",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -702,6 +707,9 @@ func TestMinuteSearchFieldExtractorsFallbacks(t *testing.T) {
|
||||
if got := minuteSearchAppLink(item); got != "https://meetings.feishu.cn/minutes/fallback" {
|
||||
t.Fatalf("minuteSearchAppLink() = %q", got)
|
||||
}
|
||||
if got := minuteSearchAvatar(item); got != "https://p3-lark-file.byteimg.com/img/fallback.jpg" {
|
||||
t.Fatalf("minuteSearchAvatar() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinuteSearchFieldExtractorsMissingMetaData verifies extractors fall back to empty values without metadata.
|
||||
@@ -722,32 +730,7 @@ func TestMinuteSearchFieldExtractorsMissingMetaData(t *testing.T) {
|
||||
if got := minuteSearchAppLink(item); got != "" {
|
||||
t.Fatalf("minuteSearchAppLink() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStripAvatarFromItems verifies the avatar field is removed from items in place.
|
||||
func TestStripAvatarFromItems(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
items := []interface{}{
|
||||
map[string]interface{}{
|
||||
"token": "minute_1",
|
||||
"meta_data": map[string]interface{}{
|
||||
"description": "周会纪要",
|
||||
"avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg",
|
||||
},
|
||||
},
|
||||
nil,
|
||||
map[string]interface{}{"token": "minute_no_meta"},
|
||||
}
|
||||
|
||||
stripAvatarFromItems(items)
|
||||
|
||||
first, _ := items[0].(map[string]interface{})
|
||||
meta, _ := first["meta_data"].(map[string]interface{})
|
||||
if _, ok := meta["avatar"]; ok {
|
||||
t.Fatalf("avatar should be stripped, got meta = %v", meta)
|
||||
}
|
||||
if meta["description"] != "周会纪要" {
|
||||
t.Fatalf("description should be preserved, got %v", meta["description"])
|
||||
if got := minuteSearchAvatar(item); got != "" {
|
||||
t.Fatalf("minuteSearchAvatar() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,5 @@ func Shortcuts() []common.Shortcut {
|
||||
MinutesTodo,
|
||||
MinutesSpeakerReplace,
|
||||
MinutesWordReplace,
|
||||
MinutesDetail,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package note owns the Note domain: querying note detail and the unified
|
||||
// transcript by a known note_id. The vc domain locates a
|
||||
// note_id from meeting context and delegates note-detail parsing here, so the
|
||||
// parsing logic lives in exactly one place.
|
||||
package note
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// NoNoteReadPermissionCode is returned when the caller lacks read permission
|
||||
// for the requested note.
|
||||
const NoNoteReadPermissionCode = 121005
|
||||
|
||||
// ErrEmptyDetail identifies note detail responses that do not contain a note
|
||||
// object. Callers should use errors.Is instead of matching the display message.
|
||||
var ErrEmptyDetail = errors.New("note detail is empty")
|
||||
|
||||
// artifact_type enum from the note detail API.
|
||||
const (
|
||||
artifactTypeMainDoc = 1 // main note document
|
||||
artifactTypeVerbatim = 2 // verbatim transcript
|
||||
)
|
||||
|
||||
// note_display_type enum (i32) from the note detail API. Surfaced to callers as
|
||||
// a stable string so Agents route on a name, not a magic number.
|
||||
const (
|
||||
displayTypeNormal = 1
|
||||
displayTypeUnified = 2
|
||||
)
|
||||
|
||||
// Detail is the parsed note detail shared by `note +detail` and `vc +notes`.
|
||||
type Detail struct {
|
||||
NoteID string
|
||||
CreatorID string
|
||||
CreateTime string
|
||||
DisplayType string // unknown | normal | unified
|
||||
NoteDocToken string
|
||||
VerbatimDocToken string
|
||||
SharedDocTokens []string
|
||||
}
|
||||
|
||||
// FetchDetail queries GET /open-apis/vc/v1/notes/{note_id} and parses the note
|
||||
// object. API errors are returned as typed errs.* values so callers can enrich
|
||||
// user guidance without downgrading the envelope.
|
||||
func FetchDetail(_ context.Context, runtime *common.RuntimeContext, noteID string) (*Detail, error) {
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
noteObj, _ := data["note"].(map[string]any)
|
||||
if noteObj == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "note detail is empty").WithCause(ErrEmptyDetail)
|
||||
}
|
||||
noteDoc, verbatimDoc := extractArtifactTokens(common.GetSlice(noteObj, "artifacts"))
|
||||
return &Detail{
|
||||
NoteID: noteID,
|
||||
CreatorID: common.GetString(noteObj, "creator_id"),
|
||||
CreateTime: common.FormatTime(noteObj["create_time"]),
|
||||
DisplayType: displayTypeString(displayTypeValue(noteObj)),
|
||||
NoteDocToken: noteDoc,
|
||||
VerbatimDocToken: verbatimDoc,
|
||||
SharedDocTokens: extractDocTokens(common.GetSlice(noteObj, "references")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ToMap renders the detail as the field map consumed by `vc +notes`, keeping
|
||||
// the historical key set (shared_doc_tokens omitted when empty) and adding the
|
||||
// note_id / note_display_type fields.
|
||||
func (d *Detail) ToMap() map[string]any {
|
||||
m := map[string]any{
|
||||
"note_id": d.NoteID,
|
||||
"note_display_type": d.DisplayType,
|
||||
"creator_id": d.CreatorID,
|
||||
"create_time": d.CreateTime,
|
||||
"note_doc_token": d.NoteDocToken,
|
||||
"verbatim_doc_token": d.VerbatimDocToken,
|
||||
}
|
||||
if len(d.SharedDocTokens) > 0 {
|
||||
m["shared_doc_tokens"] = d.SharedDocTokens
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// displayTypeValue reads the display-type field, tolerating either the
|
||||
// documented note_display_type key or a bare display_type fallback.
|
||||
func displayTypeValue(note map[string]any) any {
|
||||
if v, ok := note["note_display_type"]; ok {
|
||||
return v
|
||||
}
|
||||
return note["display_type"]
|
||||
}
|
||||
|
||||
func displayTypeString(v any) string {
|
||||
switch parseLooseInt(v) {
|
||||
case displayTypeNormal:
|
||||
return "normal"
|
||||
case displayTypeUnified:
|
||||
return "unified"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// extractArtifactTokens picks main-doc and verbatim-doc tokens from artifacts.
|
||||
func extractArtifactTokens(artifacts []any) (noteDoc, verbatimDoc string) {
|
||||
for _, a := range artifacts {
|
||||
artifact, _ := a.(map[string]any)
|
||||
if artifact == nil {
|
||||
continue
|
||||
}
|
||||
docToken, _ := artifact["doc_token"].(string)
|
||||
switch parseLooseInt(artifact["artifact_type"]) {
|
||||
case artifactTypeMainDoc:
|
||||
noteDoc = docToken
|
||||
case artifactTypeVerbatim:
|
||||
verbatimDoc = docToken
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// extractDocTokens collects doc_token values from a list of reference objects.
|
||||
func extractDocTokens(refs []any) []string {
|
||||
var tokens []string
|
||||
for _, s := range refs {
|
||||
source, _ := s.(map[string]any)
|
||||
if source == nil {
|
||||
continue
|
||||
}
|
||||
if docToken, _ := source["doc_token"].(string); docToken != "" {
|
||||
tokens = append(tokens, docToken)
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
// parseLooseInt extracts an int from the varying JSON number representations
|
||||
// DoAPIJSON may yield (json.Number, float64, or int).
|
||||
func parseLooseInt(v any) int {
|
||||
switch n := v.(type) {
|
||||
case json.Number:
|
||||
i, _ := n.Int64()
|
||||
return int(i)
|
||||
case float64:
|
||||
// Reject fractional values: truncating 1.9 to 1 would silently coerce
|
||||
// a malformed enum into a valid one.
|
||||
if n != float64(int64(n)) {
|
||||
return 0
|
||||
}
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// parseLooseCursorID extracts a positive cursor as a string. String cursors are
|
||||
// preferred because large JSON numbers lose precision when decoded into any.
|
||||
func parseLooseCursorID(v any) (string, bool) {
|
||||
switch n := v.(type) {
|
||||
case string:
|
||||
s := strings.TrimSpace(n)
|
||||
if s == "" || s == "0" {
|
||||
return "", false
|
||||
}
|
||||
return s, true
|
||||
case json.Number:
|
||||
i, err := n.Int64()
|
||||
if err != nil || i <= 0 {
|
||||
return "", false
|
||||
}
|
||||
return strconv.FormatInt(i, 10), true
|
||||
case float64:
|
||||
// encoding/json decodes numbers in map[string]any as float64. Accept
|
||||
// only values that can round-trip safely as an integer cursor.
|
||||
const maxSafeJSONInteger = 1<<53 - 1
|
||||
if n <= 0 || n != float64(int64(n)) || n > maxSafeJSONInteger {
|
||||
return "", false
|
||||
}
|
||||
return strconv.FormatInt(int64(n), 10), true
|
||||
case int64:
|
||||
if n <= 0 {
|
||||
return "", false
|
||||
}
|
||||
return strconv.FormatInt(n, 10), true
|
||||
case int:
|
||||
if n <= 0 {
|
||||
return "", false
|
||||
}
|
||||
return strconv.Itoa(n), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// note +detail — get note metadata and document tokens by a known note_id.
|
||||
|
||||
package note
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// NoteDetail queries note metadata, display type and document tokens by note_id.
|
||||
var NoteDetail = common.Shortcut{
|
||||
Service: "note",
|
||||
Command: "+detail",
|
||||
Description: "Get note detail (display type, document tokens) by note_id",
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:note:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "note-id", Desc: "note ID", Required: true},
|
||||
},
|
||||
Validate: func(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
noteID := strings.TrimSpace(runtime.Str("note-id"))
|
||||
if noteID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--note-id is required").WithParam("--note-id")
|
||||
}
|
||||
if err := validate.ResourceName(noteID, "--note-id"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--note-id").WithCause(err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
noteID := strings.TrimSpace(runtime.Str("note-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
noteID := strings.TrimSpace(runtime.Str("note-id"))
|
||||
detail, err := FetchDetail(ctx, runtime, noteID)
|
||||
if err != nil {
|
||||
return mapNoteError(err)
|
||||
}
|
||||
runtime.OutFormat(map[string]any{"note": detail.ToMap()}, nil, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// mapNoteError surfaces the no-permission case explicitly and passes through
|
||||
// any other typed API error unchanged.
|
||||
func mapNoteError(err error) error {
|
||||
if problem, ok := errs.ProblemOf(err); ok && problem.Code == NoNoteReadPermissionCode {
|
||||
message := strings.TrimSpace(problem.Message)
|
||||
if message == "" {
|
||||
message = "no read permission for this note"
|
||||
} else if !strings.Contains(message, "no read permission for this note") {
|
||||
message = fmt.Sprintf("no read permission for this note: %s", message)
|
||||
}
|
||||
var permErr *errs.PermissionError
|
||||
if errors.As(err, &permErr) {
|
||||
mapped := *permErr
|
||||
mapped.Problem.Message = message
|
||||
if mapped.Problem.Hint == "" {
|
||||
mapped.Problem.Hint = "Ask the note owner to grant read permission, then retry"
|
||||
}
|
||||
mapped.Cause = err
|
||||
return &mapped
|
||||
}
|
||||
mappedProblem := *problem
|
||||
mappedProblem.Category = errs.CategoryAuthorization
|
||||
mappedProblem.Subtype = errs.SubtypePermissionDenied
|
||||
mappedProblem.Message = message
|
||||
if mappedProblem.Hint == "" {
|
||||
mappedProblem.Hint = "Ask the note owner to grant read permission, then retry"
|
||||
}
|
||||
return &errs.PermissionError{Problem: mappedProblem, Cause: err}
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package note
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// These tests were relocated from shortcuts/vc/vc_notes_test.go together with
|
||||
// the note-detail parsing helpers they cover.
|
||||
|
||||
func TestParseLooseInt(t *testing.T) {
|
||||
tests := []struct {
|
||||
input any
|
||||
want int
|
||||
}{
|
||||
{float64(1), 1},
|
||||
{float64(2), 2},
|
||||
{float64(1.9), 0},
|
||||
{json.Number("3"), 3},
|
||||
{"unknown", 0},
|
||||
{nil, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := parseLooseInt(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("parseLooseInt(%v) = %d, want %d", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLooseCursorID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in any
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{name: "string", in: "7648924766078847940", want: "7648924766078847940", ok: true},
|
||||
{name: "trim string", in: " 123 ", want: "123", ok: true},
|
||||
{name: "empty string", in: "", ok: false},
|
||||
{name: "zero string", in: "0", ok: false},
|
||||
{name: "json number", in: json.Number("123"), want: "123", ok: true},
|
||||
{name: "float safe integer", in: float64(123), want: "123", ok: true},
|
||||
{name: "float unsafe integer", in: float64(1<<53 + 1), ok: false},
|
||||
{name: "float fractional", in: float64(1.5), ok: false},
|
||||
{name: "negative", in: -1, ok: false},
|
||||
{name: "nil", in: nil, ok: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := parseLooseCursorID(tt.in)
|
||||
if got != tt.want || ok != tt.ok {
|
||||
t.Fatalf("parseLooseCursorID(%v) = (%q, %v), want (%q, %v)", tt.in, got, ok, tt.want, tt.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractArtifactTokens(t *testing.T) {
|
||||
artifacts := []any{
|
||||
map[string]any{"doc_token": "main_doc", "artifact_type": float64(1)},
|
||||
map[string]any{"doc_token": "verbatim_doc", "artifact_type": float64(2)},
|
||||
map[string]any{"doc_token": "unknown_doc", "artifact_type": float64(99)},
|
||||
nil,
|
||||
}
|
||||
noteDoc, verbatimDoc := extractArtifactTokens(artifacts)
|
||||
if noteDoc != "main_doc" {
|
||||
t.Errorf("noteDoc = %q, want %q", noteDoc, "main_doc")
|
||||
}
|
||||
if verbatimDoc != "verbatim_doc" {
|
||||
t.Errorf("verbatimDoc = %q, want %q", verbatimDoc, "verbatim_doc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractArtifactTokens_Empty(t *testing.T) {
|
||||
noteDoc, verbatimDoc := extractArtifactTokens(nil)
|
||||
if noteDoc != "" || verbatimDoc != "" {
|
||||
t.Errorf("expected empty tokens for nil input, got %q, %q", noteDoc, verbatimDoc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDocTokens(t *testing.T) {
|
||||
refs := []any{
|
||||
map[string]any{"doc_token": "shared1"},
|
||||
map[string]any{"doc_token": "shared2"},
|
||||
map[string]any{"doc_token": ""},
|
||||
map[string]any{},
|
||||
nil,
|
||||
}
|
||||
tokens := extractDocTokens(refs)
|
||||
if len(tokens) != 2 || tokens[0] != "shared1" || tokens[1] != "shared2" {
|
||||
t.Errorf("extractDocTokens = %v, want [shared1 shared2]", tokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDocTokens_Empty(t *testing.T) {
|
||||
tokens := extractDocTokens(nil)
|
||||
if tokens != nil {
|
||||
t.Errorf("expected nil for nil input, got %v", tokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailToMap(t *testing.T) {
|
||||
detail := &Detail{
|
||||
NoteID: "note_1",
|
||||
CreatorID: "creator_1",
|
||||
CreateTime: "2026-06-09 12:00:00",
|
||||
DisplayType: "unified",
|
||||
NoteDocToken: "note_doc",
|
||||
VerbatimDocToken: "verbatim_doc",
|
||||
SharedDocTokens: []string{"shared_1", "shared_2"},
|
||||
}
|
||||
|
||||
got := detail.ToMap()
|
||||
want := map[string]any{
|
||||
"note_id": "note_1",
|
||||
"creator_id": "creator_1",
|
||||
"create_time": "2026-06-09 12:00:00",
|
||||
"note_display_type": "unified",
|
||||
"note_doc_token": "note_doc",
|
||||
"verbatim_doc_token": "verbatim_doc",
|
||||
"shared_doc_tokens": []string{"shared_1", "shared_2"},
|
||||
}
|
||||
for key, wantValue := range want {
|
||||
gotValue, ok := got[key]
|
||||
if !ok {
|
||||
t.Fatalf("ToMap missing key %q in %#v", key, got)
|
||||
}
|
||||
if !valuesEqual(gotValue, wantValue) {
|
||||
t.Fatalf("ToMap[%q] = %#v, want %#v", key, gotValue, wantValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailToMap_OmitsEmptySharedDocTokens(t *testing.T) {
|
||||
got := (&Detail{NoteID: "note_1"}).ToMap()
|
||||
if _, ok := got["shared_doc_tokens"]; ok {
|
||||
t.Fatalf("ToMap should omit empty shared_doc_tokens, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapNoteError_NoReadPermission(t *testing.T) {
|
||||
err := &errs.PermissionError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryAuthorization,
|
||||
Subtype: errs.SubtypePermissionDenied,
|
||||
Code: NoNoteReadPermissionCode,
|
||||
Message: "upstream permission denied",
|
||||
LogID: "log_1",
|
||||
},
|
||||
MissingScopes: []string{"vc:note:read"},
|
||||
Identity: "user",
|
||||
}
|
||||
|
||||
got := mapNoteError(err)
|
||||
problem, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("mapNoteError returned %T, want typed problem", got)
|
||||
}
|
||||
if problem.Code != NoNoteReadPermissionCode {
|
||||
t.Fatalf("mapped code = %d, want %d", problem.Code, NoNoteReadPermissionCode)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "no read permission for this note") || !strings.Contains(problem.Message, "upstream permission denied") {
|
||||
t.Fatalf("mapped message = %q, want note permission guidance with upstream message", problem.Message)
|
||||
}
|
||||
if !errors.Is(got, err) {
|
||||
t.Fatal("mapped error should preserve the original typed error as cause")
|
||||
}
|
||||
originalProblem, _ := errs.ProblemOf(err)
|
||||
if originalProblem.Message != "upstream permission denied" {
|
||||
t.Fatalf("original message was mutated to %q", originalProblem.Message)
|
||||
}
|
||||
var gotPerm *errs.PermissionError
|
||||
if !errors.As(got, &gotPerm) {
|
||||
t.Fatalf("mapped error = %T, want PermissionError", got)
|
||||
}
|
||||
if gotPerm.LogID != "log_1" {
|
||||
t.Fatalf("LogID = %q, want preserved log_1", gotPerm.LogID)
|
||||
}
|
||||
if len(gotPerm.MissingScopes) != 1 || gotPerm.MissingScopes[0] != "vc:note:read" {
|
||||
t.Fatalf("MissingScopes = %#v, want preserved vc:note:read", gotPerm.MissingScopes)
|
||||
}
|
||||
if gotPerm.Identity != "user" {
|
||||
t.Fatalf("Identity = %q, want preserved user", gotPerm.Identity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapNoteError_NormalizesNonPermissionTypedError(t *testing.T) {
|
||||
err := &errs.APIError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryAPI,
|
||||
Subtype: errs.SubtypeUnknown,
|
||||
Code: NoNoteReadPermissionCode,
|
||||
Message: "upstream api error",
|
||||
LogID: "log_2",
|
||||
},
|
||||
}
|
||||
|
||||
got := mapNoteError(err)
|
||||
var gotPerm *errs.PermissionError
|
||||
if !errors.As(got, &gotPerm) {
|
||||
t.Fatalf("mapped error = %T, want PermissionError", got)
|
||||
}
|
||||
if gotPerm.Category != errs.CategoryAuthorization || gotPerm.Subtype != errs.SubtypePermissionDenied {
|
||||
t.Fatalf("mapped category/subtype = %q/%q, want authorization/permission_denied", gotPerm.Category, gotPerm.Subtype)
|
||||
}
|
||||
if !strings.Contains(gotPerm.Message, "no read permission for this note") || !strings.Contains(gotPerm.Message, "upstream api error") {
|
||||
t.Fatalf("mapped message = %q, want note permission guidance with upstream message", gotPerm.Message)
|
||||
}
|
||||
if gotPerm.Hint == "" {
|
||||
t.Fatal("mapped hint should not be empty")
|
||||
}
|
||||
if gotPerm.LogID != "log_2" {
|
||||
t.Fatalf("LogID = %q, want preserved log_2", gotPerm.LogID)
|
||||
}
|
||||
if !errors.Is(got, err) {
|
||||
t.Fatal("mapped error should preserve the original typed error as cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapNoteError_Passthrough(t *testing.T) {
|
||||
err := errors.New("boom")
|
||||
if got := mapNoteError(err); got != err {
|
||||
t.Fatalf("mapNoteError passthrough = %v, want original", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteDetailEmptyDetailPreservesSentinelCause(t *testing.T) {
|
||||
factory, stdout, _, reg := noteShortcutTestFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/note_empty_detail",
|
||||
Body: map[string]any{
|
||||
"code": 0,
|
||||
"data": map[string]any{},
|
||||
},
|
||||
})
|
||||
|
||||
err := runNoteShortcut(t, NoteDetail, []string{"+detail", "--note-id", "note_empty_detail", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected empty detail to fail")
|
||||
}
|
||||
if !errors.Is(err, ErrEmptyDetail) {
|
||||
t.Fatalf("errors.Is(ErrEmptyDetail) = false for %T: %v", err, err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("category/subtype = %v/%v, want Internal/InvalidResponse", problem.Category, problem.Subtype)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcuts(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
if len(shortcuts) != 2 {
|
||||
t.Fatalf("Shortcuts len = %d, want 2", len(shortcuts))
|
||||
}
|
||||
if shortcuts[0].Command != "+detail" || shortcuts[1].Command != "+transcript" {
|
||||
t.Fatalf("Shortcuts commands = %q, %q", shortcuts[0].Command, shortcuts[1].Command)
|
||||
}
|
||||
}
|
||||
|
||||
func valuesEqual(a, b any) bool {
|
||||
ab, _ := json.Marshal(a)
|
||||
bb, _ := json.Marshal(b)
|
||||
return string(ab) == string(bb)
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// note +transcript — fetch the unified note transcript by a
|
||||
// known note_id. The API is paginated; the CLI walks all pages internally,
|
||||
// concatenates the content and saves the whole transcript to a local file.
|
||||
|
||||
package note
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
transcriptFormatMarkdown = "markdown"
|
||||
transcriptFormatPlainText = "plain_text"
|
||||
|
||||
logPrefix = "[note +transcript]"
|
||||
|
||||
// maxTranscriptPages bounds the pagination loop so a misbehaving has_more
|
||||
// can never spin forever. transcriptPageSize reduces round trips; full
|
||||
// transcript correctness still depends on has_more/cursor pagination.
|
||||
maxTranscriptPages = 500
|
||||
transcriptPageSize = 200
|
||||
|
||||
// pageDelay throttles successive page requests to stay gentle on the
|
||||
// downstream, matching the batch cadence used by `vc +notes`.
|
||||
pageDelay = 100 * time.Millisecond
|
||||
|
||||
// noteArtifactSubdir is the default top-level directory for note-scoped
|
||||
// artifacts (parallel to the "minutes" layout used by minute artifacts).
|
||||
noteArtifactSubdir = "notes"
|
||||
)
|
||||
|
||||
// NoteTranscript fetches the full unified transcript and saves it to a file.
|
||||
var NoteTranscript = common.Shortcut{
|
||||
Service: "note",
|
||||
Command: "+transcript",
|
||||
Description: "Fetch the unified note transcript and save it to a file",
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:note:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "note-id", Desc: "note ID", Required: true},
|
||||
{Name: "transcript-format", Desc: "transcript content format", Default: transcriptFormatMarkdown, Enum: []string{transcriptFormatMarkdown, transcriptFormatPlainText}},
|
||||
{Name: "locale", Desc: "transcript locale, e.g. zh_cn, en_us, ja_jp (default follows profile language or brand)"},
|
||||
{Name: "output", Desc: "output file path (default: ./notes/{note_id}/unified_transcript.{md,txt})"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite an existing output file"},
|
||||
},
|
||||
Validate: func(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
noteID := strings.TrimSpace(runtime.Str("note-id"))
|
||||
if noteID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--note-id is required").WithParam("--note-id")
|
||||
}
|
||||
if err := validate.ResourceName(noteID, "--note-id"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--note-id").WithCause(err)
|
||||
}
|
||||
if out := strings.TrimSpace(runtime.Str("output")); out != "" {
|
||||
if err := common.ValidateSafePathTyped(runtime.FileIO(), out); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
noteID := strings.TrimSpace(runtime.Str("note-id"))
|
||||
transcriptFormat := runtime.Str("transcript-format")
|
||||
locale := resolveTranscriptLocale(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID))).
|
||||
Desc("[1] Check note_display_type and verbatim_doc_token before transcript fetch").
|
||||
GET(fmt.Sprintf("/open-apis/vc/v1/notes/%s/unified_note_transcript", validate.EncodePathSegment(noteID))).
|
||||
Desc("[2] Fetch unified note transcript pages; subsequent pages add cursor_id internally").
|
||||
Params(map[string]interface{}{
|
||||
"format": transcriptFormat,
|
||||
"page_size": transcriptPageSize,
|
||||
"locale": locale,
|
||||
}).
|
||||
Set("transcript_format", transcriptFormat).
|
||||
Set("locale", locale).
|
||||
Set("note", "CLI first checks note_display_type via note detail, then paginates internally (cursor_id) and saves the full unified transcript to a file")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
noteID := strings.TrimSpace(runtime.Str("note-id"))
|
||||
transcriptFormat := runtime.Str("transcript-format")
|
||||
locale := resolveTranscriptLocale(runtime)
|
||||
|
||||
outPath := strings.TrimSpace(runtime.Str("output"))
|
||||
if outPath == "" {
|
||||
outPath = defaultTranscriptPath(noteID, transcriptFormat)
|
||||
}
|
||||
if !runtime.Bool("overwrite") {
|
||||
if _, statErr := runtime.FileIO().Stat(outPath); statErr == nil {
|
||||
precondition := errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s", outPath).
|
||||
WithHint("Pass --overwrite to replace the existing file")
|
||||
if strings.TrimSpace(runtime.Str("output")) != "" {
|
||||
precondition = precondition.WithParam("--output")
|
||||
}
|
||||
return precondition
|
||||
}
|
||||
}
|
||||
|
||||
if err := ensureUnifiedNote(ctx, runtime, noteID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
content, err := fetchUnifiedTranscript(ctx, runtime, noteID, transcriptFormat, locale)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
saved, err := runtime.FileIO().Save(outPath, fileio.SaveOptions{}, bytes.NewReader(content))
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
resolved, rerr := runtime.FileIO().ResolvePath(outPath)
|
||||
if rerr != nil || resolved == "" {
|
||||
resolved = outPath
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]any{
|
||||
"note_id": noteID,
|
||||
"transcript_format": transcriptFormat,
|
||||
"transcript_file": resolved,
|
||||
"size_bytes": saved.Size(),
|
||||
}, nil, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func ensureUnifiedNote(ctx context.Context, runtime *common.RuntimeContext, noteID string) error {
|
||||
detail, err := FetchDetail(ctx, runtime, noteID)
|
||||
if err != nil {
|
||||
return mapNoteError(err)
|
||||
}
|
||||
if detail.DisplayType != "unified" {
|
||||
if detail.VerbatimDocToken != "" {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "note %s is not a unified note (note_display_type=%s, verbatim_doc_token=%s)", noteID, detail.DisplayType, detail.VerbatimDocToken).
|
||||
WithHint("Use docs +fetch --api-version v2 --doc %s for normal note transcripts", detail.VerbatimDocToken)
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "note %s is not a unified note (note_display_type=%s, verbatim_doc_token=)", noteID, detail.DisplayType).
|
||||
WithHint("Use note +detail to inspect document tokens")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchUnifiedTranscript walks every page of the unified transcript and returns
|
||||
// the concatenated content. Any page error fails the whole call: a partial
|
||||
// transcript is misleading, so we prefer an explicit error over silent loss.
|
||||
func fetchUnifiedTranscript(ctx context.Context, runtime *common.RuntimeContext, noteID, transcriptFormat, locale string) ([]byte, error) {
|
||||
errOut := runtime.IO().ErrOut
|
||||
apiPath := fmt.Sprintf("/open-apis/vc/v1/notes/%s/unified_note_transcript", validate.EncodePathSegment(noteID))
|
||||
|
||||
var buf bytes.Buffer
|
||||
var cursor string
|
||||
seenCursors := map[string]bool{}
|
||||
for page := 1; ; page++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, transcriptContextError(err)
|
||||
}
|
||||
if page > maxTranscriptPages {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "transcript exceeded %d pages; aborting to avoid an unbounded loop", maxTranscriptPages)
|
||||
}
|
||||
|
||||
query := larkcore.QueryParams{
|
||||
"format": []string{transcriptFormat},
|
||||
"locale": []string{locale},
|
||||
"page_size": []string{strconv.Itoa(transcriptPageSize)},
|
||||
}
|
||||
if cursor != "" {
|
||||
query["cursor_id"] = []string{cursor}
|
||||
}
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodGet, apiPath, query, nil)
|
||||
if err != nil {
|
||||
return nil, mapNoteError(err)
|
||||
}
|
||||
|
||||
if transcript, _ := data["transcript"].(map[string]any); transcript != nil {
|
||||
if chunk, _ := transcript[transcriptFormat].(string); chunk != "" {
|
||||
buf.WriteString(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
hasMore, _ := data["has_more"].(bool)
|
||||
if !hasMore {
|
||||
break
|
||||
}
|
||||
next, ok := parseLooseCursorID(data["next_cursor_id"])
|
||||
if !ok || next == cursor || seenCursors[next] {
|
||||
fmt.Fprintf(errOut, "%s has_more set but cursor did not advance at page %d\n", logPrefix, page)
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "transcript pagination cursor did not advance at page %d; aborting to avoid saving a partial transcript", page)
|
||||
}
|
||||
seenCursors[cursor] = true
|
||||
cursor = next
|
||||
timer := time.NewTimer(pageDelay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return nil, transcriptContextError(ctx.Err())
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
|
||||
if buf.Len() == 0 {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "transcript is empty for note %s in %s format; aborting to avoid saving an empty transcript", noteID, transcriptFormat)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func transcriptContextError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
subtype := errs.SubtypeNetworkTransport
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
subtype = errs.SubtypeNetworkTimeout
|
||||
}
|
||||
return errs.NewNetworkError(subtype, "transcript fetch interrupted: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
// defaultTranscriptPath builds the default save path for a note transcript.
|
||||
func defaultTranscriptPath(noteID, transcriptFormat string) string {
|
||||
name := "unified_transcript.md"
|
||||
if transcriptFormat == transcriptFormatPlainText {
|
||||
name = "unified_transcript.txt"
|
||||
}
|
||||
return filepath.Join(noteArtifactSubdir, noteID, name)
|
||||
}
|
||||
|
||||
func resolveTranscriptLocale(runtime *common.RuntimeContext) string {
|
||||
if explicit := strings.TrimSpace(runtime.Str("locale")); explicit != "" {
|
||||
return explicit
|
||||
}
|
||||
if lang := runtime.Lang(); lang != "" {
|
||||
return string(lang)
|
||||
}
|
||||
if runtime.Config.Brand == core.BrandLark {
|
||||
return string(i18n.LangEnUS)
|
||||
}
|
||||
return string(i18n.LangZhCN)
|
||||
}
|
||||
@@ -1,438 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package note
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestNoteTranscriptRequiresUnifiedNote(t *testing.T) {
|
||||
factory, stdout, _, reg := noteShortcutTestFactory(t)
|
||||
reg.Register(noteDetailStub("note_normal", displayTypeNormal))
|
||||
|
||||
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_normal", "--output", "out.md", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected non-unified note to fail")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, "not a unified note") || !strings.Contains(got, "note_display_type=normal") || !strings.Contains(got, "verbatim_doc_token=doc_verbatim") {
|
||||
t.Fatalf("err = %q, want non-unified message", got)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("subtype = %v, want FailedPrecondition", problem.Subtype)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "docs +fetch --api-version v2 --doc doc_verbatim") {
|
||||
t.Fatalf("hint = %q, want docs +fetch guidance", problem.Hint)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteTranscriptFetchesUnifiedNote(t *testing.T) {
|
||||
factory, stdout, _, reg := noteShortcutTestFactory(t)
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
reg.Register(noteDetailStub("note_unified", displayTypeUnified))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/note_unified/unified_note_transcript?format=markdown&locale=zh_cn&page_size=200",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"transcript": map[string]interface{}{
|
||||
"markdown": "# transcript\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_unified", "--as", "user"}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(dir, "notes", "note_unified", "unified_transcript.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile transcript err=%v", err)
|
||||
}
|
||||
if string(content) != "# transcript\n" {
|
||||
t.Fatalf("transcript = %q, want %q", string(content), "# transcript\n")
|
||||
}
|
||||
data := decodeNoteEnvelope(t, stdout)
|
||||
if data["note_id"] != "note_unified" || data["size_bytes"] != float64(len(content)) {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteTranscriptFormatFlagDoesNotShadowOutputFormat(t *testing.T) {
|
||||
factory, stdout, _, reg := noteShortcutTestFactory(t)
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
reg.Register(noteDetailStub("note_plain", displayTypeUnified))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/note_plain/unified_note_transcript?format=plain_text&locale=zh_cn&page_size=200",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"transcript": map[string]interface{}{
|
||||
"plain_text": "plain transcript\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runNoteShortcut(t, NoteTranscript, []string{
|
||||
"+transcript",
|
||||
"--note-id", "note_plain",
|
||||
"--transcript-format", "plain_text",
|
||||
"--format", "json",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(dir, "notes", "note_plain", "unified_transcript.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile transcript err=%v", err)
|
||||
}
|
||||
if string(content) != "plain transcript\n" {
|
||||
t.Fatalf("transcript = %q, want plain transcript", string(content))
|
||||
}
|
||||
data := decodeNoteEnvelope(t, stdout)
|
||||
if data["transcript_format"] != "plain_text" {
|
||||
t.Fatalf("transcript_format = %#v, want plain_text; output=%s", data["transcript_format"], stdout.String())
|
||||
}
|
||||
if _, ok := data["format"]; ok {
|
||||
t.Fatalf("output should not expose ambiguous format field: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteTranscriptPassesLocaleThrough(t *testing.T) {
|
||||
factory, stdout, _, reg := noteShortcutTestFactory(t)
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
reg.Register(noteDetailStub("note_locale", displayTypeUnified))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/note_locale/unified_note_transcript?format=markdown&locale=en_us&page_size=200",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"transcript": map[string]interface{}{
|
||||
"markdown": "# en transcript\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_locale", "--locale", "en_us", "--as", "user"}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(dir, "notes", "note_locale", "unified_transcript.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile transcript err=%v", err)
|
||||
}
|
||||
if string(content) != "# en transcript\n" {
|
||||
t.Fatalf("transcript = %q, want en transcript", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteTranscriptDefaultsLocaleFromLarkBrand(t *testing.T) {
|
||||
config := &core.CliConfig{
|
||||
AppID: "test-app-lark-locale",
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandLark,
|
||||
UserOpenId: "ou_testuser",
|
||||
}
|
||||
factory, stdout, _, reg := noteShortcutTestFactoryWithConfig(t, config)
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
reg.Register(noteDetailStub("note_lark", displayTypeUnified))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/note_lark/unified_note_transcript?format=markdown&locale=en_us&page_size=200",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"transcript": map[string]interface{}{
|
||||
"markdown": "# en transcript\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_lark", "--as", "user"}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteTranscriptRejectsExistingOutputBeforeFetch(t *testing.T) {
|
||||
factory, stdout, _, _ := noteShortcutTestFactory(t)
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
outPath := filepath.Join("notes", "note_exists", "unified_transcript.md")
|
||||
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
|
||||
t.Fatalf("MkdirAll err=%v", err)
|
||||
}
|
||||
if err := os.WriteFile(outPath, []byte("old"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile err=%v", err)
|
||||
}
|
||||
|
||||
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_exists", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected existing output to fail")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, "output file already exists") {
|
||||
t.Fatalf("err = %q, want existing output error", got)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("err = %T, want ValidationError", err)
|
||||
}
|
||||
if validationErr.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("subtype = %v, want FailedPrecondition", validationErr.Subtype)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "--overwrite") {
|
||||
t.Fatalf("hint = %q, want --overwrite guidance", validationErr.Hint)
|
||||
}
|
||||
// The CLI picked the default path itself, so no input param is at fault.
|
||||
if validationErr.Param != "" {
|
||||
t.Fatalf("param = %q, want empty for default output path", validationErr.Param)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteTranscriptRejectsEmptyTranscript(t *testing.T) {
|
||||
factory, stdout, _, reg := noteShortcutTestFactory(t)
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
reg.Register(noteDetailStub("note_empty", displayTypeUnified))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/note_empty/unified_note_transcript?format=markdown&locale=zh_cn&page_size=200",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"transcript": map[string]interface{}{
|
||||
"markdown": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_empty", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected empty transcript to fail")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, "transcript is empty") || !strings.Contains(got, "note_empty") {
|
||||
t.Fatalf("err = %q, want empty transcript error", got)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("category/subtype = %v/%v, want Internal/InvalidResponse", problem.Category, problem.Subtype)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(dir, "notes", "note_empty", "unified_transcript.md")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("transcript file should not exist, statErr=%v", statErr)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteTranscriptRejectsCursorCycle(t *testing.T) {
|
||||
factory, stdout, _, reg := noteShortcutTestFactory(t)
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
reg.Register(noteDetailStub("note_cycle", displayTypeUnified))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/note_cycle/unified_note_transcript?format=markdown&locale=zh_cn&page_size=200",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": true,
|
||||
"next_cursor_id": "A",
|
||||
"transcript": map[string]interface{}{
|
||||
"markdown": "page1\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "cursor_id=A",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": true,
|
||||
"next_cursor_id": "B",
|
||||
"transcript": map[string]interface{}{
|
||||
"markdown": "page2\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "cursor_id=B",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": true,
|
||||
"next_cursor_id": "A",
|
||||
"transcript": map[string]interface{}{
|
||||
"markdown": "page3\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runNoteShortcut(t, NoteTranscript, []string{"+transcript", "--note-id", "note_cycle", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected cursor cycle to fail")
|
||||
}
|
||||
if got := err.Error(); !strings.Contains(got, "pagination cursor did not advance") {
|
||||
t.Fatalf("err = %q, want cursor advance error", got)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("category/subtype = %v/%v, want Internal/InvalidResponse", problem.Category, problem.Subtype)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(dir, "notes", "note_cycle", "unified_transcript.md")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("transcript file should not exist, statErr=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscriptContextErrorPreservesCause(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
subtype errs.Subtype
|
||||
}{
|
||||
{
|
||||
name: "canceled",
|
||||
err: context.Canceled,
|
||||
subtype: errs.SubtypeNetworkTransport,
|
||||
},
|
||||
{
|
||||
name: "deadline",
|
||||
err: context.DeadlineExceeded,
|
||||
subtype: errs.SubtypeNetworkTimeout,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := transcriptContextError(tt.err)
|
||||
if !errors.Is(err, tt.err) {
|
||||
t.Fatalf("errors.Is(%v) = false", tt.err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryNetwork || problem.Subtype != tt.subtype {
|
||||
t.Fatalf("category/subtype = %v/%v, want Network/%v", problem.Category, problem.Subtype, tt.subtype)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func noteShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
config := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ReplaceAll(strings.ToLower(t.Name()), "/", "-"),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_testuser",
|
||||
}
|
||||
return noteShortcutTestFactoryWithConfig(t, config)
|
||||
}
|
||||
|
||||
func noteShortcutTestFactoryWithConfig(t *testing.T, config *core.CliConfig) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
return cmdutil.TestFactory(t, config)
|
||||
}
|
||||
|
||||
func runNoteShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "note"}
|
||||
shortcut.Mount(parent, factory)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
stdout.Reset()
|
||||
if stderr, ok := factory.IOStreams.ErrOut.(*bytes.Buffer); ok {
|
||||
stderr.Reset()
|
||||
}
|
||||
return parent.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
func noteDetailStub(noteID string, displayType int) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/" + noteID,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"note": map[string]interface{}{
|
||||
"note_display_type": displayType,
|
||||
"artifacts": []interface{}{
|
||||
map[string]interface{}{"artifact_type": artifactTypeVerbatim, "doc_token": "doc_verbatim"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func decodeNoteEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if data, _ := envelope["data"].(map[string]interface{}); data != nil {
|
||||
return data
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package note
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
// Shortcuts returns all note-domain shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
NoteDetail,
|
||||
NoteTranscript,
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,6 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/mail"
|
||||
"github.com/larksuite/cli/shortcuts/markdown"
|
||||
"github.com/larksuite/cli/shortcuts/minutes"
|
||||
"github.com/larksuite/cli/shortcuts/note"
|
||||
"github.com/larksuite/cli/shortcuts/sheets"
|
||||
sheetsbackward "github.com/larksuite/cli/shortcuts/sheets/backward"
|
||||
"github.com/larksuite/cli/shortcuts/slides"
|
||||
@@ -78,10 +77,8 @@ func init() {
|
||||
allShortcuts = append(allShortcuts, markdown.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, slides.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, minutes.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, note.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, task.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, vc.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, note.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, whiteboard.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, wiki.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, okr.Shortcuts()...)
|
||||
|
||||
@@ -11,7 +11,6 @@ func Shortcuts() []common.Shortcut {
|
||||
VCSearch,
|
||||
VCNotes,
|
||||
VCRecording,
|
||||
VCDetail,
|
||||
VCMeetingJoin,
|
||||
VCMeetingLeave,
|
||||
VCMeetingEvents,
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// vc +detail — get meeting details including note_id and minute_token
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const detailLogPrefix = "[vc +detail]"
|
||||
|
||||
var scopesDetailMeetingIDs = []string{
|
||||
"vc:meeting.meetingevent:read",
|
||||
"vc:record:readonly",
|
||||
}
|
||||
|
||||
// meetingDetailItem represents a single meeting detail result.
|
||||
type meetingDetailItem struct {
|
||||
MeetingID string `json:"meeting_id"`
|
||||
MeetingNo string `json:"meeting_no,omitempty"`
|
||||
Topic string `json:"topic"`
|
||||
StartTime string `json:"start_time,omitempty"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
NoteID string `json:"note_id,omitempty"`
|
||||
MinuteToken string `json:"minute_token,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
// fetchMeetingDetail queries meeting.get and recording API to return a
|
||||
// consolidated view of meeting metadata, note_id, and minute_token.
|
||||
// Error is only set when an API call actually fails; note_id and minute_token
|
||||
// are always present (empty string when not available).
|
||||
func fetchMeetingDetail(ctx context.Context, runtime *common.RuntimeContext, meetingID string) *meetingDetailItem {
|
||||
result := &meetingDetailItem{MeetingID: meetingID}
|
||||
|
||||
// Step 1: query meeting detail
|
||||
data, err := runtime.CallAPITyped(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/vc/v1/meetings/%s", validate.EncodePathSegment(meetingID)),
|
||||
map[string]interface{}{"with_participants": "false", "query_mode": "0"}, nil)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("failed to query meeting detail: %v", err)
|
||||
return result
|
||||
}
|
||||
|
||||
meeting, _ := data["meeting"].(map[string]any)
|
||||
if meeting == nil {
|
||||
result.Error = "meeting not found in response"
|
||||
return result
|
||||
}
|
||||
|
||||
if v, ok := meeting["meeting_no"].(string); ok {
|
||||
result.MeetingNo = v
|
||||
}
|
||||
if v, ok := meeting["topic"].(string); ok {
|
||||
result.Topic = v
|
||||
}
|
||||
if v := common.FormatTime(meeting["start_time"]); v != "" {
|
||||
result.StartTime = v
|
||||
}
|
||||
if v := common.FormatTime(meeting["end_time"]); v != "" {
|
||||
result.EndTime = v
|
||||
}
|
||||
if v, ok := meeting["note_id"].(string); ok && v != "" {
|
||||
result.NoteID = v
|
||||
}
|
||||
|
||||
// Step 2: query minute_token via recording API
|
||||
minuteToken, minuteHint, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
|
||||
if minuteErr != nil {
|
||||
// Recording API failed — surface the error but keep data from step 1
|
||||
result.Error = fmt.Sprintf("failed to query minutes: %v", minuteErr)
|
||||
minuteHint = ""
|
||||
}
|
||||
if minuteToken != "" {
|
||||
result.MinuteToken = minuteToken
|
||||
}
|
||||
|
||||
// Add hints for empty resources (not errors, just informational)
|
||||
var emptyFields []string
|
||||
if result.NoteID == "" {
|
||||
emptyFields = append(emptyFields, "note_id")
|
||||
}
|
||||
if result.MinuteToken == "" && minuteErr == nil && minuteHint == "" {
|
||||
emptyFields = append(emptyFields, "minute_token")
|
||||
}
|
||||
if len(emptyFields) > 0 {
|
||||
result.Hint = fmt.Sprintf("%s not found for this meeting", strings.Join(emptyFields, ", "))
|
||||
}
|
||||
if minuteHint != "" {
|
||||
if result.Hint != "" {
|
||||
result.Hint += "; " + minuteHint
|
||||
} else {
|
||||
result.Hint = minuteHint
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// VCDetail gets meeting details including note_id and minute_token.
|
||||
var VCDetail = common.Shortcut{
|
||||
Service: "vc",
|
||||
Command: "+detail",
|
||||
Description: "Get meeting details including note_id and minute_token by meeting IDs",
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:meeting.meetingevent:read", "vc:record:readonly"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "meeting-ids", Desc: "meeting IDs, comma-separated for batch", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ids := common.SplitCSV(runtime.Str("meeting-ids"))
|
||||
const maxBatchSize = 50
|
||||
if len(ids) > maxBatchSize {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--meeting-ids: too many IDs (%d), maximum is %d", len(ids), maxBatchSize).WithParam("--meeting-ids")
|
||||
}
|
||||
// dynamic scope check
|
||||
result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID))
|
||||
if err == nil && result != nil && result.Scopes != "" {
|
||||
if missing := auth.MissingScopes(result.Scopes, scopesDetailMeetingIDs); len(missing) > 0 {
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"missing required scope(s): %s", strings.Join(missing, ", ")).
|
||||
WithHint("run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")).
|
||||
WithMissingScopes(missing...).
|
||||
WithIdentity(string(runtime.As()))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ids := runtime.Str("meeting-ids")
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/vc/v1/meetings/{meeting_id}").
|
||||
GET("/open-apis/vc/v1/meetings/{meeting_id}/recording").
|
||||
Set("meeting_ids", common.SplitCSV(ids)).
|
||||
Set("steps", "meeting.get → note_id + recording API → minute_token")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
errOut := runtime.IO().ErrOut
|
||||
meetingIDs := common.SplitCSV(runtime.Str("meeting-ids"))
|
||||
results := make([]*meetingDetailItem, 0, len(meetingIDs))
|
||||
|
||||
const batchDelay = 100 * time.Millisecond
|
||||
fmt.Fprintf(errOut, "%s querying %d meeting_id(s)\n", detailLogPrefix, len(meetingIDs))
|
||||
for i, id := range meetingIDs {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if i > 0 {
|
||||
time.Sleep(batchDelay)
|
||||
}
|
||||
fmt.Fprintf(errOut, "%s querying meeting_id=%s ...\n", detailLogPrefix, sanitizeLogValue(id))
|
||||
results = append(results, fetchMeetingDetail(ctx, runtime, id))
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
for _, r := range results {
|
||||
if r.Error == "" {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(errOut, "%s done: %d total, %d succeeded, %d failed\n", detailLogPrefix, len(results), successCount, len(results)-successCount)
|
||||
|
||||
if successCount == 0 && len(results) > 0 {
|
||||
return runtime.OutPartialFailure(map[string]any{"meetings": results}, &output.Meta{Count: len(results)})
|
||||
}
|
||||
|
||||
outData := map[string]any{"meetings": results}
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(results)}, func(w io.Writer) {
|
||||
if len(results) == 0 {
|
||||
fmt.Fprintln(w, "No meetings.")
|
||||
return
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
for _, r := range results {
|
||||
row := map[string]interface{}{"meeting_id": r.MeetingID}
|
||||
if r.Error != "" {
|
||||
row["status"] = "FAIL"
|
||||
row["error"] = r.Error
|
||||
} else {
|
||||
row["status"] = "OK"
|
||||
}
|
||||
if r.NoteID != "" {
|
||||
row["note_id"] = r.NoteID
|
||||
}
|
||||
if r.MinuteToken != "" {
|
||||
row["minute_token"] = r.MinuteToken
|
||||
}
|
||||
row["topic"] = r.Topic
|
||||
if r.Hint != "" {
|
||||
row["hint"] = r.Hint
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
fmt.Fprintf(w, "\n%d meeting(s), %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDetail_Validation_MissingMeetingIDs(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCDetail, []string{"+detail", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for missing --meeting-ids")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "meeting-ids") {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Validation_BatchLimit(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
ids := make([]string, 51)
|
||||
for i := range ids {
|
||||
ids[i] = fmt.Sprintf("m%d", i)
|
||||
}
|
||||
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", strings.Join(ids, ","), "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected batch limit error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "too many IDs") {
|
||||
t.Errorf("expected 'too many IDs' error, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DryRun tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDetail_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m001", "--dry-run", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "/open-apis/vc/v1/meetings/") {
|
||||
t.Errorf("dry-run should show meeting API path, got: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "recording") {
|
||||
t.Errorf("dry-run should show recording API path, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execute tests with mocked HTTP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDetail_Execute_Success(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingGetStub("m_detail1", "note_001"))
|
||||
reg.Register(recordingOKStub("m_detail1", "https://meetings.feishu.cn/minutes/obc_detail1"))
|
||||
|
||||
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m_detail1", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse output: %v", err)
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
meetings, _ := data["meetings"].([]any)
|
||||
if len(meetings) != 1 {
|
||||
t.Fatalf("expected 1 meeting, got %d", len(meetings))
|
||||
}
|
||||
m, _ := meetings[0].(map[string]any)
|
||||
if m["meeting_id"] != "m_detail1" {
|
||||
t.Errorf("meeting_id = %v, want m_detail1", m["meeting_id"])
|
||||
}
|
||||
if m["note_id"] != "note_001" {
|
||||
t.Errorf("note_id = %v, want note_001", m["note_id"])
|
||||
}
|
||||
if m["minute_token"] != "obc_detail1" {
|
||||
t.Errorf("minute_token = %v, want obc_detail1", m["minute_token"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Execute_NoNoteNoMinute(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingGetStub("m_nonote", ""))
|
||||
reg.Register(recordingErrStub("m_nonote", 121004, "not found"))
|
||||
|
||||
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m_nonote", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify hint is present for empty note_id and missing recording
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse output: %v", err)
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
meetings, _ := data["meetings"].([]any)
|
||||
m, _ := meetings[0].(map[string]any)
|
||||
if hint, _ := m["hint"].(string); !strings.Contains(hint, "note_id") || !strings.Contains(hint, "no minute file for this meeting") {
|
||||
t.Errorf("hint should mention note_id and minute file missing, got: %v", hint)
|
||||
}
|
||||
if errMsg, _ := m["error"].(string); errMsg != "" {
|
||||
t.Errorf("error should be empty, got: %v", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Execute_MeetingNotFound(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/meetings/m_bad",
|
||||
Body: map[string]interface{}{"code": 121004, "msg": "data not found"},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m_bad", "--as", "user"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial failure error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetail_Execute_Batch(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
// m1 succeeds with note and minute
|
||||
reg.Register(meetingGetStub("m_batch1", "note_b1"))
|
||||
reg.Register(recordingOKStub("m_batch1", "https://meetings.feishu.cn/minutes/obc_b1"))
|
||||
// m2 has no note_id but has minute
|
||||
reg.Register(meetingGetStub("m_batch2", ""))
|
||||
reg.Register(recordingOKStub("m_batch2", "https://meetings.feishu.cn/minutes/obc_b2"))
|
||||
|
||||
err := mountAndRun(t, VCDetail, []string{"+detail", "--meeting-ids", "m_batch1,m_batch2", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse output: %v", err)
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
meetings, _ := data["meetings"].([]any)
|
||||
if len(meetings) != 2 {
|
||||
t.Fatalf("expected 2 meetings, got %d", len(meetings))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure function tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFetchMeetingDetail_MeetingWithNoteAndMinute(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingGetStub("m_fn", "note_fn"))
|
||||
reg.Register(recordingOKStub("m_fn", "https://meetings.feishu.cn/minutes/obc_fn"))
|
||||
|
||||
if err := botExec(t, "detail-fn", f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
result := fetchMeetingDetail(context.Background(), rctx, "m_fn")
|
||||
if result.MeetingID != "m_fn" {
|
||||
t.Errorf("meeting_id = %v, want m_fn", result.MeetingID)
|
||||
}
|
||||
if result.NoteID != "note_fn" {
|
||||
t.Errorf("note_id = %v, want note_fn", result.NoteID)
|
||||
}
|
||||
if result.MinuteToken != "obc_fn" {
|
||||
t.Errorf("minute_token = %v, want obc_fn", result.MinuteToken)
|
||||
}
|
||||
if result.Error != "" {
|
||||
t.Errorf("unexpected error: %v", result.Error)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchMeetingDetail_MeetingNotFound(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/meetings/m_nf",
|
||||
Body: map[string]interface{}{"code": 121004, "msg": "data not found"},
|
||||
})
|
||||
|
||||
if err := botExec(t, "detail-nf", f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
result := fetchMeetingDetail(context.Background(), rctx, "m_nf")
|
||||
if result.Error == "" {
|
||||
t.Error("expected error for meeting not found")
|
||||
}
|
||||
// note_id and minute_token should still be present (empty)
|
||||
if result.NoteID != "" {
|
||||
t.Errorf("note_id = %q, want empty", result.NoteID)
|
||||
}
|
||||
if result.MinuteToken != "" {
|
||||
t.Errorf("minute_token = %q, want empty", result.MinuteToken)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchMeetingDetail_RecordingFailsButNoteOK(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingGetStub("m_partial", "note_partial"))
|
||||
reg.Register(recordingErrStub("m_partial", 121004, "not found"))
|
||||
|
||||
if err := botExec(t, "detail-partial", f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
result := fetchMeetingDetail(context.Background(), rctx, "m_partial")
|
||||
if result.NoteID != "note_partial" {
|
||||
t.Errorf("note_id = %v, want note_partial", result.NoteID)
|
||||
}
|
||||
if result.MinuteToken != "" {
|
||||
t.Errorf("minute_token = %q, want empty", result.MinuteToken)
|
||||
}
|
||||
if result.Error != "" {
|
||||
t.Errorf("error = %q, want empty", result.Error)
|
||||
}
|
||||
if !strings.Contains(result.Hint, "no minute file for this meeting") {
|
||||
t.Errorf("hint = %q, want contains 'no minute file for this meeting'", result.Hint)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchMeetingDetail_RecordingAPIErrorButNoteOK(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingGetStub("m_api_err", "note_apierr"))
|
||||
reg.Register(recordingErrStub("m_api_err", 99999, "weird API error"))
|
||||
|
||||
if err := botExec(t, "detail-apierr", f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
result := fetchMeetingDetail(context.Background(), rctx, "m_api_err")
|
||||
if result.NoteID != "note_apierr" {
|
||||
t.Errorf("note_id = %v, want note_apierr", result.NoteID)
|
||||
}
|
||||
if result.MinuteToken != "" {
|
||||
t.Errorf("minute_token = %q, want empty", result.MinuteToken)
|
||||
}
|
||||
if !strings.Contains(result.Error, "failed to query minutes") || !strings.Contains(result.Error, "weird API error") {
|
||||
t.Errorf("error = %q, want contains 'failed to query minutes' and 'weird API error'", result.Error)
|
||||
}
|
||||
if strings.Contains(result.Hint, "minute_token") {
|
||||
t.Errorf("hint = %q, should not mention minute_token when there is an error", result.Hint)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -818,7 +818,7 @@ func TestVCShortcuts_RegistersMeetingAgentCommands(t *testing.T) {
|
||||
for _, shortcut := range got {
|
||||
commands = append(commands, shortcut.Command)
|
||||
}
|
||||
want := []string{"+search", "+notes", "+recording", "+detail", "+meeting-join", "+meeting-leave", "+meeting-events"}
|
||||
want := []string{"+search", "+notes", "+recording", "+meeting-join", "+meeting-leave", "+meeting-events"}
|
||||
if !reflect.DeepEqual(commands, want) {
|
||||
t.Fatalf("shortcut commands = %#v, want %#v", commands, want)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ package vc
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -29,7 +30,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/larksuite/cli/shortcuts/note"
|
||||
)
|
||||
|
||||
// per-flag additional scope requirements for +notes (vc:note:read is checked by framework)
|
||||
@@ -51,6 +51,12 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
// artifact type enum from note detail API
|
||||
const (
|
||||
artifactTypeMainDoc = 1 // main note document
|
||||
artifactTypeVerbatim = 2 // verbatim transcript
|
||||
)
|
||||
|
||||
const logPrefix = "[vc +notes]"
|
||||
|
||||
const (
|
||||
@@ -60,6 +66,9 @@ const (
|
||||
recordingNotFoundCode = 121004 // 该会议没有妙记文件
|
||||
recordingNoPermissionCode = 121005 // 非会议参与者无权查看
|
||||
recordingGeneratingCode = 124002 // 录制/妙记文件仍在生成中
|
||||
|
||||
// note detail API specific error code.
|
||||
noteNoPermissionCode = 121005 // 调用者没有该纪要的阅读权限
|
||||
)
|
||||
|
||||
func minutesReadError(err error, minuteToken string) error {
|
||||
@@ -212,7 +221,7 @@ func fetchNoteByCalendarEventID(ctx context.Context, runtime *common.RuntimeCont
|
||||
// success means note detail was retrieved, regardless of whether the
|
||||
// recording API (minute_token) call succeeded — minute_token failures
|
||||
// surface as part of the merged `error` string for downstream visibility.
|
||||
if noteID, _ := noteResult["note_id"].(string); noteID != "" {
|
||||
if _, ok := noteResult["note_doc_token"].(string); ok {
|
||||
for k, v := range noteResult {
|
||||
result[k] = v
|
||||
}
|
||||
@@ -263,35 +272,42 @@ func asStringSlice(v any) []string {
|
||||
}
|
||||
|
||||
// fetchMeetingMinuteToken queries the recording API of a meeting and returns
|
||||
// the associated minute_token (parsed from the recording URL), an optional
|
||||
// hint for expected missing states, and an error for unexpected failures.
|
||||
func fetchMeetingMinuteToken(runtime *common.RuntimeContext, meetingID string) (token, hint string, err error) {
|
||||
data, apiErr := runtime.CallAPITyped(http.MethodGet,
|
||||
// the associated minute_token (parsed from the recording URL) and an
|
||||
// optional human-friendly error message. On success token is non-empty and
|
||||
// errMsg is empty; on failure token is empty and errMsg describes the cause:
|
||||
// - 121004: meeting has no minute file
|
||||
// - 121005: caller has no permission for the meeting recording
|
||||
// - 124002: recording / minute file is still being generated
|
||||
//
|
||||
// Other failures fall back to the raw API error description so Agents can
|
||||
// still parse the underlying cause.
|
||||
func fetchMeetingMinuteToken(runtime *common.RuntimeContext, meetingID string) (token, errMsg string) {
|
||||
data, err := runtime.CallAPITyped(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/vc/v1/meetings/%s/recording", validate.EncodePathSegment(meetingID)),
|
||||
nil, nil)
|
||||
if apiErr != nil {
|
||||
if p, ok := errs.ProblemOf(apiErr); ok {
|
||||
if err != nil {
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
switch p.Code {
|
||||
case recordingNotFoundCode:
|
||||
return "", "no minute file for this meeting", nil
|
||||
return "", "no minute file for this meeting"
|
||||
case recordingNoPermissionCode:
|
||||
return "", "no permission to access this meeting's minute; ask the meeting owner to share the minute", nil
|
||||
return "", "no permission to access this meeting's minute; ask the meeting owner to share the minute"
|
||||
case recordingGeneratingCode:
|
||||
return "", "minute file is still being generated; please retry later", nil
|
||||
return "", "minute file is still being generated; please retry later"
|
||||
}
|
||||
}
|
||||
return "", "", apiErr
|
||||
return "", fmt.Sprintf("failed to query recording: %v", err)
|
||||
}
|
||||
|
||||
recording, _ := data["recording"].(map[string]any)
|
||||
if recording == nil {
|
||||
return "", "no recording available for this meeting", nil
|
||||
return "", "no recording available for this meeting"
|
||||
}
|
||||
recordingURL, _ := recording["url"].(string)
|
||||
if t := extractMinuteToken(recordingURL); t != "" {
|
||||
return t, "", nil
|
||||
return t, ""
|
||||
}
|
||||
return "", "no minute_token found in recording URL", nil
|
||||
return "", "no minute_token found in recording URL"
|
||||
}
|
||||
|
||||
// fetchNoteByMeetingID queries notes via meeting_id and additionally fetches
|
||||
@@ -314,7 +330,7 @@ func fetchNoteByMeetingID(ctx context.Context, runtime *common.RuntimeContext, m
|
||||
// Always attempt to query the meeting's minute_token via the recording API,
|
||||
// regardless of whether the meeting has a note_id, so callers always see
|
||||
// minute state for follow-up calls (e.g. `vc +notes --minute-tokens=...`).
|
||||
minuteToken, minuteHint, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
|
||||
minuteToken, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
|
||||
|
||||
var result map[string]any
|
||||
var noteErr string
|
||||
@@ -333,13 +349,7 @@ func fetchNoteByMeetingID(ctx context.Context, runtime *common.RuntimeContext, m
|
||||
if minuteToken != "" {
|
||||
result["minute_token"] = minuteToken
|
||||
}
|
||||
var minuteErrMsg string
|
||||
if minuteHint != "" {
|
||||
minuteErrMsg = minuteHint
|
||||
} else if minuteErr != nil {
|
||||
minuteErrMsg = minuteErr.Error()
|
||||
}
|
||||
if combined := joinErrors(noteErr, minuteErrMsg); combined != "" {
|
||||
if combined := joinErrors(noteErr, minuteErr); combined != "" {
|
||||
result["error"] = combined
|
||||
}
|
||||
return result
|
||||
@@ -359,13 +369,11 @@ func joinErrors(msgs ...string) string {
|
||||
|
||||
// hasNotesPayload reports whether a result map carries any usable note or
|
||||
// minute payload, irrespective of partial failures surfaced via `error`.
|
||||
// note_id counts: it is the routing key for `note +detail` / `note +transcript`,
|
||||
// so a detail hit without doc tokens is still an actionable result.
|
||||
func hasNotesPayload(m map[string]any) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
for _, k := range []string{"note_id", "note_doc_token", "verbatim_doc_token", "minute_token", "meeting_notes", "shared_doc_tokens", "artifacts"} {
|
||||
for _, k := range []string{"note_doc_token", "verbatim_doc_token", "minute_token", "meeting_notes", "shared_doc_tokens", "artifacts"} {
|
||||
if v, ok := m[k]; ok && v != nil && v != "" {
|
||||
return true
|
||||
}
|
||||
@@ -511,22 +519,84 @@ func saveTranscriptToFile(runtime *common.RuntimeContext, minuteToken, title str
|
||||
return transcriptPath
|
||||
}
|
||||
|
||||
// fetchNoteDetail retrieves note fields via note_id by delegating to the note
|
||||
// domain (the canonical owner of note-detail parsing) and adapting the typed
|
||||
// result into the historical map shape `vc +notes` merges into its output. The
|
||||
// new note_id / note_display_type fields ride along via Detail.ToMap.
|
||||
func fetchNoteDetail(ctx context.Context, runtime *common.RuntimeContext, noteID string) map[string]any {
|
||||
detail, err := note.FetchDetail(ctx, runtime, noteID)
|
||||
if err != nil {
|
||||
if problem, ok := errs.ProblemOf(err); ok && problem.Code == note.NoNoteReadPermissionCode {
|
||||
return map[string]any{"error": fmt.Sprintf("[%v]: no read permission for this meeting note", problem.Code)}
|
||||
// parseArtifactType extracts artifact_type as int from varying JSON number representations.
|
||||
func parseArtifactType(v any) int {
|
||||
switch n := v.(type) {
|
||||
case json.Number:
|
||||
i, _ := n.Int64()
|
||||
return int(i)
|
||||
case float64:
|
||||
return int(n)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// extractArtifactTokens picks main-doc and verbatim-doc tokens from the artifacts list.
|
||||
func extractArtifactTokens(artifacts []any) (noteDoc, verbatimDoc string) {
|
||||
for _, a := range artifacts {
|
||||
artifact, _ := a.(map[string]any)
|
||||
if artifact == nil {
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, note.ErrEmptyDetail) {
|
||||
return map[string]any{"error": note.ErrEmptyDetail.Error()}
|
||||
docToken, _ := artifact["doc_token"].(string)
|
||||
switch parseArtifactType(artifact["artifact_type"]) {
|
||||
case artifactTypeMainDoc:
|
||||
noteDoc = docToken
|
||||
case artifactTypeVerbatim:
|
||||
verbatimDoc = docToken
|
||||
default:
|
||||
// ignore unknown artifact types
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// extractDocTokens collects doc_token values from a list of reference objects.
|
||||
func extractDocTokens(refs []any) []string {
|
||||
var tokens []string
|
||||
for _, s := range refs {
|
||||
source, _ := s.(map[string]any)
|
||||
if source == nil {
|
||||
continue
|
||||
}
|
||||
if docToken, _ := source["doc_token"].(string); docToken != "" {
|
||||
tokens = append(tokens, docToken)
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
// fetchNoteDetail retrieves note document tokens via note_id.
|
||||
func fetchNoteDetail(_ context.Context, runtime *common.RuntimeContext, noteID string) map[string]any {
|
||||
data, err := runtime.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)), nil, nil)
|
||||
if err != nil {
|
||||
if p, ok := errs.ProblemOf(err); ok && p.Code == noteNoPermissionCode {
|
||||
return map[string]any{"error": fmt.Sprintf("[%v]: no read permission for this meeting note", p.Code)}
|
||||
}
|
||||
return map[string]any{"error": fmt.Sprintf("failed to query note detail: %v", err)}
|
||||
}
|
||||
return detail.ToMap()
|
||||
|
||||
note, _ := data["note"].(map[string]any)
|
||||
if note == nil {
|
||||
return map[string]any{"error": "note detail is empty"}
|
||||
}
|
||||
|
||||
creatorID, _ := note["creator_id"].(string)
|
||||
createTime := common.FormatTime(note["create_time"])
|
||||
noteDocToken, verbatimDocToken := extractArtifactTokens(common.GetSlice(note, "artifacts"))
|
||||
sharedDocTokens := extractDocTokens(common.GetSlice(note, "references"))
|
||||
|
||||
result := map[string]any{
|
||||
"creator_id": creatorID,
|
||||
"create_time": createTime,
|
||||
"note_doc_token": noteDocToken,
|
||||
"verbatim_doc_token": verbatimDocToken,
|
||||
}
|
||||
if len(sharedDocTokens) > 0 {
|
||||
result["shared_doc_tokens"] = sharedDocTokens
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// VCNotes queries meeting notes via meeting-ids, minute-tokens, or calendar-event-ids.
|
||||
@@ -537,7 +607,6 @@ var VCNotes = common.Shortcut{
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:note:read"}, // minimum scope; additional per-flag scopes checked in Validate
|
||||
AuthTypes: []string{"user"},
|
||||
Hidden: true, // hidden from --help; prefer vc +detail, minutes +detail, or note +detail
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "meeting-ids", Desc: "meeting IDs, comma-separated for batch"},
|
||||
@@ -706,12 +775,6 @@ var VCNotes = common.Shortcut{
|
||||
id, _ = m["calendar_event_id"].(string)
|
||||
}
|
||||
row := map[string]interface{}{"id": id}
|
||||
if v, _ := m["note_id"].(string); v != "" {
|
||||
row["note_id"] = v
|
||||
}
|
||||
if v, _ := m["note_display_type"].(string); v != "" {
|
||||
row["note_display_type"] = v
|
||||
}
|
||||
if errMsg, _ := m["error"].(string); errMsg != "" {
|
||||
row["status"] = "FAIL"
|
||||
row["error"] = errMsg
|
||||
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/larksuite/cli/shortcuts/note"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -120,21 +119,6 @@ func noteDetailStub(noteID string) *httpmock.Stub {
|
||||
}
|
||||
}
|
||||
|
||||
func noteDetailDisplayOnlyStub(noteID string, displayType int) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/" + noteID,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"note": map[string]interface{}{
|
||||
"note_display_type": displayType,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func artifactsStub(token, transcript string) *httpmock.Stub {
|
||||
data := map[string]interface{}{
|
||||
"summary": "Test summary content",
|
||||
@@ -194,9 +178,68 @@ func TestSanitizeDirName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Note-detail parsing helpers (parseArtifactType/extractArtifactTokens/
|
||||
// extractDocTokens) moved to the note domain; their tests live in
|
||||
// shortcuts/note/note_test.go.
|
||||
func TestParseArtifactType(t *testing.T) {
|
||||
tests := []struct {
|
||||
input any
|
||||
want int
|
||||
}{
|
||||
{float64(1), 1},
|
||||
{float64(2), 2},
|
||||
{json.Number("3"), 3},
|
||||
{"unknown", 0},
|
||||
{nil, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := parseArtifactType(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("parseArtifactType(%v) = %d, want %d", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractArtifactTokens(t *testing.T) {
|
||||
artifacts := []any{
|
||||
map[string]any{"doc_token": "main_doc", "artifact_type": float64(1)},
|
||||
map[string]any{"doc_token": "verbatim_doc", "artifact_type": float64(2)},
|
||||
map[string]any{"doc_token": "unknown_doc", "artifact_type": float64(99)},
|
||||
nil,
|
||||
}
|
||||
noteDoc, verbatimDoc := extractArtifactTokens(artifacts)
|
||||
if noteDoc != "main_doc" {
|
||||
t.Errorf("noteDoc = %q, want %q", noteDoc, "main_doc")
|
||||
}
|
||||
if verbatimDoc != "verbatim_doc" {
|
||||
t.Errorf("verbatimDoc = %q, want %q", verbatimDoc, "verbatim_doc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractArtifactTokens_Empty(t *testing.T) {
|
||||
noteDoc, verbatimDoc := extractArtifactTokens(nil)
|
||||
if noteDoc != "" || verbatimDoc != "" {
|
||||
t.Errorf("expected empty tokens for nil input, got %q, %q", noteDoc, verbatimDoc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDocTokens(t *testing.T) {
|
||||
refs := []any{
|
||||
map[string]any{"doc_token": "shared1"},
|
||||
map[string]any{"doc_token": "shared2"},
|
||||
map[string]any{"doc_token": ""},
|
||||
map[string]any{},
|
||||
nil,
|
||||
}
|
||||
tokens := extractDocTokens(refs)
|
||||
if len(tokens) != 2 || tokens[0] != "shared1" || tokens[1] != "shared2" {
|
||||
t.Errorf("extractDocTokens = %v, want [shared1 shared2]", tokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDocTokens_Empty(t *testing.T) {
|
||||
tokens := extractDocTokens(nil)
|
||||
if tokens != nil {
|
||||
t.Errorf("expected nil for nil input, got %v", tokens)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration tests: +notes with mocked HTTP
|
||||
@@ -319,6 +362,25 @@ func TestNotes_BatchLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArtifactType_AllBranches(t *testing.T) {
|
||||
// cover json.Number branch
|
||||
if got := parseArtifactType(json.Number("1")); got != 1 {
|
||||
t.Errorf("json.Number: got %d, want 1", got)
|
||||
}
|
||||
// cover float64 branch
|
||||
if got := parseArtifactType(float64(2)); got != 2 {
|
||||
t.Errorf("float64: got %d, want 2", got)
|
||||
}
|
||||
// cover default branch
|
||||
if got := parseArtifactType("str"); got != 0 {
|
||||
t.Errorf("default: got %d, want 0", got)
|
||||
}
|
||||
// cover nil
|
||||
if got := parseArtifactType(nil); got != 0 {
|
||||
t.Errorf("nil: got %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests for new calendar-to-notes functions
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -533,33 +595,6 @@ func TestNotes_CalendarPath_FallbackWhenMeetingChainFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotes_CalendarPath_KeepsNoteIDOnlyDetail(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
calID := "cal_test"
|
||||
reg.Register(primaryCalendarStub(calID))
|
||||
reg.Register(calendarRelationStub(calID, "evt_note_only", []string{"m_note_only"}, nil))
|
||||
reg.Register(meetingGetStub("m_note_only", "note_only"))
|
||||
reg.Register(noteDetailDisplayOnlyStub("note_only", 2))
|
||||
reg.Register(recordingErrStub("m_note_only", 121004, "not found"))
|
||||
|
||||
err := mountAndRun(t, VCNotes, []string{"+notes", "--calendar-event-ids", "evt_note_only", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
note := extractFirstNote(t, stdout)
|
||||
if got := note["note_id"]; got != "note_only" {
|
||||
t.Fatalf("note_id = %v, want note_only; note=%#v", got, note)
|
||||
}
|
||||
if got := note["note_display_type"]; got != "unified" {
|
||||
t.Fatalf("note_display_type = %v, want unified; note=%#v", got, note)
|
||||
}
|
||||
if got := note["calendar_event_id"]; got != "evt_note_only" {
|
||||
t.Fatalf("calendar_event_id = %v, want evt_note_only; note=%#v", got, note)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotes_CalendarPath_NeedNotes_RequestBody(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
@@ -613,26 +648,6 @@ func TestNotes_CalendarPath_NeedNotes_RequestBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotes_TableOutputIncludesNoteRoutingFields(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(meetingGetStub("m_table", "note_table"))
|
||||
reg.Register(noteDetailDisplayOnlyStub("note_table", 2))
|
||||
reg.Register(recordingErrStub("m_table", 121004, "not found"))
|
||||
|
||||
err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m_table", "--format", "table", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "note_table") {
|
||||
t.Fatalf("table output missing note_id:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "unified") {
|
||||
t.Fatalf("table output missing note_display_type:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transcript path layout tests (unified ./minutes/{token}/ default)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -741,9 +756,7 @@ func TestHasNotesPayload(t *testing.T) {
|
||||
{"nil", nil, false},
|
||||
{"empty", map[string]any{}, false},
|
||||
{"only meta", map[string]any{"meeting_id": "m1", "error": "fail"}, false},
|
||||
{"empty values", map[string]any{"note_doc_token": "", "minute_token": "", "note_id": ""}, false},
|
||||
{"only note_id", map[string]any{"note_id": "note1"}, true},
|
||||
{"note_id with display type", map[string]any{"note_id": "note1", "note_display_type": "unified", "note_doc_token": ""}, true},
|
||||
{"empty values", map[string]any{"note_doc_token": "", "minute_token": ""}, false},
|
||||
{"has note_doc_token", map[string]any{"note_doc_token": "doc1"}, true},
|
||||
{"has verbatim_doc_token", map[string]any{"verbatim_doc_token": "v1"}, true},
|
||||
{"has minute_token", map[string]any{"minute_token": "obc"}, true},
|
||||
@@ -792,15 +805,12 @@ func TestFetchMeetingMinuteToken_Success(t *testing.T) {
|
||||
reg.Register(recordingOKStub("m_ok", "https://meetings.feishu.cn/minutes/obctoken_ok"))
|
||||
|
||||
if err := botExec(t, "fmmt-ok", f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
token, hint, err := fetchMeetingMinuteToken(rctx, "m_ok")
|
||||
token, msg := fetchMeetingMinuteToken(rctx, "m_ok")
|
||||
if token != "obctoken_ok" {
|
||||
t.Errorf("token = %q, want obctoken_ok", token)
|
||||
}
|
||||
if hint != "" {
|
||||
t.Errorf("hint = %q, want empty", hint)
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("err = %v, want nil", err)
|
||||
if msg != "" {
|
||||
t.Errorf("errMsg = %q, want empty", msg)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
@@ -826,15 +836,12 @@ func TestFetchMeetingMinuteToken_KnownErrorCodes(t *testing.T) {
|
||||
reg.Register(recordingErrStub(tt.meetingID, tt.code, "err"))
|
||||
|
||||
if err := botExec(t, "fmmt-"+tt.meetingID, f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
token, hint, err := fetchMeetingMinuteToken(rctx, tt.meetingID)
|
||||
token, msg := fetchMeetingMinuteToken(rctx, tt.meetingID)
|
||||
if token != "" {
|
||||
t.Errorf("token = %q, want empty on error", token)
|
||||
}
|
||||
if !strings.Contains(hint, tt.wantMsg) {
|
||||
t.Errorf("hint = %q, want contains %q", hint, tt.wantMsg)
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("err = %v, want nil", err)
|
||||
if !strings.Contains(msg, tt.wantMsg) {
|
||||
t.Errorf("errMsg = %q, want contains %q", msg, tt.wantMsg)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
@@ -850,15 +857,12 @@ func TestFetchMeetingMinuteToken_GenericAPIError(t *testing.T) {
|
||||
reg.Register(recordingErrStub("m_other", 99999, "weird"))
|
||||
|
||||
if err := botExec(t, "fmmt-generic", f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
token, hint, err := fetchMeetingMinuteToken(rctx, "m_other")
|
||||
token, msg := fetchMeetingMinuteToken(rctx, "m_other")
|
||||
if token != "" {
|
||||
t.Errorf("token = %q, want empty", token)
|
||||
}
|
||||
if hint != "" {
|
||||
t.Errorf("hint = %q, want empty", hint)
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "weird") {
|
||||
t.Errorf("err = %v, want contains 'weird'", err)
|
||||
if !strings.Contains(msg, "failed to query recording") {
|
||||
t.Errorf("errMsg = %q, want contains 'failed to query recording'", msg)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
@@ -875,15 +879,12 @@ func TestFetchMeetingMinuteToken_NoRecording(t *testing.T) {
|
||||
}))
|
||||
|
||||
if err := botExec(t, "fmmt-norec", f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
token, hint, err := fetchMeetingMinuteToken(rctx, "m_norec")
|
||||
token, msg := fetchMeetingMinuteToken(rctx, "m_norec")
|
||||
if token != "" {
|
||||
t.Errorf("token = %q, want empty", token)
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("err = %v, want nil", err)
|
||||
}
|
||||
if !strings.Contains(hint, "no recording available") {
|
||||
t.Errorf("hint = %q, want contains 'no recording available'", hint)
|
||||
if !strings.Contains(msg, "no recording available") {
|
||||
t.Errorf("errMsg = %q, want contains 'no recording available'", msg)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
@@ -897,15 +898,12 @@ func TestFetchMeetingMinuteToken_URLWithoutToken(t *testing.T) {
|
||||
reg.Register(recordingOKStub("m_notok", "https://example.com/no/minute/path"))
|
||||
|
||||
if err := botExec(t, "fmmt-notok", f, func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
token, hint, err := fetchMeetingMinuteToken(rctx, "m_notok")
|
||||
token, msg := fetchMeetingMinuteToken(rctx, "m_notok")
|
||||
if token != "" {
|
||||
t.Errorf("token = %q, want empty", token)
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("err = %v, want nil", err)
|
||||
}
|
||||
if !strings.Contains(hint, "no minute_token found") {
|
||||
t.Errorf("hint = %q, want contains 'no minute_token found'", hint)
|
||||
if !strings.Contains(msg, "no minute_token found") {
|
||||
t.Errorf("errMsg = %q, want contains 'no minute_token found'", msg)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
@@ -998,7 +996,7 @@ func TestNotes_MeetingPath_OnlyMinuteFails_PartialSuccess(t *testing.T) {
|
||||
t.Errorf("note_doc_token = %v, want doc_main", got)
|
||||
}
|
||||
assertNoteFieldAbsent(t, note, "minute_token")
|
||||
assertNoteError(t, note, "no permission to access this meeting's minute; ask the meeting owner to share the minute")
|
||||
assertNoteError(t, note, "no permission to access this meeting's minute")
|
||||
}
|
||||
|
||||
func TestNotes_MeetingPath_NoNote_ButMinuteOK(t *testing.T) {
|
||||
@@ -1083,7 +1081,6 @@ func TestNotes_MeetingPath_NoteNoPermission_FriendlyHint(t *testing.T) {
|
||||
assertNoteError(t, note,
|
||||
"[121005]",
|
||||
"no read permission for this meeting note",
|
||||
"no permission to access this meeting's minute",
|
||||
"; ", // note + minute causes joined with semicolon
|
||||
)
|
||||
}
|
||||
@@ -1269,7 +1266,7 @@ func TestFetchNoteDetail_NoteNoPermission_ProblemOf(t *testing.T) {
|
||||
|
||||
// meeting.get returns note_id, note detail returns 121005
|
||||
reg.Register(meetingGetStub("m_noteperm2", "note_perm2"))
|
||||
reg.Register(noteDetailErrStub("note_perm2", note.NoNoteReadPermissionCode, "no permission"))
|
||||
reg.Register(noteDetailErrStub("note_perm2", noteNoPermissionCode, "no permission"))
|
||||
reg.Register(recordingOKStub("m_noteperm2", "https://meetings.feishu.cn/minutes/obcpermtest"))
|
||||
|
||||
// note fails but minute_token succeeds → partial success (hasNotesPayload=true)
|
||||
@@ -1289,29 +1286,6 @@ func TestFetchNoteDetail_NoteNoPermission_ProblemOf(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchNoteDetail_EmptyDetailKeepsLegacyError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/notes/note_empty_detail",
|
||||
Body: map[string]any{
|
||||
"code": 0,
|
||||
"data": map[string]any{},
|
||||
},
|
||||
})
|
||||
|
||||
if err := botExec(t, "empty-note-detail", f, func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
got := fetchNoteDetail(ctx, rctx, "note_empty_detail")
|
||||
if got["error"] != "note detail is empty" {
|
||||
t.Fatalf("error = %#v, want legacy empty-detail text", got["error"])
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotes_AllFailed_OutPartialFailure pins that when every item in the batch
|
||||
// fails (successCount == 0), Execute returns *output.PartialFailureError with
|
||||
// ExitAPI code, and stdout still carries the ok:false envelope with notes data.
|
||||
|
||||
@@ -230,16 +230,9 @@ var VCSearch = common.Shortcut{
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
items := common.GetSlice(data, "items")
|
||||
// Strip avatar from meta_data — not useful for AI agents.
|
||||
for _, raw := range items {
|
||||
if m, ok := raw.(map[string]interface{}); ok {
|
||||
if meta, ok := m["meta_data"].(map[string]interface{}); ok {
|
||||
delete(meta, "avatar")
|
||||
}
|
||||
}
|
||||
}
|
||||
outData := map[string]interface{}{
|
||||
"items": items,
|
||||
"total": data["total"],
|
||||
"has_more": data["has_more"],
|
||||
"page_token": data["page_token"],
|
||||
}
|
||||
|
||||
@@ -31,8 +31,6 @@ lark-cli calendar +agenda --as user
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) |
|
||||
| [`+search-event`](references/lark-calendar-search-event.md) | 按关键词、时间范围和参会人搜索日程 |
|
||||
| [`+meeting`](references/lark-calendar-meeting.md) | 通过日程事件 ID 获取关联的视频会议信息(meeting_id、meeting_note) |
|
||||
| [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人(ISO 8601 时间) |
|
||||
| [`+update`](references/lark-calendar-update.md) | 更新既有日程字段,或独立增量添加/移除参会人和会议室 |
|
||||
| [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和 RSVP 状态 |
|
||||
@@ -66,9 +64,6 @@ lark-cli calendar +agenda --as user
|
||||
|----------|--------|
|
||||
| 查询过去的会议("昨天的会议""上周的会") | [`../lark-vc/SKILL.md`](../lark-vc/SKILL.md)(会议数据含即时会议,仅查日程会遗漏) |
|
||||
| 查询日历/日程或未来时间的会议 | 本 skill |
|
||||
| 按关键词搜索日程 | 本 skill(`+search-event`) |
|
||||
| 从日程获取关联的视频会议 ID 或用户绑定的会议纪要文档 | 本 skill(`+meeting`) |
|
||||
| 从日程进一步拿 AI 智能纪要 / 逐字稿 / 妙记产物 | 先 `+meeting` 取 `meeting_id`,再 [`vc +detail`](../lark-vc/references/lark-vc-detail.md) → [`note +detail`](../lark-note/references/lark-note-detail.md) / [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md) |
|
||||
| 预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
|
||||
|
||||
## 任务类型分流
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
|
||||
# calendar +meeting
|
||||
|
||||
通过日程 ID(`event_id`) 获取关联的视频会议信息(`meeting_id`、`meeting_note`)。只读。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 单个 / 批量(逗号分隔,最多 50 个)
|
||||
lark-cli calendar +meeting --event-ids <event_id1>,<event_id2>
|
||||
|
||||
# 默认使用主日历,需要时显式传 --calendar-id
|
||||
lark-cli calendar +meeting --event-ids <event_id> --calendar-id <calendar_id>
|
||||
```
|
||||
|
||||
## 输出字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `event_id` | 日程 ID |
|
||||
| `meeting_id` | 关联的视频会议 ID |
|
||||
| `meeting_note` | 用户主动绑定到日程的纪要文档 Token(`MeetingNotes`,由用户在日程页手动添加;)。**与会中产生的 AI 智能纪要 `note_doc_token` 是两份不同文档**,要拿 AI 纪要请继续走 `vc +detail` → `note +detail`。 |
|
||||
|
||||
## 下游链路
|
||||
|
||||
`calendar +meeting` 只把日程 ID 翻译为 `meeting_id` / `meeting_note`,要拿会中产生的产物(AI 智能纪要、逐字稿、妙记)需继续调用:
|
||||
|
||||
```bash
|
||||
# 1. meeting_id → note_id + minute_token(同一会议两份产物,可能各自为空)
|
||||
lark-cli vc +detail --meeting-ids <meeting_id>
|
||||
|
||||
# 2a. note_id → 纪要文档 token(note_doc_token / verbatim_doc_token / shared_doc_tokens)
|
||||
lark-cli note +detail --note-id <note_id>
|
||||
|
||||
# 2b. minute_token → 妙记 AI 产物(按需获取,不传不返回任何 AI 内容)
|
||||
lark-cli minutes +detail --minute-tokens <minute_token> --summary --todo --chapter --keyword --transcript
|
||||
|
||||
# 3. 任意文档 token(meeting_note / note_doc_token / verbatim_doc_token / shared_doc_token)→ 正文
|
||||
lark-cli docs +fetch --api-version v2 --doc <doc_token> --doc-format markdown
|
||||
```
|
||||
@@ -1,29 +0,0 @@
|
||||
|
||||
# calendar +search-event
|
||||
|
||||
按关键词、时间范围和参会人搜索日历日程。只读。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 按关键词
|
||||
lark-cli calendar +search-event --query "周会"
|
||||
|
||||
# 按时间范围(ISO 8601 或 YYYY-MM-DD)
|
||||
lark-cli calendar +search-event --start "2026-04-20T00:00:00+08:00" --end "2026-04-27T23:59:59+08:00"
|
||||
|
||||
# 按参会人(自动识别 ou_ 用户 / oc_ 群聊 / omm_ 会议室前缀)
|
||||
lark-cli calendar +search-event --attendee-ids "ou_user1,oc_chat1,omm_room1"
|
||||
|
||||
# 组合
|
||||
lark-cli calendar +search-event --query "周会" --start 2026-04-20 --end 2026-04-27 --attendee-ids "ou_user1"
|
||||
```
|
||||
|
||||
## 输出字段
|
||||
|
||||
`items` 列表每条返回 `event_id` / `summary` / `start` / `end` / `is_all_day` / `app_link`;外层有 `has_more`、`page_token`。**仅返回基础字段,要拿日程详情用 `calendar events get`。**
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 分页:`has_more=true` 时持续用 `page_token` 翻页直到 false,不要遗漏;`page-size` 最大 30。
|
||||
- 已结束的会议优先用 `vc +search`——日历不收录"即时会议",只查日程会漏。
|
||||
@@ -56,7 +56,6 @@ 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(推荐优先使用)
|
||||
|
||||
@@ -11,11 +11,11 @@ This skill maps to the shortcut: `lark-cli im +chat-list` (internally calls `GET
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# List the user's chats (default sort: create_time, ascending)
|
||||
# List the user's chats (default sort: ByCreateTimeAsc)
|
||||
lark-cli im +chat-list
|
||||
|
||||
# Sort by recent activity (most recently active first)
|
||||
lark-cli im +chat-list --sort active_time
|
||||
lark-cli im +chat-list --sort-type ByActiveTimeDesc
|
||||
|
||||
# Limit page size
|
||||
lark-cli im +chat-list --page-size 50
|
||||
@@ -48,7 +48,7 @@ lark-cli im +chat-list --as user --types p2p
|
||||
|------|------|------|------|
|
||||
| `--user-id-type <type>` | No | `open_id` (default), `union_id`, `user_id` | ID type used for `owner_id` in the response |
|
||||
| `--types <strings>` | No | `group`, `p2p` (comma-separated or repeated) | Chat types to include. Omitted = groups only (backward compatible). `p2p` requires user identity (`--as user`); under `--as bot`, `--types=p2p` alone is rejected and `--types=p2p,group` is silently downgraded to `group` |
|
||||
| `--sort <field>` | No | `create_time` (default, ascending), `active_time` (descending) | Result ordering |
|
||||
| `--sort-type <type>` | No | `ByCreateTimeAsc` (default), `ByActiveTimeDesc` | Result ordering |
|
||||
| `--page-size <n>` | No | 1-100, default 20 | Number of results per page |
|
||||
| `--page-token <token>` | No | - | Pagination token from the previous response |
|
||||
| `--exclude-muted` | No | User identity only | Drop chats the current user has muted (do-not-disturb). Under `--as bot`, the flag is silently inactive; see "Filtering muted chats" below |
|
||||
@@ -130,13 +130,13 @@ When the flag is set, the JSON envelope gains a `filter` sub-object (absent othe
|
||||
### Scenario 1: List my recent chats
|
||||
|
||||
```bash
|
||||
lark-cli im +chat-list --sort active_time --page-size 10
|
||||
lark-cli im +chat-list --sort-type ByActiveTimeDesc --page-size 10
|
||||
```
|
||||
|
||||
### Scenario 2: List my non-muted chats sorted by activity
|
||||
|
||||
```bash
|
||||
lark-cli im +chat-list --sort active_time --exclude-muted
|
||||
lark-cli im +chat-list --sort-type ByActiveTimeDesc --exclude-muted
|
||||
```
|
||||
|
||||
### Scenario 3: Iterate all my chats programmatically
|
||||
|
||||
@@ -24,7 +24,7 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --start "2026-03-10T00:00:00+08
|
||||
lark-cli im +chat-messages-list --chat-id oc_xxx --start 2026-03-10 --end 2026-03-11
|
||||
|
||||
# Control sort order and page size (max 50)
|
||||
lark-cli im +chat-messages-list --chat-id oc_xxx --order asc --page-size 20
|
||||
lark-cli im +chat-messages-list --chat-id oc_xxx --sort asc --page-size 20
|
||||
|
||||
# Pagination
|
||||
lark-cli im +chat-messages-list --chat-id oc_xxx --page-token "xxx"
|
||||
@@ -41,7 +41,7 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --format json
|
||||
| `--user-id <id>` | One of two | Specify a DM conversation by the other user's open_id (`ou_xxx`); p2p chat_id is resolved automatically. Requires user identity (`--as user`); not supported with bot identity |
|
||||
| `--start <time>` | No | Start time (ISO 8601 or date only) |
|
||||
| `--end <time>` | No | End time (ISO 8601 or date only) |
|
||||
| `--order <order>` | No | Sort order: `asc` / `desc` (default `desc`) |
|
||||
| `--sort <order>` | No | Sort order: `asc` / `desc` (default `desc`) |
|
||||
| `--page-size <n>` | No | Page size (default 50, max 50) |
|
||||
| `--page-token <token>` | No | Pagination token |
|
||||
| `--no-reactions` | No | Skip auto-fetching the `reactions` block |
|
||||
@@ -49,8 +49,6 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --format json
|
||||
|
||||
> Rule: `--chat-id` and `--user-id` are mutually exclusive. You must provide exactly one of them.
|
||||
|
||||
> **CAUTION:** `--order` is the only sort axis — messages are always ordered by creation time, `asc` or `desc`. There is no field axis: the command cannot sort by sender or any other field, so do **not** attempt `--sort sender` or similar (it is rejected). If the user asks to group or sort by sender, fetch with `--order` and aggregate client-side, and tell them this is local post-processing, not a CLI/API sort capability.
|
||||
|
||||
## Resource Rendering
|
||||
|
||||
Messages are rendered into human-readable text for inspection. Image messages are shown as placeholders such as `[Image: img_xxx]`; files, audio, and videos are rendered with resource keys in the content (e.g. `<audio key="file_xxx" duration="Xs"/>`). By default resource binaries are **not** downloaded.
|
||||
@@ -77,8 +75,8 @@ lark-cli im +threads-messages-list --thread omt_xxx
|
||||
|
||||
| Scenario | Recommendation |
|
||||
|------|------|
|
||||
| You need context | Call `im +threads-messages-list --order desc --page-size 10` for the discovered thread_id to inspect recent replies |
|
||||
| The user asks for the "full discussion" | Use `im +threads-messages-list --order asc --page-size 50`, then paginate if needed |
|
||||
| You need context | Call `im +threads-messages-list --sort desc --page-size 10` for the discovered thread_id to inspect recent replies |
|
||||
| The user asks for the "full discussion" | Use `im +threads-messages-list --sort asc --page-size 50`, then paginate if needed |
|
||||
| You only need an overview | Skip thread expansion |
|
||||
|
||||
## Output Fields
|
||||
|
||||
@@ -50,7 +50,7 @@ lark-cli im +chat-search --query "project" --dry-run
|
||||
| `--member-ids <ids>` | No (at least one of `--query` / `--member-ids` required) | Up to 50, format `ou_xxx` | Filter by member open_ids; can be used alone or combined with `--query` |
|
||||
| `--is-manager` | No | - | Only show chats you created or manage |
|
||||
| `--disable-search-by-user` | No | - | Disable member-name-based matching and search by group name only |
|
||||
| `--sort <field>` | No | `create_time`, `update_time`, `member_count` | Sort field (always descending) |
|
||||
| `--sort-by <field>` | No | `create_time_desc`, `update_time_desc`, `member_count_desc` | Sort field in descending order |
|
||||
| `--page-size <n>` | No | 1-100, default 20 | Number of results per page |
|
||||
| `--page-token <token>` | No | - | Pagination token from the previous response |
|
||||
| `--exclude-muted` | No | User identity only | Drop chats the current user has muted (do-not-disturb). Under `--as bot`, the flag is silently inactive (mute is a per-user setting); see "Filtering muted chats" below |
|
||||
@@ -59,8 +59,6 @@ lark-cli im +chat-search --query "project" --dry-run
|
||||
|
||||
> **Note:** Supports both `--as user` (default) and `--as bot`. When using bot identity, the app must have bot capability enabled.
|
||||
|
||||
> **CAUTION:** `--sort` is **always descending** — the search API only ranks the chosen field high-to-low (e.g. `member_count` = most members first). There is no ascending option. If the user asks for "fewest first / ascending / 从少到多", tell them the search API does not support ascending order; any low-to-high view requires re-sorting the fetched page client-side and is not an upstream sort. Do **not** invent values like `member_count_asc` or pass `asc` (they are rejected).
|
||||
|
||||
## Output Fields
|
||||
|
||||
| Field | Description |
|
||||
|
||||
@@ -15,7 +15,7 @@ This skill maps to the shortcut: `lark-cli im +threads-messages-list` (internall
|
||||
lark-cli im +threads-messages-list --thread omt_xxx
|
||||
|
||||
# Reverse chronological order (latest first)
|
||||
lark-cli im +threads-messages-list --thread omt_xxx --order desc
|
||||
lark-cli im +threads-messages-list --thread omt_xxx --sort desc
|
||||
|
||||
# Control page size
|
||||
lark-cli im +threads-messages-list --thread omt_xxx --page-size 20
|
||||
@@ -42,7 +42,7 @@ lark-cli im +threads-messages-list --thread omt_xxx --dry-run
|
||||
| `--thread <id>` | Yes | Thread ID (`om_xxx` or `omt_xxx` format) |
|
||||
| `--no-reactions` | No | Skip auto-fetching the `reactions` block |
|
||||
| `--download-resources` | No | Download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block. Off by default |
|
||||
| `--order <order>` | No | Sort order: `asc` (default) / `desc` |
|
||||
| `--sort <order>` | No | Sort order: `asc` (default) / `desc` |
|
||||
| `--page-size <n>` | No | Number of items per page (default 50, range 1-500) |
|
||||
| `--page-token <token>` | No | Pagination token for the next page |
|
||||
| `--format <fmt>` | No | Output format: `json` (default) / `pretty` / `table` / `ndjson` / `csv` |
|
||||
@@ -68,9 +68,9 @@ Thread messages do not support `start_time` / `end_time` filtering because of Fe
|
||||
|
||||
| Scenario | Recommended Parameters |
|
||||
|------|---------|
|
||||
| Quickly inspect recent replies | `--order desc --page-size 10` |
|
||||
| Read the full thread in chronological order | `--order asc --page-size 50`, then paginate as needed |
|
||||
| Just confirm whether replies exist | `--order desc --page-size 1` |
|
||||
| Quickly inspect recent replies | `--sort desc --page-size 10` |
|
||||
| Read the full thread in chronological order | `--sort asc --page-size 50`, then paginate as needed |
|
||||
| Just confirm whether replies exist | `--sort desc --page-size 1` |
|
||||
|
||||
## Usage Scenarios
|
||||
|
||||
|
||||
@@ -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` 只读单封邮件;已有多个 `message_id` 时用 `+messages` 批量读取,不要循环调用 `+message`;`+thread` 读整个会话
|
||||
3. **阅读** — `+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`)。`+message` 只适合单个 `message_id`;多个已知 `message_id` 请一次性传给 `+messages --message-ids <id1>,<id2>,<id3>`。仅需确认操作结果(如验证标记已读、移动文件夹是否成功)时,用 `--html=false` 跳过 HTML 正文,只返回纯文本,显著减少 token 消耗。
|
||||
`+message`、`+messages`、`+thread` 默认返回 HTML 正文(`--html=true`)。仅需确认操作结果(如验证标记已读、移动文件夹是否成功)时,用 `--html=false` 跳过 HTML 正文,只返回纯文本,显著减少 token 消耗。
|
||||
|
||||
输出默认为结构化 JSON,可直接读取,无需额外编码转换。
|
||||
|
||||
@@ -357,9 +357,6 @@ 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`)
|
||||
@@ -469,8 +466,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli mail +<verb> [flags]`)
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+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. |
|
||||
| [`+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. |
|
||||
| [`+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. |
|
||||
@@ -660,3 +657,4 @@ 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` |
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
|
||||
读取指定邮件的完整内容,包括邮件头、正文(纯文本 + 可选 HTML)以及统一的 `attachments` 列表(涵盖普通附件和内嵌图片)。
|
||||
|
||||
`mail +message` 只适合读取一封邮件、一个 `message_id`。如果手上已有多个 `message_id`,请使用 `mail +messages --message-ids <id1>,<id2>,<id3>`;不要循环调用 `mail +message`。
|
||||
|
||||
CLI 分两阶段构建最终 JSON:
|
||||
- 安全的邮件元数据字段直接透传
|
||||
- 正文、附件和辅助字段由 shortcut 派生
|
||||
@@ -36,7 +34,7 @@ lark-cli mail +message --message-id <message-id> --dry-run
|
||||
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `--message-id <id>` | 是 | — | 单个邮件 ID;多个 ID 使用 `mail +messages --message-ids` |
|
||||
| `--message-id <id>` | 是 | — | 邮件 ID |
|
||||
| `--mailbox <email>` | 否 | 当前用户 | 邮箱地址(`user_mailbox_id`) |
|
||||
| `--html` | 否 | true | 是否返回 HTML 正文(`false` 仅返回纯文本,减少带宽) |
|
||||
| `--format <mode>` | 否 | json | 输出格式:`json`(默认)/ `pretty` / `table` / `ndjson` / `csv` |
|
||||
@@ -157,7 +155,6 @@ 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:
|
||||
|
||||
@@ -4,16 +4,16 @@
|
||||
|
||||
通过传入逗号分隔的 `message_id` 列表,一次性读取多封邮件的完整内容。
|
||||
|
||||
超过 20 个 ID 可以直接传入 CLI;CLI 会按 20 个 ID 自动拆批并合并输出,不需要手动拆批,也不要逐封循环调用 `+message`。
|
||||
|
||||
本 shortcut 是 `mail +message` 的批量版本。每个返回的 `messages[]` 项使用与 `+message` 相同的归一化结构:安全元数据字段直接透传,正文和辅助字段由 shortcut 派生。
|
||||
|
||||
优先使用本 shortcut,因为:
|
||||
优先使用本 shortcut 而非原生 `mail user_mailbox.messages batch_get` API,因为:
|
||||
- 正文字段已 base64url 解码
|
||||
- 每条邮件的输出结构已归一化
|
||||
- 不可用的 message ID 会被显式列出
|
||||
|
||||
本 skill 对应 shortcut `lark-cli mail +messages`;每条返回的邮件使用与 `+message` 相同的规则归一化输出。
|
||||
本 skill 对应 shortcut `lark-cli mail +messages`,内部步骤:
|
||||
1. `POST /open-apis/mail/v1/user_mailboxes/{mailbox}/messages/batch_get` — 批量获取邮件
|
||||
2. 对每条返回的邮件使用与 `+message` 相同的规则归一化输出
|
||||
|
||||
## 命令
|
||||
|
||||
@@ -38,7 +38,7 @@ lark-cli mail +messages --message-ids <id1>,<id2> --dry-run
|
||||
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `--message-ids <id1>,<id2>,<id3>` | 是 | — | 逗号分隔的邮件 ID 列表;超过 20 个 ID 时 CLI 自动按 20 拆批并合并输出 |
|
||||
| `--message-ids <id1,id2,...>` | 是 | — | 逗号分隔的邮件 ID 列表 |
|
||||
| `--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`。
|
||||
- CLI 每 20 个 ID 拆成一次调用并合并输出,不需要为大列表手动拆请求。
|
||||
- `--message-ids` 无硬性上限;shortcut 内部会自动将大列表拆分为多次批量 API 调用。
|
||||
- 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。
|
||||
|
||||
@@ -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: read full content: single message use mail +message --message-id <id>; multiple messages use mail +messages --message-ids <id1>,<id2>,<id3>
|
||||
tip: use mail +message --message-id <id> to read full content
|
||||
```
|
||||
|
||||
公共邮箱场景下,`--mailbox` 会自动出现在续页和 tip 中:
|
||||
```text
|
||||
next page: mail +triage --mailbox 'shared@example.com' --query '合同审批' --page-token 'search:abc123...'
|
||||
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>
|
||||
tip: use mail +message --mailbox 'shared@example.com' --message-id <id> to read full content
|
||||
```
|
||||
|
||||
### 搜索分页注意事项
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-minutes
|
||||
version: 1.0.0
|
||||
description: "飞书妙记:搜索妙记、查看妙记基础信息、下载/上传音视频、读取或编辑妙记的产物内容、改标题、替换说话人/关键词。当给出minute_token、本地音视频文件,要查/改/转妙记产物时使用;本地音视频转纪要/逐字稿优先走本 skill,不要用 ffmpeg/whisper 本地转写。不负责:获取会议关联妙记,或仅按自然语言标题定位纪要"
|
||||
description: "飞书妙记:搜索妙记列表、查看妙记基础信息、下载妙记音视频文件、上传音视频生成妙记、更新妙记标题、替换说话人。当需要获取、操作或者生成妙记时使用。也支持将本地音视频文件转成纪要和逐字稿(优先使用本 skill,不要用 ffmpeg/whisper 本地转写)。不负责:获取会议关联妙记、纪要/逐字稿内容获取走 lark-vc"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -27,34 +27,26 @@ metadata:
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+search`](references/lark-minutes-search.md) | 按关键词、所有者、参与者、时间范围搜索妙记 |
|
||||
| [`+detail`](references/lark-minutes-detail.md) | 查询妙记详情(标题和关联的纪要note_id),按需获取 AI 产物(总结、待办、章节、逐字稿、关键词) |
|
||||
| [`+download`](references/lark-minutes-download.md) | 下载妙记音视频媒体文件 |
|
||||
| [`+upload`](references/lark-minutes-upload.md) | 上传 file_token 生成妙记 |
|
||||
| [`+update`](references/lark-minutes-update.md) | 更新妙记标题 |
|
||||
| [`+speaker-replace`](references/lark-minutes-speaker-replace.md) | 替换妙记逐字稿中的说话人(仅支持用户 ID,不支持姓名) |
|
||||
| `+word-replace` | 批量替换逐字稿关键词(详见 `lark-cli minutes +word-replace --help`) |
|
||||
| [`+summary`](references/lark-minutes-summary.md) | 替换妙记 AI 总结全文 |
|
||||
| [`+todo`](references/lark-minutes-todo.md) | 新建/更新/删除妙记 AI 待办(单条或 `--todos` 批量;不是 lark-task) |
|
||||
|
||||
- 使用任何 Shortcut 前,必须先读其对应 reference 文档。
|
||||
|
||||
## 意图路由
|
||||
|
||||
| 用户意图 | 命令 |
|
||||
|---------|------|
|
||||
| 我的妙记 / 搜索妙记 / 某段时间的妙记 | `+search` |
|
||||
| 妙记基础信息:标题 / 时长 / 封面 / 链接 | `minutes get` |
|
||||
| 下载妙记音视频文件、获取媒体下载链接 | `+download`(仅媒体;要妙记内容用 `+detail`) |
|
||||
| 妙记总结 / 章节 / 待办 / 关键词 / 逐字稿 | `+detail --minute-tokens <token>` + 显式产物 flag |
|
||||
| 基于妙记**提炼/总结/分析/回顾**会议 | `+detail --minute-tokens <token> --transcript`,再独立分析(**禁止照搬 AI 总结**) |
|
||||
| 拿这条妙记关联的纪要文档(`note_doc_token` / `verbatim_doc_token` / `shared_doc_tokens`) | `+detail` 取顶层 `note_id` → [`note +detail --note-id`](../lark-note/SKILL.md) |
|
||||
| 把本地音视频转纪要 / 逐字稿 / 文字稿 | `drive +upload` 取 `file_token` → `+upload` 生成 `minute_url` → `+detail` 拿产物 |
|
||||
| 在妙记里增加 / 更改 / 删除 AI 待办 | `+todo`(**禁止走 lark-task**) |
|
||||
| 替换妙记的AI 总结 | `+summary` |
|
||||
| 重命名妙记/改妙记标题 | `+update` |
|
||||
| 替换说话人/把 A 的发言改成 B/重新归属发言人 | `+speaker-replace` |
|
||||
| 批量替换逐字稿关键词 | `+word-replace` |
|
||||
| 用户同时提到"会议/开会"和"妙记" | 先 [lark-vc](../lark-vc/SKILL.md)(`+search` → `+recording`)获取 `minute_token`,再本 skill |
|
||||
| 用户意图 | 路由到 |
|
||||
|----------|--------|
|
||||
| "我的妙记""搜索妙记""妙记列表" | 本 skill(`+search`) |
|
||||
| "这个妙记的标题/时长/封面/链接" | 本 skill(`minutes get`) |
|
||||
| "下载妙记的视频/音频" | 本 skill(`+download`) |
|
||||
| "把音视频转妙记/上传文件生成妙记" | 本 skill(`+upload`) |
|
||||
| "重命名妙记/改妙记标题" | 本 skill(`+update`) |
|
||||
| "替换说话人/把 A 的发言改成 B" | 本 skill(`+speaker-replace`) |
|
||||
| "这个妙记的逐字稿/总结/待办/章节" | [lark-vc](../lark-vc/SKILL.md)(`vc +notes --minute-tokens`) |
|
||||
| "把音视频文件转成纪要/逐字稿/文字稿" | 先本 skill(`+upload`),再 [lark-vc](../lark-vc/SKILL.md)(`vc +notes --minute-tokens`) |
|
||||
| 用户同时提到"会议/开会"和"妙记" | 先 [lark-vc](../lark-vc/SKILL.md)(`+search` → `+recording`),再本 skill |
|
||||
|
||||
## 核心概念
|
||||
|
||||
@@ -65,30 +57,60 @@ metadata:
|
||||
|
||||
### 1. 搜索妙记
|
||||
|
||||
1. 如果是会议的妙记,应优先通过 [lark-vc](../lark-vc/SKILL.md) 定位会议并获取 `minute_token`。
|
||||
2. 会议场景的妙记路由,以及"参与的妙记"如何解释,统一以 [minutes +search](references/lark-minutes-search.md) 为准。
|
||||
1. 当用户描述的是"我的妙记""包含某个关键词的妙记""某段时间内的妙记",优先使用 `minutes +search`。
|
||||
2. 仅支持使用关键词、时间段、参与者、所有者等筛选条件搜索妙记记录,对于不支持的筛选条件,需要提示用户。
|
||||
3. 搜索结果存在多条数据时,务必注意分页数据获取,不要遗漏任何妙记记录。
|
||||
4. 如果是会议的妙记,应优先通过 [lark-vc](../lark-vc/SKILL.md) 定位会议并获取 `minute_token`。
|
||||
5. 会议场景的妙记路由,以及"参与的妙记"如何解释,统一以 [minutes +search](references/lark-minutes-search.md) 为准。
|
||||
|
||||
|
||||
### 2. 查看妙记基础信息
|
||||
|
||||
1. 当用户只需要确认某条妙记的标题、封面、时长、所有者、URL 等基础信息时,使用 `minutes minutes get`。
|
||||
2. 如果是会议 / 日程上下文中的妙记基础信息,先通过 VC/Calendar 链路拿到 `minute_token`,再调用 `minutes minutes get`。
|
||||
3. 用户意图不明确时,默认先给基础元信息,帮助确认是否命中目标妙记。
|
||||
2. 如果用户给的是妙记 URL,应先从 URL 末尾提取 `minute_token`,再调用 `minutes minutes get`。
|
||||
3. 如果是会议 / 日程上下文中的妙记基础信息,先通过 VC 链路拿到 `minute_token`,再调用 `minutes minutes get`。
|
||||
4. 用户意图不明确时,默认先给基础元信息,帮助确认是否命中目标妙记。
|
||||
|
||||
> 使用 `lark-cli schema minutes.minutes.get` 可查看完整返回值结构。核心字段包含:`title`(标题)、`cover`(封面 URL)、`duration`(时长,毫秒)、`owner_id`(所有者 ID)、`url`(妙记链接)。
|
||||
|
||||
### 3. 上传音视频文件生成妙记(并可继续获取纪要 / 逐字稿)
|
||||
### 3. 下载妙记音视频文件
|
||||
|
||||
1. 当用户说"把音视频文件转成纪要""把录音转成逐字稿/文字稿/撰写文字""把 mp4/mp3 转成总结/待办/章节"时,也先走这个入口。
|
||||
2. **处理流程**:
|
||||
1. 下载妙记音视频文件到本地,或获取有效期 1 天的下载链接。详见 [minutes +download](references/lark-minutes-download.md)。
|
||||
2. `+download` 只负责音视频媒体文件。用户需要逐字稿、总结、待办、章节等纪要内容时,请使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)。
|
||||
3. 用户只想拿可分享的下载地址时,使用 `--url-only`;用户要落地到本地文件时,直接下载。
|
||||
4. 未显式指定路径时,文件默认落到 `./minutes/{minute_token}/<server-filename>`,与 `vc +notes` 的逐字稿共享同一目录便于聚合。
|
||||
|
||||
> **注意**:`+download` 只负责音视频媒体文件。如果用户需要的是逐字稿、总结、待办、章节等纪要内容,请使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)。
|
||||
|
||||
### 4. 读取妙记的逐字稿、总结、待办、章节(只读)
|
||||
|
||||
1. 当用户要**查看 / 读取**"这个妙记的逐字稿""总结""待办""章节"时,使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)。
|
||||
2. 如果当前上下文中已有 `minute_token`,可直接传给 `vc +notes`;如果只有妙记 URL,先提取 `minute_token`。
|
||||
3. 如果用户给的是**本地音视频文件**,但目标是"转成纪要""转成逐字稿""转成文字稿""转成撰写文字",应先按下文第 5 节上传文件生成妙记,再把返回的 `minute_url` 提取成 `minute_token`,继续调用 `vc +notes --minute-tokens`。
|
||||
4. 用户如果直接给出本地文件名或路径,并要求"转逐字稿""转文字稿""整理成撰写文字",这也是本 skill 的明确触发信号。
|
||||
|
||||
```bash
|
||||
# 通过 minute_token 获取纪要产物(逐字稿、总结、待办、章节)
|
||||
lark-cli vc +notes --minute-tokens <minute_token>
|
||||
```
|
||||
|
||||
> **跨 skill 路由**:逐字稿、AI 总结、待办、章节等纪要内容由 [lark-vc](../lark-vc/SKILL.md) 的 `+notes` 命令提供
|
||||
> **读 vs 写**:`vc +notes` 只负责**读取** AI 产物。用户要**新建 / 修改 / 删除**妙记内的 AI 待办或替换 AI 总结,见下文第 6 节,**不要**走 [lark-task](../lark-task/SKILL.md)。
|
||||
|
||||
### 5. 上传音视频文件生成妙记(并可继续获取纪要 / 逐字稿)
|
||||
|
||||
1. 当用户需要通过上传本地音视频文件来生成妙记时使用。
|
||||
2. 当用户说"把音视频文件转成纪要""把录音转成逐字稿/文字稿/撰写文字""把 mp4/mp3 转成总结/待办/章节"时,也先走这个入口。
|
||||
3. **处理流程**:
|
||||
- **上传音视频获取 `file_token`**:使用 [`lark-cli drive +upload`](../lark-drive/references/lark-drive-upload.md) 上传本地文件到云空间(云盘/云存储)并获取 `file_token`。
|
||||
- **生成妙记**:获取到 `file_token` 后,调用 [`lark-cli minutes +upload`](references/lark-minutes-upload.md) 将文件转换为妙记并获取 `minute_url` 链接。
|
||||
- **继续获取纪要 / 逐字稿(按需)**:如果用户目标不是只要妙记链接,而是要纪要、逐字稿、总结、待办或章节,则从 `minute_url` 中提取 `minute_token`,再调用 [`lark-cli minutes +detail --minute-tokens`](references/lark-minutes-detail.md) 获取对应产物。
|
||||
- **继续获取纪要 / 逐字稿(按需)**:如果用户目标不是只要妙记链接,而是要纪要、逐字稿、总结、待办或章节,则从 `minute_url` 中提取 `minute_token`,再调用 [`lark-cli vc +notes --minute-tokens`](../lark-vc/references/lark-vc-notes.md) 获取对应产物。
|
||||
|
||||
> **注意**:必须先获取飞书云空间(云盘/云存储)的 `file_token` 才能进行转换。
|
||||
>
|
||||
> **不要误走本地转写工具**:当用户目标是把本地音视频文件转成纪要、逐字稿、文字稿、撰写文字时,不要改用 `ffmpeg`、`whisper` 或其他本地 ASR/转码命令;标准路径就是 `drive +upload -> minutes +upload -> minutes +detail --minute-tokens`。
|
||||
> **不要误走本地转写工具**:当用户目标是把本地音视频文件转成纪要、逐字稿、文字稿、撰写文字时,不要改用 `ffmpeg`、`whisper` 或其他本地 ASR/转码命令;标准路径就是 `drive +upload -> minutes +upload -> vc +notes --minute-tokens`。
|
||||
|
||||
### 5. 编辑妙记的 AI 待办与 AI 总结(写入)
|
||||
### 6. 编辑妙记的 AI 待办与 AI 总结(写入)
|
||||
|
||||
当用户要在**某条妙记内**操作 AI 待办或 AI 总结时使用本节。**不是**飞书任务(Task)清单里的待办。
|
||||
|
||||
@@ -118,44 +140,69 @@ lark-cli minutes +todo --minute-token <token> --as user --todos '[
|
||||
]'
|
||||
```
|
||||
|
||||
**更新 / 删除前**:先用 `minutes +detail --minute-tokens <token> --todo` 读取 `todos[].todo_id`(按 `content` 匹配目标条目;列表顺序不保证稳定,**不要**用"第 2 条"代替 `todo_id`)。
|
||||
**更新 / 删除前**:先用 `vc +notes --minute-tokens <token>` 读取 `todos[].todo_id`(按 `content` 匹配目标条目;列表顺序不保证稳定,**不要**用"第 2 条"代替 `todo_id`)。
|
||||
|
||||
**无编辑权限**:若 CLI 返回 `error.type=no_edit_permission`,表示对**这条妙记**没有编辑权,应请所有者授权;**不要**误走 `auth login --scope`。
|
||||
|
||||
**逐字稿关键词替换无命中**:`minutes +word-replace` 时,若 CLI 返回 `error.type=words_not_found`,表示传入的 `source_word` 在该妙记逐字稿中**一个都没匹配到**,未做任何替换。这是**参数问题不是权限问题**:先用 `minutes +detail --minute-tokens <token> --transcript` 读取当前逐字稿,核对 `source_word` 的精确写法与大小写后重试。
|
||||
**逐字稿关键词替换无命中**:`minutes +word-replace` 时,若 CLI 返回 `error.type=words_not_found`,表示传入的 `source_word` 在该妙记逐字稿中**一个都没匹配到**,未做任何替换。这是**参数问题不是权限问题**:先用 `vc +notes --minute-tokens <token>` 读取当前逐字稿,核对 `source_word` 的精确写法与大小写后重试。
|
||||
|
||||
**替换 AI 总结全文**:见 [minutes +summary](references/lark-minutes-summary.md)。
|
||||
|
||||
> 使用 `+todo` 前必须阅读 [references/lark-minutes-todo.md](references/lark-minutes-todo.md);使用 `+summary` 前必须阅读 [references/lark-minutes-summary.md](references/lark-minutes-summary.md)。
|
||||
|
||||
## 行为规则
|
||||
## 资源关系
|
||||
|
||||
### 1. `+detail` 必须显式声明产物 flag
|
||||
|
||||
不传 `--summary` / `--todo` / `--chapter` / `--keyword` / `--transcript` 时只返回基础信息(含顶层 `note_id`),AI 产物字段一律不返回。即使产物为空也会返回空值字段,便于程序化处理。
|
||||
|
||||
```bash
|
||||
# 拿全产物
|
||||
lark-cli minutes +detail --minute-tokens <token> --summary --todo --chapter --keyword --transcript
|
||||
```text
|
||||
Minutes (妙记) ← minute_token 标识
|
||||
├── Metadata (标题、封面、时长、owner、url) → minutes minutes get
|
||||
└── MediaFile (音频/视频文件) → minutes +download
|
||||
```
|
||||
|
||||
### 2. "提炼 / 总结"必须基于 Transcript,不要照搬 AI 总结
|
||||
> **能力边界**:`minutes` 负责 **搜索妙记、查看基础元信息、下载/上传音视频、编辑妙记 AI 待办与 AI 总结、重命名、逐字稿说话人/关键词替换**。
|
||||
>
|
||||
> **路由规则**:
|
||||
>
|
||||
> - 用户说"妙记列表 / 搜索妙记 / 某个关键词的妙记" → `minutes +search`
|
||||
> - 用户只是想看"我的妙记 / 某段时间内的妙记 / 妙记列表",不要先走 [lark-vc](../lark-vc/SKILL.md),而应直接使用本 skill
|
||||
> - 用户如果同时提到"会议 / 会 / 开会 / 某场会",即使也提到了"妙记",也应优先走 [lark-vc](../lark-vc/SKILL.md) 先定位会议,再通过 [vc +recording](../lark-vc/references/lark-vc-recording.md) 获取 `minute_token`
|
||||
> - 用户如果要的是妙记基础信息,拿到 `minute_token` 后用 `minutes minutes get`;用户如果要**读取**逐字稿、文字稿、撰写文字、总结、待办、章节,再走 `vc +notes --minute-tokens`
|
||||
> - “我的妙记”“参与的妙记”等自然语言映射细则,以 [minutes +search](references/lark-minutes-search.md) 为准
|
||||
> - 结果有多页时,使用 `page_token` 持续翻页,直到确认没有更多结果
|
||||
> - `minutes +search` 单次最多返回 `200` 条;结果总数没有固定上限
|
||||
> - 用户说"这个妙记的标题 / 时长 / 封面 / 链接" → `minutes minutes get`
|
||||
> - 用户说"下载这个妙记的视频 / 音频 / 媒体文件" → `minutes +download`
|
||||
> - 用户要**读取**"这个妙记的逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)
|
||||
> - 用户要在**妙记内新建 / 修改 / 删除 AI 待办**(含「妙记里加待办」「任务1 已完成」等)→ [`minutes +todo`](references/lark-minutes-todo.md),**禁止**走 lark-task
|
||||
> - 用户要**替换妙记 AI 总结全文** → [`minutes +summary`](references/lark-minutes-summary.md)
|
||||
> - 用户说"通过文件生成妙记 / 把音视频转妙记" → 先上传获取 `file_token`,然后使用 `minutes +upload`
|
||||
> - 用户说"把音视频文件转成纪要 / 逐字稿 / 文字稿 / 撰写文字 / 总结 / 待办 / 章节" → 先上传获取 `file_token`,调用 `minutes +upload` 生成 `minute_url`,再提取 `minute_token` 走 `vc +notes --minute-tokens`
|
||||
> - 用户说"重命名妙记 / 改妙记标题 / 修改妙记名字" → `minutes +update`
|
||||
> - 用户说"替换说话人 / 把 A 的发言改成 B / 重新归属发言人" → `minutes +speaker-replace`
|
||||
> - 用户说"批量替换逐字稿关键词" → `minutes +word-replace`
|
||||
|
||||
AI 总结是模型对会议的二次压缩,可能遗漏争论过程和隐含决策。用户要求"提炼"或"重新总结"时,期望基于原始发言独立分析,而非搬运 AI 产物。**优先 `--transcript`,再独立写结论**。
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
### 3. 从妙记反查纪要:不绕 lark-vc
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli minutes +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
`minutes +detail` 顶层直接返回 `note_id`(仅在该妙记关联纪要时存在)。不需要绕回 [lark-vc](../lark-vc/SKILL.md),直接:
|
||||
| Shortcut | 说明 |
|
||||
| -------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| [`+search`](references/lark-minutes-search.md) | Search minutes by keyword, owners, participants, and time range |
|
||||
| [`+download`](references/lark-minutes-download.md) | Download audio/video media file of a minute |
|
||||
| [`+upload`](references/lark-minutes-upload.md) | Upload a media file token to generate a minute |
|
||||
| [`+update`](references/lark-minutes-update.md) | Update a minute's title |
|
||||
| [`+speaker-replace`](references/lark-minutes-speaker-replace.md) | Replace a speaker in a minute's transcript (rebind from one user to another) |
|
||||
| [`+summary`](references/lark-minutes-summary.md) | Replace the full AI summary text of a minute |
|
||||
| [`+todo`](references/lark-minutes-todo.md) | Add, update, or delete **AI todo(s) inside a minute** (single or batch via `--todos`; not Feishu Task) |
|
||||
|
||||
```bash
|
||||
# 1) 取 note_id(顶层 .minutes[0].note_id)
|
||||
lark-cli minutes +detail --minute-tokens <minute_token> --format json
|
||||
# 2) 用上一步拿到的 note_id 读纪要 token
|
||||
lark-cli note +detail --note-id <note_id> # 拿 note_doc_token / verbatim_doc_token / shared_doc_tokens
|
||||
```
|
||||
|
||||
顶层无 `note_id` 字段即代表无关联纪要,到此为止——不要继续尝试用 `minute_token` 当 `note_id`。
|
||||
- 使用 `+search` 命令时,必须阅读 [references/lark-minutes-search.md](references/lark-minutes-search.md),了解搜索参数和返回值结构。
|
||||
- 使用 `+download` 命令时,必须阅读 [references/lark-minutes-download.md](references/lark-minutes-download.md),了解下载参数和返回值结构。
|
||||
- 使用 `+upload` 命令时,必须阅读 [references/lark-minutes-upload.md](references/lark-minutes-upload.md),了解生成参数和返回值结构。
|
||||
- 使用 `+update` 命令时,必须阅读 [references/lark-minutes-update.md](references/lark-minutes-update.md),了解修改参数和返回值结构。
|
||||
- 使用 `+speaker-replace` 命令时,必须阅读 [references/lark-minutes-speaker-replace.md](references/lark-minutes-speaker-replace.md),了解参数和限制(仅支持用户 ID,不支持姓名)。
|
||||
- 使用 `+summary` 命令时,必须阅读 [references/lark-minutes-summary.md](references/lark-minutes-summary.md),了解全文替换参数。
|
||||
- 使用 `+todo` 命令时,必须阅读 [references/lark-minutes-todo.md](references/lark-minutes-todo.md),了解单条与 `--todos` 批量模式;**不要**用 lark-task。
|
||||
|
||||
<!-- AUTO-GENERATED-START — gen-skills.py 管理,勿手动编辑 -->
|
||||
|
||||
## API Resources
|
||||
|
||||
@@ -171,8 +218,6 @@ lark-cli minutes <resource> <method> [flags]
|
||||
|
||||
## 不在本 skill 范围
|
||||
|
||||
- 搜索历史会议记录、查参会人快照 → [lark-vc](../lark-vc/SKILL.md)
|
||||
- 未来日程 / 日历查询 → [lark-calendar](../lark-calendar/SKILL.md)
|
||||
- 已知 `note_id` 直接读纪要详情 → [lark-note](../lark-note/SKILL.md)
|
||||
- 飞书任务清单(个人 Todo / 共享清单) → [lark-task](../lark-task/SKILL.md)
|
||||
- 只有自然语言纪要标题、没有 `minute_token` / 妙记 URL / 本地音视频时定位逐字稿 → 文档搜索([lark-drive](../lark-drive/SKILL.md) / [lark-doc](../lark-doc/SKILL.md))
|
||||
- 纪要/逐字稿/总结/待办/章节内容获取 → [lark-vc](../lark-vc/SKILL.md)(`vc +notes --minute-tokens`)
|
||||
- 搜索历史会议记录 → [lark-vc](../lark-vc/SKILL.md)
|
||||
- 查询未来的会议日程 → [lark-calendar](../lark-calendar/SKILL.md)
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
|
||||
# minutes +detail
|
||||
|
||||
通过 `minute_token` 查询妙记详情,按需获取 AI 产物(总结/待办/章节/逐字稿/关键词)。只读。
|
||||
|
||||
> `--summary` / `--todo` / `--chapter` / `--keyword` / `--transcript` 至少一个;不传任何产物 flag 时只返回基础信息(如 `title`),AI 产物字段都不会出现。一次性获取所有产物:`--summary --todo --chapter --keyword --transcript`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 仅基础信息
|
||||
lark-cli minutes +detail --minute-tokens obcxxxxxxxxxx
|
||||
|
||||
# 批量(逗号分隔,最多 50 个)
|
||||
lark-cli minutes +detail --minute-tokens obcxxx,obcyyy --summary --todo
|
||||
|
||||
# 全产物
|
||||
lark-cli minutes +detail --minute-tokens obcxxx --summary --todo --chapter --keyword --transcript
|
||||
|
||||
# 仅逐字稿,覆盖已有文件
|
||||
lark-cli minutes +detail --minute-tokens obcxxx --transcript --overwrite
|
||||
```
|
||||
|
||||
## 输出
|
||||
|
||||
`minutes` 数组每条含 `minute_token`、`title`、`note_id`、`artifacts`。`note_id` 仅在该妙记关联了会议纪要时返回,可直接传给 [`note +detail`](../../lark-note/references/lark-note-detail.md) 拿纪要文档 token,无需再绕回 `vc +detail`。`artifacts` 中**只包含本次请求的产物**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `artifacts.summary` | string | AI 总结。 |
|
||||
| `artifacts.todos` | array | 待办事项列表。 |
|
||||
| `artifacts.chapters` | array | 章节列表。 |
|
||||
| `artifacts.keywords` | array | 关键词列表。 |
|
||||
| `artifacts.transcript_file` | string | 逐字稿本地文件路径。 |
|
||||
|
||||
逐字稿默认落地 `./minutes/{minute_token}/transcript.txt`,与 `minutes +download` 同目录便于聚合。
|
||||
|
||||
## minute_token 来源
|
||||
|
||||
| 来源 | 取值字段 |
|
||||
|------|---------|
|
||||
| 妙记 URL `https://*.feishu.cn/minutes/obcxxx` | 截路径最后一段 `obcxxx` |
|
||||
| `vc +detail --meeting-ids` | `minute_token` |
|
||||
| `vc +recording --meeting-ids` | `minute_token` |
|
||||
| `minutes +search` | `minute_token` |
|
||||
|
||||
## 典型链路:从 minute_token 拿纪要文档 token
|
||||
|
||||
只持有 `minute_token`(如妙记 URL 入口),又想拿 AI 智能纪要 / 逐字稿文档时:
|
||||
|
||||
```bash
|
||||
# 1. 取妙记关联的 note_id,没有关联会议纪要则为空
|
||||
lark-cli minutes +detail --minute-tokens <minute_token>
|
||||
|
||||
# 2. 用 note_id 拿 note_doc_token / verbatim_doc_token / shared_doc_tokens
|
||||
lark-cli note +detail --note-id <note_id>
|
||||
|
||||
# 3. 读纪要 / 逐字稿正文
|
||||
lark-cli docs +fetch --api-version v2 --doc <note_doc_token> --doc-format markdown
|
||||
```
|
||||
|
||||
> `minute_token` 不要直接传给 `note +detail`:必须先用本命令拿到 `note_id` 再调用 `note +detail`。
|
||||
@@ -43,7 +43,7 @@ lark-cli minutes +download --minute-tokens obcnxxxxxxxxxxxxxxxxxxxx --dry-run
|
||||
| `--url-only` | 否 | 仅返回下载链接,不下载文件 |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
> **默认落点**:未指定 `--output` / `--output-dir` 时,文件落到 `./minutes/{minute_token}/<server-filename>`。文件名沿用服务端 Content-Disposition / Content-Type 推断,Agent 可从 `saved_path` 字段读取实际路径。同一 minute_token 的录像和 `minutes +detail` 的逐字稿默认会落在**同一目录**下,方便聚合。
|
||||
> **默认落点**:未指定 `--output` / `--output-dir` 时,文件落到 `./minutes/{minute_token}/<server-filename>`。文件名沿用服务端 Content-Disposition / Content-Type 推断,Agent 可从 `saved_path` 字段读取实际路径。同一 minute_token 的录像和 `vc +notes` 的逐字稿默认会落在**同一目录**下,方便聚合。
|
||||
|
||||
## 核心约束
|
||||
|
||||
@@ -85,7 +85,7 @@ API 限流 5 次/秒,批量下载时需注意控制频率。
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `minute_token` | 妙记 Token(用于 Agent 索引) |
|
||||
| `artifact_type` | 固定为 `"recording"`(与 `minutes +detail` 的 `"transcript"` 区分) |
|
||||
| `artifact_type` | 固定为 `"recording"`(与 `vc +notes` 的 `"transcript"` 区分) |
|
||||
| `saved_path` | 文件保存的本地路径(绝对路径) |
|
||||
| `size_bytes` | 文件大小(字节) |
|
||||
|
||||
@@ -125,13 +125,13 @@ API 限流 5 次/秒,批量下载时需注意控制频率。
|
||||
## 提示
|
||||
|
||||
- 音视频文件可能较大,下载无固定超时限制(由用户 Ctrl+C 控制取消)。
|
||||
- 默认落点 `./minutes/{minute_token}/` 与 `minutes +detail` 的逐字稿共享同一目录,方便 Agent 聚合同一会议的所有产物。
|
||||
- 默认落点 `./minutes/{minute_token}/` 与 `vc +notes` 的逐字稿共享同一目录,方便 Agent 聚合同一会议的所有产物。
|
||||
- 单 token 模式下 `--output` 若传入已存在目录(如 `--output ./existing-dir`),等价于 `--output-dir`,文件落入该目录(cp 语义)。
|
||||
- 批量模式下 `--output` 不接受已存在的文件路径(会报错),应改用 `--output-dir`。
|
||||
- 如需获取妙记的纪要内容(逐字稿、AI 总结等),请使用 [minutes +detail](lark-minutes-detail.md)。
|
||||
- 如需获取妙记的纪要内容(逐字稿、AI 总结等),请使用 [vc +notes](../../lark-vc/references/lark-vc-notes.md)。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-minutes](../SKILL.md) — 妙记全部命令
|
||||
- [lark-minutes-detail](lark-minutes-detail.md) — 妙记详情与 AI 产物查询
|
||||
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 会议纪要查询
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -129,6 +129,8 @@ CLI 会先按输入的本地日历日语义解析,再标准化为 RFC3339 时
|
||||
2. `vc +recording` 获取 `minute_token`
|
||||
3. `minutes minutes get` 查询妙记基础信息
|
||||
|
||||
不要为了查"妙记信息"直接走 `vc +notes --meeting-ids`。`vc +notes` 只适用于逐字稿、总结、待办、章节等纪要内容。
|
||||
|
||||
<br />
|
||||
|
||||
## 时间格式
|
||||
@@ -171,8 +173,8 @@ lark-cli minutes +search --query "预算复盘" --page-size 20 --page-token '<PA
|
||||
# 首先查询妙记元信息(标题、时长、封面) → 用本 skill
|
||||
lark-cli minutes minutes get --params '{"minute_token": "obcn***************"}'
|
||||
|
||||
# 查妙记关联的纪要产物:逐字稿、总结、待办、章节等 → 用 lark-cli minutes +detail
|
||||
lark-cli minutes +detail --minute-tokens obcn_EXAMPLE_TOKEN
|
||||
# 查妙记关联的纪要产物:逐字稿、总结、待办、章节等 → 用 lark-cli vc +notes
|
||||
lark-cli vc +notes --minute-tokens obcn_EXAMPLE_TOKEN
|
||||
```
|
||||
|
||||
## 常见错误与排查
|
||||
@@ -190,7 +192,7 @@ lark-cli minutes +detail --minute-tokens obcn_EXAMPLE_TOKEN
|
||||
- 当用户说“我的妙记”时,优先理解为 `--owner-ids me`。
|
||||
- 当用户说“我参与的妙记”“我参加过的妙记”时,默认理解为 `--owner-ids me` 与 `--participant-ids me` 两次查询后的并集。
|
||||
- 当用户明确说“仅我参与但不是我拥有”时,才优先理解为 `--participant-ids me`。
|
||||
- 当用户同时提到“会议 / 会 / 开会 / 某场会”和“妙记”时,优先先定位会议;如果要的是妙记信息,走 `vc +recording` 获取 `minute_token` → `minutes minutes get`,只有要妙记产物内容时才走 `minutes +detail --minute-tokens`。
|
||||
- 当用户同时提到“会议 / 会 / 开会 / 某场会”和“妙记”时,优先先定位会议;如果要的是妙记信息,走 `vc +recording` → `minutes minutes get`,只有要纪要内容时才走 `vc +notes --minute-tokens`。
|
||||
- 必须使用 `--format json` 输出,你更加擅长解析 JSON 数据。
|
||||
- 排查参数与请求结构时优先使用 `--dry-run`。
|
||||
- 搜索的时间范围最大为 1 个月,如果需要搜索更长时间范围的妙记,需要拆分为多次时间范围为一个月查询。
|
||||
@@ -198,7 +200,7 @@ lark-cli minutes +detail --minute-tokens obcn_EXAMPLE_TOKEN
|
||||
## 参考
|
||||
|
||||
- [lark-minutes](../SKILL.md) -- 妙记相关命令
|
||||
- [lark-minutes-detail](lark-minutes-detail.md) -- 基于 `minute_token` 获取逐字稿、总结、待办、章节等产物
|
||||
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) -- 基于 `minute_token` 获取逐字稿、总结、待办、章节等产物
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
- [lark-vc](../../lark-vc/SKILL.md) -- 视频会议全部命令
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary @sum
|
||||
|
||||
### 1. 先读后写
|
||||
|
||||
替换前建议先用 `lark-cli minutes +detail --minute-tokens <token> --summary` 读取当前总结,确认 `minute_token` 与待替换内容无误。
|
||||
替换前建议先用 `lark-cli vc +notes --minute-tokens <token>` 读取当前总结,确认 `minute_token` 与待替换内容无误。
|
||||
|
||||
### 2. Markdown 展示说明
|
||||
|
||||
@@ -104,7 +104,7 @@ lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary @sum
|
||||
|------|---------|
|
||||
| 妙记 URL | 从 URL 末尾提取,如 `https://sample.feishu.cn/minutes/obcnxxxxxxxxxxxxxxxxxxxx` |
|
||||
| 妙记搜索 | `lark-cli minutes +search --query "关键词"` |
|
||||
| 会议产物查询 | `lark-cli vc +detail --meeting-ids <id>` 拿到 `minute_token`,或 `vc +recording` |
|
||||
| 会议产物查询 | `lark-cli vc +notes --minute-tokens <token>` |
|
||||
|
||||
## 常见错误与排查
|
||||
|
||||
@@ -118,5 +118,5 @@ lark-cli minutes +summary --minute-token obcnxxxxxxxxxxxxxxxxxxxx --summary @sum
|
||||
|
||||
- [lark-minutes](../SKILL.md) — 妙记全部命令
|
||||
- [minutes +todo](lark-minutes-todo.md) — 替换待办项
|
||||
- [minutes +detail](lark-minutes-detail.md) — 读取总结、待办等 AI 产物
|
||||
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 读取总结、待办等 AI 产物
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -94,7 +94,7 @@ lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --operation add -
|
||||
|
||||
### 1. 先读后写,待办 id 如何获取
|
||||
|
||||
更新 / 删除前先用 `lark-cli minutes +detail --minute-tokens <token> --todo` 读取当前待办。返回的每条待办带 `todo_id` 字段。
|
||||
更新 / 删除前先用 `lark-cli vc +notes --minute-tokens <token>` 读取当前待办。返回的每条待办带 `todo_id` 字段。
|
||||
|
||||
> 待办 id 仅用于程序内部定位,不必展示给用户。
|
||||
|
||||
@@ -134,5 +134,5 @@ lark-cli minutes +todo --minute-token obcnxxxxxxxxxxxxxxxxxxxx --operation add -
|
||||
|
||||
- [lark-minutes](../SKILL.md)
|
||||
- [minutes +summary](lark-minutes-summary.md)
|
||||
- [minutes +detail](lark-minutes-detail.md)
|
||||
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md)
|
||||
- [lark-shared](../../lark-shared/SKILL.md)
|
||||
|
||||
@@ -31,13 +31,13 @@
|
||||
```
|
||||
- 命令执行成功后,将返回生成的妙记链接 `minute_url`。
|
||||
|
||||
3. **如需纪要 / 逐字稿 / 文字稿 / 撰写文字,继续提取 `minute_token` 调用 `minutes +detail`**
|
||||
3. **如需纪要 / 逐字稿 / 文字稿 / 撰写文字,继续提取 `minute_token` 调用 `vc +notes`**
|
||||
- 从返回的 `minute_url` 中提取路径最后一段,得到 `minute_token`。
|
||||
- 如果用户要的是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,继续调用:
|
||||
```bash
|
||||
lark-cli minutes +detail --minute-tokens <minute_token> --transcript
|
||||
lark-cli vc +notes --minute-tokens <minute_token>
|
||||
```
|
||||
- `minutes +detail --minute-tokens` 会返回纪要文档、逐字稿文档,以及 AI 内置产物(总结、待办、章节);必要时还会把逐字稿落地到本地文件。
|
||||
- `vc +notes --minute-tokens` 会返回纪要文档、逐字稿文档,以及 AI 内置产物(总结、待办、章节);必要时还会把逐字稿落地到本地文件。
|
||||
|
||||
> **异步生成提示**:API 会立即返回 `minute_url`,但妙记可能仍在异步生成中,您可以直接通过该妙记链接查看当前的处理状态和转写结果。
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
lark-cli minutes +upload --file-token boxcnxxxxxxxxxxxxxxxx
|
||||
|
||||
# 通过 minute_token 继续获取纪要 / 逐字稿 / 文字稿 / AI 产物
|
||||
lark-cli minutes +detail --minute-tokens obcnxxxxxxxxxxxxxxxx
|
||||
lark-cli vc +notes --minute-tokens obcnxxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -81,9 +81,9 @@ lark-cli minutes +detail --minute-tokens obcnxxxxxxxxxxxxxxxx
|
||||
1. 使用 `lark-cli drive +upload --file <path>` 上传本地音视频文件到云空间(云盘/云存储)
|
||||
2. 从返回结果中取出 `file_token`
|
||||
3. 调用 `lark-cli minutes +upload --file-token <file_token>` 生成妙记
|
||||
4. 如果目标是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,再从 `minute_url` 提取 `minute_token`,继续调用 `lark-cli minutes +detail --minute-tokens <minute_token>`
|
||||
4. 如果目标是纪要、逐字稿、文字稿、撰写文字、总结、待办或章节,再从 `minute_url` 提取 `minute_token`,继续调用 `lark-cli vc +notes --minute-tokens <minute_token>`
|
||||
|
||||
> **边界说明**:`minutes +upload` 本身只负责把文件转成妙记并返回 `minute_url`。纪要内容、逐字稿、文字稿、撰写文字、总结、待办、章节属于后续产物获取,应由 [minutes +detail](lark-minutes-detail.md) 承接。
|
||||
> **边界说明**:`minutes +upload` 本身只负责把文件转成妙记并返回 `minute_url`。纪要内容、逐字稿、文字稿、撰写文字、总结、待办、章节属于后续产物获取,应由 [vc +notes](../../lark-vc/references/lark-vc-notes.md) 承接。
|
||||
|
||||
## 输出结果示例
|
||||
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
---
|
||||
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)。
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-vc/references/vc-domain-boundaries.md`](../lark-vc/references/vc-domain-boundaries.md)**,不读将导致命令使用、会议产物决策、领域边界职责判断错误:
|
||||
> 1. 了解日历 & VC、会议产物 & 文档的关联关系和职责划分
|
||||
> 2. 了解会议产物(妙记和纪要)之间的关联关系,例如:**妙记和纪要产生条件相互独立**
|
||||
> 3. 了解不同会议产物的组成部分,以便根据需求决策使用哪种产物的数据
|
||||
|
||||
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` |
|
||||
| 只持有 `meeting_id` | 先 `vc +detail --meeting-ids <id>` 拿 `note_id`,再 `note +detail --note-id NOTE_ID` |
|
||||
| 只持有 `minute_token`(妙记 URL) | 先 `minutes +detail --minute-tokens <token>` 顶层取 `note_id`,再 `note +detail --note-id NOTE_ID`(不要把 `minute_token` 当 `note_id`) |
|
||||
| 只持有日程 `event_id` | 先 `calendar +meeting --event-ids <id>` 拿 `meeting_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` 定位纪要(`note_id`)→ [lark-vc](../lark-vc/SKILL.md)(`vc +detail`)。
|
||||
- 通过 `minute_token` 定位纪要(`note_id`)→ [lark-minutes](../lark-minutes/SKILL.md)(`minutes +detail` 顶层返回 `note_id`)。
|
||||
- 通过日程 `event_id` 定位会议(`meeting_id`) / 用户绑定纪要(`meeting_note`) → [lark-calendar](../lark-calendar/SKILL.md)(`calendar +meeting`)。
|
||||
- 自然语言纪要标题搜索 → [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 原始记录或处理本地输出文件 |
|
||||
|
||||
## 核心概念
|
||||
|
||||
- **会议纪要(Note)**:视频会议结束后生成的结构化文档,通过 `note_id` 标识。一个 Note 包含 AI 智能纪要文档、逐字稿文档和会中共享文档。
|
||||
- **note_id**:纪要的唯一标识符,可通过 `vc +detail --meeting-ids` 获取。
|
||||
- **AI 智能纪要(MainDoc)**:AI 生成的会议总结与待办,对应 `note_doc_token`。
|
||||
- **逐字稿(VerbatimDoc)**:会议的逐句发言记录,含说话人和时间戳,对应 `verbatim_doc_token`。
|
||||
- **共享文档(SharedDoc)**:会中投屏共享的文档,对应 `shared_doc_tokens`。
|
||||
|
||||
## 核心场景
|
||||
|
||||
### 1. 通过 note_id 获取纪要文档 Token
|
||||
|
||||
1. 当用户已有 `note_id`,需要获取对应的 `note_doc_token`、`verbatim_doc_token` 或 `shared_doc_tokens` 时,使用 `note +detail`。
|
||||
2. `note_id` 通常来自 `vc +detail` 的返回结果。
|
||||
3. 获取到文档 Token 后,可使用 `docs +fetch --api-version v2` 读取文档内容,或使用 `drive metas batch_query` 获取文档元信息。
|
||||
|
||||
```bash
|
||||
# 1. 从会议获取 note_id
|
||||
lark-cli vc +detail --meeting-ids <meeting_id>
|
||||
|
||||
# 2. 用 note_id 拿文档 Token
|
||||
lark-cli note +detail --note-id <note_id>
|
||||
|
||||
# 3. 读取纪要文档内容
|
||||
lark-cli docs +fetch --api-version v2 --doc <note_doc_token> --doc-format markdown
|
||||
```
|
||||
@@ -1,26 +0,0 @@
|
||||
# note +detail
|
||||
|
||||
通过 `note_id` 查询会议纪要详情,获取下挂文档 Token(AI 智能纪要、逐字稿、会中共享文档)。只读,仅支持 `--as user`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli note +detail --note-id <note_id>
|
||||
```
|
||||
|
||||
## `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 路由。
|
||||
@@ -1,23 +0,0 @@
|
||||
# note +transcript
|
||||
|
||||
只在 `note +detail` 已确认 `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` 非空 | 先按独立逐字稿文档读取 |
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-okr
|
||||
version: 1.0.0
|
||||
description: "飞书 OKR:管理目标与关键结果。查看和编辑 OKR 周期、目标、关键结果、对齐关系、量化指标和进展记录。当用户需要查看或创建 OKR、管理目标和关键结果、查看对齐关系时使用。不负责:待办任务管理(lark-task)、日程/会议安排(lark-calendar)、绩效评估"
|
||||
description: "飞书 OKR:管理目标与关键结果。查看和编辑 OKR 周期、目标(Objective)、关键结果(Key Result)、对齐关系、量化指标和进展记录。当用户需要查看或创建 OKR、管理目标和关键结果、查看对齐关系时使用。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: [ "lark-cli" ]
|
||||
@@ -12,8 +12,6 @@ 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 的操作优先使用。
|
||||
@@ -22,7 +20,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 的进展记录内容 |
|
||||
@@ -37,6 +35,13 @@ 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` — 删除对齐关系
|
||||
@@ -50,13 +55,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`)
|
||||
|
||||
- `list` — 批量获取用户周期
|
||||
- `objectives_position` — 更新用户周期下全部目标的位置
|
||||
- 请求中必须携带对应周期下全部目标的 ID,否则会参数校验失败。以传入的目标ID顺序重新排列目标。
|
||||
- 请求中必须同时修改对应周期下全部目标的位置,且不允许位置重叠,否则会参数校验失败。
|
||||
- `objectives_weight` — 更新用户周期下全部目标的权重
|
||||
- 请求中必须同时修改对应周期下全部目标的权重,且所有权重值的和必须等于 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
|
||||
```
|
||||
- 请求中必须同时修改对应周期下全部目标的权重,且所有权重值的和必须等于 1 ,否则会参数校验失败。
|
||||
|
||||
### cycle.objectives
|
||||
|
||||
@@ -82,9 +83,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`)
|
||||
- `delete` — 删除目标
|
||||
- `get` — 获取目标
|
||||
- `key_results_position` — 更新全部关键结果的位置
|
||||
- 请求中必须携带对应周期下全部关键结果的 ID,否则会参数校验失败。以传入的关键结果ID顺序重新排列关键结果。
|
||||
- 请求中必须同时修改对应目标下全部关键结果的位置,且不允许位置重叠,否则会参数校验失败。
|
||||
- `key_results_weight` — 更新全部关键结果的权重
|
||||
- 类似 `objectives_weight`, 请求中必须同时修改对应目标下全部关键结果的权重,且所有权重值的和必须等于 1 ,否则会参数校验失败。
|
||||
- 请求中必须同时修改对应目标下全部关键结果的权重,且所有权重值的和必须等于 1 ,否则会参数校验失败。
|
||||
- `patch` — 更新目标
|
||||
|
||||
### objective.alignments
|
||||
@@ -102,10 +103,31 @@ Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`)
|
||||
- `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` |
|
||||
|
||||
|
||||
@@ -1,168 +1,89 @@
|
||||
---
|
||||
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."
|
||||
description: "首次配置 lark-cli、运行 auth login、用 --as 切换 user/bot 身份、处理权限不足或 scope 错误、遇到高风险写操作的确认门禁(exit 10 / confirmation)、更新 lark-cli、或看到 JSON 输出里的 _notice 时使用。"
|
||||
---
|
||||
|
||||
# lark-cli 共享规则
|
||||
|
||||
本技能指导你如何通过lark-cli操作飞书资源, 以及有哪些注意事项。
|
||||
通过 lark-cli 操作飞书资源的通用规则。正文是常驻核心;以下细节按需读取(`lark-cli skills read lark-shared references/<file>`):
|
||||
|
||||
## 配置初始化
|
||||
- **遇到 exit 10 / confirmation 错误** → 读 [`references/lark-shared-high-risk-approval.md`](references/lark-shared-high-risk-approval.md)(错误形态、识别、按 hint 重试、如何识别高风险)
|
||||
- **要帮用户做 user 身份授权** → 读 [`references/lark-shared-auth-split-flow.md`](references/lark-shared-auth-split-flow.md)(split-flow 完整步骤)
|
||||
- **拿到 `/wiki/` 链接或 wiki token** → 读 [`references/lark-wiki-token-routing.md`](references/lark-wiki-token-routing.md)(token 解包与按底层对象路由)
|
||||
|
||||
首次使用需运行 `lark-cli config init` 完成应用配置。
|
||||
|
||||
当你帮用户初始化配置时,使用background方式使用下面的命令发起配置应用流程,启动后读取输出,从中提取授权链接并发给用户。
|
||||
|
||||
**URL 转发规则**:当命令输出 `verification_url`、`verification_uri_complete`、`console_url` 等 URL 字段时:**必须生成二维码**:你必须调用 `lark-cli auth qrcode` 将 URL 转为二维码并展示给用户,这是必须步骤,不要跳过。优先生成 PNG 二维码(--output);仅当用户明确要求时才使用 ASCII(--ascii)。**URL 输出规则**:将 URL 视为不可修改的 opaque string,不要做任何修改(包括 URL 编码/解码、添加空格或标点、重新拼接 query),二维码和链接请一起展示给用户。
|
||||
|
||||
```bash
|
||||
# 发起配置(该命令会阻塞直到用户打开链接并完成操作或过期)
|
||||
lark-cli config init --new
|
||||
```
|
||||
|
||||
## 认证
|
||||
## 认证与身份
|
||||
|
||||
### 身份类型
|
||||
|
||||
两种身份类型,通过 `--as` 切换:
|
||||
两种身份,通过 `--as` 切换:
|
||||
|
||||
| 身份 | 标识 | 获取方式 | 适用场景 |
|
||||
|------|------|---------|---------|
|
||||
| user 用户身份 | `--as user` | `lark-cli auth login` 等 | 访问用户自己的资源(日历、云空间/云盘/云存储等) |
|
||||
| bot 应用身份 | `--as bot` | 自动,只需 appId + appSecret | 应用级操作,访问bot自己的资源 |
|
||||
| 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` 授权,两层都要满足
|
||||
|
||||
- **Bot 看不到用户资源**:无法访问用户的日历、云空间文档、邮箱等个人资源。例如 `--as bot` 查日程返回 bot 自己的(空)日历。
|
||||
- **Bot 无法代表用户操作**:发消息以应用名义发送,创建文档归属 bot。
|
||||
- **Bot 权限**:只需在飞书开发者后台开通 scope,无需 `auth login`。
|
||||
- **User 权限**:后台开通 scope + 用户通过 `auth login` 授权,两层都要满足。
|
||||
|
||||
### 权限不足处理
|
||||
|
||||
遇到权限相关错误时,**根据当前身份类型采取不同解决方案**。
|
||||
遇到权限相关错误时,**根据当前身份采取不同方案**。错误响应中的关键字段(注意区分来源):
|
||||
|
||||
错误响应中包含关键信息:
|
||||
- `permission_violations`:列出缺失的 scope (N选1)
|
||||
- `console_url`:飞书开发者后台的权限配置链接
|
||||
- `hint`:建议的修复命令
|
||||
- 缺失的 scope:`permission_violations`(原始 API 错误块,元素形如 `{subject: "<scope>"}`);CLI 结构化错误里则是已抽取好的 `missing_scopes`(scope 字符串数组)。
|
||||
- `console_url`:飞书开发者后台的权限配置链接。
|
||||
- `hint`:建议的修复命令。
|
||||
|
||||
#### Bot 身份(`--as bot`)
|
||||
- **Bot 身份**:将 `console_url` 提供给用户(按下方「安全规则」的 URL 规则展示),引导去后台开通 scope。**禁止**对 bot 执行 `auth login`。
|
||||
- **User 身份**:
|
||||
```bash
|
||||
lark-cli auth login --domain <domain> # 按业务域授权
|
||||
lark-cli auth login --scope "<missing_scope>" # 按具体 scope 授权(推荐,最小权限)
|
||||
```
|
||||
auth login 必须指定范围(`--domain` 或 `--scope`);多次 login 的 scope 会累积(增量授权)。
|
||||
|
||||
将错误中的 `console_url` 原样提供给用户,引导去后台开通 scope。**禁止**对 bot 执行 `auth login`。
|
||||
### Agent 代理发起认证
|
||||
|
||||
#### User 身份(`--as user`)
|
||||
优先用 split-flow(`--no-wait` 发起 → 展示给用户 → 后续轮 `--device-code` 完成),避免同轮阻塞。三条铁律:① 不在同一轮展示 URL 后立刻阻塞轮询 `--device-code`(交还控制权,等用户回来);② `--device-code` 由你亲自执行,不要让用户自己跑;③ 不缓存 `verification_url` / `device_code`,每次需要授权都重新发起。授权 URL 的二维码展示按「安全规则」。完整步骤详见 [`references/lark-shared-auth-split-flow.md`](references/lark-shared-auth-split-flow.md)。
|
||||
|
||||
```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 config init --new`:帮用户初始化时**非阻塞启动**该命令、**持续读取输出**、从中提取授权链接发给用户(URL 的二维码展示按「安全规则」)。
|
||||
|
||||
## 更新检查
|
||||
|
||||
lark-cli 命令执行后,如果检测到新版本,JSON 输出中会包含 `_notice.update` 字段(含 `message`、`command` 等)。
|
||||
命令执行后若检测到新版本,JSON 输出会包含 `_notice.update`(字段:`current`、`latest`、`message`、`command`)。
|
||||
|
||||
**当你在输出中看到 `_notice.update` 时,完成用户当前请求后,主动提议帮用户更新**:
|
||||
**看到 `_notice.update` 时,完成用户当前请求后,主动提议更新**:
|
||||
|
||||
1. 告知用户当前版本和最新版本号
|
||||
2. 提议执行更新(同时更新 CLI 和 Skills):
|
||||
```bash
|
||||
lark-cli update
|
||||
```
|
||||
3. 更新完成后提醒用户:**退出并重新打开 AI Agent** 以加载最新 Skills
|
||||
1. 告知用户当前版本和最新版本号(也可用 `lark-cli update --check` 只检查不安装)。
|
||||
2. 提议执行 `lark-cli update`(同时更新 CLI 和 AI Skills)。
|
||||
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 传入,避免路径和转义问题。
|
||||
- **文件路径只接受相对路径**:`--file`、`--output`、`--output-dir`、`@file` 等路径参数只接受 cwd 下的相对路径,传绝对路径会报 `unsafe file path`。数据输入(`@file`、大 JSON)优先用 stdin,避免路径和转义问题。
|
||||
- **输出任何授权 / 配置类 URL(`verification_url` / `verification_uri_complete` / `console_url` 等)时**:必须用 `lark-cli auth qrcode` 生成并展示二维码(URL 在前、二维码在后,不可跳过);URL 视为 opaque string,不改写(不编码/解码、不加空格标点、不重拼 query)。
|
||||
|
||||
## 高风险操作的审批协议(exit 10)
|
||||
## 高风险操作的确认门禁(exit 10)
|
||||
|
||||
lark-cli 对高风险写操作(`risk: "high-risk-write"`)有强制确认门禁。当你不带 `--yes` 调用这类命令时,CLI 会退出码 `10`、并在 stderr 返回如下结构化 envelope:
|
||||
高风险写操作(`risk: "high-risk-write"`)未确认时,CLI **退出码 `10`**,并返回确认 envelope(`type` 为 `confirmation` / `confirmation_required`)。
|
||||
|
||||
```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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
**遇到 exit 10:绝不当普通错误放弃,绝不静默加 `--yes`。**
|
||||
|
||||
**遇到这种情况,不要当普通错误放弃。** 按以下流程处理:
|
||||
1. **停下**,把这次高风险操作和关键参数讲给用户,等其**显式同意**。
|
||||
2. 同意后,从 envelope 的 `hint` 读出确认 flag(`--yes` / `--force`),以 argv 数组**追加到原始命令**重试——不写死 `--yes`,不用 `sh -c` 拼接。
|
||||
3. 用户拒绝则终止。
|
||||
|
||||
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"`
|
||||
**错误形态识别、`action` 字段位置、如何判断高风险、`--dry-run` 预览 → 详见 [`references/lark-shared-high-risk-approval.md`](references/lark-shared-high-risk-approval.md)。**
|
||||
36
skills/lark-shared/references/lark-shared-auth-split-flow.md
Normal file
36
skills/lark-shared/references/lark-shared-auth-split-flow.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Agent 代理发起认证(split-flow)
|
||||
|
||||
当你作为 AI agent 需要帮用户完成 user 身份认证时,优先使用 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。
|
||||
|
||||
## 第一步:发起授权(当前轮)
|
||||
|
||||
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. 此命令会轮询授权状态并完成登录;返回成功即结束。
|
||||
|
||||
## 关键规则
|
||||
|
||||
- **你必须亲自执行 `--device-code` 命令**,不要指示用户自行执行。
|
||||
- **不要在同一轮中展示 URL 后立刻执行 `--device-code`**,这会导致用户看不到 URL。
|
||||
- **禁止缓存 `verification_url` / `device_code`**:每次需要授权时都重新执行 `lark-cli auth login --no-wait --json` 生成新链接,不要将授权链接和 device code 存入上下文供后续复用。
|
||||
|
||||
## 授权范围
|
||||
|
||||
- auth login 必须指定范围(`--domain <domain>` 或 `--scope "<scope>"`);推荐 `--scope`,符合最小权限原则。
|
||||
- 多次 login 的 scope 会累积(增量授权)。
|
||||
- 可用 `--exclude "<scope>"` 排除特定 scope、`--recommend` 只请求推荐(可自动批准)的 scope。
|
||||
@@ -0,0 +1,71 @@
|
||||
# 高风险操作的确认门禁(exit 10)
|
||||
|
||||
lark-cli 对高风险写操作(`risk: "high-risk-write"`)有强制确认门禁。不带确认标志调用这类命令时,CLI 退出码 `10`,并在 stderr 返回结构化 envelope。
|
||||
|
||||
> 正文已给出安全默认(停下、绝不静默 `--yes`、从 `hint` 取 flag 追加到原始 argv 重试)。本文件是机制细节,遇到 exit 10 时按需读取。
|
||||
|
||||
## 关键:可靠信号是退出码 10,不是 type 字符串
|
||||
|
||||
仓库正在从「扁平错误」迁移到「typed 错误」,同一个门禁可能以两种形态出现,但**都以 exit 10 为信号**:
|
||||
|
||||
- **扁平式**(service / shortcut 命令,旧形态):
|
||||
|
||||
```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 式**(如 `config bind`,新形态):
|
||||
|
||||
```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`"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
只用 `error.type == "confirmation_required"` 判断会**漏掉 typed 式**。正确识别:**子进程 exit code = 10**,且 `error` 命中任一:
|
||||
|
||||
- `type == "confirmation_required"`(扁平),或
|
||||
- `type == "confirmation" && subtype == "confirmation_required"`(typed)。
|
||||
|
||||
## 处理流程
|
||||
|
||||
1. **识别**:exit code = 10 且命中上述任一形态。
|
||||
2. **向用户确认**:把操作名和关键参数展示给用户,明确告知"这是高风险操作",等待显式同意。
|
||||
- 操作名位置随形态而异:typed 在 `error.action`;扁平在 `error.risk.action`。取 `error.action || error.risk.action`。
|
||||
- 注意 `error.risk` 形态也不同:扁平是对象 `{level, action}`,typed 是字符串(如 `"high-risk-write"`)。
|
||||
3. **用户同意 → 从 `hint` 读出确认 flag,追加到原始 argv 后重试**。`hint` 是给 Agent 看的自然语言提示,写明了该用哪个 flag——**提取那个 flag(如 `--yes` / `--force`)追加到你的原始命令**,不要写死 `--yes`,也**不要照抄 hint 里的示例命令**(示例不含用户原始参数,照抄会丢参数):
|
||||
- 扁平式:`hint = "add --yes to confirm"` → 原始 argv 末尾追加 `--yes`。
|
||||
- typed 式(bind):`hint` 提示用 `--force` → 原始 argv 末尾追加 `--force`。
|
||||
4. **用户拒绝 → 终止流程**,不擅自改写参数或跳过门禁。
|
||||
|
||||
## 绝对不允许
|
||||
|
||||
- 看到 exit 10 就默认加 `--yes` 静默重试(等于禁用门禁)。
|
||||
- 把 `confirmation` / `confirmation_required` 当网络错误 / 权限错误处理。
|
||||
- 用户没明确同意就追加确认 flag 重试。
|
||||
- 用 `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"`(同时 schema 会注入 `yes` 字段标记需确认)。
|
||||
- 注意:被标注 `high-risk-write` ≠ 一定走 exit-10 门禁。例如 `lark-cli update` 标了 risk 但没有 `--yes` flag、不走该门禁——以**实际 exit 10 + envelope** 为准,不要臆造 `--yes`。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user