mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
14 Commits
codex/insp
...
feat/start
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b6aa7dc6a | ||
|
|
ed63e12725 | ||
|
|
761aa55cbf | ||
|
|
2098c3c412 | ||
|
|
4215ad9908 | ||
|
|
0b305a2248 | ||
|
|
8f5504c51c | ||
|
|
d0a896ce91 | ||
|
|
99ceb2279c | ||
|
|
ec2ffebf47 | ||
|
|
ee5113f9d0 | ||
|
|
7cce7468d6 | ||
|
|
281cdbd37c | ||
|
|
add079ea1c |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -36,6 +36,7 @@ tests/mail/reports/
|
||||
.hammer/
|
||||
.lark-slides/
|
||||
internal/registry/meta_data.json
|
||||
internal/registry/metastatic/meta_data_gen.go
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
/sidecar-server-demo
|
||||
|
||||
@@ -2,6 +2,8 @@ version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
# fetch_meta.py also regenerates the static Go registry (meta_data_gen.go),
|
||||
# the sole source of the embedded command tree.
|
||||
- python3 scripts/fetch_meta.py
|
||||
|
||||
builds:
|
||||
|
||||
3
Makefile
3
Makefile
@@ -12,6 +12,9 @@ PREFIX ?= /usr/local
|
||||
|
||||
all: test
|
||||
|
||||
# fetch_meta fetches meta_data.json AND regenerates the static Go registry
|
||||
# (internal/registry/metastatic/meta_data_gen.go) — the sole build-time source
|
||||
# of the embedded command tree. Both are gitignored; build/vet/test depend on it.
|
||||
fetch_meta:
|
||||
python3 scripts/fetch_meta.py
|
||||
|
||||
|
||||
@@ -72,10 +72,12 @@ to generate QR codes (supports ASCII and PNG formats).`,
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
|
||||
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
|
||||
// Brand only — never decrypt the app secret just to build help text
|
||||
// (avoids a keychain read on every `auth login --help` / completion).
|
||||
var helpBrand core.LarkBrand
|
||||
if f != nil && f.Config != nil {
|
||||
if cfg, err := f.Config(); err == nil && cfg != nil {
|
||||
helpBrand = cfg.Brand
|
||||
if f != nil && f.ConfigBrand != nil {
|
||||
if b, ok := f.ConfigBrand(); ok {
|
||||
helpBrand = b
|
||||
}
|
||||
}
|
||||
available := sortedKnownDomains(helpBrand)
|
||||
|
||||
87
cmd/command_tree_dump_test.go
Normal file
87
cmd/command_tree_dump_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Tree-dump tool: dumps the full command tree (paths, flags, descriptions,
|
||||
// annotations) in a canonical, line-stable form so two builds can be diffed
|
||||
// byte-for-byte (e.g. before/after a registry change). Set LARK_TREE_DUMP=<path>
|
||||
// to write the dump; otherwise the test is a no-op. Not a committed golden — the
|
||||
// meta data is fetched/gitignored and drifts.
|
||||
package cmd_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func esc(s string) string {
|
||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||
s = strings.ReplaceAll(s, "\n", "\\n")
|
||||
s = strings.ReplaceAll(s, "\t", "\\t")
|
||||
s = strings.ReplaceAll(s, "\r", "\\r")
|
||||
return s
|
||||
}
|
||||
|
||||
func dumpCommandTree(root *cobra.Command) string {
|
||||
var lines []string
|
||||
var walk func(c *cobra.Command)
|
||||
walk = func(c *cobra.Command) {
|
||||
path := strings.TrimSpace(strings.TrimPrefix(c.CommandPath(), "lark-cli"))
|
||||
head := fmt.Sprintf("CMD %q use=%q short=%q long=%q runnable=%t hidden=%t",
|
||||
path, esc(c.Use), esc(c.Short), esc(c.Long), c.Runnable(), c.Hidden)
|
||||
lines = append(lines, head)
|
||||
|
||||
if len(c.Annotations) > 0 {
|
||||
keys := make([]string, 0, len(c.Annotations))
|
||||
for k := range c.Annotations {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
lines = append(lines, fmt.Sprintf(" ann %s=%q", k, esc(c.Annotations[k])))
|
||||
}
|
||||
}
|
||||
|
||||
var flags []string
|
||||
c.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
flags = append(flags, fmt.Sprintf(" flag --%s -%s type=%s def=%q usage=%q",
|
||||
f.Name, f.Shorthand, f.Value.Type(), esc(f.DefValue), esc(f.Usage)))
|
||||
})
|
||||
sort.Strings(flags)
|
||||
lines = append(lines, flags...)
|
||||
|
||||
subs := c.Commands()
|
||||
sort.Slice(subs, func(i, j int) bool { return subs[i].Name() < subs[j].Name() })
|
||||
for _, sub := range subs {
|
||||
walk(sub)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
|
||||
func TestDumpCommandTree(t *testing.T) {
|
||||
out := os.Getenv("LARK_TREE_DUMP")
|
||||
if out == "" {
|
||||
t.Skip("set LARK_TREE_DUMP=<path> to dump the command tree")
|
||||
}
|
||||
// Deterministic: embedded meta only (no remote cache), empty config dir so
|
||||
// strict-mode/plugins/policy cannot reshape the tree.
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
root := cmd.Build(context.Background(), cmdutil.InvocationContext{})
|
||||
dump := dumpCommandTree(root)
|
||||
if err := os.WriteFile(out, []byte(dump), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("wrote %d bytes, %d lines to %s", len(dump), strings.Count(dump, "\n"), out)
|
||||
}
|
||||
@@ -64,8 +64,8 @@ Use 'event schema <EventKey>' for parameter details.`,
|
||||
cmd.Flags().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output")
|
||||
cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr")
|
||||
cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)")
|
||||
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop.")
|
||||
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout').")
|
||||
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop. Bounded runs ignore stdin EOF.")
|
||||
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout'). Bounded runs ignore stdin EOF.")
|
||||
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
|
||||
@@ -184,8 +184,9 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
errOut = io.Discard
|
||||
}
|
||||
|
||||
// Non-TTY only: stdin EOF is shutdown for subprocess callers; in TTY Ctrl-D must not exit.
|
||||
if !f.IOStreams.IsTerminal {
|
||||
// Non-TTY unbounded consumers use stdin EOF as shutdown for subprocess callers.
|
||||
// Bounded runs already have --max-events/--timeout as their lifecycle control.
|
||||
if shouldWatchStdinEOF(f.IOStreams.IsTerminal, o.maxEvents, o.timeout) {
|
||||
watchStdinEOF(os.Stdin, cancel, errOut)
|
||||
}
|
||||
|
||||
@@ -370,3 +371,8 @@ func watchStdinEOF(r io.Reader, cancel context.CancelFunc, errOut io.Writer) {
|
||||
cancel()
|
||||
}()
|
||||
}
|
||||
|
||||
// shouldWatchStdinEOF gates the stdin-EOF shutdown watcher: non-TTY unbounded runs only (<= 0 mirrors downstream's >0-is-bounded semantics, so negative bounds stay unbounded).
|
||||
func shouldWatchStdinEOF(isTerminal bool, maxEvents int, timeout time.Duration) bool {
|
||||
return !isTerminal && maxEvents <= 0 && timeout <= 0
|
||||
}
|
||||
|
||||
@@ -61,3 +61,70 @@ func TestWatchStdinEOF_DiagnosticMessage(t *testing.T) {
|
||||
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldWatchStdinEOF(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
isTerminal bool
|
||||
maxEvents int
|
||||
timeout time.Duration
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "terminal",
|
||||
isTerminal: true,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal unbounded",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal negative max events is unbounded",
|
||||
maxEvents: -1,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal negative timeout is unbounded",
|
||||
timeout: -1 * time.Second,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal max events bounded",
|
||||
maxEvents: 1,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal timeout bounded",
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal both bounds positive",
|
||||
maxEvents: 1,
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal bounded max events with negative timeout",
|
||||
maxEvents: 1,
|
||||
timeout: -1 * time.Second,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal bounded timeout with negative max events",
|
||||
maxEvents: -1,
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := shouldWatchStdinEOF(tt.isTerminal, tt.maxEvents, tt.timeout)
|
||||
if got != tt.want {
|
||||
t.Fatalf("shouldWatchStdinEOF() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/registry/metaschema"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
@@ -30,74 +31,56 @@ func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
|
||||
}
|
||||
|
||||
func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
|
||||
for _, project := range registry.ListFromMetaProjects() {
|
||||
spec := registry.LoadFromMeta(project)
|
||||
if spec == nil {
|
||||
for _, spec := range registry.TypedServices() {
|
||||
if spec.Name == "" || spec.ServicePath == "" || len(spec.Resources) == 0 {
|
||||
continue
|
||||
}
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
servicePath := registry.GetStrFromMap(spec, "servicePath")
|
||||
if specName == "" || servicePath == "" {
|
||||
continue
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
continue
|
||||
}
|
||||
registerServiceWithContext(ctx, parent, spec, resources, f)
|
||||
registerServiceWithContext(ctx, parent, spec, f)
|
||||
}
|
||||
}
|
||||
|
||||
func registerService(parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
|
||||
registerServiceWithContext(context.Background(), parent, spec, resources, f)
|
||||
svc := registry.MapToService(spec)
|
||||
svc.Resources = registry.MapToResources(resources)
|
||||
registerServiceWithContext(context.Background(), parent, svc, f)
|
||||
}
|
||||
|
||||
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
specDesc := registry.GetServiceDescription(specName, "en")
|
||||
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec metaschema.Service, f *cmdutil.Factory) {
|
||||
specDesc := registry.GetServiceDescription(spec.Name, "en")
|
||||
if specDesc == "" {
|
||||
specDesc = registry.GetStrFromMap(spec, "description")
|
||||
specDesc = spec.Description
|
||||
}
|
||||
|
||||
// Find existing service command or create one
|
||||
var svc *cobra.Command
|
||||
for _, c := range parent.Commands() {
|
||||
if c.Name() == specName {
|
||||
if c.Name() == spec.Name {
|
||||
svc = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if svc == nil {
|
||||
svc = &cobra.Command{
|
||||
Use: specName,
|
||||
Use: spec.Name,
|
||||
Short: specDesc,
|
||||
}
|
||||
parent.AddCommand(svc)
|
||||
}
|
||||
|
||||
for resName, resource := range resources {
|
||||
resMap, _ := resource.(map[string]interface{})
|
||||
if resMap == nil {
|
||||
continue
|
||||
}
|
||||
registerResourceWithContext(ctx, svc, spec, resName, resMap, f)
|
||||
for _, resource := range spec.Resources {
|
||||
registerResourceWithContext(ctx, svc, spec, resource, f)
|
||||
}
|
||||
}
|
||||
|
||||
func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
|
||||
func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec metaschema.Service, resource metaschema.Resource, f *cmdutil.Factory) {
|
||||
res := &cobra.Command{
|
||||
Use: name,
|
||||
Short: name + " operations",
|
||||
Use: resource.Name,
|
||||
Short: resource.Name + " operations",
|
||||
}
|
||||
parent.AddCommand(res)
|
||||
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
for methodName, method := range methods {
|
||||
methodMap, _ := method.(map[string]interface{})
|
||||
if methodMap == nil {
|
||||
continue
|
||||
}
|
||||
registerMethodWithContext(ctx, res, spec, methodMap, methodName, name, f)
|
||||
for _, method := range resource.Methods {
|
||||
registerMethodWithContext(ctx, res, spec, method, method.Name, resource.Name, f)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,31 +108,36 @@ type ServiceMethodOptions struct {
|
||||
FileFields []string // auto-detected file field names from metadata
|
||||
}
|
||||
|
||||
// detectFileFields delegates to the shared cmdutil.DetectFileFields helper.
|
||||
func detectFileFields(method map[string]interface{}) []string {
|
||||
return cmdutil.DetectFileFields(method)
|
||||
// detectFileFieldsTyped returns the names of file-type fields in the method's
|
||||
// request body (used to decide whether to register --file).
|
||||
func detectFileFieldsTyped(m metaschema.Method) []string {
|
||||
var fields []string
|
||||
for _, fld := range m.RequestBody {
|
||||
if fld.Type == "file" {
|
||||
fields = append(fields, fld.Name)
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func registerMethodWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
|
||||
func registerMethodWithContext(ctx context.Context, parent *cobra.Command, spec metaschema.Service, method metaschema.Method, name string, resName string, f *cmdutil.Factory) {
|
||||
parent.AddCommand(NewCmdServiceMethodWithContext(ctx, f, spec, method, name, resName, nil))
|
||||
}
|
||||
|
||||
// NewCmdServiceMethod creates a command for a dynamically registered service method.
|
||||
// NewCmdServiceMethod creates a command for a dynamically registered service
|
||||
// method from map specs (kept for tests; converts to typed internally).
|
||||
func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
return NewCmdServiceMethodWithContext(context.Background(), f, spec, method, name, resName, runF)
|
||||
return NewCmdServiceMethodWithContext(context.Background(), f, registry.MapToService(spec), registry.MapToMethod(name, method), name, resName, runF)
|
||||
}
|
||||
|
||||
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
desc := registry.GetStrFromMap(method, "description")
|
||||
httpMethod := registry.GetStrFromMap(method, "httpMethod")
|
||||
risk := registry.GetStrFromMap(method, "risk")
|
||||
specName := registry.GetStrFromMap(spec, "name")
|
||||
schemaPath := fmt.Sprintf("%s.%s.%s", specName, resName, name)
|
||||
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec metaschema.Service, method metaschema.Method, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
|
||||
desc := method.Description
|
||||
httpMethod := method.HTTPMethod
|
||||
risk := method.Risk
|
||||
schemaPath := fmt.Sprintf("%s.%s.%s", spec.Name, resName, name)
|
||||
|
||||
opts := &ServiceMethodOptions{
|
||||
Factory: f,
|
||||
Spec: spec,
|
||||
Method: method,
|
||||
SchemaPath: schemaPath,
|
||||
}
|
||||
var asStr string
|
||||
@@ -159,6 +147,10 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
Short: desc,
|
||||
Long: fmt.Sprintf("%s\n\nView parameter definitions before calling:\n lark-cli schema %s", desc, schemaPath),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Materialize the maps the execution path still reads lazily — only
|
||||
// when THIS command actually runs, never at startup.
|
||||
opts.Spec = registry.ServiceToMap(spec)
|
||||
opts.Method = registry.MethodToMap(method)
|
||||
opts.Cmd = cmd
|
||||
opts.Ctx = cmd.Context()
|
||||
opts.As = core.Identity(asStr)
|
||||
@@ -188,7 +180,7 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
}
|
||||
|
||||
// Conditionally register --file for methods with file-type fields.
|
||||
fileFields := detectFileFields(method)
|
||||
fileFields := detectFileFieldsTyped(method)
|
||||
opts.FileFields = fileFields
|
||||
if len(fileFields) > 0 {
|
||||
switch httpMethod {
|
||||
@@ -200,10 +192,15 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips"))
|
||||
// meta_data.json carries no per-method tips; SetTips(nil) matches prior behavior.
|
||||
cmdutil.SetTips(cmd, nil)
|
||||
cmdutil.SetRisk(cmd, risk)
|
||||
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
|
||||
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(tokens))
|
||||
if len(method.AccessTokens) > 0 {
|
||||
toks := make([]interface{}, len(method.AccessTokens))
|
||||
for i, t := range method.AccessTokens {
|
||||
toks[i] = t
|
||||
}
|
||||
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(toks))
|
||||
}
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -752,7 +753,7 @@ func TestDetectFileFields(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := detectFileFields(tt.method)
|
||||
got := detectFileFieldsTyped(registry.MapToMethod("", tt.method))
|
||||
if len(got) != len(tt.want) {
|
||||
t.Errorf("detectFileFields() = %v, want %v", got, tt.want)
|
||||
return
|
||||
|
||||
@@ -30,10 +30,11 @@ type InvocationContext struct {
|
||||
}
|
||||
|
||||
type Factory struct {
|
||||
Config func() (*core.CliConfig, error) // lazily loads app config from Credential
|
||||
HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers)
|
||||
LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls
|
||||
IOStreams *IOStreams // stdin/stdout/stderr streams
|
||||
Config func() (*core.CliConfig, error) // lazily loads app config from Credential
|
||||
ConfigBrand func() (core.LarkBrand, bool) // brand only, no secret decryption — for startup help/registration (avoids keychain)
|
||||
HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers)
|
||||
LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls
|
||||
IOStreams *IOStreams // stdin/stdout/stderr streams
|
||||
|
||||
Invocation InvocationContext // Immutable call context; do not mutate after Factory construction.
|
||||
Keychain keychain.KeychainAccess // secret storage (real keychain in prod, mock in tests)
|
||||
@@ -151,11 +152,14 @@ func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
|
||||
if f.Credential == nil {
|
||||
return core.StrictModeOff
|
||||
}
|
||||
acct, err := f.Credential.ResolveAccount(ctx)
|
||||
if err != nil || acct == nil {
|
||||
// Strict mode is plain config metadata; resolve it WITHOUT decrypting the
|
||||
// app secret so identity-flag registration at startup never touches the
|
||||
// keychain (ResolveStrictMode is called per command during Build).
|
||||
_, supported, ok := f.Credential.ResolveMeta(ctx)
|
||||
if !ok {
|
||||
return core.StrictModeOff
|
||||
}
|
||||
ids := extcred.IdentitySupport(acct.SupportedIdentities)
|
||||
ids := extcred.IdentitySupport(supported)
|
||||
switch {
|
||||
case ids.BotOnly():
|
||||
return core.StrictModeBot
|
||||
|
||||
@@ -78,6 +78,18 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
return cfg, nil
|
||||
})
|
||||
|
||||
// ConfigBrand resolves just the brand without decrypting the app secret, so
|
||||
// brand-aware help and shortcut registration at startup do not touch the
|
||||
// keychain. It still initializes the registry with the resolved brand — the
|
||||
// same side effect Config has, minus the secret.
|
||||
f.ConfigBrand = sync.OnceValues(func() (core.LarkBrand, bool) {
|
||||
brand, _, ok := f.Credential.ResolveMeta(context.Background())
|
||||
if ok {
|
||||
registry.InitWithBrand(brand)
|
||||
}
|
||||
return brand, ok
|
||||
})
|
||||
|
||||
// Phase 4: LarkClient from Credential (placeholder AppSecret)
|
||||
f.LarkClient = cachedLarkClientFunc(f)
|
||||
|
||||
|
||||
@@ -65,7 +65,13 @@ func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer,
|
||||
)
|
||||
|
||||
f := &Factory{
|
||||
Config: func() (*core.CliConfig, error) { return config, nil },
|
||||
Config: func() (*core.CliConfig, error) { return config, nil },
|
||||
ConfigBrand: func() (core.LarkBrand, bool) {
|
||||
if config != nil {
|
||||
return config.Brand, true
|
||||
}
|
||||
return "", false
|
||||
},
|
||||
HttpClient: func() (*http.Client, error) { return mockClient, nil },
|
||||
LarkClient: func() (*lark.Client, error) { return testLarkClient, nil },
|
||||
IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf},
|
||||
|
||||
@@ -21,6 +21,14 @@ type DefaultAccountResolver interface {
|
||||
ResolveAccount(ctx context.Context) (*Account, error)
|
||||
}
|
||||
|
||||
// metaResolver is an optional capability: resolve config metadata (brand +
|
||||
// strict-mode identity support) without resolving the app secret (no keychain
|
||||
// access). Providers that don't implement it fall back to ResolveAccount inside
|
||||
// CredentialProvider.ResolveMeta.
|
||||
type metaResolver interface {
|
||||
ResolveMeta(ctx context.Context) (core.LarkBrand, uint8, bool)
|
||||
}
|
||||
|
||||
// DefaultTokenResolver is implemented by the default token provider.
|
||||
type DefaultTokenResolver interface {
|
||||
ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error)
|
||||
@@ -141,6 +149,11 @@ type CredentialProvider struct {
|
||||
accountErr error
|
||||
selectedSource credentialSource
|
||||
|
||||
metaOnce sync.Once
|
||||
metaBrand core.LarkBrand
|
||||
metaIdents uint8
|
||||
metaOK bool
|
||||
|
||||
hintOnce sync.Once
|
||||
hint *IdentityHint
|
||||
hintErr error
|
||||
@@ -172,6 +185,44 @@ func (p *CredentialProvider) ResolveAccount(ctx context.Context) (*Account, erro
|
||||
return p.account, p.accountErr
|
||||
}
|
||||
|
||||
// ResolveMeta resolves config metadata — brand and strict-mode identity support
|
||||
// — cheaply, WITHOUT decrypting the app secret for the default
|
||||
// (config.json/keychain) provider. It mirrors doResolveAccount's provider
|
||||
// selection: external providers (env/sidecar) are asked first via ResolveAccount
|
||||
// (they do not touch the keychain), then the default provider's keychain-free
|
||||
// metaResolver path. Cached after first call. Best-effort: returns ok=false when
|
||||
// nothing is configured, so callers keep their defaults. Used for brand-aware
|
||||
// help text, shortcut registration, and strict-mode checks at startup, where
|
||||
// decrypting the secret would be wasteful.
|
||||
func (p *CredentialProvider) ResolveMeta(ctx context.Context) (core.LarkBrand, uint8, bool) {
|
||||
p.metaOnce.Do(func() {
|
||||
p.metaBrand, p.metaIdents, p.metaOK = p.doResolveMeta(ctx)
|
||||
})
|
||||
return p.metaBrand, p.metaIdents, p.metaOK
|
||||
}
|
||||
|
||||
func (p *CredentialProvider) doResolveMeta(ctx context.Context) (core.LarkBrand, uint8, bool) {
|
||||
for _, prov := range p.providers {
|
||||
acct, err := prov.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
if acct != nil {
|
||||
internal := convertAccount(acct)
|
||||
return internal.Brand, internal.SupportedIdentities, true
|
||||
}
|
||||
}
|
||||
if p.defaultAcct != nil {
|
||||
if mr, ok := p.defaultAcct.(metaResolver); ok {
|
||||
return mr.ResolveMeta(ctx)
|
||||
}
|
||||
if acct, err := p.defaultAcct.ResolveAccount(ctx); err == nil && acct != nil {
|
||||
return acct.Brand, acct.SupportedIdentities, true
|
||||
}
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, error) {
|
||||
for _, prov := range p.providers {
|
||||
acct, err := prov.ResolveAccount(ctx)
|
||||
|
||||
@@ -76,6 +76,23 @@ func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account,
|
||||
return AccountFromCliConfig(cfg), nil
|
||||
}
|
||||
|
||||
// ResolveMeta returns config metadata — brand and the strict-mode identity
|
||||
// support — from config.json WITHOUT resolving the app secret (no keychain
|
||||
// access). Both are plain config fields, so brand-aware help, shortcut
|
||||
// registration, and strict-mode checks at startup need not decrypt the secret.
|
||||
// Returns ok=false when no config exists, so callers keep their defaults.
|
||||
func (p *DefaultAccountProvider) ResolveMeta(_ context.Context) (core.LarkBrand, uint8, bool) {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
app := multi.CurrentAppConfig(p.profile)
|
||||
if app == nil {
|
||||
return "", 0, false
|
||||
}
|
||||
return app.Brand, strictModeToIdentitySupport(multi, p.profile), true
|
||||
}
|
||||
|
||||
// strictModeToIdentitySupport maps the config-level strict mode to
|
||||
// the SupportedIdentities bitflag using an already-loaded MultiAppConfig.
|
||||
func strictModeToIdentitySupport(multi *core.MultiAppConfig, profileOverride string) uint8 {
|
||||
|
||||
@@ -130,7 +130,7 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintln(errOut, listeningText(opts))
|
||||
if !opts.IsTTY {
|
||||
fmt.Fprintln(errOut, stopHintText())
|
||||
fmt.Fprintln(errOut, stopHintText(opts))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +213,11 @@ func exitReason(ctx context.Context, emitted int64, opts Options) string {
|
||||
return "signal"
|
||||
}
|
||||
|
||||
func stopHintText() string {
|
||||
func stopHintText(opts Options) string {
|
||||
if opts.MaxEvents > 0 || opts.Timeout > 0 {
|
||||
return "[event] to stop gracefully: send SIGTERM (kill <pid>). " +
|
||||
"Avoid kill -9 — it skips cleanup and may leak server-side subscriptions."
|
||||
}
|
||||
return "[event] to stop gracefully: send SIGTERM (kill <pid>) or close stdin. " +
|
||||
"Avoid kill -9 — it skips cleanup and may leak server-side subscriptions."
|
||||
}
|
||||
|
||||
@@ -50,12 +50,32 @@ func TestListeningText_NonTTY_MaxEventsAndTimeout(t *testing.T) {
|
||||
}
|
||||
|
||||
// AI-facing contract: must name "kill -9" + "cleanup" so agents parsing stderr are steered away from SIGKILL.
|
||||
func TestStopHintText_Content(t *testing.T) {
|
||||
got := stopHintText()
|
||||
mustContain := []string{"SIGTERM", "kill -9", "cleanup"}
|
||||
func TestStopHintText_Unbounded(t *testing.T) {
|
||||
got := stopHintText(Options{})
|
||||
mustContain := []string{"SIGTERM", "kill -9", "cleanup", "close stdin"}
|
||||
for _, s := range mustContain {
|
||||
if !bytes.Contains([]byte(got), []byte(s)) {
|
||||
t.Errorf("stopHintText missing %q; got %q", s, got)
|
||||
t.Errorf("stopHintText(unbounded) missing %q; got %q", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AI-facing contract: must name "kill -9" + "cleanup" so agents parsing stderr are steered away from SIGKILL.
|
||||
func TestStopHintText_Bounded(t *testing.T) {
|
||||
cases := []Options{
|
||||
{MaxEvents: 1},
|
||||
{Timeout: 30 * time.Second},
|
||||
}
|
||||
for _, opts := range cases {
|
||||
got := stopHintText(opts)
|
||||
mustContain := []string{"SIGTERM", "kill -9", "cleanup"}
|
||||
for _, s := range mustContain {
|
||||
if !bytes.Contains([]byte(got), []byte(s)) {
|
||||
t.Errorf("stopHintText(bounded) missing %q; got %q", s, got)
|
||||
}
|
||||
}
|
||||
if bytes.Contains([]byte(got), []byte("close stdin")) {
|
||||
t.Errorf("stopHintText(bounded) must not contain \"close stdin\"; got %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,72 +19,32 @@ import (
|
||||
//go:embed scope_priorities.json scope_overrides.json
|
||||
var registryFS embed.FS
|
||||
|
||||
// embeddedMetaJSON is set by loader_embedded.go when meta_data.json is compiled in.
|
||||
var embeddedMetaJSON []byte
|
||||
|
||||
// EmbeddedMetaJSON returns the raw embedded meta_data.json bytes for callers
|
||||
// that need to parse key order or other JSON-level structure not exposed by
|
||||
// LoadFromMeta (which loses map insertion order).
|
||||
func EmbeddedMetaJSON() []byte {
|
||||
return embeddedMetaJSON
|
||||
}
|
||||
|
||||
var (
|
||||
embeddedServicesMap map[string]map[string]interface{} // service name -> spec
|
||||
embeddedServiceNames []string // sorted
|
||||
embeddedParseOnce sync.Once
|
||||
)
|
||||
|
||||
// parseEmbeddedServices parses embeddedMetaJSON into a service name → spec map
|
||||
// without touching mergedServices. Safe to call multiple times (sync.Once).
|
||||
func parseEmbeddedServices() {
|
||||
embeddedParseOnce.Do(func() {
|
||||
embeddedServicesMap = make(map[string]map[string]interface{})
|
||||
if len(embeddedMetaJSON) == 0 {
|
||||
return
|
||||
}
|
||||
var wrapper struct {
|
||||
Services []map[string]interface{} `json:"services"`
|
||||
}
|
||||
if err := json.Unmarshal(embeddedMetaJSON, &wrapper); err != nil {
|
||||
return
|
||||
}
|
||||
for _, svc := range wrapper.Services {
|
||||
name, _ := svc["name"].(string)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
embeddedServicesMap[name] = svc
|
||||
}
|
||||
embeddedServiceNames = make([]string, 0, len(embeddedServicesMap))
|
||||
for name := range embeddedServicesMap {
|
||||
embeddedServiceNames = append(embeddedServiceNames, name)
|
||||
}
|
||||
sort.Strings(embeddedServiceNames)
|
||||
})
|
||||
}
|
||||
|
||||
// EmbeddedSpec returns the embedded spec for one service, or nil if unknown.
|
||||
// Bypasses remote overlay — used for deterministic envelope output.
|
||||
// EmbeddedSpec returns the embedded baseline spec for one service as a map, or
|
||||
// nil if the service is unknown. It reads the static compile-time registry
|
||||
// (metastatic.Registry) and bypasses the remote overlay, so envelope output is
|
||||
// deterministic across machines.
|
||||
func EmbeddedSpec(serviceName string) map[string]interface{} {
|
||||
parseEmbeddedServices()
|
||||
return embeddedServicesMap[serviceName]
|
||||
if svc, ok := baselineServiceByName(serviceName); ok {
|
||||
return ServiceToMap(svc)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmbeddedServiceNames returns sorted embedded service names (no overlay).
|
||||
// Returns a defensive copy — callers must not mutate the package-level slice.
|
||||
// EmbeddedServiceNames returns the embedded baseline service names, sorted
|
||||
// (no remote overlay).
|
||||
func EmbeddedServiceNames() []string {
|
||||
parseEmbeddedServices()
|
||||
out := make([]string, len(embeddedServiceNames))
|
||||
copy(out, embeddedServiceNames)
|
||||
svcs := baselineServices()
|
||||
out := make([]string, 0, len(svcs))
|
||||
for _, s := range svcs {
|
||||
out = append(out, s.Name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
var (
|
||||
mergedServices = make(map[string]map[string]interface{}) // project name → parsed spec
|
||||
mergedProjectList []string // sorted project names
|
||||
embeddedVersion string // version from embedded meta_data.json
|
||||
initOnce sync.Once
|
||||
embeddedVersion string // baseline data version (from the static registry)
|
||||
initOnce sync.Once
|
||||
)
|
||||
|
||||
// Init initializes the registry with default brand (feishu).
|
||||
@@ -101,55 +61,27 @@ func Init() {
|
||||
func InitWithBrand(brand core.LarkBrand) {
|
||||
initOnce.Do(func() {
|
||||
configuredBrand = brand
|
||||
// 1. Load embedded meta_data.json as baseline (no-op if not compiled in)
|
||||
loadEmbeddedIntoMerged()
|
||||
// 2. Remote overlay
|
||||
// 1. Baseline version: the static compile-time registry (metastatic).
|
||||
embeddedVersion = baselineVersion()
|
||||
// 2. Remote overlay — still fetched/refreshed at runtime, decoded into
|
||||
// the same typed shape and merged over the baseline.
|
||||
if remoteEnabled() && cacheWritable() {
|
||||
// Check if brand changed since last cache
|
||||
meta, metaErr := loadCacheMeta()
|
||||
brandChanged := metaErr == nil && meta.Brand != "" && meta.Brand != string(brand)
|
||||
|
||||
if !brandChanged {
|
||||
if cached, err := loadCachedMerged(); err == nil {
|
||||
overlayMergedServices(cached)
|
||||
}
|
||||
_ = loadCachedTyped()
|
||||
}
|
||||
if len(mergedServices) == 0 || brandChanged {
|
||||
// No data at all or brand changed — must sync fetch
|
||||
if !hasTypedData() || brandChanged {
|
||||
// No data at all (e.g. stub build, no cache) or brand changed.
|
||||
doSyncFetch()
|
||||
} else if shouldRefresh(meta) || metaErr != nil {
|
||||
// Have embedded/cached data; refresh in background if TTL expired or first run
|
||||
triggerBackgroundRefresh()
|
||||
}
|
||||
}
|
||||
// 3. Build sorted project list
|
||||
rebuildProjectList()
|
||||
})
|
||||
}
|
||||
|
||||
// loadEmbeddedIntoMerged parses the embedded meta_data.json and populates
|
||||
// mergedServices. No-op if meta_data.json is not compiled in.
|
||||
func loadEmbeddedIntoMerged() {
|
||||
if len(embeddedMetaJSON) == 0 {
|
||||
return
|
||||
}
|
||||
var reg MergedRegistry
|
||||
if err := json.Unmarshal(embeddedMetaJSON, ®); err != nil {
|
||||
return
|
||||
}
|
||||
embeddedVersion = reg.Version
|
||||
overlayMergedServices(®)
|
||||
}
|
||||
|
||||
// rebuildProjectList rebuilds the sorted list of project names from mergedServices.
|
||||
func rebuildProjectList() {
|
||||
mergedProjectList = make([]string, 0, len(mergedServices))
|
||||
for name := range mergedServices {
|
||||
mergedProjectList = append(mergedProjectList, name)
|
||||
}
|
||||
sort.Strings(mergedProjectList)
|
||||
}
|
||||
|
||||
var cachedAllScopes map[string][]string
|
||||
|
||||
// CollectAllScopesFromMeta collects all unique scopes from from_meta/*.json
|
||||
@@ -226,7 +158,11 @@ func CollectAllScopesFromMeta(identity string) []string {
|
||||
// It returns data from the merged registry (embedded + cached remote overlay).
|
||||
func LoadFromMeta(project string) map[string]interface{} {
|
||||
Init()
|
||||
return mergedServices[project]
|
||||
svc, ok := typedServiceByName(project)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return ServiceToMap(svc)
|
||||
}
|
||||
|
||||
// ListFromMetaProjects lists available service project names (sorted).
|
||||
@@ -234,7 +170,7 @@ func LoadFromMeta(project string) map[string]interface{} {
|
||||
//go:noinline
|
||||
func ListFromMetaProjects() []string {
|
||||
Init()
|
||||
return mergedProjectList
|
||||
return typedServiceNames()
|
||||
}
|
||||
|
||||
// DefaultScopeScore is the score assigned to scopes not in the priorities table.
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package registry
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed meta_data*.json
|
||||
var metaFS embed.FS
|
||||
|
||||
//go:embed meta_data_default.json
|
||||
var embeddedMetaDataDefaultJSON []byte
|
||||
|
||||
func init() {
|
||||
if data, err := metaFS.ReadFile("meta_data.json"); err == nil && len(data) > 0 {
|
||||
embeddedMetaJSON = data
|
||||
} else {
|
||||
embeddedMetaJSON = embeddedMetaDataDefaultJSON
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"version":"0.0.0","services":[]}
|
||||
99
internal/registry/metaschema/types.go
Normal file
99
internal/registry/metaschema/types.go
Normal file
@@ -0,0 +1,99 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package metaschema defines the typed shape of the command-spec registry
|
||||
// (meta_data.json). The embedded baseline is emitted as static Go data in
|
||||
// package metastatic (no runtime JSON parse, no startup allocation); the remote
|
||||
// overlay is decoded into these same types at runtime.
|
||||
//
|
||||
// All container fields are slices (never maps): a package-level slice literal is
|
||||
// laid out in the binary's data section and costs zero heap allocation at
|
||||
// startup, whereas a map literal builds an hmap at init time. Map keys from the
|
||||
// JSON (resource/method/field names) are preserved in the Name field.
|
||||
package metaschema
|
||||
|
||||
// Registry is the top level of meta_data.json: {version, services:[...]}.
|
||||
type Registry struct {
|
||||
Version string
|
||||
Services []Service
|
||||
}
|
||||
|
||||
// Service is one API domain (e.g. "im", "calendar").
|
||||
type Service struct {
|
||||
Name string
|
||||
Version string
|
||||
Title string
|
||||
Description string
|
||||
ServicePath string
|
||||
Resources []Resource // JSON "resources" map, keyed by Resource.Name
|
||||
}
|
||||
|
||||
// Resource groups methods under a service (e.g. "messages").
|
||||
type Resource struct {
|
||||
Name string
|
||||
Methods []Method // JSON "methods" map, keyed by Method.Name
|
||||
}
|
||||
|
||||
// Method is a single API call.
|
||||
type Method struct {
|
||||
Name string // JSON map key
|
||||
ID string
|
||||
Path string
|
||||
HTTPMethod string
|
||||
Description string
|
||||
Risk string
|
||||
DocURL string
|
||||
Danger bool
|
||||
Scopes []string
|
||||
AccessTokens []string
|
||||
ParameterOrder []string
|
||||
RequiredScopes []string
|
||||
Parameters []Field // JSON "parameters" map, keyed by Field.Name
|
||||
RequestBody []Field // JSON "requestBody" map
|
||||
ResponseBody []Field // JSON "responseBody" map
|
||||
Affordance *Affordance // optional AI-facing usage overlay; nil on most methods
|
||||
}
|
||||
|
||||
// Field is one parameter / request-body / response-body entry. Nested object
|
||||
// fields recurse via Properties.
|
||||
type Field struct {
|
||||
Name string // JSON map key
|
||||
Type string
|
||||
Location string
|
||||
Description string
|
||||
Default string
|
||||
Example string
|
||||
EnumName string
|
||||
Min string
|
||||
Max string
|
||||
Ref string
|
||||
Required bool
|
||||
Options []Option
|
||||
Enum []string
|
||||
Annotations []string
|
||||
Properties []Field
|
||||
}
|
||||
|
||||
// Option is one allowed value for a field with an enum-like option list.
|
||||
type Option struct {
|
||||
Value string
|
||||
Description string
|
||||
}
|
||||
|
||||
// Affordance is the optional AI-facing usage overlay for a method, surfaced in
|
||||
// the schema envelope as _meta.affordance. Absent (nil) on most methods; it is
|
||||
// authored upstream in registry-config.yaml and merged into meta_data.json.
|
||||
type Affordance struct {
|
||||
UseWhen []string
|
||||
DoNotUseWhen []string
|
||||
Prerequisites []string
|
||||
Examples []AffordanceExample
|
||||
Related []string
|
||||
}
|
||||
|
||||
// AffordanceExample is one ready-to-run example: a one-line description plus a
|
||||
// complete lark-cli command string.
|
||||
type AffordanceExample struct {
|
||||
Description string
|
||||
Command string
|
||||
}
|
||||
255
internal/registry/metastatic/gen.go
Normal file
255
internal/registry/metastatic/gen.go
Normal file
@@ -0,0 +1,255 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build ignore
|
||||
|
||||
// Command gen reads internal/registry/meta_data.json and emits
|
||||
// meta_data_gen.go: the embedded command spec as a single static
|
||||
// metaschema.Registry literal (zero runtime JSON parse, zero startup heap
|
||||
// allocation). Run via: go run internal/registry/metastatic/gen.go
|
||||
//
|
||||
// Maps in the JSON (resources/methods/fields) are emitted as slices sorted by
|
||||
// key so generation is deterministic.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
inPath = "internal/registry/meta_data.json"
|
||||
outPath = "internal/registry/metastatic/meta_data_gen.go"
|
||||
)
|
||||
|
||||
func gs(m map[string]any, k string) string {
|
||||
if v, ok := m[k].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func gb(m map[string]any, k string) bool {
|
||||
if v, ok := m[k].(bool); ok {
|
||||
return v
|
||||
}
|
||||
return false
|
||||
}
|
||||
func gss(m map[string]any, k string) []string {
|
||||
raw, _ := m[k].([]any)
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, e := range raw {
|
||||
if s, ok := e.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func gm(m map[string]any, k string) map[string]any {
|
||||
if v, ok := m[k].(map[string]any); ok {
|
||||
return v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func sortedKeys(m map[string]any) []string {
|
||||
ks := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
sort.Strings(ks)
|
||||
return ks
|
||||
}
|
||||
|
||||
func emitStrSlice(b *strings.Builder, name string, vs []string) {
|
||||
if len(vs) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(b, "%s: []string{", name)
|
||||
for _, v := range vs {
|
||||
fmt.Fprintf(b, "%q, ", v)
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
|
||||
func emitOptions(b *strings.Builder, raw []any) {
|
||||
if len(raw) == 0 {
|
||||
return
|
||||
}
|
||||
b.WriteString("Options: []metaschema.Option{")
|
||||
for _, e := range raw {
|
||||
o, _ := e.(map[string]any)
|
||||
fmt.Fprintf(b, "{Value: %q, Description: %q}, ", gs(o, "value"), gs(o, "description"))
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
|
||||
// emitFields emits a metaschema.Field slice from a JSON map[fieldName]fieldSpec.
|
||||
func emitFields(b *strings.Builder, label string, fm map[string]any) {
|
||||
if len(fm) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(b, "%s: []metaschema.Field{\n", label)
|
||||
for _, name := range sortedKeys(fm) {
|
||||
f, _ := fm[name].(map[string]any)
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
b.WriteString("{")
|
||||
fmt.Fprintf(b, "Name: %q, ", name)
|
||||
for _, kv := range []struct{ k, field string }{
|
||||
{"type", "Type"}, {"location", "Location"}, {"description", "Description"},
|
||||
{"default", "Default"}, {"example", "Example"}, {"enumName", "EnumName"},
|
||||
{"min", "Min"}, {"max", "Max"}, {"ref", "Ref"},
|
||||
} {
|
||||
if v := gs(f, kv.k); v != "" {
|
||||
fmt.Fprintf(b, "%s: %q, ", kv.field, v)
|
||||
}
|
||||
}
|
||||
if gb(f, "required") {
|
||||
b.WriteString("Required: true, ")
|
||||
}
|
||||
emitStrSlice(b, "Enum", gss(f, "enum"))
|
||||
emitStrSlice(b, "Annotations", gss(f, "annotations"))
|
||||
if opts, ok := f["options"].([]any); ok {
|
||||
emitOptions(b, opts)
|
||||
}
|
||||
if props := gm(f, "properties"); props != nil {
|
||||
emitFields(b, "Properties", props)
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
|
||||
// emitAffordance emits a metaschema.Affordance literal from a method's
|
||||
// "affordance" JSON object, or nothing when absent/empty.
|
||||
func emitAffordance(b *strings.Builder, raw map[string]any) {
|
||||
if raw == nil {
|
||||
return
|
||||
}
|
||||
useWhen := gss(raw, "use_when")
|
||||
doNot := gss(raw, "do_not_use_when")
|
||||
prereq := gss(raw, "prerequisites")
|
||||
related := gss(raw, "related")
|
||||
examples, _ := raw["examples"].([]any)
|
||||
if len(useWhen) == 0 && len(doNot) == 0 && len(prereq) == 0 && len(related) == 0 && len(examples) == 0 {
|
||||
return
|
||||
}
|
||||
b.WriteString("Affordance: &metaschema.Affordance{")
|
||||
emitStrSlice(b, "UseWhen", useWhen)
|
||||
emitStrSlice(b, "DoNotUseWhen", doNot)
|
||||
emitStrSlice(b, "Prerequisites", prereq)
|
||||
if len(examples) > 0 {
|
||||
b.WriteString("Examples: []metaschema.AffordanceExample{")
|
||||
for _, e := range examples {
|
||||
ex, _ := e.(map[string]any)
|
||||
fmt.Fprintf(b, "{Description: %q, Command: %q}, ", gs(ex, "description"), gs(ex, "command"))
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
emitStrSlice(b, "Related", related)
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
|
||||
func emitMethods(b *strings.Builder, mm map[string]any) {
|
||||
b.WriteString("Methods: []metaschema.Method{\n")
|
||||
for _, name := range sortedKeys(mm) {
|
||||
m, _ := mm[name].(map[string]any)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
b.WriteString("{")
|
||||
fmt.Fprintf(b, "Name: %q, ID: %q, Path: %q, HTTPMethod: %q, Description: %q, ",
|
||||
name, gs(m, "id"), gs(m, "path"), gs(m, "httpMethod"), gs(m, "description"))
|
||||
if v := gs(m, "risk"); v != "" {
|
||||
fmt.Fprintf(b, "Risk: %q, ", v)
|
||||
}
|
||||
if v := gs(m, "docUrl"); v != "" {
|
||||
fmt.Fprintf(b, "DocURL: %q, ", v)
|
||||
}
|
||||
if gb(m, "danger") {
|
||||
b.WriteString("Danger: true, ")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
emitStrSlice(b, "Scopes", gss(m, "scopes"))
|
||||
emitStrSlice(b, "AccessTokens", gss(m, "accessTokens"))
|
||||
emitStrSlice(b, "ParameterOrder", gss(m, "parameterOrder"))
|
||||
emitStrSlice(b, "RequiredScopes", gss(m, "requiredScopes"))
|
||||
emitFields(b, "Parameters", gm(m, "parameters"))
|
||||
emitFields(b, "RequestBody", gm(m, "requestBody"))
|
||||
emitFields(b, "ResponseBody", gm(m, "responseBody"))
|
||||
emitAffordance(b, gm(m, "affordance"))
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
|
||||
func main() {
|
||||
data, err := os.ReadFile(inPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "read:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
var reg map[string]any
|
||||
if err := json.Unmarshal(data, ®); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "unmarshal:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("// Code generated from meta_data.json by gen.go. DO NOT EDIT.\n")
|
||||
b.WriteString("// Gitignored; produced at build time by `make fetch_meta`.\n\n")
|
||||
b.WriteString("package metastatic\n\n")
|
||||
b.WriteString("import \"github.com/larksuite/cli/internal/registry/metaschema\"\n\n")
|
||||
b.WriteString("// registryData holds the command spec as static Go data. It is a\n")
|
||||
b.WriteString("// package-level var, so its backing arrays live in the binary's static\n")
|
||||
b.WriteString("// section (zero heap alloc on read). init() wires it into the Registry\n")
|
||||
b.WriteString("// declared by stub.go with a single struct-header copy. No build tag is\n")
|
||||
b.WriteString("// needed: when this generated file is absent (fresh checkout) stub.go's\n")
|
||||
b.WriteString("// empty Registry stands alone; when present, init() augments it.\n")
|
||||
b.WriteString("var registryData = metaschema.Registry{\n")
|
||||
fmt.Fprintf(&b, "Version: %q,\n", gs(reg, "version"))
|
||||
b.WriteString("Services: []metaschema.Service{\n")
|
||||
svcs, _ := reg["services"].([]any)
|
||||
for _, sv := range svcs {
|
||||
s, _ := sv.(map[string]any)
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
b.WriteString("{")
|
||||
fmt.Fprintf(&b, "Name: %q, Version: %q, Title: %q, Description: %q, ServicePath: %q,\n",
|
||||
gs(s, "name"), gs(s, "version"), gs(s, "title"), gs(s, "description"), gs(s, "servicePath"))
|
||||
b.WriteString("Resources: []metaschema.Resource{\n")
|
||||
res := gm(s, "resources")
|
||||
for _, rname := range sortedKeys(res) {
|
||||
r, _ := res[rname].(map[string]any)
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "{Name: %q,\n", rname)
|
||||
emitMethods(&b, gm(r, "methods"))
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
b.WriteString("},\n") // Resources
|
||||
b.WriteString("},\n") // Service
|
||||
}
|
||||
b.WriteString("},\n") // Services
|
||||
b.WriteString("}\n\n") // registryData literal
|
||||
b.WriteString("func init() { Registry = registryData }\n")
|
||||
|
||||
src, err := format.Source([]byte(b.String()))
|
||||
if err != nil {
|
||||
// Write unformatted for debugging, then fail.
|
||||
_ = os.WriteFile(outPath+".broken", []byte(b.String()), 0644)
|
||||
fmt.Fprintln(os.Stderr, "gofmt:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(outPath, src, 0644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "write:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("wrote %s (%d services, %d bytes)\n", outPath, len(svcs), len(src))
|
||||
}
|
||||
15
internal/registry/metastatic/stub.go
Normal file
15
internal/registry/metastatic/stub.go
Normal file
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package metastatic
|
||||
|
||||
import "github.com/larksuite/cli/internal/registry/metaschema"
|
||||
|
||||
// Registry is the command spec as static Go data. It is declared here (zero
|
||||
// value) so the package always compiles, and populated by meta_data_gen.go's
|
||||
// init() when that generated file is present. On a fresh checkout the generated
|
||||
// file is absent — it is gitignored and produced at build time by
|
||||
// `make gen_meta` — so Registry stays empty. This keeps the "heavy spec is
|
||||
// never committed, only generated" model, now without a build tag: the
|
||||
// generated file augments this one rather than replacing it under a tag.
|
||||
var Registry = metaschema.Registry{}
|
||||
90
internal/registry/metastatic_validate_test.go
Normal file
90
internal/registry/metastatic_validate_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Validation for the static-meta registry: the generated metastatic.Registry is
|
||||
// the sole embedded baseline (no JSON parsed at runtime), and a deep read of it
|
||||
// allocates nothing. The data is generated from meta_data.json at build time
|
||||
// (`make fetch_meta`) and is gitignored, so these tests skip on a bare checkout
|
||||
// where it has not been generated yet.
|
||||
package registry
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry/metaschema"
|
||||
"github.com/larksuite/cli/internal/registry/metastatic"
|
||||
)
|
||||
|
||||
func countFieldsStatic(fs []metaschema.Field) int {
|
||||
n := 0
|
||||
for _, f := range fs {
|
||||
n++
|
||||
n += countFieldsStatic(f.Properties)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func countStatic() (svc, res, meth, fld int) {
|
||||
svc = len(metastatic.Registry.Services)
|
||||
for _, s := range metastatic.Registry.Services {
|
||||
for _, r := range s.Resources {
|
||||
res++
|
||||
for _, m := range r.Methods {
|
||||
meth++
|
||||
fld += countFieldsStatic(m.Parameters) + countFieldsStatic(m.RequestBody) + countFieldsStatic(m.ResponseBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// TestStaticRegistryPopulated checks the generated registry carries data. It
|
||||
// skips on a bare checkout where meta_data_gen.go has not been generated yet.
|
||||
func TestStaticRegistryPopulated(t *testing.T) {
|
||||
if len(metastatic.Registry.Services) == 0 {
|
||||
t.Skip("static registry empty; run `make fetch_meta` to generate it")
|
||||
}
|
||||
svc, res, meth, fld := countStatic()
|
||||
t.Logf("static: services=%d resources=%d methods=%d fields=%d", svc, res, meth, fld)
|
||||
if svc == 0 || res == 0 || meth == 0 || fld == 0 {
|
||||
t.Fatalf("static registry incomplete: svc=%d res=%d meth=%d fld=%d", svc, res, meth, fld)
|
||||
}
|
||||
if metastatic.Registry.Version == "" {
|
||||
t.Error("static registry has empty Version")
|
||||
}
|
||||
}
|
||||
|
||||
var sinkInt int
|
||||
|
||||
// --- zero-alloc: a deep read of the static registry must allocate nothing ---
|
||||
|
||||
func deepReadStatic() int {
|
||||
n := 0
|
||||
for _, s := range metastatic.Registry.Services {
|
||||
n += len(s.Name)
|
||||
for _, r := range s.Resources {
|
||||
for _, m := range r.Methods {
|
||||
n += len(m.ID) + len(m.Scopes) + countFieldsStatic(m.Parameters) + countFieldsStatic(m.ResponseBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestStaticReadZeroAlloc(t *testing.T) {
|
||||
if len(metastatic.Registry.Services) == 0 {
|
||||
t.Skip("static registry empty; run `make fetch_meta` to generate it")
|
||||
}
|
||||
avg := testing.AllocsPerRun(50, func() { sinkInt = deepReadStatic() })
|
||||
t.Logf("static deep-read: %.1f allocs/op", avg)
|
||||
if avg > 0 {
|
||||
t.Errorf("static read allocates %.1f/op, want 0 (data should be in the binary, not heap)", avg)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkReadStaticRegistry(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
sinkInt = deepReadStatic()
|
||||
}
|
||||
}
|
||||
@@ -147,22 +147,6 @@ func saveCacheMeta(meta CacheMeta) error {
|
||||
return validate.AtomicWrite(cacheMetaPath(), data, 0644)
|
||||
}
|
||||
|
||||
func loadCachedMerged() (*MergedRegistry, error) {
|
||||
path := cachePath()
|
||||
data, err := vfs.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var reg MergedRegistry
|
||||
if err := json.Unmarshal(data, ®); err != nil {
|
||||
// Cache corrupted — remove it so next run triggers a fresh fetch
|
||||
vfs.Remove(path)
|
||||
vfs.Remove(cacheMetaPath())
|
||||
return nil, err
|
||||
}
|
||||
return ®, nil
|
||||
}
|
||||
|
||||
func saveCachedMerged(data []byte, meta CacheMeta) error {
|
||||
if err := vfs.MkdirAll(cacheDir(), 0700); err != nil {
|
||||
return err
|
||||
@@ -253,7 +237,7 @@ func doSyncFetch() {
|
||||
Brand: string(configuredBrand),
|
||||
}
|
||||
_ = saveCachedMerged(data, meta)
|
||||
overlayMergedServices(reg)
|
||||
_ = loadCachedTyped()
|
||||
}
|
||||
|
||||
// --- background refresh ---
|
||||
@@ -308,15 +292,3 @@ func shouldRefresh(meta CacheMeta) bool {
|
||||
}
|
||||
return time.Since(time.Unix(meta.LastCheckAt, 0)) > metaTTL()
|
||||
}
|
||||
|
||||
// overlayMergedServices merges remote services into the in-memory map.
|
||||
// Remote entries override embedded entries with the same name.
|
||||
func overlayMergedServices(reg *MergedRegistry) {
|
||||
for _, svc := range reg.Services {
|
||||
name, ok := svc["name"].(string)
|
||||
if !ok || name == "" {
|
||||
continue
|
||||
}
|
||||
mergedServices[name] = svc
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/registry/metaschema"
|
||||
"github.com/larksuite/cli/internal/registry/metastatic"
|
||||
)
|
||||
|
||||
// waitBackgroundRefresh blocks until any in-flight background refresh started by
|
||||
@@ -30,8 +32,7 @@ func resetInit() {
|
||||
// reads globals this function mutates (see CI race: TestComputeMinimumScopeSet → Tenant).
|
||||
waitBackgroundRefresh()
|
||||
initOnce = sync.Once{}
|
||||
mergedServices = make(map[string]map[string]interface{})
|
||||
mergedProjectList = nil
|
||||
resetTyped()
|
||||
embeddedVersion = ""
|
||||
cachedAllScopes = nil
|
||||
cachedScopePriorities = nil
|
||||
@@ -55,16 +56,10 @@ func TestResetInitClearsEmbeddedVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// hasEmbeddedServices returns true if meta_data.json with real services is compiled in.
|
||||
// hasEmbeddedServices returns true if the static registry has services compiled
|
||||
// in (generated from meta_data.json at build time).
|
||||
func hasEmbeddedServices() bool {
|
||||
if len(embeddedMetaJSON) == 0 {
|
||||
return false
|
||||
}
|
||||
var reg MergedRegistry
|
||||
if err := json.Unmarshal(embeddedMetaJSON, ®); err != nil {
|
||||
return false
|
||||
}
|
||||
return len(reg.Services) > 0
|
||||
return len(metastatic.Registry.Services) > 0
|
||||
}
|
||||
|
||||
// testRegistry returns a minimal MergedRegistry with one service.
|
||||
@@ -302,50 +297,36 @@ func TestMetaTTL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayMergedServices(t *testing.T) {
|
||||
func TestRemoteOverlayTyped(t *testing.T) {
|
||||
resetInit()
|
||||
mergedServices = make(map[string]map[string]interface{})
|
||||
mergedServices["existing"] = map[string]interface{}{"name": "existing", "version": "v1"}
|
||||
setRemoteOverrides([]metaschema.Service{
|
||||
{Name: "existing", Version: "v2"},
|
||||
{Name: "brand_new", Version: "v1"},
|
||||
})
|
||||
|
||||
reg := &MergedRegistry{
|
||||
Services: []map[string]interface{}{
|
||||
{"name": "existing", "version": "v2"},
|
||||
{"name": "brand_new", "version": "v1"},
|
||||
},
|
||||
// override present
|
||||
if s, ok := typedServiceByName("existing"); !ok || s.Version != "v2" {
|
||||
t.Errorf("expected existing override v2, got %+v ok=%v", s, ok)
|
||||
}
|
||||
overlayMergedServices(reg)
|
||||
|
||||
// existing should be overridden
|
||||
if v := mergedServices["existing"]["version"].(string); v != "v2" {
|
||||
t.Errorf("expected existing to be overridden to v2, got %s", v)
|
||||
}
|
||||
// brand_new should be added
|
||||
if _, ok := mergedServices["brand_new"]; !ok {
|
||||
// new service added
|
||||
if _, ok := typedServiceByName("brand_new"); !ok {
|
||||
t.Error("expected brand_new to be added")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayMergedServicesDoesNotPolluteFollowingInit(t *testing.T) {
|
||||
func TestRemoteOverlayDoesNotPolluteFollowingInit(t *testing.T) {
|
||||
resetInit()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||
|
||||
const leakedExisting = "test_isolation_existing_sentinel"
|
||||
const leakedOverlay = "test_isolation_overlay_sentinel"
|
||||
|
||||
mergedServices = map[string]map[string]interface{}{
|
||||
leakedExisting: {"name": leakedExisting, "version": "v1"},
|
||||
}
|
||||
overlayMergedServices(&MergedRegistry{Services: []map[string]interface{}{{"name": leakedOverlay, "version": "v1"}}})
|
||||
const leaked = "test_isolation_overlay_sentinel"
|
||||
setRemoteOverrides([]metaschema.Service{{Name: leaked, Version: "v1"}})
|
||||
|
||||
resetInit()
|
||||
Init()
|
||||
|
||||
if spec := LoadFromMeta(leakedExisting); spec != nil {
|
||||
t.Fatalf("polluted service %q survived resetInit", leakedExisting)
|
||||
}
|
||||
if spec := LoadFromMeta(leakedOverlay); spec != nil {
|
||||
t.Fatalf("polluted service %q survived resetInit", leakedOverlay)
|
||||
if spec := LoadFromMeta(leaked); spec != nil {
|
||||
t.Fatalf("polluted service %q survived resetInit", leaked)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,8 +406,8 @@ func TestCorruptedCache_SelfHeals(t *testing.T) {
|
||||
metaData, _ := json.Marshal(meta)
|
||||
os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), metaData, 0644)
|
||||
|
||||
// loadCachedMerged should fail and remove the corrupted files
|
||||
_, err := loadCachedMerged()
|
||||
// loadCachedTyped should fail and remove the corrupted files
|
||||
err := loadCachedTyped()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for corrupted cache")
|
||||
}
|
||||
|
||||
579
internal/registry/typed.go
Normal file
579
internal/registry/typed.go
Normal file
@@ -0,0 +1,579 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry/metaschema"
|
||||
"github.com/larksuite/cli/internal/registry/metastatic"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// This file is the typed registry layer for the static-meta migration.
|
||||
//
|
||||
// - The embedded baseline is metastatic.Registry: static Go data laid out in
|
||||
// the binary at compile time (zero startup cost). It is empty on a fresh
|
||||
// checkout (stub.go) until the generated meta_data_gen.go is produced by
|
||||
// `make fetch_meta`; no build tag is involved.
|
||||
// - The remote overlay (~/.lark-cli/cache/remote_meta.json) is still fetched
|
||||
// and refreshed at runtime, decoded into the same typed shape, and merged
|
||||
// over the baseline as per-service overrides.
|
||||
//
|
||||
// Startup (command-tree build) reads these typed structs directly. Execution-
|
||||
// path consumers that still expect map[string]interface{} go through
|
||||
// ServiceToMap, which rebuilds one service's map lazily, on demand — never the
|
||||
// whole spec at startup.
|
||||
|
||||
var (
|
||||
typedMu sync.RWMutex
|
||||
remoteOverrides map[string]metaschema.Service // service name -> remote override
|
||||
typedNamesCache []string
|
||||
)
|
||||
|
||||
// resetTyped clears the typed overlay state (test/teardown helper).
|
||||
func resetTyped() {
|
||||
typedMu.Lock()
|
||||
defer typedMu.Unlock()
|
||||
remoteOverrides = nil
|
||||
typedNamesCache = nil
|
||||
}
|
||||
|
||||
// baselineServices returns the embedded baseline service specs: the static
|
||||
// compile-time data in metastatic.Registry (zero parse, zero alloc). It is
|
||||
// empty only on a fresh checkout where meta_data_gen.go has not been generated
|
||||
// yet (see stub.go).
|
||||
var (
|
||||
baselineOnce sync.Once
|
||||
baselineSvcs []metaschema.Service
|
||||
baselineVer string
|
||||
)
|
||||
|
||||
func loadBaseline() {
|
||||
baselineOnce.Do(func() {
|
||||
baselineSvcs = metastatic.Registry.Services
|
||||
baselineVer = metastatic.Registry.Version
|
||||
})
|
||||
}
|
||||
|
||||
func baselineServices() []metaschema.Service {
|
||||
loadBaseline()
|
||||
return baselineSvcs
|
||||
}
|
||||
|
||||
func baselineVersion() string {
|
||||
loadBaseline()
|
||||
return baselineVer
|
||||
}
|
||||
|
||||
// baselineServiceByName returns the embedded baseline service spec by name.
|
||||
func baselineServiceByName(name string) (metaschema.Service, bool) {
|
||||
svcs := baselineServices()
|
||||
for i := range svcs {
|
||||
if svcs[i].Name == name {
|
||||
return svcs[i], true
|
||||
}
|
||||
}
|
||||
return metaschema.Service{}, false
|
||||
}
|
||||
|
||||
// typedServiceByName returns the effective typed spec for a service: the remote
|
||||
// override if present, otherwise the static baseline.
|
||||
func typedServiceByName(name string) (metaschema.Service, bool) {
|
||||
typedMu.RLock()
|
||||
if s, ok := remoteOverrides[name]; ok {
|
||||
typedMu.RUnlock()
|
||||
return s, true
|
||||
}
|
||||
typedMu.RUnlock()
|
||||
return baselineServiceByName(name)
|
||||
}
|
||||
|
||||
// typedServiceNames returns all effective service names (baseline + remote
|
||||
// additions), sorted. Cached until the overlay changes.
|
||||
func typedServiceNames() []string {
|
||||
typedMu.RLock()
|
||||
if typedNamesCache != nil {
|
||||
out := typedNamesCache
|
||||
typedMu.RUnlock()
|
||||
return out
|
||||
}
|
||||
typedMu.RUnlock()
|
||||
|
||||
seen := make(map[string]bool)
|
||||
for _, s := range baselineServices() {
|
||||
seen[s.Name] = true
|
||||
}
|
||||
typedMu.RLock()
|
||||
for name := range remoteOverrides {
|
||||
seen[name] = true
|
||||
}
|
||||
typedMu.RUnlock()
|
||||
|
||||
names := make([]string, 0, len(seen))
|
||||
for n := range seen {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
typedMu.Lock()
|
||||
typedNamesCache = names
|
||||
typedMu.Unlock()
|
||||
return names
|
||||
}
|
||||
|
||||
// setRemoteOverrides installs the parsed remote overlay (called from Init).
|
||||
func setRemoteOverrides(svcs []metaschema.Service) {
|
||||
typedMu.Lock()
|
||||
defer typedMu.Unlock()
|
||||
if remoteOverrides == nil {
|
||||
remoteOverrides = make(map[string]metaschema.Service, len(svcs))
|
||||
}
|
||||
for _, s := range svcs {
|
||||
remoteOverrides[s.Name] = s
|
||||
}
|
||||
typedNamesCache = nil
|
||||
}
|
||||
|
||||
// TypedService returns the effective typed spec for a service (remote override
|
||||
// or static baseline). Public accessor for the command-tree builder.
|
||||
func TypedService(name string) (metaschema.Service, bool) {
|
||||
Init()
|
||||
return typedServiceByName(name)
|
||||
}
|
||||
|
||||
// TypedServices returns all effective service specs, sorted by name. Reading
|
||||
// these builds nothing on the heap (static data); the remote overlay, if any,
|
||||
// was allocated once at Init.
|
||||
func TypedServices() []metaschema.Service {
|
||||
Init()
|
||||
names := typedServiceNames()
|
||||
out := make([]metaschema.Service, 0, len(names))
|
||||
for _, n := range names {
|
||||
if s, ok := typedServiceByName(n); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hasTypedData reports whether any typed spec is available (static baseline or
|
||||
// remote overlay). False only when the static registry has not been generated
|
||||
// (fresh checkout) and there is no cache.
|
||||
func hasTypedData() bool {
|
||||
if len(baselineServices()) > 0 {
|
||||
return true
|
||||
}
|
||||
typedMu.RLock()
|
||||
defer typedMu.RUnlock()
|
||||
return len(remoteOverrides) > 0
|
||||
}
|
||||
|
||||
// loadCachedTyped reads the on-disk remote cache, decodes it into the typed
|
||||
// shape, and installs it as the remote overlay (typed replacement for the old
|
||||
// map-based loadCachedMerged + overlay).
|
||||
func loadCachedTyped() error {
|
||||
data, err := vfs.ReadFile(cachePath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var reg wireRegistry
|
||||
if err := json.Unmarshal(data, ®); err != nil {
|
||||
// Cache corrupted — remove it so the next run triggers a fresh fetch.
|
||||
_ = vfs.Remove(cachePath())
|
||||
_ = vfs.Remove(cacheMetaPath())
|
||||
return err
|
||||
}
|
||||
svcs := make([]metaschema.Service, 0, len(reg.Services))
|
||||
for _, ws := range reg.Services {
|
||||
svcs = append(svcs, wireToService(ws))
|
||||
}
|
||||
setRemoteOverrides(svcs)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- typed -> map[string]interface{} shim (lazy, per service, execution-path) ---
|
||||
|
||||
func strList(ss []string) []interface{} {
|
||||
if len(ss) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]interface{}, len(ss))
|
||||
for i, s := range ss {
|
||||
out[i] = s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fieldToMap(f metaschema.Field) map[string]interface{} {
|
||||
m := map[string]interface{}{}
|
||||
put := func(k, v string) {
|
||||
if v != "" {
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
put("type", f.Type)
|
||||
put("location", f.Location)
|
||||
put("description", f.Description)
|
||||
put("default", f.Default)
|
||||
put("example", f.Example)
|
||||
put("enumName", f.EnumName)
|
||||
put("min", f.Min)
|
||||
put("max", f.Max)
|
||||
put("ref", f.Ref)
|
||||
if f.Required {
|
||||
m["required"] = true
|
||||
}
|
||||
if v := strList(f.Enum); v != nil {
|
||||
m["enum"] = v
|
||||
}
|
||||
if v := strList(f.Annotations); v != nil {
|
||||
m["annotations"] = v
|
||||
}
|
||||
if len(f.Options) > 0 {
|
||||
opts := make([]interface{}, len(f.Options))
|
||||
for i, o := range f.Options {
|
||||
opts[i] = map[string]interface{}{"value": o.Value, "description": o.Description}
|
||||
}
|
||||
m["options"] = opts
|
||||
}
|
||||
if len(f.Properties) > 0 {
|
||||
m["properties"] = fieldsToMap(f.Properties)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func fieldsToMap(fs []metaschema.Field) map[string]interface{} {
|
||||
if len(fs) == 0 {
|
||||
return nil
|
||||
}
|
||||
m := make(map[string]interface{}, len(fs))
|
||||
for _, f := range fs {
|
||||
m[f.Name] = fieldToMap(f)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// affordanceToMap rebuilds the JSON-shaped affordance object (snake_case keys)
|
||||
// so the schema assembler's parseAffordance(method["affordance"]) keeps working
|
||||
// through the typed registry. Returns nil when the overlay carries nothing.
|
||||
func affordanceToMap(a *metaschema.Affordance) map[string]interface{} {
|
||||
m := map[string]interface{}{}
|
||||
if v := strList(a.UseWhen); v != nil {
|
||||
m["use_when"] = v
|
||||
}
|
||||
if v := strList(a.DoNotUseWhen); v != nil {
|
||||
m["do_not_use_when"] = v
|
||||
}
|
||||
if v := strList(a.Prerequisites); v != nil {
|
||||
m["prerequisites"] = v
|
||||
}
|
||||
if len(a.Examples) > 0 {
|
||||
ex := make([]interface{}, len(a.Examples))
|
||||
for i, e := range a.Examples {
|
||||
ex[i] = map[string]interface{}{"description": e.Description, "command": e.Command}
|
||||
}
|
||||
m["examples"] = ex
|
||||
}
|
||||
if v := strList(a.Related); v != nil {
|
||||
m["related"] = v
|
||||
}
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func MethodToMap(mth metaschema.Method) map[string]interface{} {
|
||||
m := map[string]interface{}{
|
||||
"id": mth.ID,
|
||||
"path": mth.Path,
|
||||
"httpMethod": mth.HTTPMethod,
|
||||
"description": mth.Description,
|
||||
}
|
||||
if mth.Risk != "" {
|
||||
m["risk"] = mth.Risk
|
||||
}
|
||||
if mth.DocURL != "" {
|
||||
m["docUrl"] = mth.DocURL
|
||||
}
|
||||
if mth.Danger {
|
||||
m["danger"] = true
|
||||
}
|
||||
if v := strList(mth.Scopes); v != nil {
|
||||
m["scopes"] = v
|
||||
}
|
||||
if v := strList(mth.AccessTokens); v != nil {
|
||||
m["accessTokens"] = v
|
||||
}
|
||||
if v := strList(mth.ParameterOrder); v != nil {
|
||||
m["parameterOrder"] = v
|
||||
}
|
||||
if v := strList(mth.RequiredScopes); v != nil {
|
||||
m["requiredScopes"] = v
|
||||
}
|
||||
if v := fieldsToMap(mth.Parameters); v != nil {
|
||||
m["parameters"] = v
|
||||
}
|
||||
if v := fieldsToMap(mth.RequestBody); v != nil {
|
||||
m["requestBody"] = v
|
||||
}
|
||||
if v := fieldsToMap(mth.ResponseBody); v != nil {
|
||||
m["responseBody"] = v
|
||||
}
|
||||
if mth.Affordance != nil {
|
||||
if am := affordanceToMap(mth.Affordance); am != nil {
|
||||
m["affordance"] = am
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ServiceToMap rebuilds the JSON-shaped map[string]interface{} for one service,
|
||||
// so execution-path consumers (and method RunE) keep working unchanged.
|
||||
func ServiceToMap(s metaschema.Service) map[string]interface{} {
|
||||
resources := make(map[string]interface{}, len(s.Resources))
|
||||
for _, r := range s.Resources {
|
||||
methods := make(map[string]interface{}, len(r.Methods))
|
||||
for _, mth := range r.Methods {
|
||||
methods[mth.Name] = MethodToMap(mth)
|
||||
}
|
||||
resources[r.Name] = map[string]interface{}{"methods": methods}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"name": s.Name,
|
||||
"version": s.Version,
|
||||
"title": s.Title,
|
||||
"description": s.Description,
|
||||
"servicePath": s.ServicePath,
|
||||
"resources": resources,
|
||||
}
|
||||
}
|
||||
|
||||
// --- map[string]interface{} -> typed (for the map-based wrappers still used by
|
||||
// tests; production builds from typed directly) ---
|
||||
|
||||
func ifaceStrs(v interface{}) []string {
|
||||
raw, _ := v.([]interface{})
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, e := range raw {
|
||||
if s, ok := e.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedMapKeys(m map[string]interface{}) []string {
|
||||
ks := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
sort.Strings(ks)
|
||||
return ks
|
||||
}
|
||||
|
||||
func mapToField(name string, m map[string]interface{}) metaschema.Field {
|
||||
f := metaschema.Field{
|
||||
Name: name, Type: GetStrFromMap(m, "type"), Location: GetStrFromMap(m, "location"),
|
||||
Description: GetStrFromMap(m, "description"), Default: GetStrFromMap(m, "default"),
|
||||
Example: GetStrFromMap(m, "example"), EnumName: GetStrFromMap(m, "enumName"),
|
||||
Min: GetStrFromMap(m, "min"), Max: GetStrFromMap(m, "max"), Ref: GetStrFromMap(m, "ref"),
|
||||
Enum: ifaceStrs(m["enum"]), Annotations: ifaceStrs(m["annotations"]),
|
||||
}
|
||||
if b, ok := m["required"].(bool); ok {
|
||||
f.Required = b
|
||||
}
|
||||
if opts, ok := m["options"].([]interface{}); ok {
|
||||
for _, o := range opts {
|
||||
om, _ := o.(map[string]interface{})
|
||||
f.Options = append(f.Options, metaschema.Option{Value: GetStrFromMap(om, "value"), Description: GetStrFromMap(om, "description")})
|
||||
}
|
||||
}
|
||||
f.Properties = mapToFields(m["properties"])
|
||||
return f
|
||||
}
|
||||
|
||||
func mapToFields(v interface{}) []metaschema.Field {
|
||||
fm, _ := v.(map[string]interface{})
|
||||
if len(fm) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]metaschema.Field, 0, len(fm))
|
||||
for _, k := range sortedMapKeys(fm) {
|
||||
em, _ := fm[k].(map[string]interface{})
|
||||
out = append(out, mapToField(k, em))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func MapToMethod(name string, m map[string]interface{}) metaschema.Method {
|
||||
return metaschema.Method{
|
||||
Name: name, ID: GetStrFromMap(m, "id"), Path: GetStrFromMap(m, "path"),
|
||||
HTTPMethod: GetStrFromMap(m, "httpMethod"), Description: GetStrFromMap(m, "description"),
|
||||
Risk: GetStrFromMap(m, "risk"), DocURL: GetStrFromMap(m, "docUrl"),
|
||||
Danger: boolFromMap(m, "danger"),
|
||||
Scopes: ifaceStrs(m["scopes"]),
|
||||
AccessTokens: ifaceStrs(m["accessTokens"]),
|
||||
ParameterOrder: ifaceStrs(m["parameterOrder"]),
|
||||
RequiredScopes: ifaceStrs(m["requiredScopes"]),
|
||||
Parameters: mapToFields(m["parameters"]),
|
||||
RequestBody: mapToFields(m["requestBody"]),
|
||||
ResponseBody: mapToFields(m["responseBody"]),
|
||||
}
|
||||
}
|
||||
|
||||
func boolFromMap(m map[string]interface{}, k string) bool {
|
||||
b, _ := m[k].(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
func MapToResources(v interface{}) []metaschema.Resource {
|
||||
rm, _ := v.(map[string]interface{})
|
||||
if len(rm) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]metaschema.Resource, 0, len(rm))
|
||||
for _, rk := range sortedMapKeys(rm) {
|
||||
res, _ := rm[rk].(map[string]interface{})
|
||||
mm, _ := res["methods"].(map[string]interface{})
|
||||
methods := make([]metaschema.Method, 0, len(mm))
|
||||
for _, mk := range sortedMapKeys(mm) {
|
||||
methodMap, _ := mm[mk].(map[string]interface{})
|
||||
methods = append(methods, MapToMethod(mk, methodMap))
|
||||
}
|
||||
out = append(out, metaschema.Resource{Name: rk, Methods: methods})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MapToService converts a JSON-shaped service spec (with embedded "resources")
|
||||
// into the typed form.
|
||||
func MapToService(spec map[string]interface{}) metaschema.Service {
|
||||
return metaschema.Service{
|
||||
Name: GetStrFromMap(spec, "name"), Version: GetStrFromMap(spec, "version"),
|
||||
Title: GetStrFromMap(spec, "title"), Description: GetStrFromMap(spec, "description"),
|
||||
ServicePath: GetStrFromMap(spec, "servicePath"), Resources: MapToResources(spec["resources"]),
|
||||
}
|
||||
}
|
||||
|
||||
// --- remote JSON (wire) -> typed ---
|
||||
|
||||
type wireRegistry struct {
|
||||
Version string `json:"version"`
|
||||
Services []wireService `json:"services"`
|
||||
}
|
||||
|
||||
type wireService struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ServicePath string `json:"servicePath"`
|
||||
Resources map[string]wireResource `json:"resources"`
|
||||
}
|
||||
|
||||
type wireResource struct {
|
||||
Methods map[string]wireMethod `json:"methods"`
|
||||
}
|
||||
|
||||
type wireMethod struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
HTTPMethod string `json:"httpMethod"`
|
||||
Description string `json:"description"`
|
||||
Risk string `json:"risk"`
|
||||
DocURL string `json:"docUrl"`
|
||||
Danger bool `json:"danger"`
|
||||
Scopes []string `json:"scopes"`
|
||||
AccessTokens []string `json:"accessTokens"`
|
||||
ParameterOrder []string `json:"parameterOrder"`
|
||||
RequiredScopes []string `json:"requiredScopes"`
|
||||
Parameters map[string]wireField `json:"parameters"`
|
||||
RequestBody map[string]wireField `json:"requestBody"`
|
||||
ResponseBody map[string]wireField `json:"responseBody"`
|
||||
}
|
||||
|
||||
type wireField struct {
|
||||
Type string `json:"type"`
|
||||
Location string `json:"location"`
|
||||
Description string `json:"description"`
|
||||
Default string `json:"default"`
|
||||
Example string `json:"example"`
|
||||
EnumName string `json:"enumName"`
|
||||
Min string `json:"min"`
|
||||
Max string `json:"max"`
|
||||
Ref string `json:"ref"`
|
||||
Required bool `json:"required"`
|
||||
Options []metaschema.Option `json:"options"`
|
||||
Enum []string `json:"enum"`
|
||||
Annotations []string `json:"annotations"`
|
||||
Properties map[string]wireField `json:"properties"`
|
||||
}
|
||||
|
||||
func sortedFieldKeys(m map[string]wireField) []string {
|
||||
ks := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
sort.Strings(ks)
|
||||
return ks
|
||||
}
|
||||
|
||||
func wireFields(m map[string]wireField) []metaschema.Field {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]metaschema.Field, 0, len(m))
|
||||
for _, name := range sortedFieldKeys(m) {
|
||||
wf := m[name]
|
||||
out = append(out, metaschema.Field{
|
||||
Name: name, Type: wf.Type, Location: wf.Location, Description: wf.Description,
|
||||
Default: wf.Default, Example: wf.Example, EnumName: wf.EnumName,
|
||||
Min: wf.Min, Max: wf.Max, Ref: wf.Ref, Required: wf.Required,
|
||||
Options: wf.Options, Enum: wf.Enum, Annotations: wf.Annotations,
|
||||
Properties: wireFields(wf.Properties),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func wireToService(ws wireService) metaschema.Service {
|
||||
resKeys := make([]string, 0, len(ws.Resources))
|
||||
for k := range ws.Resources {
|
||||
resKeys = append(resKeys, k)
|
||||
}
|
||||
sort.Strings(resKeys)
|
||||
resources := make([]metaschema.Resource, 0, len(resKeys))
|
||||
for _, rk := range resKeys {
|
||||
wr := ws.Resources[rk]
|
||||
methKeys := make([]string, 0, len(wr.Methods))
|
||||
for k := range wr.Methods {
|
||||
methKeys = append(methKeys, k)
|
||||
}
|
||||
sort.Strings(methKeys)
|
||||
methods := make([]metaschema.Method, 0, len(methKeys))
|
||||
for _, mk := range methKeys {
|
||||
wm := wr.Methods[mk]
|
||||
methods = append(methods, metaschema.Method{
|
||||
Name: mk, ID: wm.ID, Path: wm.Path, HTTPMethod: wm.HTTPMethod,
|
||||
Description: wm.Description, Risk: wm.Risk, DocURL: wm.DocURL, Danger: wm.Danger,
|
||||
Scopes: wm.Scopes, AccessTokens: wm.AccessTokens,
|
||||
ParameterOrder: wm.ParameterOrder, RequiredScopes: wm.RequiredScopes,
|
||||
Parameters: wireFields(wm.Parameters), RequestBody: wireFields(wm.RequestBody),
|
||||
ResponseBody: wireFields(wm.ResponseBody),
|
||||
})
|
||||
}
|
||||
resources = append(resources, metaschema.Resource{Name: rk, Methods: methods})
|
||||
}
|
||||
return metaschema.Service{
|
||||
Name: ws.Name, Version: ws.Version, Title: ws.Title,
|
||||
Description: ws.Description, ServicePath: ws.ServicePath, Resources: resources,
|
||||
}
|
||||
}
|
||||
@@ -4,290 +4,14 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
// MethodKeyOrder records the natural meta_data.json key order for one method's
|
||||
// parameters / requestBody / responseBody. Nested object key orders are stored
|
||||
// under NestedKeys, keyed by dotted path from the method root
|
||||
// (e.g. "responseBody.items.properties").
|
||||
type MethodKeyOrder struct {
|
||||
Parameters []string
|
||||
RequestBody []string
|
||||
ResponseBody []string
|
||||
NestedKeys map[string][]string
|
||||
}
|
||||
|
||||
var (
|
||||
keyOrderIndex map[string]*MethodKeyOrder // dottedPath -> order
|
||||
keyOrderInitOnce sync.Once
|
||||
)
|
||||
|
||||
// lookupKeyOrder returns the key-order record for service.resourcePath.method,
|
||||
// or nil if the method is not in the embedded data (e.g. remote-cached).
|
||||
func lookupKeyOrder(service string, resourcePath []string, method string) *MethodKeyOrder {
|
||||
keyOrderInitOnce.Do(buildKeyOrderIndex)
|
||||
if keyOrderIndex == nil {
|
||||
return nil
|
||||
}
|
||||
dotted := dottedPath(service, resourcePath, method)
|
||||
return keyOrderIndex[dotted]
|
||||
}
|
||||
|
||||
func dottedPath(service string, resourcePath []string, method string) string {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(service)
|
||||
for _, r := range resourcePath {
|
||||
buf.WriteByte('.')
|
||||
buf.WriteString(r)
|
||||
}
|
||||
buf.WriteByte('.')
|
||||
buf.WriteString(method)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// buildKeyOrderIndex parses the embedded meta_data.json bytes once at init,
|
||||
// walking services -> resources -> methods -> {parameters,requestBody,responseBody}
|
||||
// and recording each map's key insertion order via json.Decoder.Token().
|
||||
func buildKeyOrderIndex() {
|
||||
raw := registry.EmbeddedMetaJSON()
|
||||
if len(raw) == 0 {
|
||||
return
|
||||
}
|
||||
keyOrderIndex = make(map[string]*MethodKeyOrder)
|
||||
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
// Top-level: { "services": [...], "version": "..." }
|
||||
if !expectDelim(dec, '{') {
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
key, _ := readKey(dec)
|
||||
if key != "services" {
|
||||
skipValue(dec)
|
||||
continue
|
||||
}
|
||||
if !expectDelim(dec, '[') {
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
parseService(dec)
|
||||
}
|
||||
// closing ]
|
||||
_, _ = dec.Token()
|
||||
}
|
||||
}
|
||||
|
||||
// parseService consumes one service object inside services[].
|
||||
// meta_data.json may emit "resources" before "name", so we first capture both
|
||||
// raw fields, then walk resources with the resolved service name.
|
||||
func parseService(dec *json.Decoder) {
|
||||
if !expectDelim(dec, '{') {
|
||||
return
|
||||
}
|
||||
var serviceName string
|
||||
var resourcesRaw json.RawMessage
|
||||
for dec.More() {
|
||||
key, _ := readKey(dec)
|
||||
switch key {
|
||||
case "name":
|
||||
tok, _ := dec.Token()
|
||||
if s, ok := tok.(string); ok {
|
||||
serviceName = s
|
||||
}
|
||||
case "resources":
|
||||
if err := dec.Decode(&resourcesRaw); err != nil {
|
||||
skipValue(dec)
|
||||
}
|
||||
default:
|
||||
skipValue(dec)
|
||||
}
|
||||
}
|
||||
_, _ = dec.Token() // closing }
|
||||
if serviceName != "" && len(resourcesRaw) > 0 {
|
||||
subDec := json.NewDecoder(bytes.NewReader(resourcesRaw))
|
||||
parseResources(subDec, serviceName, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// parseResources walks a resources map (resName -> resource object).
|
||||
// resourcePath is the accumulated path of parent resources (for nested resources).
|
||||
func parseResources(dec *json.Decoder, service string, resourcePath []string) {
|
||||
if !expectDelim(dec, '{') {
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
resName, _ := readKey(dec)
|
||||
parseResourceObj(dec, service, append(resourcePath, resName))
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
}
|
||||
|
||||
// parseResourceObj consumes one resource value: { methods: {...}, ... } and may
|
||||
// recurse into nested resources via "resources" key if present.
|
||||
func parseResourceObj(dec *json.Decoder, service string, resourcePath []string) {
|
||||
if !expectDelim(dec, '{') {
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
key, _ := readKey(dec)
|
||||
switch key {
|
||||
case "methods":
|
||||
parseMethods(dec, service, resourcePath)
|
||||
case "resources":
|
||||
parseResources(dec, service, resourcePath)
|
||||
default:
|
||||
skipValue(dec)
|
||||
}
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
}
|
||||
|
||||
// parseMethods consumes the methods map (methodName -> method object).
|
||||
func parseMethods(dec *json.Decoder, service string, resourcePath []string) {
|
||||
if !expectDelim(dec, '{') {
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
methodName, _ := readKey(dec)
|
||||
mko := parseMethod(dec)
|
||||
dotted := dottedPath(service, resourcePath, methodName)
|
||||
keyOrderIndex[dotted] = mko
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
}
|
||||
|
||||
// parseMethod consumes one method object and records key orders.
|
||||
func parseMethod(dec *json.Decoder) *MethodKeyOrder {
|
||||
mko := &MethodKeyOrder{NestedKeys: make(map[string][]string)}
|
||||
if !expectDelim(dec, '{') {
|
||||
return mko
|
||||
}
|
||||
for dec.More() {
|
||||
key, _ := readKey(dec)
|
||||
switch key {
|
||||
case "parameters":
|
||||
mko.Parameters = recordObjectKeysRecursive(dec, "parameters", mko.NestedKeys)
|
||||
case "requestBody":
|
||||
mko.RequestBody = recordObjectKeysRecursive(dec, "requestBody", mko.NestedKeys)
|
||||
case "responseBody":
|
||||
mko.ResponseBody = recordObjectKeysRecursive(dec, "responseBody", mko.NestedKeys)
|
||||
default:
|
||||
skipValue(dec)
|
||||
}
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
return mko
|
||||
}
|
||||
|
||||
// recordObjectKeysRecursive consumes an object and records the top-level key
|
||||
// order. It also recurses into each child's "properties" submap, recording
|
||||
// nested orders under prefix.subpath in nestedKeys. Returns the top-level keys
|
||||
// in order.
|
||||
func recordObjectKeysRecursive(dec *json.Decoder, prefix string, nestedKeys map[string][]string) []string {
|
||||
if !expectDelim(dec, '{') {
|
||||
return nil
|
||||
}
|
||||
var order []string
|
||||
for dec.More() {
|
||||
key, _ := readKey(dec)
|
||||
order = append(order, key)
|
||||
// Each child value is itself an object; we want its nested "properties" order if present.
|
||||
consumeFieldRecursive(dec, prefix+"."+key, nestedKeys)
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
if prefix != "" && len(order) > 0 {
|
||||
nestedKeys[prefix] = order
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// consumeFieldRecursive consumes a field object (e.g. one parameter spec) and,
|
||||
// if it contains "properties": {...}, recursively records that submap's order.
|
||||
func consumeFieldRecursive(dec *json.Decoder, path string, nestedKeys map[string][]string) {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
delim, ok := tok.(json.Delim)
|
||||
if !ok || delim != '{' {
|
||||
// Not an object — skip the rest of the value
|
||||
skipValueAfterToken(dec, tok)
|
||||
return
|
||||
}
|
||||
for dec.More() {
|
||||
fieldKey, _ := readKey(dec)
|
||||
if fieldKey == "properties" {
|
||||
recordObjectKeysRecursive(dec, path+".properties", nestedKeys)
|
||||
} else {
|
||||
skipValue(dec)
|
||||
}
|
||||
}
|
||||
_, _ = dec.Token()
|
||||
}
|
||||
|
||||
// --- json.Decoder helpers ---
|
||||
|
||||
func expectDelim(dec *json.Decoder, want json.Delim) bool {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
delim, ok := tok.(json.Delim)
|
||||
return ok && delim == want
|
||||
}
|
||||
|
||||
func readKey(dec *json.Decoder) (string, error) {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s, _ := tok.(string)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// skipValue consumes the next complete value (scalar, object, or array).
|
||||
func skipValue(dec *json.Decoder) {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
skipValueAfterToken(dec, tok)
|
||||
}
|
||||
|
||||
func skipValueAfterToken(dec *json.Decoder, tok json.Token) {
|
||||
delim, ok := tok.(json.Delim)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// We started inside a container of type `delim` ({ or [) and must eat
|
||||
// tokens until that container closes, tracking nested containers of any
|
||||
// kind. depth counts how many open containers we are currently inside.
|
||||
_ = delim
|
||||
depth := 1
|
||||
for depth > 0 {
|
||||
t, err := dec.Token()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if d, ok := t.(json.Delim); ok {
|
||||
switch d {
|
||||
case '{', '[':
|
||||
depth++
|
||||
case '}', ']':
|
||||
depth--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// coerceLiteral converts a meta_data literal (default / enum / example) to
|
||||
// the JSON Schema type declared by the field (integer/number/boolean/string).
|
||||
// meta_data stores every literal as a string, so without coercion an
|
||||
@@ -501,10 +225,6 @@ func buildOrderedProps(raw map[string]interface{}, nestedPath string) (*OrderedP
|
||||
return op, required
|
||||
}
|
||||
|
||||
// currentMethodOrder is the per-method key-order context used by orderedKeys.
|
||||
// It is set inside AssembleEnvelope (under assembleMu) and reset on return.
|
||||
var currentMethodOrder *MethodKeyOrder
|
||||
|
||||
// parseAffordance lifts the affordance overlay from a method's raw meta_data.json
|
||||
// entry into a typed *Affordance. Returns nil when the field is absent, malformed,
|
||||
// or carries no populated subfields.
|
||||
@@ -611,8 +331,6 @@ func buildMeta(method map[string]interface{}) *Meta {
|
||||
// The params / data wrapping mirrors the CLI's actual flag layout:
|
||||
// path+query → --params JSON, body → --data JSON, file → --file. AI consumers
|
||||
// can pluck inputSchema.properties.params and pass it verbatim to --params.
|
||||
//
|
||||
// Caller must set currentMethodOrder for property-order preservation.
|
||||
func buildInputSchema(method map[string]interface{}) *InputSchema {
|
||||
is := &InputSchema{
|
||||
Type: "object",
|
||||
@@ -738,27 +456,11 @@ func buildOutputSchema(method map[string]interface{}) *OutputSchema {
|
||||
return os
|
||||
}
|
||||
|
||||
// assembleMu serializes AssembleEnvelope calls so that the package-level
|
||||
// currentMethodOrder pointer is safe for concurrent callers.
|
||||
var assembleMu sync.Mutex
|
||||
|
||||
// AssembleEnvelope is the main entry point: takes a service / resource path /
|
||||
// method name plus its meta_data spec, and produces a fully assembled MCP
|
||||
// envelope. Output is fully determined by inputs (same arguments → same
|
||||
// envelope), but assembly briefly publishes the per-method key-order context
|
||||
// through the package-level currentMethodOrder so orderedKeys can reach it
|
||||
// without threading it through every helper. assembleMu serializes that
|
||||
// publish, which is why concurrent callers are still safe — they queue
|
||||
// rather than run in parallel.
|
||||
//
|
||||
// If parallelism becomes a bottleneck, replace currentMethodOrder with an
|
||||
// assembler struct or pass *MethodKeyOrder explicitly down the call chain.
|
||||
// envelope).
|
||||
func AssembleEnvelope(serviceName string, resourcePath []string, methodName string, method map[string]interface{}) Envelope {
|
||||
assembleMu.Lock()
|
||||
defer assembleMu.Unlock()
|
||||
currentMethodOrder = lookupKeyOrder(serviceName, resourcePath, methodName)
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
name := serviceName
|
||||
for _, r := range resourcePath {
|
||||
name += " " + r
|
||||
@@ -836,35 +538,10 @@ func walkMethods(resources map[string]interface{}, parentPath []string,
|
||||
}
|
||||
}
|
||||
|
||||
// orderedKeys returns the keys of raw in their meta_data natural order if
|
||||
// the current per-method key-order context has them recorded; otherwise
|
||||
// alphabetical fallback.
|
||||
func orderedKeys(raw map[string]interface{}, nestedPath string) []string {
|
||||
if currentMethodOrder != nil && nestedPath != "" {
|
||||
if order, ok := currentMethodOrder.NestedKeys[nestedPath]; ok {
|
||||
// Filter to keys that actually exist in raw (defensive)
|
||||
out := make([]string, 0, len(order))
|
||||
seen := make(map[string]bool)
|
||||
for _, k := range order {
|
||||
if _, ok := raw[k]; ok {
|
||||
out = append(out, k)
|
||||
seen[k] = true
|
||||
}
|
||||
}
|
||||
// Append any keys present in raw but missing from order (defensive),
|
||||
// alphabetically for determinism.
|
||||
var extra []string
|
||||
for k := range raw {
|
||||
if !seen[k] {
|
||||
extra = append(extra, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(extra)
|
||||
out = append(out, extra...)
|
||||
return out
|
||||
}
|
||||
}
|
||||
// Fallback: alphabetical
|
||||
// orderedKeys returns the keys of raw in alphabetical order. Field display
|
||||
// order is not preserved: the schema envelope is consumed as a JSON Schema (MCP
|
||||
// tool spec), where object property order carries no meaning.
|
||||
func orderedKeys(raw map[string]interface{}, _ string) []string {
|
||||
keys := make([]string, 0, len(raw))
|
||||
for k := range raw {
|
||||
keys = append(keys, k)
|
||||
|
||||
@@ -7,10 +7,12 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/registry/metaschema"
|
||||
)
|
||||
|
||||
// TestMain isolates registry-backed tests from any host ~/.lark-cli cache so
|
||||
@@ -35,58 +37,6 @@ func TestMain(m *testing.M) {
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestKeyOrderIndex_ImReactionsList(t *testing.T) {
|
||||
// We only assert key-set membership, not absolute order — the upstream
|
||||
// meta_data API does not guarantee a stable JSON key sequence across
|
||||
// fetches, so hard-coding the order makes CI flaky. Order preservation
|
||||
// from input to output is tested separately in TestBuildInputSchema_*.
|
||||
order := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||||
if order == nil {
|
||||
t.Fatal("expected key order for im.reactions.list, got nil")
|
||||
}
|
||||
wantParams := map[string]bool{
|
||||
"message_id": true, "reaction_type": true, "page_token": true,
|
||||
"page_size": true, "user_id_type": true,
|
||||
}
|
||||
if got, want := len(order.Parameters), len(wantParams); got != want {
|
||||
t.Errorf("parameters count = %d, want %d (got %v)", got, want, order.Parameters)
|
||||
}
|
||||
for _, k := range order.Parameters {
|
||||
if !wantParams[k] {
|
||||
t.Errorf("unexpected parameter key %q", k)
|
||||
}
|
||||
}
|
||||
// im.reactions.list 是 GET,没有 requestBody
|
||||
if len(order.RequestBody) != 0 {
|
||||
t.Errorf("expected empty RequestBody, got %v", order.RequestBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyOrderIndex_ImImagesCreate(t *testing.T) {
|
||||
// Membership-only assertion; see comment on TestKeyOrderIndex_ImReactionsList.
|
||||
order := lookupKeyOrder("im", []string{"images"}, "create")
|
||||
if order == nil {
|
||||
t.Fatal("expected key order for im.images.create, got nil")
|
||||
}
|
||||
wantBody := map[string]bool{"image_type": true, "image": true}
|
||||
if got, want := len(order.RequestBody), len(wantBody); got != want {
|
||||
t.Errorf("requestBody count = %d, want %d (got %v)", got, want, order.RequestBody)
|
||||
}
|
||||
for _, k := range order.RequestBody {
|
||||
if !wantBody[k] {
|
||||
t.Errorf("unexpected requestBody key %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyOrderIndex_UnknownPath(t *testing.T) {
|
||||
// 远端缓存的命令(不在 embedded 内)查不到 key order,返回 nil 走字母序兜底
|
||||
order := lookupKeyOrder("nonexistent_service", []string{"foo"}, "bar")
|
||||
if order != nil {
|
||||
t.Errorf("expected nil for unknown path, got %+v", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_BasicTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -288,9 +238,6 @@ func TestConvertProperty_DescriptionDefaultExample(t *testing.T) {
|
||||
|
||||
func TestBuildInputSchema_ReactionsList(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||||
currentMethodOrder = mko
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
is := buildInputSchema(method)
|
||||
|
||||
@@ -313,16 +260,15 @@ func TestBuildInputSchema_ReactionsList(t *testing.T) {
|
||||
if !reflect.DeepEqual(params.Required, []string{"message_id"}) {
|
||||
t.Errorf("params.Required = %v, want [message_id]", params.Required)
|
||||
}
|
||||
if !reflect.DeepEqual(params.Properties.Order, mko.Parameters) {
|
||||
t.Errorf("params.properties order = %v, want (from key index) %v",
|
||||
params.Properties.Order, mko.Parameters)
|
||||
// Property order is alphabetical now: the envelope is a JSON Schema (MCP
|
||||
// tool spec) where object property order carries no meaning.
|
||||
if !sort.StringsAreSorted(params.Properties.Order) {
|
||||
t.Errorf("params.properties order not alphabetical: %v", params.Properties.Order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInputSchema_ImagesCreate_FileAndBody(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"images"}, "create")
|
||||
currentMethodOrder = lookupKeyOrder("im", []string{"images"}, "create")
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
is := buildInputSchema(method)
|
||||
|
||||
@@ -382,9 +328,6 @@ func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
currentMethodOrder = nil
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
is := buildInputSchema(method)
|
||||
|
||||
// yes lives at inputSchema.properties.yes (sibling of params/data)
|
||||
@@ -413,9 +356,6 @@ func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) {
|
||||
|
||||
func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||||
currentMethodOrder = mko
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
is := buildInputSchema(method)
|
||||
if _, ok := is.Properties.Map["yes"]; ok {
|
||||
@@ -425,9 +365,6 @@ func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) {
|
||||
|
||||
func TestBuildOutputSchema_ReactionsList(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
|
||||
currentMethodOrder = mko
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
os := buildOutputSchema(method)
|
||||
|
||||
@@ -613,6 +550,45 @@ func TestBuildMeta_AffordanceFromMethod(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMeta_AffordanceThroughTypedRegistry guards the static-registry path:
|
||||
// a method's affordance must survive metaschema.Method -> registry.MethodToMap
|
||||
// -> buildMeta, so `schema --format json` keeps emitting _meta.affordance after
|
||||
// the embedded-JSON-to-typed-registry migration. Without typed-side support the
|
||||
// overlay is silently stripped whenever meta_data.json carries affordance.
|
||||
func TestBuildMeta_AffordanceThroughTypedRegistry(t *testing.T) {
|
||||
mth := metaschema.Method{
|
||||
Name: "primary",
|
||||
Affordance: &metaschema.Affordance{
|
||||
UseWhen: []string{"用户想拿到自己默认日历的 ID"},
|
||||
DoNotUseWhen: []string{"已经知道某个具体日历的 ID"},
|
||||
Prerequisites: []string{"user 身份登录"},
|
||||
Examples: []metaschema.AffordanceExample{
|
||||
{Description: "取主日历", Command: "lark-cli calendar calendars primary"},
|
||||
},
|
||||
Related: []string{"calendars.list", "calendars.get"},
|
||||
},
|
||||
}
|
||||
method := registry.MethodToMap(mth)
|
||||
m := buildMeta(method)
|
||||
if m.Affordance == nil {
|
||||
t.Fatal("affordance dropped through the typed registry (MethodToMap -> buildMeta)")
|
||||
}
|
||||
a := m.Affordance
|
||||
if len(a.UseWhen) != 1 || a.UseWhen[0] != "用户想拿到自己默认日历的 ID" {
|
||||
t.Errorf("UseWhen = %v", a.UseWhen)
|
||||
}
|
||||
if len(a.DoNotUseWhen) != 1 || len(a.Prerequisites) != 1 {
|
||||
t.Errorf("DoNotUseWhen=%v Prerequisites=%v", a.DoNotUseWhen, a.Prerequisites)
|
||||
}
|
||||
if len(a.Examples) != 1 || a.Examples[0].Description != "取主日历" ||
|
||||
a.Examples[0].Command != "lark-cli calendar calendars primary" {
|
||||
t.Errorf("Examples = %+v", a.Examples)
|
||||
}
|
||||
if len(a.Related) != 2 {
|
||||
t.Errorf("Related = %v", a.Related)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"scopes": []interface{}{"x"},
|
||||
@@ -634,7 +610,6 @@ func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
|
||||
func TestBuildOutputSchema_EmptyResponseBody(t *testing.T) {
|
||||
// 装配器对空 responseBody 应生成 properties = {} (不 nil)
|
||||
method := map[string]interface{}{}
|
||||
currentMethodOrder = nil
|
||||
os := buildOutputSchema(method)
|
||||
if os.Type != "object" {
|
||||
t.Errorf("Type = %q, want \"object\"", os.Type)
|
||||
|
||||
@@ -83,9 +83,13 @@ type AffordanceCase struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
// OrderedProps is map[string]Property with preserved key order on MarshalJSON.
|
||||
// It is used wherever JSON output must reflect meta_data.json's natural field
|
||||
// order rather than Go's default alphabetical map encoding.
|
||||
// OrderedProps is map[string]Property that emits its keys in Order on
|
||||
// MarshalJSON. Order is now populated alphabetically (see orderedKeys): the
|
||||
// schema envelope is an MCP tool spec / JSON Schema, where object property
|
||||
// order carries no meaning. The machinery that once preserved meta_data.json's
|
||||
// natural field order was removed with the static-registry migration; Order is
|
||||
// retained so MarshalJSON has one stable key sequence (and callers that leave
|
||||
// it empty fall back to alphabetical over Map).
|
||||
type OrderedProps struct {
|
||||
Order []string
|
||||
Map map[string]Property
|
||||
|
||||
@@ -6,6 +6,7 @@ OUT_DIR="$ROOT_DIR/.pkg-pr-new"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
# fetch_meta.py also regenerates the static Go registry (meta_data_gen.go).
|
||||
python3 scripts/fetch_meta.py
|
||||
|
||||
rm -rf "$OUT_DIR"
|
||||
|
||||
@@ -63,6 +63,19 @@ def fetch_remote(brand):
|
||||
return data
|
||||
|
||||
|
||||
def run_gen():
|
||||
"""Regenerate the static Go registry (metastatic/meta_data_gen.go) from
|
||||
meta_data.json. Run after every fetch so any caller that fetches also
|
||||
produces the sole build-time source of the embedded command tree — no build
|
||||
tag, no JSON embedded in the binary. Output is gitignored."""
|
||||
print("fetch-meta: generating static Go registry (metastatic/meta_data_gen.go)", file=sys.stderr)
|
||||
subprocess.run(
|
||||
["go", "run", "internal/registry/metastatic/gen.go"],
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Fetch meta_data.json for build-time embedding")
|
||||
parser.add_argument("--brand", default="feishu", choices=["feishu", "lark"],
|
||||
@@ -71,27 +84,29 @@ def main():
|
||||
help="force refresh from remote even if local file exists")
|
||||
args = parser.parse_args()
|
||||
|
||||
if os.path.exists(OUT_PATH) and not args.force:
|
||||
if os.path.isfile(OUT_PATH):
|
||||
try:
|
||||
with open(OUT_PATH, "r", encoding="utf-8") as fp:
|
||||
local = json.load(fp)
|
||||
if local.get("services"):
|
||||
print(f"fetch-meta: {OUT_PATH} already exists, skipping (use --force to re-fetch)", file=sys.stderr)
|
||||
return
|
||||
print(f"fetch-meta: {OUT_PATH} has no services, re-fetching", file=sys.stderr)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
print(f"fetch-meta: {OUT_PATH} is invalid JSON, re-fetching", file=sys.stderr)
|
||||
else:
|
||||
print(f"fetch-meta: {OUT_PATH} is not a file, re-fetching", file=sys.stderr)
|
||||
have_valid = False
|
||||
if os.path.isfile(OUT_PATH) and not args.force:
|
||||
try:
|
||||
with open(OUT_PATH, "r", encoding="utf-8") as fp:
|
||||
local = json.load(fp)
|
||||
have_valid = bool(local.get("services"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
have_valid = False
|
||||
|
||||
data = fetch_remote(args.brand)
|
||||
count = len(data.get("services", []))
|
||||
print(f"fetch-meta: OK, {count} services from remote API", file=sys.stderr)
|
||||
if have_valid:
|
||||
print(f"fetch-meta: {OUT_PATH} already exists, skipping fetch (use --force to re-fetch)", file=sys.stderr)
|
||||
else:
|
||||
data = fetch_remote(args.brand)
|
||||
count = len(data.get("services", []))
|
||||
print(f"fetch-meta: OK, {count} services from remote API", file=sys.stderr)
|
||||
with open(OUT_PATH, "w") as fp:
|
||||
json.dump(data, fp, ensure_ascii=False, indent=2)
|
||||
fp.write("\n")
|
||||
|
||||
with open(OUT_PATH, "w") as fp:
|
||||
json.dump(data, fp, ensure_ascii=False, indent=2)
|
||||
fp.write("\n")
|
||||
# Always (re)generate the static Go registry so every fetch also produces
|
||||
# the embedded command tree — the build-time replacement for the old
|
||||
# embedded meta_data.json.
|
||||
run_gen()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -13,10 +13,12 @@ import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -28,8 +30,17 @@ const markdownEmptyContentError = "empty markdown content is not supported; cann
|
||||
const (
|
||||
markdownUploadParentTypeExplorer = "explorer"
|
||||
markdownUploadParentTypeWiki = "wiki"
|
||||
markdownUploadAllAction = "upload markdown file failed"
|
||||
markdownUploadPrepareAction = "initialize markdown multipart upload failed"
|
||||
markdownUploadFinishAction = "finalize markdown multipart upload failed"
|
||||
markdownFetchNameAction = "fetch existing markdown file name failed"
|
||||
)
|
||||
|
||||
var markdownUploadRetryBackoffs = []time.Duration{
|
||||
200 * time.Millisecond,
|
||||
500 * time.Millisecond,
|
||||
}
|
||||
|
||||
type markdownUploadSpec struct {
|
||||
FileToken string
|
||||
FileName string
|
||||
@@ -387,58 +398,68 @@ func uploadMarkdownContent(runtime *common.RuntimeContext, spec markdownUploadSp
|
||||
fileName := finalMarkdownFileName(spec)
|
||||
fileSize := int64(len(payload))
|
||||
if fileSize > markdownSinglePartSizeLimit {
|
||||
return uploadMarkdownFileMultipart(runtime, spec, bytes.NewReader(payload), fileName, fileSize)
|
||||
return uploadMarkdownFileMultipart(runtime, spec, fileName, fileSize, func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(bytes.NewReader(payload)), nil
|
||||
})
|
||||
}
|
||||
return uploadMarkdownFileAll(runtime, spec, bytes.NewReader(payload), fileName, fileSize)
|
||||
return uploadMarkdownFileAll(runtime, spec, fileName, fileSize, func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(bytes.NewReader(payload)), nil
|
||||
})
|
||||
}
|
||||
|
||||
func uploadMarkdownLocalFile(runtime *common.RuntimeContext, spec markdownUploadSpec, fileSize int64) (markdownUploadResult, error) {
|
||||
fileName := finalMarkdownFileName(spec)
|
||||
f, err := runtime.FileIO().Open(spec.FilePath)
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, common.WrapInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if fileSize > markdownSinglePartSizeLimit {
|
||||
return uploadMarkdownFileMultipart(runtime, spec, f, fileName, fileSize)
|
||||
return uploadMarkdownFileMultipart(runtime, spec, fileName, fileSize, func() (io.ReadCloser, error) {
|
||||
return runtime.FileIO().Open(spec.FilePath)
|
||||
})
|
||||
}
|
||||
return uploadMarkdownFileAll(runtime, spec, f, fileName, fileSize)
|
||||
return uploadMarkdownFileAll(runtime, spec, fileName, fileSize, func() (io.ReadCloser, error) {
|
||||
return runtime.FileIO().Open(spec.FilePath)
|
||||
})
|
||||
}
|
||||
|
||||
func uploadMarkdownFileAll(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
|
||||
func uploadMarkdownFileAll(runtime *common.RuntimeContext, spec markdownUploadSpec, fileName string, fileSize int64, openReader func() (io.ReadCloser, error)) (markdownUploadResult, error) {
|
||||
target := spec.Target()
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddField("parent_type", target.ParentType)
|
||||
fd.AddField("parent_node", target.ParentNode)
|
||||
fd.AddField("size", fmt.Sprintf("%d", fileSize))
|
||||
if spec.FileToken != "" {
|
||||
fd.AddField("file_token", spec.FileToken)
|
||||
}
|
||||
fd.AddFile("file", fileReader)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return markdownUploadResult{}, err
|
||||
return withMarkdownUploadRetryResult(runtime, markdownUploadAllAction, func() (markdownUploadResult, error) {
|
||||
fileReader, err := openReader()
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, common.WrapInputStatErrorTyped(err)
|
||||
}
|
||||
return markdownUploadResult{}, output.ErrNetwork("upload failed: %v", err)
|
||||
}
|
||||
defer fileReader.Close()
|
||||
|
||||
data, err := common.ParseDriveMediaUploadResponse(apiResp, "upload failed")
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
return parseMarkdownUploadResult(data, spec.FileToken != "")
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddField("parent_type", target.ParentType)
|
||||
fd.AddField("parent_node", target.ParentNode)
|
||||
fd.AddField("size", fmt.Sprintf("%d", fileSize))
|
||||
if spec.FileToken != "" {
|
||||
fd.AddField("file_token", spec.FileToken)
|
||||
}
|
||||
fd.AddFile("file", fileReader)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, markdownUploadProblem(client.WrapDoAPIError(err), markdownUploadAllAction)
|
||||
}
|
||||
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, markdownUploadProblem(err, markdownUploadAllAction)
|
||||
}
|
||||
result, err := parseMarkdownUploadResult(data, spec.FileToken != "")
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, markdownUploadProblem(err, markdownUploadAllAction)
|
||||
}
|
||||
return result, nil
|
||||
})
|
||||
}
|
||||
|
||||
func uploadMarkdownFileMultipart(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
|
||||
func uploadMarkdownFileMultipart(runtime *common.RuntimeContext, spec markdownUploadSpec, fileName string, fileSize int64, openReader func() (io.ReadCloser, error)) (markdownUploadResult, error) {
|
||||
target := spec.Target()
|
||||
prepareBody := map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
@@ -450,31 +471,53 @@ func uploadMarkdownFileMultipart(runtime *common.RuntimeContext, spec markdownUp
|
||||
prepareBody["file_token"] = spec.FileToken
|
||||
}
|
||||
|
||||
prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
|
||||
prepareResult, err := withMarkdownUploadRetryData(runtime, markdownUploadPrepareAction, func() (map[string]interface{}, error) {
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
|
||||
if err != nil {
|
||||
return nil, markdownUploadProblem(err, markdownUploadPrepareAction)
|
||||
}
|
||||
return data, nil
|
||||
})
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
|
||||
session, err := parseMarkdownMultipartSession(prepareResult)
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
return markdownUploadResult{}, markdownUploadProblem(err, markdownUploadPrepareAction)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, common.FormatSize(session.BlockSize))
|
||||
|
||||
fileReader, err := openReader()
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, common.WrapInputStatErrorTyped(err)
|
||||
}
|
||||
defer fileReader.Close()
|
||||
|
||||
if err := uploadMarkdownMultipartParts(runtime, fileReader, fileSize, session); err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
|
||||
finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
|
||||
"upload_id": session.UploadID,
|
||||
"block_num": session.BlockNum,
|
||||
finishResult, err := withMarkdownUploadRetryData(runtime, markdownUploadFinishAction, func() (map[string]interface{}, error) {
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
|
||||
"upload_id": session.UploadID,
|
||||
"block_num": session.BlockNum,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, markdownUploadProblem(err, markdownUploadFinishAction)
|
||||
}
|
||||
return data, nil
|
||||
})
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, err
|
||||
}
|
||||
|
||||
return parseMarkdownUploadResult(finishResult, spec.FileToken != "")
|
||||
result, err := parseMarkdownUploadResult(finishResult, spec.FileToken != "")
|
||||
if err != nil {
|
||||
return markdownUploadResult{}, markdownUploadProblem(err, markdownUploadFinishAction)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseMarkdownMultipartSession(data map[string]interface{}) (markdownMultipartSession, error) {
|
||||
@@ -484,7 +527,7 @@ func parseMarkdownMultipartSession(data map[string]interface{}) (markdownMultipa
|
||||
BlockNum: int(common.GetFloat(data, "block_num")),
|
||||
}
|
||||
if session.UploadID == "" || session.BlockSize <= 0 || session.BlockNum <= 0 {
|
||||
return markdownMultipartSession{}, output.Errorf(output.ExitAPI, "api_error",
|
||||
return markdownMultipartSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
|
||||
session.UploadID, session.BlockSize, session.BlockNum)
|
||||
}
|
||||
@@ -494,9 +537,8 @@ func parseMarkdownMultipartSession(data map[string]interface{}) (markdownMultipa
|
||||
func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.Reader, payloadSize int64, session markdownMultipartSession) error {
|
||||
expectedBlocks := int((payloadSize + session.BlockSize - 1) / session.BlockSize)
|
||||
if session.BlockNum != expectedBlocks {
|
||||
return output.Errorf(
|
||||
output.ExitAPI,
|
||||
"api_error",
|
||||
return errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"upload_prepare returned inconsistent chunk plan: block_size=%d, block_num=%d, expected_block_num=%d, payload_size=%d",
|
||||
session.BlockSize,
|
||||
session.BlockNum,
|
||||
@@ -507,7 +549,7 @@ func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.
|
||||
|
||||
maxInt := int64(^uint(0) >> 1)
|
||||
if session.BlockSize > maxInt {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_size returned")
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_size returned")
|
||||
}
|
||||
|
||||
buffer := make([]byte, int(session.BlockSize))
|
||||
@@ -528,22 +570,27 @@ func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.
|
||||
fd.AddField("upload_id", session.UploadID)
|
||||
fd.AddField("seq", fmt.Sprintf("%d", seq))
|
||||
fd.AddField("size", fmt.Sprintf("%d", n))
|
||||
fd.AddFile("file", bytes.NewReader(buffer[:n]))
|
||||
action := fmt.Sprintf("upload markdown file part %d/%d failed", seq+1, session.BlockNum)
|
||||
if err := withMarkdownUploadRetryVoid(runtime, action, func() error {
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("upload_id", session.UploadID)
|
||||
fd.AddField("seq", fmt.Sprintf("%d", seq))
|
||||
fd.AddField("size", fmt.Sprintf("%d", n))
|
||||
fd.AddFile("file", bytes.NewReader(buffer[:n]))
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/files/upload_part",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/files/upload_part",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return markdownUploadProblem(client.WrapDoAPIError(err), action)
|
||||
}
|
||||
return output.ErrNetwork("upload part %d/%d failed: %v", seq+1, session.BlockNum, err)
|
||||
}
|
||||
|
||||
if _, err := common.ParseDriveMediaUploadResponse(apiResp, fmt.Sprintf("upload part %d/%d failed", seq+1, session.BlockNum)); err != nil {
|
||||
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
|
||||
return markdownUploadProblem(err, action)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -551,9 +598,8 @@ func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.
|
||||
remaining -= int64(n)
|
||||
}
|
||||
if remaining != 0 {
|
||||
return output.Errorf(
|
||||
output.ExitAPI,
|
||||
"api_error",
|
||||
return errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"upload_prepare returned inconsistent chunk plan: %d bytes remain after %d blocks",
|
||||
remaining,
|
||||
session.BlockNum,
|
||||
@@ -572,28 +618,34 @@ func parseMarkdownUploadResult(data map[string]interface{}, requireVersion bool)
|
||||
result.Version = common.GetString(data, "data_version")
|
||||
}
|
||||
if result.FileToken == "" {
|
||||
return markdownUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
|
||||
return markdownUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload failed: no file_token returned")
|
||||
}
|
||||
if requireVersion && result.Version == "" {
|
||||
return markdownUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "overwrite failed: no version returned")
|
||||
return markdownUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "overwrite failed: no version returned")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func fetchMarkdownFileName(runtime *common.RuntimeContext, fileToken string) (string, error) {
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": fileToken,
|
||||
"doc_type": "file",
|
||||
data, err := withMarkdownUploadRetryData(runtime, markdownFetchNameAction, func() (map[string]interface{}, error) {
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": fileToken,
|
||||
"doc_type": "file",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
if err != nil {
|
||||
return nil, markdownUploadProblem(err, markdownFetchNameAction)
|
||||
}
|
||||
return data, nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -606,6 +658,97 @@ func fetchMarkdownFileName(runtime *common.RuntimeContext, fileToken string) (st
|
||||
return common.GetString(meta, "title"), nil
|
||||
}
|
||||
|
||||
func withMarkdownUploadRetryResult(runtime *common.RuntimeContext, action string, fn func() (markdownUploadResult, error)) (markdownUploadResult, error) {
|
||||
var zero markdownUploadResult
|
||||
for attempt := 0; ; attempt++ {
|
||||
result, err := fn()
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if !markdownUploadShouldRetry(err) || attempt >= len(markdownUploadRetryBackoffs) {
|
||||
return zero, markdownUploadRetryExhausted(err, action, attempt)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "%s; retrying (attempt %d/%d)\n", err.Error(), attempt+1, len(markdownUploadRetryBackoffs))
|
||||
time.Sleep(markdownUploadRetryBackoffs[attempt])
|
||||
}
|
||||
}
|
||||
|
||||
func withMarkdownUploadRetryData(runtime *common.RuntimeContext, action string, fn func() (map[string]interface{}, error)) (map[string]interface{}, error) {
|
||||
for attempt := 0; ; attempt++ {
|
||||
result, err := fn()
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if !markdownUploadShouldRetry(err) || attempt >= len(markdownUploadRetryBackoffs) {
|
||||
return nil, markdownUploadRetryExhausted(err, action, attempt)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "%s; retrying (attempt %d/%d)\n", err.Error(), attempt+1, len(markdownUploadRetryBackoffs))
|
||||
time.Sleep(markdownUploadRetryBackoffs[attempt])
|
||||
}
|
||||
}
|
||||
|
||||
func withMarkdownUploadRetryVoid(runtime *common.RuntimeContext, action string, fn func() error) error {
|
||||
for attempt := 0; ; attempt++ {
|
||||
err := fn()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !markdownUploadShouldRetry(err) || attempt >= len(markdownUploadRetryBackoffs) {
|
||||
return markdownUploadRetryExhausted(err, action, attempt)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "%s; retrying (attempt %d/%d)\n", err.Error(), attempt+1, len(markdownUploadRetryBackoffs))
|
||||
time.Sleep(markdownUploadRetryBackoffs[attempt])
|
||||
}
|
||||
}
|
||||
|
||||
func markdownUploadShouldRetry(err error) bool {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p == nil {
|
||||
return false
|
||||
}
|
||||
return p.Retryable || p.Category == errs.CategoryNetwork
|
||||
}
|
||||
|
||||
func markdownUploadRetryExhausted(err error, action string, retries int) error {
|
||||
if retries <= 0 {
|
||||
return err
|
||||
}
|
||||
return appendMarkdownProblemHint(err, fmt.Sprintf("%s remained retryable after %d attempts; retry later if the upstream service is throttling or temporarily unavailable", action, retries+1))
|
||||
}
|
||||
|
||||
func markdownUploadProblem(err error, action string) error {
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
p.Message = action + ": " + p.Message
|
||||
switch p.Code {
|
||||
case 99991672, 99991679:
|
||||
appendMarkdownProblemHint(err, "The current token or identity lacks the required document upload scope/capability. Grant the document upload scope or use a token with the appropriate permissions, then retry.")
|
||||
case 10071:
|
||||
appendMarkdownProblemHint(err, "The target document has reached its version limit. Clean up old versions or create a new file before retrying.")
|
||||
case 90003087:
|
||||
appendMarkdownProblemHint(err, "The current tenant or user may not have document capabilities enabled. Ask an administrator to verify document-module access.")
|
||||
case 1061003, 1061044:
|
||||
appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the token you passed to the command.")
|
||||
case 1061004, 1062501:
|
||||
appendMarkdownProblemHint(err, "Check whether the current identity has write access to the target folder or wiki node.")
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func appendMarkdownProblemHint(err error, hint string) error {
|
||||
if strings.TrimSpace(hint) == "" {
|
||||
return err
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if strings.TrimSpace(p.Hint) != "" {
|
||||
p.Hint = p.Hint + "\n" + hint
|
||||
} else {
|
||||
p.Hint = hint
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func prettyPrintMarkdownWrite(w io.Writer, data map[string]interface{}) {
|
||||
fmt.Fprintf(w, "file_token: %s\n", common.GetString(data, "file_token"))
|
||||
fmt.Fprintf(w, "file_name: %s\n", common.GetString(data, "file_name"))
|
||||
|
||||
@@ -17,9 +17,11 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -603,6 +605,100 @@ func TestMarkdownCreateSuccessUploadAllToWikiReturnsMetaURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateUploadAllReturnsTypedScopeError(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "Access denied. One of the following scopes is required: [drive:file:upload]",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "log-md-upload-scope",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello\n",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected scope error")
|
||||
}
|
||||
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if p.Code != 99991672 {
|
||||
t.Fatalf("code = %d, want 99991672", p.Code)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeAppScopeNotApplied {
|
||||
t.Fatalf("subtype = %s, want %s", p.Subtype, errs.SubtypeAppScopeNotApplied)
|
||||
}
|
||||
if !strings.HasPrefix(p.Message, markdownUploadAllAction+": ") {
|
||||
t.Fatalf("message = %q, want %q prefix", p.Message, markdownUploadAllAction+": ")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "lacks the required document upload scope") {
|
||||
t.Fatalf("hint = %q, want upload scope guidance", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateUploadAllRetriesRateLimit(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991400,
|
||||
"msg": "request frequency limit exceeded",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "log-md-upload-ratelimit-1",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "box_md_retry_success",
|
||||
"version": "1003",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"metas": []map[string]interface{}{
|
||||
{"doc_token": "box_md_retry_success", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_retry_success"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello\n",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "retrying (attempt 1/2)") {
|
||||
t.Fatalf("stderr = %q, want retry log", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"file_token": "box_md_retry_success"`) {
|
||||
t.Fatalf("stdout missing retried upload token: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreatePrettyOutputIncludesPermissionGrant(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1033,6 +1129,270 @@ func TestUploadMarkdownMultipartPartsRejectsOversizedBlockSize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithMarkdownUploadRetryDataDoesNotRetryNonRetryable(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
rt := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create"}, markdownTestConfig(), f, core.AsUser)
|
||||
|
||||
attempts := 0
|
||||
expected := errs.NewAPIError(errs.SubtypePermissionDenied, "permission denied").WithCode(1061004)
|
||||
_, err := withMarkdownUploadRetryData(rt, markdownUploadAllAction, func() (map[string]interface{}, error) {
|
||||
attempts++
|
||||
return nil, expected
|
||||
})
|
||||
if err != expected {
|
||||
t.Fatalf("err = %v, want original error", err)
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Fatalf("attempts = %d, want 1", attempts)
|
||||
}
|
||||
if stderr.String() != "" {
|
||||
t.Fatalf("stderr = %q, want no retry log", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithMarkdownUploadRetryVoidExhaustedAppendsHint(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
rt := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create"}, markdownTestConfig(), f, core.AsUser)
|
||||
|
||||
orig := markdownUploadRetryBackoffs
|
||||
markdownUploadRetryBackoffs = []time.Duration{0, 0}
|
||||
t.Cleanup(func() { markdownUploadRetryBackoffs = orig })
|
||||
|
||||
attempts := 0
|
||||
err := withMarkdownUploadRetryVoid(rt, markdownUploadFinishAction, func() error {
|
||||
attempts++
|
||||
return errs.NewAPIError(errs.SubtypeRateLimit, "too many requests").WithCode(99991400).WithRetryable()
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected retryable error")
|
||||
}
|
||||
if attempts != 3 {
|
||||
t.Fatalf("attempts = %d, want 3", attempts)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "remained retryable after 3 attempts") {
|
||||
t.Fatalf("hint = %q, want retry exhaustion guidance", p.Hint)
|
||||
}
|
||||
if strings.Count(stderr.String(), "retrying (attempt") != 2 {
|
||||
t.Fatalf("stderr = %q, want 2 retry logs", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownUploadShouldRetryBranches(t *testing.T) {
|
||||
if markdownUploadShouldRetry(errors.New("plain")) {
|
||||
t.Fatal("plain error should not be retryable")
|
||||
}
|
||||
if !markdownUploadShouldRetry(errs.NewAPIError(errs.SubtypeRateLimit, "slow down").WithRetryable()) {
|
||||
t.Fatal("retryable API error should be retryable")
|
||||
}
|
||||
if !markdownUploadShouldRetry(errs.NewNetworkError(errs.SubtypeNetworkServer, "gateway").WithCode(502)) {
|
||||
t.Fatal("network error should be retryable by category")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownUploadRetryExhaustedZeroRetriesKeepsOriginal(t *testing.T) {
|
||||
original := errs.NewAPIError(errs.SubtypeRateLimit, "slow down").WithRetryable()
|
||||
got := markdownUploadRetryExhausted(original, markdownUploadAllAction, 0)
|
||||
if got != original {
|
||||
t.Fatalf("got = %v, want original error", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownUploadProblemAppendsCodeSpecificHints(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code int
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing scope",
|
||||
code: 99991672,
|
||||
want: "lacks the required document upload scope",
|
||||
},
|
||||
{
|
||||
name: "version limit",
|
||||
code: 10071,
|
||||
want: "reached its version limit",
|
||||
},
|
||||
{
|
||||
name: "document capability",
|
||||
code: 90003087,
|
||||
want: "document capabilities enabled",
|
||||
},
|
||||
{
|
||||
name: "target not found",
|
||||
code: 1061044,
|
||||
want: "target folder or wiki node still exists",
|
||||
},
|
||||
{
|
||||
name: "no write access",
|
||||
code: 1062501,
|
||||
want: "has write access",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithCode(tt.code)
|
||||
got := markdownUploadProblem(err, markdownUploadAllAction)
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", got, got)
|
||||
}
|
||||
if !strings.HasPrefix(p.Message, markdownUploadAllAction+": ") {
|
||||
t.Fatalf("message = %q, want action prefix", p.Message)
|
||||
}
|
||||
if !strings.Contains(p.Hint, tt.want) {
|
||||
t.Fatalf("hint = %q, want substring %q", p.Hint, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMarkdownFileAllMissingFileTokenGetsActionPrefix(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"version": "1001",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := uploadMarkdownFileAll(
|
||||
common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create"}, markdownTestConfig(), f, core.AsUser),
|
||||
markdownUploadSpec{ContentSet: true},
|
||||
"README.md",
|
||||
int64(len("# hello\n")),
|
||||
func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("# hello\n")), nil
|
||||
},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if !strings.HasPrefix(p.Message, markdownUploadAllAction+": ") {
|
||||
t.Fatalf("message = %q, want %q prefix", p.Message, markdownUploadAllAction+": ")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMarkdownFileMultipartPrepareAndFinishParseErrorsGetActionPrefix(t *testing.T) {
|
||||
t.Run("prepare", func(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_num": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := uploadMarkdownFileMultipart(
|
||||
common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create"}, markdownTestConfig(), f, core.AsUser),
|
||||
markdownUploadSpec{ContentSet: true},
|
||||
"README.md",
|
||||
int64(len("# hello\n")),
|
||||
func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("# hello\n")), nil
|
||||
},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected prepare parse error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if !strings.HasPrefix(p.Message, markdownUploadPrepareAction+": ") {
|
||||
t.Fatalf("message = %q, want %q prefix", p.Message, markdownUploadPrepareAction+": ")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("finish", func(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_size": float64(8),
|
||||
"block_num": float64(1),
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_part",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_finish",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"version": "1001",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := uploadMarkdownFileMultipart(
|
||||
common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create"}, markdownTestConfig(), f, core.AsUser),
|
||||
markdownUploadSpec{ContentSet: true},
|
||||
"README.md",
|
||||
int64(len("# hello\n")),
|
||||
func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("# hello\n")), nil
|
||||
},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected finish parse error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if !strings.HasPrefix(p.Message, markdownUploadFinishAction+": ") {
|
||||
t.Fatalf("message = %q, want %q prefix", p.Message, markdownUploadFinishAction+": ")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppendMarkdownProblemHintAppendsAndIgnoresBlank(t *testing.T) {
|
||||
err := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithHint("first")
|
||||
appendMarkdownProblemHint(err, "second")
|
||||
appendMarkdownProblemHint(err, " ")
|
||||
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if p.Hint != "first\nsecond" {
|
||||
t.Fatalf("hint = %q, want newline-joined hints", p.Hint)
|
||||
}
|
||||
|
||||
plain := errors.New("plain")
|
||||
if got := appendMarkdownProblemHint(plain, "ignored"); got != plain {
|
||||
t.Fatalf("plain error should pass through unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownOverwriteUploadAllIncludesFileTokenAndVersion(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1303,7 +1663,18 @@ func TestMarkdownOverwriteRejectsEmptyLocalFile(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMarkdownOverwriteMetadataLookupFailure(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1061044,
|
||||
"msg": "parent node not exist",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "log-md-meta-notfound",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownOverwrite, []string{
|
||||
"+overwrite",
|
||||
@@ -1313,6 +1684,19 @@ func TestMarkdownOverwriteMetadataLookupFailure(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected metadata lookup failure")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if p.Code != 1061044 {
|
||||
t.Fatalf("code = %d, want 1061044", p.Code)
|
||||
}
|
||||
if !strings.HasPrefix(p.Message, markdownFetchNameAction+": ") {
|
||||
t.Fatalf("message = %q, want %q prefix", p.Message, markdownFetchNameAction+": ")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "target folder or wiki node still exists") {
|
||||
t.Fatalf("hint = %q, want target guidance", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownOverwriteMissingFileReturnsReadError(t *testing.T) {
|
||||
|
||||
@@ -97,11 +97,13 @@ func RegisterShortcuts(program *cobra.Command, f *cmdutil.Factory) {
|
||||
}
|
||||
|
||||
func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f *cmdutil.Factory) {
|
||||
// Factory.Config may be nil in tests that pass a zero-value factory.
|
||||
// Brand only — never decrypt the app secret at registration time (avoids a
|
||||
// keychain read on every invocation). ConfigBrand may be nil in tests that
|
||||
// pass a zero-value factory.
|
||||
var brand core.LarkBrand
|
||||
if f != nil && f.Config != nil {
|
||||
if cfg, err := f.Config(); err == nil && cfg != nil {
|
||||
brand = cfg.Brand
|
||||
if f != nil && f.ConfigBrand != nil {
|
||||
if b, ok := f.ConfigBrand(); ok {
|
||||
brand = b
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ func newFactoryWithBrand(brand core.LarkBrand) *cmdutil.Factory {
|
||||
Config: func() (*core.CliConfig, error) {
|
||||
return &core.CliConfig{Brand: brand}, nil
|
||||
},
|
||||
// Registration reads the brand via ConfigBrand (no secret decryption).
|
||||
ConfigBrand: func() (core.LarkBrand, bool) {
|
||||
return brand, true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,56 +1,35 @@
|
||||
---
|
||||
name: lark-approval
|
||||
version: 1.0.0
|
||||
description: "飞书审批 API:审批实例、审批任务管理。"
|
||||
version: 1.1.0
|
||||
description: "飞书审批:当前用户审批的查询与全部处理操作,覆盖待本人审批的任务与本人发起的实例。审批待办不是飞书任务(任务类待办走 lark-task);不负责创建审批定义和发起新审批。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli approval --help"
|
||||
---
|
||||
|
||||
# approval (v4)
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
## API Resources
|
||||
所有命令默认 `--as user`(审批是人的动作)。调用前先 `lark-cli schema approval.<resource>.<method>` 查参数结构,不要猜字段。
|
||||
|
||||
## 选哪个命令
|
||||
|
||||
| 想做什么 | 命令 |
|
||||
|---|---|
|
||||
| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读)|
|
||||
| 看表单/进度/当前节点 | `instances get` |
|
||||
| 同意/拒绝 | `tasks approve` / `tasks reject` |
|
||||
| 转交/加签/退回 | `tasks transfer` / `tasks add_sign` / `tasks rollback` |
|
||||
| 催办 | `tasks remind` |
|
||||
| 撤回/抄送/按定义查已发起 | `instances cancel` / `instances cc` / `instances initiated` |
|
||||
|
||||
处理链:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 需要细节再 `instances get` → 执行操作。
|
||||
|
||||
```bash
|
||||
lark-cli schema approval.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
lark-cli approval <resource> <method> [flags] # 调用 API
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
lark-cli approval tasks approve --data '{"instance_code":"<ic>","task_id":"<tid>","comment":"同意"}' --as user
|
||||
```
|
||||
|
||||
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
|
||||
### instances
|
||||
|
||||
- `get` — 获取单个审批实例详情
|
||||
- `cancel` — 撤回审批实例
|
||||
- `cc` — 抄送审批实例
|
||||
- `initiated` — 查询用户的已发起列表
|
||||
|
||||
### tasks
|
||||
|
||||
- `remind` — 催办审批人
|
||||
- `approve` — 同意审批任务
|
||||
- `reject` — 拒绝审批任务
|
||||
- `transfer` — 转交审批任务
|
||||
- `query` — 查询用户的任务列表
|
||||
- `add_sign` — 审批任务加签
|
||||
- `rollback` — 退回审批任务
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `instances.get` | `approval:instance:read` |
|
||||
| `instances.cancel` | `approval:instance:write` |
|
||||
| `instances.cc` | `approval:instance:write` |
|
||||
| `instances.initiated` | `approval:instance:read` |
|
||||
| `tasks.remind` | `approval:instance:write` |
|
||||
| `tasks.approve` | `approval:task:write` |
|
||||
| `tasks.reject` | `approval:task:write` |
|
||||
| `tasks.transfer` | `approval:task:write` |
|
||||
| `tasks.query` | `approval:task:read` |
|
||||
| `tasks.add_sign` | `approval:task:write` |
|
||||
| `tasks.rollback` | `approval:task:write` |
|
||||
## 不在本 skill 范围
|
||||
|
||||
创建审批定义/发起新审批(走飞书客户端或审批管理后台);非审批类待办 → [`lark-task`](../lark-task/SKILL.md)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-calendar
|
||||
version: 1.0.0
|
||||
description: "飞书日历(calendar):提供日历与日程(会议)的全面管理能力。核心场景包括:查看/搜索日程、创建/更新日程、管理参会人、查询忙闲状态及推荐空闲时段、查询/搜索与预定会议室。注意:涉及【预约日程/会议】或【查询/预定会议室】时,必须先读取 references/lark-calendar-schedule-meeting.md 工作流!高频操作请优先使用 Shortcuts:+agenda(快速概览今日/近期行程)、+create(创建日程并按需邀请参会人及预定会议室)、+update(更新既有日程字段,或独立增删参会人/会议室)、+freebusy(查询用户主日历的忙闲信息和rsvp的状态)、+rsvp(回复日程邀请)"
|
||||
description: "飞书日历:管理日历日程和会议室。查看/搜索日程、创建/更新日程、管理参会人、查询忙闲和推荐时段、预定会议室。当用户需要查看日程安排、创建/修改会议、查询/预定会议室时使用。不负责:查询过去的视频会议记录(走 lark-vc)、待办任务(走 lark-task)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -10,93 +10,88 @@ metadata:
|
||||
|
||||
# calendar (v4)
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
**CRITICAL — 所有的 Shortcuts 在执行之前,务必先使用 Read 工具读取其对应的说明文档,禁止直接盲目调用命令。**
|
||||
**CRITICAL — 凡涉及【预约日程/会议】或【查询/搜索会议室】,第一步 MUST 强制使用 Read 工具读取 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)。禁止跳过此步直接调用 API 或 Shortcut!**
|
||||
**CRITICAL — 术语约束:用户日常表达中常说的“帮我约个日历”、“查一下今天的日历”等,其实际意图通常是针对 日程(Event) 的创建或查询,而非操作 日历(Calendar) 容器本身。请自动将口语化的“日历”意图映射为“日程”操作(如 `+create`, `+agenda`)。**
|
||||
**CRITICAL — 会议与日程的意图路由:**
|
||||
- **查询过去时间的会议**:如果用户明确查询过去时间的会议(如“昨天的会议”、“上周的会议”),**优先使用 [`../lark-vc/SKILL.md`](../lark-vc/SKILL.md) 搜索会议记录**。因为会议数据不仅包含从日程发起的视频会议,还包含即时会议,仅查询日程数据会导致结果不全。
|
||||
- **查询日历/日程或未来时间的会议**:如果用户明确表达的是“日历”、“日程”,或者涉及**未来时间**的安排,则属于本技能(lark-calendar)的业务域,请继续使用本技能处理。
|
||||
**CRITICAL — 任务类型分流:处理“预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间”时,必须先判断用户是在“新建日程”还是“编辑已有日程”。**
|
||||
- **编辑已有日程的强信号**:用户明确提到某个已存在的日程锚点(如标题、时间段、`这个日程`、`这场会`)并表达修改动作(如“添加”“移除”“改到”“换会议室”“调整时间”)。这类请求默认走**编辑已有日程**,绝不能直接按新建处理。
|
||||
- **编辑已有日程的前置步骤**:一旦判定为编辑,MUST 先定位目标日程或具体实例的 `event_id`,再继续后续流程。若是重复性日程,MUST 先定位到对应实例的 `event_id`。
|
||||
- **新建日程**:只有当用户表达的是“新约一个会/创建一个日程/安排一次会议”等新增意图,且没有指向某个既有日程的修改动作时,才进入新建流程。
|
||||
开始前先读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)(认证、权限处理)。
|
||||
|
||||
**CRITICAL — 验证与同步延迟:在涉及删除日程(delete)、修改日程(patch)或者涉及添加移除参与人/会议室之后,如果需要进行二次查询验证操作结果,MUST 等待至少 2 秒后再进行查询,以防止因数据同步延迟导致查不到最新数据。注意:不要向用户提及你等待了这 2 秒钟的事情。**
|
||||
**CRITICAL — 凡涉及预约日程/会议或查询/搜索会议室,第一步 MUST 读 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)。禁止跳过此步直接调用 API 或 Shortcut!**
|
||||
|
||||
**CRITICAL — 重复性日程的实例操作:目前已经完全具备对重复性日程的某个具体实例进行操作的能力(例如:编辑某个实例、删除某个实例、为某个实例添加/删除参与人、为某个实例添加/移除会议室)。只要在对应的操作中传递对应实例的 `event_id` 即可。因此,MUST 先定位到对应的那次实例的 `event_id`(可通过 `events search_event` 搜索日程,或 `+agenda` 查看对应时间范围的日程等相关查询获取),绝对禁止直接使用原重复性日程的 `event_id` 进行操作。**
|
||||
## 身份
|
||||
|
||||
**时间与日期推断规范:**
|
||||
为确保准确性,在涉及时间推断时,请严格遵循以下规则:
|
||||
- **星期的定义**:周一是一周的第一天,周日是一周的最后一天。计算`下周一`等相对日期时,务必基于当前真实日期和星期基准进行推算,避免算错日期。
|
||||
- **一天的范围**:当用户提到`明天`、`今天`等泛指某一天时,时间范围应默认覆盖整天时间范围。**切勿**自行缩减查询范围,以免遗漏晚上的时间安排。
|
||||
- **历史时间约束**:不能预约已经完全过去的时间。唯一的例外情况是“跨越当前时间”的日程,即日程的开始时间在过去,但结束时间在未来。
|
||||
日程操作默认使用 `--as user`(查看和管理当前用户的日程)。`--as bot` 只能访问 bot 自己的(空)日历,会拿到空结果——不要用 bot 身份查用户日程。
|
||||
|
||||
## 核心场景
|
||||
```bash
|
||||
# BAD — bot 身份查用户日程,返回空列表
|
||||
lark-cli calendar +agenda --as bot
|
||||
|
||||
### 1. 预约新日程/会议、编辑已有日程、查询/搜索可用会议室
|
||||
**BLOCKING REQUIREMENT (阻塞性要求): 只要用户的意图包含“预约日程/会议”或“查询/搜索可用会议室”,你必须立即停止其他思考,优先使用 Read 工具完整读取 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)!未读取该文件前,绝对禁止执行任何日程创建或会议室查询操作。**
|
||||
**CRITICAL: 必须严格按照上述文档中定义的工作流(Workflow)执行后续操作。处理该场景时,默认做“智能助理”,不要做“表单填写机”。能补全的默认值先补全,只有在时间冲突、结果无法唯一确定、时间语义存在歧义时才主动追问。**
|
||||
**CRITICAL: 执行顺序必须固定为:先判断任务类型(新建/编辑);若为编辑先定位目标日程 `event_id`;再补默认值或继承已定位日程的已知信息;再判断时间是否明确;最后进入“明确时间”或“模糊时间/无时间信息”分支。不要跳步。**
|
||||
**CRITICAL: 明确时间且需要会议室时,先基于最终确定的时间块执行 `+room-find`,再按需执行 `+freebusy`;模糊时间或无时间信息时,先 `+suggestion`,如需会议室再批量 `+room-find`。如果是编辑已有日程且不改时间,只新增会议室,则必须基于已定位日程的原始时间执行 `+room-find`,且最终落地时默认保留已存在的会议室;只有用户明确表达“更换会议室”或“移除会议室”时,才删除原会议室。**
|
||||
**CRITICAL: 当用户说“查会议室”“找会议室”“搜可用会议室”或“推荐常用会议室”时,默认是查会议室可用性,不是查会议室资源名录,更严禁拉取历史日程做统计分析。完整规则以 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) 为准。**
|
||||
**BLOCKING REQUIREMENT: 即使用户的核心诉求是“查会议室”,只要【没有提供明确的起止时间】,绝对禁止直接调用 `+room-find`!必须先进入【无时间/模糊时间】分支,调用 `+suggestion` 拿到候选时间块后,再将时间块传给 `+room-find`。**
|
||||
**BLOCKING REQUIREMENT: 只要面临时间方案或会议室方案的选择(如模糊时间、无时间或需要会议室),在最终执行创建新日程或更新既有日程之前,必须先向用户展示候选方案并等待用户明确确认。绝对禁止擅自替用户做决定。**
|
||||
|
||||
## 核心概念
|
||||
|
||||
- **日历(Calendar)**:日程的容器。每个用户有一个主日历(primary calendar),也可以创建或订阅共享日历。
|
||||
- **日程(Event)**:日历中的单个日程,包含起止时间、地点、标题、参与人等属性。支持单次日程和重复日程,遵循RFC5545 iCalendar国际标准。
|
||||
- ***全天日程(All-day Event)***: 只按日期占用、没有具体起止时刻的日程,结束日期是包含在日程时间内的。
|
||||
- **日程实例(Instance)**:日程的具体时间实例,本质是对日程的展开。普通日程和例外日程对应1个Instance,重复性日程对应N个Instance。在按时间段查询时,可通过实例视图将重复日程展开为独立的实例返回,以便在时间线上准确展示和管理。
|
||||
- **重复规则(Rrule/Recurrence Rule)**:定义重复性日程的重复规则,比如`FREQ=DAILY;UNTIL=20230307T155959Z;INTERVAL=14`表示每14天重复一次。
|
||||
- **例外日程(Exception)**:重复性日程中与原重复性日程不一致的日程。
|
||||
- **参会人(Attendee)**:日程的参与者,可以是用户、群、会议室资源、外部邮箱地址等。每个参与人有独立的RSVP状态。
|
||||
- **响应状态(RSVP)**:参与人对日程邀请的回复状态(接受/拒绝/待定)。
|
||||
- **忙闲时间(FreeBusy)**:查询用户在指定时间段的忙闲状态,用于会议时间协调。
|
||||
- **会议室(Room)**:“room”不是“房间”,是“会议室”。请在理解和处理意图时将“room”和“房间”准确映射为“会议室”及其相关操作。
|
||||
- **时间块(Time Slot / Time Block)**:指一个**具体且确定**的连续时间段(如 `14:00~15:00`)。在文档中,它与泛指的“时间范围/区间”(如“今天下午”、“下周”)有严格区别。在调用预定、查询可用会议室等确切操作时,必须基于确定的“时间块”而非模糊的“时间范围”。
|
||||
|
||||
## 资源关系
|
||||
|
||||
```
|
||||
Calendar (日历)
|
||||
└── Event (日程)
|
||||
├── Attendee (参会人)
|
||||
└── Reminder (提醒)
|
||||
# GOOD — user 身份查日程
|
||||
lark-cli calendar +agenda --as user
|
||||
```
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli calendar +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) |
|
||||
| [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人(ISO 8601 时间) |
|
||||
| [`+update`](references/lark-calendar-update.md) | 更新既有日程字段,或独立增量添加/移除参会人和会议室 |
|
||||
| [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和rsvp的状态 |
|
||||
| [`+room-find`](references/lark-calendar-room-find.md) | 针对一个或多个**明确的**时间块查找可用会议室(**无明确时间时禁止直接调用,需先走 +suggestion**) |
|
||||
| [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和 RSVP 状态 |
|
||||
| [`+room-find`](references/lark-calendar-room-find.md) | 针对一个或多个**明确的**时间块查找可用会议室(无明确时间时禁止直接调用,需先走 +suggestion) |
|
||||
| [`+rsvp`](references/lark-calendar-rsvp.md) | 回复日程(接受/拒绝/待定) |
|
||||
| [`+suggestion`](references/lark-calendar-suggestion.md) | 根据非明确时间或一段时间范围,推荐多个可用时间块方案 |
|
||||
|
||||
## 会议室相关规则
|
||||
## 前置条件路由
|
||||
|
||||
- **会议室是日程的一种参与人(resource attendee),不能脱离日程单独存在或单独预定。**
|
||||
- **凡是用户意图是“预定/查询/搜索可用会议室”时,都必须进入 `references/lark-calendar-schedule-meeting.md` 工作流处理。**
|
||||
- `+room-find` 的时间输入必须是**确定时间块**,不能是时间区间搜索。
|
||||
- **强制约束:如果用户仅要求“查询会议室”但未提供明确时间,必须先调用 `+suggestion` 获取可用时间块,然后再将时间块交给 `+room-find` 批量查询。严禁直接猜测时间并盲目调用 `+room-find`。**
|
||||
- **编辑已有日程时,如果用户表达的是“添加会议室/再加一个会议室”,默认语义是增量添加,必须保留已有会议室;只有在用户明确表达“更换会议室”“把原会议室换掉”“移除会议室”时,才执行旧会议室删除。**
|
||||
| 场景 | 前置要求 |
|
||||
|------|----------|
|
||||
| 预约日程/会议、查会议室 | 先读 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) |
|
||||
| 编辑已有日程 | 先定位目标日程 `event_id`;若是重复性日程,必须定位到具体实例的 `event_id`(禁止使用原重复日程 ID) |
|
||||
| 删除/修改后验证 | 等待 2 秒再查询(API 最终一致性),不要告知用户你等待了 |
|
||||
| 调用任何 Shortcut | 先读其对应 reference 文档 |
|
||||
|
||||
## 核心概念
|
||||
|
||||
- **日程实例(Instance)**:重复性日程展开后的具体时间实例。操作重复日程的某次实例时,必须先定位该实例的 `event_id`,禁止使用原重复日程的 `event_id`。
|
||||
- **全天日程(All-day Event)**:只按日期占用、没有具体起止时刻的日程,结束日期是包含在日程时间内的。
|
||||
- **时间块 vs 时间范围**:时间块是具体确定的连续时间段(如 `14:00~15:00`),时间范围是泛指(如"今天下午")。`+room-find` 必须基于确定时间块,不能基于模糊范围。
|
||||
- **会议室(Room)**:"room"不是"房间",是"会议室"。会议室是日程的一种参与人(resource attendee),不能脱离日程单独预定。
|
||||
|
||||
## 术语映射
|
||||
|
||||
用户日常说的"帮我约个日历""查一下今天的日历",实际意图是针对**日程(Event)**的创建或查询,而非操作日历(Calendar)容器本身。自动将口语化的"日历"意图映射为"日程"操作。
|
||||
|
||||
## 意图路由
|
||||
|
||||
| 用户意图 | 路由到 |
|
||||
|----------|--------|
|
||||
| 查询过去的会议("昨天的会议""上周的会") | [`../lark-vc/SKILL.md`](../lark-vc/SKILL.md)(会议数据含即时会议,仅查日程会遗漏) |
|
||||
| 查询日历/日程或未来时间的会议 | 本 skill |
|
||||
| 预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
|
||||
|
||||
## 任务类型分流
|
||||
|
||||
处理"预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间"时,必须先判断新建 vs 编辑:
|
||||
|
||||
- **编辑已有日程的强信号**:用户提到已存在的日程锚点(标题、时间段、`这个日程`、`这场会`)并表达修改动作(添加、移除、改到、换会议室、调整时间)。默认走编辑流,绝不能按新建处理。
|
||||
- **新建日程**:用户表达新增意图("新约一个会""创建一个日程""安排一次会议"),且没有指向既有日程的修改动作。
|
||||
|
||||
## 时间推断规范
|
||||
|
||||
- **星期的定义**:周一是一周的第一天,周日是最后一天。计算"下周一"等相对日期时,基于当前真实日期推算。
|
||||
- **一天的范围**:用户提到"明天""今天"等泛指某天时,时间范围应覆盖整天,不要自行缩减。
|
||||
- **历史时间约束**:不能预约已经完全过去的时间。唯一例外是"跨越当前时间"的日程(开始在过去、结束在未来)。
|
||||
|
||||
## 会议室规则
|
||||
|
||||
- 凡是"预定/查询/搜索可用会议室",都必须进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md)。
|
||||
- `+room-find` 的时间输入必须是确定时间块,不能是时间区间搜索。
|
||||
- 用户仅要求"查会议室"但未提供明确时间时,必须先调用 `+suggestion` 获取可用时间块,再将时间块交给 `+room-find`。严禁猜测时间盲目调用。
|
||||
- 编辑已有日程时,"添加会议室"默认是增量语义,保留已有会议室;只有用户明确说"更换会议室""移除会议室"时才删除旧会议室。
|
||||
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
lark-cli schema calendar.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
lark-cli calendar <resource> <method> [flags] # 调用 API
|
||||
lark-cli calendar <resource> <method> [flags]
|
||||
```
|
||||
|
||||
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
|
||||
### calendars
|
||||
|
||||
- `create` — 创建共享日历
|
||||
@@ -120,35 +115,18 @@ lark-cli calendar <resource> <method> [flags] # 调用 API
|
||||
- `get` — 获取日程
|
||||
- `instance_view` — 查询日程视图
|
||||
- `patch` — 更新日程
|
||||
- `search_event` — 搜索日程(注:目前只会返回日程id、日程主题、日程时间的信息,需要更多的日程详情,需要走 `events get` 命令)
|
||||
- `search_event` — 搜索日程(仅返回 日程ID/主题/时间,详情需走 `events get`)
|
||||
- `share_info` — 获取日程分享链接
|
||||
|
||||
### freebusys
|
||||
|
||||
- `list` — 查询主日历日程忙闲信息
|
||||
|
||||
## 权限表
|
||||
## 不在本 skill 范围
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `calendars.create` | `calendar:calendar:create` |
|
||||
| `calendars.delete` | `calendar:calendar:delete` |
|
||||
| `calendars.get` | `calendar:calendar:read` |
|
||||
| `calendars.list` | `calendar:calendar:read` |
|
||||
| `calendars.patch` | `calendar:calendar:update` |
|
||||
| `calendars.primary` | `calendar:calendar:read` |
|
||||
| `calendars.search` | `calendar:calendar:read` |
|
||||
| `event.attendees.batch_delete` | `calendar:calendar.event:update` |
|
||||
| `event.attendees.create` | `calendar:calendar.event:update` |
|
||||
| `event.attendees.list` | `calendar:calendar.event:read` |
|
||||
| `events.create` | `calendar:calendar.event:create` |
|
||||
| `events.delete` | `calendar:calendar.event:delete` |
|
||||
| `events.get` | `calendar:calendar.event:read` |
|
||||
| `events.instance_view` | `calendar:calendar.event:read` |
|
||||
| `events.patch` | `calendar:calendar.event:update` |
|
||||
| `events.search_event` | `calendar:calendar.event:read` |
|
||||
| `events.share_info` | `calendar:calendar.event:read` |
|
||||
| `freebusys.list` | `calendar:calendar.free_busy:read` |
|
||||
- 查询过去的视频会议记录 → [lark-vc](../lark-vc/SKILL.md)
|
||||
- 待办任务管理 → [lark-task](../lark-task/SKILL.md)
|
||||
- 会议室物理设施管理 → 管理员后台
|
||||
|
||||
**注意(强制性):**
|
||||
- 涉及日期(时间)字符串与时间戳的相互转换时,务必调用系统命令或脚本代码等外部工具进行处理,以确保转换的绝对准确。违者将导致严重的逻辑错误!
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-doc
|
||||
version: 2.0.0
|
||||
description: "飞书云文档 / Docx / 知识库 Wiki 文档(v2):创建、打开、读取、获取、查看、总结、整理、改写、翻译、审阅和编辑飞书文档内容。当用户给出飞书文档 URL/token,或说查看/读取/打开某个文档、提取文档内容、总结文档、生成/创建文档、追加/替换/删除/移动内容、调整排版、插入或下载文档图片/附件/素材/画板缩略图时使用。文档内容中出现嵌入电子表格、多维表格、需要将重要信息可视化为画板(含 SVG 画板)、引用或同步块时,也先用本 skill 读取和提取 token,再切到对应 skill 下钻。默认使用 DocxXML,也支持 Markdown。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。"
|
||||
description: "飞书云文档(Docx / Wiki 文档,v2 API):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Base 的数据操作。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -10,7 +10,9 @@ metadata:
|
||||
|
||||
# docs (v2)
|
||||
|
||||
> **⚠️ API 版本:本 skill 使用 v2 API。所有 `docs +create --api-version v2`、`docs +fetch --api-version v2`、`docs +update --api-version v2` 命令必须携带 `--api-version v2`。**
|
||||
**身份:文档操作默认使用 `--as user`。首次使用前执行 `lark-cli auth login`。**
|
||||
|
||||
> **CRITICAL — API 版本:本 skill 使用 v2 API。执行 `docs +create`、`docs +fetch`、`docs +update` 时必须显式传入 `--api-version v2`。**
|
||||
|
||||
```bash
|
||||
# 常用示例
|
||||
@@ -69,3 +71,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)
|
||||
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
|
||||
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |
|
||||
| [`+whiteboard-update`](../lark-whiteboard/references/lark-whiteboard-update.md) | Alias of `whiteboard +update`. Update an existing whiteboard with DSL, Mermaid or PlantUML. Prefer `whiteboard +update`; refer to lark-whiteboard skill for details. |
|
||||
|
||||
## 不在本 Skill 范围
|
||||
|
||||
- 文档评论管理 → [`lark-drive`](../lark-drive/SKILL.md)
|
||||
- 电子表格或 Base 的数据操作 → [`lark-sheets`](../lark-sheets/SKILL.md) / [`lark-base`](../lark-base/SKILL.md)
|
||||
- 云空间文件上传、下载、权限管理 → [`lark-drive`](../lark-drive/SKILL.md)
|
||||
|
||||
@@ -32,6 +32,8 @@ metadata:
|
||||
- 用户要获取某个文件的封面图,优先使用 `lark-cli drive +cover`;先 `--list-only` 看规格,再选 `--spec` 下载。
|
||||
- 用户要把本地文件上传到知识库 / 文档库里的某个 wiki 节点下时,仍然使用 `lark-cli drive +upload --wiki-token <wiki_token>`;不要误切到 `wiki` 域命令。
|
||||
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。
|
||||
- 用户给的是 wiki URL / token,且后续还没明确底层资源类型时,先用 `lark-cli drive +inspect` 解包;`+inspect` 失败后不要自动切到别的写接口继续尝试,先按错误提示处理权限、scope 或链接问题。
|
||||
- `drive +inspect` / `drive +upload` 遇到 `not found`、`permission denied`、`missing scope` 时,默认停止重试;只有 `rate limit` 或临时网络错误才适合有限重试。
|
||||
|
||||
## 修改标题
|
||||
- 使用 `drive files patch` 命令,通过new_title字段可以修改标题,支持 docx、sheet、bitable、file、wiki、folder 类型
|
||||
|
||||
@@ -69,7 +69,7 @@ wait
|
||||
|
||||
### stdin EOF = graceful exit
|
||||
|
||||
`event consume` treats stdin close as a shutdown signal (wired for AI subprocess callers). `< /dev/null` / `nohup` / systemd's default `StandardInput=null` will cause an immediate graceful exit (stderr `reason: signal`). To keep running:
|
||||
`event consume` treats stdin close as a shutdown signal (wired for AI subprocess callers). **Bounded runs are exempt: when `--max-events` or `--timeout` is set (> 0), stdin EOF is ignored and the run exits only via its own bound, timeout, or SIGTERM.** For unbounded runs, `< /dev/null` / `nohup` / systemd's default `StandardInput=null` will cause an immediate graceful exit (stderr `reason: signal`). To keep an unbounded run alive:
|
||||
|
||||
- Feed stdin a source that never EOFs: `< <(tail -f /dev/null)`
|
||||
- Or run bounded: `--max-events N` / `--timeout D`
|
||||
@@ -82,7 +82,7 @@ On exit, the last stderr line is `[event] exited — received N event(s) in Xs (
|
||||
|---|---|---|
|
||||
| 0 | `reason: limit` | `--max-events` reached |
|
||||
| 0 | `reason: timeout` | `--timeout` reached |
|
||||
| 0 | `reason: signal` | Ctrl+C / SIGTERM / stdin EOF |
|
||||
| 0 | `reason: signal` | Ctrl+C / SIGTERM / stdin EOF (stdin EOF applies to unbounded runs only) |
|
||||
| non-0 | `Error: ...` (no `exited` line) | Startup / runtime failure (permissions, network, params, config) |
|
||||
|
||||
Orchestrators should treat `reason: limit/timeout/signal` (all exit 0) as "business completion" and non-zero as "failure".
|
||||
|
||||
@@ -15,6 +15,7 @@ metadata:
|
||||
## 快速决策
|
||||
|
||||
- 身份:Markdown 文件通常属于用户云空间资源,优先使用 `--as user`。如为自动化场景,或应用已创建并持有目标文件权限,可按场景使用 `--as bot`。首次以 `user` 身份访问前执行 `lark-cli auth login`
|
||||
- `markdown +create` / `+overwrite` 失败时,先判断是不是身份和权限问题:`bot` 更常见的是 app scope 或目标目录 ACL,`user` 更常见的是用户授权或用户 ACL;不要不加判断地来回切身份重试。
|
||||
|
||||
- 用户要**上传、创建一个原生 `.md` 文件**,使用 `lark-cli markdown +create`
|
||||
- 用户要**比较原生 `.md` 文件的历史版本差异**,或比较远端 Markdown 与本地草稿,使用 `lark-cli markdown +diff`
|
||||
@@ -24,6 +25,7 @@ metadata:
|
||||
- 用户要先拿 Markdown 文件的历史版本号,再做比较/下载/回滚,先用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +version-history`
|
||||
- 用户要把本地 Markdown **导入成在线新版文档(docx)**,不要用本 skill,改用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +import --type docx`
|
||||
- 用户要对 Markdown 文件做**rename / move / delete / 搜索 / 权限 / 评论**等云空间(云盘/云存储)操作,不要留在本 skill,切到 [`lark-drive`](../lark-drive/SKILL.md)
|
||||
- `markdown +create` / `+overwrite` 命中 `missing scope`、`permission denied`、`not found`、`version limit` 时,默认停止重试并按报错 hint 处理;只有 `rate limit` 或临时网络错误才做有限重试。
|
||||
|
||||
## 核心边界
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-minutes
|
||||
version: 1.0.0
|
||||
description: "飞书妙记:妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围);2.获取妙记基础信息(标题、封面、时长 等);3.下载妙记音视频文件;4.获取妙记相关 AI 产物(总结、待办、章节);5.上传音视频生成妙记,也支持将本地音视频文件转成纪要、逐字稿、文字稿、撰写文字等产物;6.更新妙记标题(重命名妙记);7.替换妙记逐字稿中的说话人。遇到这类请求时,应优先使用本 skill。飞书妙记 URL 格式: http(s)://<host>/minutes/<minute-token>"
|
||||
description: "飞书妙记:搜索妙记列表、查看妙记基础信息、下载妙记音视频文件、上传音视频生成妙记、更新妙记标题、替换说话人。当需要获取、操作或者生成妙记时使用。也支持将本地音视频文件转成纪要和逐字稿(优先使用本 skill,不要用 ffmpeg/whisper 本地转写)。不负责:获取会议关联妙记、纪要/逐字稿内容获取走 lark-vc"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -18,10 +18,40 @@ metadata:
|
||||
> 3. 了解不同会议产物的组成部分,以便根据需求决策使用哪种产物的数据
|
||||
> 4. 了解会议总结、分析和信息提取的标准流程
|
||||
|
||||
## 身份
|
||||
|
||||
所有 minutes 命令默认使用 `--as user`。
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+search`](references/lark-minutes-search.md) | 按关键词、所有者、参与者、时间范围搜索妙记 |
|
||||
| [`+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,不支持姓名) |
|
||||
|
||||
- 使用任何 Shortcut 前,必须先读其对应 reference 文档。
|
||||
|
||||
## 意图路由
|
||||
|
||||
| 用户意图 | 路由到 |
|
||||
|----------|--------|
|
||||
| "我的妙记""搜索妙记""妙记列表" | 本 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 |
|
||||
|
||||
## 核心概念
|
||||
|
||||
- **妙记(Minutes)**:来源于飞书视频会议的录制产物或用户上传的音视频文件,通过 `minute_token` 标识。
|
||||
- **妙记 Token(minute\_token)**:妙记的唯一标识符,可从妙记 URL 末尾提取(例如 `https://*.feishu.cn/minutes/obcnxxxxxxxxxxxxxxxxxxxx` 中的 `obcnxxxxxxxxxxxxxxxxxxxx`)。如果 URL 中包含额外参数(如 `?xxx`),应截取路径最后一段。
|
||||
- **妙记 Token(minute_token)**:妙记的唯一标识符,可从妙记 URL 末尾提取(如 `https://*.feishu.cn/minutes/obcnxxx` 中的 `obcnxxx`)。如果 URL 中包含额外参数(如 `?xxx`),截取路径最后一段。
|
||||
|
||||
## 核心场景
|
||||
|
||||
@@ -30,7 +60,7 @@ metadata:
|
||||
1. 当用户描述的是"我的妙记""包含某个关键词的妙记""某段时间内的妙记",优先使用 `minutes +search`。
|
||||
2. 仅支持使用关键词、时间段、参与者、所有者等筛选条件搜索妙记记录,对于不支持的筛选条件,需要提示用户。
|
||||
3. 搜索结果存在多条数据时,务必注意分页数据获取,不要遗漏任何妙记记录。
|
||||
4. 如果是会议的妙记,应优先使用 [vc +search](../lark-vc/references/lark-vc-search.md) 先定位会议,再按需通过 [vc +recording](../lark-vc/references/lark-vc-recording.md) 获取 `minute_token`。
|
||||
4. 如果是会议的妙记,应优先通过 [lark-vc](../lark-vc/SKILL.md) 定位会议并获取 `minute_token`。
|
||||
5. 会议场景的妙记路由,以及"参与的妙记"如何解释,统一以 [minutes +search](references/lark-minutes-search.md) 为准。
|
||||
|
||||
|
||||
@@ -46,7 +76,7 @@ metadata:
|
||||
### 3. 下载妙记音视频文件
|
||||
|
||||
1. 下载妙记音视频文件到本地,或获取有效期 1 天的下载链接。详见 [minutes +download](references/lark-minutes-download.md)。
|
||||
2. `minutes +download` 只负责音视频媒体文件。
|
||||
2. `+download` 只负责音视频媒体文件。用户需要逐字稿、总结、待办、章节等纪要内容时,请使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)。
|
||||
3. 用户只想拿可分享的下载地址时,使用 `--url-only`;用户要落地到本地文件时,直接下载。
|
||||
4. 未显式指定路径时,文件默认落到 `./minutes/{minute_token}/<server-filename>`,与 `vc +notes` 的逐字稿共享同一目录便于聚合。
|
||||
|
||||
@@ -107,49 +137,20 @@ Minutes (妙记) ← minute_token 标识
|
||||
> - 用户说"重命名妙记 / 改妙记标题 / 修改妙记名字" → `minutes +update`
|
||||
> - 用户说"替换说话人 / 把 A 的发言改成 B / 重新归属发言人" → `minutes +speaker-replace`
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli minutes +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
| 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) |
|
||||
|
||||
- 使用 `+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,不支持姓名)。
|
||||
|
||||
<!-- AUTO-GENERATED-START — gen-skills.py 管理,勿手动编辑 -->
|
||||
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
lark-cli schema minutes.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
lark-cli minutes <resource> <method> [flags] # 调用 API
|
||||
lark-cli minutes <resource> <method> [flags]
|
||||
```
|
||||
|
||||
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
|
||||
### minutes
|
||||
|
||||
- `get` — 获取妙记信息
|
||||
|
||||
> **权限错误**:如果返回 `[2091005] permission deny`,表示用户没有对应妙记文件的阅读权限,需提示用户联系妙记 owner 申请权限。
|
||||
|
||||
## 权限表
|
||||
## 不在本 skill 范围
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
| ------------- | ------------------------------ |
|
||||
| `+search` | `minutes:minutes.search:read` |
|
||||
| `minutes.get` | `minutes:minutes:readonly` |
|
||||
| `+download` | `minutes:minutes.media:export` |
|
||||
| `+update` | `minutes:minutes:update` |
|
||||
| `+speaker-replace` | `minutes:minutes:update` |
|
||||
|
||||
<!-- AUTO-GENERATED-END -->
|
||||
- 纪要/逐字稿/总结/待办/章节内容获取 → [lark-vc](../lark-vc/SKILL.md)(`vc +notes --minute-tokens`)
|
||||
- 搜索历史会议记录 → [lark-vc](../lark-vc/SKILL.md)
|
||||
- 查询未来的会议日程 → [lark-calendar](../lark-calendar/SKILL.md)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-slides
|
||||
version: 1.0.0
|
||||
description: "飞书幻灯片:创建和编辑幻灯片,接口通过 XML 协议通信。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。"
|
||||
description: "飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。不负责:云文档内容编辑(走 lark-doc)、云文档里的独立画板对象(走 lark-whiteboard,注意 slide 内嵌的流程图/架构图仍属本 skill)、上传或下载普通文件(走 lark-drive)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -25,7 +25,7 @@ metadata:
|
||||
| 用户提到模板、主题、版式 | 先检索模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
|
||||
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md`、`validation-checklist.md` |
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),认证、权限和全局参数均以 lark-shared 为准。**
|
||||
|
||||
**CRITICAL — 生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
|
||||
|
||||
@@ -267,12 +267,14 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
|
||||
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
|
||||
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
|
||||
|
||||
没有 Shortcut 覆盖时使用原生 API。高频资源:`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。
|
||||
|
||||
```bash
|
||||
lark-cli schema slides.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
```
|
||||
|
||||
原生 API 高频资源:`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜字段。
|
||||
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
|
||||
## 核心规则
|
||||
|
||||
@@ -285,17 +287,4 @@ lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
7. **编辑已有页面优先块级替换**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;只有需要替换整页结构时才用 `slide.delete` + `slide.create`
|
||||
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。
|
||||
|
||||
## 权限速查
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `slides +create` | `slides:presentation:create`, `slides:presentation:write_only`(含 `@` 占位符时还需 `docs:document.media:upload`) |
|
||||
| `slides +media-upload` | `docs:document.media:upload`(wiki URL 解析还需 `wiki:node:read`) |
|
||||
| `slides +replace-slide` | `slides:presentation:update`(wiki URL 解析还需 `wiki:node:read`) |
|
||||
| `xml_presentations.get` | `slides:presentation:read` |
|
||||
| `xml_presentation.slide.create` | `slides:presentation:update` 或 `slides:presentation:write_only` |
|
||||
| `xml_presentation.slide.delete` | `slides:presentation:update` 或 `slides:presentation:write_only` |
|
||||
| `xml_presentation.slide.get` | `slides:presentation:read` |
|
||||
| `xml_presentation.slide.replace` | `slides:presentation:update` |
|
||||
|
||||
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-vc
|
||||
version: 1.0.0
|
||||
description: "飞书视频会议:搜索历史会议、查询会议纪要产物(总结、待办、章节、逐字稿)、查询会议参会人快照。1. 查询已经结束的会议数量或详情时使用本技能(如历史日期|昨天|上周|今天已经开过的会议等场景),查询未开始的会议日程使用 lark-calendar 技能。2. 支持通过关键词、时间范围、组织者、参与者、会议室等筛选条件搜索会议。3. 获取或整理会议纪要、逐字稿、录制产物时使用本技能。4. 查询“谁参加过某会议”“参会人列表”等参会人快照信息用 vc meeting get --with-participants(任意时点可查,含已结束会议)。注意:**Agent 真实入会/离会、感知正在进行中会议的实时事件**请使用 lark-vc-agent 技能,本技能不覆盖写操作和会中事件流。"
|
||||
description: "飞书视频会议:搜索历史会议记录、查询会议纪要(总结/待办/章节/逐字稿)、查询参会人快照。当用户查询已结束的会议、获取会议产物(纪要/妙记)、查看参会人时使用;查询未来日程走 lark-calendar。不负责:Agent 真实入会/离会、会中实时事件(走 lark-vc-agent)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -18,15 +18,59 @@ metadata:
|
||||
> 3. 了解不同会议产物的组成部分,以便根据需求决策使用哪种产物的数据
|
||||
> 4. 了解会议总结、分析和信息提取的标准流程
|
||||
|
||||
## 身份
|
||||
|
||||
所有 vc 命令默认使用 `--as user`。`+search` 和 `meeting get` 也支持 `--as bot`。
|
||||
|
||||
```bash
|
||||
# BAD — 查昨天的会议用 calendar,会漏掉即时会议
|
||||
lark-cli calendar events search_event --query "站会" --start-time ...
|
||||
|
||||
# GOOD — 查已结束的会议用 vc +search
|
||||
lark-cli vc +search --query "站会" --start-time ...
|
||||
```
|
||||
|
||||
## Shortcuts (推荐优先使用)
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+search`](references/lark-vc-search.md) | 搜索历史会议记录(需至少一个筛选条件) |
|
||||
| [`+notes`](references/lark-vc-notes.md) | 查询会议纪要和妙记产物(通过 meeting-ids、minute-tokens 或 calendar-event-ids) |
|
||||
| [`+recording`](references/lark-vc-recording.md) | 通过 meeting-ids 或 calendar-event-ids 查询 minute_token |
|
||||
|
||||
- 使用任何 Shortcut 前,必须先读其对应 reference 文档。
|
||||
|
||||
## 意图路由
|
||||
|
||||
| 用户意图 | 路由到 |
|
||||
|----------|--------|
|
||||
| 查"昨天的会议""上周的会""已结束的会议" | 本 skill(`+search`,含即时会议) |
|
||||
| 查日历/日程或未来时间的会议 | [lark-calendar](../lark-calendar/SKILL.md) |
|
||||
| 查"今天有哪些会议" | `vc +search`(已结束)+ lark-calendar(未开始),合并展示 |
|
||||
| Agent 真实入会/离会、会中实时事件 | [lark-vc-agent](../lark-vc-agent/SKILL.md) |
|
||||
| 本地音视频文件转纪要/逐字稿 | 先走 [lark-minutes](../lark-minutes/SKILL.md) 上传,再回 `vc +notes --minute-tokens` |
|
||||
|
||||
## 核心概念
|
||||
|
||||
- **视频会议(Meeting)**:飞书视频会议实例,通过 meeting_id 标识。已结束的会议支持通过关键词、时间段、参会人、组织者、会议室等条件搜索(见 `+search`)。
|
||||
- **会议纪要(Note)**:视频会议结束后生成的结构化文档,包含纪要文档(包含总结、待办)和逐字稿文档。
|
||||
- **妙记(Minutes)**:来源于飞书视频会议的录制产物或用户上传的音视频文件,支持视频/音频的转写,包含总结、待办、章节和文字记录,通过 minute_token 标识。
|
||||
- **视频会议(Meeting)**:飞书视频会议实例,通过 meeting_id 标识。已结束的会议支持通过关键词、时间段、参会人、组织者、会议室等条件搜索。
|
||||
- **会议纪要(Note)**:视频会议结束后生成的结构化文档,包含纪要文档(总结+待办)和逐字稿文档。
|
||||
- **妙记(Minutes)**:来源于飞书视频会议的录制产物或用户上传的音视频文件,包含总结、待办、章节和文字记录,通过 minute_token 标识。
|
||||
- **纪要文档(MainDoc)**:AI 智能纪要的主文档,包含 AI 生成的总结和待办,对应 `note_doc_token`。
|
||||
- **用户会议纪要(MeetingNotes)**:用户主动绑定到会议的纪要文档,对应 `meeting_notes`。仅通过 `--calendar-event-ids` 路径返回。
|
||||
- **逐字稿(VerbatimDoc)**:会议的逐句文字记录,包含说话人和时间戳。
|
||||
|
||||
## 产物选择决策
|
||||
|
||||
| 用户意图 | 必须读取的产物 | 禁止 |
|
||||
|---------|-------------|------|
|
||||
| 提炼/总结/重新总结/整理会议内容/回顾会议 | 逐字稿(`verbatim_doc_token`)或妙记文字记录(Transcript),基于原始对话独立分析 | 禁止直接搬运 AI 纪要(`note_doc_token`)的总结作为最终输出 |
|
||||
| 查看待办/章节 | AI 纪要(`note_doc_token`)或妙记产物 — AI 待办更友好(含提出人和负责人),章节按话题划分更结构化 | — |
|
||||
| 查看纪要链接/文档地址 | 仅返回文档链接,无需读取内容 | — |
|
||||
| 直接看 AI 总结结果 | AI 纪要(`note_doc_token`) | — |
|
||||
| 谁说了什么/完整发言记录 | 逐字稿(`verbatim_doc_token`) | — |
|
||||
|
||||
> **为什么"提炼/总结"必须从逐字稿出发?** AI 纪要是模型对会议的二次压缩,可能遗漏讨论细节、争论过程和隐含决策。用户要求"提炼"或"重新总结"时,期望的是基于原始对话的独立分析,而非对 AI 产物的重新排版。
|
||||
|
||||
## 核心场景
|
||||
|
||||
### 1. 搜索会议记录
|
||||
@@ -36,23 +80,12 @@ metadata:
|
||||
|
||||
### 2. 整理会议纪要
|
||||
|
||||
> ⚠️ 在选择读取哪个产物前,请先确认你理解 AI 总结链路 vs 录制链路的区别。如不确定,先读 [`references/vc-domain-boundaries.md`](references/vc-domain-boundaries.md) 的「两条链路的独立性」章节。
|
||||
|
||||
**⚠️ 产物选择决策 — 根据用户意图严格区分:**
|
||||
|
||||
| 用户意图 | 必须读取的产物 | 禁止 |
|
||||
|---------|-------------|------|
|
||||
| **提炼/总结/重新总结/整理会议内容/回顾会议** | 逐字稿(`verbatim_doc_token`)或妙记文字记录(Transcript),基于原始对话独立分析 | 禁止直接搬运 AI 纪要(`note_doc_token`)的总结作为最终输出|
|
||||
| **查看待办/章节** | AI 纪要(`note_doc_token`)或妙记产物 — AI 待办更友好(含提出人和负责人),章节按话题划分更结构化 | — |
|
||||
| **查看纪要链接/文档地址** | 仅返回文档链接,无需读取内容 | — |
|
||||
| **直接看 AI 总结结果** | AI 纪要(`note_doc_token`) | — |
|
||||
| **谁说了什么/完整发言记录** | 逐字稿(`verbatim_doc_token`) | — |
|
||||
|
||||
> **为什么"提炼/总结"必须从逐字稿出发?** AI 纪要是模型对会议的二次压缩,可能遗漏讨论细节、争论过程和隐含决策。用户要求"提炼"或"重新总结"时,期望的是基于原始对话的独立分析,而非对 AI 产物的重新排版。AI 纪要可作为补充参考,但不能作为唯一信息源。
|
||||
> 在选择读取哪个产物前,先确认你理解 AI 总结链路 vs 录制链路的区别。如不确定,先读 [`references/vc-domain-boundaries.md`](references/vc-domain-boundaries.md)。
|
||||
|
||||
1. 整理纪要文档时默认给出纪要文档、逐字稿、妙记链接即可,无需读取纪要文档或逐字稿内容。
|
||||
2. 用户明确需要获取总结、待办、章节产物时,再读取文档获取具体内容。
|
||||
3. 读取智能纪要(`note_doc_token`)内容时,纪要文档的**第一个 `<whiteboard>`** 标签是封面图(AI 生成的总结可视化),应同时下载展示给用户:
|
||||
|
||||
```bash
|
||||
# 1. 读取纪要内容
|
||||
lark-cli docs +fetch --api-version v2 --doc <note_doc_token> --doc-format markdown
|
||||
@@ -121,70 +154,31 @@ Meeting (视频会议)
|
||||
└── Keywords (推荐关键词)
|
||||
```
|
||||
|
||||
> **注意**:`+search` 只能查询已结束的历史会议。查询未来的日程安排请使用 [lark-calendar](../lark-calendar/SKILL.md)。
|
||||
>
|
||||
> **优先级**:当用户搜索历史会议时,应优先使用 `vc +search` 而非 `calendar events search`。calendar 的搜索面向日程,vc 的搜索面向已结束的会议记录,支持按参会人、组织者、会议室等维度过滤。
|
||||
>
|
||||
> **路由规则**:如果用户在问“开过的会”“今天开了哪些会”“最近参加过什么会”“已结束的会议”“历史会议记录”,优先使用 `vc +search`。只有在查询未来日程、待开的会、agenda 时才优先使用 [lark-calendar](../lark-calendar/SKILL.md)。
|
||||
>
|
||||
> **妙记边界**:`+notes` 负责纪要内容、逐字稿和 AI 产物;妙记基础信息请优先看 [`+recording`](references/lark-vc-recording.md) 与 [lark-minutes](../lark-minutes/SKILL.md)。
|
||||
>
|
||||
> **文件转纪要边界**:如果用户给的是本地音视频文件,并希望得到纪要、逐字稿、总结、待办或章节,入口应先走 [lark-minutes](../lark-minutes/SKILL.md) 的上传流程生成 `minute_url` / `minute_token`,再回到 `vc +notes --minute-tokens` 获取内容产物。
|
||||
>
|
||||
> **特殊情况**: 当用户查询“今天有哪些会议”时,通过 `vc +search` 查询今天开过的会议记录,同时使用 lark-calendar 技能查询今天还未开始的会议,统一整理后展示给用户。
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+search`](references/lark-vc-search.md) | Search meeting records (requires at least one filter) |
|
||||
| [`+notes`](references/lark-vc-notes.md) | Query meeting notes and minutes (via meeting-ids, minute-tokens, or calendar-event-ids) |
|
||||
| [`+recording`](references/lark-vc-recording.md) | Query minute_token from meeting-ids or calendar-event-ids |
|
||||
|
||||
- 使用 `+search` 命令时,必须阅读 [references/lark-vc-search.md](references/lark-vc-search.md),了解搜索参数和返回值结构。
|
||||
- 使用 `+notes` 命令时,必须阅读 [references/lark-vc-notes.md](references/lark-vc-notes.md),了解查询参数、产物类型和返回值结构。
|
||||
- 使用 `+recording` 命令时,必须阅读 [references/lark-vc-recording.md](references/lark-vc-recording.md),了解查询参数和返回值结构。
|
||||
|
||||
> **Agent 参会相关命令已独立**:`+meeting-join` / `+meeting-leave` / `+meeting-events` 请使用 [`lark-vc-agent`](../lark-vc-agent/SKILL.md) 技能。
|
||||
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
lark-cli schema vc.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
lark-cli vc <resource> <method> [flags] # 调用 API
|
||||
lark-cli vc <resource> <method> [flags]
|
||||
```
|
||||
|
||||
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
|
||||
### meeting
|
||||
|
||||
- `get` — 获取会议详情(主题、时间、参会人、note_id)
|
||||
|
||||
```bash
|
||||
# 获取会议基础信息:不包含参会人列表
|
||||
# 获取会议基础信息(不含参会人)
|
||||
lark-cli vc meeting get --params '{"meeting_id": "<meeting_id>"}'
|
||||
|
||||
|
||||
# 获取会议基础信息:包含参会人列表
|
||||
# 获取会议基础信息(含参会人)
|
||||
lark-cli vc meeting get --params '{"meeting_id": "<meeting_id>", "with_participants": true}'
|
||||
```
|
||||
|
||||
### minutes(跨域,详见 [lark-minutes](../lark-minutes/SKILL.md))
|
||||
|
||||
- `get` — 获取妙记基础信息(标题、时长、封面);查询纪要**内容**请用 `+notes --minute-tokens <minute-token>`
|
||||
- `get` — 获取妙记基础信息(标题、时长、封面);查询妙记**内容**请用 `+notes --minute-tokens <minute-token>`
|
||||
|
||||
## 权限表
|
||||
## 不在本 skill 范围
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `+notes --meeting-ids` | `vc:meeting.meetingevent:read`、`vc:note:read`、 `vc:record:readonly` |
|
||||
| `+notes --minute-tokens` | `vc:note:read`、`minutes:minutes:readonly`、`minutes:minutes.artifacts:read`、`minutes:minutes.transcript:export` |
|
||||
| `+notes --calendar-event-ids` | `calendar:calendar:read`、`calendar:calendar.event:read`、`vc:meeting.meetingevent:read`、`vc:note:read`、 `vc:record:readonly` |
|
||||
| `+recording --meeting-ids` | `vc:record:readonly` |
|
||||
| `+recording --calendar-event-ids` | `vc:record:readonly`、`calendar:calendar:read`、`calendar:calendar.event:read` |
|
||||
| `+search` | `vc:meeting.search:read` |
|
||||
| `meeting.get` | `vc:meeting.meetingevent:read` |
|
||||
|
||||
> Agent 参会相关 scope(`vc:meeting.bot.join:write` / `vc:meeting.meetingevent:read`)见 [`lark-vc-agent`](../lark-vc-agent/SKILL.md)。
|
||||
- 查询未来的会议日程 → [lark-calendar](../lark-calendar/SKILL.md)
|
||||
- Agent 真实入会/离会、会中实时事件 → [lark-vc-agent](../lark-vc-agent/SKILL.md)
|
||||
- 本地音视频文件转纪要/逐字稿 → [lark-minutes](../lark-minutes/SKILL.md)(上传后回 `vc +notes`)
|
||||
- 妙记搜索/下载/上传/重命名/替换说话人 → [lark-minutes](../lark-minutes/SKILL.md)
|
||||
|
||||
Reference in New Issue
Block a user