mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 08:12:36 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdae999541 | ||
|
|
36ff632a13 | ||
|
|
ab94ee9f54 | ||
|
|
30327abacb | ||
|
|
70081f62b1 | ||
|
|
17cbc13fcb | ||
|
|
e98471ce26 | ||
|
|
9e2be14301 | ||
|
|
367cfc9d06 | ||
|
|
e182b01f68 | ||
|
|
1135fc2767 | ||
|
|
68d78d5067 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,5 @@
|
||||
# Build output
|
||||
/lark-cli
|
||||
/lark-cli*
|
||||
.cache/
|
||||
dist/
|
||||
bin/
|
||||
|
||||
25
CHANGELOG.md
25
CHANGELOG.md
@@ -2,6 +2,30 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.42] - 2026-05-27
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: Add `+draft-send` shortcut for batch draft sending (#1017)
|
||||
- **im**: Enrich messages with reactions and output `update_time` (#1095)
|
||||
- **schema**: Output JSON spec envelope for all API commands (#1048)
|
||||
- **event**: Support `vc` / `note` / `minute` events (#1113)
|
||||
- **drive**: Add secure label shortcuts (#985)
|
||||
- **affordance**: Use description and command in affordance example schema (#1126)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **docs**: Remove unsupported `fetch` text format (#1109)
|
||||
|
||||
### Refactor
|
||||
|
||||
- **auth**: Drop duplicate top-level user fields in `status` (#1128)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Document block anchor URLs in `lark-doc` skill (#1120)
|
||||
- **whiteboard**: Improve SVG/Mermaid instructions (#1097)
|
||||
|
||||
## [v1.0.41] - 2026-05-26
|
||||
|
||||
### Features
|
||||
@@ -886,6 +910,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.42]: https://github.com/larksuite/cli/releases/tag/v1.0.42
|
||||
[v1.0.41]: https://github.com/larksuite/cli/releases/tag/v1.0.41
|
||||
[v1.0.40]: https://github.com/larksuite/cli/releases/tag/v1.0.40
|
||||
[v1.0.39]: https://github.com/larksuite/cli/releases/tag/v1.0.39
|
||||
|
||||
@@ -61,7 +61,6 @@ func authStatusRun(opts *StatusOptions) error {
|
||||
diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify)
|
||||
result["identities"] = diagnostics
|
||||
result["identity"] = effectiveIdentity(diagnostics)
|
||||
addLegacyUserFields(result, diagnostics.User)
|
||||
addEffectiveVerification(result, diagnostics)
|
||||
addStatusNote(result, diagnostics)
|
||||
|
||||
@@ -86,29 +85,6 @@ func effectiveIdentity(d identitydiag.Result) string {
|
||||
}
|
||||
}
|
||||
|
||||
func addLegacyUserFields(result map[string]interface{}, user identitydiag.Identity) {
|
||||
if user.OpenID == "" {
|
||||
return
|
||||
}
|
||||
result["userName"] = user.UserName
|
||||
result["userOpenId"] = user.OpenID
|
||||
if user.TokenStatus != "" {
|
||||
result["tokenStatus"] = user.TokenStatus
|
||||
}
|
||||
if user.Scope != "" {
|
||||
result["scope"] = user.Scope
|
||||
}
|
||||
if user.ExpiresAt != "" {
|
||||
result["expiresAt"] = user.ExpiresAt
|
||||
}
|
||||
if user.RefreshExpiresAt != "" {
|
||||
result["refreshExpiresAt"] = user.RefreshExpiresAt
|
||||
}
|
||||
if user.GrantedAt != "" {
|
||||
result["grantedAt"] = user.GrantedAt
|
||||
}
|
||||
}
|
||||
|
||||
func addEffectiveVerification(result map[string]interface{}, d identitydiag.Result) {
|
||||
switch result["identity"] {
|
||||
case identityUser:
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -24,7 +25,8 @@ type SchemaOptions struct {
|
||||
Ctx context.Context
|
||||
|
||||
// Positional args
|
||||
Path string
|
||||
Path string // first positional, when only one is given
|
||||
ExtraArgs []string // 2nd+ positional args (space-separated form)
|
||||
|
||||
// Flags
|
||||
Format string
|
||||
@@ -359,13 +361,16 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
opts := &SchemaOptions{Factory: f}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "schema [path]",
|
||||
Use: "schema [path | service resource method]",
|
||||
Short: "View API method parameters, types, and scopes",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Args: cobra.MaximumNArgs(8),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
opts.Path = args[0]
|
||||
}
|
||||
if len(args) > 1 {
|
||||
opts.ExtraArgs = args[1:]
|
||||
}
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
@@ -380,60 +385,108 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// completeSchemaPath provides tab-completion for the schema path argument.
|
||||
// It handles dotted resource names (e.g. app.table.fields) by iterating all
|
||||
// resources and classifying each as a prefix-match or fully-matched.
|
||||
// It handles both legacy dotted resource names (e.g. app.table.fields) and the
|
||||
// newer space-separated form (e.g. `schema im messages reply`).
|
||||
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) > 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
mode := f.ResolveStrictMode(cmd.Context())
|
||||
|
||||
parts := strings.Split(toComplete, ".")
|
||||
|
||||
// Level 1: complete service names
|
||||
if len(parts) <= 1 {
|
||||
var completions []string
|
||||
for _, s := range registry.ListFromMetaProjects() {
|
||||
if strings.HasPrefix(s, toComplete) {
|
||||
completions = append(completions, s+".")
|
||||
// Case 1: legacy "single dotted arg" path — no previous args yet
|
||||
if len(args) == 0 {
|
||||
parts := strings.Split(toComplete, ".")
|
||||
if len(parts) <= 1 {
|
||||
var completions []string
|
||||
for _, s := range registry.ListFromMetaProjects() {
|
||||
if strings.HasPrefix(s, toComplete) {
|
||||
completions = append(completions, s+".")
|
||||
}
|
||||
}
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
spec = filterSpecByStrictMode(spec, mode)
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
afterService := strings.Join(parts[1:], ".")
|
||||
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
|
||||
allTrailingDot := len(completions) > 0
|
||||
for _, c := range completions {
|
||||
if !strings.HasSuffix(c, ".") {
|
||||
allTrailingDot = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if allTrailingDot {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
return completions, directive
|
||||
}
|
||||
|
||||
serviceName := parts[0]
|
||||
// Case 2: space-form, args already has segments
|
||||
// Walk down service -> resource(s) -> method based on existing args
|
||||
serviceName := args[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
mode := f.ResolveStrictMode(cmd.Context())
|
||||
spec = filterSpecByStrictMode(spec, mode)
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
if resources == nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
afterService := strings.Join(parts[1:], ".")
|
||||
completions := completeSchemaPathForSpec(serviceName, resources, afterService)
|
||||
|
||||
allTrailingDot := len(completions) > 0
|
||||
for _, c := range completions {
|
||||
if !strings.HasSuffix(c, ".") {
|
||||
allTrailingDot = false
|
||||
break
|
||||
// args[1:] are resource path segments (possibly partial); current
|
||||
// toComplete is the next segment under cursor.
|
||||
consumed := args[1:]
|
||||
resource, _, remaining := findResourceByPath(resources, consumed)
|
||||
if resource == nil {
|
||||
// Suggest top-level resource names that match toComplete
|
||||
var completions []string
|
||||
for resName := range resources {
|
||||
if strings.HasPrefix(resName, toComplete) {
|
||||
completions = append(completions, resName)
|
||||
}
|
||||
}
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
if len(remaining) > 0 {
|
||||
// Already typed past the resource — suggest methods
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
var completions []string
|
||||
for mName := range methods {
|
||||
if strings.HasPrefix(mName, toComplete) {
|
||||
completions = append(completions, mName)
|
||||
}
|
||||
}
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
// Resource matched exactly, suggest methods
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
var completions []string
|
||||
for mName := range methods {
|
||||
if strings.HasPrefix(mName, toComplete) {
|
||||
completions = append(completions, mName)
|
||||
}
|
||||
}
|
||||
directive := cobra.ShellCompDirectiveNoFileComp
|
||||
if allTrailingDot {
|
||||
directive |= cobra.ShellCompDirectiveNoSpace
|
||||
}
|
||||
return completions, directive
|
||||
sort.Strings(completions)
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,94 +522,231 @@ func schemaRun(opts *SchemaOptions) error {
|
||||
out := opts.Factory.IOStreams.Out
|
||||
mode := opts.Factory.ResolveStrictMode(opts.Ctx)
|
||||
|
||||
if opts.Path == "" {
|
||||
printServices(out)
|
||||
return nil
|
||||
// args may have arrived as a single string (legacy single-arg path) or
|
||||
// split into multiple — normalize to a single args slice.
|
||||
var rawArgs []string
|
||||
if opts.Path != "" {
|
||||
rawArgs = []string{opts.Path}
|
||||
}
|
||||
|
||||
parts := strings.Split(opts.Path, ".")
|
||||
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown service: %s", serviceName),
|
||||
fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", ")))
|
||||
}
|
||||
|
||||
if len(parts) == 1 {
|
||||
if opts.Format == "pretty" {
|
||||
printResourceList(out, spec, mode)
|
||||
if len(opts.ExtraArgs) > 0 {
|
||||
if opts.Path != "" {
|
||||
rawArgs = append([]string{opts.Path}, opts.ExtraArgs...)
|
||||
} else {
|
||||
output.PrintJson(out, filterSpecByStrictMode(spec, mode))
|
||||
rawArgs = append([]string(nil), opts.ExtraArgs...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
parts := schema.ParsePath(rawArgs)
|
||||
|
||||
if opts.Format == "pretty" {
|
||||
return runPrettyMode(out, parts, mode)
|
||||
}
|
||||
return runJSONMode(out, parts, mode)
|
||||
}
|
||||
|
||||
// runJSONMode dispatches list/single envelope output based on parts.
|
||||
// JSON mode uses embedded data only (bypasses remote overlay) so envelope
|
||||
// output is deterministic across machines.
|
||||
func runJSONMode(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
filter := strictModeFilter(mode)
|
||||
|
||||
switch len(parts) {
|
||||
case 0:
|
||||
envs := schema.AssembleAll(filter)
|
||||
output.PrintJson(out, envs)
|
||||
return nil
|
||||
case 1:
|
||||
spec := registry.EmbeddedSpec(parts[0])
|
||||
if spec == nil {
|
||||
return errUnknownEmbeddedService(parts[0])
|
||||
}
|
||||
envs := schema.AssembleService(parts[0], spec, filter)
|
||||
output.PrintJson(out, envs)
|
||||
return nil
|
||||
default:
|
||||
return runJSONForPath(out, parts, filter)
|
||||
}
|
||||
}
|
||||
|
||||
// runJSONForPath handles len(parts) >= 2: try resource match first, fallback
|
||||
// to single-method match. Uses embedded data only.
|
||||
func runJSONForPath(out io.Writer, parts []string, filter schema.MethodFilter) error {
|
||||
serviceName := parts[0]
|
||||
spec := registry.EmbeddedSpec(serviceName)
|
||||
if spec == nil {
|
||||
return errUnknownEmbeddedService(serviceName)
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resource, resName, remaining := findResourceByPath(resources, parts[1:])
|
||||
if resource == nil {
|
||||
var resNames []string
|
||||
var names []string
|
||||
for k := range resources {
|
||||
resNames = append(resNames, k)
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
|
||||
fmt.Sprintf("Available: %s", strings.Join(resNames, ", ")))
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
|
||||
if len(remaining) == 0 {
|
||||
if opts.Format == "pretty" {
|
||||
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
for _, mName := range sortedKeys(methods) {
|
||||
m, _ := methods[mName].(map[string]interface{})
|
||||
httpMethod := registry.GetStrFromMap(m, "httpMethod")
|
||||
desc := registry.GetStrFromMap(m, "description")
|
||||
fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
|
||||
} else {
|
||||
// For JSON output, filter methods in a copy to avoid mutating the registry.
|
||||
if mode.IsActive() {
|
||||
filtered := make(map[string]interface{})
|
||||
for k, v := range resource {
|
||||
filtered[k] = v
|
||||
}
|
||||
if methods, ok := resource["methods"].(map[string]interface{}); ok {
|
||||
filtered["methods"] = filterMethodsByStrictMode(methods, mode)
|
||||
}
|
||||
output.PrintJson(out, filtered)
|
||||
} else {
|
||||
output.PrintJson(out, resource)
|
||||
}
|
||||
}
|
||||
// Resource-scoped envelope array
|
||||
envs := assembleResource(serviceName, resName, resource, filter)
|
||||
output.PrintJson(out, envs)
|
||||
return nil
|
||||
}
|
||||
methodName := remaining[0]
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
method, ok := methods[methodName].(map[string]interface{})
|
||||
if !ok {
|
||||
var names []string
|
||||
for k := range methods {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
if len(remaining) > 1 {
|
||||
// Method exists but caller appended extra segments — reject so they
|
||||
// don't silently get this method's schema when they typo'd the path.
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown path: %s.%s.%s",
|
||||
serviceName, resName, strings.Join(remaining, ".")),
|
||||
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
|
||||
methodName, strings.Join(remaining[1:], ".")))
|
||||
}
|
||||
if filter != nil && !filter(method) {
|
||||
// Method exists in spec but filtered out by strict mode
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Method %s.%s.%s not available in current identity mode", serviceName, resName, methodName),
|
||||
"Use --as user / --as bot to switch")
|
||||
}
|
||||
env := schema.AssembleEnvelope(serviceName, []string{resName}, methodName, method)
|
||||
output.PrintJson(out, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
func assembleResource(serviceName, resName string, resource map[string]interface{}, filter schema.MethodFilter) []schema.Envelope {
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
resourcePath := []string{resName}
|
||||
var envs []schema.Envelope
|
||||
for methodName, raw := range methods {
|
||||
method, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if filter != nil && !filter(method) {
|
||||
continue
|
||||
}
|
||||
envs = append(envs, schema.AssembleEnvelope(serviceName, resourcePath, methodName, method))
|
||||
}
|
||||
sort.Slice(envs, func(i, j int) bool { return envs[i].Name < envs[j].Name })
|
||||
return envs
|
||||
}
|
||||
|
||||
// runPrettyMode preserves the existing legacy pretty rendering verbatim.
|
||||
// All printServices/printResourceList/printMethodDetail calls stay unchanged.
|
||||
func runPrettyMode(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||
if len(parts) == 0 {
|
||||
printServices(out)
|
||||
return nil
|
||||
}
|
||||
serviceName := parts[0]
|
||||
spec := registry.LoadFromMeta(serviceName)
|
||||
if spec == nil {
|
||||
return errUnknownService(serviceName)
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
printResourceList(out, spec, mode)
|
||||
return nil
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resource, resName, remaining := findResourceByPath(resources, parts[1:])
|
||||
if resource == nil {
|
||||
var names []string
|
||||
for k := range resources {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
if len(remaining) == 0 {
|
||||
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
for _, mName := range sortedKeys(methods) {
|
||||
m, _ := methods[mName].(map[string]interface{})
|
||||
httpMethod := registry.GetStrFromMap(m, "httpMethod")
|
||||
desc := registry.GetStrFromMap(m, "description")
|
||||
fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset)
|
||||
}
|
||||
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
|
||||
return nil
|
||||
}
|
||||
methodName := remaining[0]
|
||||
methods, _ := resource["methods"].(map[string]interface{})
|
||||
methods = filterMethodsByStrictMode(methods, mode)
|
||||
method, ok := methods[methodName].(map[string]interface{})
|
||||
if !ok {
|
||||
var mNames []string
|
||||
var names []string
|
||||
for k := range methods {
|
||||
mNames = append(mNames, k)
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
|
||||
fmt.Sprintf("Available: %s", strings.Join(mNames, ", ")))
|
||||
fmt.Sprintf("Available: %s", strings.Join(names, ", ")))
|
||||
}
|
||||
|
||||
if opts.Format == "pretty" {
|
||||
printMethodDetail(out, spec, resName, methodName, method)
|
||||
} else {
|
||||
output.PrintJson(out, method)
|
||||
if len(remaining) > 1 {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown path: %s.%s.%s",
|
||||
serviceName, resName, strings.Join(remaining, ".")),
|
||||
fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve",
|
||||
methodName, strings.Join(remaining[1:], ".")))
|
||||
}
|
||||
printMethodDetail(out, spec, resName, methodName, method)
|
||||
return nil
|
||||
}
|
||||
|
||||
// strictModeFilter adapts core.StrictMode into a schema.MethodFilter, or returns
|
||||
// nil if strict mode is not active.
|
||||
func strictModeFilter(mode core.StrictMode) schema.MethodFilter {
|
||||
if !mode.IsActive() {
|
||||
return nil
|
||||
}
|
||||
token := registry.IdentityToAccessToken(string(mode.ForcedIdentity()))
|
||||
return func(method map[string]interface{}) bool {
|
||||
tokens, _ := method["accessTokens"].([]interface{})
|
||||
if tokens == nil {
|
||||
return true // permissive when meta_data lacks accessTokens
|
||||
}
|
||||
for _, t := range tokens {
|
||||
if s, _ := t.(string); s == token {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func errUnknownService(name string) error {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown service: %s", name),
|
||||
fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", ")))
|
||||
}
|
||||
|
||||
// errUnknownEmbeddedService is the JSON-mode variant: it lists only embedded
|
||||
// services (no overlay) because JSON mode itself bypasses overlay; suggesting
|
||||
// overlay-only services would mislead callers when those services subsequently
|
||||
// fail to resolve in envelope output.
|
||||
func errUnknownEmbeddedService(name string) error {
|
||||
return output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("Unknown service: %s", name),
|
||||
fmt.Sprintf("Available: %s", strings.Join(registry.EmbeddedServiceNames(), ", ")))
|
||||
}
|
||||
|
||||
// filterSpecByStrictMode returns a shallow copy of spec with each resource's methods
|
||||
// filtered by strict mode. Returns the original spec when strict mode is off.
|
||||
func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} {
|
||||
|
||||
@@ -5,6 +5,7 @@ package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -33,17 +34,165 @@ func TestSchemaCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_NoArgs(t *testing.T) {
|
||||
func TestSchemaCmd_NoArgs_Pretty(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
cmd.SetArgs([]string{"--format", "pretty"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Available services") {
|
||||
t.Error("expected service list output")
|
||||
t.Error("expected service list in pretty mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_NoArgs_JSON_IsArray(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{}) // default --format json
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := strings.TrimSpace(stdout.String())
|
||||
if !strings.HasPrefix(out, "[") {
|
||||
head := out
|
||||
if len(head) > 80 {
|
||||
head = head[:80]
|
||||
}
|
||||
t.Errorf("expected JSON array root, first 80 chars:\n%s", head)
|
||||
}
|
||||
var envs []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &envs); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
if len(envs) < 193 {
|
||||
t.Errorf("envelopes count = %d, want >= 193", len(envs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_JSONIsEnvelope(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.images.create", "--format", "json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env["name"] != "im images create" {
|
||||
t.Errorf("name = %v, want \"im images create\"", env["name"])
|
||||
}
|
||||
for _, key := range []string{"description", "inputSchema", "outputSchema", "_meta"} {
|
||||
if _, ok := env[key]; !ok {
|
||||
t.Errorf("missing top-level key: %s", key)
|
||||
}
|
||||
}
|
||||
meta, _ := env["_meta"].(map[string]interface{})
|
||||
if meta["envelope_version"] != "1.0" {
|
||||
t.Errorf("envelope_version = %v, want \"1.0\"", meta["envelope_version"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_SpaceSeparatedPath_EqualsDotted(t *testing.T) {
|
||||
f1, out1, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd1 := NewCmdSchema(f1, nil)
|
||||
cmd1.SetArgs([]string{"im", "images", "create"})
|
||||
if err := cmd1.Execute(); err != nil {
|
||||
t.Fatalf("space form failed: %v", err)
|
||||
}
|
||||
|
||||
f2, out2, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd2 := NewCmdSchema(f2, nil)
|
||||
cmd2.SetArgs([]string{"im.images.create"})
|
||||
if err := cmd2.Execute(); err != nil {
|
||||
t.Fatalf("dotted form failed: %v", err)
|
||||
}
|
||||
|
||||
if out1.String() != out2.String() {
|
||||
t.Errorf("space and dotted forms produced different output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_ServiceListIsArray(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var envs []map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envs); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(envs) == 0 {
|
||||
t.Fatal("expected non-empty array for service im")
|
||||
}
|
||||
for _, e := range envs {
|
||||
name, _ := e["name"].(string)
|
||||
if !strings.HasPrefix(name, "im ") {
|
||||
t.Errorf("envelope name %q does not start with \"im \"", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_HighRiskYesInjection(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.messages.delete"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
is, _ := env["inputSchema"].(map[string]interface{})
|
||||
props, _ := is["properties"].(map[string]interface{})
|
||||
if _, ok := props["yes"]; !ok {
|
||||
t.Errorf("inputSchema.properties.yes missing for high-risk-write command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_NoYesForReadRisk(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.reactions.list"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
is, _ := env["inputSchema"].(map[string]interface{})
|
||||
props, _ := is["properties"].(map[string]interface{})
|
||||
if _, ok := props["yes"]; ok {
|
||||
t.Errorf("yes property should not appear for risk=read command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCmd_PrettyUnchanged_KeyTextPresent(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
cmd := NewCmdSchema(f, nil)
|
||||
cmd.SetArgs([]string{"im.images.create", "--format", "pretty"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
// Existing pretty rendering surfaces these markers — they must still appear
|
||||
for _, want := range []string{"Parameters:", "Response:", "Identity:", "Scopes:", "CLI:"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("pretty output missing marker %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
116
events/minutes/minute_generated.go
Normal file
116
events/minutes/minute_generated.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
const (
|
||||
minutesDetailRetryDelay = 500 * time.Millisecond
|
||||
minutesDetailMaxRetries = 2
|
||||
)
|
||||
|
||||
// MinutesMinuteSourceOutput is the flattened minute source payload.
|
||||
type MinutesMinuteSourceOutput struct {
|
||||
SourceType string `json:"source_type,omitempty" desc:"Minute source type"`
|
||||
SourceEntityID string `json:"source_entity_id,omitempty" desc:"Source entity ID"`
|
||||
}
|
||||
|
||||
// MinutesMinuteGeneratedOutput is the flattened shape for minutes.minute.generated_v1.
|
||||
type MinutesMinuteGeneratedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always minutes.minute.generated_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
|
||||
MinuteToken string `json:"minute_token,omitempty" desc:"Minute token"`
|
||||
Title string `json:"title,omitempty" desc:"Minute title"`
|
||||
MinuteSource *MinutesMinuteSourceOutput `json:"minute_source,omitempty" desc:"Minute source metadata"`
|
||||
}
|
||||
|
||||
func processMinutesMinuteGenerated(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
MinuteToken string `json:"minute_token"`
|
||||
MinuteSource struct {
|
||||
SourceType string `json:"source_type"`
|
||||
SourceEntityID string `json:"source_entity_id"`
|
||||
} `json:"minute_source"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
out := &MinutesMinuteGeneratedOutput{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
MinuteToken: envelope.Event.MinuteToken,
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
if src := envelope.Event.MinuteSource; src.SourceType != "" || src.SourceEntityID != "" {
|
||||
out.MinuteSource = &MinutesMinuteSourceOutput{
|
||||
SourceType: src.SourceType,
|
||||
SourceEntityID: src.SourceEntityID,
|
||||
}
|
||||
}
|
||||
|
||||
if rt != nil && out.MinuteToken != "" {
|
||||
fillMinutesMinuteGeneratedDetails(ctx, rt, out)
|
||||
}
|
||||
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func fillMinutesMinuteGeneratedDetails(ctx context.Context, rt event.APIClient, out *MinutesMinuteGeneratedOutput) {
|
||||
if rt == nil || out == nil || out.MinuteToken == "" {
|
||||
return
|
||||
}
|
||||
|
||||
path := fmt.Sprintf(pathMinuteDetailFmt, validate.EncodePathSegment(out.MinuteToken))
|
||||
|
||||
type minuteDetailResp struct {
|
||||
Data struct {
|
||||
Minute struct {
|
||||
Title string `json:"title"`
|
||||
} `json:"minute"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
for attempt := 0; attempt <= minutesDetailMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
time.Sleep(minutesDetailRetryDelay)
|
||||
}
|
||||
|
||||
raw, err := rt.CallAPI(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var resp minuteDetailResp
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.Data.Minute.Title == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
out.Title = resp.Data.Minute.Title
|
||||
return
|
||||
}
|
||||
}
|
||||
353
events/minutes/minute_generated_test.go
Normal file
353
events/minutes/minute_generated_test.go
Normal file
@@ -0,0 +1,353 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
type stubAPIClient struct {
|
||||
callFn func(ctx context.Context, method, path string, body any) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
func (s *stubAPIClient) CallAPI(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
if s.callFn == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.callFn(ctx, method, path, body)
|
||||
}
|
||||
|
||||
func assertSubscriptionRequest(t *testing.T, gotBody any, wantEventType string) {
|
||||
t.Helper()
|
||||
want := map[string]string{"event_type": wantEventType}
|
||||
if !reflect.DeepEqual(gotBody, want) {
|
||||
t.Fatalf("request body = %#v, want %#v", gotBody, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
for _, k := range Keys() {
|
||||
event.RegisterKey(k)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestMinutesKeys_ProcessedMinuteGeneratedRegistered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
def, ok := event.Lookup(eventTypeMinuteGenerated)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated)
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Error("Processed key must set Schema.Custom")
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Error("Processed key must not set Schema.Native")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Error("Process must not be nil for processed key")
|
||||
}
|
||||
if def.PreConsume == nil {
|
||||
t.Error("PreConsume must not be nil for processed key")
|
||||
}
|
||||
if len(def.Scopes) != 1 || def.Scopes[0] != "minutes:minutes.basic:read" {
|
||||
t.Errorf("Scopes = %v", def.Scopes)
|
||||
}
|
||||
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v", def.AuthTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMinutesMinuteGenerated(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
var gotMethod, gotPath string
|
||||
rt := &stubAPIClient{
|
||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
gotMethod = method
|
||||
gotPath = path
|
||||
if body != nil {
|
||||
t.Fatalf("GET detail body = %#v, want nil", body)
|
||||
}
|
||||
return json.RawMessage(`{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"minute": {
|
||||
"token": "<doc_token_001>",
|
||||
"title": "产品周会的视频会议",
|
||||
"note_id": "7616590025794260496"
|
||||
}
|
||||
}
|
||||
}`), nil
|
||||
},
|
||||
}
|
||||
|
||||
out := runMinuteGenerated(t, rt, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_minute_001",
|
||||
"event_type": "minutes.minute.generated_v1",
|
||||
"create_time": "1608725989000"
|
||||
},
|
||||
"event": {
|
||||
"minute_token": "<doc_token_001>",
|
||||
"minute_source": {
|
||||
"source_type": "meeting",
|
||||
"source_entity_id": "6911188411934433028"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if gotMethod != "GET" {
|
||||
t.Errorf("detail method = %q, want GET", gotMethod)
|
||||
}
|
||||
if gotPath != fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment("<doc_token_001>")) {
|
||||
t.Errorf("detail path = %q", gotPath)
|
||||
}
|
||||
if out.Type != eventTypeMinuteGenerated {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.EventID != "ev_minute_001" || out.Timestamp != "1608725989000" {
|
||||
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
|
||||
}
|
||||
if out.MinuteToken != "<doc_token_001>" {
|
||||
t.Errorf("MinuteToken = %q", out.MinuteToken)
|
||||
}
|
||||
if out.Title != "产品周会的视频会议" {
|
||||
t.Errorf("Title = %q", out.Title)
|
||||
}
|
||||
if out.MinuteSource == nil {
|
||||
t.Fatal("MinuteSource should not be nil")
|
||||
}
|
||||
if out.MinuteSource.SourceType != "meeting" || out.MinuteSource.SourceEntityID != "6911188411934433028" {
|
||||
t.Errorf("MinuteSource = %+v", out.MinuteSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMinutesMinuteGenerated_DetailFailureFallsBackToBaseFields(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
called := 0
|
||||
rt := &stubAPIClient{
|
||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
called++
|
||||
return nil, context.DeadlineExceeded
|
||||
},
|
||||
}
|
||||
|
||||
out := runMinuteGenerated(t, rt, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_minute_002",
|
||||
"event_type": "minutes.minute.generated_v1",
|
||||
"create_time": "1608725989001"
|
||||
},
|
||||
"event": {
|
||||
"minute_token": "<doc_token_004>",
|
||||
"minute_source": {
|
||||
"source_type": "meeting",
|
||||
"source_entity_id": "7641156270787481117"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
wantCalls := 1 + minutesDetailMaxRetries
|
||||
if called != wantCalls {
|
||||
t.Fatalf("detail API called %d times, want %d", called, wantCalls)
|
||||
}
|
||||
if out.MinuteToken != "<doc_token_004>" {
|
||||
t.Errorf("MinuteToken = %q", out.MinuteToken)
|
||||
}
|
||||
if out.Title != "" {
|
||||
t.Errorf("Title = %q, want empty", out.Title)
|
||||
}
|
||||
if out.MinuteSource == nil {
|
||||
t.Fatal("MinuteSource should remain from event payload")
|
||||
}
|
||||
if out.MinuteSource.SourceType != "meeting" || out.MinuteSource.SourceEntityID != "7641156270787481117" {
|
||||
t.Errorf("MinuteSource = %+v", out.MinuteSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMinutesMinuteGenerated_EmptyTitleRetriesAndSucceeds(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
called := 0
|
||||
rt := &stubAPIClient{
|
||||
callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) {
|
||||
called++
|
||||
if called <= 1 {
|
||||
return json.RawMessage(`{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"minute": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
}`), nil
|
||||
}
|
||||
return json.RawMessage(`{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"minute": {
|
||||
"title": "delayed title"
|
||||
}
|
||||
}
|
||||
}`), nil
|
||||
},
|
||||
}
|
||||
|
||||
out := runMinuteGenerated(t, rt, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_minute_retry",
|
||||
"event_type": "minutes.minute.generated_v1",
|
||||
"create_time": "1608725989000"
|
||||
},
|
||||
"event": {
|
||||
"minute_token": "<doc_token_003>"
|
||||
}
|
||||
}`)
|
||||
|
||||
if called != 2 {
|
||||
t.Fatalf("detail API called %d times, want 2 (1 initial + 1 retry)", called)
|
||||
}
|
||||
if out.Title != "delayed title" {
|
||||
t.Errorf("Title = %q, want delayed title", out.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMinutesMinuteGenerated_EmptyTitleExhaustsRetries(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
called := 0
|
||||
rt := &stubAPIClient{
|
||||
callFn: func(_ context.Context, _, _ string, _ any) (json.RawMessage, error) {
|
||||
called++
|
||||
return json.RawMessage(`{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"minute": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
}`), nil
|
||||
},
|
||||
}
|
||||
|
||||
out := runMinuteGenerated(t, rt, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_minute_exhaust",
|
||||
"event_type": "minutes.minute.generated_v1",
|
||||
"create_time": "1608725989000"
|
||||
},
|
||||
"event": {
|
||||
"minute_token": "<doc_token_002>"
|
||||
}
|
||||
}`)
|
||||
|
||||
wantCalls := 1 + minutesDetailMaxRetries
|
||||
if called != wantCalls {
|
||||
t.Fatalf("detail API called %d times, want %d", called, wantCalls)
|
||||
}
|
||||
if out.Title != "" {
|
||||
t.Errorf("Title = %q, want empty after exhausted retries", out.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesMinuteGenerated_PreConsumeSubscriptionLifecycle(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
def, ok := event.Lookup(eventTypeMinuteGenerated)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated)
|
||||
}
|
||||
|
||||
type call struct {
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
}
|
||||
var calls []call
|
||||
rt := &stubAPIClient{
|
||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
calls = append(calls, call{method: method, path: path, body: body})
|
||||
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
|
||||
},
|
||||
}
|
||||
|
||||
cleanup, err := def.PreConsume(context.Background(), rt, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PreConsume error: %v", err)
|
||||
}
|
||||
if cleanup == nil {
|
||||
t.Fatal("cleanup must not be nil")
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
|
||||
}
|
||||
if calls[0].method != "POST" || calls[0].path != pathMinuteSubscribe {
|
||||
t.Fatalf("subscribe call = %+v", calls[0])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[0].body, eventTypeMinuteGenerated)
|
||||
|
||||
cleanup()
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
|
||||
}
|
||||
if calls[1].method != "POST" || calls[1].path != pathMinuteUnsubscribe {
|
||||
t.Fatalf("unsubscribe call = %+v", calls[1])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[1].body, eventTypeMinuteGenerated)
|
||||
}
|
||||
|
||||
func TestProcessMinutesMinuteGenerated_MalformedPayload(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeMinuteGenerated,
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processMinutesMinuteGenerated(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func runMinuteGenerated(t *testing.T, rt event.APIClient, payload string) MinutesMinuteGeneratedOutput {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeMinuteGenerated,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processMinutesMinuteGenerated(context.Background(), rt, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
var out MinutesMinuteGeneratedOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid MinutesMinuteGeneratedOutput JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
33
events/minutes/preconsume.go
Normal file
33
events/minutes/preconsume.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const cleanupTimeout = 5 * time.Second
|
||||
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
42
events/minutes/register.go
Normal file
42
events/minutes/register.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package minutes registers Minutes-domain EventKeys.
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const (
|
||||
eventTypeMinuteGenerated = "minutes.minute.generated_v1"
|
||||
|
||||
pathMinuteSubscribe = "/open-apis/minutes/v1/minutes/subscription"
|
||||
pathMinuteUnsubscribe = "/open-apis/minutes/v1/minutes/unsubscription"
|
||||
|
||||
pathMinuteDetailFmt = "/open-apis/minutes/v1/minutes/%s"
|
||||
)
|
||||
|
||||
// Keys returns all Minutes-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeMinuteGenerated,
|
||||
DisplayName: "Minute generated",
|
||||
Description: "Triggered when a minute has been generated",
|
||||
EventType: eventTypeMinuteGenerated,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(MinutesMinuteGeneratedOutput{})},
|
||||
},
|
||||
Process: processMinutesMinuteGenerated,
|
||||
PreConsume: subscriptionPreConsume(eventTypeMinuteGenerated, pathMinuteSubscribe, pathMinuteUnsubscribe),
|
||||
Scopes: []string{"minutes:minutes.basic:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeMinuteGenerated},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,17 @@ package events
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/events/im"
|
||||
"github.com/larksuite/cli/events/minutes"
|
||||
"github.com/larksuite/cli/events/vc"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// Mail is intentionally omitted: only IM is wired up this phase.
|
||||
// Mail is intentionally omitted in this phase.
|
||||
func init() {
|
||||
all := [][]event.KeyDefinition{
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
vc.Keys(),
|
||||
}
|
||||
for _, keys := range all {
|
||||
for _, k := range keys {
|
||||
|
||||
77
events/vc/participant_meeting_ended.go
Normal file
77
events/vc/participant_meeting_ended.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// VCParticipantMeetingEndedOutput is the flattened shape for vc.meeting.participant_meeting_ended_v1.
|
||||
type VCParticipantMeetingEndedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_ended_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
|
||||
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
|
||||
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
|
||||
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
|
||||
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
|
||||
EndTime string `json:"end_time,omitempty" desc:"Meeting end time in RFC3339, converted to the local timezone"`
|
||||
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
|
||||
}
|
||||
|
||||
func processVCParticipantMeetingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
Meeting struct {
|
||||
ID string `json:"id"`
|
||||
Topic string `json:"topic"`
|
||||
MeetingNo string `json:"meeting_no"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
CalendarEventID string `json:"calendar_event_id"`
|
||||
} `json:"meeting"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
meeting := envelope.Event.Meeting
|
||||
out := &VCParticipantMeetingEndedOutput{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
MeetingID: meeting.ID,
|
||||
Topic: meeting.Topic,
|
||||
MeetingNo: meeting.MeetingNo,
|
||||
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
|
||||
EndTime: unixSecondsToLocalRFC3339(meeting.EndTime),
|
||||
CalendarEventID: meeting.CalendarEventID,
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func unixSecondsToLocalRFC3339(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
secs, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return time.Unix(secs, 0).Local().Format(time.RFC3339)
|
||||
}
|
||||
203
events/vc/participant_meeting_ended_test.go
Normal file
203
events/vc/participant_meeting_ended_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
for _, k := range Keys() {
|
||||
event.RegisterKey(k)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestVCKeys_ProcessedMeetingEndedRegistered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
def, ok := event.Lookup(eventTypeMeetingEnded)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", eventTypeMeetingEnded)
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Error("Processed key must set Schema.Custom")
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Error("Processed key must not set Schema.Native")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Error("Process must not be nil for processed key")
|
||||
}
|
||||
if def.PreConsume == nil {
|
||||
t.Error("PreConsume must not be nil for processed key")
|
||||
}
|
||||
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:meeting.meetingevent:read" {
|
||||
t.Errorf("Scopes = %v", def.Scopes)
|
||||
}
|
||||
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v", def.AuthTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCParticipantMeetingEnded(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_vc_end_001",
|
||||
"event_type": "vc.meeting.participant_meeting_ended_v1",
|
||||
"create_time": "1608725989000",
|
||||
"app_id": "cli_test"
|
||||
},
|
||||
"event": {
|
||||
"meeting": {
|
||||
"id": "6911188411934433028",
|
||||
"topic": "my meeting",
|
||||
"meeting_no": "235812466",
|
||||
"start_time": "1608883322",
|
||||
"end_time": "1608883899",
|
||||
"calendar_event_id": "efa67a98-06a8-4df5-8559-746c8f4477ef_0"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runMeetingEnded(t, payload)
|
||||
|
||||
if out.Type != eventTypeMeetingEnded {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.EventID != "ev_vc_end_001" {
|
||||
t.Errorf("EventID = %q", out.EventID)
|
||||
}
|
||||
if out.Timestamp != "1608725989000" {
|
||||
t.Errorf("Timestamp = %q", out.Timestamp)
|
||||
}
|
||||
if out.MeetingID != "6911188411934433028" {
|
||||
t.Errorf("MeetingID = %q", out.MeetingID)
|
||||
}
|
||||
if out.Topic != "my meeting" || out.MeetingNo != "235812466" {
|
||||
t.Errorf("Topic/MeetingNo = %q/%q", out.Topic, out.MeetingNo)
|
||||
}
|
||||
if out.CalendarEventID != "efa67a98-06a8-4df5-8559-746c8f4477ef_0" {
|
||||
t.Errorf("CalendarEventID = %q", out.CalendarEventID)
|
||||
}
|
||||
if want := time.Unix(1608883322, 0).Local().Format(time.RFC3339); out.StartTime != want {
|
||||
t.Errorf("StartTime = %q, want %q", out.StartTime, want)
|
||||
}
|
||||
if want := time.Unix(1608883899, 0).Local().Format(time.RFC3339); out.EndTime != want {
|
||||
t.Errorf("EndTime = %q, want %q", out.EndTime, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCParticipantMeetingEnded_InvalidMeetingTimes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_vc_end_002",
|
||||
"event_type": "vc.meeting.participant_meeting_ended_v1",
|
||||
"create_time": "1608725989001"
|
||||
},
|
||||
"event": {
|
||||
"meeting": {
|
||||
"id": "meeting_invalid_time",
|
||||
"start_time": "bad",
|
||||
"end_time": ""
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runMeetingEnded(t, payload)
|
||||
if out.StartTime != "" || out.EndTime != "" {
|
||||
t.Errorf("StartTime/EndTime = %q/%q, want empty strings", out.StartTime, out.EndTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCParticipantMeetingEnded_MalformedPayload(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeMeetingEnded,
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processVCParticipantMeetingEnded(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVCParticipantMeetingEnded_PreConsumeSubscriptionLifecycle(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
def, ok := event.Lookup("vc.meeting.participant_meeting_ended_v1")
|
||||
if !ok {
|
||||
t.Fatal("vc.meeting.participant_meeting_ended_v1 should be registered via Keys()")
|
||||
}
|
||||
|
||||
type call struct {
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
}
|
||||
var calls []call
|
||||
rt := &stubAPIClient{
|
||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
calls = append(calls, call{method: method, path: path, body: body})
|
||||
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
|
||||
},
|
||||
}
|
||||
|
||||
cleanup, err := def.PreConsume(context.Background(), rt, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PreConsume error: %v", err)
|
||||
}
|
||||
if cleanup == nil {
|
||||
t.Fatal("cleanup must not be nil")
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
|
||||
}
|
||||
if calls[0].method != "POST" || calls[0].path != pathMeetingSubscribe {
|
||||
t.Fatalf("subscribe call = %+v", calls[0])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[0].body, eventTypeMeetingEnded)
|
||||
|
||||
cleanup()
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
|
||||
}
|
||||
if calls[1].method != "POST" || calls[1].path != pathMeetingUnsubscribe {
|
||||
t.Fatalf("unsubscribe call = %+v", calls[1])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[1].body, eventTypeMeetingEnded)
|
||||
}
|
||||
|
||||
func runMeetingEnded(t *testing.T, payload string) VCParticipantMeetingEndedOutput {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeMeetingEnded,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processVCParticipantMeetingEnded(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
var out VCParticipantMeetingEndedOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid VCParticipantMeetingEndedOutput JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
33
events/vc/preconsume.go
Normal file
33
events/vc/preconsume.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const cleanupTimeout = 5 * time.Second
|
||||
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
43
events/vc/register.go
Normal file
43
events/vc/register.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package vc registers VC-domain EventKeys.
|
||||
package vc
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const (
|
||||
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
|
||||
eventTypeNoteGenerated = "vc.note.generated_v1"
|
||||
|
||||
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
|
||||
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
|
||||
pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription"
|
||||
pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription"
|
||||
)
|
||||
|
||||
// Keys returns all VC-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeMeetingEnded,
|
||||
DisplayName: "Participant meeting ended",
|
||||
Description: "Triggered when a meeting the current user participates in has ended",
|
||||
EventType: eventTypeMeetingEnded,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingEndedOutput{})},
|
||||
},
|
||||
Process: processVCParticipantMeetingEnded,
|
||||
PreConsume: subscriptionPreConsume(eventTypeMeetingEnded, pathMeetingSubscribe, pathMeetingUnsubscribe),
|
||||
Scopes: []string{"vc:meeting.meetingevent:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeMeetingEnded},
|
||||
},
|
||||
}
|
||||
}
|
||||
30
events/vc/test_helpers_test.go
Normal file
30
events/vc/test_helpers_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type stubAPIClient struct {
|
||||
callFn func(ctx context.Context, method, path string, body any) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
func (s *stubAPIClient) CallAPI(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
if s.callFn == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.callFn(ctx, method, path, body)
|
||||
}
|
||||
|
||||
func assertSubscriptionRequest(t *testing.T, gotBody any, wantEventType string) {
|
||||
t.Helper()
|
||||
want := map[string]string{"event_type": wantEventType}
|
||||
if !reflect.DeepEqual(gotBody, want) {
|
||||
t.Fatalf("request body = %#v, want %#v", gotBody, want)
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func RequireConfirmation(action string) error {
|
||||
Message: fmt.Sprintf("%s requires confirmation", action),
|
||||
Hint: "add --yes to confirm",
|
||||
Risk: &output.RiskDetail{
|
||||
Level: "high-risk-write",
|
||||
Level: RiskHighRiskWrite,
|
||||
Action: action,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,11 +7,20 @@ import "github.com/spf13/cobra"
|
||||
|
||||
const riskLevelAnnotationKey = "risk_level"
|
||||
|
||||
// Risk level constants — the three-tier convention used across the CLI.
|
||||
// Use these in place of string literals so the typo radius is one place,
|
||||
// not every call site.
|
||||
const (
|
||||
RiskRead = "read"
|
||||
RiskWrite = "write"
|
||||
RiskHighRiskWrite = "high-risk-write"
|
||||
)
|
||||
|
||||
// SetRisk stores a command's static risk level on cobra annotations so the
|
||||
// help renderer (cmd/root.go) can surface a Risk: line without importing
|
||||
// shortcuts/common. Levels follow the three-tier convention: "read" | "write"
|
||||
// | "high-risk-write". Framework-level confirmation gating only acts on
|
||||
// "high-risk-write".
|
||||
// shortcuts/common. Levels follow the three-tier convention: RiskRead |
|
||||
// RiskWrite | RiskHighRiskWrite. Framework-level confirmation gating only
|
||||
// acts on RiskHighRiskWrite.
|
||||
func SetRisk(cmd *cobra.Command, level string) {
|
||||
if level == "" {
|
||||
return
|
||||
|
||||
@@ -66,6 +66,19 @@ const (
|
||||
|
||||
// IM resource ownership mismatch.
|
||||
LarkErrOwnershipMismatch = 231205
|
||||
|
||||
// Mail send: account / mailbox-level failures returned by
|
||||
// POST /open-apis/mail/v1/user_mailboxes/:user_mailbox_id/drafts/:draft_id/send.
|
||||
// Mail v1 uses service-scoped 123xxxx codes; keep the full upstream code
|
||||
// because ErrAPI preserves Detail.Code exactly as returned by the server.
|
||||
// These codes indicate the entire batch will keep failing identically and
|
||||
// are consumed by shortcuts/mail.isFatalSendErr to abort early.
|
||||
LarkErrMailboxNotFound = 1234013 // mailbox not found or not active
|
||||
LarkErrMailSendQuotaUser = 1236007 // user daily send count exceeded
|
||||
LarkErrMailSendQuotaUserExt = 1236008 // user daily external recipient count exceeded
|
||||
LarkErrMailSendQuotaTenantExt = 1236009 // tenant daily external recipient count exceeded
|
||||
LarkErrMailQuota = 1236010 // mail quota limit
|
||||
LarkErrTenantStorageLimit = 1236013 // tenant storage limit exceeded
|
||||
)
|
||||
|
||||
// legacyHints supplies the per-code actionable hint string for the legacy
|
||||
|
||||
@@ -91,6 +91,32 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailSendErrorConstantsUseServiceScopedCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
got int
|
||||
want int
|
||||
}{
|
||||
{name: "mailbox not found", got: LarkErrMailboxNotFound, want: 1234013},
|
||||
{name: "user daily send quota", got: LarkErrMailSendQuotaUser, want: 1236007},
|
||||
{name: "user external recipient quota", got: LarkErrMailSendQuotaUserExt, want: 1236008},
|
||||
{name: "tenant external recipient quota", got: LarkErrMailSendQuotaTenantExt, want: 1236009},
|
||||
{name: "mail quota", got: LarkErrMailQuota, want: 1236010},
|
||||
{name: "tenant storage limit", got: LarkErrTenantStorageLimit, want: 1236013},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if tt.got != tt.want {
|
||||
t.Fatalf("code=%d, want %d", tt.got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyLarkError_WikiLockContention verifies the wiki write-lock
|
||||
// contention error (131009) maps to an actionable retry hint instead of
|
||||
// a generic "api_error". Surfaces during concurrent wiki +node-create
|
||||
|
||||
@@ -22,6 +22,64 @@ 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.
|
||||
func EmbeddedSpec(serviceName string) map[string]interface{} {
|
||||
parseEmbeddedServices()
|
||||
return embeddedServicesMap[serviceName]
|
||||
}
|
||||
|
||||
// EmbeddedServiceNames returns sorted embedded service names (no overlay).
|
||||
// Returns a defensive copy — callers must not mutate the package-level slice.
|
||||
func EmbeddedServiceNames() []string {
|
||||
parseEmbeddedServices()
|
||||
out := make([]string, len(embeddedServiceNames))
|
||||
copy(out, embeddedServiceNames)
|
||||
return out
|
||||
}
|
||||
|
||||
var (
|
||||
mergedServices = make(map[string]map[string]interface{}) // project name → parsed spec
|
||||
mergedProjectList []string // sorted project names
|
||||
|
||||
874
internal/schema/assembler.go
Normal file
874
internal/schema/assembler.go
Normal file
@@ -0,0 +1,874 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
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
|
||||
// `integer` field would emit string literals and fail any standard validator.
|
||||
// Already-typed values pass through unchanged. Returns (value, true) on
|
||||
// success, or (nil, false) when the literal cannot be coerced (caller should
|
||||
// drop it).
|
||||
func coerceLiteral(fieldType string, raw interface{}) (interface{}, bool) {
|
||||
s, isStr := raw.(string)
|
||||
if !isStr {
|
||||
// Already typed (e.g. meta_data emitted a JSON number/bool directly).
|
||||
return raw, true
|
||||
}
|
||||
switch fieldType {
|
||||
case "integer":
|
||||
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
return v, true
|
||||
}
|
||||
return nil, false
|
||||
case "number":
|
||||
if v, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return v, true
|
||||
}
|
||||
return nil, false
|
||||
case "boolean":
|
||||
switch s {
|
||||
case "true":
|
||||
return true, true
|
||||
case "false":
|
||||
return false, true
|
||||
}
|
||||
return nil, false
|
||||
default: // "string", "" (nested objects), or unknown
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
|
||||
// sortEnum sorts an enum slice in-place using a comparator appropriate for
|
||||
// the declared JSON Schema type, so integer enums end up [1, 2, 10] rather
|
||||
// than the lexicographic [1, 10, 2].
|
||||
func sortEnum(fieldType string, vals []interface{}) {
|
||||
sort.SliceStable(vals, func(i, j int) bool {
|
||||
switch fieldType {
|
||||
case "integer":
|
||||
ai, _ := vals[i].(int64)
|
||||
bi, _ := vals[j].(int64)
|
||||
return ai < bi
|
||||
case "number":
|
||||
af, _ := vals[i].(float64)
|
||||
bf, _ := vals[j].(float64)
|
||||
return af < bf
|
||||
case "boolean":
|
||||
ab, _ := vals[i].(bool)
|
||||
bb, _ := vals[j].(bool)
|
||||
return !ab && bb // false < true
|
||||
default:
|
||||
as, _ := vals[i].(string)
|
||||
bs, _ := vals[j].(string)
|
||||
return as < bs
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// convertProperty recursively converts one meta_data field map into a Property.
|
||||
// nestedPath is the dotted lookup key into the current method's NestedKeys map
|
||||
// (e.g. "responseBody.items.properties"). Empty path = top-level, no nested
|
||||
// lookup needed.
|
||||
func convertProperty(field map[string]interface{}, nestedPath string) Property {
|
||||
var p Property
|
||||
|
||||
rawType, _ := field["type"].(string)
|
||||
switch rawType {
|
||||
case "file":
|
||||
p.Type = "string"
|
||||
p.Format = "binary"
|
||||
case "list":
|
||||
// meta_data uses non-standard "list" on a couple of fields;
|
||||
// translate to JSON Schema "array" so validators accept it.
|
||||
p.Type = "array"
|
||||
default:
|
||||
p.Type = rawType
|
||||
}
|
||||
|
||||
if s, ok := field["description"].(string); ok {
|
||||
p.Description = s
|
||||
}
|
||||
if v, ok := field["default"]; ok {
|
||||
// Coerce default literal to match the declared JSON Schema type so
|
||||
// validators do not reject e.g. {type:"integer", default:"500"}.
|
||||
// When coercion fails (e.g. default:"" on an integer field, which
|
||||
// meta_data uses to mean "no default"), omit the field entirely
|
||||
// instead of emitting a type-mismatched default — the result is a
|
||||
// missing `default` key rather than a contract violation.
|
||||
if coerced, ok := coerceLiteral(p.Type, v); ok {
|
||||
p.Default = coerced
|
||||
}
|
||||
}
|
||||
if v, ok := field["example"]; ok {
|
||||
// meta_data stores examples as strings even when the field is integer/
|
||||
// boolean/number; coerce to the declared type so downstream validators
|
||||
// accept the envelope. Drop on coerce failure (same policy as default).
|
||||
if coerced, ok := coerceLiteral(p.Type, v); ok {
|
||||
p.Example = coerced
|
||||
}
|
||||
}
|
||||
|
||||
// min / max are stored as strings in meta_data; parse on best-effort.
|
||||
if minStr, ok := field["min"].(string); ok && minStr != "" {
|
||||
if v, err := strconv.ParseFloat(minStr, 64); err == nil {
|
||||
p.Minimum = &v
|
||||
}
|
||||
}
|
||||
if maxStr, ok := field["max"].(string); ok && maxStr != "" {
|
||||
if v, err := strconv.ParseFloat(maxStr, 64); err == nil {
|
||||
p.Maximum = &v
|
||||
}
|
||||
}
|
||||
|
||||
// enum: prefer existing "enum" array; else extract from options[].value.
|
||||
// Values are typed per p.Type so integer fields get integer enums, etc.
|
||||
// (JSON Schema 2020-12 requires enum value types to match the declared
|
||||
// type — meta_data stores everything as strings.)
|
||||
if enumRaw, ok := field["enum"].([]interface{}); ok && len(enumRaw) > 0 {
|
||||
for _, e := range enumRaw {
|
||||
if v, ok := coerceLiteral(p.Type, e); ok {
|
||||
p.Enum = append(p.Enum, v)
|
||||
}
|
||||
}
|
||||
// Numeric/boolean enums get sorted (no inherent meaning in meta_data
|
||||
// order); string enums keep meta_data order, which sometimes carries
|
||||
// semantic priority (e.g. image_type ["message","avatar"]).
|
||||
if p.Type != "string" && p.Type != "" {
|
||||
sortEnum(p.Type, p.Enum)
|
||||
}
|
||||
} else if optsRaw, ok := field["options"].([]interface{}); ok && len(optsRaw) > 0 {
|
||||
seen := make(map[string]bool)
|
||||
for _, o := range optsRaw {
|
||||
om, ok := o.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
raw, ok := om["value"].(string)
|
||||
if !ok || seen[raw] {
|
||||
continue
|
||||
}
|
||||
seen[raw] = true
|
||||
if v, ok := coerceLiteral(p.Type, raw); ok {
|
||||
p.Enum = append(p.Enum, v)
|
||||
}
|
||||
}
|
||||
// Same policy as the `enum` branch: numeric/boolean enums get sorted
|
||||
// (no semantic meaning in source order); string enums keep meta_data
|
||||
// order, which may carry semantic priority.
|
||||
if p.Type != "string" && p.Type != "" {
|
||||
sortEnum(p.Type, p.Enum)
|
||||
}
|
||||
}
|
||||
|
||||
// nested properties: recurse
|
||||
if propsRaw, ok := field["properties"].(map[string]interface{}); ok && len(propsRaw) > 0 {
|
||||
nested, nestedRequired := buildOrderedProps(propsRaw, nestedPath)
|
||||
if p.Type == "array" {
|
||||
// meta_data quirk: array element schema is wrapped in "properties".
|
||||
// Unfold into Items: { type: "object", properties: <nested> }
|
||||
p.Items = &Property{
|
||||
Type: "object",
|
||||
Properties: nested,
|
||||
Required: nestedRequired,
|
||||
}
|
||||
// Property.Properties stays nil for arrays
|
||||
} else {
|
||||
if p.Type == "" {
|
||||
p.Type = "object" // infer
|
||||
}
|
||||
p.Properties = nested
|
||||
p.Required = nestedRequired
|
||||
}
|
||||
}
|
||||
|
||||
// array items fallback: emit `items: {}` (any schema) for every array that
|
||||
// meta_data does not describe an element shape for — whether it arrived as
|
||||
// "list" or natively as "array". Without this, typeless arrays (e.g. arrays
|
||||
// of bare ID strings) violate the L1 lint rule and are not JSON Schema valid
|
||||
// for consumers that require `items`.
|
||||
if p.Type == "array" && p.Items == nil {
|
||||
p.Items = &Property{}
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// buildOrderedProps converts a map[string]interface{} of field specs into an
|
||||
// OrderedProps plus the alphabetized list of child keys marked `required:true`
|
||||
// in meta_data. Callers attach that list to the enclosing object's `required`,
|
||||
// so nested objects faithfully report their call contract (top-level required
|
||||
// is handled separately by buildInputSchema).
|
||||
func buildOrderedProps(raw map[string]interface{}, nestedPath string) (*OrderedProps, []string) {
|
||||
op := &OrderedProps{Map: make(map[string]Property, len(raw))}
|
||||
|
||||
var required []string
|
||||
keys := orderedKeys(raw, nestedPath)
|
||||
for _, k := range keys {
|
||||
fieldRaw, _ := raw[k].(map[string]interface{})
|
||||
op.Order = append(op.Order, k)
|
||||
op.Map[k] = convertProperty(fieldRaw, nestedPath+"."+k+".properties")
|
||||
if req, _ := fieldRaw["required"].(bool); req {
|
||||
required = append(required, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(required)
|
||||
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.
|
||||
//
|
||||
// Affordance is authored in larksuite-cli-registry's registry-config.yaml under
|
||||
// overrides.<resource>.<method>.affordance and flows through gen-registry.py's
|
||||
// deep_merge into the embedded meta_data.json.
|
||||
func parseAffordance(raw interface{}) *Affordance {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var a Affordance
|
||||
if err := json.Unmarshal(b, &a); err != nil {
|
||||
return nil
|
||||
}
|
||||
if len(a.UseWhen) == 0 && len(a.DoNotUseWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Examples) == 0 && len(a.Related) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &a
|
||||
}
|
||||
|
||||
// convertAccessTokens translates from_meta accessTokens (uses "tenant") into
|
||||
// CLI --as form (uses "bot"). The result is deduped and sorted alphabetically.
|
||||
// Unknown tokens are dropped. Returns an empty slice for nil/empty input.
|
||||
func convertAccessTokens(raw []interface{}) []string {
|
||||
seen := make(map[string]bool)
|
||||
for _, t := range raw {
|
||||
s, ok := t.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch s {
|
||||
case "tenant":
|
||||
seen["bot"] = true
|
||||
case "user":
|
||||
seen["user"] = true
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for k := range seen {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// buildMeta produces the _meta extension namespace.
|
||||
func buildMeta(method map[string]interface{}) *Meta {
|
||||
m := &Meta{
|
||||
EnvelopeVersion: "1.0",
|
||||
RequiredScopes: []string{}, // never nil for stable JSON
|
||||
}
|
||||
|
||||
if scopesRaw, ok := method["scopes"].([]interface{}); ok {
|
||||
for _, s := range scopesRaw {
|
||||
if str, ok := s.(string); ok {
|
||||
m.Scopes = append(m.Scopes, str)
|
||||
}
|
||||
}
|
||||
}
|
||||
if rsRaw, ok := method["requiredScopes"].([]interface{}); ok {
|
||||
for _, s := range rsRaw {
|
||||
if str, ok := s.(string); ok {
|
||||
m.RequiredScopes = append(m.RequiredScopes, str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
atRaw, _ := method["accessTokens"].([]interface{})
|
||||
m.AccessTokens = convertAccessTokens(atRaw)
|
||||
|
||||
m.Danger, _ = method["danger"].(bool)
|
||||
|
||||
if risk, _ := method["risk"].(string); risk != "" {
|
||||
m.Risk = risk
|
||||
} else {
|
||||
m.Risk = cmdutil.RiskRead
|
||||
}
|
||||
|
||||
if docURL, _ := method["docUrl"].(string); docURL != "" {
|
||||
m.DocURL = docURL
|
||||
}
|
||||
|
||||
m.Affordance = parseAffordance(method["affordance"])
|
||||
return m
|
||||
}
|
||||
|
||||
// buildInputSchema produces the inputSchema for one API method.
|
||||
//
|
||||
// Top-level shape:
|
||||
//
|
||||
// { type: object,
|
||||
// required: [<"params" if any param required>, <"data" if any body required>],
|
||||
// properties: {
|
||||
// params: { type: object, required: [...], properties: { ...path/query fields } }, // only if method has parameters
|
||||
// data: { type: object, required: [...], properties: { ...body fields } }, // only if method has requestBody
|
||||
// yes: { type: boolean, default: false, ... } // only when risk == "high-risk-write"
|
||||
// } }
|
||||
//
|
||||
// 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",
|
||||
Required: []string{}, // never nil — stable envelope shape
|
||||
Properties: &OrderedProps{Map: make(map[string]Property)},
|
||||
}
|
||||
|
||||
// Build the "params" sub-object from method.parameters (path + query).
|
||||
paramsRaw, _ := method["parameters"].(map[string]interface{})
|
||||
paramsProps := &OrderedProps{Map: make(map[string]Property)}
|
||||
var paramsRequired []string
|
||||
for _, k := range orderedKeys(paramsRaw, "parameters") {
|
||||
field, _ := paramsRaw[k].(map[string]interface{})
|
||||
prop := convertProperty(field, "parameters."+k+".properties")
|
||||
paramsProps.Order = append(paramsProps.Order, k)
|
||||
paramsProps.Map[k] = prop
|
||||
if req, _ := field["required"].(bool); req {
|
||||
paramsRequired = append(paramsRequired, k)
|
||||
}
|
||||
}
|
||||
if len(paramsProps.Order) > 0 {
|
||||
sort.Strings(paramsRequired)
|
||||
is.Properties.Order = append(is.Properties.Order, "params")
|
||||
is.Properties.Map["params"] = Property{
|
||||
Type: "object",
|
||||
Required: paramsRequired,
|
||||
Properties: paramsProps,
|
||||
}
|
||||
if len(paramsRequired) > 0 {
|
||||
is.Required = append(is.Required, "params")
|
||||
}
|
||||
}
|
||||
|
||||
// Split method.requestBody into two buckets:
|
||||
// - data: non-file body fields → corresponds to CLI --data JSON
|
||||
// - file: type:file body fields → corresponds to CLI --file <key>=<path>
|
||||
// File fields are kept *out* of `data` so the schema mirrors the actual
|
||||
// CLI flag dispatch: --file owns one wire format (multipart upload),
|
||||
// --data owns the rest (JSON body).
|
||||
bodyRaw, _ := method["requestBody"].(map[string]interface{})
|
||||
dataProps := &OrderedProps{Map: make(map[string]Property)}
|
||||
fileProps := &OrderedProps{Map: make(map[string]Property)}
|
||||
var dataRequired []string
|
||||
var fileRequired []string
|
||||
for _, k := range orderedKeys(bodyRaw, "requestBody") {
|
||||
field, _ := bodyRaw[k].(map[string]interface{})
|
||||
prop := convertProperty(field, "requestBody."+k+".properties")
|
||||
isFile := false
|
||||
if t, _ := field["type"].(string); t == "file" {
|
||||
isFile = true
|
||||
}
|
||||
if isFile {
|
||||
fileProps.Order = append(fileProps.Order, k)
|
||||
fileProps.Map[k] = prop
|
||||
if req, _ := field["required"].(bool); req {
|
||||
fileRequired = append(fileRequired, k)
|
||||
}
|
||||
} else {
|
||||
dataProps.Order = append(dataProps.Order, k)
|
||||
dataProps.Map[k] = prop
|
||||
if req, _ := field["required"].(bool); req {
|
||||
dataRequired = append(dataRequired, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(dataProps.Order) > 0 {
|
||||
sort.Strings(dataRequired)
|
||||
is.Properties.Order = append(is.Properties.Order, "data")
|
||||
is.Properties.Map["data"] = Property{
|
||||
Type: "object",
|
||||
Required: dataRequired,
|
||||
Properties: dataProps,
|
||||
}
|
||||
if len(dataRequired) > 0 {
|
||||
is.Required = append(is.Required, "data")
|
||||
}
|
||||
}
|
||||
if len(fileProps.Order) > 0 {
|
||||
sort.Strings(fileRequired)
|
||||
is.Properties.Order = append(is.Properties.Order, "file")
|
||||
is.Properties.Map["file"] = Property{
|
||||
Type: "object",
|
||||
Description: "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file <key>=<path>.",
|
||||
Required: fileRequired,
|
||||
Properties: fileProps,
|
||||
}
|
||||
if len(fileRequired) > 0 {
|
||||
is.Required = append(is.Required, "file")
|
||||
}
|
||||
}
|
||||
|
||||
// high-risk-write injects a top-level `yes` confirmation flag — sibling
|
||||
// of params/data. It is a CLI gate (consumed by lark-cli, not sent to
|
||||
// the backend), not an API field.
|
||||
if risk, _ := method["risk"].(string); risk == cmdutil.RiskHighRiskWrite {
|
||||
is.Properties.Order = append(is.Properties.Order, "yes")
|
||||
falseVal := false
|
||||
is.Properties.Map["yes"] = Property{
|
||||
Type: "boolean",
|
||||
Default: falseVal,
|
||||
Description: "CLI confirmation gate. Must be true to execute; lark-cli rejects with confirmation_required if absent or false. Not sent to the backend.",
|
||||
}
|
||||
// yes is intentionally NOT added to top-level Required; the gate is
|
||||
// enforced semantically (yes==true) by the CLI, not structurally.
|
||||
}
|
||||
|
||||
sort.Strings(is.Required) // alphabetical
|
||||
return is
|
||||
}
|
||||
|
||||
// buildOutputSchema produces the outputSchema for one API method.
|
||||
func buildOutputSchema(method map[string]interface{}) *OutputSchema {
|
||||
os := &OutputSchema{
|
||||
Type: "object",
|
||||
Properties: &OrderedProps{Map: make(map[string]Property)},
|
||||
}
|
||||
respRaw, _ := method["responseBody"].(map[string]interface{})
|
||||
for _, k := range orderedKeys(respRaw, "responseBody") {
|
||||
field, _ := respRaw[k].(map[string]interface{})
|
||||
os.Properties.Order = append(os.Properties.Order, k)
|
||||
os.Properties.Map[k] = convertProperty(field, "responseBody."+k+".properties")
|
||||
}
|
||||
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.
|
||||
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
|
||||
}
|
||||
name += " " + methodName
|
||||
|
||||
desc, _ := method["description"].(string)
|
||||
|
||||
return Envelope{
|
||||
Name: name,
|
||||
Description: desc,
|
||||
InputSchema: buildInputSchema(method),
|
||||
OutputSchema: buildOutputSchema(method),
|
||||
Meta: buildMeta(method),
|
||||
}
|
||||
}
|
||||
|
||||
// MethodFilter is an optional predicate used by AssembleService and
|
||||
// AssembleAll to filter methods (e.g. by access token for strict mode).
|
||||
// Pass nil to include all methods.
|
||||
type MethodFilter func(method map[string]interface{}) bool
|
||||
|
||||
// AssembleService assembles all methods under one service into a sorted
|
||||
// envelope slice (sorted by Envelope.Name ascending).
|
||||
func AssembleService(serviceName string, spec map[string]interface{}, filter MethodFilter) []Envelope {
|
||||
if spec == nil {
|
||||
return nil
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
var out []Envelope
|
||||
walkMethods(resources, nil, func(resourcePath []string, methodName string, method map[string]interface{}) {
|
||||
if filter != nil && !filter(method) {
|
||||
return
|
||||
}
|
||||
out = append(out, AssembleEnvelope(serviceName, resourcePath, methodName, method))
|
||||
})
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// AssembleAll assembles every embedded service into one big sorted slice.
|
||||
// Uses embedded data only (bypasses remote overlay) so envelope output is
|
||||
// deterministic across machines (CI vs dev vs different user brands).
|
||||
func AssembleAll(filter MethodFilter) []Envelope {
|
||||
var out []Envelope
|
||||
for _, svc := range registry.EmbeddedServiceNames() {
|
||||
spec := registry.EmbeddedSpec(svc)
|
||||
out = append(out, AssembleService(svc, spec, filter)...)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// walkMethods recursively walks resources -> methods, calling visit for each
|
||||
// terminal method. It supports nested resources via the optional "resources"
|
||||
// key inside a resource value (matches meta_data.json structure).
|
||||
func walkMethods(resources map[string]interface{}, parentPath []string,
|
||||
visit func(resourcePath []string, methodName string, method map[string]interface{})) {
|
||||
for resName, resRaw := range resources {
|
||||
resMap, ok := resRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
curPath := append(append([]string(nil), parentPath...), resName)
|
||||
if methods, ok := resMap["methods"].(map[string]interface{}); ok {
|
||||
for mName, mRaw := range methods {
|
||||
if m, ok := mRaw.(map[string]interface{}); ok {
|
||||
visit(curPath, mName, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if nested, ok := resMap["resources"].(map[string]interface{}); ok {
|
||||
walkMethods(nested, curPath, visit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
keys := make([]string, 0, len(raw))
|
||||
for k := range raw {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
782
internal/schema/assembler_test.go
Normal file
782
internal/schema/assembler_test.go
Normal file
@@ -0,0 +1,782 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
// TestMain isolates registry-backed tests from any host ~/.lark-cli cache so
|
||||
// the suite gives the same answer on every machine. Without this, a stale
|
||||
// local remote_meta.json could surface methods that aren't in the embedded
|
||||
// snapshot (or alter their data) depending on the contributor's environment.
|
||||
//
|
||||
// Note: os.Exit skips deferred functions, so cleanup is done explicitly
|
||||
// after m.Run before exiting.
|
||||
func TestMain(m *testing.M) {
|
||||
dir, err := os.MkdirTemp("", "schema-test-cfg-*")
|
||||
if err != nil {
|
||||
// Surface the failure rather than silently running against the host
|
||||
// cache — that defeats the whole purpose of this isolation.
|
||||
println("schema test setup: MkdirTemp failed:", err.Error())
|
||||
os.Exit(2)
|
||||
}
|
||||
os.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
os.Setenv("LARKSUITE_CLI_REMOTE_META", "off") // never touch network
|
||||
code := m.Run()
|
||||
os.RemoveAll(dir)
|
||||
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
|
||||
input map[string]interface{}
|
||||
wantType string
|
||||
}{
|
||||
{"string", map[string]interface{}{"type": "string"}, "string"},
|
||||
{"integer", map[string]interface{}{"type": "integer"}, "integer"},
|
||||
{"boolean", map[string]interface{}{"type": "boolean"}, "boolean"},
|
||||
{"number", map[string]interface{}{"type": "number"}, "number"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := convertProperty(tt.input, "")
|
||||
if got.Type != tt.wantType {
|
||||
t.Errorf("Type = %q, want %q", got.Type, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_FileBinary(t *testing.T) {
|
||||
input := map[string]interface{}{"type": "file", "description": "upload"}
|
||||
got := convertProperty(input, "")
|
||||
if got.Type != "string" {
|
||||
t.Errorf("Type = %q, want \"string\"", got.Type)
|
||||
}
|
||||
if got.Format != "binary" {
|
||||
t.Errorf("Format = %q, want \"binary\"", got.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_OptionsToEnum(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"type": "string",
|
||||
"options": []interface{}{
|
||||
map[string]interface{}{"value": "banana"},
|
||||
map[string]interface{}{"value": "apple"},
|
||||
map[string]interface{}{"value": "banana"}, // duplicate
|
||||
},
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
// string enums preserve source order (deduped), matching the `enum`
|
||||
// branch. Numeric/boolean enums would still be sorted by value.
|
||||
want := []interface{}{"banana", "apple"}
|
||||
if !reflect.DeepEqual(got.Enum, want) {
|
||||
t.Errorf("Enum = %v, want %v", got.Enum, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_EnumPassThrough(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"type": "string",
|
||||
"enum": []interface{}{"x", "y"},
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
want := []interface{}{"x", "y"} // pass through, no sort
|
||||
if !reflect.DeepEqual(got.Enum, want) {
|
||||
t.Errorf("Enum = %v, want %v", got.Enum, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_EnumIntegerCoerce(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"type": "integer",
|
||||
"options": []interface{}{
|
||||
map[string]interface{}{"value": "10"},
|
||||
map[string]interface{}{"value": "1"},
|
||||
map[string]interface{}{"value": "2"},
|
||||
},
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
want := []interface{}{int64(1), int64(2), int64(10)} // typed + numerically sorted
|
||||
if !reflect.DeepEqual(got.Enum, want) {
|
||||
t.Errorf("Enum = %v, want %v", got.Enum, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_ListTypeFallback(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"type": "list",
|
||||
"description": "ids",
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
if got.Type != "array" {
|
||||
t.Errorf("Type = %q, want %q", got.Type, "array")
|
||||
}
|
||||
if got.Items == nil {
|
||||
t.Fatalf("Items = nil, want non-nil (any-schema fallback)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_MinMaxParsing(t *testing.T) {
|
||||
input := map[string]interface{}{"type": "integer", "min": "10", "max": "50"}
|
||||
got := convertProperty(input, "")
|
||||
if got.Minimum == nil || *got.Minimum != 10.0 {
|
||||
t.Errorf("Minimum = %v, want 10", got.Minimum)
|
||||
}
|
||||
if got.Maximum == nil || *got.Maximum != 50.0 {
|
||||
t.Errorf("Maximum = %v, want 50", got.Maximum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_MinMaxInvalid(t *testing.T) {
|
||||
input := map[string]interface{}{"type": "integer", "min": "not_a_number"}
|
||||
got := convertProperty(input, "")
|
||||
if got.Minimum != nil {
|
||||
t.Errorf("Minimum = %v, want nil for unparseable min", got.Minimum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_ArrayWithProperties(t *testing.T) {
|
||||
// meta_data quirk: array element schema is in "properties" not "items"
|
||||
input := map[string]interface{}{
|
||||
"type": "array",
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]interface{}{"type": "string"},
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
if got.Type != "array" {
|
||||
t.Fatalf("Type = %q, want \"array\"", got.Type)
|
||||
}
|
||||
if got.Items == nil {
|
||||
t.Fatal("Items is nil, want non-nil")
|
||||
}
|
||||
if got.Items.Type != "object" {
|
||||
t.Errorf("Items.Type = %q, want \"object\"", got.Items.Type)
|
||||
}
|
||||
if got.Items.Properties == nil || len(got.Items.Properties.Map) != 2 {
|
||||
t.Errorf("Items.Properties did not contain both id and name")
|
||||
}
|
||||
if got.Properties != nil {
|
||||
t.Error("array Property must not have top-level Properties after unfold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_ObjectWithProperties(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"x": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
if got.Type != "object" {
|
||||
t.Errorf("Type = %q, want \"object\"", got.Type)
|
||||
}
|
||||
if got.Properties == nil || got.Properties.Map["x"].Type != "string" {
|
||||
t.Errorf("nested Properties not preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_InferObjectFromProperties(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"properties": map[string]interface{}{
|
||||
"y": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
if got.Type != "object" {
|
||||
t.Errorf("Type = %q, want \"object\" (inferred)", got.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_DropsRefAndAnnotations(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"type": "string",
|
||||
"ref": "operator",
|
||||
"annotations": []interface{}{"readOnly"},
|
||||
"enumName": "FooEnum",
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
// 这些字段直接被丢弃;Property 结构里也没存这些字段,断言只有 type 设置即可
|
||||
if got.Type != "string" {
|
||||
t.Errorf("Type = %q", got.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertProperty_DescriptionDefaultExample(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "hello\nworld",
|
||||
"default": "",
|
||||
"example": "ex",
|
||||
}
|
||||
got := convertProperty(input, "")
|
||||
if got.Description != "hello\nworld" {
|
||||
t.Errorf("Description not preserved verbatim")
|
||||
}
|
||||
if got.Default != "" {
|
||||
t.Errorf("Default = %v, want empty string (preserved)", got.Default)
|
||||
}
|
||||
if got.Example != "ex" {
|
||||
t.Errorf("Example = %v, want \"ex\"", got.Example)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if is.Type != "object" {
|
||||
t.Errorf("Type = %q, want \"object\"", is.Type)
|
||||
}
|
||||
// top-level required: ["params"] because message_id is a required path param
|
||||
if !reflect.DeepEqual(is.Required, []string{"params"}) {
|
||||
t.Errorf("Required = %v, want [params]", is.Required)
|
||||
}
|
||||
// top-level properties only contains "params" (no body fields, no high-risk-write)
|
||||
if !reflect.DeepEqual(is.Properties.Order, []string{"params"}) {
|
||||
t.Errorf("top-level properties order = %v, want [params]", is.Properties.Order)
|
||||
}
|
||||
// params sub-object: required + property order
|
||||
params := is.Properties.Map["params"]
|
||||
if params.Type != "object" {
|
||||
t.Errorf("params.Type = %q, want \"object\"", params.Type)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// top-level required: ["data", "file"] — image_type body required + image file required
|
||||
if !reflect.DeepEqual(is.Required, []string{"data", "file"}) {
|
||||
t.Errorf("Required = %v, want [data, file]", is.Required)
|
||||
}
|
||||
// top-level properties: data (for non-file body) + file (for binary upload)
|
||||
if !reflect.DeepEqual(is.Properties.Order, []string{"data", "file"}) {
|
||||
t.Errorf("top-level properties order = %v, want [data, file]", is.Properties.Order)
|
||||
}
|
||||
// data sub-object carries only non-file body fields (image_type)
|
||||
data := is.Properties.Map["data"]
|
||||
if !reflect.DeepEqual(data.Required, []string{"image_type"}) {
|
||||
t.Errorf("data.Required = %v, want [image_type]", data.Required)
|
||||
}
|
||||
if !reflect.DeepEqual(data.Properties.Order, []string{"image_type"}) {
|
||||
t.Errorf("data.properties order = %v, want [image_type]", data.Properties.Order)
|
||||
}
|
||||
if it := data.Properties.Map["image_type"]; !reflect.DeepEqual(it.Enum, []interface{}{"message", "avatar"}) {
|
||||
t.Errorf("image_type unexpected: %+v", it)
|
||||
}
|
||||
if _, isFile := data.Properties.Map["image"]; isFile {
|
||||
t.Errorf("image (file field) should NOT appear in data sub-object")
|
||||
}
|
||||
|
||||
// file sub-object carries the binary upload field
|
||||
file := is.Properties.Map["file"]
|
||||
if file.Type != "object" {
|
||||
t.Errorf("file.Type = %q, want \"object\"", file.Type)
|
||||
}
|
||||
if !reflect.DeepEqual(file.Required, []string{"image"}) {
|
||||
t.Errorf("file.Required = %v, want [image]", file.Required)
|
||||
}
|
||||
if !reflect.DeepEqual(file.Properties.Order, []string{"image"}) {
|
||||
t.Errorf("file.properties order = %v, want [image]", file.Properties.Order)
|
||||
}
|
||||
img := file.Properties.Map["image"]
|
||||
if img.Type != "string" {
|
||||
t.Errorf("image.Type = %q, want \"string\"", img.Type)
|
||||
}
|
||||
if img.Format != "binary" {
|
||||
t.Errorf("image.Format = %q, want \"binary\"", img.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) {
|
||||
// Synthesized method to avoid registry-overlay variance (remote cache may
|
||||
// strip `risk` field); buildInputSchema only cares about the method map.
|
||||
method := map[string]interface{}{
|
||||
"risk": "high-risk-write",
|
||||
"parameters": map[string]interface{}{
|
||||
"message_id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"location": "path",
|
||||
"required": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
currentMethodOrder = nil
|
||||
defer func() { currentMethodOrder = nil }()
|
||||
|
||||
is := buildInputSchema(method)
|
||||
|
||||
// yes lives at inputSchema.properties.yes (sibling of params/data)
|
||||
yes, ok := is.Properties.Map["yes"]
|
||||
if !ok {
|
||||
t.Fatal("expected top-level `yes` property in high-risk-write envelope, not found")
|
||||
}
|
||||
if yes.Type != "boolean" {
|
||||
t.Errorf("yes.Type = %q, want \"boolean\"", yes.Type)
|
||||
}
|
||||
if v, _ := yes.Default.(bool); v != false {
|
||||
t.Errorf("yes.Default = %v, want false", yes.Default)
|
||||
}
|
||||
// yes must NOT be in top-level required
|
||||
for _, r := range is.Required {
|
||||
if r == "yes" {
|
||||
t.Errorf("`yes` should not appear in top-level required")
|
||||
}
|
||||
}
|
||||
// yes is appended to properties.Order
|
||||
last := is.Properties.Order[len(is.Properties.Order)-1]
|
||||
if last != "yes" {
|
||||
t.Errorf("`yes` should be last in properties.Order, got: %v", is.Properties.Order)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Errorf("`yes` must not be injected for risk=read")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if os.Type != "object" {
|
||||
t.Errorf("Type = %q, want \"object\"", os.Type)
|
||||
}
|
||||
// Top-level response: has_more, page_token, items
|
||||
if _, ok := os.Properties.Map["items"]; !ok {
|
||||
t.Fatal("items not found in outputSchema")
|
||||
}
|
||||
items := os.Properties.Map["items"]
|
||||
if items.Type != "array" {
|
||||
t.Errorf("items.Type = %q, want \"array\"", items.Type)
|
||||
}
|
||||
if items.Items == nil {
|
||||
t.Fatal("items.Items is nil (array unfold failed)")
|
||||
}
|
||||
if items.Items.Type != "object" {
|
||||
t.Errorf("items.Items.Type = %q, want \"object\"", items.Items.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertAccessTokens(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []interface{}
|
||||
want []string
|
||||
}{
|
||||
{"tenant only", []interface{}{"tenant"}, []string{"bot"}},
|
||||
{"user only", []interface{}{"user"}, []string{"user"}},
|
||||
{"tenant then user", []interface{}{"tenant", "user"}, []string{"bot", "user"}},
|
||||
{"user then tenant", []interface{}{"user", "tenant"}, []string{"bot", "user"}},
|
||||
{"deduped", []interface{}{"tenant", "tenant", "user"}, []string{"bot", "user"}},
|
||||
{"empty", []interface{}{}, []string{}},
|
||||
{"nil", nil, []string{}},
|
||||
{"unknown skipped", []interface{}{"user", "admin"}, []string{"user"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := convertAccessTokens(tt.input)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeta_FullFields(t *testing.T) {
|
||||
// Synthesized method to avoid runtime variance from remote-cache overlay
|
||||
// (which strips `risk` from merged services). All other field semantics
|
||||
// match the real im.images.create entry in meta_data.json.
|
||||
method := map[string]interface{}{
|
||||
"risk": "write",
|
||||
"danger": true,
|
||||
"scopes": []interface{}{
|
||||
"im:resource:upload",
|
||||
"im:resource",
|
||||
},
|
||||
"accessTokens": []interface{}{"tenant"},
|
||||
"docUrl": "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/image/create",
|
||||
}
|
||||
m := buildMeta(method)
|
||||
|
||||
if m.EnvelopeVersion != "1.0" {
|
||||
t.Errorf("EnvelopeVersion = %q", m.EnvelopeVersion)
|
||||
}
|
||||
if m.Risk != "write" {
|
||||
t.Errorf("Risk = %q, want \"write\"", m.Risk)
|
||||
}
|
||||
if !m.Danger {
|
||||
t.Errorf("Danger = false, want true")
|
||||
}
|
||||
if !reflect.DeepEqual(m.AccessTokens, []string{"bot"}) {
|
||||
t.Errorf("AccessTokens = %v, want [bot]", m.AccessTokens)
|
||||
}
|
||||
if m.DocURL == "" {
|
||||
t.Errorf("DocURL should be present for im.images.create")
|
||||
}
|
||||
if !reflect.DeepEqual(m.Scopes, []string{"im:resource:upload", "im:resource"}) {
|
||||
t.Errorf("Scopes = %v, want [im:resource:upload, im:resource] (meta_data natural order)", m.Scopes)
|
||||
}
|
||||
if m.RequiredScopes == nil {
|
||||
t.Errorf("RequiredScopes should be empty slice, not nil")
|
||||
}
|
||||
if len(m.RequiredScopes) != 0 {
|
||||
t.Errorf("RequiredScopes should be empty for this method, got %v", m.RequiredScopes)
|
||||
}
|
||||
if m.Affordance != nil {
|
||||
t.Errorf("Affordance must be nil when method has no affordance field, got %+v", m.Affordance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeta_MissingRiskDefaultsToRead(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"scopes": []interface{}{"x"},
|
||||
"accessTokens": []interface{}{"user"},
|
||||
// no risk field
|
||||
}
|
||||
m := buildMeta(method)
|
||||
if m.Risk != "read" {
|
||||
t.Errorf("Risk = %q, want \"read\" (default for missing risk)", m.Risk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeta_RequiredScopesPresent(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "mail", []string{"user_mailbox", "messages"}, "get")
|
||||
m := buildMeta(method)
|
||||
if len(m.RequiredScopes) == 0 {
|
||||
t.Errorf("RequiredScopes should be non-empty for mail.user_mailbox.messages.get")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAffordance_NilOrEmpty(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw interface{}
|
||||
}{
|
||||
{"nil", nil},
|
||||
{"empty object", map[string]interface{}{}},
|
||||
{"all-five-empty-arrays", map[string]interface{}{
|
||||
"use_when": []interface{}{},
|
||||
"do_not_use_when": []interface{}{},
|
||||
"prerequisites": []interface{}{},
|
||||
"examples": []interface{}{},
|
||||
"related": []interface{}{},
|
||||
}},
|
||||
{"malformed (string)", "not an object"},
|
||||
{"malformed (number)", 42},
|
||||
{"malformed (nested type mismatch)", map[string]interface{}{
|
||||
"examples": "should be a list, not a string",
|
||||
}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := parseAffordance(c.raw); got != nil {
|
||||
t.Errorf("parseAffordance(%v) = %+v, want nil", c.raw, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAffordance_FullPopulated(t *testing.T) {
|
||||
raw := map[string]interface{}{
|
||||
"use_when": []interface{}{"需要拿到当前用户的主日历 ID"},
|
||||
"do_not_use_when": []interface{}{"已知具体某一个非主日历的 calendar_id"},
|
||||
"prerequisites": []interface{}{"user 身份登录"},
|
||||
"examples": []interface{}{
|
||||
map[string]interface{}{"description": "获取主日历", "command": "lark-cli calendar calendars primary"},
|
||||
},
|
||||
"related": []interface{}{"calendars.list"},
|
||||
}
|
||||
a := parseAffordance(raw)
|
||||
if a == nil {
|
||||
t.Fatal("parseAffordance returned nil, want populated")
|
||||
}
|
||||
if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" {
|
||||
t.Errorf("UseWhen = %v", a.UseWhen)
|
||||
}
|
||||
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) != 1 || a.Related[0] != "calendars.list" {
|
||||
t.Errorf("Related = %v", a.Related)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeta_AffordanceFromMethod(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"scopes": []interface{}{"x"},
|
||||
"accessTokens": []interface{}{"user"},
|
||||
"risk": "read",
|
||||
"affordance": map[string]interface{}{
|
||||
"use_when": []interface{}{"trigger"},
|
||||
},
|
||||
}
|
||||
m := buildMeta(method)
|
||||
if m.Affordance == nil {
|
||||
t.Fatal("Affordance should be populated from method[\"affordance\"]")
|
||||
}
|
||||
if len(m.Affordance.UseWhen) != 1 || m.Affordance.UseWhen[0] != "trigger" {
|
||||
t.Errorf("UseWhen = %v", m.Affordance.UseWhen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
|
||||
method := map[string]interface{}{
|
||||
"scopes": []interface{}{"x"},
|
||||
"accessTokens": []interface{}{"user"},
|
||||
"risk": "read",
|
||||
// no docUrl
|
||||
}
|
||||
m := buildMeta(method)
|
||||
if m.DocURL != "" {
|
||||
t.Errorf("DocURL = %q, want empty (will be omitempty)", m.DocURL)
|
||||
}
|
||||
// Verify JSON serialization omits doc_url
|
||||
b, _ := json.Marshal(m)
|
||||
if strings.Contains(string(b), "doc_url") {
|
||||
t.Errorf("doc_url should be omitted from JSON, got: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if os.Properties == nil {
|
||||
t.Fatal("Properties is nil, want empty OrderedProps")
|
||||
}
|
||||
if len(os.Properties.Order) != 0 {
|
||||
t.Errorf("Properties.Order should be empty, got %v", os.Properties.Order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleEnvelope_ReactionsList_FullStructure(t *testing.T) {
|
||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||
env := AssembleEnvelope("im", []string{"reactions"}, "list", method)
|
||||
|
||||
if env.Name != "im reactions list" {
|
||||
t.Errorf("Name = %q, want \"im reactions list\"", env.Name)
|
||||
}
|
||||
if env.Description == "" {
|
||||
t.Errorf("Description should not be empty for im.reactions.list")
|
||||
}
|
||||
if env.InputSchema == nil || env.OutputSchema == nil || env.Meta == nil {
|
||||
t.Fatal("InputSchema/OutputSchema/Meta must all be non-nil")
|
||||
}
|
||||
if env.Meta.EnvelopeVersion != "1.0" {
|
||||
t.Errorf("Meta.EnvelopeVersion = %q", env.Meta.EnvelopeVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleEnvelope_NestedResource_NameJoinedWithSpaces(t *testing.T) {
|
||||
// im.chat.members.create — resource path is one element "chat.members" with
|
||||
// an internal dot. Substituted from plan's `bots` because remote-cache
|
||||
// overlay strips `bots` from the loaded method map on this environment;
|
||||
// the assertion is about name joining, not method specifics.
|
||||
method := loadMethodFromRegistry(t, "im", []string{"chat.members"}, "create")
|
||||
env := AssembleEnvelope("im", []string{"chat.members"}, "create", method)
|
||||
// chat.members resourcePath stays as one element in the slice with a dot;
|
||||
// name should split it to "im chat.members create" — we keep the dot as-is
|
||||
// inside the resource segment to round-trip with completion logic.
|
||||
if env.Name != "im chat.members create" {
|
||||
t.Errorf("Name = %q, want \"im chat.members create\"", env.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleEnvelope_JSONIsStable(t *testing.T) {
|
||||
// Assemble twice; JSON output must be byte-identical (determinism).
|
||||
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
|
||||
a := AssembleEnvelope("im", []string{"reactions"}, "list", method)
|
||||
b := AssembleEnvelope("im", []string{"reactions"}, "list", method)
|
||||
ja, _ := json.MarshalIndent(a, "", " ")
|
||||
jb, _ := json.MarshalIndent(b, "", " ")
|
||||
if string(ja) != string(jb) {
|
||||
t.Errorf("envelope assembly is non-deterministic:\nfirst:\n%s\nsecond:\n%s", ja, jb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleService_Im(t *testing.T) {
|
||||
spec := registry.LoadFromMeta("im")
|
||||
envs := AssembleService("im", spec, nil)
|
||||
if len(envs) == 0 {
|
||||
t.Fatal("expected non-empty envelopes for service im")
|
||||
}
|
||||
// Every envelope.Name starts with "im "
|
||||
for _, e := range envs {
|
||||
if !strings.HasPrefix(e.Name, "im ") {
|
||||
t.Errorf("envelope name %q does not start with \"im \"", e.Name)
|
||||
}
|
||||
}
|
||||
// Sorted by name
|
||||
for i := 1; i < len(envs); i++ {
|
||||
if envs[i-1].Name > envs[i].Name {
|
||||
t.Errorf("envelopes not sorted by name at idx %d: %q > %q", i, envs[i-1].Name, envs[i].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleService_FilterByAccessToken(t *testing.T) {
|
||||
spec := registry.LoadFromMeta("im")
|
||||
// Filter to bot-only (--as bot, which corresponds to "tenant")
|
||||
envs := AssembleService("im", spec, func(method map[string]interface{}) bool {
|
||||
tokens, _ := method["accessTokens"].([]interface{})
|
||||
for _, t := range tokens {
|
||||
if s, _ := t.(string); s == "tenant" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
// Every envelope's _meta.access_tokens must contain "bot"
|
||||
for _, e := range envs {
|
||||
found := false
|
||||
for _, t := range e.Meta.AccessTokens {
|
||||
if t == "bot" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("envelope %q does not declare bot access", e.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembleAll_AtLeast193(t *testing.T) {
|
||||
envs := AssembleAll(nil)
|
||||
// Envelope assembly is overlay-independent (Task 17b): AssembleAll walks the
|
||||
// embedded meta_data.json directly, so the count is stable across machines.
|
||||
if len(envs) < 193 {
|
||||
t.Errorf("AssembleAll returned %d envelopes, expected >= 193", len(envs))
|
||||
}
|
||||
// Spot check: im reactions list should be present
|
||||
found := false
|
||||
for _, e := range envs {
|
||||
if e.Name == "im reactions list" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("im reactions list not found in AssembleAll output")
|
||||
}
|
||||
}
|
||||
|
||||
// loadMethodFromRegistry is a test helper that pulls one method's spec from the
|
||||
// real embedded meta_data.json via the registry package.
|
||||
func loadMethodFromRegistry(t *testing.T, service string, resourcePath []string, methodName string) map[string]interface{} {
|
||||
t.Helper()
|
||||
spec := registry.LoadFromMeta(service)
|
||||
if spec == nil {
|
||||
t.Fatalf("service %q not found in registry", service)
|
||||
}
|
||||
resources, _ := spec["resources"].(map[string]interface{})
|
||||
resKey := strings.Join(resourcePath, ".")
|
||||
res, ok := resources[resKey].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("resource %q.%s not found", service, resKey)
|
||||
}
|
||||
methods, _ := res["methods"].(map[string]interface{})
|
||||
m, ok := methods[methodName].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("method %q.%s.%s not found", service, resKey, methodName)
|
||||
}
|
||||
return m
|
||||
}
|
||||
233
internal/schema/lint.go
Normal file
233
internal/schema/lint.go
Normal file
@@ -0,0 +1,233 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
var validJSONSchemaTypes = map[string]bool{
|
||||
"string": true,
|
||||
"integer": true,
|
||||
"number": true,
|
||||
"boolean": true,
|
||||
"array": true,
|
||||
"object": true,
|
||||
}
|
||||
|
||||
var validAccessTokens = map[string]bool{
|
||||
"user": true,
|
||||
"bot": true,
|
||||
}
|
||||
|
||||
// lintEnvelope runs L1-L3 checks and returns a list of errors. Empty slice
|
||||
// means the envelope is compliant.
|
||||
func lintEnvelope(env Envelope) []error {
|
||||
var errs []error
|
||||
|
||||
// ---- L1: structural ----
|
||||
if env.Name == "" {
|
||||
errs = append(errs, errors.New("L1: name must not be empty"))
|
||||
}
|
||||
if env.InputSchema == nil {
|
||||
errs = append(errs, errors.New("L1: inputSchema must not be nil"))
|
||||
} else {
|
||||
if env.InputSchema.Type != "object" {
|
||||
errs = append(errs, fmt.Errorf("L1: inputSchema.type = %q, want \"object\"", env.InputSchema.Type))
|
||||
}
|
||||
if env.InputSchema.Properties == nil {
|
||||
errs = append(errs, errors.New("L1: inputSchema.properties must not be nil"))
|
||||
}
|
||||
}
|
||||
if env.OutputSchema == nil {
|
||||
errs = append(errs, errors.New("L1: outputSchema must not be nil"))
|
||||
} else {
|
||||
if env.OutputSchema.Type != "object" {
|
||||
errs = append(errs, fmt.Errorf("L1: outputSchema.type = %q, want \"object\"", env.OutputSchema.Type))
|
||||
}
|
||||
}
|
||||
if env.Meta == nil {
|
||||
errs = append(errs, errors.New("L1: _meta must not be nil"))
|
||||
// Cannot continue meta-dependent checks
|
||||
return errs
|
||||
}
|
||||
if env.Meta.EnvelopeVersion != "1.0" {
|
||||
errs = append(errs, fmt.Errorf("L1: _meta.envelope_version = %q, want \"1.0\"", env.Meta.EnvelopeVersion))
|
||||
}
|
||||
|
||||
// L1: validate every Property type recursively
|
||||
if env.InputSchema != nil && env.InputSchema.Properties != nil {
|
||||
validatePropertyTypes(env.InputSchema.Properties, &errs)
|
||||
}
|
||||
if env.OutputSchema != nil && env.OutputSchema.Properties != nil {
|
||||
validatePropertyTypes(env.OutputSchema.Properties, &errs)
|
||||
}
|
||||
|
||||
// ---- L2: type-level consistency ----
|
||||
if env.InputSchema != nil && env.InputSchema.Properties != nil {
|
||||
// Walk the whole property tree so format/min-max checks reach leaf
|
||||
// fields nested under the params/data wrapper.
|
||||
walkForL2(env.InputSchema.Properties, &errs)
|
||||
// Top-level required keys must exist in top-level properties.
|
||||
for _, r := range env.InputSchema.Required {
|
||||
if _, ok := env.InputSchema.Properties.Map[r]; !ok {
|
||||
errs = append(errs, fmt.Errorf("L2: required key %q not found in properties", r))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- L3: cross-field self-consistency ----
|
||||
dangerExpected := env.Meta.Risk == cmdutil.RiskWrite || env.Meta.Risk == cmdutil.RiskHighRiskWrite
|
||||
if env.Meta.Danger != dangerExpected {
|
||||
errs = append(errs, fmt.Errorf("L3: _meta.danger=%v inconsistent with risk=%q", env.Meta.Danger, env.Meta.Risk))
|
||||
}
|
||||
|
||||
// `yes` lives at inputSchema.properties.yes (sibling of params/data),
|
||||
// injected only for risk == RiskHighRiskWrite.
|
||||
hasYes := false
|
||||
if env.InputSchema != nil && env.InputSchema.Properties != nil {
|
||||
_, hasYes = env.InputSchema.Properties.Map["yes"]
|
||||
}
|
||||
wantYes := env.Meta.Risk == cmdutil.RiskHighRiskWrite
|
||||
if hasYes != wantYes {
|
||||
errs = append(errs, fmt.Errorf("L3: inputSchema `yes` property=%v inconsistent with risk=%q", hasYes, env.Meta.Risk))
|
||||
}
|
||||
|
||||
if len(env.Meta.AccessTokens) == 0 {
|
||||
errs = append(errs, errors.New("L3: _meta.access_tokens must not be empty"))
|
||||
}
|
||||
for _, t := range env.Meta.AccessTokens {
|
||||
if !validAccessTokens[t] {
|
||||
errs = append(errs, fmt.Errorf("L3: _meta.access_tokens contains invalid value %q (allowed: user, bot)", t))
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
// walkForL2 recursively applies per-field L2 checks (format:binary on
|
||||
// non-string; minimum>=maximum) plus the sub-object required-exists invariant.
|
||||
// Required only matters on object-typed Properties (e.g. the params / data
|
||||
// wrappers); leaf scalars ignore it.
|
||||
func walkForL2(props *OrderedProps, errs *[]error) {
|
||||
if props == nil {
|
||||
return
|
||||
}
|
||||
for _, k := range props.Order {
|
||||
p := props.Map[k]
|
||||
if p.Format == "binary" && p.Type != "string" {
|
||||
*errs = append(*errs, fmt.Errorf("L2: field %q has format: binary but type = %q (want string)", k, p.Type))
|
||||
}
|
||||
if p.Minimum != nil && p.Maximum != nil && *p.Minimum >= *p.Maximum {
|
||||
*errs = append(*errs, fmt.Errorf("L2: field %q minimum (%v) >= maximum (%v)", k, *p.Minimum, *p.Maximum))
|
||||
}
|
||||
if len(p.Required) > 0 && p.Properties != nil {
|
||||
for _, r := range p.Required {
|
||||
if _, ok := p.Properties.Map[r]; !ok {
|
||||
*errs = append(*errs, fmt.Errorf("L2: required key %q in %q not found in its properties", r, k))
|
||||
}
|
||||
}
|
||||
}
|
||||
if p.Properties != nil {
|
||||
walkForL2(p.Properties, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validatePropertyTypes walks an OrderedProps tree and asserts:
|
||||
// - every Property.Type is in validJSONSchemaTypes (or empty for nested objects with only properties)
|
||||
// - array Properties have Items
|
||||
//
|
||||
// Errors are appended to *errs.
|
||||
func validatePropertyTypes(props *OrderedProps, errs *[]error) {
|
||||
if props == nil {
|
||||
return
|
||||
}
|
||||
for _, k := range props.Order {
|
||||
p := props.Map[k]
|
||||
if p.Type != "" && !validJSONSchemaTypes[p.Type] {
|
||||
*errs = append(*errs, fmt.Errorf("L1: property %q has invalid type %q", k, p.Type))
|
||||
}
|
||||
if p.Type == "array" && p.Items == nil {
|
||||
*errs = append(*errs, fmt.Errorf("L1: array property %q missing items", k))
|
||||
}
|
||||
if p.Properties != nil {
|
||||
validatePropertyTypes(p.Properties, errs)
|
||||
}
|
||||
// Validate the array-element schema itself, not only its child
|
||||
// properties — a primitive element with an invalid type (e.g.
|
||||
// `items.type = "list"`) would otherwise slip past lint.
|
||||
if p.Items != nil {
|
||||
validateItemSchema(k, p.Items, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateItemSchema checks a single array element schema for invalid types,
|
||||
// then recurses into any further nested properties/items.
|
||||
func validateItemSchema(parentKey string, item *Property, errs *[]error) {
|
||||
if item.Type != "" && !validJSONSchemaTypes[item.Type] {
|
||||
*errs = append(*errs, fmt.Errorf("L1: array property %q items has invalid type %q", parentKey, item.Type))
|
||||
}
|
||||
if item.Type == "array" && item.Items == nil {
|
||||
*errs = append(*errs, fmt.Errorf("L1: array property %q items (nested array) missing items", parentKey))
|
||||
}
|
||||
if item.Properties != nil {
|
||||
validatePropertyTypes(item.Properties, errs)
|
||||
}
|
||||
if item.Items != nil {
|
||||
validateItemSchema(parentKey, item.Items, errs)
|
||||
}
|
||||
}
|
||||
|
||||
// coverageBaseline is the per-metric warn threshold for L4 coverage checks.
|
||||
// If the measured rate drops below the baseline, t.Logf emits a warning but
|
||||
// does NOT fail the test. Adjust these constants upward as meta_data quality
|
||||
// improves over time.
|
||||
var coverageBaseline = map[string]float64{
|
||||
"description": 0.99,
|
||||
"scopes": 1.00,
|
||||
"doc_url": 0.98,
|
||||
"risk": 0.96,
|
||||
}
|
||||
|
||||
// measureCoverage returns the non-empty rate for each tracked metric.
|
||||
func measureCoverage(envs []Envelope) map[string]float64 {
|
||||
if len(envs) == 0 {
|
||||
return map[string]float64{
|
||||
"description": 0,
|
||||
"scopes": 0,
|
||||
"doc_url": 0,
|
||||
"risk": 0,
|
||||
}
|
||||
}
|
||||
total := float64(len(envs))
|
||||
var descNonEmpty, scopesNonEmpty, docURLNonEmpty, riskNonEmpty float64
|
||||
for _, e := range envs {
|
||||
if e.Description != "" {
|
||||
descNonEmpty++
|
||||
}
|
||||
if e.Meta == nil {
|
||||
continue
|
||||
}
|
||||
if len(e.Meta.Scopes) > 0 {
|
||||
scopesNonEmpty++
|
||||
}
|
||||
if e.Meta.DocURL != "" {
|
||||
docURLNonEmpty++
|
||||
}
|
||||
if e.Meta.Risk != "" {
|
||||
riskNonEmpty++
|
||||
}
|
||||
}
|
||||
return map[string]float64{
|
||||
"description": descNonEmpty / total,
|
||||
"scopes": scopesNonEmpty / total,
|
||||
"doc_url": docURLNonEmpty / total,
|
||||
"risk": riskNonEmpty / total,
|
||||
}
|
||||
}
|
||||
379
internal/schema/lint_test.go
Normal file
379
internal/schema/lint_test.go
Normal file
@@ -0,0 +1,379 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
// validEnvelope builds a baseline valid envelope used as a starting point in
|
||||
// negative tests below.
|
||||
func validEnvelope() Envelope {
|
||||
props := &OrderedProps{Map: map[string]Property{}}
|
||||
return Envelope{
|
||||
Name: "x y z",
|
||||
Description: "ok",
|
||||
InputSchema: &InputSchema{
|
||||
Type: "object",
|
||||
Properties: props,
|
||||
},
|
||||
OutputSchema: &OutputSchema{
|
||||
Type: "object",
|
||||
Properties: &OrderedProps{Map: map[string]Property{}},
|
||||
},
|
||||
Meta: &Meta{
|
||||
EnvelopeVersion: "1.0",
|
||||
AccessTokens: []string{"user"},
|
||||
Risk: "read",
|
||||
Danger: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestLintEnvelope_Valid(t *testing.T) {
|
||||
env := validEnvelope()
|
||||
errs := lintEnvelope(env)
|
||||
if len(errs) != 0 {
|
||||
t.Errorf("expected no errors, got: %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLintEnvelope_L1_StructuralChecks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Envelope)
|
||||
wantSub string
|
||||
}{
|
||||
{
|
||||
name: "empty name",
|
||||
mutate: func(e *Envelope) { e.Name = "" },
|
||||
wantSub: "name",
|
||||
},
|
||||
{
|
||||
name: "nil InputSchema",
|
||||
mutate: func(e *Envelope) { e.InputSchema = nil },
|
||||
wantSub: "inputSchema",
|
||||
},
|
||||
{
|
||||
name: "inputSchema type not object",
|
||||
mutate: func(e *Envelope) { e.InputSchema.Type = "string" },
|
||||
wantSub: "inputSchema.type",
|
||||
},
|
||||
{
|
||||
name: "nil OutputSchema",
|
||||
mutate: func(e *Envelope) { e.OutputSchema = nil },
|
||||
wantSub: "outputSchema",
|
||||
},
|
||||
{
|
||||
name: "nil Meta",
|
||||
mutate: func(e *Envelope) { e.Meta = nil },
|
||||
wantSub: "_meta",
|
||||
},
|
||||
{
|
||||
name: "wrong envelope version",
|
||||
mutate: func(e *Envelope) { e.Meta.EnvelopeVersion = "0.9" },
|
||||
wantSub: "envelope_version",
|
||||
},
|
||||
{
|
||||
name: "invalid property type",
|
||||
mutate: func(e *Envelope) {
|
||||
e.InputSchema.Properties.Order = []string{"x"}
|
||||
e.InputSchema.Properties.Map["x"] = Property{Type: "unknown_type"}
|
||||
},
|
||||
wantSub: "invalid type",
|
||||
},
|
||||
{
|
||||
name: "array missing items",
|
||||
mutate: func(e *Envelope) {
|
||||
e.InputSchema.Properties.Order = []string{"x"}
|
||||
e.InputSchema.Properties.Map["x"] = Property{Type: "array"} // no Items
|
||||
},
|
||||
wantSub: "items",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
env := validEnvelope()
|
||||
tt.mutate(&env)
|
||||
errs := lintEnvelope(env)
|
||||
if len(errs) == 0 {
|
||||
t.Fatalf("expected lint error, got none")
|
||||
}
|
||||
found := false
|
||||
for _, e := range errs {
|
||||
if strings.Contains(e.Error(), tt.wantSub) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected error containing %q, got: %v", tt.wantSub, errs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLintEnvelope_L2_TypeChecks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Envelope)
|
||||
wantSub string
|
||||
}{
|
||||
{
|
||||
name: "format binary on non-string",
|
||||
mutate: func(e *Envelope) {
|
||||
e.InputSchema.Properties.Order = []string{"f"}
|
||||
e.InputSchema.Properties.Map["f"] = Property{Type: "integer", Format: "binary"}
|
||||
},
|
||||
wantSub: "format: binary",
|
||||
},
|
||||
{
|
||||
name: "required key not in properties",
|
||||
mutate: func(e *Envelope) {
|
||||
e.InputSchema.Required = []string{"nonexistent"}
|
||||
},
|
||||
wantSub: "required",
|
||||
},
|
||||
{
|
||||
name: "minimum >= maximum",
|
||||
mutate: func(e *Envelope) {
|
||||
min, max := 50.0, 10.0
|
||||
e.InputSchema.Properties.Order = []string{"n"}
|
||||
e.InputSchema.Properties.Map["n"] = Property{Type: "integer", Minimum: &min, Maximum: &max}
|
||||
},
|
||||
wantSub: "minimum",
|
||||
},
|
||||
{
|
||||
// Regression guard: walkForL2 must recurse into the params/data
|
||||
// sub-objects introduced by the 4-bucket inputSchema, not only the
|
||||
// top-level Properties map.
|
||||
name: "format binary on non-string inside params sub-object",
|
||||
mutate: func(e *Envelope) {
|
||||
e.InputSchema.Properties.Order = []string{"params"}
|
||||
e.InputSchema.Properties.Map["params"] = Property{
|
||||
Type: "object",
|
||||
Properties: &OrderedProps{
|
||||
Order: []string{"id"},
|
||||
Map: map[string]Property{
|
||||
"id": {Type: "integer", Format: "binary"}, // wrong: binary on integer
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
wantSub: "format: binary",
|
||||
},
|
||||
{
|
||||
name: "sub-object required references missing property",
|
||||
mutate: func(e *Envelope) {
|
||||
e.InputSchema.Properties.Order = []string{"data"}
|
||||
e.InputSchema.Properties.Map["data"] = Property{
|
||||
Type: "object",
|
||||
Required: []string{"ghost"}, // not in properties below
|
||||
Properties: &OrderedProps{
|
||||
Order: []string{"real"},
|
||||
Map: map[string]Property{"real": {Type: "string"}},
|
||||
},
|
||||
}
|
||||
},
|
||||
wantSub: "ghost",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
env := validEnvelope()
|
||||
tt.mutate(&env)
|
||||
errs := lintEnvelope(env)
|
||||
if len(errs) == 0 {
|
||||
t.Fatalf("expected lint error, got none")
|
||||
}
|
||||
found := false
|
||||
for _, e := range errs {
|
||||
if strings.Contains(e.Error(), tt.wantSub) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected error containing %q, got: %v", tt.wantSub, errs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLintEnvelope_L3_CrossFieldChecks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Envelope)
|
||||
wantSub string
|
||||
}{
|
||||
{
|
||||
name: "danger true but risk read",
|
||||
mutate: func(e *Envelope) {
|
||||
e.Meta.Danger = true
|
||||
e.Meta.Risk = "read"
|
||||
},
|
||||
wantSub: "danger",
|
||||
},
|
||||
{
|
||||
name: "high-risk-write without yes",
|
||||
mutate: func(e *Envelope) {
|
||||
e.Meta.Risk = "high-risk-write"
|
||||
e.Meta.Danger = true
|
||||
// no yes injection
|
||||
},
|
||||
wantSub: "yes",
|
||||
},
|
||||
{
|
||||
name: "yes injected but risk not high-risk-write",
|
||||
mutate: func(e *Envelope) {
|
||||
e.InputSchema.Properties.Order = []string{"yes"}
|
||||
e.InputSchema.Properties.Map["yes"] = Property{Type: "boolean"}
|
||||
},
|
||||
wantSub: "yes",
|
||||
},
|
||||
{
|
||||
name: "empty access_tokens",
|
||||
mutate: func(e *Envelope) {
|
||||
e.Meta.AccessTokens = []string{}
|
||||
},
|
||||
wantSub: "access_tokens",
|
||||
},
|
||||
{
|
||||
name: "invalid access_token value",
|
||||
mutate: func(e *Envelope) {
|
||||
e.Meta.AccessTokens = []string{"admin"}
|
||||
},
|
||||
wantSub: "access_tokens",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
env := validEnvelope()
|
||||
tt.mutate(&env)
|
||||
errs := lintEnvelope(env)
|
||||
if len(errs) == 0 {
|
||||
t.Fatalf("expected lint error, got none")
|
||||
}
|
||||
found := false
|
||||
for _, e := range errs {
|
||||
if strings.Contains(e.Error(), tt.wantSub) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected error containing %q, got: %v", tt.wantSub, errs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeasureCoverage_Counts(t *testing.T) {
|
||||
envs := []Envelope{
|
||||
{Description: "ok", Meta: &Meta{Scopes: []string{"s"}, Risk: "read", DocURL: "http://x"}},
|
||||
{Description: "", Meta: &Meta{Scopes: []string{}, Risk: "", DocURL: ""}},
|
||||
{Description: "ok2", Meta: &Meta{Scopes: []string{"s"}, Risk: "write", DocURL: "http://y"}},
|
||||
}
|
||||
c := measureCoverage(envs)
|
||||
// 2/3 have non-empty description = ~0.667
|
||||
if c["description"] < 0.66 || c["description"] > 0.67 {
|
||||
t.Errorf("description coverage = %v, want ~0.667", c["description"])
|
||||
}
|
||||
// 2/3 have non-empty scopes
|
||||
if c["scopes"] < 0.66 || c["scopes"] > 0.67 {
|
||||
t.Errorf("scopes coverage = %v, want ~0.667", c["scopes"])
|
||||
}
|
||||
// 2/3 have doc_url
|
||||
if c["doc_url"] < 0.66 || c["doc_url"] > 0.67 {
|
||||
t.Errorf("doc_url coverage = %v, want ~0.667", c["doc_url"])
|
||||
}
|
||||
// 2/3 have non-empty risk (but our builder always fills risk with "read" default — this test uses raw envs)
|
||||
if c["risk"] < 0.66 || c["risk"] > 0.67 {
|
||||
t.Errorf("risk coverage = %v, want ~0.667", c["risk"])
|
||||
}
|
||||
}
|
||||
|
||||
// isKnownDataInconsistency returns true for lint errors that originate from
|
||||
// real meta_data quality issues we still have to ship around in PR-1. With
|
||||
// Task 17b the assembler walks embedded data only, so overlay-induced
|
||||
// inconsistencies (risk-stripping) no longer appear; only the true embedded
|
||||
// meta_data data-quality patterns remain.
|
||||
//
|
||||
// As meta_data quality improves this filter should be tightened/removed so
|
||||
// TestAllEnvelopesPass becomes a hard gate again.
|
||||
func isKnownDataInconsistency(msg string) bool {
|
||||
switch {
|
||||
case strings.Contains(msg, `L3: _meta.danger=false inconsistent with risk="write"`):
|
||||
// Embedded meta_data has ~7 envelopes (e.g. attendance.user_tasks.query,
|
||||
// drive.user.subscription, mail.user_mailbox.event.subscribe) where
|
||||
// `risk="write"` but `danger` is missing (defaults to false). Needs a
|
||||
// meta_data fix to set danger=true on these write methods.
|
||||
return true
|
||||
case strings.Contains(msg, `L3: _meta.danger=true inconsistent with risk="read"`):
|
||||
// Embedded meta_data has ~9 envelopes (e.g. calendar.events.search_event,
|
||||
// drive.metas.batch_query, mail.user_mailbox.templates.create) where
|
||||
// `danger=true` but `risk` is missing (defaults to "read"). Needs a
|
||||
// meta_data fix to set the proper risk level on these methods.
|
||||
return true
|
||||
case strings.Contains(msg, "L2: field") && strings.Contains(msg, "minimum") && strings.Contains(msg, "maximum"):
|
||||
// meta_data sets min == max on some fields (e.g.
|
||||
// mail.user_mailbox.event.subscribe.event_type), which the lint reads
|
||||
// as min >= max. Real fix is in meta_data.
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestAllEnvelopesPass(t *testing.T) {
|
||||
failCount := 0
|
||||
knownWarnings := 0
|
||||
knownEnvelopes := map[string]bool{}
|
||||
// Use embedded data only so the gate is deterministic across machines
|
||||
// (matches Task 17b: envelope assembly is overlay-independent).
|
||||
for _, svc := range registry.EmbeddedServiceNames() {
|
||||
spec := registry.EmbeddedSpec(svc)
|
||||
envs := AssembleService(svc, spec, nil)
|
||||
for _, env := range envs {
|
||||
errs := lintEnvelope(env)
|
||||
if len(errs) == 0 {
|
||||
continue
|
||||
}
|
||||
var realErrs []error
|
||||
for _, e := range errs {
|
||||
if isKnownDataInconsistency(e.Error()) {
|
||||
t.Logf("env %s skipped: known data-level inconsistency: %v", env.Name, e)
|
||||
knownWarnings++
|
||||
knownEnvelopes[env.Name] = true
|
||||
continue
|
||||
}
|
||||
realErrs = append(realErrs, e)
|
||||
}
|
||||
if len(realErrs) > 0 {
|
||||
for _, e := range realErrs {
|
||||
t.Errorf("%s: %v", env.Name, e)
|
||||
}
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("L1-L3 known data-level inconsistencies: %d warnings across %d envelopes (danger/risk mismatch + min==max)", knownWarnings, len(knownEnvelopes))
|
||||
if failCount > 0 {
|
||||
t.Fatalf("%d envelopes failed L1-L3 lint with non-data-level errors", failCount)
|
||||
}
|
||||
|
||||
// L4 coverage report (warn-only via t.Logf)
|
||||
all := AssembleAll(nil)
|
||||
c := measureCoverage(all)
|
||||
for metric, rate := range c {
|
||||
baseline := coverageBaseline[metric]
|
||||
if rate < baseline {
|
||||
t.Logf("L4 coverage warn: %s = %.1f%% (baseline: %.1f%%)", metric, rate*100, baseline*100)
|
||||
} else {
|
||||
t.Logf("L4 coverage ok: %s = %.1f%% (baseline: %.1f%%)", metric, rate*100, baseline*100)
|
||||
}
|
||||
}
|
||||
}
|
||||
30
internal/schema/path.go
Normal file
30
internal/schema/path.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
|
||||
import "strings"
|
||||
|
||||
// ParsePath normalizes the positional arguments of `lark-cli schema` into a
|
||||
// slice of path segments. It accepts two equivalent forms:
|
||||
//
|
||||
// lark-cli schema im.messages.reply -> single arg, split on "."
|
||||
// lark-cli schema im messages reply -> multiple args, used as-is
|
||||
// lark-cli schema "im chat.members bots" is NOT a supported form; quote
|
||||
// arguments individually if your shell needs it. Nested resources keep their
|
||||
// internal dots (e.g. "chat.members").
|
||||
//
|
||||
// Returns nil for zero args (bare invocation).
|
||||
func ParsePath(args []string) []string {
|
||||
switch len(args) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
if strings.Contains(args[0], ".") {
|
||||
return strings.Split(args[0], ".")
|
||||
}
|
||||
return []string{args[0]}
|
||||
default:
|
||||
return args
|
||||
}
|
||||
}
|
||||
34
internal/schema/path_test.go
Normal file
34
internal/schema/path_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParsePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{"empty args -> nil", nil, nil},
|
||||
{"empty slice -> nil", []string{}, nil},
|
||||
{"single dotted", []string{"im.messages.reply"}, []string{"im", "messages", "reply"}},
|
||||
{"single no-dot", []string{"im"}, []string{"im"}},
|
||||
{"multi args", []string{"im", "messages", "reply"}, []string{"im", "messages", "reply"}},
|
||||
{"two args", []string{"im", "messages"}, []string{"im", "messages"}},
|
||||
{"nested resource dotted", []string{"im.chat.members.bots"}, []string{"im", "chat", "members", "bots"}},
|
||||
{"nested resource space form", []string{"im", "chat.members", "bots"}, []string{"im", "chat.members", "bots"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ParsePath(tt.args)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("ParsePath(%v) = %v, want %v", tt.args, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
164
internal/schema/types.go
Normal file
164
internal/schema/types.go
Normal file
@@ -0,0 +1,164 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Envelope is the MCP Tool spec contract for a single API method command.
|
||||
type Envelope struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema *InputSchema `json:"inputSchema"`
|
||||
OutputSchema *OutputSchema `json:"outputSchema"`
|
||||
Meta *Meta `json:"_meta"`
|
||||
}
|
||||
|
||||
// InputSchema is JSON Schema Draft 2020-12 flattened.
|
||||
//
|
||||
// Required is intentionally rendered (no omitempty) so the envelope shape
|
||||
// stays stable for AI consumers — an empty []string means "no required
|
||||
// fields" rather than "schema is missing the field".
|
||||
type InputSchema struct {
|
||||
Type string `json:"type"`
|
||||
Required []string `json:"required"`
|
||||
Properties *OrderedProps `json:"properties"`
|
||||
}
|
||||
|
||||
// OutputSchema wraps responseBody into a JSON Schema object.
|
||||
type OutputSchema struct {
|
||||
Type string `json:"type"`
|
||||
Properties *OrderedProps `json:"properties"`
|
||||
}
|
||||
|
||||
// Property is one field's JSON Schema shape, recursive.
|
||||
//
|
||||
// Required is used when Property describes a nested object (e.g. the
|
||||
// "params" / "data" sub-objects inside inputSchema): it lists which keys
|
||||
// inside that object's Properties are mandatory. Leaf fields ignore it.
|
||||
type Property struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Enum []interface{} `json:"enum,omitempty"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Example interface{} `json:"example,omitempty"`
|
||||
Minimum *float64 `json:"minimum,omitempty"`
|
||||
Maximum *float64 `json:"maximum,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
Required []string `json:"required,omitempty"`
|
||||
Properties *OrderedProps `json:"properties,omitempty"`
|
||||
Items *Property `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
// Meta is the Lark-specific extension namespace.
|
||||
type Meta struct {
|
||||
EnvelopeVersion string `json:"envelope_version"`
|
||||
Scopes []string `json:"scopes"`
|
||||
RequiredScopes []string `json:"required_scopes"`
|
||||
AccessTokens []string `json:"access_tokens"`
|
||||
Danger bool `json:"danger"`
|
||||
Risk string `json:"risk"`
|
||||
DocURL string `json:"doc_url,omitempty"`
|
||||
Affordance *Affordance `json:"affordance,omitempty"`
|
||||
}
|
||||
|
||||
// Affordance is the hand-written overlay (PR-1 only defines the type, no YAML loaded).
|
||||
type Affordance struct {
|
||||
UseWhen []string `json:"use_when,omitempty"`
|
||||
DoNotUseWhen []string `json:"do_not_use_when,omitempty"`
|
||||
Prerequisites []string `json:"prerequisites,omitempty"`
|
||||
Examples []AffordanceCase `json:"examples,omitempty"`
|
||||
Related []string `json:"related,omitempty"`
|
||||
}
|
||||
|
||||
// AffordanceCase is one example entry: a one-line description plus a
|
||||
// ready-to-run lark-cli command string.
|
||||
type AffordanceCase struct {
|
||||
Description string `json:"description"`
|
||||
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.
|
||||
type OrderedProps struct {
|
||||
Order []string
|
||||
Map map[string]Property
|
||||
}
|
||||
|
||||
// MarshalJSON emits keys in Order, not alphabetical. If Order is empty but
|
||||
// Map has entries, fall back to alphabetical key order over Map so callers
|
||||
// that only populated Map (no explicit ordering) still see their fields.
|
||||
func (o *OrderedProps) MarshalJSON() ([]byte, error) {
|
||||
if o == nil || (len(o.Order) == 0 && len(o.Map) == 0) {
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
keys := o.Order
|
||||
if len(keys) == 0 {
|
||||
keys = make([]string, 0, len(o.Map))
|
||||
for k := range o.Map {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
buf.WriteByte('{')
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
buf.WriteByte(',')
|
||||
}
|
||||
keyJSON, err := json.Marshal(k)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal key %q: %w", k, err)
|
||||
}
|
||||
buf.Write(keyJSON)
|
||||
buf.WriteByte(':')
|
||||
valJSON, err := json.Marshal(o.Map[k])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal value for %q: %w", k, err)
|
||||
}
|
||||
buf.Write(valJSON)
|
||||
}
|
||||
buf.WriteByte('}')
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON parses an object preserving key order via json.Decoder.Token().
|
||||
// Used for round-tripping in tests (and future golden update flows).
|
||||
func (o *OrderedProps) UnmarshalJSON(data []byte) error {
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if delim, ok := tok.(json.Delim); !ok || delim != '{' {
|
||||
return fmt.Errorf("expected object, got %v", tok)
|
||||
}
|
||||
o.Order = nil
|
||||
o.Map = make(map[string]Property)
|
||||
for dec.More() {
|
||||
keyTok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, ok := keyTok.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("expected string key, got %v", keyTok)
|
||||
}
|
||||
var prop Property
|
||||
if err := dec.Decode(&prop); err != nil {
|
||||
return err
|
||||
}
|
||||
o.Order = append(o.Order, key)
|
||||
o.Map[key] = prop
|
||||
}
|
||||
if _, err := dec.Token(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
58
internal/schema/types_test.go
Normal file
58
internal/schema/types_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package schema
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// OrderedProps 在测试里验证:MarshalJSON 按 Order 切片顺序输出 key,跳过 Go map 默认字母序。
|
||||
func TestOrderedProps_MarshalJSON_PreservesOrder(t *testing.T) {
|
||||
op := &OrderedProps{
|
||||
Order: []string{"z_first", "a_second", "m_third"},
|
||||
Map: map[string]Property{
|
||||
"z_first": {Type: "string"},
|
||||
"a_second": {Type: "integer"},
|
||||
"m_third": {Type: "boolean"},
|
||||
},
|
||||
}
|
||||
b, err := json.Marshal(op)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
got := string(b)
|
||||
want := `{"z_first":{"type":"string"},"a_second":{"type":"integer"},"m_third":{"type":"boolean"}}`
|
||||
if got != want {
|
||||
t.Errorf("OrderedProps key order not preserved:\ngot: %s\nwant: %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderedProps_MarshalJSON_Empty(t *testing.T) {
|
||||
op := &OrderedProps{Order: nil, Map: nil}
|
||||
b, err := json.Marshal(op)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal failed: %v", err)
|
||||
}
|
||||
if string(b) != "{}" {
|
||||
t.Errorf("empty OrderedProps should marshal to {}, got: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderedProps_UnmarshalJSON_RoundTrip(t *testing.T) {
|
||||
in := []byte(`{"first":{"type":"string"},"second":{"type":"integer"}}`)
|
||||
var op OrderedProps
|
||||
if err := json.Unmarshal(in, &op); err != nil {
|
||||
t.Fatalf("Unmarshal failed: %v", err)
|
||||
}
|
||||
if len(op.Order) != 2 {
|
||||
t.Fatalf("expected 2 keys, got %d", len(op.Order))
|
||||
}
|
||||
if op.Order[0] != "first" || op.Order[1] != "second" {
|
||||
t.Errorf("unmarshal lost order: got %v", op.Order)
|
||||
}
|
||||
if op.Map["first"].Type != "string" {
|
||||
t.Errorf("first.type mismatch")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.41",
|
||||
"version": "1.0.42",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -21,7 +21,7 @@ var AppsAccessScopeGet = common.Shortcut{
|
||||
Command: "+access-scope-get",
|
||||
Description: "Get Miaoda app access scope configuration",
|
||||
Risk: "read",
|
||||
Scopes: []string{"spark:app.access_scope:read"},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
|
||||
@@ -27,7 +27,7 @@ var AppsAccessScopeSet = common.Shortcut{
|
||||
Command: "+access-scope-set",
|
||||
Description: "Set Miaoda app access scope (specific / public / tenant)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"spark:app.access_scope:write"},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
|
||||
@@ -21,7 +21,7 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
Command: "+html-publish",
|
||||
Description: "Publish HTML to a Miaoda app (single multipart POST returns the access URL)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"spark:app:publish"},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
|
||||
func v2FetchFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "doc-format", Desc: "content format", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown", "text"}},
|
||||
{Name: "doc-format", Desc: "content format", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "detail", Desc: "export detail level: simple (read-only) | with-ids (block IDs for cross-referencing) | full (all attrs for editing)", Hidden: true, Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
|
||||
{Name: "revision-id", Desc: "document revision (-1 = latest)", Hidden: true, Type: "int", Default: "-1"},
|
||||
{Name: "scope", Desc: "partial read scope: outline | range | keyword | section (omit to read whole doc)", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
|
||||
@@ -142,7 +142,7 @@ func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
return ro
|
||||
}
|
||||
|
||||
// validateFetchDetail 非 xml 格式(markdown/text)不承载 block_id 与样式属性,拒绝 with-ids/full。
|
||||
// validateFetchDetail 非 xml 格式(markdown)不承载 block_id 与样式属性,拒绝 with-ids/full。
|
||||
func validateFetchDetail(runtime *common.RuntimeContext) error {
|
||||
format := strings.TrimSpace(runtime.Str("doc-format"))
|
||||
detail := strings.TrimSpace(runtime.Str("detail"))
|
||||
|
||||
124
shortcuts/drive/drive_secure_label.go
Normal file
124
shortcuts/drive/drive_secure_label.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
secureLabelReadScope = "drive:file.meta.sec_label.read_only"
|
||||
secureLabelUpdateScope = "docs:secure_label:write_only"
|
||||
)
|
||||
|
||||
var secureLabelTypes = permApplyTypes
|
||||
|
||||
// DriveSecureLabelList lists secure labels available to the current user.
|
||||
var DriveSecureLabelList = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+secure-label-list",
|
||||
Description: "List secure labels available to the current user",
|
||||
Risk: "read",
|
||||
Scopes: []string{secureLabelReadScope},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-size", Type: "int", Default: "10", Desc: "page size, 1-10"},
|
||||
{Name: "page-token", Desc: "pagination token from previous response"},
|
||||
{Name: "lang", Desc: "label language", Enum: []string{"zh", "en", "ja"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
pageSize := runtime.Int("page-size")
|
||||
if pageSize < 1 || pageSize > 10 {
|
||||
return output.ErrValidation("--page-size must be between 1 and 10")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("List secure labels available to the current user").
|
||||
GET("/open-apis/drive/v2/my_secure_labels").
|
||||
Params(buildSecureLabelListParams(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
data, err := runtime.CallAPI("GET",
|
||||
"/open-apis/drive/v2/my_secure_labels",
|
||||
buildSecureLabelListParams(runtime),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutFormat(data, nil, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// DriveSecureLabelUpdate updates the secure label on a Drive file/document.
|
||||
var DriveSecureLabelUpdate = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+secure-label-update",
|
||||
Description: "Update the secure label on a Drive file or document",
|
||||
Risk: "write",
|
||||
Scopes: []string{secureLabelUpdateScope},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target file token or document URL (docx/sheets/base/file/wiki/doc/mindnote/slides)", Required: true},
|
||||
{Name: "type", Desc: "target type; auto-inferred from URL when omitted", Enum: secureLabelTypes},
|
||||
{Name: "label-id", Desc: "secure label ID to set", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, _, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, docType, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Update Drive secure label").
|
||||
PATCH("/open-apis/drive/v2/files/:file_token/secure_label").
|
||||
Params(map[string]interface{}{"type": docType}).
|
||||
Body(map[string]interface{}{"id": runtime.Str("label-id")}).
|
||||
Set("file_token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, docType, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{"id": runtime.Str("label-id")}
|
||||
data, err := runtime.CallAPI("PATCH",
|
||||
fmt.Sprintf("/open-apis/drive/v2/files/%s/secure_label", validate.EncodePathSegment(token)),
|
||||
map[string]interface{}{"type": docType},
|
||||
body,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildSecureLabelListParams(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{"page_size": runtime.Int("page-size")}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
if lang := runtime.Str("lang"); lang != "" {
|
||||
params["lang"] = lang
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func resolveSecureLabelTarget(raw, explicitType string) (token, docType string, err error) {
|
||||
return resolvePermApplyTarget(raw, explicitType)
|
||||
}
|
||||
164
shortcuts/drive/drive_secure_label_test.go
Normal file
164
shortcuts/drive/drive_secure_label_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestDriveSecureLabelList_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveSecureLabelList, []string{
|
||||
"+secure-label-list",
|
||||
"--page-size", "5",
|
||||
"--page-token", "page_1",
|
||||
"--lang", "zh",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"/open-apis/drive/v2/my_secure_labels",
|
||||
`"GET"`,
|
||||
`"page_size": 5`,
|
||||
`"page_token": "page_1"`,
|
||||
`"lang": "zh"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelList_ValidatePageSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveSecureLabelList, []string{
|
||||
"+secure-label-list",
|
||||
"--page-size", "11",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "page-size") {
|
||||
t.Fatalf("expected page-size validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelList_ExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/my_secure_labels?page_size=10",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "7217780879644737540", "name": "L1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveSecureLabelList, []string{
|
||||
"+secure-label-list",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"L1"`) {
|
||||
t.Fatalf("stdout missing label:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_DryRunInfersTypeFromURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
|
||||
"+secure-label-update",
|
||||
"--token", "https://example.feishu.cn/docx/doxTok123?from=share",
|
||||
"--label-id", "7217780879644737539",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"/open-apis/drive/v2/files/doxTok123/secure_label",
|
||||
`"PATCH"`,
|
||||
`"docx"`,
|
||||
`"id": "7217780879644737539"`,
|
||||
`"file_token": "doxTok123"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/drive/v2/files/doxTok123/secure_label?type=docx",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
|
||||
"+secure-label-update",
|
||||
"--token", "doxTok123",
|
||||
"--type", "docx",
|
||||
"--label-id", "7217780879644737539",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
if body["id"] != "7217780879644737539" {
|
||||
t.Fatalf("id = %v, want label id", body["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsAPIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/drive/v2/files/doxTok123/secure_label",
|
||||
Status: 403,
|
||||
Body: map[string]interface{}{
|
||||
"code": 1063013, "msg": "Security label downgrade requires approval",
|
||||
},
|
||||
})
|
||||
|
||||
targetURL := "https://example.feishu.cn/docx/doxTok123"
|
||||
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
|
||||
"+secure-label-update",
|
||||
"--token", targetURL,
|
||||
"--label-id", "7217780879644737539",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected 1063013 error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Security label downgrade requires approval") {
|
||||
t.Fatalf("expected raw API error message, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveSync,
|
||||
DriveTaskResult,
|
||||
DriveApplyPermission,
|
||||
DriveSecureLabelList,
|
||||
DriveSecureLabelUpdate,
|
||||
DriveSearch,
|
||||
DriveInspect,
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+sync",
|
||||
"+task_result",
|
||||
"+apply-permission",
|
||||
"+secure-label-list",
|
||||
"+secure-label-update",
|
||||
"+search",
|
||||
"+inspect",
|
||||
}
|
||||
|
||||
@@ -155,6 +155,20 @@ func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
|
||||
}
|
||||
|
||||
// Preserve API-provided fields (even if this formatter doesn't otherwise use them).
|
||||
// update_time is only meaningful when the message was actually edited;
|
||||
// the server echoes update_time == create_time for unedited messages, which
|
||||
// would otherwise make every output look "updated" to downstream consumers.
|
||||
if updated {
|
||||
if v, ok := m["update_time"]; ok && v != nil {
|
||||
if s, isStr := v.(string); isStr {
|
||||
if strings.TrimSpace(s) != "" {
|
||||
msg["update_time"] = common.FormatTime(s)
|
||||
}
|
||||
} else {
|
||||
msg["update_time"] = common.FormatTime(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if v, ok := m["chat_id"]; ok {
|
||||
msg["chat_id"] = v
|
||||
}
|
||||
|
||||
@@ -95,6 +95,61 @@ func TestFormatMessageItem(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatMessageItem_UpdateTime_Present(t *testing.T) {
|
||||
raw := map[string]interface{}{
|
||||
"msg_type": "text",
|
||||
"message_id": "om_edit",
|
||||
"updated": true,
|
||||
"create_time": "1710500000",
|
||||
"update_time": "1710600000",
|
||||
"sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"},
|
||||
"body": map[string]interface{}{"content": `{"text":"edited"}`},
|
||||
}
|
||||
|
||||
got := FormatMessageItem(raw, nil)
|
||||
want := common.FormatTime("1710600000")
|
||||
if got["update_time"] != want {
|
||||
t.Fatalf("FormatMessageItem() update_time = %#v, want %#v", got["update_time"], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatMessageItem_UpdateTime_Absent(t *testing.T) {
|
||||
raw := map[string]interface{}{
|
||||
"msg_type": "text",
|
||||
"message_id": "om_no_edit",
|
||||
"updated": false,
|
||||
"create_time": "1710500000",
|
||||
"sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"},
|
||||
"body": map[string]interface{}{"content": `{"text":"hi"}`},
|
||||
}
|
||||
|
||||
got := FormatMessageItem(raw, nil)
|
||||
if _, ok := got["update_time"]; ok {
|
||||
t.Fatalf("FormatMessageItem() should not include update_time when absent, got = %#v", got["update_time"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatMessageItem_UpdateTime_UnchangedMessage: real API behavior — even
|
||||
// for unedited messages, server returns update_time == create_time. We must
|
||||
// NOT echo it through, otherwise every message looks "edited" to consumers.
|
||||
// Gate the output on updated==true.
|
||||
func TestFormatMessageItem_UpdateTime_UnchangedMessage(t *testing.T) {
|
||||
raw := map[string]interface{}{
|
||||
"msg_type": "text",
|
||||
"message_id": "om_unchanged",
|
||||
"updated": false,
|
||||
"create_time": "1710500000",
|
||||
"update_time": "1710500000", // server echoes create_time
|
||||
"sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"},
|
||||
"body": map[string]interface{}{"content": `{"text":"hi"}`},
|
||||
}
|
||||
|
||||
got := FormatMessageItem(raw, nil)
|
||||
if v, ok := got["update_time"]; ok {
|
||||
t.Fatalf("FormatMessageItem() must skip update_time for unedited message, got = %#v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAppLinkDomain(t *testing.T) {
|
||||
if got := resolveAppLinkDomain(core.BrandFeishu); got != "applink.feishu.cn" {
|
||||
t.Fatalf("resolveAppLinkDomain(feishu) = %q", got)
|
||||
|
||||
207
shortcuts/im/convert_lib/reactions.go
Normal file
207
shortcuts/im/convert_lib/reactions.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package convertlib
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// reactionsBatchQueryMaxQueries is the server-side hard limit on queries[]
|
||||
// length for POST /im/v1/messages/reactions/batch_query (see
|
||||
// larkim/message/members/facade_reaction/service: batchListReactionsMaxMessageIDs).
|
||||
const reactionsBatchQueryMaxQueries = 20
|
||||
|
||||
// EnrichReactions enriches messages with their reactions by calling the
|
||||
// im.reactions.batch_query API. Messages are modified in place: each message
|
||||
// that the server returns reactions for gets a "reactions" map attached.
|
||||
//
|
||||
// Failure modes (warning to stderr + skip; never aborts main message output):
|
||||
// - batch_query call fails (network, 5xx, scope insufficient, rate limited):
|
||||
// each message in the failed batch is marked with "reactions_error": true
|
||||
// so callers can distinguish "fetch failed" from "no reactions exist".
|
||||
// - batch_query returns a partial result: only messages the server failed on
|
||||
// get "reactions_error": true; the successful ones get the reactions block.
|
||||
//
|
||||
// The "reactions_error" flag mirrors the "thread_replies_error" pattern in
|
||||
// thread.go so downstream consumers handle both enrichment failures uniformly.
|
||||
//
|
||||
// Output shape (only on messages that the server actually returned data for):
|
||||
//
|
||||
// "reactions": {
|
||||
// "counts": [{"reaction_type": "SMILE", "count": 3}],
|
||||
// "details": [{"reaction_id": "...", "emoji_type": "SMILE",
|
||||
// "operator": {...}, "action_time": "..."}]
|
||||
// }
|
||||
//
|
||||
// The server caps queries[] at 20 per call, so messages are split into
|
||||
// batches of size <= 20 before invoking the API.
|
||||
func EnrichReactions(runtime *common.RuntimeContext, messages []map[string]interface{}) {
|
||||
if len(messages) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Index messages by ID so we can merge reactions back later.
|
||||
// A single message_id may appear more than once (e.g. mget --message-ids
|
||||
// om_a,om_a); every occurrence must receive the reactions block, but the
|
||||
// API should only be queried once per distinct id.
|
||||
// Walks into msg["thread_replies"] recursively so replies attached by
|
||||
// ExpandThreadReplies are enriched in the same batched call as their parent.
|
||||
idIndex := make(map[string][]map[string]interface{}, len(messages))
|
||||
var ids []string
|
||||
collectMessageNodes(messages, idIndex, &ids)
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for i := 0; i < len(ids); i += reactionsBatchQueryMaxQueries {
|
||||
end := i + reactionsBatchQueryMaxQueries
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
fetchReactionsBatch(runtime, ids[i:end], idIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// collectMessageNodes walks messages (and any nested thread_replies) and
|
||||
// records each map under its message_id. Distinct ids are appended to *ids in
|
||||
// first-seen order so the API is queried at most once per id.
|
||||
func collectMessageNodes(messages []map[string]interface{}, idIndex map[string][]map[string]interface{}, ids *[]string) {
|
||||
for _, msg := range messages {
|
||||
if id, _ := msg["message_id"].(string); id != "" {
|
||||
if _, seen := idIndex[id]; !seen {
|
||||
*ids = append(*ids, id)
|
||||
}
|
||||
idIndex[id] = append(idIndex[id], msg)
|
||||
}
|
||||
// thread_replies may arrive as a typed slice (set by ExpandThreadReplies)
|
||||
// or as []interface{} (e.g. when produced via JSON round-trip).
|
||||
switch nested := msg["thread_replies"].(type) {
|
||||
case []map[string]interface{}:
|
||||
collectMessageNodes(nested, idIndex, ids)
|
||||
case []interface{}:
|
||||
typed := make([]map[string]interface{}, 0, len(nested))
|
||||
for _, raw := range nested {
|
||||
if m, ok := raw.(map[string]interface{}); ok {
|
||||
typed = append(typed, m)
|
||||
}
|
||||
}
|
||||
collectMessageNodes(typed, idIndex, ids)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fetchReactionsBatch invokes batch_query for one batch of <= 20 message IDs
|
||||
// and merges the results into idIndex. Failures are logged to stderr without
|
||||
// aborting subsequent batches.
|
||||
func fetchReactionsBatch(runtime *common.RuntimeContext, batchIDs []string, idIndex map[string][]map[string]interface{}) {
|
||||
queries := make([]map[string]interface{}, 0, len(batchIDs))
|
||||
for _, id := range batchIDs {
|
||||
queries = append(queries, map[string]interface{}{"message_id": id})
|
||||
}
|
||||
|
||||
data, err := runtime.DoAPIJSON(http.MethodPost,
|
||||
"/open-apis/im/v1/messages/reactions/batch_query",
|
||||
nil,
|
||||
map[string]interface{}{"queries": queries},
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: reactions_batch_query_failed: %v\n", err)
|
||||
markReactionsError(batchIDs, idIndex)
|
||||
return
|
||||
}
|
||||
|
||||
countsByMsg := groupReactionCounts(data["success_msg_reaction_counts"])
|
||||
detailsByMsg := groupReactionDetails(data["success_msg_reaction_details"])
|
||||
|
||||
// Attach the merged reactions block to every message that had any data.
|
||||
// Each id may map to >1 message map (duplicate input), so iterate the slice.
|
||||
for _, id := range batchIDs {
|
||||
msgs := idIndex[id]
|
||||
if len(msgs) == 0 {
|
||||
continue
|
||||
}
|
||||
counts := countsByMsg[id]
|
||||
details := detailsByMsg[id]
|
||||
if len(counts) == 0 && len(details) == 0 {
|
||||
continue
|
||||
}
|
||||
block := make(map[string]interface{}, 2)
|
||||
if len(counts) > 0 {
|
||||
block["counts"] = counts
|
||||
}
|
||||
if len(details) > 0 {
|
||||
block["details"] = details
|
||||
}
|
||||
for _, msg := range msgs {
|
||||
msg["reactions"] = block
|
||||
}
|
||||
}
|
||||
|
||||
// Surface per-message failures from the API response.
|
||||
if fails, _ := data["fail_msg_reaction_details"].([]interface{}); len(fails) > 0 {
|
||||
var failedIDs []string
|
||||
for _, raw := range fails {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
if id, _ := item["message_id"].(string); id != "" {
|
||||
failedIDs = append(failedIDs, id)
|
||||
}
|
||||
}
|
||||
if len(failedIDs) > 0 {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"warning: reactions_partial_failed: %d message(s) failed (%v)\n",
|
||||
len(failedIDs), failedIDs)
|
||||
markReactionsError(failedIDs, idIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// markReactionsError flags every message map indexed under the given ids with
|
||||
// reactions_error=true, so downstream consumers can distinguish "fetch failed"
|
||||
// from "no reactions exist" by reading stdout alone.
|
||||
func markReactionsError(ids []string, idIndex map[string][]map[string]interface{}) {
|
||||
for _, id := range ids {
|
||||
for _, msg := range idIndex[id] {
|
||||
msg["reactions_error"] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func groupReactionCounts(raw interface{}) map[string][]interface{} {
|
||||
groups := map[string][]interface{}{}
|
||||
items, _ := raw.([]interface{})
|
||||
for _, item := range items {
|
||||
row, _ := item.(map[string]interface{})
|
||||
msgID, _ := row["message_id"].(string)
|
||||
if msgID == "" {
|
||||
continue
|
||||
}
|
||||
entries, _ := row["reaction_count"].([]interface{})
|
||||
if len(entries) == 0 {
|
||||
continue
|
||||
}
|
||||
groups[msgID] = append(groups[msgID], entries...)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func groupReactionDetails(raw interface{}) map[string][]interface{} {
|
||||
groups := map[string][]interface{}{}
|
||||
items, _ := raw.([]interface{})
|
||||
for _, item := range items {
|
||||
row, _ := item.(map[string]interface{})
|
||||
msgID, _ := row["message_id"].(string)
|
||||
if msgID == "" {
|
||||
continue
|
||||
}
|
||||
entries, _ := row["message_reaction_items"].([]interface{})
|
||||
if len(entries) == 0 {
|
||||
continue
|
||||
}
|
||||
groups[msgID] = append(groups[msgID], entries...)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
352
shortcuts/im/convert_lib/reactions_test.go
Normal file
352
shortcuts/im/convert_lib/reactions_test.go
Normal file
@@ -0,0 +1,352 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package convertlib
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEnrichReactions_Success exercises the basic happy path: messages that
|
||||
// carry reactions get a "reactions" field, messages without reactions stay
|
||||
// untouched.
|
||||
func TestEnrichReactions_Success(t *testing.T) {
|
||||
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/reactions/batch_query") {
|
||||
return nil, fmt.Errorf("unexpected path: %s", req.URL.Path)
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
queries, _ := payload["queries"].([]interface{})
|
||||
if len(queries) != 2 {
|
||||
t.Fatalf("queries size = %d, want 2", len(queries))
|
||||
}
|
||||
return convertlibJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"success_msg_reaction_counts": []interface{}{
|
||||
map[string]interface{}{
|
||||
"message_id": "om_a",
|
||||
"reaction_count": []interface{}{
|
||||
map[string]interface{}{"reaction_type": "SMILE", "count": 3},
|
||||
},
|
||||
},
|
||||
},
|
||||
"success_msg_reaction_details": []interface{}{
|
||||
map[string]interface{}{
|
||||
"message_id": "om_a",
|
||||
"message_reaction_items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"reaction_id": "react_1",
|
||||
"emoji_type": "SMILE",
|
||||
"operator": map[string]interface{}{"operator_id": "ou_x", "operator_type": "user"},
|
||||
"action_time": "1710600000",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"fail_msg_reaction_details": []interface{}{},
|
||||
},
|
||||
}), nil
|
||||
}))
|
||||
|
||||
messages := []map[string]interface{}{
|
||||
{"message_id": "om_a"},
|
||||
{"message_id": "om_b"},
|
||||
}
|
||||
|
||||
EnrichReactions(runtime, messages)
|
||||
|
||||
reactionsA, ok := messages[0]["reactions"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("message om_a missing reactions field: %#v", messages[0])
|
||||
}
|
||||
counts, _ := reactionsA["counts"].([]interface{})
|
||||
if len(counts) != 1 {
|
||||
t.Fatalf("om_a counts = %d, want 1", len(counts))
|
||||
}
|
||||
details, _ := reactionsA["details"].([]interface{})
|
||||
if len(details) != 1 {
|
||||
t.Fatalf("om_a details = %d, want 1", len(details))
|
||||
}
|
||||
|
||||
if _, ok := messages[1]["reactions"]; ok {
|
||||
t.Fatalf("message om_b should not have reactions field (none in response): %#v", messages[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichReactions_BatchSize splits queries into batches of 20 (server-side
|
||||
// max for batch_query).
|
||||
func TestEnrichReactions_BatchSize(t *testing.T) {
|
||||
var observedBatchSizes []int
|
||||
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
var payload map[string]interface{}
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
queries, _ := payload["queries"].([]interface{})
|
||||
observedBatchSizes = append(observedBatchSizes, len(queries))
|
||||
return convertlibJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
}), nil
|
||||
}))
|
||||
|
||||
messages := make([]map[string]interface{}, 25)
|
||||
for i := range messages {
|
||||
messages[i] = map[string]interface{}{"message_id": fmt.Sprintf("om_%02d", i)}
|
||||
}
|
||||
|
||||
EnrichReactions(runtime, messages)
|
||||
|
||||
if want := []int{20, 5}; !reflect.DeepEqual(observedBatchSizes, want) {
|
||||
t.Fatalf("batch sizes = %v, want %v", observedBatchSizes, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichReactions_APIFailure: when the API call fails, messages stay
|
||||
// without a reactions field but get marked with reactions_error=true so
|
||||
// downstream consumers can distinguish "fetch failed" from "no reactions".
|
||||
// Mirrors the thread_replies_error pattern in thread.go.
|
||||
func TestEnrichReactions_APIFailure(t *testing.T) {
|
||||
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("simulated network error")
|
||||
}))
|
||||
|
||||
messages := []map[string]interface{}{
|
||||
{"message_id": "om_a"},
|
||||
{"message_id": "om_b"},
|
||||
}
|
||||
|
||||
EnrichReactions(runtime, messages)
|
||||
|
||||
for _, m := range messages {
|
||||
if _, ok := m["reactions"]; ok {
|
||||
t.Fatalf("message %v should have no reactions after API failure", m["message_id"])
|
||||
}
|
||||
if v, _ := m["reactions_error"].(bool); !v {
|
||||
t.Fatalf("message %v should have reactions_error=true after API failure, got = %#v",
|
||||
m["message_id"], m["reactions_error"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichReactions_PartialFailure: when batch_query returns a fail entry
|
||||
// for one ID, that message gets reactions_error=true while the rest stay
|
||||
// clean (no error flag) and keep their normal reactions block.
|
||||
func TestEnrichReactions_PartialFailure(t *testing.T) {
|
||||
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return convertlibJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"success_msg_reaction_counts": []interface{}{
|
||||
map[string]interface{}{
|
||||
"message_id": "om_ok",
|
||||
"reaction_count": []interface{}{
|
||||
map[string]interface{}{"reaction_type": "SMILE", "count": 1},
|
||||
},
|
||||
},
|
||||
},
|
||||
"fail_msg_reaction_details": []interface{}{
|
||||
map[string]interface{}{"message_id": "om_bad"},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}))
|
||||
|
||||
ok := map[string]interface{}{"message_id": "om_ok"}
|
||||
bad := map[string]interface{}{"message_id": "om_bad"}
|
||||
EnrichReactions(runtime, []map[string]interface{}{ok, bad})
|
||||
|
||||
if _, has := ok["reactions"]; !has {
|
||||
t.Fatalf("om_ok should have reactions: %#v", ok)
|
||||
}
|
||||
if v, _ := ok["reactions_error"].(bool); v {
|
||||
t.Fatalf("om_ok must not carry reactions_error: %#v", ok)
|
||||
}
|
||||
if _, has := bad["reactions"]; has {
|
||||
t.Fatalf("om_bad should have no reactions block: %#v", bad)
|
||||
}
|
||||
if v, _ := bad["reactions_error"].(bool); !v {
|
||||
t.Fatalf("om_bad should have reactions_error=true, got = %#v", bad["reactions_error"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichReactions_EmptyMessages: no messages -> no API call at all.
|
||||
func TestEnrichReactions_EmptyMessages(t *testing.T) {
|
||||
called := false
|
||||
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
called = true
|
||||
return convertlibJSONResponse(200, map[string]interface{}{"code": 0, "data": map[string]interface{}{}}), nil
|
||||
}))
|
||||
|
||||
EnrichReactions(runtime, nil)
|
||||
EnrichReactions(runtime, []map[string]interface{}{})
|
||||
|
||||
if called {
|
||||
t.Fatalf("API should not be called when messages list is empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichReactions_SkipsMessagesWithoutID: messages missing message_id
|
||||
// (defensive) should not crash and not be sent in queries.
|
||||
func TestEnrichReactions_SkipsMessagesWithoutID(t *testing.T) {
|
||||
var sentIDs []string
|
||||
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
var payload map[string]interface{}
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
queries, _ := payload["queries"].([]interface{})
|
||||
for _, q := range queries {
|
||||
qm, _ := q.(map[string]interface{})
|
||||
id, _ := qm["message_id"].(string)
|
||||
sentIDs = append(sentIDs, id)
|
||||
}
|
||||
return convertlibJSONResponse(200, map[string]interface{}{"code": 0, "data": map[string]interface{}{}}), nil
|
||||
}))
|
||||
|
||||
messages := []map[string]interface{}{
|
||||
{"message_id": "om_a"},
|
||||
{}, // no message_id
|
||||
{"message_id": ""},
|
||||
{"message_id": "om_b"},
|
||||
}
|
||||
|
||||
EnrichReactions(runtime, messages)
|
||||
|
||||
if want := []string{"om_a", "om_b"}; !reflect.DeepEqual(sentIDs, want) {
|
||||
t.Fatalf("sent IDs = %v, want %v", sentIDs, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichReactions_WalksThreadReplies: thread_replies nested under a parent
|
||||
// message must also be enriched, in the same batch_query call as the parent —
|
||||
// otherwise the parent gets reactions but its replies don't, leaving the output
|
||||
// inconsistent.
|
||||
func TestEnrichReactions_WalksThreadReplies(t *testing.T) {
|
||||
var observedQueriedIDs []string
|
||||
var observedCallCount int
|
||||
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
observedCallCount++
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
var payload map[string]interface{}
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
queries, _ := payload["queries"].([]interface{})
|
||||
for _, q := range queries {
|
||||
qm, _ := q.(map[string]interface{})
|
||||
id, _ := qm["message_id"].(string)
|
||||
observedQueriedIDs = append(observedQueriedIDs, id)
|
||||
}
|
||||
return convertlibJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"success_msg_reaction_counts": []interface{}{
|
||||
map[string]interface{}{
|
||||
"message_id": "om_top",
|
||||
"reaction_count": []interface{}{
|
||||
map[string]interface{}{"reaction_type": "SMILE", "count": 1},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"message_id": "om_reply1",
|
||||
"reaction_count": []interface{}{
|
||||
map[string]interface{}{"reaction_type": "THUMBSUP", "count": 2},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"message_id": "om_reply2",
|
||||
"reaction_count": []interface{}{
|
||||
map[string]interface{}{"reaction_type": "HEART", "count": 3},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}))
|
||||
|
||||
reply1 := map[string]interface{}{"message_id": "om_reply1"}
|
||||
reply2 := map[string]interface{}{"message_id": "om_reply2"}
|
||||
top := map[string]interface{}{
|
||||
"message_id": "om_top",
|
||||
"thread_replies": []map[string]interface{}{reply1, reply2},
|
||||
}
|
||||
messages := []map[string]interface{}{top}
|
||||
|
||||
EnrichReactions(runtime, messages)
|
||||
|
||||
if observedCallCount != 1 {
|
||||
t.Fatalf("expected 1 batched API call, got %d", observedCallCount)
|
||||
}
|
||||
sort.Strings(observedQueriedIDs)
|
||||
if want := []string{"om_reply1", "om_reply2", "om_top"}; !reflect.DeepEqual(observedQueriedIDs, want) {
|
||||
t.Fatalf("queried IDs = %v, want %v (top + thread_replies)", observedQueriedIDs, want)
|
||||
}
|
||||
|
||||
if _, ok := top["reactions"]; !ok {
|
||||
t.Fatalf("top message missing reactions")
|
||||
}
|
||||
if _, ok := reply1["reactions"]; !ok {
|
||||
t.Fatalf("reply1 missing reactions — thread_replies were not walked")
|
||||
}
|
||||
if _, ok := reply2["reactions"]; !ok {
|
||||
t.Fatalf("reply2 missing reactions — thread_replies were not walked")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichReactions_DuplicateMessageID: when the caller passes two distinct
|
||||
// message maps that share the same message_id (e.g. mget --message-ids om_a,om_a),
|
||||
// both maps must receive the same reactions block, and the API must be queried
|
||||
// for the id only once.
|
||||
func TestEnrichReactions_DuplicateMessageID(t *testing.T) {
|
||||
var observedQueriesPerCall []int
|
||||
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
var payload map[string]interface{}
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
queries, _ := payload["queries"].([]interface{})
|
||||
observedQueriesPerCall = append(observedQueriesPerCall, len(queries))
|
||||
return convertlibJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"success_msg_reaction_counts": []interface{}{
|
||||
map[string]interface{}{
|
||||
"message_id": "om_a",
|
||||
"reaction_count": []interface{}{
|
||||
map[string]interface{}{"reaction_type": "SMILE", "count": 2},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}))
|
||||
|
||||
first := map[string]interface{}{"message_id": "om_a"}
|
||||
second := map[string]interface{}{"message_id": "om_a"}
|
||||
other := map[string]interface{}{"message_id": "om_b"}
|
||||
messages := []map[string]interface{}{first, other, second}
|
||||
|
||||
EnrichReactions(runtime, messages)
|
||||
|
||||
if want := []int{2}; !reflect.DeepEqual(observedQueriesPerCall, want) {
|
||||
t.Fatalf("queries-per-call = %v, want %v (each id once, no dup fetch)", observedQueriesPerCall, want)
|
||||
}
|
||||
|
||||
firstReactions, firstOK := first["reactions"]
|
||||
secondReactions, secondOK := second["reactions"]
|
||||
if !firstOK {
|
||||
t.Fatalf("first om_a entry missing reactions")
|
||||
}
|
||||
if !secondOK {
|
||||
t.Fatalf("second om_a entry missing reactions — dup msg_id was dropped")
|
||||
}
|
||||
if !reflect.DeepEqual(firstReactions, secondReactions) {
|
||||
t.Fatalf("dup entries reactions differ: %#v vs %#v", firstReactions, secondReactions)
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,8 @@ var ImChatMessageList = common.Shortcut{
|
||||
Description: "List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "contact:user.base:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.base:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
@@ -34,6 +34,7 @@ var ImChatMessageList = common.Shortcut{
|
||||
{Name: "sort", Default: "desc", Desc: "sort order", Enum: []string{"asc", "desc"}},
|
||||
{Name: "page-size", Default: "50", Desc: "page size (1-50)"},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
d := common.NewDryRunAPI()
|
||||
@@ -54,7 +55,12 @@ var ImChatMessageList = common.Shortcut{
|
||||
dryParams[k] = vs[0]
|
||||
}
|
||||
}
|
||||
return d.GET("/open-apis/im/v1/messages").Params(dryParams)
|
||||
d = d.GET("/open-apis/im/v1/messages").Params(dryParams)
|
||||
if !runtime.Bool("no-reactions") {
|
||||
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
|
||||
Desc("Reaction enrichment: queries returned messages (including thread_replies expanded inline) in batches of up to 20. Pass --no-reactions to skip.")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// Under bot identity, --user-id is not supported; require --chat-id only.
|
||||
@@ -121,6 +127,9 @@ var ImChatMessageList = common.Shortcut{
|
||||
convertlib.ResolveSenderNames(runtime, messages, nameCache)
|
||||
convertlib.AttachSenderNames(messages, nameCache)
|
||||
convertlib.ExpandThreadReplies(runtime, messages, nameCache, convertlib.ThreadRepliesPerThread, convertlib.ThreadRepliesTotalLimit)
|
||||
if !runtime.Bool("no-reactions") {
|
||||
convertlib.EnrichReactions(runtime, messages)
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"messages": messages,
|
||||
|
||||
@@ -22,16 +22,22 @@ var ImMessagesMGet = common.Shortcut{
|
||||
Description: "Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "contact:user.basic_profile:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "contact:user.base:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read", "contact:user.base:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "message-ids", Desc: "message IDs, comma-separated (om_xxx,om_yyy)", Required: true},
|
||||
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ids := common.SplitCSV(runtime.Str("message-ids"))
|
||||
return common.NewDryRunAPI().GET(buildMGetURL(ids))
|
||||
d := common.NewDryRunAPI().GET(buildMGetURL(ids))
|
||||
if !runtime.Bool("no-reactions") {
|
||||
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
|
||||
Desc("Reaction enrichment: queries returned messages in batches of up to 20 to attach the reactions block (operator, action_time, counts). Pass --no-reactions to skip.")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ids := common.SplitCSV(runtime.Str("message-ids"))
|
||||
@@ -69,6 +75,9 @@ var ImMessagesMGet = common.Shortcut{
|
||||
convertlib.ResolveSenderNames(runtime, messages, nameCache)
|
||||
convertlib.AttachSenderNames(messages, nameCache)
|
||||
convertlib.ExpandThreadReplies(runtime, messages, nameCache, convertlib.ThreadRepliesPerThread, convertlib.ThreadRepliesTotalLimit)
|
||||
if !runtime.Bool("no-reactions") {
|
||||
convertlib.EnrichReactions(runtime, messages)
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"messages": messages,
|
||||
|
||||
@@ -30,7 +30,7 @@ var ImMessagesSearch = common.Shortcut{
|
||||
Command: "+messages-search",
|
||||
Description: "Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, enriches results via mget and chats batch_query",
|
||||
Risk: "read",
|
||||
Scopes: []string{"search:message", "contact:user.basic_profile:readonly"},
|
||||
Scopes: []string{"search:message", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
@@ -49,6 +49,7 @@ var ImMessagesSearch = common.Shortcut{
|
||||
{Name: "page-token", Desc: "page token"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate search results"},
|
||||
{Name: "page-limit", Type: "int", Default: "20", Desc: "max search pages when auto-pagination is enabled (default 20, max 40)"},
|
||||
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
req, err := buildMessagesSearchRequest(runtime)
|
||||
@@ -68,12 +69,17 @@ var ImMessagesSearch = common.Shortcut{
|
||||
} else {
|
||||
d = d.Desc("Step 1: search messages")
|
||||
}
|
||||
return d.
|
||||
d = d.
|
||||
POST("/open-apis/im/v1/messages/search").
|
||||
Params(dryParams).
|
||||
Body(req.body).
|
||||
Desc("Step 2 (if results): GET /open-apis/im/v1/messages/mget?message_ids=... — batch fetch message details (max 50)").
|
||||
Desc("Step 3 (if results): POST /open-apis/im/v1/chats/batch_query — fetch chat names for context")
|
||||
if !runtime.Bool("no-reactions") {
|
||||
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
|
||||
Desc("Step 4 (if results): reaction enrichment in batches of up to 20 messages. Pass --no-reactions to skip.")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := buildMessagesSearchRequest(runtime)
|
||||
@@ -184,6 +190,9 @@ var ImMessagesSearch = common.Shortcut{
|
||||
// Enrich: resolve sender names for outer messages (reuses cache from merge_forward)
|
||||
convertlib.ResolveSenderNames(runtime, enriched, nameCache)
|
||||
convertlib.AttachSenderNames(enriched, nameCache)
|
||||
if !runtime.Bool("no-reactions") {
|
||||
convertlib.EnrichReactions(runtime, enriched)
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"messages": enriched,
|
||||
|
||||
@@ -24,8 +24,8 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
Description: "List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "contact:user.basic_profile:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "contact:user.base:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read", "contact:user.base:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
@@ -33,6 +33,7 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
{Name: "sort", Default: "asc", Desc: "sort order", Enum: []string{"asc", "desc"}},
|
||||
{Name: "page-size", Default: "50", Desc: "page size (1-500)"},
|
||||
{Name: "page-token", Desc: "page token"},
|
||||
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
threadFlag := runtime.Str("thread")
|
||||
@@ -65,10 +66,15 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
|
||||
return d.
|
||||
d = d.
|
||||
GET("/open-apis/im/v1/messages").
|
||||
Params(params).
|
||||
Set("thread", threadFlag).Set("sort", sortFlag).Set("page_size", pageSizeStr)
|
||||
if !runtime.Bool("no-reactions") {
|
||||
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
|
||||
Desc("Reaction enrichment: queries returned thread messages in batches of up to 20. Pass --no-reactions to skip.")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
threadId := runtime.Str("thread")
|
||||
@@ -124,6 +130,9 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
// Enrich: resolve sender names for outer messages (reuses cache from merge_forward)
|
||||
convertlib.ResolveSenderNames(runtime, messages, nameCache)
|
||||
convertlib.AttachSenderNames(messages, nameCache)
|
||||
if !runtime.Bool("no-reactions") {
|
||||
convertlib.EnrichReactions(runtime, messages)
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"thread_id": threadId,
|
||||
|
||||
330
shortcuts/mail/mail_draft_send.go
Normal file
330
shortcuts/mail/mail_draft_send.go
Normal file
@@ -0,0 +1,330 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// MaxBatchSendDrafts caps the number of draft IDs accepted in a single
|
||||
// +draft-send invocation. The limit is purely client-side: it bounds command-
|
||||
// line length comfortably below ARG_MAX and keeps the failure blast radius of
|
||||
// a single batch small. It is intentionally local to this shortcut (rather
|
||||
// than living in limits.go) because no other shortcut shares the semantics.
|
||||
const MaxBatchSendDrafts = 50
|
||||
|
||||
// sentDraft is the per-draft success entry in the +draft-send aggregated
|
||||
// output. message_id and thread_id come from the server response of
|
||||
// POST /drafts/:draft_id/send.
|
||||
type sentDraft struct {
|
||||
DraftID string `json:"draft_id"`
|
||||
MessageID string `json:"message_id"`
|
||||
ThreadID string `json:"thread_id,omitempty"`
|
||||
}
|
||||
|
||||
// failedDraft is the per-draft failure entry. error is the
|
||||
// human-readable err.Error() string (typically including ClassifyLarkError
|
||||
// hints); v2 may surface a structured errno field separately once the server-
|
||||
// side mapping stabilises (see tech-design "待确认事项").
|
||||
type failedDraft struct {
|
||||
DraftID string `json:"draft_id"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// batchSendOutput is the JSON envelope data shape:
|
||||
//
|
||||
// {
|
||||
// "mailbox_id": "me",
|
||||
// "total": 3,
|
||||
// "success_count": 2,
|
||||
// "failure_count": 1,
|
||||
// "sent": [{"draft_id":..., "message_id":..., "thread_id":...}, ...],
|
||||
// "failed":[{"draft_id":..., "error":...}]
|
||||
// }
|
||||
//
|
||||
// failed is marked omitempty so a fully successful batch returns a clean shape
|
||||
// without an empty array.
|
||||
type batchSendOutput struct {
|
||||
MailboxID string `json:"mailbox_id"`
|
||||
Total int `json:"total"`
|
||||
SuccessCount int `json:"success_count"`
|
||||
FailureCount int `json:"failure_count"`
|
||||
Sent []sentDraft `json:"sent"`
|
||||
Failed []failedDraft `json:"failed,omitempty"`
|
||||
}
|
||||
|
||||
// MailDraftSend is the `+draft-send` shortcut: send N existing drafts
|
||||
// sequentially via POST /drafts/:draft_id/send, isolating per-draft failures.
|
||||
// Risk is "high-risk-write"; callers must pass --yes. User identity only —
|
||||
// drafts are user-owned resources and bot has no coherent semantics here.
|
||||
//
|
||||
// Output schema is the batchSendOutput type above. Partial failures (any
|
||||
// failed[]) return exit 1 with envelope.error.type="partial_failure" so that
|
||||
// agents can distinguish "all sent" from "some sent" without parsing the
|
||||
// success_count field.
|
||||
var MailDraftSend = common.Shortcut{
|
||||
Service: "mail",
|
||||
Command: "+draft-send",
|
||||
Description: "Send one or more existing mail drafts sequentially. Calls " +
|
||||
"POST /drafts/:draft_id/send for each input ID, isolates per-draft " +
|
||||
"failures, and aggregates the results. Use after the drafts have " +
|
||||
"already been created (via the Lark client, +draft-create, or the " +
|
||||
"drafts.create API).",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"mail:user_mailbox.message:send"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "mailbox", Desc: "Mailbox email address that owns the drafts (default: me)."},
|
||||
{Name: "draft-id", Type: "string_slice", Required: true,
|
||||
Desc: "Draft IDs to send; comma-separated or repeat the flag (max 50)."},
|
||||
{Name: "stop-on-error", Type: "bool",
|
||||
Desc: "Stop at the first recoverable per-draft failure (default: continue and aggregate). " +
|
||||
"Fatal errors (auth, permission, network, mailbox-level quota) always abort immediately " +
|
||||
"regardless of this flag."},
|
||||
},
|
||||
Validate: validateDraftSend,
|
||||
DryRun: dryRunDraftSend,
|
||||
Execute: executeDraftSend,
|
||||
}
|
||||
|
||||
// executeDraftSend runs the +draft-send command:
|
||||
//
|
||||
// 1. Resolve mailbox ID (defaults to "me" via resolveComposeMailboxID).
|
||||
// 2. Validate the draft-id slice (non-empty, under MaxBatchSendDrafts cap,
|
||||
// no empty elements).
|
||||
// 3. Loop over each draft ID, calling POST .../drafts/:id/send directly via
|
||||
// runtime.CallAPI. Per-draft outcomes:
|
||||
// - fatal err (isFatalSendErr) → return immediately (bypasses --stop-on-error).
|
||||
// - recoverable err → append to failed[]; honor --stop-on-error.
|
||||
// - success + automation_send_disable signal → return immediately with
|
||||
// ExitAPI/"automation_send_disabled".
|
||||
// - success → append to sent[].
|
||||
// 4. Emit batchSendOutput via runtime.Out.
|
||||
// 5. If any draft failed, return ExitAPI/"partial_failure" so exit code = 1.
|
||||
func executeDraftSend(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
mailboxID := resolveComposeMailboxID(rt)
|
||||
draftIDs, err := normalizedDraftSendIDs(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := batchSendOutput{MailboxID: mailboxID, Total: len(draftIDs)}
|
||||
stopOnErr := rt.Bool("stop-on-error")
|
||||
for i, id := range draftIDs {
|
||||
idx := i + 1
|
||||
writeDraftSendProgressf(rt, "[%d/%d] sending draft %s",
|
||||
idx, len(draftIDs), sanitizeForSingleLine(id))
|
||||
// Direct CallAPI rather than draftpkg.Send: this shortcut never sends
|
||||
// a body, so the helper's send_time-aware envelope would add no value.
|
||||
data, err := rt.CallAPI("POST",
|
||||
mailboxPath(mailboxID, "drafts", id, "send"), nil, nil)
|
||||
if err != nil {
|
||||
if isFatalSendErr(err) {
|
||||
writeDraftSendProgressf(rt, "[%d/%d] aborting after draft %s: %s",
|
||||
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(err.Error()))
|
||||
hadProgress := out.hasProgress()
|
||||
out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()})
|
||||
if hadProgress {
|
||||
emitDraftSendOutput(rt, &out)
|
||||
}
|
||||
// Account- / mailbox-level failures (auth, permission, network,
|
||||
// quota) will repeat identically for every remaining draft —
|
||||
// abort immediately so the caller sees a single clear error
|
||||
// instead of 100 redundant failed[] entries.
|
||||
return err
|
||||
}
|
||||
writeDraftSendProgressf(rt, "[%d/%d] failed draft %s: %s",
|
||||
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(err.Error()))
|
||||
out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()})
|
||||
if stopOnErr {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
if reason := extractAutomationDisabledReason(data); reason != "" {
|
||||
err := output.Errorf(output.ExitAPI, "automation_send_disabled",
|
||||
"automation send is disabled for this mailbox: %s", reason)
|
||||
writeDraftSendProgressf(rt, "[%d/%d] aborting after draft %s: %s",
|
||||
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(err.Error()))
|
||||
if out.hasProgress() {
|
||||
out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()})
|
||||
emitDraftSendOutput(rt, &out)
|
||||
}
|
||||
// HTTP success (code: 0) but the backend signaled automation send
|
||||
// is disabled — every subsequent send will fail the same way, so
|
||||
// abort the batch with a single descriptive error.
|
||||
return err
|
||||
}
|
||||
s := sentDraft{DraftID: id}
|
||||
if v, ok := data["message_id"].(string); ok {
|
||||
s.MessageID = v
|
||||
}
|
||||
if v, ok := data["thread_id"].(string); ok {
|
||||
s.ThreadID = v
|
||||
}
|
||||
out.Sent = append(out.Sent, s)
|
||||
if s.MessageID != "" {
|
||||
writeDraftSendProgressf(rt, "[%d/%d] sent draft %s message_id=%s",
|
||||
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(s.MessageID))
|
||||
} else {
|
||||
writeDraftSendProgressf(rt, "[%d/%d] sent draft %s",
|
||||
idx, len(draftIDs), sanitizeForSingleLine(id))
|
||||
}
|
||||
}
|
||||
emitDraftSendOutput(rt, &out)
|
||||
|
||||
if out.FailureCount == 0 {
|
||||
return nil
|
||||
}
|
||||
return output.Errorf(output.ExitAPI, "partial_failure",
|
||||
"%d of %d drafts failed to send", out.FailureCount, out.Total)
|
||||
}
|
||||
|
||||
// dryRunDraftSend builds the --dry-run preview: one POST call per draft ID,
|
||||
// in input order, with a header description summarising the batch size.
|
||||
func dryRunDraftSend(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveComposeMailboxID(rt)
|
||||
draftIDs, _ := normalizedDraftSendIDs(rt)
|
||||
api := common.NewDryRunAPI().Desc(fmt.Sprintf(
|
||||
"Send %d existing drafts sequentially", len(draftIDs)))
|
||||
for _, id := range draftIDs {
|
||||
api = api.POST(mailboxPath(mailboxID, "drafts", id, "send"))
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
func validateDraftSend(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
_, err := normalizedDraftSendIDs(rt)
|
||||
return err
|
||||
}
|
||||
|
||||
func normalizedDraftSendIDs(rt *common.RuntimeContext) ([]string, error) {
|
||||
return normalizeDraftSendIDs(rt.StrSlice("draft-id"))
|
||||
}
|
||||
|
||||
func normalizeDraftSendIDs(draftIDs []string) ([]string, error) {
|
||||
if len(draftIDs) == 0 {
|
||||
return nil, output.ErrValidation("--draft-id is required")
|
||||
}
|
||||
|
||||
normalized := make([]string, 0, len(draftIDs))
|
||||
seen := make(map[string]struct{}, len(draftIDs))
|
||||
for _, id := range draftIDs {
|
||||
trimmed := strings.TrimSpace(id)
|
||||
if trimmed == "" {
|
||||
return nil, output.ErrValidation("--draft-id contains empty value")
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
return nil, output.ErrValidation("--draft-id contains duplicate value: %s", trimmed)
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
normalized = append(normalized, trimmed)
|
||||
}
|
||||
if len(normalized) > MaxBatchSendDrafts {
|
||||
return nil, output.ErrValidation(
|
||||
"too many drafts: %d > %d (split into multiple batches)",
|
||||
len(normalized), MaxBatchSendDrafts)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func (out *batchSendOutput) hasProgress() bool {
|
||||
return len(out.Sent) > 0 || len(out.Failed) > 0
|
||||
}
|
||||
|
||||
func emitDraftSendOutput(rt *common.RuntimeContext, out *batchSendOutput) {
|
||||
out.SuccessCount = len(out.Sent)
|
||||
out.FailureCount = len(out.Failed)
|
||||
rt.Out(*out, nil)
|
||||
}
|
||||
|
||||
func writeDraftSendProgressf(rt *common.RuntimeContext, format string, args ...interface{}) {
|
||||
if rt == nil || rt.Factory == nil || rt.Factory.IOStreams == nil || rt.Factory.IOStreams.ErrOut == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(rt.Factory.IOStreams.ErrOut, "mail +draft-send: "+format+"\n", args...)
|
||||
}
|
||||
|
||||
// isFatalSendErr reports whether err is an account- or mailbox-level failure
|
||||
// that will repeat identically for every subsequent draft. Fatal errors
|
||||
// bypass --stop-on-error and immediately abort the batch.
|
||||
//
|
||||
// Trigger conditions:
|
||||
//
|
||||
// - err does not unwrap to an *output.ExitError, or its Detail is missing:
|
||||
// unknown shapes are treated as fatal so they cannot accidentally
|
||||
// accumulate into failed[] for every remaining draft.
|
||||
// - Detail.Type ∈ {"auth", "app_status", "config", "permission",
|
||||
// "rate_limit", "network"}: token, scope, app-installation problems,
|
||||
// throttling, and connectivity are account-level.
|
||||
// - Code == output.ExitNetwork: connectivity loss is account-level.
|
||||
// - Detail.Code ∈ {LarkErrMailboxNotFound, LarkErrMailSendQuotaUser,
|
||||
// LarkErrMailSendQuotaUserExt, LarkErrMailSendQuotaTenantExt,
|
||||
// LarkErrMailQuota, LarkErrTenantStorageLimit}: mailbox / quota
|
||||
// exhaustion is account-level.
|
||||
func isFatalSendErr(err error) bool {
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
return true
|
||||
}
|
||||
switch exitErr.Detail.Type {
|
||||
case "auth", "app_status", "config":
|
||||
return true
|
||||
case "permission", "rate_limit", "network":
|
||||
return true
|
||||
}
|
||||
if exitErr.Code == output.ExitNetwork || wrapsExitCode(err, output.ExitNetwork) {
|
||||
return true
|
||||
}
|
||||
switch exitErr.Detail.Code {
|
||||
case output.LarkErrMailboxNotFound,
|
||||
output.LarkErrMailSendQuotaUser,
|
||||
output.LarkErrMailSendQuotaUserExt,
|
||||
output.LarkErrMailSendQuotaTenantExt,
|
||||
output.LarkErrMailQuota,
|
||||
output.LarkErrTenantStorageLimit:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func wrapsExitCode(err error, code int) bool {
|
||||
for unwrapped := errors.Unwrap(err); unwrapped != nil; unwrapped = errors.Unwrap(unwrapped) {
|
||||
if exitErr, ok := unwrapped.(*output.ExitError); ok && exitErr.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractAutomationDisabledReason returns the human-readable reason when the
|
||||
// send succeeded at HTTP level (code: 0) but the backend reports that
|
||||
// automation send is disabled for this mailbox. An empty return value means
|
||||
// automation send is enabled.
|
||||
//
|
||||
// The data["automation_send_disable"] payload is best-effort: a malformed
|
||||
// shape or missing reason still produces a generic non-empty message so the
|
||||
// caller can surface the disabled status to the user instead of silently
|
||||
// continuing.
|
||||
func extractAutomationDisabledReason(data map[string]interface{}) string {
|
||||
ad, ok := data["automation_send_disable"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
m, ok := ad.(map[string]interface{})
|
||||
if !ok {
|
||||
return "automation send disabled (no reason provided)"
|
||||
}
|
||||
if reason, ok := m["reason"].(string); ok && strings.TrimSpace(reason) != "" {
|
||||
return strings.TrimSpace(reason)
|
||||
}
|
||||
return "automation send disabled (no reason provided)"
|
||||
}
|
||||
942
shortcuts/mail/mail_draft_send_test.go
Normal file
942
shortcuts/mail/mail_draft_send_test.go
Normal file
@@ -0,0 +1,942 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestMailDraftSend_Metadata pins the public surface of the +draft-send
|
||||
// shortcut: command name, risk level, scopes, auth type, and the three
|
||||
// declared flags. Changing any of these is a public-contract change and must
|
||||
// be intentional.
|
||||
func TestMailDraftSend_Metadata(t *testing.T) {
|
||||
if MailDraftSend.Service != "mail" {
|
||||
t.Errorf("Service = %q, want %q", MailDraftSend.Service, "mail")
|
||||
}
|
||||
if MailDraftSend.Command != "+draft-send" {
|
||||
t.Errorf("Command = %q, want %q", MailDraftSend.Command, "+draft-send")
|
||||
}
|
||||
if MailDraftSend.Risk != "high-risk-write" {
|
||||
t.Errorf("Risk = %q, want %q", MailDraftSend.Risk, "high-risk-write")
|
||||
}
|
||||
if !MailDraftSend.HasFormat {
|
||||
t.Error("HasFormat must be true so --format is auto-injected")
|
||||
}
|
||||
if len(MailDraftSend.AuthTypes) != 1 || MailDraftSend.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v, want [user]", MailDraftSend.AuthTypes)
|
||||
}
|
||||
// Minimum-permission rule: only :send. Adding :modify or :readonly here is
|
||||
// an explicit scope-policy regression.
|
||||
if len(MailDraftSend.Scopes) != 1 || MailDraftSend.Scopes[0] != "mail:user_mailbox.message:send" {
|
||||
t.Errorf("Scopes = %v, want [mail:user_mailbox.message:send]", MailDraftSend.Scopes)
|
||||
}
|
||||
|
||||
flagByName := map[string]common.Flag{}
|
||||
for _, fl := range MailDraftSend.Flags {
|
||||
flagByName[fl.Name] = fl
|
||||
}
|
||||
mailbox, ok := flagByName["mailbox"]
|
||||
if !ok {
|
||||
t.Fatal("missing --mailbox flag")
|
||||
}
|
||||
if mailbox.Required {
|
||||
t.Error("--mailbox must NOT be Required (defaults to me via resolveComposeMailboxID)")
|
||||
}
|
||||
if mailbox.Default != "" {
|
||||
t.Errorf("--mailbox Default should be empty (let resolveComposeMailboxID supply 'me'); got %q", mailbox.Default)
|
||||
}
|
||||
draftID, ok := flagByName["draft-id"]
|
||||
if !ok {
|
||||
t.Fatal("missing --draft-id flag")
|
||||
}
|
||||
if !draftID.Required {
|
||||
t.Error("--draft-id must be Required so cobra rejects missing-flag invocations")
|
||||
}
|
||||
if draftID.Type != "string_slice" {
|
||||
t.Errorf("--draft-id Type = %q, want %q", draftID.Type, "string_slice")
|
||||
}
|
||||
stopOnErr, ok := flagByName["stop-on-error"]
|
||||
if !ok {
|
||||
t.Fatal("missing --stop-on-error flag")
|
||||
}
|
||||
if stopOnErr.Required {
|
||||
t.Error("--stop-on-error must be optional")
|
||||
}
|
||||
if stopOnErr.Type != "bool" {
|
||||
t.Errorf("--stop-on-error Type = %q, want %q", stopOnErr.Type, "bool")
|
||||
}
|
||||
}
|
||||
|
||||
// stubDraftSend registers a stub for POST .../drafts/<draftID>/send with the
|
||||
// supplied response body. Used to assemble multi-draft test scenarios.
|
||||
func stubDraftSend(reg *httpmock.Registry, draftID string, body map[string]interface{}) *httpmock.Stub {
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/drafts/" + draftID + "/send",
|
||||
Body: body,
|
||||
}
|
||||
reg.Register(stub)
|
||||
return stub
|
||||
}
|
||||
|
||||
// TestMailDraftSend_AllSuccess verifies the happy path: every draft sends
|
||||
// successfully, sent[] is fully populated, failed[] is omitted from the JSON,
|
||||
// and exit code = 0 (err == nil).
|
||||
func TestMailDraftSend_AllSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stubDraftSend(reg, "d1", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"message_id": "msg_1",
|
||||
"thread_id": "thread_1",
|
||||
},
|
||||
})
|
||||
stubDraftSend(reg, "d2", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"message_id": "msg_2",
|
||||
"thread_id": "thread_2",
|
||||
},
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftSend, []string{
|
||||
"+draft-send",
|
||||
"--draft-id", "d1,d2",
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil err on full success, got %v", err)
|
||||
}
|
||||
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
if data["total"].(float64) != 2 {
|
||||
t.Errorf("total = %v, want 2", data["total"])
|
||||
}
|
||||
if data["success_count"].(float64) != 2 {
|
||||
t.Errorf("success_count = %v, want 2", data["success_count"])
|
||||
}
|
||||
if data["failure_count"].(float64) != 0 {
|
||||
t.Errorf("failure_count = %v, want 0", data["failure_count"])
|
||||
}
|
||||
sent, ok := data["sent"].([]interface{})
|
||||
if !ok || len(sent) != 2 {
|
||||
t.Fatalf("sent[] missing or wrong size: %#v", data["sent"])
|
||||
}
|
||||
if _, exists := data["failed"]; exists {
|
||||
t.Errorf("failed[] should be omitted on full success; got %#v", data["failed"])
|
||||
}
|
||||
first := sent[0].(map[string]interface{})
|
||||
if first["draft_id"] != "d1" || first["message_id"] != "msg_1" || first["thread_id"] != "thread_1" {
|
||||
t.Errorf("first sent entry shape unexpected: %#v", first)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_ProgressWritesToStderr verifies long sends do not look
|
||||
// hung: per-draft progress is emitted on stderr while stdout remains the
|
||||
// final machine-readable JSON ledger.
|
||||
func TestMailDraftSend_ProgressWritesToStderr(t *testing.T) {
|
||||
f, stdout, stderr, reg := mailShortcutTestFactory(t)
|
||||
stubDraftSend(reg, "d1", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"message_id": "msg_1",
|
||||
},
|
||||
})
|
||||
stubDraftSend(reg, "d2", map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "draft not found",
|
||||
})
|
||||
stubDraftSend(reg, "d3", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"message_id": "msg_3",
|
||||
},
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftSend, []string{
|
||||
"+draft-send",
|
||||
"--draft-id", "d1,d2,d3",
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial_failure error, got nil")
|
||||
}
|
||||
|
||||
progress := stderr.String()
|
||||
for _, want := range []string{
|
||||
"mail +draft-send: [1/3] sending draft d1",
|
||||
"mail +draft-send: [1/3] sent draft d1 message_id=msg_1",
|
||||
"mail +draft-send: [2/3] sending draft d2",
|
||||
"mail +draft-send: [2/3] failed draft d2:",
|
||||
"mail +draft-send: [3/3] sending draft d3",
|
||||
"mail +draft-send: [3/3] sent draft d3 message_id=msg_3",
|
||||
} {
|
||||
if !strings.Contains(progress, want) {
|
||||
t.Errorf("stderr missing %q; got %s", want, progress)
|
||||
}
|
||||
}
|
||||
if strings.Contains(stdout.String(), "mail +draft-send:") {
|
||||
t.Errorf("stdout must not contain progress lines; got %s", stdout.String())
|
||||
}
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
if data["success_count"].(float64) != 2 || data["failure_count"].(float64) != 1 {
|
||||
t.Errorf("unexpected aggregate counts: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_PartialFailure verifies that one recoverable per-draft
|
||||
// failure does not abort the batch; the remaining drafts are attempted; both
|
||||
// arrays are populated; and the call returns ExitAPI/"partial_failure".
|
||||
func TestMailDraftSend_PartialFailure(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stubDraftSend(reg, "d1", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"message_id": "msg_1"},
|
||||
})
|
||||
// Non-fatal code (not in the {auth, app_status, config, permission,
|
||||
// network, 1234013, 1236007, 1236008, 1236009, 1236010, 1236013}
|
||||
// set) → recoverable.
|
||||
stubDraftSend(reg, "d2", map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "draft not found or already sent",
|
||||
})
|
||||
stubDraftSend(reg, "d3", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"message_id": "msg_3"},
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftSend, []string{
|
||||
"+draft-send",
|
||||
"--draft-id", "d1,d2,d3",
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial_failure error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Errorf("Code = %d, want ExitAPI=%d", exitErr.Code, output.ExitAPI)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "partial_failure" {
|
||||
t.Errorf("Detail.Type = %v, want partial_failure", exitErr.Detail)
|
||||
}
|
||||
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
if data["total"].(float64) != 3 {
|
||||
t.Errorf("total = %v, want 3", data["total"])
|
||||
}
|
||||
if data["success_count"].(float64) != 2 {
|
||||
t.Errorf("success_count = %v, want 2", data["success_count"])
|
||||
}
|
||||
if data["failure_count"].(float64) != 1 {
|
||||
t.Errorf("failure_count = %v, want 1", data["failure_count"])
|
||||
}
|
||||
failed, ok := data["failed"].([]interface{})
|
||||
if !ok || len(failed) != 1 {
|
||||
t.Fatalf("failed[] missing or wrong size: %#v", data["failed"])
|
||||
}
|
||||
failedEntry := failed[0].(map[string]interface{})
|
||||
if failedEntry["draft_id"] != "d2" {
|
||||
t.Errorf("failed entry draft_id = %v, want d2", failedEntry["draft_id"])
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(failedEntry["error"].(string)), "draft not found") {
|
||||
t.Errorf("failed entry error should contain server msg, got %q", failedEntry["error"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_StopOnError verifies --stop-on-error short-circuits at the
|
||||
// first recoverable failure. d3 is intentionally NOT stubbed: if the loop
|
||||
// kept going, the httpmock RoundTripper would return "no stub for POST
|
||||
// /user_mailboxes/me/drafts/d3/send" and Execute would surface it.
|
||||
func TestMailDraftSend_StopOnError(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stubDraftSend(reg, "d1", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"message_id": "msg_1"},
|
||||
})
|
||||
stubDraftSend(reg, "d2", map[string]interface{}{
|
||||
"code": 230001,
|
||||
"msg": "draft not found",
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftSend, []string{
|
||||
"+draft-send",
|
||||
"--draft-id", "d1,d2,d3",
|
||||
"--yes",
|
||||
"--stop-on-error",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected partial_failure error, got nil")
|
||||
}
|
||||
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
if data["success_count"].(float64) != 1 {
|
||||
t.Errorf("success_count = %v, want 1", data["success_count"])
|
||||
}
|
||||
if data["failure_count"].(float64) != 1 {
|
||||
t.Errorf("failure_count = %v, want 1", data["failure_count"])
|
||||
}
|
||||
if data["total"].(float64) != 3 {
|
||||
t.Errorf("total = %v, want 3", data["total"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_FatalAborts verifies that a fatal errno (mailbox not
|
||||
// found) aborts the batch immediately and does NOT populate failed[]; the
|
||||
// later drafts are not attempted (d2 is intentionally not stubbed — any
|
||||
// attempt would be observable as a runner failure from the httpmock layer).
|
||||
func TestMailDraftSend_FatalAborts(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stubDraftSend(reg, "d1", map[string]interface{}{
|
||||
"code": output.LarkErrMailboxNotFound,
|
||||
"msg": "mailbox not found",
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftSend, []string{
|
||||
"+draft-send",
|
||||
"--draft-id", "d1,d2",
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected fatal abort error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Code != output.LarkErrMailboxNotFound {
|
||||
t.Errorf("expected Detail.Code = %d, got %#v", output.LarkErrMailboxNotFound, exitErr.Detail)
|
||||
}
|
||||
// No JSON envelope on stdout because Execute returned early before rt.Out.
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("expected no JSON output on fatal abort, got %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_FatalAfterSuccessEmitsLedger verifies that a fatal error
|
||||
// after earlier side effects still emits the aggregate stdout ledger before
|
||||
// returning the fatal stderr error. This lets callers avoid blindly retrying a
|
||||
// draft that was already sent.
|
||||
func TestMailDraftSend_FatalAfterSuccessEmitsLedger(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stubDraftSend(reg, "d1", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"message_id": "msg_1"},
|
||||
})
|
||||
stubDraftSend(reg, "d2", map[string]interface{}{
|
||||
"code": output.LarkErrMailSendQuotaUser,
|
||||
"msg": "user daily send count exceeded",
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftSend, []string{
|
||||
"+draft-send",
|
||||
"--draft-id", "d1,d2,d3",
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected fatal abort error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Code != output.LarkErrMailSendQuotaUser {
|
||||
t.Errorf("expected Detail.Code = %d, got %#v", output.LarkErrMailSendQuotaUser, exitErr.Detail)
|
||||
}
|
||||
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
if data["total"].(float64) != 3 {
|
||||
t.Errorf("total = %v, want 3", data["total"])
|
||||
}
|
||||
if data["success_count"].(float64) != 1 {
|
||||
t.Errorf("success_count = %v, want 1", data["success_count"])
|
||||
}
|
||||
if data["failure_count"].(float64) != 1 {
|
||||
t.Errorf("failure_count = %v, want 1", data["failure_count"])
|
||||
}
|
||||
if got := gjsonLikeString(t, data, "sent", 0, "draft_id"); got != "d1" {
|
||||
t.Errorf("sent[0].draft_id = %q, want d1", got)
|
||||
}
|
||||
if got := gjsonLikeString(t, data, "failed", 0, "draft_id"); got != "d2" {
|
||||
t.Errorf("failed[0].draft_id = %q, want d2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_AutomationDisabled verifies that an HTTP-success response
|
||||
// carrying the automation_send_disable signal aborts the batch with
|
||||
// ExitAPI/"automation_send_disabled" and does NOT continue to subsequent
|
||||
// drafts (d2 intentionally has no stub — any attempt would surface as an
|
||||
// httpmock "no stub" failure).
|
||||
func TestMailDraftSend_AutomationDisabled(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stubDraftSend(reg, "d1", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"message_id": "msg_1",
|
||||
"automation_send_disable": map[string]interface{}{
|
||||
"reason": "policy: outbound automation disabled",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftSend, []string{
|
||||
"+draft-send",
|
||||
"--draft-id", "d1,d2",
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected automation_send_disabled error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Errorf("Code = %d, want ExitAPI=%d", exitErr.Code, output.ExitAPI)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "automation_send_disabled" {
|
||||
t.Errorf("Detail.Type = %v, want automation_send_disabled", exitErr.Detail)
|
||||
}
|
||||
if !strings.Contains(exitErr.Error(), "outbound automation disabled") {
|
||||
t.Errorf("error message should propagate reason, got %q", exitErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_AutomationDisabledAfterSuccessEmitsLedger verifies that an
|
||||
// automation-send policy stop after earlier successful sends still writes the
|
||||
// batch ledger to stdout before returning the structured fatal error.
|
||||
func TestMailDraftSend_AutomationDisabledAfterSuccessEmitsLedger(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stubDraftSend(reg, "d1", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"message_id": "msg_1"},
|
||||
})
|
||||
stubDraftSend(reg, "d2", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"message_id": "msg_2",
|
||||
"automation_send_disable": map[string]interface{}{
|
||||
"reason": "policy: outbound automation disabled",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftSend, []string{
|
||||
"+draft-send",
|
||||
"--draft-id", "d1,d2,d3",
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected automation_send_disabled error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "automation_send_disabled" {
|
||||
t.Errorf("Detail.Type = %v, want automation_send_disabled", exitErr.Detail)
|
||||
}
|
||||
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
if data["total"].(float64) != 3 {
|
||||
t.Errorf("total = %v, want 3", data["total"])
|
||||
}
|
||||
if data["success_count"].(float64) != 1 {
|
||||
t.Errorf("success_count = %v, want 1", data["success_count"])
|
||||
}
|
||||
if data["failure_count"].(float64) != 1 {
|
||||
t.Errorf("failure_count = %v, want 1", data["failure_count"])
|
||||
}
|
||||
if got := gjsonLikeString(t, data, "sent", 0, "draft_id"); got != "d1" {
|
||||
t.Errorf("sent[0].draft_id = %q, want d1", got)
|
||||
}
|
||||
if got := gjsonLikeString(t, data, "failed", 0, "draft_id"); got != "d2" {
|
||||
t.Errorf("failed[0].draft_id = %q, want d2", got)
|
||||
}
|
||||
if got := gjsonLikeString(t, data, "failed", 0, "error"); !strings.Contains(got, "outbound automation disabled") {
|
||||
t.Errorf("failed[0].error should contain reason, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_ValidateErrors verifies that input-shape problems are
|
||||
// caught in the pre-call layers (cobra Required + Validate). No network call
|
||||
// is registered; the test should fail loudly if any HTTP call is attempted
|
||||
// (httpmock returns "no stub" in that case).
|
||||
func TestMailDraftSend_ValidateErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantSub string
|
||||
wantCobra bool // true → cobra-level MarkFlagRequired error path
|
||||
}{
|
||||
{
|
||||
name: "missing draft-id",
|
||||
args: []string{"+draft-send", "--yes"},
|
||||
wantSub: `required flag(s) "draft-id" not set`,
|
||||
wantCobra: true,
|
||||
},
|
||||
{
|
||||
// cobra's StringSlice treats a bare "" as an unset flag, so pass a
|
||||
// whitespace-only element instead to drive the Validate-callback
|
||||
// empty-element branch.
|
||||
name: "whitespace-only value",
|
||||
args: []string{"+draft-send", "--draft-id", " ", "--yes"},
|
||||
wantSub: "--draft-id contains empty value",
|
||||
},
|
||||
{
|
||||
name: "exceeds cap",
|
||||
args: []string{"+draft-send", "--draft-id", manyDraftIDs(MaxBatchSendDrafts + 1), "--yes"},
|
||||
wantSub: "too many drafts",
|
||||
},
|
||||
{
|
||||
name: "duplicate value",
|
||||
args: []string{"+draft-send", "--draft-id", "d1,d2,d1", "--yes"},
|
||||
wantSub: "--draft-id contains duplicate value: d1",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailDraftSend, c.args, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.wantSub) {
|
||||
t.Errorf("err = %v, want substring %q", err, c.wantSub)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailDraftSend_DryRunValidateErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantSub string
|
||||
}{
|
||||
{
|
||||
name: "whitespace-only value",
|
||||
args: []string{"+draft-send", "--draft-id", " ", "--dry-run"},
|
||||
wantSub: "--draft-id contains empty value",
|
||||
},
|
||||
{
|
||||
name: "exceeds cap",
|
||||
args: []string{"+draft-send", "--draft-id", manyDraftIDs(MaxBatchSendDrafts + 1), "--dry-run"},
|
||||
wantSub: "too many drafts",
|
||||
},
|
||||
{
|
||||
name: "duplicate value",
|
||||
args: []string{"+draft-send", "--draft-id", "d1,d2,d1", "--dry-run"},
|
||||
wantSub: "--draft-id contains duplicate value: d1",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailDraftSend, c.args, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.wantSub) {
|
||||
t.Errorf("err = %v, want substring %q", err, c.wantSub)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("expected no dry-run output on validation error, got %s", stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// manyDraftIDs returns a CSV string with n synthesised IDs. Used to drive the
|
||||
// >MaxBatchSendDrafts validation branch without bloating the test file with a
|
||||
// hand-written list.
|
||||
func manyDraftIDs(n int) string {
|
||||
parts := make([]string, n)
|
||||
for i := range parts {
|
||||
parts[i] = "d" + strings.Repeat("x", 1) + intToString(i)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// intToString avoids the strconv import noise for a tiny test helper.
|
||||
func intToString(i int) string {
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
pos := len(buf)
|
||||
for i > 0 {
|
||||
pos--
|
||||
buf[pos] = byte('0' + i%10)
|
||||
i /= 10
|
||||
}
|
||||
return string(buf[pos:])
|
||||
}
|
||||
|
||||
// TestMailDraftSend_MissingYes verifies the framework's high-risk-write
|
||||
// confirmation gate triggers ExitConfirmationRequired (10) when --yes is
|
||||
// omitted, before Execute is called.
|
||||
func TestMailDraftSend_MissingYes(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailDraftSend, []string{
|
||||
"+draft-send",
|
||||
"--draft-id", "d1",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected ExitConfirmationRequired, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitConfirmationRequired {
|
||||
t.Errorf("Code = %d, want ExitConfirmationRequired=%d", exitErr.Code, output.ExitConfirmationRequired)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_DryRun verifies --dry-run prints N POST calls in input
|
||||
// order and does NOT touch the network.
|
||||
func TestMailDraftSend_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailDraftSend, []string{
|
||||
"+draft-send",
|
||||
"--draft-id", " d1 , d2 ",
|
||||
"--draft-id", " d3 ",
|
||||
"--yes",
|
||||
"--dry-run",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run failed: %v", err)
|
||||
}
|
||||
s := stdout.String()
|
||||
for _, want := range []string{
|
||||
`/user_mailboxes/me/drafts/d1/send`,
|
||||
`/user_mailboxes/me/drafts/d2/send`,
|
||||
`/user_mailboxes/me/drafts/d3/send`,
|
||||
`"method"`,
|
||||
`"POST"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("dry-run output missing %q; got %s", want, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_NormalizesDraftIDs verifies request paths and output use
|
||||
// trimmed draft IDs rather than preserving CLI whitespace.
|
||||
func TestMailDraftSend_NormalizesDraftIDs(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stubDraftSend(reg, "d1", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"message_id": "msg_1"},
|
||||
})
|
||||
stubDraftSend(reg, "d2", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"message_id": "msg_2"},
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftSend, []string{
|
||||
"+draft-send",
|
||||
"--draft-id", " d1 , d2 ",
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
if got := gjsonLikeString(t, data, "sent", 0, "draft_id"); got != "d1" {
|
||||
t.Errorf("sent[0].draft_id = %q, want d1", got)
|
||||
}
|
||||
if got := gjsonLikeString(t, data, "sent", 1, "draft_id"); got != "d2" {
|
||||
t.Errorf("sent[1].draft_id = %q, want d2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_DryRunDirectInvocation drives dryRunDraftSend through a
|
||||
// hand-built RuntimeContext so the dry-run plan can be inspected without the
|
||||
// full Mount pipeline. Useful for catching path-encoding regressions in
|
||||
// mailboxPath().
|
||||
func TestMailDraftSend_DryRunDirectInvocation(t *testing.T) {
|
||||
rt := runtimeForMailDraftSendTest(t, map[string]string{
|
||||
"mailbox": "alice@example.com",
|
||||
}, []string{"d1", "d2"})
|
||||
api := dryRunDraftSend(context.Background(), rt)
|
||||
raw, err := json.Marshal(api)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry-run failed: %v", err)
|
||||
}
|
||||
s := string(raw)
|
||||
for _, want := range []string{
|
||||
`/user_mailboxes/alice@example.com/drafts/d1/send`,
|
||||
`/user_mailboxes/alice@example.com/drafts/d2/send`,
|
||||
`"method":"POST"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("dry-run JSON missing %q; got %s", want, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runtimeForMailDraftSendTest builds a minimal RuntimeContext with the +draft-
|
||||
// send flag set so the DryRun callback can be exercised directly. Mirrors
|
||||
// runtimeForMailDeclineReceiptDryRun.
|
||||
func runtimeForMailDraftSendTest(t *testing.T, strFlags map[string]string, draftIDs []string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("mailbox", "", "")
|
||||
cmd.Flags().StringSlice("draft-id", nil, "")
|
||||
cmd.Flags().Bool("stop-on-error", false, "")
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("parse flags failed: %v", err)
|
||||
}
|
||||
for k, v := range strFlags {
|
||||
if err := cmd.Flags().Set(k, v); err != nil {
|
||||
t.Fatalf("set flag --%s failed: %v", k, err)
|
||||
}
|
||||
}
|
||||
for _, id := range draftIDs {
|
||||
if err := cmd.Flags().Set("draft-id", id); err != nil {
|
||||
t.Fatalf("set draft-id failed: %v", err)
|
||||
}
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_MailboxFallback verifies that omitting --mailbox falls
|
||||
// through to "me" via resolveComposeMailboxID, and the output reflects it.
|
||||
func TestMailDraftSend_MailboxFallback(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stubDraftSend(reg, "d1", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"message_id": "msg_1"},
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftSend, []string{
|
||||
"+draft-send",
|
||||
"--draft-id", "d1",
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
if data["mailbox_id"] != "me" {
|
||||
t.Errorf("mailbox_id = %v, want me (default)", data["mailbox_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftSend_RepeatedFlagAndCSV verifies that string_slice supports
|
||||
// both the repeated-flag form (--draft-id d1 --draft-id d2) and the
|
||||
// comma-separated form (--draft-id d1,d2) — and mixing both in one invocation.
|
||||
func TestMailDraftSend_RepeatedFlagAndCSV(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
stubDraftSend(reg, "d1", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"message_id": "msg_1"},
|
||||
})
|
||||
stubDraftSend(reg, "d2", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"message_id": "msg_2"},
|
||||
})
|
||||
stubDraftSend(reg, "d3", map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"message_id": "msg_3"},
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftSend, []string{
|
||||
"+draft-send",
|
||||
"--draft-id", "d1,d2",
|
||||
"--draft-id", "d3",
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
if data["success_count"].(float64) != 3 {
|
||||
t.Errorf("success_count = %v, want 3", data["success_count"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsFatalSendErr is a focused unit test for the classifier. Covers every
|
||||
// branch documented in the doc comment so future tweaks immediately surface
|
||||
// mis-categorisation.
|
||||
func TestIsFatalSendErr(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil-like / unknown shape → fatal",
|
||||
err: errors.New("raw network panic surfaced unwrapped"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "ExitError without Detail → fatal",
|
||||
err: &output.ExitError{Code: output.ExitInternal},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "auth → fatal",
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAuth,
|
||||
Detail: &output.ErrDetail{Type: "auth", Message: "token expired"},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "app_status → fatal",
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAuth,
|
||||
Detail: &output.ErrDetail{Type: "app_status", Message: "app disabled"},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "config → fatal",
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAuth,
|
||||
Detail: &output.ErrDetail{Type: "config", Message: "bad app_id"},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "permission → fatal",
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{Type: "permission", Message: "denied"},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "rate_limit → fatal",
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{Type: "rate_limit", Code: output.LarkErrRateLimit},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "ExitNetwork → fatal",
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitNetwork,
|
||||
Detail: &output.ErrDetail{Type: "network", Message: "DNS timeout"},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wrapped ExitNetwork → fatal",
|
||||
err: output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", output.ErrNetwork("DNS timeout")),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "LarkErrMailboxNotFound → fatal",
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrMailboxNotFound},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "LarkErrMailSendQuotaUser → fatal",
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrMailSendQuotaUser},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "LarkErrTenantStorageLimit → fatal",
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrTenantStorageLimit},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "generic api_error → recoverable",
|
||||
err: &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{Type: "api_error", Code: 230001},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := isFatalSendErr(c.err)
|
||||
if got != c.want {
|
||||
t.Errorf("isFatalSendErr(%s) = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractAutomationDisabledReason verifies all branches of the helper:
|
||||
// missing key → "", malformed map → generic message, empty/whitespace reason
|
||||
// → generic message, non-empty reason → trimmed value.
|
||||
func TestExtractAutomationDisabledReason(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in map[string]interface{}
|
||||
want string
|
||||
}{
|
||||
{"missing key", map[string]interface{}{"message_id": "x"}, ""},
|
||||
{"non-map value", map[string]interface{}{
|
||||
"automation_send_disable": "not a map",
|
||||
}, "automation send disabled (no reason provided)"},
|
||||
{"map but no reason", map[string]interface{}{
|
||||
"automation_send_disable": map[string]interface{}{},
|
||||
}, "automation send disabled (no reason provided)"},
|
||||
{"reason empty", map[string]interface{}{
|
||||
"automation_send_disable": map[string]interface{}{"reason": " "},
|
||||
}, "automation send disabled (no reason provided)"},
|
||||
{"reason populated", map[string]interface{}{
|
||||
"automation_send_disable": map[string]interface{}{"reason": " policy block "},
|
||||
}, "policy block"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := extractAutomationDisabledReason(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("extractAutomationDisabledReason() = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func gjsonLikeString(t *testing.T, data map[string]interface{}, arrayKey string, index int, field string) string {
|
||||
t.Helper()
|
||||
items, ok := data[arrayKey].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("%s missing or wrong type: %#v", arrayKey, data[arrayKey])
|
||||
}
|
||||
if index >= len(items) {
|
||||
t.Fatalf("%s[%d] missing; len=%d", arrayKey, index, len(items))
|
||||
}
|
||||
item, ok := items[index].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("%s[%d] wrong type: %#v", arrayKey, index, items[index])
|
||||
}
|
||||
value, ok := item[field].(string)
|
||||
if !ok {
|
||||
t.Fatalf("%s[%d].%s missing or wrong type: %#v", arrayKey, index, field, item[field])
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -17,6 +17,7 @@ func Shortcuts() []common.Shortcut {
|
||||
MailReplyAll,
|
||||
MailSend,
|
||||
MailDraftCreate,
|
||||
MailDraftSend,
|
||||
MailDraftEdit,
|
||||
MailForward,
|
||||
MailSendReceipt,
|
||||
|
||||
@@ -33,6 +33,10 @@ When using bot identity (`--as bot`) to fetch messages (e.g. `+chat-messages-lis
|
||||
|
||||
**Solution**: Check the app's visibility settings in the Lark Developer Console — ensure the app's visible range covers the users whose names need to be resolved. Alternatively, use `--as user` to fetch messages with user identity, which typically has broader contact access.
|
||||
|
||||
### Default message enrichment (reactions / update_time)
|
||||
|
||||
The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+messages-search`, `+threads-messages-list`) automatically attach a `reactions` block and (for edited messages) `update_time` to each returned message — no separate `im.reactions.batch_query` call is needed. Pass `--no-reactions` to opt out. For the full contract (output shape, the `im:message.reactions:read` scope requirement, and the "missing field ≠ fetch failure" data rules), read [`references/lark-im-message-enrichment.md`](references/lark-im-message-enrichment.md).
|
||||
|
||||
### Card Messages (Interactive)
|
||||
|
||||
Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr.
|
||||
|
||||
@@ -32,6 +32,11 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
|
||||
> - **精准编辑场景**(`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML(`--doc-format xml`,即默认值)。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。
|
||||
|
||||
## 快速决策
|
||||
- 用户需要“某个 block 的直达链接 / 锚点链接”时:返回 `文档基础 URL#block_id`。如果当前只有文档 URL 没有 block_id,先用 `docs +fetch --detail with-ids` 拿到目标 block 的 id
|
||||
- 例:
|
||||
- 已知文档 URL = `https://xxx.feishu.cn/docx/doxcn123`
|
||||
- 已知 block_id = `blkcn456`
|
||||
- 应返回 `https://xxx.feishu.cn/docx/doxcn123#blkcn456`
|
||||
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
|
||||
- 写文档时,重要信息(核心流程、架构、对比、风险、路线图、关键指标、因果关系)优先规划为画板,不要只用文字或表格承载
|
||||
- 新增画板必须隔离到 SubAgent:简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读 `lark-whiteboard`;复杂图才由主 Agent 先建 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 读取 `lark-whiteboard` 写入
|
||||
|
||||
@@ -86,4 +86,3 @@ lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项
|
||||
- [`lark-doc-update.md`](lark-doc-update.md) — 更新文档
|
||||
- [`lark-doc-media-insert.md`](lark-doc-media-insert.md) — 插入图片/文件到文档
|
||||
- [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
|
||||
@@ -36,10 +36,9 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
|
||||
| 意图 | `--detail` | 说明 |
|
||||
|------|-----------|------|
|
||||
| **只读**:浏览或总结文档内容 | `simple`(默认) | 简洁 XML/Markdown,不含 block ID、样式属性、引用元数据 |
|
||||
| **定位**:需要 block ID 与其他业务交互 | `with-ids` | 包含 block ID(如 `<p id="blkcnXXXX">`),可用于 `+update` 的 `--block-id` |
|
||||
| **定位**:需要 block ID 与其他业务交互 | `with-ids` | 包含 block ID(如 `<p id="blkcnXXXX">`),可用于 `+update` 的 `--block-id`,也可用于拼接 `文档URL#block_id` 形式的直达链接 |
|
||||
| **编辑**:任何修改文档内容的需求 | `full` | 包含 block ID + 样式属性 + 引用元数据,提供完整文档结构信息 |
|
||||
|
||||
|
||||
## 选 `--scope`(读取范围)
|
||||
|
||||
`--scope` 和 `--detail` 正交可组合。**省略 `--scope` 即读整篇;获取一小节时优先用局部读取。**
|
||||
|
||||
@@ -4,56 +4,133 @@
|
||||
|
||||
## 两个 Skill 的职责边界
|
||||
|
||||
| Skill | 核心职责 | 约束 |
|
||||
|------|------|------|
|
||||
| `lark-doc` | 识别画板机会、判断简单/复杂、调度 SubAgent、插入简单 SVG 画板或复杂空白画板 | 主 Agent 不直接创作画板内容;简单图不需要读取 `lark-whiteboard` |
|
||||
| `lark-whiteboard` | 查询/导出已有画板;复杂图表生成(Mermaid/DSL/SVG 路由、场景选型、渲染验证);写入已有/空白画板 | 仅复杂图或已有画板更新时由独立 SubAgent 读取 |
|
||||
| Skill | 核心职责 | 约束 |
|
||||
|-------------------|-----------------------------------------------------------|---------------------------------|
|
||||
| `lark-doc` | 识别画板机会、使用 Mermaid/SVG 创建图表、调度 SubAgent、插入简单 SVG 画板或复杂空白画板 | 主 Agent 不直接创作画板内容; |
|
||||
| `lark-whiteboard` | 查询/导出已有画板;复杂图表生成(Mermaid/DSL/SVG 路由、场景选型、渲染验证);写入已有/空白画板 | 仅特别复杂的图表或已有画板更新时由独立 SubAgent 读取 |
|
||||
|
||||
## 画板优先规则
|
||||
|
||||
写文档时,重要信息优先画板化。遇到核心流程、系统架构、方案对比、风险链路、里程碑、指标趋势、因果归因、组织关系、能力分层等内容,不要只用段落或表格承载;除非内容只是一次性补充说明,否则应规划为画板。
|
||||
|
||||
同一篇文档可以有多个画板。优先多个聚焦画板,而不是把所有信息塞进一张大图。
|
||||
同一篇文档可以有多个画板。优先设计多个聚焦画板,而不是把所有信息塞进一张大图。
|
||||
|
||||
## 文档与画板协同流程
|
||||
|
||||
### 步骤 1:识别画板机会
|
||||
|
||||
| 场景 | 入口 |
|
||||
|------|------|
|
||||
| 文档中需要插入简单新画板 | 走步骤 2A |
|
||||
| 文档中需要插入复杂新画板 | 走步骤 2B |
|
||||
| 已有画板需要更新内容 | 先 `docs +fetch --api-version v2` 获取 `board_token`,跳至步骤 3B |
|
||||
| 只查看 / 下载已有画板 | 切换至 `lark-whiteboard`,不走本流程 |
|
||||
| 场景 | 入口 |
|
||||
|-------------------------|-----------------------------------------------------------|
|
||||
| 文档中需要思维导图、时序图、类图、饼图、甘特图 | 步骤 2A:使用 mermaid 插入图表 |
|
||||
| 文档中需要插入其他图表/自定义图形 | 步骤 2B: 使用 SVG 插入图表 |
|
||||
| 已有画板需要更新内容 | 先 `docs +fetch --api-version v2` 获取 `board_token`,跳至步骤 3B |
|
||||
| 只查看 / 下载已有画板 | 切换至 `lark-whiteboard`,不走本流程 |
|
||||
|
||||
简单图判定:节点少、静态、布局可控、适合一个完整自包含 SVG 表达,例如小型流程、2-3 方对比、小型状态机、简单时间线或小型示意图。
|
||||
> [!IMPORTANT]
|
||||
> ⚠️ **分别对每个图表进行决策**
|
||||
|
||||
复杂图判定:节点多、跨泳道/跨系统、需要自动布局或精细排版、包含数据图表、组织架构、复杂架构、复杂依赖、已有画板更新,或需要 `lark-whiteboard` 的渲染验证。
|
||||
如果有多个位置需要插入图表,你需要根据每个图表的内容**分别决定**采用步骤 2A 还是 2B
|
||||
中的方式插入这个图表。在需要插入思维导图、时序图、类图、饼图、甘特图的时候可以插入 mermaid 块,在需要插入其他类型图表时启动
|
||||
SubAgent 插入 SVG。
|
||||
|
||||
### 步骤 2A:简单图 — SubAgent 直接插入 SVG 画板
|
||||
建议优先使用 SVG 插入图表,除非其属于思维导图、时序图、类图、饼图、甘特图这类可以直接使用 mermaid 语法描述,且不适宜用 SVG 绘制的图表
|
||||
|
||||
### 步骤 2A: 使用 mermaid 插入图表
|
||||
|
||||
```xml
|
||||
|
||||
<whiteboard type="mermaid">
|
||||
mermaid 代码...
|
||||
</whiteboard>
|
||||
```
|
||||
|
||||
### 步骤 2B: SubAgent 使用 SVG 插入图表
|
||||
|
||||
主 Agent 启动 SubAgent,让它用 `docs +create --api-version v2` / `docs +update --api-version v2` 插入:
|
||||
|
||||
```xml
|
||||
<whiteboard type="svg"><svg ...>...</svg></whiteboard>
|
||||
|
||||
<whiteboard type="svg">
|
||||
<svg...>...
|
||||
</svg>
|
||||
</whiteboard>
|
||||
```
|
||||
|
||||
简单图 SubAgent 的最小上下文:
|
||||
Sub Agent 需要携带以下的最小上下文,以及后续的 [SVG 设计 Workflow] 章节指南:
|
||||
|
||||
- doc token、插入位置(标题 / block_id / command)
|
||||
- 图表目标、受众、源段落或数据
|
||||
- 要求读取 `lark-doc-xml.md`;不需要读取 `lark-whiteboard`
|
||||
- SVG 必须完整自包含:包含 `<svg>` 根节点和 `viewBox`,不引用外部图片、脚本、远程资源
|
||||
|
||||
### 步骤 2B:复杂图 — 先创建空白画板
|
||||
#### 画板 SVG 设计指南
|
||||
|
||||
- 主 Agent 使用 `docs +create --api-version v2` / `docs +update --api-version v2` 插入 `<whiteboard type="blank"></whiteboard>`。
|
||||
- 从 v2 响应的 `data.document.new_blocks[]` 中读取 `block_type == "whiteboard"` 的 `block_token` 作为 board_token。
|
||||
使用 SVG 插入画板时,最终交付是**画板跨越重排渲染的节点**(你写 SVG → 画板解析)
|
||||
**核心心智纠正 (重要)**:
|
||||
|
||||
### 步骤 3B:复杂图或已有画板 — 启动 lark-whiteboard SubAgent
|
||||
- 大多数 AI 如果只考虑“绝对不报错/完美映射”, 最终给出的都是全篇纯白底色加单层 `<rect>` 的方正卡片网格, 极其死板单调, *
|
||||
*这将被视为不及格!**
|
||||
- **SVG 给你了完全的设计自由**, 请大胆使用你脑内的图标路径 (`<path>`), 连接指引 (`流畅的 <path>`), 各种环境氛围点缀,
|
||||
大胆一点, 充分信任你的品味, 发挥出你的顶级艺术创造力!
|
||||
|
||||
##### SVG 设计 Workflow
|
||||
|
||||
###### 1. 想清楚要画什么
|
||||
|
||||
- **核心信息是什么?** 能做到一图胜千言, 绝对不要只生成平平无奇的文字表格, 要有设计感
|
||||
- **内容充实度**:如果用户描述稀疏简略, 利用你的领域知识扩展, 保证信息维度和内容充实, 但不要过度堆砌, 淹没重点
|
||||
- **视觉层级与隐喻**:这个没有固定的形式, 你自由判断, 比如: 给重要的节点加光环, 加高亮背景;给对比项设计天平或对称结构
|
||||
|
||||
###### 2. 写 SVG
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 布局, 配色, 信息密度, 装饰物——**全部由你判断**, 打破单调的 `<rect>` 牢笼, 严禁通篇用矩形和文字应付用户
|
||||
> 操作边界约束:
|
||||
|
||||
- **语言跟随用户**:图表文字的语言与用户 prompt 保持一致, 技术术语用行业里通用的写法, 不机械翻译
|
||||
- 文字用 `<text>`(不是 `<path>`), 容器宽度留够——画板按 CJK ≈ 1em / Latin ≈ 0.6em 重排
|
||||
- 连线使用正交折线替代斜直线(`<polyline>` 带水平/垂直折点)视觉效果更好
|
||||
- 可自由使用 `translate`, `rotate`, `scale`但请尽量避免使用 `skewX` / `skewY` / `matrix(...)` 发生空间级扭曲
|
||||
|
||||
###### 画板怎么处理 SVG
|
||||
|
||||
画板的 svg-parser 把可识别元素转成可编辑节点, 其余降级为内嵌图片(渲染没问题, 虽然不可编辑, 但是可以正常显示);但
|
||||
`<radialGradient>` / `<filter>` / `<clipPath>` 等装饰特性画板完全不支持,会导致渲染问题(见下方⚠️)
|
||||
**不需要所有元素都可编辑, 但必须避免使用不支持的装饰特性, 且要兼顾可编辑和美观漂亮**
|
||||
|
||||
**可识别的元素**
|
||||
|
||||
- 形状:`<rect>` / `<circle>` / `<ellipse>` / `<polygon>`
|
||||
- 连线:`<line>` / `<polyline>` / `<path>`(自动识别为直线 / 折线 / 曲线)
|
||||
- 文本:`<text>` / `<tspan>` 画板硬编码 Noto Sans SC **文字必须用 `<text>`**
|
||||
- 分组:`<g>` / `<a>` / `<use>` 引用 `<symbol>`
|
||||
- 变换:`translate` / `rotate` / `scale` 正常;`skewX` / `skewY` / `matrix(...)` 降级
|
||||
|
||||
> [!IMPORTANT]
|
||||
> ⚠️ ** 不支持的装饰特性**
|
||||
|
||||
- `<radialGradient>` / `<filter>` / `<pattern>` / `<clipPath>` / `<mask>` → 画板都不支持,**请避免使用,否则会导致画板渲染问题
|
||||
**
|
||||
|
||||
###### 3.插入后审查
|
||||
|
||||
插入画板后,可以从返回值使用 lark-cli 指令,将画板内容导出为 png
|
||||
图片。若是对设计不满意,可以修改后,删除原来的画板再重新插入,或是调用 [
|
||||
`../../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md) 编辑。
|
||||
|
||||
```bash
|
||||
lark-cli whiteboard +query \
|
||||
--whiteboard-token "wbcnxxxxxxxx" \
|
||||
--output_as image \
|
||||
--output ./preview.png
|
||||
```
|
||||
|
||||
### 步骤 3B:编辑已有画板 — 启动 lark-whiteboard SubAgent
|
||||
|
||||
复杂图和已有画板更新必须启动 SubAgent。主 Agent 只传最小上下文,不直接执行 `lark-whiteboard` 的渲染和写入流程。
|
||||
|
||||
复杂图 SubAgent 的最小上下文:
|
||||
|
||||
- board_token
|
||||
- 图表目标、推荐画板类型、受众
|
||||
- 与图表直接相关的源段落或数据
|
||||
@@ -63,35 +140,12 @@
|
||||
|
||||
### 步骤 4:完成校验
|
||||
|
||||
- 简单 SVG:确认插入的是 `<whiteboard type="svg">`,且内容是完整 `<svg ...>...</svg>`
|
||||
- 复杂画板:确认每个 token 对应的画板都已填充真实内容
|
||||
- Mermaid: 确认插入的是 `<whiteboard type="mermaid">`,且内容 mermaid 语法完整
|
||||
- SVG: 确认插入的是 `<whiteboard type="svg">`,且内容是完整 `<svg ...>...</svg>`
|
||||
- 不保留空白占位画板;复杂路径只有空白画板而无内容视为任务未完成
|
||||
|
||||
---
|
||||
|
||||
## 语义与画板类型映射
|
||||
|
||||
下表用于帮助主 Agent 判断简单/复杂路径,并给 SubAgent 指定推荐画板类型。
|
||||
|
||||
| 语义 | 画板类型 |
|
||||
|------|------|
|
||||
| 小型流程/状态机/简单时间线/小型对比/小型示意图 | SVG 画板(简单路径) |
|
||||
| 架构/分层/技术方案/模块依赖/调用关系 | 架构图(复杂路径) |
|
||||
| 流程/审批/部署/业务流转/状态机 | 流程图(按复杂度分流) |
|
||||
| 跨角色流程/跨系统交互/端到端链路 | 泳道图(复杂路径) |
|
||||
| 组织/层级/汇报关系 | 组织架构图 |
|
||||
| 时间线/里程碑/版本规划 | 里程碑图 |
|
||||
| 因果/复盘/根因分析 | 鱼骨图 |
|
||||
| 方案对比/技术选型/功能矩阵 | 对比图 |
|
||||
| 循环/飞轮/闭环/增长链路 | 飞轮图 |
|
||||
| 层级占比/能力模型/需求层次 | 金字塔图 |
|
||||
| 矩形树图/层级面积占比 | 树状图 |
|
||||
| 转化漏斗/销售漏斗 | 漏斗图 |
|
||||
| 分类梳理/知识体系/思维导图/时序图/类图 | Mermaid |
|
||||
| 数据分布/占比/饼图 | Mermaid |
|
||||
| 简单自定义图形/小型 SVG 示意图 | SVG 画板(简单路径) |
|
||||
| 柱状图/条形图/数据对比 | 柱状图 |
|
||||
| 折线图/趋势图/时序数据 | 折线图 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -35,12 +35,12 @@
|
||||
|
||||
5. `docs +fetch --api-version v2 --detail with-ids` 获取文档,审查整体效果
|
||||
6. 评估样式达标(富 block 密度、元素多样性、连续 `<p>` 数量)
|
||||
7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。重要信息优先画板化,记录需要插图的章节、推荐画板类型、简单/复杂路径和用于画图的源内容
|
||||
7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。重要信息优先画板化,记录需要插图的章节、推荐画板类型、mermaid/SVG 路径和用于画图的源内容
|
||||
|
||||
### 第四波 — 画板与润色(并行 Agent)
|
||||
|
||||
8. **优先处理第三波识别出的画板需求**:
|
||||
- 简单图:启动 SVG SubAgent,直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`;不读取 **lark-whiteboard**
|
||||
- 复杂图:主 Agent 先插入 `<whiteboard type="blank"></whiteboard>` 并提取 `block_token`,再为每个 `block_token` 启动 SubAgent 使用 **lark-whiteboard** skill 写入画板
|
||||
参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。
|
||||
9. Spawn 内容改写 Agent 定向润色:
|
||||
- 文字密集章节转为 `<table>`/`<grid>`/`<callout>`
|
||||
- 主要章节间补充 `<hr/>`
|
||||
@@ -51,6 +51,8 @@
|
||||
|
||||
内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、具体的 `docs +update` command 和 `--block-id`。
|
||||
|
||||
SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、图表目标、源内容片段、`lark-doc-xml.md` 路径。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`。
|
||||
Mermaid 图由主 Agent 直接插入 `<whiteboard type="mermaid">...</whiteboard>`,无需 SubAgent。
|
||||
|
||||
复杂画板 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
|
||||
SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、图表目标、源内容片段、`lark-doc-xml.md` 路径,以及[lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的 "SVG 设计 Workflow" 指南。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`。
|
||||
|
||||
已有画板更新 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
|
||||
|
||||
@@ -12,21 +12,20 @@
|
||||
|
||||
## 二、元素选择指南
|
||||
|
||||
涉及图表需求时,先判定简单/复杂:简单图启动 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读取 **lark-whiteboard**;复杂图才使用空白画板 + **lark-whiteboard** SubAgent。
|
||||
涉及图表需求时,按类型选择插入方式:思维导图/时序图/类图/饼图/甘特图用 `<whiteboard type="mermaid">` 直接内嵌;其他新图表启动 SubAgent 插入 `<whiteboard type="svg">完整 SVG</whiteboard>`;只有编辑**已有**画板时才调用 **lark-whiteboard** skill。
|
||||
|
||||
| 场景 | 推荐方案 |
|
||||
|--------------------------------------------|---------------------------------------|
|
||||
| 核心结论 / 摘要 / 注意事项 | `<callout>` + emoji + 背景色 |
|
||||
| 重要方案对比 / 优劣势 / Before vs After | `<grid>` 2 列分栏;SVG SubAgent |
|
||||
| 简短低风险对比 | `<grid>` 2 列分栏 |
|
||||
| 3+ 属性的结构化数据 / 指标表 | `<table>` + 表头背景色 |
|
||||
| 任务清单 / 检查项 | `<checkbox>` |
|
||||
| 代码片段 | `<pre lang="x" caption="说明">` |
|
||||
| 引用 / 公式 | `<blockquote>` / `<latex>` |
|
||||
| 操作入口 / 跳转链接 | `<button>` / `<a type="url-preview">` |
|
||||
| 流程图 / 时间线 / 示意图 / 自定义图形 / 架构图 / 数据图 /思维导图等 | 画板图表 |
|
||||
|
||||
| 场景 | 推荐方案 |
|
||||
|-|---------------------------------------------------------------|
|
||||
| 核心结论 / 摘要 / 注意事项 | `<callout>` + emoji + 背景色 |
|
||||
| 重要方案对比 / 优劣势 / Before vs After | `<grid>` 2 列分栏;简单 SVG SubAgent;复杂矩阵用 lark-whiteboard SubAgent |
|
||||
| 简短低风险对比 | `<grid>` 2 列分栏 |
|
||||
| 3+ 属性的结构化数据 / 指标表 | `<table>` + 表头背景色 |
|
||||
| 任务清单 / 检查项 | `<checkbox>` |
|
||||
| 代码片段 | `<pre lang="x" caption="说明">` |
|
||||
| 引用 / 公式 | `<blockquote>` / `<latex>` |
|
||||
| 操作入口 / 跳转链接 | `<button>` / `<a type="url-preview">` |
|
||||
| 简单流程图 / 小型状态机 / 小型时间线 | 简单 SVG SubAgent |
|
||||
| 简单自定义图形 / 小型 SVG 示意图 | 简单 SVG SubAgent |
|
||||
| 复杂架构图 / 数据图 / 思维导图 / 组织架构 | 空白画板 + lark-whiteboard SubAgent |
|
||||
|
||||
### 画板意图识别
|
||||
|
||||
@@ -49,28 +48,8 @@
|
||||
|
||||
**判断规则:**
|
||||
- 重要信息能图示就图示;不要为了省步骤把关键流程、架构、对比、风险链路写成纯文本
|
||||
- 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读取 **lark-whiteboard**
|
||||
- 复杂图或已有画板更新才先插入 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 使用 **lark-whiteboard** skill 写入内容
|
||||
- 低重要度、局部辅助信息才用 `<table>` / `<grid>` / `<callout>` 承载
|
||||
|
||||
### 画板语法与插入
|
||||
|
||||
> **提醒:** `docs +update` 不能编辑已有画板内容;下面的语法都是**新增**画板块。修改已有画板需启动 SubAgent 读取 [`lark-whiteboard`](../../../lark-whiteboard/SKILL.md)。
|
||||
|
||||
#### 简单 SVG 画板(SubAgent 插入)
|
||||
|
||||
1. 主 Agent 启动 SubAgent,传入 doc token、插入位置、图表目标和源内容
|
||||
2. SubAgent 使用 `<whiteboard type="svg">完整自包含 SVG</whiteboard>` 通过 `docs +create --api-version v2` / `docs +update --api-version v2` 插入
|
||||
3. SVG 必须包含 `<svg>` 根节点和 `viewBox`,不要引用外部图片、脚本或远程资源
|
||||
|
||||
#### 复杂画板(空白画板 + lark-whiteboard SubAgent)
|
||||
|
||||
1. 用 `<whiteboard type="blank"></whiteboard>` 通过 `docs +create --api-version v2` / `docs +update --api-version v2` 插入空白画板
|
||||
2. 从 v2 响应 `data.document.new_blocks` 中提取画板 `block_token`
|
||||
3. 必须启动 SubAgent,把 `block_token`、图表目标、推荐画板类型和源内容交给它
|
||||
4. SubAgent 读取 [`lark-whiteboard`](../../../lark-whiteboard/SKILL.md) skill 并写入该画板;主 Agent 不直接调用画板渲染流程
|
||||
|
||||
更完整的协同流程见 [`lark-doc-whiteboard.md`](../lark-doc-whiteboard.md)。
|
||||
- 确定需要插入哪些图表后,参照 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的方式,插入图表画板。
|
||||
|
||||
## 三、颜色语义
|
||||
|
||||
|
||||
@@ -25,14 +25,13 @@
|
||||
- 用户明确要改整篇 → `docs +fetch --api-version v2 --detail with-ids`
|
||||
- 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) "意图引导:选择正确的 --scope"
|
||||
2. 系统性评估:结构清晰度、富 block 密度(≥40%)、元素多样性(≥3种)、连续 `<p>` 是否超过 3 段、是否有开头 callout 和章节 `<hr/>`
|
||||
3. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断哪些段落的信息适合用图表达。重要信息优先画板化,记录需要插图的章节(block ID)、推荐画板类型、简单/复杂路径和源内容片段
|
||||
3. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断哪些段落的信息适合用图表达。重要信息优先画板化,记录需要插图的章节(block ID)、推荐画板类型、mermaid/SVG路径和源内容片段
|
||||
4. 向用户简要说明改进计划(包含识别出的画板机会)
|
||||
|
||||
### 第二波 — 定向改写(并行 Agent)
|
||||
|
||||
5. **优先处理第一波识别出的画板候选段落**:
|
||||
- 简单图:启动 SVG SubAgent,直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`;不读取 **lark-whiteboard**
|
||||
- 复杂图:主 Agent 先插入 `<whiteboard type="blank"></whiteboard>` 并提取 `block_token`,再为每个 `block_token` 启动 SubAgent 使用 **lark-whiteboard** skill 写入画板
|
||||
参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。
|
||||
6. Spawn 内容改写 Agent 在不重叠的章节上并行改进,各 Agent 收到文档 token 和特定 block ID:(见 `lark-doc-style.md`)
|
||||
- 开头适当添加 `<callout>`、重组引言
|
||||
- 纯文本转为 `<grid>`/`<table>`/`<callout>`
|
||||
@@ -47,8 +46,10 @@
|
||||
|
||||
内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、具体的 `docs +update` command 和 `--block-id`。
|
||||
|
||||
SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、图表目标、源内容片段、`lark-doc-xml.md` 路径。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`。
|
||||
Mermaid 图由主 Agent 直接插入 `<whiteboard type="mermaid">...</whiteboard>`,无需 SubAgent。
|
||||
|
||||
复杂画板 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
|
||||
SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、图表目标、源内容片段、`lark-doc-xml.md` 路径,以及[lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的 "SVG 设计 Workflow" 指南。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`。
|
||||
|
||||
已有画板更新 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
|
||||
|
||||
**上下文节省提示**:Agent 如需在自己负责的章节内重新读取内容,优先用 `docs +fetch --api-version v2 --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉自己的章节,不要重复拉全文。
|
||||
|
||||
@@ -283,6 +283,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations |
|
||||
| [`+inspect`](references/lark-drive-inspect.md) | Inspect a Lark document URL to get its type, title, and canonical token; auto-unwraps wiki URLs to the underlying document |
|
||||
| [`+apply-permission`](references/lark-drive-apply-permission.md) | Apply to the document owner for view/edit access (user-only; 5/day per document) |
|
||||
| [`+secure-label-list`](references/lark-drive-secure-label.md) | List secure labels available to the current user |
|
||||
| [`+secure-label-update`](references/lark-drive-secure-label.md) | Update a Drive file/document secure label; downgrade approval errors require opening the document UI |
|
||||
|
||||
## API Resources
|
||||
|
||||
|
||||
52
skills/lark-drive/references/lark-drive-secure-label.md
Normal file
52
skills/lark-drive/references/lark-drive-secure-label.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# drive +secure-label-list / +secure-label-update(云文档密级标签)
|
||||
|
||||
## 何时使用
|
||||
|
||||
- `drive +secure-label-list`:查询当前用户可用的密级标签,先拿到目标 `id`。
|
||||
- `drive +secure-label-update`:把目标云文档调整为指定密级标签。
|
||||
|
||||
这两个 shortcut 都使用用户身份(`--as user`)。修改密级前,通常先执行 `+secure-label-list` 确认可用标签 ID。
|
||||
|
||||
## 查询可用密级标签
|
||||
|
||||
```bash
|
||||
lark-cli drive +secure-label-list --page-size 10 --lang zh
|
||||
```
|
||||
|
||||
可选参数:
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `--page-size` | 分页大小,范围 `1..10`,默认 `10` |
|
||||
| `--page-token` | 上一页响应里的 `page_token` |
|
||||
| `--lang` | 标签语言:`zh`、`en`、`ja` |
|
||||
|
||||
底层接口:`GET /open-apis/drive/v2/my_secure_labels`。
|
||||
|
||||
## 修改文档密级
|
||||
|
||||
```bash
|
||||
lark-cli drive +secure-label-update \
|
||||
--token "https://example.feishu.cn/docx/doxcnxxxx" \
|
||||
--label-id "7217780879644737539"
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `--token` | 目标文档 URL 或 bare token;URL 可自动推断 `--type` |
|
||||
| `--type` | bare token 必填;URL 输入时可省略。可选:`doc`、`docx`、`sheet`、`file`、`bitable`、`mindnote`、`slides` |
|
||||
| `--label-id` | 要设置的密级标签 ID |
|
||||
|
||||
底层接口:`PATCH /open-apis/drive/v2/files/:file_token/secure_label`,query 参数 `type`,请求体 `{ "id": "<label-id>" }`。
|
||||
|
||||
## 错误处理
|
||||
|
||||
CLI 不会在 shortcut 中为密级错误码追加专用 hint;agent 必须根据返回的 `error.code` 做以下引导。
|
||||
|
||||
| 错误码 | 含义 | 引导 |
|
||||
|--------|------|------|
|
||||
| `1063013` | 密级降级需要审批 | 提示用户打开目标文档,在文档界面完成密级降级审批后重试;如果用户传入的是文档 URL,必须把该 URL 一并给用户作为操作入口 |
|
||||
|
||||
遇到 `1063013` 时,不要继续重试 API,也不要提示补 scope;这是文档侧审批流程要求,需要用户到文档里操作。
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-event
|
||||
version: 1.0.0
|
||||
description: "Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume <EventKey>` (covers IM message receive, reactions, chat member changes, etc.). Use for Lark bots, real-time message processing, long-running subscribers, streaming webhook/push handlers. Supports `--max-events` / `--timeout` bounded runs and a stderr ready-marker contract — designed for AI agents running as subprocesses."
|
||||
description: "Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume <EventKey>` (covers IM messages/reactions/chat changes, VC meeting ended, Minutes generated, etc.). Use for Lark bots, real-time message processing, long-running subscribers, streaming webhook/push handlers. Supports `--max-events` / `--timeout` bounded runs and a stderr ready-marker contract — designed for AI agents running as subprocesses."
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -143,3 +143,5 @@ Lark-defined semantic tags (**not** JSON Schema's standard `format`). Common val
|
||||
| Topic | Reference | Coverage |
|
||||
|---|---|---|
|
||||
| IM | [`references/lark-event-im.md`](references/lark-event-im.md) | Catalog of 11 IM EventKeys + shape notes (flat vs V2 envelope) + `im.message.receive_v1` field gotchas (`sender_id` is open_id only; `.content` is plain text except for `interactive` cards) + common jq recipes (filter by chat_type / message_type / sender) |
|
||||
| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | Catalog of 1 VC EventKey (`vc.meeting.participant_meeting_ended_v1`) + field reference + time conversion gotchas (unix seconds → local RFC3339) |
|
||||
| Minutes | [`references/lark-event-minutes.md`](references/lark-event-minutes.md) | Catalog of 1 Minutes EventKey (`minutes.minute.generated_v1`) + field reference + enrichment & degradation semantics (minute detail API fills `title`; `minute_source` from event payload survives enrichment failure) |
|
||||
|
||||
54
skills/lark-event/references/lark-event-minutes.md
Normal file
54
skills/lark-event/references/lark-event-minutes.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Minutes Events
|
||||
|
||||
> **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage).
|
||||
|
||||
## Key catalog (1)
|
||||
|
||||
| EventKey | Purpose |
|
||||
|---|---|
|
||||
| `minutes.minute.generated_v1` | A minute (妙记) has been generated |
|
||||
|
||||
This key uses a **Custom schema** (flat output at `.xxx`) and carries a **PreConsume hook** that auto-subscribes / unsubscribes via OAPI on first / last consumer.
|
||||
|
||||
## Scopes & auth
|
||||
|
||||
| EventKey | Scope | Auth |
|
||||
|---|---|---|
|
||||
| `minutes.minute.generated_v1` | `minutes:minutes.basic:read` | user |
|
||||
|
||||
Requires `--as user`.
|
||||
|
||||
## `minutes.minute.generated_v1`
|
||||
|
||||
### Output fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `type` | string | Event type; always `minutes.minute.generated_v1` |
|
||||
| `event_id` | string | Globally unique event ID; safe for deduplication |
|
||||
| `timestamp` | string (timestamp_ms) | Event delivery time (ms timestamp string) |
|
||||
| `minute_token` | string | Minute token |
|
||||
| `title` | string | Minute title (enriched via detail API) |
|
||||
| `minute_source` | object | Minute source metadata; only present when the source is a meeting |
|
||||
| `minute_source.source_type` | string | Source type; only present when the source is a meeting (value: `meeting`) |
|
||||
| `minute_source.source_entity_id` | string | Source entity ID (meeting ID); only present when the source is a meeting |
|
||||
|
||||
### Enrichment & degradation
|
||||
|
||||
The Process hook calls `GET /open-apis/minutes/v1/minutes/{minute_token}` to enrich `title`. If the detail API fails, this field is left empty — the base fields (`type`, `event_id`, `timestamp`, `minute_token`, `minute_source`) are always present.
|
||||
|
||||
`minute_source` is populated from the event payload directly (not the detail API), so it survives enrichment failures. Note: `minute_source` is only present when the minute originates from a meeting; for other sources (e.g. recording, local upload) this field is absent.
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
lark-cli event consume minutes.minute.generated_v1 --as user
|
||||
|
||||
# Project title and token only (skip events where enrichment failed)
|
||||
lark-cli event consume minutes.minute.generated_v1 --as user \
|
||||
--jq 'select(.title != "") | {minute_token, title}'
|
||||
|
||||
# Filter by source type
|
||||
lark-cli event consume minutes.minute.generated_v1 --as user \
|
||||
--jq 'select(.minute_source.source_type == "meeting") | {minute_token, title}'
|
||||
```
|
||||
50
skills/lark-event/references/lark-event-vc.md
Normal file
50
skills/lark-event/references/lark-event-vc.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# VC Events
|
||||
|
||||
> **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage).
|
||||
|
||||
## Key catalog (1)
|
||||
|
||||
| EventKey | Purpose |
|
||||
|---|---|
|
||||
| `vc.meeting.participant_meeting_ended_v1` | A meeting the current user participates in has ended |
|
||||
|
||||
This key uses a **Custom schema** (flat output at `.xxx`) and carries a **PreConsume hook** that auto-subscribes / unsubscribes via OAPI on first / last consumer.
|
||||
|
||||
## Scopes & auth
|
||||
|
||||
| EventKey | Scope | Auth |
|
||||
|---|---|---|
|
||||
| `vc.meeting.participant_meeting_ended_v1` | `vc:meeting.meetingevent:read` | user |
|
||||
|
||||
Requires `--as user`.
|
||||
|
||||
## `vc.meeting.participant_meeting_ended_v1`
|
||||
|
||||
### Output fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `type` | string | Event type; always `vc.meeting.participant_meeting_ended_v1` |
|
||||
| `event_id` | string | Globally unique event ID; safe for deduplication |
|
||||
| `timestamp` | string (timestamp_ms) | Event delivery time (ms timestamp string) |
|
||||
| `meeting_id` | string | Meeting ID |
|
||||
| `topic` | string | Meeting topic |
|
||||
| `meeting_no` | string | Meeting number |
|
||||
| `start_time` | string | Meeting start time in RFC3339, converted to the local timezone |
|
||||
| `end_time` | string | Meeting end time in RFC3339, converted to the local timezone |
|
||||
| `calendar_event_id` | string | Calendar event ID associated with the meeting |
|
||||
|
||||
### Gotchas
|
||||
|
||||
- `start_time` / `end_time` are **not** the raw unix-seconds from OAPI — the Process hook converts them to local-timezone RFC3339. If the raw value is empty or non-numeric, the field is left empty.
|
||||
- No detail API call is made; all fields come from the event payload itself.
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
lark-cli event consume vc.meeting.participant_meeting_ended_v1 --as user
|
||||
|
||||
# Project meeting topic and end time only
|
||||
lark-cli event consume vc.meeting.participant_meeting_ended_v1 --as user \
|
||||
--jq '{meeting: .meeting_id, topic: .topic, ended: .end_time}'
|
||||
```
|
||||
@@ -47,6 +47,10 @@ When using bot identity (`--as bot`) to fetch messages (e.g. `+chat-messages-lis
|
||||
|
||||
**Solution**: Check the app's visibility settings in the Lark Developer Console — ensure the app's visible range covers the users whose names need to be resolved. Alternatively, use `--as user` to fetch messages with user identity, which typically has broader contact access.
|
||||
|
||||
### Default message enrichment (reactions / update_time)
|
||||
|
||||
The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+messages-search`, `+threads-messages-list`) automatically attach a `reactions` block and (for edited messages) `update_time` to each returned message — no separate `im.reactions.batch_query` call is needed. Pass `--no-reactions` to opt out. For the full contract (output shape, the `im:message.reactions:read` scope requirement, and the "missing field ≠ fetch failure" data rules), read [`references/lark-im-message-enrichment.md`](references/lark-im-message-enrichment.md).
|
||||
|
||||
### Card Messages (Interactive)
|
||||
|
||||
Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr.
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
Fetch the message list for a conversation. Supports both group chats and direct messages.
|
||||
|
||||
By default the response carries a `reactions` block (counts + details from `im.reactions.batch_query`) on every message that has reactions, and `update_time` on messages that were actually edited. Thread replies expanded via auto-`thread_replies` participate in the same batched enrichment. Pass `--no-reactions` to skip the extra round-trip. See [message enrichment](lark-im-message-enrichment.md) for the full contract.
|
||||
|
||||
This skill maps to the shortcut: `lark-cli im +chat-messages-list` (internally calls `GET /open-apis/im/v1/messages`, and automatically resolves the p2p chat_id when needed).
|
||||
|
||||
## Commands
|
||||
|
||||
28
skills/lark-im/references/lark-im-message-enrichment.md
Normal file
28
skills/lark-im/references/lark-im-message-enrichment.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# im default message enrichment (reactions / update_time)
|
||||
|
||||
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
|
||||
|
||||
This is the single source of truth for the automatic message-enrichment contract shared by the four message-pulling shortcuts — [`+messages-mget`](lark-im-messages-mget.md), [`+chat-messages-list`](lark-im-chat-messages-list.md), [`+messages-search`](lark-im-messages-search.md), [`+threads-messages-list`](lark-im-threads-messages-list.md). They automatically attach `reactions` and `update_time` to each returned message, so callers do **not** need to invoke the raw [`im.reactions.batch_query`](lark-im-reactions.md) API separately.
|
||||
|
||||
- **`reactions`** — populated from one batched `im.reactions.batch_query` call as `{counts, details}`. The field is only attached when the server actually returns data; messages with no reactions omit it. Replies inside `thread_replies` are enriched in the **same batched call** as their parent, so outer and inner messages follow identical semantics.
|
||||
- **`update_time`** — emitted only when `updated == true` (message was actually edited). The server echoes `update_time == create_time` for unedited messages too, but the CLI gates that output away so consumers don't misread every message as "edited".
|
||||
- **Opt-out** — each shortcut accepts `--no-reactions` to skip the extra round-trip when the caller only needs message bodies.
|
||||
|
||||
## Scope requirement
|
||||
|
||||
The default enrichment requires `im:message.reactions:read`, already declared in each shortcut's `UserScopes` / `BotScopes` (or `Scopes` for the user-only search command), so the framework's pre-flight check surfaces a `missing_scope` error before the request is sent. Bots that were registered before this scope was added need an incremental authorization in the Feishu developer console; users can run:
|
||||
|
||||
```bash
|
||||
lark-cli auth login --scope "im:message.reactions:read"
|
||||
```
|
||||
|
||||
## Data contract — missing field ≠ fetch failure
|
||||
|
||||
| Situation | Output |
|
||||
|---|---|
|
||||
| Message has no reactions | `reactions` field is omitted (not `{}`, not an empty list) |
|
||||
| Message was never edited | `update_time` field is omitted |
|
||||
| Whole batch failed | Messages in that batch carry no `reactions`; one line on stderr: `warning: reactions_batch_query_failed: ...` |
|
||||
| Some message IDs failed | Failed IDs go to stderr: `warning: reactions_partial_failed: N message(s) failed (...)` |
|
||||
|
||||
When deciding "has the user already reacted?", branch on the **presence of the `reactions` field plus its `counts` contents**, not on whether a value is `null` — the field's absence means "no data attached" (which usually means "no reactions exist"), not "fetch failed".
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
Fetch message details in batch. Given a list of message IDs, this returns the full content for multiple messages in one call and automatically resolves sender names.
|
||||
|
||||
By default the response also carries a `reactions` block (counts + details from `im.reactions.batch_query`) on every message that has reactions, and `update_time` on messages that were actually edited. Replies inside `thread_replies` participate in the same batched enrichment. Pass `--no-reactions` to skip the extra round-trip. See [message enrichment](lark-im-message-enrichment.md) for the full contract.
|
||||
|
||||
> **Supports both `--as user` (default) and `--as bot`.**
|
||||
|
||||
This skill maps to the shortcut: `lark-cli im +messages-mget` (internally calls `GET /open-apis/im/v1/messages/mget`).
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
Search Feishu messages across conversations. This shortcut automatically performs a multi-step workflow: search for message IDs, batch fetch message details, then enrich the results with chat context.
|
||||
|
||||
By default each result message also carries a `reactions` block (counts + details from `im.reactions.batch_query`) when the server has reactions for it, and `update_time` for messages that were actually edited. With `--page-all`, every page is enriched; pass `--no-reactions` to skip the extra round-trip. See [message enrichment](lark-im-message-enrichment.md) for the full contract.
|
||||
|
||||
> **User identity only** (`--as user`). Bot identity is not supported.
|
||||
|
||||
This skill maps to the shortcut: `lark-cli im +messages-search` (internally calls `POST /open-apis/im/v1/messages/search` + batched `GET /open-apis/im/v1/messages/mget`, then batch-fetches chat context).
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
|
||||
|
||||
> **Heads-up — don't reach for `batch_query` by default.** The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+messages-search`, `+threads-messages-list`) already call `im.reactions.batch_query` automatically and attach the result as a `reactions` block on each message (replies inside `thread_replies` included). Use those shortcuts for any "read reactions of messages I'm already pulling" task. Reach for the raw `batch_query` API only when you have a standalone `message_id` outside that pull flow. See the main [message enrichment](lark-im-message-enrichment.md) for the contract.
|
||||
|
||||
This reference is the shared annotation target for the IM reaction APIs:
|
||||
|
||||
- `im.reactions.create`
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
Fetch the reply message list inside a thread. When `im +chat-messages-list` returns messages that include a `thread_id` field, use this command to inspect all replies in that thread.
|
||||
|
||||
By default each reply also carries a `reactions` block (counts + details from `im.reactions.batch_query`) when the server has reactions for it, and `update_time` for messages that were actually edited. Pass `--no-reactions` to skip the extra round-trip. See [message enrichment](lark-im-message-enrichment.md) for the full contract.
|
||||
|
||||
This skill maps to the shortcut: `lark-cli im +threads-messages-list` (internally calls `GET /open-apis/im/v1/messages` with `container_id_type=thread` to fetch thread messages).
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -44,7 +44,7 @@ lark-cli task +create --summary "Test Task" --dry-run
|
||||
## Workflow
|
||||
|
||||
1. Confirm with the user: task summary, due date, assignee, and tasklist if necessary.
|
||||
- **Crucial Rule for Assignee**: If the user explicitly or implicitly says "create a task for me" (给我创建一个任务), or "help me create a task" (帮我新建/创建一个任务), you MUST assign the task to the current logged-in user. You can get the current user's `open_id` by executing `lark-cli auth status` (it already outputs JSON by default, so do not add `--json`) or `lark-cli contact +get-user` first, extracting the `userOpenId` or `open_id`, and then passing it to the `--assignee` parameter.
|
||||
- **Crucial Rule for Assignee**: If the user explicitly or implicitly says "create a task for me" (给我创建一个任务), or "help me create a task" (帮我新建/创建一个任务), you MUST assign the task to the current logged-in user. You can get the current user's `open_id` by executing `lark-cli auth status` (it already outputs JSON by default, so do not add `--json`) or `lark-cli contact +get-user` first, extracting `.identities.user.openId` (from `auth status`) or `.data.user.open_id` (from `contact +get-user`), and then passing it to the `--assignee` parameter.
|
||||
2. Execute `lark-cli task +create --summary "..." ...`
|
||||
3. Report the result: task ID and summary.
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Drive CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 29 leaf commands
|
||||
- Covered: 9
|
||||
- Coverage: 31.0%
|
||||
- Denominator: 31 leaf commands
|
||||
- Covered: 10
|
||||
- Coverage: 32.3%
|
||||
|
||||
## Summary
|
||||
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
|
||||
@@ -13,6 +13,7 @@
|
||||
- TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API.
|
||||
- TestDriveAddCommentDryRun_File: dry-run coverage for `drive +add-comment` on supported Drive file targets; pins the `metas.batch_query -> files/:token/new_comments` request chain, `file_type=file`, and the required placeholder `anchor.block_id`.
|
||||
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`.
|
||||
- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows.
|
||||
- TestDriveExportDryRun_FileNameMetadata: dry-run coverage for `drive +export`; asserts export task request shape and local `--file-name` / `--output-dir` metadata without calling live APIs.
|
||||
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
|
||||
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
|
||||
@@ -34,6 +35,8 @@
|
||||
| ✕ | drive +move | shortcut | | none | no move workflow yet |
|
||||
| ✓ | drive +pull | shortcut | drive_pull_dryrun_test.go::TestDrive_PullDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--on-duplicate-remote=rename\|newest\|oldest`; `--delete-local --yes` guard | dry-run locks flag/validate shape; live workflow proves duplicate fail-fast and rename recovery |
|
||||
| ✓ | drive +push | shortcut | drive_push_dryrun_test.go::TestDrive_PushDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--if-exists`; `--on-duplicate-remote=newest\|oldest`; `--delete-remote --yes` | dry-run locks flag/validate shape; live workflow proves overwrite + duplicate cleanup converges status |
|
||||
| ✓ | drive +secure-label-list | shortcut | drive_secure_label_dryrun_test.go::TestDrive_SecureLabelDryRun | `--page-size`; `--page-token`; `--lang` | dry-run only; live label availability depends on tenant security-label configuration |
|
||||
| ✓ | drive +secure-label-update | shortcut | drive_secure_label_dryrun_test.go::TestDrive_SecureLabelDryRun | `--token` URL inference; `--type`; `--label-id` body | dry-run only; live update can require document-level approval or mutate a fixture document's security level |
|
||||
| ✓ | drive +status | shortcut | drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_status_dryrun_test.go::TestDrive_StatusDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; bucketed `new_local` / `new_remote` / `modified` / `unchanged` outputs | dry-run pins request shape; live workflows cover both normal hashing buckets and duplicate-remote failure |
|
||||
| ✓ | drive +sync | shortcut | drive_sync_dryrun_test.go::TestDrive_SyncDryRun + drive_sync_workflow_test.go::TestDrive_SyncWorkflow + drive_sync_workflow_test.go::TestDrive_SyncEmptyDirWorkflow | `--local-dir`; `--folder-token`; `--on-conflict=remote-wins\|local-wins\|keep-both\|ask`; `--on-duplicate-remote=fail\|newest\|oldest`; `--quick` | dry-run validates request shape, flag acceptance, and path safety guards; live workflow proves new_remote→pull, new_local→push, remote-wins/local-wins/keep-both conflict resolution, empty directory creation, and post-sync convergence |
|
||||
| ✕ | drive +task_result | shortcut | | none | no async task-result workflow yet |
|
||||
|
||||
98
tests/cli_e2e/drive/drive_secure_label_dryrun_test.go
Normal file
98
tests/cli_e2e/drive/drive_secure_label_dryrun_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestDrive_SecureLabelDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantMethod string
|
||||
wantURL string
|
||||
assert func(t *testing.T, out string)
|
||||
}{
|
||||
{
|
||||
name: "list available labels",
|
||||
args: []string{
|
||||
"drive", "+secure-label-list",
|
||||
"--page-size", "5",
|
||||
"--page-token", "page_1",
|
||||
"--lang", "zh",
|
||||
"--dry-run",
|
||||
},
|
||||
wantMethod: "GET",
|
||||
wantURL: "/open-apis/drive/v2/my_secure_labels",
|
||||
assert: func(t *testing.T, out string) {
|
||||
if got := gjson.Get(out, "api.0.params.page_size").Int(); got != 5 {
|
||||
t.Fatalf("page_size = %d, want 5\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.page_token").String(); got != "page_1" {
|
||||
t.Fatalf("page_token = %q, want page_1\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.lang").String(); got != "zh" {
|
||||
t.Fatalf("lang = %q, want zh\nstdout:\n%s", got, out)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update label with URL inference",
|
||||
args: []string{
|
||||
"drive", "+secure-label-update",
|
||||
"--token", "https://example.feishu.cn/docx/doxcnE2E001?from=share",
|
||||
"--label-id", "7217780879644737539",
|
||||
"--dry-run",
|
||||
},
|
||||
wantMethod: "PATCH",
|
||||
wantURL: "/open-apis/drive/v2/files/doxcnE2E001/secure_label",
|
||||
assert: func(t *testing.T, out string) {
|
||||
if got := gjson.Get(out, "api.0.params.type").String(); got != "docx" {
|
||||
t.Fatalf("type = %q, want docx\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.id").String(); got != "7217780879644737539" {
|
||||
t.Fatalf("body.id = %q, want label id\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "file_token").String(); got != "doxcnE2E001" {
|
||||
t.Fatalf("file_token = %q, want doxcnE2E001\nstdout:\n%s", got, out)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != tt.wantMethod {
|
||||
t.Fatalf("method = %q, want %s\nstdout:\n%s", got, tt.wantMethod, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != tt.wantURL {
|
||||
t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out)
|
||||
}
|
||||
tt.assert(t, out)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
# Mail CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 62 leaf commands
|
||||
- Covered: 13
|
||||
- Coverage: 21.0%
|
||||
- Denominator: 63 leaf commands
|
||||
- Covered: 14
|
||||
- Coverage: 22.2%
|
||||
|
||||
## Summary
|
||||
- TestMail_DraftLifecycleWorkflowAsUser: proves a self-contained user draft workflow across `mail user_mailboxes profile`, `mail +draft-create`, `mail user_mailbox.drafts list`, `mail user_mailbox.drafts get`, `mail +draft-edit`, and `mail user_mailbox.drafts delete`; key `t.Run(...)` proof points are `get mailbox profile as user`, `create draft with shortcut as user`, `list draft as user`, `get created draft as user`, `inspect created draft as user`, `update draft subject with shortcut as user`, `inspect updated draft as user`, `delete draft as user`, and `verify draft removed from list as user`.
|
||||
- TestMail_DraftSendWorkflowAsUser: proves a self-contained user draft-send workflow across `mail user_mailboxes profile`, `mail +draft-create`, `mail +draft-send`, and `mail +triage`; key `t.Run(...)` proof points are `get mailbox profile as user`, `create self-addressed draft as user`, `send draft with shortcut as user`, and `find self-received message for cleanup`.
|
||||
- TestMail_SendWorkflowAsUser: proves a self-contained self-mail workflow across `mail +send`, `mail +triage`, `mail +message`, `mail +messages`, `mail +thread`, `mail +reply`, and `mail +forward`; key `t.Run(...)` proof points are `send mail to self with shortcut as user`, `find self sent mail in triage as user`, `get sent message as user`, `get received message as user`, `get both self sent messages as user`, `get self send thread as user`, `reply to received message with shortcut as user`, `inspect reply draft as user`, `forward received message with shortcut as user`, and `inspect forward draft as user`.
|
||||
- Blocked area: `mail +reply-all` is still uncovered because the self-send workflow produces only self-recipient traffic and reply-all’s recipient expansion becomes degenerate after self-address exclusion; `+signature`, `+watch`, event commands, and many raw message/thread mutation APIs still need dedicated tenant-aware workflows.
|
||||
|
||||
@@ -16,6 +17,7 @@
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| ✓ | mail +draft-create | shortcut | mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/create draft with shortcut as user | `--subject`; `--body`; `--plain-text` | creates a new self-owned draft without relying on external recipients |
|
||||
| ✓ | mail +draft-edit | shortcut | mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/inspect created draft as user; mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/update draft subject with shortcut as user; mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/inspect updated draft as user | `--draft-id`; `--mailbox me`; `--inspect`; `--set-subject` | shortcut proves readback projection and subject update |
|
||||
| ✓ | mail +draft-send | shortcut | mail_draft_send_workflow_test.go::TestMail_DraftSendWorkflowAsUser/send draft with shortcut as user; mail_draft_send_dryrun_test.go::TestMail_DraftSendDryRun | `--draft-id`; `--mailbox me`; `--yes`; dry-run repeated/comma-separated `--draft-id` | sends a self-addressed draft through the batch shortcut and locks dry-run request shape |
|
||||
| ✓ | mail +forward | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/forward received message with shortcut as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/inspect forward draft as user | `--message-id`; `--to`; `--body`; `--plain-text` | uses self-generated inbox message as source and inspects forwarded draft projection |
|
||||
| ✓ | mail +message | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get sent message as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get received message as user | `--mailbox me`; `--message-id` | verifies both SENT and INBOX copies after self-send |
|
||||
| ✓ | mail +messages | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get both self sent messages as user | `--mailbox me`; `--message-ids` | batch reads both sent and received message copies |
|
||||
|
||||
124
tests/cli_e2e/mail/mail_draft_send_dryrun_test.go
Normal file
124
tests/cli_e2e/mail/mail_draft_send_dryrun_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestMail_DraftSendDryRun(t *testing.T) {
|
||||
setMailDraftSendDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"mail", "+draft-send",
|
||||
"--mailbox", "alias@example.com",
|
||||
"--draft-id", " draft_001, draft_002 ",
|
||||
"--draft-id", " draft_003 ",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
wantURLs := []string{
|
||||
"/open-apis/mail/v1/user_mailboxes/alias@example.com/drafts/draft_001/send",
|
||||
"/open-apis/mail/v1/user_mailboxes/alias@example.com/drafts/draft_002/send",
|
||||
"/open-apis/mail/v1/user_mailboxes/alias@example.com/drafts/draft_003/send",
|
||||
}
|
||||
assert.Equal(t, int64(len(wantURLs)), gjson.Get(result.Stdout, "api.#").Int(), "stdout:\n%s", result.Stdout)
|
||||
for i, wantURL := range wantURLs {
|
||||
idx := strconv.Itoa(i)
|
||||
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api."+idx+".method").String(), "stdout:\n%s", result.Stdout)
|
||||
assert.Equal(t, wantURL, gjson.Get(result.Stdout, "api."+idx+".url").String(), "stdout:\n%s", result.Stdout)
|
||||
assert.False(t, gjson.Get(result.Stdout, "api."+idx+".body").Exists(), "stdout:\n%s", result.Stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMail_DraftSendDryRunValidation(t *testing.T) {
|
||||
setMailDraftSendDryRunEnv(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantMsg string
|
||||
}{
|
||||
{
|
||||
name: "reject whitespace draft id",
|
||||
args: []string{
|
||||
"mail", "+draft-send",
|
||||
"--draft-id", " ",
|
||||
"--dry-run",
|
||||
},
|
||||
wantMsg: "--draft-id contains empty value",
|
||||
},
|
||||
{
|
||||
name: "reject too many draft ids",
|
||||
args: []string{
|
||||
"mail", "+draft-send",
|
||||
"--draft-id", manyDraftIDsForE2E(51),
|
||||
"--dry-run",
|
||||
},
|
||||
wantMsg: "too many drafts",
|
||||
},
|
||||
{
|
||||
name: "reject duplicate draft id",
|
||||
args: []string{
|
||||
"mail", "+draft-send",
|
||||
"--draft-id", "draft_001,draft_002,draft_001",
|
||||
"--dry-run",
|
||||
},
|
||||
wantMsg: "--draft-id contains duplicate value: draft_001",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
output := result.Stdout + result.Stderr
|
||||
assert.Contains(t, output, tt.wantMsg, "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setMailDraftSendDryRunEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "mail_draft_send_dryrun_test")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "mail_draft_send_dryrun_secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
}
|
||||
|
||||
func manyDraftIDsForE2E(n int) string {
|
||||
ids := make([]byte, 0, n*4)
|
||||
for i := 0; i < n; i++ {
|
||||
if i > 0 {
|
||||
ids = append(ids, ',')
|
||||
}
|
||||
ids = append(ids, 'd')
|
||||
ids = strconv.AppendInt(ids, int64(i), 10)
|
||||
}
|
||||
return string(ids)
|
||||
}
|
||||
166
tests/cli_e2e/mail/mail_draft_send_workflow_test.go
Normal file
166
tests/cli_e2e/mail/mail_draft_send_workflow_test.go
Normal file
@@ -0,0 +1,166 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestMail_DraftSendWorkflowAsUser(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
|
||||
const mailboxID = "me"
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
subject := "lark-cli-e2e-mail-draft-send-" + suffix
|
||||
body := "draft-send workflow body " + suffix
|
||||
|
||||
var primaryEmail string
|
||||
var draftID string
|
||||
var draftSent bool
|
||||
var sentMessageID string
|
||||
var inboxMessageID string
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
if draftID != "" && !draftSent {
|
||||
cleanupCtx, cancel := clie2e.CleanupContext()
|
||||
defer cancel()
|
||||
|
||||
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
|
||||
Args: []string{"mail", "user_mailbox.drafts", "delete"},
|
||||
DefaultAs: "user",
|
||||
Params: map[string]any{
|
||||
"user_mailbox_id": mailboxID,
|
||||
"draft_id": draftID,
|
||||
},
|
||||
Yes: true,
|
||||
})
|
||||
clie2e.ReportCleanupFailure(parentT, "delete draft "+draftID, result, err)
|
||||
}
|
||||
|
||||
var messageIDs []string
|
||||
if sentMessageID != "" {
|
||||
messageIDs = append(messageIDs, sentMessageID)
|
||||
}
|
||||
if inboxMessageID != "" && inboxMessageID != sentMessageID {
|
||||
messageIDs = append(messageIDs, inboxMessageID)
|
||||
}
|
||||
if len(messageIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
cleanupCtx, cancel := clie2e.CleanupContext()
|
||||
defer cancel()
|
||||
|
||||
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
|
||||
Args: []string{"mail", "user_mailbox.messages", "batch_trash"},
|
||||
DefaultAs: "user",
|
||||
Params: map[string]any{"user_mailbox_id": mailboxID},
|
||||
Data: map[string]any{"message_ids": messageIDs},
|
||||
})
|
||||
clie2e.ReportCleanupFailure(parentT, "trash draft-send messages", result, err)
|
||||
})
|
||||
|
||||
t.Run("get mailbox profile as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"mail", "user_mailboxes", "profile"},
|
||||
DefaultAs: "user",
|
||||
Params: map[string]any{"user_mailbox_id": mailboxID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
primaryEmail = gjson.Get(result.Stdout, "data.primary_email_address").String()
|
||||
require.NotEmpty(t, primaryEmail, "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("create self-addressed draft as user", func(t *testing.T) {
|
||||
require.NotEmpty(t, primaryEmail, "mailbox profile should be loaded before draft create")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"mail", "+draft-create",
|
||||
"--to", primaryEmail,
|
||||
"--subject", subject,
|
||||
"--body", body,
|
||||
"--plain-text",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
draftID = gjson.Get(result.Stdout, "data.draft_id").String()
|
||||
require.NotEmpty(t, draftID, "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("send draft with shortcut as user", func(t *testing.T) {
|
||||
require.NotEmpty(t, draftID, "draft should be created before +draft-send")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"mail", "+draft-send",
|
||||
"--mailbox", mailboxID,
|
||||
"--draft-id", draftID,
|
||||
},
|
||||
DefaultAs: "user",
|
||||
Yes: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
assert.Equal(t, int64(1), gjson.Get(result.Stdout, "data.total").Int(), "stdout:\n%s", result.Stdout)
|
||||
assert.Equal(t, int64(1), gjson.Get(result.Stdout, "data.success_count").Int(), "stdout:\n%s", result.Stdout)
|
||||
assert.Equal(t, int64(0), gjson.Get(result.Stdout, "data.failure_count").Int(), "stdout:\n%s", result.Stdout)
|
||||
assert.Equal(t, draftID, gjson.Get(result.Stdout, "data.sent.0.draft_id").String(), "stdout:\n%s", result.Stdout)
|
||||
|
||||
sentMessageID = gjson.Get(result.Stdout, "data.sent.0.message_id").String()
|
||||
require.NotEmpty(t, sentMessageID, "stdout:\n%s", result.Stdout)
|
||||
draftSent = true
|
||||
})
|
||||
|
||||
t.Run("find self-received message for cleanup", func(t *testing.T) {
|
||||
require.NotEmpty(t, sentMessageID, "draft should be sent before triage lookup")
|
||||
|
||||
for attempt := 0; attempt < 12; attempt++ {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"mail", "+triage",
|
||||
"--mailbox", mailboxID,
|
||||
"--query", subject,
|
||||
"--max", "10",
|
||||
"--format", "data",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
for _, item := range gjson.Get(result.Stdout, "messages").Array() {
|
||||
if item.Get("subject").String() != subject {
|
||||
continue
|
||||
}
|
||||
messageID := item.Get("message_id").String()
|
||||
if messageID != "" && messageID != sentMessageID {
|
||||
inboxMessageID = messageID
|
||||
return
|
||||
}
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user