mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
1 Commits
main
...
feat/frame
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e32d7cb42e |
192
internal/flagalias/flagalias.go
Normal file
192
internal/flagalias/flagalias.go
Normal file
@@ -0,0 +1,192 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package flagalias owns parse-time aliases for Cobra/pflag commands.
|
||||
//
|
||||
// An alias is another accepted spelling of one canonical flag. It is not a
|
||||
// second pflag: parsing an alias resolves to the canonical flag before pflag
|
||||
// applies the value, so type, default, Changed state, required/enum/input
|
||||
// contracts, and repeated-flag behavior all stay attached to one object.
|
||||
//
|
||||
// Value conversion for non-equivalent legacy inputs is a business compatibility
|
||||
// concern, not an alias. Exact aliases always use the canonical flag's native
|
||||
// occurrence semantics; domains must not add a separate conflict policy.
|
||||
package flagalias
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// AnnotationAliases is attached to the canonical pflag. Consumers should use
|
||||
// Aliases instead of reading the annotation directly.
|
||||
const AnnotationAliases = "lark-cli/flag-aliases"
|
||||
|
||||
// Spec binds Aliases to one Canonical long-flag name. Names do not include the
|
||||
// leading "--".
|
||||
type Spec struct {
|
||||
Canonical string
|
||||
Aliases []string
|
||||
}
|
||||
|
||||
// Bind installs exact-name aliases on cmd and records them on their canonical
|
||||
// pflags for manifest/tooling introspection. Existing pflag normalization is
|
||||
// composed first; alias resolution is then applied to the normalized spelling.
|
||||
//
|
||||
// Bind is intentionally the only production owner of SetNormalizeFunc. It
|
||||
// validates the complete accepted-name set before installing alias metadata or
|
||||
// a normalizer, so a configuration error cannot leave aliases partially bound.
|
||||
func Bind(cmd *cobra.Command, specs []Spec) error {
|
||||
if len(specs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if cmd == nil {
|
||||
return fmt.Errorf("bind flag aliases: command is nil")
|
||||
}
|
||||
|
||||
cmd.InitDefaultHelpFlag()
|
||||
flagSet := cmd.Flags()
|
||||
previous := flagSet.GetNormalizeFunc()
|
||||
normalize := func(name string) string {
|
||||
if previous == nil {
|
||||
return name
|
||||
}
|
||||
return string(previous(flagSet, name))
|
||||
}
|
||||
|
||||
registered := make(map[string]string)
|
||||
collectRegistered(registered, flagSet)
|
||||
collectRegistered(registered, cmd.InheritedFlags())
|
||||
|
||||
// Existing annotations matter when Bind is composed by multiple adapters:
|
||||
// aliases are not independent pflags, so VisitAll alone cannot see them.
|
||||
acceptedAliases := make(map[string]string)
|
||||
collectAnnotatedAliases(acceptedAliases, flagSet, normalize)
|
||||
collectAnnotatedAliases(acceptedAliases, cmd.InheritedFlags(), normalize)
|
||||
|
||||
aliases := make(map[string]string)
|
||||
metadata := make(map[*pflag.Flag][]string)
|
||||
seenCanonical := make(map[string]struct{})
|
||||
for _, spec := range specs {
|
||||
if len(spec.Aliases) == 0 {
|
||||
continue
|
||||
}
|
||||
canonicalFlag := flagSet.Lookup(spec.Canonical)
|
||||
if canonicalFlag == nil {
|
||||
return fmt.Errorf("%s declares aliases for unregistered flag --%s", cmd.CommandPath(), spec.Canonical)
|
||||
}
|
||||
canonical := canonicalFlag.Name
|
||||
if _, exists := seenCanonical[canonical]; exists {
|
||||
return fmt.Errorf("%s declares flag aliases for --%s more than once after normalization", cmd.CommandPath(), canonical)
|
||||
}
|
||||
seenCanonical[canonical] = struct{}{}
|
||||
for _, alias := range spec.Aliases {
|
||||
if err := validateAliasName(alias); err != nil {
|
||||
return fmt.Errorf("%s alias for --%s: %w", cmd.CommandPath(), canonical, err)
|
||||
}
|
||||
normalized := normalize(alias)
|
||||
if normalized == "" {
|
||||
return fmt.Errorf("%s alias --%s for --%s normalizes to an empty name", cmd.CommandPath(), alias, canonical)
|
||||
}
|
||||
if normalized == canonical {
|
||||
return fmt.Errorf("%s declares --%s as an alias of itself (--%s after normalization)", cmd.CommandPath(), alias, canonical)
|
||||
}
|
||||
if existing, ok := registered[normalized]; ok {
|
||||
return fmt.Errorf("%s alias --%s for --%s conflicts with registered flag --%s after normalization", cmd.CommandPath(), alias, canonical, existing)
|
||||
}
|
||||
if existing, ok := acceptedAliases[normalized]; ok {
|
||||
return fmt.Errorf("%s alias --%s for --%s conflicts with existing alias for --%s after normalization to --%s", cmd.CommandPath(), alias, canonical, existing, normalized)
|
||||
}
|
||||
if existing, ok := aliases[normalized]; ok {
|
||||
if existing == canonical {
|
||||
return fmt.Errorf("%s declares duplicate alias --%s for --%s after normalization to --%s", cmd.CommandPath(), alias, canonical, normalized)
|
||||
}
|
||||
return fmt.Errorf("%s alias --%s maps to both --%s and --%s after normalization to --%s", cmd.CommandPath(), alias, existing, canonical, normalized)
|
||||
}
|
||||
aliases[normalized] = canonical
|
||||
metadata[canonicalFlag] = append(metadata[canonicalFlag], alias)
|
||||
}
|
||||
}
|
||||
|
||||
if len(aliases) == 0 {
|
||||
return nil
|
||||
}
|
||||
for flag, names := range metadata {
|
||||
setAliases(flag, append(Aliases(flag), names...))
|
||||
}
|
||||
flagSet.SetNormalizeFunc(func(set *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
normalized := name
|
||||
if previous != nil {
|
||||
normalized = string(previous(set, name))
|
||||
}
|
||||
if canonical, ok := aliases[normalized]; ok {
|
||||
return pflag.NormalizedName(canonical)
|
||||
}
|
||||
return pflag.NormalizedName(normalized)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// MustBind is the flag-registration form of Bind. Cobra/pflag registration
|
||||
// already treats duplicate or invalid flag definitions as programmer errors;
|
||||
// MustBind preserves that startup-fail-fast contract for callers whose mount
|
||||
// API does not return an error.
|
||||
func MustBind(cmd *cobra.Command, specs []Spec) {
|
||||
if err := Bind(cmd, specs); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Aliases returns a defensive copy of the raw accepted alias spellings stored
|
||||
// on a canonical pflag. Alias order matches declaration order.
|
||||
func Aliases(flag *pflag.Flag) []string {
|
||||
if flag == nil || len(flag.Annotations) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), flag.Annotations[AnnotationAliases]...)
|
||||
}
|
||||
|
||||
func validateAliasName(name string) error {
|
||||
switch {
|
||||
case name == "":
|
||||
return fmt.Errorf("name must not be empty")
|
||||
case strings.HasPrefix(name, "-"):
|
||||
return fmt.Errorf("name %q must not include leading dashes", name)
|
||||
case strings.ContainsAny(name, " \t\r\n"):
|
||||
return fmt.Errorf("name %q must not contain whitespace", name)
|
||||
case strings.Contains(name, "="):
|
||||
return fmt.Errorf("name %q must not contain '='", name)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func collectRegistered(dst map[string]string, set *pflag.FlagSet) {
|
||||
if set == nil {
|
||||
return
|
||||
}
|
||||
set.VisitAll(func(flag *pflag.Flag) {
|
||||
dst[flag.Name] = flag.Name
|
||||
})
|
||||
}
|
||||
|
||||
func collectAnnotatedAliases(dst map[string]string, set *pflag.FlagSet, normalize func(string) string) {
|
||||
if set == nil {
|
||||
return
|
||||
}
|
||||
set.VisitAll(func(flag *pflag.Flag) {
|
||||
for _, alias := range Aliases(flag) {
|
||||
dst[normalize(alias)] = flag.Name
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func setAliases(flag *pflag.Flag, aliases []string) {
|
||||
if flag.Annotations == nil {
|
||||
flag.Annotations = make(map[string][]string)
|
||||
}
|
||||
flag.Annotations[AnnotationAliases] = append([]string(nil), aliases...)
|
||||
}
|
||||
224
internal/flagalias/flagalias_test.go
Normal file
224
internal/flagalias/flagalias_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package flagalias
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func TestBindResolvesAliasesToOneCanonicalFlag(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "messages"}
|
||||
cmd.Flags().String("order", "desc", "message order")
|
||||
if err := cmd.MarkFlagRequired("order"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Bind(cmd, []Spec{{Canonical: "order", Aliases: []string{"sort", "sort-order"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := cmd.ParseFlags([]string{"--sort-order", "asc"}); err != nil {
|
||||
t.Fatalf("ParseFlags(alias) error = %v", err)
|
||||
}
|
||||
canonical := cmd.Flags().Lookup("order")
|
||||
if got := canonical.Value.String(); got != "asc" {
|
||||
t.Fatalf("canonical value = %q, want asc", got)
|
||||
}
|
||||
if !canonical.Changed {
|
||||
t.Fatal("alias must mark canonical flag Changed")
|
||||
}
|
||||
if err := cmd.ValidateRequiredFlags(); err != nil {
|
||||
t.Fatalf("alias must satisfy required canonical flag: %v", err)
|
||||
}
|
||||
if got := cmd.Flags().Lookup("sort-order"); got != canonical {
|
||||
t.Fatalf("Lookup(alias) = %p, want canonical %p", got, canonical)
|
||||
}
|
||||
if got := Aliases(canonical); strings.Join(got, ",") != "sort,sort-order" {
|
||||
t.Fatalf("Aliases(canonical) = %v", got)
|
||||
}
|
||||
if usage := cmd.Flags().FlagUsages(); strings.Contains(usage, "--sort") {
|
||||
t.Fatalf("aliases leaked into help:\n%s", usage)
|
||||
}
|
||||
var names []string
|
||||
cmd.Flags().VisitAll(func(flag *pflag.Flag) { names = append(names, flag.Name) })
|
||||
if strings.Contains(strings.Join(names, ","), "sort") {
|
||||
t.Fatalf("aliases were registered as independent flags: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindUsesNativeRepeatedFlagSemantics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "alias last", args: []string{"--order", "asc", "--sort", "desc"}, want: "desc"},
|
||||
{name: "canonical last", args: []string{"--sort", "desc", "--order", "asc"}, want: "asc"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "messages"}
|
||||
cmd.Flags().String("order", "", "")
|
||||
if err := Bind(cmd, []Spec{{Canonical: "order", Aliases: []string{"sort"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.ParseFlags(test.args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("order"); got != test.want {
|
||||
t.Fatalf("order = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "messages"}
|
||||
cmd.Flags().StringSlice("fields", nil, "")
|
||||
if err := Bind(cmd, []Spec{{Canonical: "fields", Aliases: []string{"field"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.ParseFlags([]string{"--field", "name", "--fields", "status"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetStringSlice("fields"); strings.Join(got, ",") != "name,status" {
|
||||
t.Fatalf("collection aliases did not accumulate: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindComposesExistingNormalizer(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "messages"}
|
||||
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
|
||||
})
|
||||
cmd.Flags().String("order", "", "")
|
||||
if err := Bind(cmd, []Spec{{Canonical: "order", Aliases: []string{"sort-order"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.ParseFlags([]string{"--sort_order", "asc"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("order"); got != "asc" {
|
||||
t.Fatalf("order = %q, want asc", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindRejectsDuplicateCanonicalAfterNormalization(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "messages"}
|
||||
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
|
||||
})
|
||||
cmd.Flags().String("sort-order", "", "")
|
||||
|
||||
err := Bind(cmd, []Spec{
|
||||
{Canonical: "sort_order", Aliases: []string{"order"}},
|
||||
{Canonical: "sort-order", Aliases: []string{"ordering"}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "more than once after normalization") {
|
||||
t.Fatalf("Bind() error = %v", err)
|
||||
}
|
||||
if got := Aliases(cmd.Flags().Lookup("sort-order")); len(got) != 0 {
|
||||
t.Fatalf("failed bind partially mutated annotations: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindRejectsAcceptedNameCollisionsWithoutMutation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
specs []Spec
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "registered canonical",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().String("order", "", "")
|
||||
cmd.Flags().String("query", "", "")
|
||||
},
|
||||
specs: []Spec{{Canonical: "order", Aliases: []string{"query"}}},
|
||||
want: "conflicts with registered flag --query",
|
||||
},
|
||||
{
|
||||
name: "ambiguous alias",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().String("order", "", "")
|
||||
cmd.Flags().String("field", "", "")
|
||||
},
|
||||
specs: []Spec{
|
||||
{Canonical: "order", Aliases: []string{"sort"}},
|
||||
{Canonical: "field", Aliases: []string{"sort"}},
|
||||
},
|
||||
want: "maps to both",
|
||||
},
|
||||
{
|
||||
name: "normalized collision",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
|
||||
})
|
||||
cmd.Flags().String("order", "", "")
|
||||
cmd.Flags().String("sort-order", "", "")
|
||||
},
|
||||
specs: []Spec{{Canonical: "order", Aliases: []string{"sort_order"}}},
|
||||
want: "after normalization",
|
||||
},
|
||||
{
|
||||
name: "invalid spelling",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().String("order", "", "")
|
||||
},
|
||||
specs: []Spec{{Canonical: "order", Aliases: []string{"--sort"}}},
|
||||
want: "must not include leading dashes",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "messages"}
|
||||
test.setup(cmd)
|
||||
err := Bind(cmd, test.specs)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Bind() error = %v, want %q", err, test.want)
|
||||
}
|
||||
if got := Aliases(cmd.Flags().Lookup("order")); len(got) != 0 {
|
||||
t.Fatalf("failed bind partially mutated annotations: %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindRejectsInheritedFlagCollision(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
parent.PersistentFlags().String("profile", "", "")
|
||||
child := &cobra.Command{Use: "messages"}
|
||||
child.Flags().String("order", "", "")
|
||||
parent.AddCommand(child)
|
||||
|
||||
err := Bind(child, []Spec{{Canonical: "order", Aliases: []string{"profile"}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "registered flag --profile") {
|
||||
t.Fatalf("Bind() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindCanComposeIndependentAdapters(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "messages"}
|
||||
cmd.Flags().String("order", "", "")
|
||||
cmd.Flags().String("query", "", "")
|
||||
if err := Bind(cmd, []Spec{{Canonical: "order", Aliases: []string{"sort"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Bind(cmd, []Spec{{Canonical: "query", Aliases: []string{"keyword"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.ParseFlags([]string{"--sort", "asc", "--keyword", "launch"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("order"); got != "asc" {
|
||||
t.Fatalf("order = %q", got)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("query"); got != "launch" {
|
||||
t.Fatalf("query = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -58,3 +59,25 @@ func WriteAlertWarning(w io.Writer, alert *extcs.Alert) error {
|
||||
alert.Provider, strings.Join(alert.MatchedRules, ", "))
|
||||
return err
|
||||
}
|
||||
|
||||
// writePaginationDiagnostic reports a record stream's pagination outcome on the
|
||||
// diagnostics stream, as one JSON object per line.
|
||||
//
|
||||
// A record stream has no envelope to carry meta, so without this a result
|
||||
// truncated by --page-limit is byte-identical to a complete one — the reader
|
||||
// cannot tell "these are all the records" from "these are the first 500". It is
|
||||
// JSON rather than prose because the reader that needs it is a program.
|
||||
func writePaginationDiagnostic(w io.Writer, meta PaginationMeta) error {
|
||||
payload := struct {
|
||||
Diagnostic string `json:"_diagnostic"`
|
||||
PaginationMeta
|
||||
}{Diagnostic: "pagination", PaginationMeta: meta}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return wrapOutputError("render", err)
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "%s\n", encoded); err != nil {
|
||||
return wrapOutputError("write", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
@@ -36,9 +35,11 @@ type EmitterConfig struct {
|
||||
// EmitOptions describes one result's wire representation.
|
||||
//
|
||||
// The format contract is explicit: JSON (including the empty default) uses an
|
||||
// Envelope; pretty, table, csv, and ndjson render naked business data. JQ takes
|
||||
// precedence over Format and filters the JSON Envelope. Raw affects only JSON
|
||||
// envelope encoding and jq's complex-value encoding.
|
||||
// Envelope. Pretty and table render business data plus a human pagination
|
||||
// summary when supplied; csv and ndjson keep stdout as naked records and put
|
||||
// pagination metadata on the diagnostics stream. JQ takes precedence over
|
||||
// Format and filters the JSON Envelope. Raw affects only JSON envelope encoding
|
||||
// and jq's complex-value encoding.
|
||||
//
|
||||
// JQSafetyWarning preserves the legacy difference between RuntimeContext.emit
|
||||
// (false) and WriteSuccessEnvelope (true) until their callers are migrated.
|
||||
@@ -94,8 +95,8 @@ func NewEmitter(config EmitterConfig) *Emitter {
|
||||
}
|
||||
|
||||
// Success scans and emits one command result by composing the package's leaf
|
||||
// primitives. JSON and jq use the standard envelope; pretty, table, csv, and
|
||||
// ndjson render the business value directly.
|
||||
// primitives. JSON and jq use the standard envelope; record formats keep their
|
||||
// stdout payload free of envelope metadata.
|
||||
func (e *Emitter) Success(data interface{}, opts EmitOptions) error {
|
||||
if err := e.requireOutput(); err != nil {
|
||||
return err
|
||||
@@ -104,14 +105,23 @@ func (e *Emitter) Success(data interface{}, opts EmitOptions) error {
|
||||
if opts.JQ != "" {
|
||||
return e.emitEnvelope(data, true, opts)
|
||||
}
|
||||
|
||||
switch opts.Format {
|
||||
case "", "json":
|
||||
return e.emitEnvelope(data, true, opts)
|
||||
case "pretty":
|
||||
if opts.Format == "pretty" {
|
||||
return e.emitPretty(data, opts)
|
||||
}
|
||||
|
||||
format, known := ParseFormat(opts.Format)
|
||||
if !known {
|
||||
fmt.Fprintf(e.errOut, "warning: unknown format %q, falling back to json\n", opts.Format)
|
||||
return e.emitEnvelope(data, true, opts)
|
||||
}
|
||||
switch format {
|
||||
case FormatJSON:
|
||||
return e.emitEnvelope(data, true, opts)
|
||||
case FormatTable, FormatCSV, FormatNDJSON:
|
||||
return e.emitFormatted(data, format, opts.Meta)
|
||||
default:
|
||||
return e.emitFormatted(data, opts.Format)
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"unsupported output format %q", format)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +255,10 @@ func (e *Emitter) emitPretty(data interface{}, opts EmitOptions) error {
|
||||
}
|
||||
if opts.Pretty != nil {
|
||||
return e.emit(func(w io.Writer) error {
|
||||
return opts.Pretty(w, e.colorEnabled)
|
||||
if err := opts.Pretty(w, e.colorEnabled); err != nil {
|
||||
return err
|
||||
}
|
||||
return writePaginationSummary(w, opts.Meta)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -255,7 +268,10 @@ func (e *Emitter) emitPretty(data interface{}, opts EmitOptions) error {
|
||||
return e.emitEnvelope(data, true, opts)
|
||||
}
|
||||
|
||||
func (e *Emitter) emitFormatted(data interface{}, rawFormat string) error {
|
||||
// emitFormatted handles only non-envelope formats. JSON, jq, and unknown-format
|
||||
// fallback are resolved by Success before reaching this function, so there is
|
||||
// exactly one JSON success contract: the standard Envelope.
|
||||
func (e *Emitter) emitFormatted(data interface{}, format Format, meta *Meta) error {
|
||||
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
@@ -266,43 +282,49 @@ func (e *Emitter) emitFormatted(data interface{}, rawFormat string) error {
|
||||
}
|
||||
}
|
||||
|
||||
format, known := ParseFormat(rawFormat)
|
||||
if !known && e.errOut != nil {
|
||||
fmt.Fprintf(e.errOut, "warning: unknown format %q, falling back to json\n", rawFormat)
|
||||
switch format {
|
||||
case FormatTable:
|
||||
return e.emit(func(w io.Writer) error {
|
||||
if err := WriteFormatted(w, data, format); err != nil {
|
||||
return err
|
||||
}
|
||||
return writePaginationSummary(w, meta)
|
||||
})
|
||||
case FormatCSV, FormatNDJSON:
|
||||
if err := e.emit(func(w io.Writer) error {
|
||||
return WriteFormatted(w, data, format)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if meta == nil || meta.Pagination == nil {
|
||||
return nil
|
||||
}
|
||||
return writePaginationDiagnostic(e.errOut, *meta.Pagination)
|
||||
default:
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"non-envelope emitter received unsupported format %q", format)
|
||||
}
|
||||
if format == FormatJSON {
|
||||
return e.printLegacyDataJSON(data)
|
||||
}
|
||||
return e.emit(func(w io.Writer) error {
|
||||
return WriteFormatted(w, data, format)
|
||||
})
|
||||
}
|
||||
|
||||
type emitterDataMap map[string]interface{}
|
||||
|
||||
// printLegacyDataJSON matches FormatValue's JSON branch while sourcing notice
|
||||
// data from this Emitter instead of PrintJson's global PendingNotice hook.
|
||||
func (e *Emitter) printLegacyDataJSON(data interface{}) error {
|
||||
// Normalise structs / named maps to plain generic types first, exactly as
|
||||
// FormatValue does, so a struct or named-map payload still matches the map
|
||||
// case below and keeps its injected _notice on the unknown-format fallback.
|
||||
data = toGeneric(data)
|
||||
if m, ok := data.(map[string]interface{}); ok {
|
||||
if _, isEnvelope := m["ok"]; isEnvelope {
|
||||
if notice := e.notice(); notice != nil {
|
||||
m = maps.Clone(m)
|
||||
m["_notice"] = notice
|
||||
}
|
||||
}
|
||||
// The named map retains identical JSON bytes while preventing PrintJson
|
||||
// from consulting its legacy global notice hook a second time.
|
||||
return e.emit(func(w io.Writer) error {
|
||||
return WriteJSON(w, emitterDataMap(m))
|
||||
})
|
||||
func writePaginationSummary(w io.Writer, meta *Meta) error {
|
||||
if meta == nil || meta.Pagination == nil {
|
||||
return nil
|
||||
}
|
||||
return e.emit(func(w io.Writer) error {
|
||||
return WriteJSON(w, data)
|
||||
})
|
||||
pagination := meta.Pagination
|
||||
status := "complete"
|
||||
if !pagination.Complete {
|
||||
status = "incomplete"
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "\nPagination: %s (%d page(s), %d item(s))", status, pagination.Pages, pagination.Items); err != nil {
|
||||
return err
|
||||
}
|
||||
if !pagination.Complete && pagination.NextToken != "" {
|
||||
if _, err := fmt.Fprintf(w, "; resume token: %q", pagination.NextToken); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := fmt.Fprintln(w)
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *Emitter) emit(render func(io.Writer) error) error {
|
||||
|
||||
@@ -63,6 +63,127 @@ func TestEmitterSuccessWritesAllBytes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterPaginationMetadataByFormat(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
data := map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1", "name": "first"}},
|
||||
}
|
||||
meta := &output.Meta{
|
||||
Count: 1,
|
||||
Pagination: &output.PaginationMeta{
|
||||
Complete: false,
|
||||
Pages: 2,
|
||||
Items: 1,
|
||||
NextToken: "resume-token",
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("json envelope", func(t *testing.T) {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout, ErrOut: stderr, CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
if err := emitter.Success(data, output.EmitOptions{Format: "json", Meta: meta}); err != nil {
|
||||
t.Fatalf("Emitter.Success() error = %v", err)
|
||||
}
|
||||
var envelope output.Envelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v", err)
|
||||
}
|
||||
if envelope.Meta == nil || !reflect.DeepEqual(envelope.Meta.Pagination, meta.Pagination) {
|
||||
t.Fatalf("pagination meta = %#v, want %#v", envelope.Meta, meta.Pagination)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("json wrote pagination diagnostic to stderr: %q", stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown format falls back to the same json envelope", func(t *testing.T) {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout, ErrOut: stderr, CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
if err := emitter.Success(data, output.EmitOptions{Format: "yaml", Meta: meta}); err != nil {
|
||||
t.Fatalf("Emitter.Success() error = %v", err)
|
||||
}
|
||||
var envelope output.Envelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("fallback stdout is not one complete JSON envelope: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if envelope.Meta == nil || !reflect.DeepEqual(envelope.Meta.Pagination, meta.Pagination) {
|
||||
t.Fatalf("fallback pagination meta = %#v, want %#v", envelope.Meta, meta.Pagination)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), `warning: unknown format "yaml", falling back to json`) {
|
||||
t.Fatalf("fallback stderr = %q, want unknown-format warning", stderr.String())
|
||||
}
|
||||
if strings.Contains(stderr.String(), `"_diagnostic":"pagination"`) {
|
||||
t.Fatalf("fallback emitted a second pagination contract: %q", stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
format string
|
||||
pretty output.PrettyRenderer
|
||||
}{
|
||||
{name: "pretty", format: "pretty", pretty: func(w io.Writer, _ bool) error {
|
||||
_, err := io.WriteString(w, "first\n")
|
||||
return err
|
||||
}},
|
||||
{name: "table", format: "table"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout, ErrOut: stderr, CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
if err := emitter.Success(data, output.EmitOptions{Format: tc.format, Pretty: tc.pretty, Meta: meta}); err != nil {
|
||||
t.Fatalf("Emitter.Success() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{"Pagination: incomplete", "2 page(s)", "1 item(s)", `resume token: "resume-token"`} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("stdout = %q, want %q", stdout.String(), want)
|
||||
}
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("%s wrote pagination diagnostic to stderr: %q", tc.format, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, format := range []string{"ndjson", "csv"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout, ErrOut: stderr, CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
if err := emitter.Success(data, output.EmitOptions{Format: format, Meta: meta}); err != nil {
|
||||
t.Fatalf("Emitter.Success() error = %v", err)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "_diagnostic") || strings.Contains(stdout.String(), "resume-token") {
|
||||
t.Fatalf("%s stdout was polluted by pagination metadata: %q", format, stdout.String())
|
||||
}
|
||||
var diagnostic struct {
|
||||
Diagnostic string `json:"_diagnostic"`
|
||||
Complete bool `json:"complete"`
|
||||
Pages int `json:"pages"`
|
||||
Items int `json:"items"`
|
||||
NextToken string `json:"next_token"`
|
||||
}
|
||||
if err := json.Unmarshal(bytes.TrimSpace(stderr.Bytes()), &diagnostic); err != nil {
|
||||
t.Fatalf("decode pagination diagnostic %q: %v", stderr.String(), err)
|
||||
}
|
||||
if diagnostic.Diagnostic != "pagination" || diagnostic.Complete || diagnostic.Pages != 2 || diagnostic.Items != 1 || diagnostic.NextToken != "resume-token" {
|
||||
t.Fatalf("pagination diagnostic = %+v", diagnostic)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterMarshalFailureReturnsTypedErrorWithoutOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
stdout := &bytes.Buffer{}
|
||||
|
||||
@@ -269,16 +269,9 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
|
||||
MatchedRules: []string{"fixture-rule"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown_format_data_envelope_notice",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"ok": true, "value": "fixture"}
|
||||
},
|
||||
ok: true,
|
||||
format: "yaml",
|
||||
useFormat: true,
|
||||
notice: map[string]interface{}{"skills": map[string]interface{}{"current": "1.0.0"}},
|
||||
},
|
||||
// Unknown-format fallback is intentionally excluded from this frozen
|
||||
// legacy set: it now uses the standard JSON Envelope. The replacement
|
||||
// contract lives in TestEmitterPaginationMetadataByFormat.
|
||||
}
|
||||
|
||||
golden := loadRuntimeContextLegacyGolden(t)
|
||||
@@ -732,7 +725,7 @@ func TestEmitterCapturesNoticeAndColorDependencies(t *testing.T) {
|
||||
t.Fatalf("Emitter.Success(unknown format) error = %v", err)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "global") || !strings.Contains(stdout.String(), "captured") {
|
||||
t.Fatalf("legacy JSON fallback consulted global notice:\n%s", stdout.String())
|
||||
t.Fatalf("JSON envelope fallback consulted global notice:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,24 @@ type Envelope struct {
|
||||
|
||||
// Meta carries optional metadata in envelope responses.
|
||||
type Meta struct {
|
||||
Count int `json:"count,omitempty"`
|
||||
Rollback string `json:"rollback,omitempty"`
|
||||
Count int `json:"count,omitempty"`
|
||||
Rollback string `json:"rollback,omitempty"`
|
||||
Pagination *PaginationMeta `json:"pagination,omitempty"`
|
||||
}
|
||||
|
||||
// PaginationMeta reports how a paginated read ended.
|
||||
//
|
||||
// It lives in the envelope's meta rather than in the business data because a
|
||||
// stop reason is not part of the resource: writing it into data both pollutes
|
||||
// the payload and forces the caller to tell an API field apart from one the CLI
|
||||
// synthesised. Complete plus NextToken is the whole story — a run either
|
||||
// exhausted the endpoint or stopped at --page-limit with somewhere to resume —
|
||||
// so there is no separate stop_reason string to keep in sync.
|
||||
type PaginationMeta struct {
|
||||
Complete bool `json:"complete"`
|
||||
Pages int `json:"pages"`
|
||||
Items int `json:"items"`
|
||||
NextToken string `json:"next_token,omitempty"`
|
||||
}
|
||||
|
||||
// PendingNotice, if set, returns system-level notices to inject as the
|
||||
|
||||
@@ -98,10 +98,6 @@
|
||||
"table_with_safety_warning": {
|
||||
"stdout": "id name \n── ─────\n1 Alice\n",
|
||||
"stderr": "warning: content safety alert from emitter-oracle (rules: fixture-rule)\n"
|
||||
},
|
||||
"unknown_format_data_envelope_notice": {
|
||||
"stdout": "{\n \"_notice\": {\n \"skills\": {\n \"current\": \"1.0.0\"\n }\n },\n \"ok\": true,\n \"value\": \"fixture\"\n}\n",
|
||||
"stderr": "warning: unknown format \"yaml\", falling back to json\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
rootcmd "github.com/larksuite/cli/cmd"
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/flagalias"
|
||||
"github.com/larksuite/cli/internal/qualitygate/manifest"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -134,6 +135,7 @@ func commandDomain(c *cobra.Command, path string, source manifest.Source) string
|
||||
func flagFromPFlag(f *pflag.Flag) manifest.Flag {
|
||||
return manifest.Flag{
|
||||
Name: f.Name,
|
||||
Aliases: flagalias.Aliases(f),
|
||||
Shorthand: f.Shorthand,
|
||||
Usage: f.Usage,
|
||||
Hidden: f.Hidden,
|
||||
@@ -141,7 +143,7 @@ func flagFromPFlag(f *pflag.Flag) manifest.Flag {
|
||||
TakesValue: f.NoOptDefVal == "",
|
||||
DefValue: f.DefValue,
|
||||
NoOptValue: f.NoOptDefVal,
|
||||
Annotations: cloneAnnotations(f.Annotations),
|
||||
Annotations: cloneAnnotations(f.Annotations, flagalias.AnnotationAliases),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,13 +164,23 @@ func hasAnnotation(f *pflag.Flag, key string) bool {
|
||||
return ok && len(values) > 0
|
||||
}
|
||||
|
||||
func cloneAnnotations(in map[string][]string) map[string][]string {
|
||||
func cloneAnnotations(in map[string][]string, excluded ...string) map[string][]string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
skip := make(map[string]struct{}, len(excluded))
|
||||
for _, key := range excluded {
|
||||
skip[key] = struct{}{}
|
||||
}
|
||||
out := make(map[string][]string, len(in))
|
||||
for key, values := range in {
|
||||
if _, ok := skip[key]; ok {
|
||||
continue
|
||||
}
|
||||
out[key] = append([]string(nil), values...)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/flagalias"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCommandFromCobraExportsAliasesAsFirstClassMetadata(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
cmd := &cobra.Command{Use: "+messages"}
|
||||
cmd.Flags().String("order", "desc", "message order")
|
||||
root.AddCommand(cmd)
|
||||
if err := flagalias.Bind(cmd, []flagalias.Spec{{Canonical: "order", Aliases: []string{"sort", "sort-order"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entry := commandFromCobra(cmd, nil)
|
||||
flag := findFlag(entry.Flags, "order")
|
||||
if flag == nil {
|
||||
t.Fatal("manifest is missing canonical --order")
|
||||
}
|
||||
if !slices.Equal(flag.Aliases, []string{"sort", "sort-order"}) {
|
||||
t.Fatalf("manifest aliases = %v", flag.Aliases)
|
||||
}
|
||||
if _, leaked := flag.Annotations[flagalias.AnnotationAliases]; leaked {
|
||||
t.Fatalf("internal alias annotation leaked into manifest: %#v", flag.Annotations)
|
||||
}
|
||||
if findFlag(entry.Flags, "sort") != nil || findFlag(entry.Flags, "sort-order") != nil {
|
||||
t.Fatalf("aliases were exported as independent flags: %#v", entry.Flags)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/qualitygate/manifest"
|
||||
@@ -90,6 +91,42 @@ func TestCollectContainsDocsFetchAndDryRunFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectExportsShortcutAliasesOnCanonicalFlags(t *testing.T) {
|
||||
got, err := collectHandAuthored(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("collectHandAuthored() error = %v", err)
|
||||
}
|
||||
tests := []struct {
|
||||
command string
|
||||
canonical string
|
||||
aliases []string
|
||||
}{
|
||||
{command: "base +url-resolve", canonical: "url", aliases: []string{"query"}},
|
||||
{command: "im +chat-messages-list", canonical: "order", aliases: []string{"sort-order"}},
|
||||
{command: "sheets +workbook-info", canonical: "spreadsheet-token", aliases: []string{"token"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.command+"/"+test.canonical, func(t *testing.T) {
|
||||
cmd := findManifestCommand(&got, test.command)
|
||||
if cmd == nil {
|
||||
t.Fatalf("manifest command %q not found", test.command)
|
||||
}
|
||||
flag := findManifestFlag(cmd, test.canonical)
|
||||
if flag == nil {
|
||||
t.Fatalf("canonical --%s not found", test.canonical)
|
||||
}
|
||||
if strings.Join(flag.Aliases, ",") != strings.Join(test.aliases, ",") {
|
||||
t.Fatalf("aliases = %v, want %v", flag.Aliases, test.aliases)
|
||||
}
|
||||
for _, alias := range test.aliases {
|
||||
if findManifestFlag(cmd, alias) != nil {
|
||||
t.Fatalf("alias --%s exported as an independent flag", alias)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectExcludesGeneratedServiceCommands(t *testing.T) {
|
||||
got, err := collectHandAuthored(context.Background())
|
||||
if err != nil {
|
||||
|
||||
@@ -19,6 +19,57 @@ func TestValidateRejectsDuplicateCommandPaths(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAcceptsDistinctFlagAliases(t *testing.T) {
|
||||
m := Manifest{SchemaVersion: 1, Commands: []Command{{
|
||||
Path: "im +messages",
|
||||
CanonicalPath: "im +messages",
|
||||
Source: SourceShortcut,
|
||||
Flags: []Flag{
|
||||
{Name: "order", Aliases: []string{"sort", "sort-order"}},
|
||||
{Name: "query", Aliases: []string{"keyword"}},
|
||||
},
|
||||
}}}
|
||||
if err := m.Validate(KindCommandManifest); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsFlagAliasCollisions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flags []Flag
|
||||
}{
|
||||
{
|
||||
name: "alias and canonical",
|
||||
flags: []Flag{
|
||||
{Name: "order", Aliases: []string{"query"}},
|
||||
{Name: "query"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "alias and alias",
|
||||
flags: []Flag{
|
||||
{Name: "order", Aliases: []string{"sort"}},
|
||||
{Name: "field", Aliases: []string{"sort"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "alias self reference",
|
||||
flags: []Flag{{Name: "order", Aliases: []string{"order"}}},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
m := Manifest{SchemaVersion: 1, Commands: []Command{{
|
||||
Path: "im +messages", CanonicalPath: "im +messages", Source: SourceShortcut, Flags: test.flags,
|
||||
}}}
|
||||
if err := m.Validate(KindCommandManifest); err == nil {
|
||||
t.Fatal("expected alias collision to fail")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsInvalidSource(t *testing.T) {
|
||||
m := Manifest{SchemaVersion: 1, Commands: []Command{
|
||||
{Path: "docs +fetch", CanonicalPath: "docs +fetch", Source: Source("invalid")},
|
||||
|
||||
@@ -40,6 +40,7 @@ type Command struct {
|
||||
|
||||
type Flag struct {
|
||||
Name string `json:"name"`
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
Shorthand string `json:"shorthand,omitempty"`
|
||||
Usage string `json:"usage,omitempty"`
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
@@ -154,15 +155,24 @@ func validateCommand(kind string, i int, cmd Command) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
seenFlags := make(map[string]struct{}, len(cmd.Flags))
|
||||
acceptedNames := make(map[string]string, len(cmd.Flags))
|
||||
for j, flag := range cmd.Flags {
|
||||
if err := validateFlag(prefix, j, flag); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := seenFlags[flag.Name]; ok {
|
||||
if existing, ok := acceptedNames[flag.Name]; ok {
|
||||
if existing != flag.Name {
|
||||
return fmt.Errorf("%s flags[%d].name %s conflicts with an alias of --%s", prefix, j, flag.Name, existing)
|
||||
}
|
||||
return fmt.Errorf("%s flags[%d].name is duplicated: %s", prefix, j, flag.Name)
|
||||
}
|
||||
seenFlags[flag.Name] = struct{}{}
|
||||
acceptedNames[flag.Name] = flag.Name
|
||||
for k, alias := range flag.Aliases {
|
||||
if existing, ok := acceptedNames[alias]; ok {
|
||||
return fmt.Errorf("%s flags[%d].aliases[%d] %s conflicts with accepted name of --%s", prefix, j, k, alias, existing)
|
||||
}
|
||||
acceptedNames[alias] = flag.Name
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -175,6 +185,29 @@ func validateFlag(commandPrefix string, i int, flag Flag) error {
|
||||
if strings.ContainsAny(flag.Name, " \t\r\n") {
|
||||
return fmt.Errorf("%s.name must not contain whitespace", prefix)
|
||||
}
|
||||
seenAliases := make(map[string]struct{}, len(flag.Aliases))
|
||||
for j, alias := range flag.Aliases {
|
||||
aliasPrefix := fmt.Sprintf("%s.aliases[%d]", prefix, j)
|
||||
if err := validateString(aliasPrefix, alias, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasPrefix(alias, "-") {
|
||||
return fmt.Errorf("%s must not include leading dashes", aliasPrefix)
|
||||
}
|
||||
if strings.ContainsAny(alias, " \t\r\n") {
|
||||
return fmt.Errorf("%s must not contain whitespace", aliasPrefix)
|
||||
}
|
||||
if strings.Contains(alias, "=") {
|
||||
return fmt.Errorf("%s must not contain '='", aliasPrefix)
|
||||
}
|
||||
if alias == flag.Name {
|
||||
return fmt.Errorf("%s must differ from canonical name %s", aliasPrefix, flag.Name)
|
||||
}
|
||||
if _, ok := seenAliases[alias]; ok {
|
||||
return fmt.Errorf("%s is duplicated: %s", aliasPrefix, alias)
|
||||
}
|
||||
seenAliases[alias] = struct{}{}
|
||||
}
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
value string
|
||||
|
||||
@@ -199,7 +199,11 @@ func materializePlaceholderExample(raw string, cmd manifest.Command) (materializ
|
||||
if eq := strings.IndexByte(name, '='); eq >= 0 {
|
||||
flagName := name[:eq]
|
||||
flag := findManifestFlag(&cmd, flagName)
|
||||
value, ok := materializePlaceholderValue(name[eq+1:], placeholderContextForFlag(flagName, flag))
|
||||
contextName := flagName
|
||||
if flag != nil {
|
||||
contextName = flag.Name
|
||||
}
|
||||
value, ok := materializePlaceholderValue(name[eq+1:], placeholderContextForFlag(contextName, flag))
|
||||
if !ok {
|
||||
return materializedExample{}, false
|
||||
}
|
||||
@@ -208,7 +212,7 @@ func materializePlaceholderExample(raw string, cmd manifest.Command) (materializ
|
||||
}
|
||||
flag := findManifestFlag(&cmd, name)
|
||||
if flag != nil && flag.TakesValue && i+1 < len(argv) {
|
||||
value, ok := materializePlaceholderValue(argv[i+1], placeholderContextForFlag(name, flag))
|
||||
value, ok := materializePlaceholderValue(argv[i+1], placeholderContextForFlag(flag.Name, flag))
|
||||
if !ok {
|
||||
return materializedExample{}, false
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package rules
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
@@ -170,7 +171,11 @@ func consumeFlags(args []string, cmd *manifest.Command) ([]string, []string, err
|
||||
hasInlineValue = true
|
||||
}
|
||||
flag := findManifestFlag(cmd, name)
|
||||
flags = append(flags, name)
|
||||
acceptedName := name
|
||||
if flag != nil {
|
||||
acceptedName = flag.Name
|
||||
}
|
||||
flags = append(flags, acceptedName)
|
||||
if flag != nil && !hasInlineValue && flag.TakesValue && i+1 < len(args) {
|
||||
i++
|
||||
}
|
||||
@@ -201,7 +206,7 @@ func isShellOperator(arg string) bool {
|
||||
|
||||
func findManifestFlag(cmd *manifest.Command, name string) *manifest.Flag {
|
||||
for i := range cmd.Flags {
|
||||
if cmd.Flags[i].Name == name || cmd.Flags[i].Shorthand == name {
|
||||
if cmd.Flags[i].Name == name || cmd.Flags[i].Shorthand == name || slices.Contains(cmd.Flags[i].Aliases, name) {
|
||||
return &cmd.Flags[i]
|
||||
}
|
||||
}
|
||||
@@ -241,6 +246,9 @@ func indexManifest(m manifest.Manifest) manifestIndex {
|
||||
flagSet := make(map[string]bool, len(cmd.Flags))
|
||||
for _, fl := range cmd.Flags {
|
||||
flagSet[fl.Name] = true
|
||||
for _, alias := range fl.Aliases {
|
||||
flagSet[alias] = true
|
||||
}
|
||||
}
|
||||
index.flags[cmd.Path] = flagSet
|
||||
}
|
||||
|
||||
@@ -379,6 +379,26 @@ func TestCheckReferencesAllowsHelpFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAgainstManifestAcceptsAliasAndCanonicalizesFact(t *testing.T) {
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
Path: "im +messages",
|
||||
Runnable: true,
|
||||
Flags: []manifest.Flag{{
|
||||
Name: "order", Aliases: []string{"sort-order"}, TakesValue: true,
|
||||
}},
|
||||
}}}
|
||||
got, err := parseAgainstManifest(m, "lark-cli im +messages --sort-order asc")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(got.Flags, ",") != "order" {
|
||||
t.Fatalf("flags = %v, want canonical order", got.Flags)
|
||||
}
|
||||
if index := indexManifest(m); !index.hasFlag("im +messages", "sort-order") {
|
||||
t.Fatal("manifest index did not retain accepted alias name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckReferencesSkipsTemplateServicePlaceholder(t *testing.T) {
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{Path: "im"}}}
|
||||
ex := skillscan.Example{Raw: "lark-cli im <resource> <method> [flags]", SourceFile: "skills/lark-demo/SKILL.md", Line: 1}
|
||||
|
||||
@@ -18,7 +18,7 @@ lint/
|
||||
├── main.go # package main — dispatches to every registered domain
|
||||
├── lintapi/ # shared types every domain returns
|
||||
│ └── violation.go # Violation, Action, ActionReject / ActionLabel / ActionWarning
|
||||
└── errscontract/ # first domain: typed-error contract guards
|
||||
├── errscontract/ # first domain: typed-error contract guards
|
||||
├── scan.go # ScanRepoWithOptions(root, opts) ← public entry
|
||||
├── runner.go
|
||||
├── typecheck.go
|
||||
@@ -30,14 +30,27 @@ lint/
|
||||
├── rule_subtype_classifier.go
|
||||
├── rule_typed_error_completeness.go
|
||||
└── *_test.go
|
||||
└── domaincontract/ # resolver ownership + approved public hostname policy
|
||||
├── domaincontract/ # resolver ownership + approved public hostname policy
|
||||
├── scan.go # ScanRepoWithOptions(root, opts) ← public entry
|
||||
├── unapproved.go # Go AST/type-aware hostname extraction
|
||||
├── policy.go # exact public/fixture allowlist validation
|
||||
├── diff.go # added-line attribution
|
||||
└── *_test.go
|
||||
└── flagcontract/ # framework ownership for flag aliases
|
||||
├── scan.go # rejects local name normalizers and independent aliases
|
||||
└── scan_test.go
|
||||
```
|
||||
|
||||
## Flag alias contract (`flagcontract`)
|
||||
|
||||
`flagcontract` keeps exact flag-name synonyms on the shared framework path. It
|
||||
rejects production calls to `SetNormalizeFunc` outside `internal/flagalias` and
|
||||
independent hidden flags described as aliases. Exact synonyms belong in the
|
||||
canonical `common.Flag.Aliases`; legacy inputs with a different value grammar
|
||||
or meaning remain real hidden flags and normalize into canonical state
|
||||
inside the business-owned `Shortcut.Normalize` execution stage. Exact
|
||||
aliases always share the canonical flag's occurrence and conflict semantics.
|
||||
|
||||
## Endpoint domain contract (`domaincontract`)
|
||||
|
||||
`domaincontract` contains two complementary Go source guards.
|
||||
|
||||
140
lint/flagcontract/scan.go
Normal file
140
lint/flagcontract/scan.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package flagcontract keeps flag aliases on the shared framework path.
|
||||
package flagcontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/lint/lintapi"
|
||||
)
|
||||
|
||||
const aliasOwnerPath = "internal/flagalias/flagalias.go"
|
||||
|
||||
// ScanOptions mirrors the aggregate lint runner's incremental interface. The
|
||||
// alias rules are repository invariants and intentionally scan all production
|
||||
// Go files; ChangedFrom is retained for a uniform caller contract.
|
||||
type ScanOptions struct {
|
||||
ChangedFrom string
|
||||
}
|
||||
|
||||
func ScanRepoWithOptions(root string, _ ScanOptions) ([]lintapi.Violation, error) {
|
||||
var out []lintapi.Violation
|
||||
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() {
|
||||
switch entry.Name() {
|
||||
case ".git", ".claude", "vendor", "node_modules", "testdata":
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, path, nil, 0)
|
||||
if err != nil {
|
||||
return nil // another compiler/lint stage owns syntax errors
|
||||
}
|
||||
ast.Inspect(file, func(node ast.Node) bool {
|
||||
switch value := node.(type) {
|
||||
case *ast.CallExpr:
|
||||
selector, ok := value.Fun.(*ast.SelectorExpr)
|
||||
if ok && selector.Sel.Name == "SetNormalizeFunc" && rel != aliasOwnerPath {
|
||||
out = append(out, violation(fset, rel, value.Pos(),
|
||||
"flag_alias_normalizer_owner",
|
||||
"SetNormalizeFunc is owned by internal/flagalias",
|
||||
"declare exact synonyms with common.Flag.Aliases or call flagalias.Bind from a framework adapter"))
|
||||
}
|
||||
case *ast.CompositeLit:
|
||||
if name, desc, hidden := hiddenFlagLiteral(value); hidden && aliasDescription(desc) {
|
||||
out = append(out, violation(fset, rel, value.Pos(),
|
||||
"flag_alias_independent_flag",
|
||||
"--"+name+" is modeled as an independent hidden alias",
|
||||
"put an exact synonym in the canonical common.Flag.Aliases; use Shortcut.Normalize only when the legacy input's value grammar or meaning differs"))
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].File != out[j].File {
|
||||
return out[i].File < out[j].File
|
||||
}
|
||||
if out[i].Line != out[j].Line {
|
||||
return out[i].Line < out[j].Line
|
||||
}
|
||||
return out[i].Rule < out[j].Rule
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func violation(fset *token.FileSet, file string, pos token.Pos, rule, message, suggestion string) lintapi.Violation {
|
||||
return lintapi.Violation{
|
||||
Rule: rule,
|
||||
Action: lintapi.ActionReject,
|
||||
File: file,
|
||||
Line: fset.Position(pos).Line,
|
||||
Message: message,
|
||||
Suggestion: suggestion,
|
||||
}
|
||||
}
|
||||
|
||||
func hiddenFlagLiteral(lit *ast.CompositeLit) (name, desc string, hidden bool) {
|
||||
for _, element := range lit.Elts {
|
||||
item, ok := element.(*ast.KeyValueExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key, ok := item.Key.(*ast.Ident)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch key.Name {
|
||||
case "Name":
|
||||
name, _ = stringLiteral(item.Value)
|
||||
case "Desc":
|
||||
desc, _ = stringLiteral(item.Value)
|
||||
case "Hidden":
|
||||
ident, ok := item.Value.(*ast.Ident)
|
||||
hidden = ok && ident.Name == "true"
|
||||
}
|
||||
}
|
||||
return name, desc, hidden && name != ""
|
||||
}
|
||||
|
||||
func stringLiteral(expr ast.Expr) (string, bool) {
|
||||
lit, ok := expr.(*ast.BasicLit)
|
||||
if !ok || lit.Kind != token.STRING {
|
||||
return "", false
|
||||
}
|
||||
value, err := strconv.Unquote(lit.Value)
|
||||
return value, err == nil
|
||||
}
|
||||
|
||||
func aliasDescription(desc string) bool {
|
||||
desc = strings.ToLower(desc)
|
||||
return strings.Contains(desc, "alias for --") ||
|
||||
strings.Contains(desc, "alias of --") ||
|
||||
strings.Contains(desc, "hidden alias")
|
||||
}
|
||||
63
lint/flagcontract/scan_test.go
Normal file
63
lint/flagcontract/scan_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package flagcontract
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScanRejectsLocalNormalizerAndIndependentAliasFlag(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFixture(t, root, "shortcuts/demo/demo.go", `package demo
|
||||
func mount(cmd interface{ SetNormalizeFunc(any) }) { cmd.SetNormalizeFunc(nil) }
|
||||
var flags = []struct { Name, Desc string; Hidden bool }{
|
||||
{Name: "sort-order", Hidden: true, Desc: "hidden alias for --order"},
|
||||
{Name: "legacy-sort", Hidden: true, Desc: "legacy vocabulary normalized to --order"},
|
||||
}
|
||||
`)
|
||||
writeFixture(t, root, "shortcuts/demo/demo_test.go", `package demo
|
||||
func ignored(cmd interface{ SetNormalizeFunc(any) }) { cmd.SetNormalizeFunc(nil) }
|
||||
`)
|
||||
writeFixture(t, root, aliasOwnerPath, `package flagalias
|
||||
func bind(cmd interface{ SetNormalizeFunc(any) }) { cmd.SetNormalizeFunc(nil) }
|
||||
`)
|
||||
|
||||
got, err := ScanRepoWithOptions(root, ScanOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("violations = %#v, want 2", got)
|
||||
}
|
||||
if got[0].Rule != "flag_alias_normalizer_owner" || got[1].Rule != "flag_alias_independent_flag" {
|
||||
t.Fatalf("rules = %q, %q", got[0].Rule, got[1].Rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanCurrentRepositoryHasNoViolations(t *testing.T) {
|
||||
root, err := filepath.Abs("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ScanRepoWithOptions(root, ScanOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("flag contract violations: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFixture(t *testing.T, root, rel, content string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(root, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/lint/domaincontract"
|
||||
"github.com/larksuite/cli/lint/errscontract"
|
||||
"github.com/larksuite/cli/lint/flagcontract"
|
||||
"github.com/larksuite/cli/lint/lintapi"
|
||||
)
|
||||
|
||||
@@ -48,6 +49,11 @@ var scanners = []scanner{
|
||||
ChangedFrom: opts.ChangedFrom,
|
||||
})
|
||||
}},
|
||||
{name: "flagcontract", fn: func(root string, opts errscontract.ScanOptions) ([]lintapi.Violation, error) {
|
||||
return flagcontract.ScanRepoWithOptions(root, flagcontract.ScanOptions{
|
||||
ChangedFrom: opts.ChangedFrom,
|
||||
})
|
||||
}},
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -25,9 +25,6 @@ func TestDryRunTableOps(t *testing.T) {
|
||||
listRT := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, map[string]int{"offset": -1, "limit": 100})
|
||||
assertDryRunContains(t, dryRunTableList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables", "offset=0", "limit=100")
|
||||
|
||||
pageSizeAliasRT := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, map[string]int{"page-size": 40})
|
||||
assertDryRunContains(t, dryRunTableList(ctx, pageSizeAliasRT), "limit=40")
|
||||
|
||||
rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_1", "name": "Orders"}, nil, nil)
|
||||
assertDryRunContains(t, dryRunTableGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1")
|
||||
assertDryRunContains(t, dryRunTableCreate(ctx, rt), "POST /open-apis/base/v3/bases/app_x/tables")
|
||||
@@ -219,18 +216,6 @@ func TestDryRunRecordOps(t *testing.T) {
|
||||
`"sort":[{"desc":true,"field":"Updated At"}]`,
|
||||
)
|
||||
|
||||
searchPageSizeAliasRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{
|
||||
"base-token": "app_x",
|
||||
"table-id": "tbl_1",
|
||||
"keyword": "Alice",
|
||||
},
|
||||
map[string][]string{"search-field": {"Name"}},
|
||||
nil,
|
||||
map[string]int{"page-size": 25},
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordSearch(ctx, searchPageSizeAliasRT), `"limit":25`)
|
||||
|
||||
upsertCreateRT := newBaseTestRuntime(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `{"Name":"A"}`},
|
||||
nil, nil,
|
||||
|
||||
@@ -2009,6 +2009,16 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search json conflict reports canonical pagination flag", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordSearch, []string{
|
||||
"+record-search", "--base-token", "app_x", "--table-id", "tbl_x",
|
||||
"--json", `{"keyword":"Alice","search_fields":["Name"]}`,
|
||||
"--limit", "10", "--page-size", "201",
|
||||
}, factory, stdout)
|
||||
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--limit"}, "mutually exclusive")
|
||||
})
|
||||
|
||||
t.Run("list canonical and alias projections reject duplicates consistently", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -37,8 +37,7 @@ var BaseURLResolve = common.Shortcut{
|
||||
AuthTypes: authTypes(),
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "Base/Wiki/record-share URL to resolve"},
|
||||
{Name: "query", Hidden: true, Desc: "Alias for --url; accepted to recover from AI routing mistakes"},
|
||||
{Name: "url", Aliases: []string{"query"}, Desc: "Base/Wiki/record-share URL to resolve"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +url-resolve --url "https://example.larkoffice.com/base/<base_token>?table=<block_id>&view=<view_id>"`,
|
||||
@@ -108,9 +107,7 @@ var BaseTitleResolve = common.Shortcut{
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Desc: "Base title keyword to search via Drive (30 characters or fewer)"},
|
||||
{Name: "query", Hidden: true, Desc: "Alias for --title; accepted to recover from AI routing mistakes"},
|
||||
{Name: "url", Hidden: true, Desc: "Alias for --title; accepted to recover from AI routing mistakes"},
|
||||
{Name: "title", Aliases: []string{"query", "url"}, Desc: "Base title keyword to search via Drive (30 characters or fewer)"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +title-resolve --title "Sales pipeline"`,
|
||||
@@ -135,15 +132,7 @@ var BaseTitleResolve = common.Shortcut{
|
||||
}
|
||||
|
||||
func readURLResolveInput(runtime *common.RuntimeContext) (string, error) {
|
||||
urlValue := strings.TrimSpace(runtime.Str("url"))
|
||||
queryValue := strings.TrimSpace(runtime.Str("query"))
|
||||
if urlValue != "" && queryValue != "" {
|
||||
return "", baseFlagErrorf("--url and --query are mutually exclusive")
|
||||
}
|
||||
value := urlValue
|
||||
if value == "" {
|
||||
value = queryValue
|
||||
}
|
||||
value := strings.TrimSpace(runtime.Str("url"))
|
||||
if value == "" {
|
||||
return "", baseFlagErrorf("specify --url")
|
||||
}
|
||||
@@ -151,25 +140,7 @@ func readURLResolveInput(runtime *common.RuntimeContext) (string, error) {
|
||||
}
|
||||
|
||||
func readTitleResolveQuery(runtime *common.RuntimeContext) (string, error) {
|
||||
values := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"title", strings.TrimSpace(runtime.Str("title"))},
|
||||
{"query", strings.TrimSpace(runtime.Str("query"))},
|
||||
{"url", strings.TrimSpace(runtime.Str("url"))},
|
||||
}
|
||||
var pickedName, pickedValue string
|
||||
for _, v := range values {
|
||||
if v.value == "" {
|
||||
continue
|
||||
}
|
||||
if pickedValue != "" {
|
||||
return "", baseFlagErrorf("--%s and --%s are mutually exclusive", pickedName, v.name)
|
||||
}
|
||||
pickedName = v.name
|
||||
pickedValue = v.value
|
||||
}
|
||||
pickedValue := strings.TrimSpace(runtime.Str("title"))
|
||||
if pickedValue == "" {
|
||||
return "", baseFlagErrorf("specify --title")
|
||||
}
|
||||
|
||||
@@ -497,24 +497,30 @@ func TestBaseURLResolveValidationErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseResolveInputXOR(t *testing.T) {
|
||||
func TestBaseResolveAliasesUseCanonicalRepeatedFlagSemantics(t *testing.T) {
|
||||
t.Run("url resolve", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.com/base/bas1", "--query", "https://example.com/base/bas2", "--as", "user",
|
||||
"+url-resolve", "--url", "https://example.com/base/bas1", "--query", "https://example.com/base/bas2", "--as", "user", "--dry-run",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v, want xor validation", err)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "bas2") || strings.Contains(got, "bas1") {
|
||||
t.Fatalf("alias should be the last occurrence: %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("title resolve", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--title", "Pipeline", "--query", "Sales", "--as", "user",
|
||||
"+title-resolve", "--title", "Pipeline", "--query", "Sales", "--as", "user", "--dry-run",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v, want xor validation", err)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "Sales") || strings.Contains(got, "Pipeline") {
|
||||
t.Fatalf("alias should be the last occurrence: %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -556,8 +562,8 @@ func TestBaseResolveHelpFlags(t *testing.T) {
|
||||
}
|
||||
for _, aliasFlag := range tc.aliasFlags {
|
||||
alias := cmd.Flags().Lookup(aliasFlag)
|
||||
if alias == nil || !alias.Hidden {
|
||||
t.Fatalf("alias flag %q should exist and be hidden: %#v", aliasFlag, alias)
|
||||
if alias != primary {
|
||||
t.Fatalf("Lookup(%q) = %#v, want canonical %#v", aliasFlag, alias, primary)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -27,25 +27,6 @@ func baseTableID(runtime *common.RuntimeContext) string {
|
||||
return strings.TrimSpace(runtime.Str("table-id"))
|
||||
}
|
||||
|
||||
func pageSizeLimitAliasFlag() common.Flag {
|
||||
return common.Flag{Name: "page-size", Type: "int", Default: "0", Desc: "hidden alias for --limit", Hidden: true}
|
||||
}
|
||||
|
||||
func getPaginationLimit(runtime *common.RuntimeContext) int {
|
||||
if !runtime.Changed("limit") && runtime.Changed("page-size") {
|
||||
return runtime.Int("page-size")
|
||||
}
|
||||
return runtime.Int("limit")
|
||||
}
|
||||
|
||||
func validateLimitPageSizeAlias(runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("limit") && runtime.Changed("page-size") {
|
||||
return common.ValidationErrorf("--limit and --page-size are mutually exclusive; use --limit").
|
||||
WithParam("--page-size")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
|
||||
@@ -433,7 +433,7 @@ func TestBasePaginationHelpShowsDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseLimitPageSizeAliasIsHidden(t *testing.T) {
|
||||
func TestBaseLimitDeclaresPageSizeAlias(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
@@ -448,18 +448,26 @@ func TestBaseLimitPageSizeAliasIsHidden(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var declared *common.Flag
|
||||
for i := range tt.shortcut.Flags {
|
||||
if tt.shortcut.Flags[i].Name == "limit" {
|
||||
declared = &tt.shortcut.Flags[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if declared == nil || len(declared.Aliases) != 1 || declared.Aliases[0] != "page-size" {
|
||||
t.Fatalf("--limit aliases = %#v, want [page-size]", declared)
|
||||
}
|
||||
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tt.shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
flag := cmd.Flags().Lookup("page-size")
|
||||
if flag == nil {
|
||||
t.Fatal("flag --page-size missing")
|
||||
}
|
||||
if !flag.Hidden {
|
||||
t.Fatal("flag --page-size must be hidden")
|
||||
if flag == nil || flag.Name != "limit" {
|
||||
t.Fatalf("Lookup(page-size) = %#v, want canonical --limit", flag)
|
||||
}
|
||||
if strings.Contains(cmd.Flags().FlagUsages(), "--page-size") {
|
||||
t.Fatalf("help should not include hidden --page-size:\n%s", cmd.Flags().FlagUsages())
|
||||
t.Fatalf("help should not list alias --page-size:\n%s", cmd.Flags().FlagUsages())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1454,47 +1462,6 @@ func TestBasePaginationValidationRejectsOutOfRange(t *testing.T) {
|
||||
),
|
||||
param: "--limit",
|
||||
},
|
||||
{
|
||||
name: "table list page-size alias",
|
||||
shortcut: BaseTableList,
|
||||
runtime: newBaseTestRuntime(map[string]string{"base-token": "b"}, nil, map[string]int{"page-size": 101}),
|
||||
param: "--page-size",
|
||||
},
|
||||
{
|
||||
name: "field list page-size alias",
|
||||
shortcut: BaseFieldList,
|
||||
runtime: newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, map[string]int{"page-size": 201}),
|
||||
param: "--page-size",
|
||||
},
|
||||
{
|
||||
name: "field search options page-size alias",
|
||||
shortcut: BaseFieldSearchOptions,
|
||||
runtime: newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "field-id": "fld_1"}, nil, map[string]int{"page-size": 201}),
|
||||
param: "--page-size",
|
||||
},
|
||||
{
|
||||
name: "view list page-size alias",
|
||||
shortcut: BaseViewList,
|
||||
runtime: newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, map[string]int{"page-size": 201}),
|
||||
param: "--page-size",
|
||||
},
|
||||
{
|
||||
name: "record list page-size alias",
|
||||
shortcut: BaseRecordList,
|
||||
runtime: newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, map[string]int{"page-size": 0}),
|
||||
param: "--page-size",
|
||||
},
|
||||
{
|
||||
name: "record search page-size alias",
|
||||
shortcut: BaseRecordSearch,
|
||||
runtime: newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
|
||||
map[string][]string{"search-field": {"Name"}},
|
||||
nil,
|
||||
map[string]int{"page-size": 201},
|
||||
),
|
||||
param: "--page-size",
|
||||
},
|
||||
{
|
||||
name: "form list",
|
||||
shortcut: BaseFormsList,
|
||||
@@ -1536,53 +1503,6 @@ func TestBasePaginationValidationRejectsOutOfRange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseLimitPageSizeAliasRejectsConflict(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
runtime *common.RuntimeContext
|
||||
}{
|
||||
{
|
||||
name: "table list",
|
||||
shortcut: BaseTableList,
|
||||
runtime: newBaseTestRuntime(map[string]string{"base-token": "b"}, nil, map[string]int{"limit": 50, "page-size": 50}),
|
||||
},
|
||||
{
|
||||
name: "record search",
|
||||
shortcut: BaseRecordSearch,
|
||||
runtime: newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
|
||||
map[string][]string{"search-field": {"Name"}},
|
||||
nil,
|
||||
map[string]int{"limit": 10, "page-size": 10},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.shortcut.Validate == nil {
|
||||
t.Fatalf("%s missing Validate", tt.shortcut.Command)
|
||||
}
|
||||
err := tt.shortcut.Validate(ctx, tt.runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "--page-size" {
|
||||
t.Fatalf("param=%q, want --page-size", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(validationErr.Message, "mutually exclusive") {
|
||||
t.Fatalf("message=%q, want mutually exclusive", validationErr.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseViewValidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if err := BaseViewCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"name":"Main"}`}, nil, nil)); err != nil {
|
||||
|
||||
@@ -20,22 +20,11 @@ var BaseFieldList = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
|
||||
pageSizeLimitAliasFlag(),
|
||||
{Name: "limit", Aliases: []string{"page-size"}, Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateLimitPageSizeAlias(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "limit", 100, 1, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Changed("page-size") {
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", 100, 1, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
_, err := common.ValidatePageSizeTyped(runtime, "limit", 100, 1, 200)
|
||||
return err
|
||||
},
|
||||
DryRun: dryRunFieldList,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -19,7 +19,7 @@ func dryRunFieldList(_ context.Context, runtime *common.RuntimeContext) *common.
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
limit := getPaginationLimit(runtime)
|
||||
limit := runtime.Int("limit")
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields").
|
||||
Params(map[string]interface{}{"offset": offset, "limit": limit}).
|
||||
@@ -73,7 +73,7 @@ func dryRunFieldDelete(_ context.Context, runtime *common.RuntimeContext) *commo
|
||||
}
|
||||
|
||||
func dryRunFieldSearchOptions(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
limit := getPaginationLimit(runtime)
|
||||
limit := runtime.Int("limit")
|
||||
params := map[string]interface{}{
|
||||
"offset": runtime.Int("offset"),
|
||||
"limit": limit,
|
||||
@@ -132,7 +132,7 @@ func executeFieldList(runtime *common.RuntimeContext) error {
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
limit := getPaginationLimit(runtime)
|
||||
limit := runtime.Int("limit")
|
||||
fields, total, err := listAllFields(runtime, runtime.Str("base-token"), baseTableID(runtime), offset, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -315,7 +315,7 @@ func executeFieldSearchOptions(runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
tableIDValue := baseTableID(runtime)
|
||||
fieldRef := runtime.Str("field-id")
|
||||
limit := getPaginationLimit(runtime)
|
||||
limit := runtime.Int("limit")
|
||||
params := map[string]interface{}{
|
||||
"offset": runtime.Int("offset"),
|
||||
"limit": limit,
|
||||
|
||||
@@ -22,26 +22,15 @@ var BaseFieldSearchOptions = common.Shortcut{
|
||||
fieldRefFlag(true),
|
||||
{Name: "keyword", Desc: "keyword for option query"},
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Type: "int", Default: "30", Desc: "pagination size, range 1-200"},
|
||||
pageSizeLimitAliasFlag(),
|
||||
{Name: "limit", Aliases: []string{"page-size"}, Type: "int", Default: "30", Desc: "pagination size, range 1-200"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +field-search-options --base-token <base_token> --table-id <table_id> --field-id "Status" --keyword "Do"`,
|
||||
"Use only for select fields, whether multiple is false or true.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateLimitPageSizeAlias(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "limit", 30, 1, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Changed("page-size") {
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", 30, 1, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
_, err := common.ValidatePageSizeTyped(runtime, "limit", 30, 1, 200)
|
||||
return err
|
||||
},
|
||||
DryRun: dryRunFieldSearchOptions,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -27,8 +27,7 @@ var BaseRecordList = common.Shortcut{
|
||||
recordFilterFlag(),
|
||||
recordSortFlag(),
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
|
||||
pageSizeLimitAliasFlag(),
|
||||
{Name: "limit", Aliases: []string{"page-size"}, Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
|
||||
recordReadFormatFlag(),
|
||||
},
|
||||
Tips: []string{
|
||||
@@ -48,17 +47,9 @@ var BaseRecordList = common.Shortcut{
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateLimitPageSizeAlias(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "limit", 100, 1, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Changed("page-size") {
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", 100, 1, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := recordProjectionFields(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
limit := getPaginationLimit(runtime)
|
||||
limit := runtime.Int("limit")
|
||||
params := url.Values{}
|
||||
params.Set("offset", strconv.Itoa(offset))
|
||||
params.Set("limit", strconv.Itoa(limit))
|
||||
@@ -522,7 +522,7 @@ func executeRecordList(runtime *common.RuntimeContext) error {
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
limit := getPaginationLimit(runtime)
|
||||
limit := runtime.Int("limit")
|
||||
params := map[string]interface{}{"offset": offset, "limit": limit}
|
||||
fields, err := recordProjectionFields(runtime)
|
||||
if err != nil {
|
||||
|
||||
@@ -190,7 +190,7 @@ func recordSearchFlagBody(runtime *common.RuntimeContext) (map[string]interface{
|
||||
offset = 0
|
||||
}
|
||||
body["offset"] = offset
|
||||
body["limit"] = getPaginationLimit(runtime)
|
||||
body["limit"] = runtime.Int("limit")
|
||||
return body, applyRecordQueryToBody(runtime, body)
|
||||
}
|
||||
|
||||
@@ -260,17 +260,9 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
|
||||
if len(runtime.StrArray("search-field")) == 0 {
|
||||
return baseFlagErrorf("--search-field is required unless --json is used")
|
||||
}
|
||||
if err := validateLimitPageSizeAlias(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "limit", 10, 1, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Changed("page-size") {
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", 10, 1, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := recordSearchProjectionFields(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -287,7 +279,6 @@ func recordSearchJSONExclusiveFlagParams(runtime *common.RuntimeContext) []strin
|
||||
"view-id",
|
||||
"offset",
|
||||
"limit",
|
||||
"page-size",
|
||||
}
|
||||
params := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
|
||||
@@ -30,8 +30,7 @@ var BaseRecordSearch = common.Shortcut{
|
||||
recordFilterFlag(),
|
||||
recordSortFlag(),
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Type: "int", Default: "10", Desc: "pagination size, range 1-200"},
|
||||
pageSizeLimitAliasFlag(),
|
||||
{Name: "limit", Aliases: []string{"page-size"}, Type: "int", Default: "10", Desc: "pagination size, range 1-200"},
|
||||
recordReadFormatFlag(),
|
||||
},
|
||||
Tips: []string{
|
||||
|
||||
@@ -19,22 +19,11 @@ var BaseTableList = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Type: "int", Default: "50", Desc: "pagination size, range 1-100"},
|
||||
pageSizeLimitAliasFlag(),
|
||||
{Name: "limit", Aliases: []string{"page-size"}, Type: "int", Default: "50", Desc: "pagination size, range 1-100"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateLimitPageSizeAlias(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "limit", 50, 1, 100); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Changed("page-size") {
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", 50, 1, 100); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
_, err := common.ValidatePageSizeTyped(runtime, "limit", 50, 1, 100)
|
||||
return err
|
||||
},
|
||||
DryRun: dryRunTableList,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -15,7 +15,7 @@ func dryRunTableList(_ context.Context, runtime *common.RuntimeContext) *common.
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
limit := getPaginationLimit(runtime)
|
||||
limit := runtime.Int("limit")
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables").
|
||||
Params(map[string]interface{}{"offset": offset, "limit": limit}).
|
||||
@@ -62,7 +62,7 @@ func executeTableList(runtime *common.RuntimeContext) error {
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
limit := getPaginationLimit(runtime)
|
||||
limit := runtime.Int("limit")
|
||||
tables, total, err := listAllTables(runtime, runtime.Str("base-token"), offset, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -20,22 +20,11 @@ var BaseViewList = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
|
||||
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
|
||||
pageSizeLimitAliasFlag(),
|
||||
{Name: "limit", Aliases: []string{"page-size"}, Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateLimitPageSizeAlias(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "limit", 100, 1, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Changed("page-size") {
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", 100, 1, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
_, err := common.ValidatePageSizeTyped(runtime, "limit", 100, 1, 200)
|
||||
return err
|
||||
},
|
||||
DryRun: dryRunViewList,
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -23,7 +23,7 @@ func dryRunViewList(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
limit := getPaginationLimit(runtime)
|
||||
limit := runtime.Int("limit")
|
||||
return dryRunViewBase(runtime).
|
||||
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/views").
|
||||
Params(map[string]interface{}{"offset": offset, "limit": limit})
|
||||
@@ -154,7 +154,7 @@ func executeViewList(runtime *common.RuntimeContext) error {
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
limit := getPaginationLimit(runtime)
|
||||
limit := runtime.Int("limit")
|
||||
views, total, err := listAllViews(runtime, runtime.Str("base-token"), baseTableID(runtime), offset, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
29
shortcuts/common/flag_aliases.go
Normal file
29
shortcuts/common/flag_aliases.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/internal/flagalias"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// installFlagAliases makes declarative Flag.Aliases parse-time synonyms for
|
||||
// their canonical flag. Only the canonical pflag is registered, so aliases
|
||||
// automatically share its type, default, enum, required, input, help, and
|
||||
// schema contracts. Downstream code therefore reads only the canonical name.
|
||||
//
|
||||
// Aliases use the canonical flag type's normal repeated-flag semantics. For
|
||||
// scalar flags, the last canonical/alias occurrence wins; collection flags
|
||||
// retain pflag's accumulation behavior. Value-transforming compatibility
|
||||
// inputs are not aliases and use the framework Normalize phase instead.
|
||||
func installFlagAliases(cmd *cobra.Command, flags []Flag) {
|
||||
specs := make([]flagalias.Spec, 0)
|
||||
for _, flag := range flags {
|
||||
if len(flag.Aliases) == 0 {
|
||||
continue
|
||||
}
|
||||
specs = append(specs, flagalias.Spec{Canonical: flag.Name, Aliases: flag.Aliases})
|
||||
}
|
||||
flagalias.MustBind(cmd, specs)
|
||||
}
|
||||
122
shortcuts/common/flag_context.go
Normal file
122
shortcuts/common/flag_context.go
Normal file
@@ -0,0 +1,122 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
// FlagNormalizer lets a business domain canonicalize compatibility inputs whose
|
||||
// value grammar or semantics differ from the canonical flag. Exact name
|
||||
// synonyms belong in Flag.Aliases and must not use this hook.
|
||||
type FlagNormalizer func(context.Context, *FlagContext) error
|
||||
|
||||
// ChainNormalizers composes independent business adapters into one ordered
|
||||
// Shortcut.Normalize hook. Nil stages are ignored and the first error stops
|
||||
// the chain.
|
||||
func ChainNormalizers(normalizers ...FlagNormalizer) FlagNormalizer {
|
||||
active := make([]FlagNormalizer, 0, len(normalizers))
|
||||
for _, normalize := range normalizers {
|
||||
if normalize != nil {
|
||||
active = append(active, normalize)
|
||||
}
|
||||
}
|
||||
if len(active) == 0 {
|
||||
return nil
|
||||
}
|
||||
return func(ctx context.Context, flags *FlagContext) error {
|
||||
for _, normalize := range active {
|
||||
if err := normalize(ctx, flags); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// FlagContext is the deliberately narrow context exposed to Shortcut.Normalize.
|
||||
// Normalize runs after pflag/Cobra structural validation and @file/stdin
|
||||
// resolution, but before canonical flag validation. It may inspect accepted
|
||||
// inputs and populate canonical flags; it does not expose identity, config, API
|
||||
// clients, or execution logic.
|
||||
type FlagContext struct {
|
||||
runtime *RuntimeContext
|
||||
}
|
||||
|
||||
// FlagContext returns the business-normalization view of runtime. It is
|
||||
// primarily useful to tests and adapters that invoke a shortcut normalizer
|
||||
// directly; normal command execution constructs the same view automatically.
|
||||
func (ctx *RuntimeContext) FlagContext() *FlagContext {
|
||||
return &FlagContext{runtime: ctx}
|
||||
}
|
||||
|
||||
// Str returns a string flag value.
|
||||
func (ctx *FlagContext) Str(name string) string { return ctx.runtime.Str(name) }
|
||||
|
||||
// Bool returns a bool flag value.
|
||||
func (ctx *FlagContext) Bool(name string) bool { return ctx.runtime.Bool(name) }
|
||||
|
||||
// Int returns an int flag value.
|
||||
func (ctx *FlagContext) Int(name string) int { return ctx.runtime.Int(name) }
|
||||
|
||||
// Float64 returns a float64 flag value.
|
||||
func (ctx *FlagContext) Float64(name string) float64 { return ctx.runtime.Float64(name) }
|
||||
|
||||
// IntArray returns an int-slice flag value.
|
||||
func (ctx *FlagContext) IntArray(name string) []int { return ctx.runtime.IntArray(name) }
|
||||
|
||||
// StrArray returns a repeated string-array flag value.
|
||||
func (ctx *FlagContext) StrArray(name string) []string { return ctx.runtime.StrArray(name) }
|
||||
|
||||
// StrSlice returns a CSV-aware string-slice flag value.
|
||||
func (ctx *FlagContext) StrSlice(name string) []string { return ctx.runtime.StrSlice(name) }
|
||||
|
||||
// Changed reports whether a spelling has populated this flag in the effective
|
||||
// parse state. Before SetCanonical is called, it distinguishes direct canonical
|
||||
// input from a legacy compatibility flag. SetCanonical then marks the canonical
|
||||
// flag changed so every downstream execution phase sees one state.
|
||||
func (ctx *FlagContext) Changed(name string) bool { return ctx.runtime.Changed(name) }
|
||||
|
||||
// SetCanonical writes a normalized value to a registered canonical flag. It
|
||||
// uses FlagSet.Set rather than Value.Set intentionally so the canonical flag is
|
||||
// marked changed and becomes the single effective input observed by Validate,
|
||||
// DryRun, and Execute.
|
||||
func (ctx *FlagContext) SetCanonical(name, value string) error {
|
||||
return ctx.SetCanonicalFrom("", name, value)
|
||||
}
|
||||
|
||||
// SetCanonicalFrom is SetCanonical with the source spelling used for immediate
|
||||
// conversion-error attribution. The source is not persisted: downstream
|
||||
// business validation can inspect the original compatibility flag's Changed
|
||||
// state when it needs to name the caller's input.
|
||||
func (ctx *FlagContext) SetCanonicalFrom(source, name, value string) error {
|
||||
if ctx == nil || ctx.runtime == nil || ctx.runtime.Cmd == nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "cannot set canonical flag --%s: flag context is not initialized", name)
|
||||
}
|
||||
if ctx.runtime.Cmd.Flags().Lookup(name) == nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "cannot set canonical flag --%s: flag is not registered", name)
|
||||
}
|
||||
if err := ctx.runtime.Cmd.Flags().Set(name, value); err != nil {
|
||||
param := name
|
||||
if source != "" {
|
||||
param = source
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot normalize --%s into --%s: %v", param, name, err).
|
||||
WithParam("--" + param).
|
||||
WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FileIO returns the command's file provider for compatibility inputs that
|
||||
// need to interpret legacy file syntax after the framework resolves Flag.Input.
|
||||
func (ctx *FlagContext) FileIO() fileio.FileIO {
|
||||
if ctx == nil || ctx.runtime == nil {
|
||||
return nil
|
||||
}
|
||||
return ctx.runtime.FileIO()
|
||||
}
|
||||
104
shortcuts/common/page_all_flags.go
Normal file
104
shortcuts/common/page_all_flags.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
const (
|
||||
PageAllFlagName = "page-all"
|
||||
|
||||
pageLimitFlagName = "page-limit"
|
||||
pageLimitDefault = 10
|
||||
pageLimitMaximum = 1000
|
||||
|
||||
pageDelayFlagName = "page-delay"
|
||||
pageDelayDefault = 200
|
||||
pageDelayMaximum = 60_000
|
||||
)
|
||||
|
||||
// PageAllFlags returns the shared pagination control definitions.
|
||||
// Each call returns a fresh slice so shortcuts cannot mutate each other.
|
||||
func PageAllFlags() []Flag {
|
||||
return []Flag{
|
||||
{
|
||||
Name: PageAllFlagName,
|
||||
Type: "bool",
|
||||
Desc: "automatically paginate until exhaustion or --page-limit",
|
||||
},
|
||||
{
|
||||
Name: pageLimitFlagName,
|
||||
Type: "int",
|
||||
Default: fmt.Sprintf("%d", pageLimitDefault),
|
||||
Desc: fmt.Sprintf("maximum pages fetched by --page-all (%d-%d)", 1, pageLimitMaximum),
|
||||
},
|
||||
{
|
||||
Name: pageDelayFlagName,
|
||||
Type: "int",
|
||||
Default: fmt.Sprintf("%d", pageDelayDefault),
|
||||
Desc: fmt.Sprintf("delay in milliseconds between pages with --page-all (%d-%d; 0 disables throttling)",
|
||||
0, pageDelayMaximum),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ValidatePageAllFlags validates the shared page budget and inter-page delay.
|
||||
// PaginateInto repeats this check defensively for callers that invoke Execute
|
||||
// directly in tests.
|
||||
func ValidatePageAllFlags(runtime *RuntimeContext) error {
|
||||
_, err := pageAllValues(runtime)
|
||||
return err
|
||||
}
|
||||
|
||||
type pageAllConfig struct {
|
||||
enabled bool
|
||||
maxPages int
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func pageAllValues(runtime *RuntimeContext) (pageAllConfig, error) {
|
||||
if runtime == nil || runtime.Cmd == nil {
|
||||
return pageAllConfig{}, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"pagination requires a mounted shortcut command")
|
||||
}
|
||||
flags := runtime.Cmd.Flags()
|
||||
if flags.Lookup(PageAllFlagName) == nil || flags.Lookup(pageLimitFlagName) == nil || flags.Lookup(pageDelayFlagName) == nil {
|
||||
return pageAllConfig{}, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"pagination flags are not registered; append common.PageAllFlags() to the shortcut flags")
|
||||
}
|
||||
enabled, err := flags.GetBool(PageAllFlagName)
|
||||
if err != nil {
|
||||
return pageAllConfig{}, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"read pagination flag --%s: %v", PageAllFlagName, err).WithCause(err)
|
||||
}
|
||||
limit, err := flags.GetInt(pageLimitFlagName)
|
||||
if err != nil {
|
||||
return pageAllConfig{}, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"read pagination flag --%s: %v", pageLimitFlagName, err).WithCause(err)
|
||||
}
|
||||
if limit < 1 || limit > pageLimitMaximum {
|
||||
return pageAllConfig{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--%s must be an integer between 1 and %d", pageLimitFlagName, pageLimitMaximum).
|
||||
WithParam("--" + pageLimitFlagName)
|
||||
}
|
||||
delayMillis, err := flags.GetInt(pageDelayFlagName)
|
||||
if err != nil {
|
||||
return pageAllConfig{}, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"read pagination flag --%s: %v", pageDelayFlagName, err).WithCause(err)
|
||||
}
|
||||
if delayMillis < 0 || delayMillis > pageDelayMaximum {
|
||||
return pageAllConfig{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--%s must be an integer between 0 and %d", pageDelayFlagName, pageDelayMaximum).
|
||||
WithParam("--" + pageDelayFlagName)
|
||||
}
|
||||
return pageAllConfig{
|
||||
enabled: enabled,
|
||||
maxPages: limit,
|
||||
delay: time.Duration(delayMillis) * time.Millisecond,
|
||||
}, nil
|
||||
}
|
||||
239
shortcuts/common/paginate_into.go
Normal file
239
shortcuts/common/paginate_into.go
Normal file
@@ -0,0 +1,239 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// PageRequest describes one paginated API walk. Pagination controls are not
|
||||
// repeated here: PaginateInto derives the policy from the command's standard
|
||||
// --page-all and --page-limit flags.
|
||||
type PageRequest struct {
|
||||
Method string
|
||||
Path string
|
||||
Params map[string]interface{}
|
||||
Body interface{}
|
||||
}
|
||||
|
||||
// PageAccumulator owns the business-specific meaning of combining pages.
|
||||
// Framework pagination deliberately knows nothing about item field names or
|
||||
// whether non-item fields come from the first, last, or every page.
|
||||
type PageAccumulator[T any] interface {
|
||||
AddPage(T) error
|
||||
}
|
||||
|
||||
// PaginateInto walks an endpoint and decodes each successful data object into
|
||||
// T before handing it to dst. A normal invocation and --page-all use the same
|
||||
// path: the former has a one-page policy, while the latter uses --page-limit.
|
||||
// An explicit --page-token is only the starting cursor and never changes that
|
||||
// policy. Multi-page runs wait --page-delay between successful page requests;
|
||||
// the wait is context-aware and never occurs before page 1 or after the final
|
||||
// page.
|
||||
//
|
||||
// The returned metadata describes the fetch stage. Callers that apply global
|
||||
// filters or enrichment should set Items to the final emitted record count.
|
||||
// Keeping the typed-page boundary here also keeps shortcut call sites stable
|
||||
// when the transport supplies a response-native decode method.
|
||||
func PaginateInto[T any](runtime *RuntimeContext, request PageRequest, dst PageAccumulator[T]) (*output.PaginationMeta, error) {
|
||||
return paginateInto(runtime, request, dst, waitPageDelay)
|
||||
}
|
||||
|
||||
type pageDelayWaiter func(context.Context, time.Duration) error
|
||||
|
||||
func paginateInto[T any](runtime *RuntimeContext, request PageRequest, dst PageAccumulator[T], wait pageDelayWaiter) (*output.PaginationMeta, error) {
|
||||
meta := &output.PaginationMeta{}
|
||||
policy, err := resolvePaginationPolicy(runtime)
|
||||
if err != nil {
|
||||
return meta, err
|
||||
}
|
||||
|
||||
pageToken := pageTokenParam(request.Params)
|
||||
seen := make(map[string]struct{})
|
||||
if pageToken != "" {
|
||||
seen[pageToken] = struct{}{}
|
||||
}
|
||||
|
||||
// maxPages is always in [1, pageLimitMaximum]. Keeping the bound in the
|
||||
// loop statement makes finite execution a structural invariant, independent
|
||||
// of cursor quality and of any future exit-condition changes below.
|
||||
for pageNumber := 1; pageNumber <= policy.maxPages; pageNumber++ {
|
||||
params := clonePageParams(request.Params)
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
if policy.showProgress {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "[page %d] fetching...\n", pageNumber)
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(request.Method, request.Path, params, request.Body)
|
||||
if err != nil {
|
||||
meta.NextToken = pageToken
|
||||
return meta, err
|
||||
}
|
||||
page, err := decodePageData[T](data, pageNumber)
|
||||
if err != nil {
|
||||
meta.NextToken = pageToken
|
||||
return meta, err
|
||||
}
|
||||
if err := dst.AddPage(page); err != nil {
|
||||
meta.NextToken = pageToken
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return meta, err
|
||||
}
|
||||
return meta, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"accumulate pagination page %d: %v", pageNumber, err).
|
||||
WithCause(err)
|
||||
}
|
||||
meta.Pages++
|
||||
|
||||
hasMore, nextPageToken := PaginationMeta(data)
|
||||
if !hasMore {
|
||||
meta.Complete = true
|
||||
meta.NextToken = ""
|
||||
return meta, nil
|
||||
}
|
||||
if nextPageToken == "" {
|
||||
return meta, invalidPageCursor("response reports more pages but returned no page token")
|
||||
}
|
||||
if _, repeated := seen[nextPageToken]; repeated {
|
||||
return meta, invalidPageCursor("response repeated page token %q, which would paginate forever", nextPageToken)
|
||||
}
|
||||
|
||||
meta.NextToken = nextPageToken
|
||||
if pageNumber == policy.maxPages {
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
seen[nextPageToken] = struct{}{}
|
||||
pageToken = nextPageToken
|
||||
if policy.pageDelay > 0 {
|
||||
ctx := runtime.Ctx()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := wait(ctx, policy.pageDelay); err != nil {
|
||||
return meta, paginationWaitError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return meta, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"pagination exhausted its page budget without producing a terminal result")
|
||||
}
|
||||
|
||||
type paginationPolicy struct {
|
||||
maxPages int
|
||||
pageDelay time.Duration
|
||||
showProgress bool
|
||||
}
|
||||
|
||||
// resolvePaginationPolicy resolves the framework's standard list semantics.
|
||||
// Even a one-page call is a pagination run; --page-all only changes its page
|
||||
// budget and progress presentation.
|
||||
func resolvePaginationPolicy(runtime *RuntimeContext) (paginationPolicy, error) {
|
||||
config, err := pageAllValues(runtime)
|
||||
if err != nil {
|
||||
return paginationPolicy{}, err
|
||||
}
|
||||
if !config.enabled {
|
||||
return paginationPolicy{maxPages: 1}, nil
|
||||
}
|
||||
return paginationPolicy{
|
||||
maxPages: config.maxPages,
|
||||
pageDelay: config.delay,
|
||||
showProgress: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func waitPageDelay(ctx context.Context, delay time.Duration) error {
|
||||
if delay <= 0 {
|
||||
return nil
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func paginationWaitError(err error) error {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
subtype := errs.SubtypeNetworkTransport
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
subtype = errs.SubtypeNetworkTimeout
|
||||
}
|
||||
return errs.NewNetworkError(subtype,
|
||||
"pagination interrupted while waiting between pages: %v", err).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
// decodePageData isolates the current map-returning RuntimeContext boundary.
|
||||
// A response-native decoder can replace this adapter without changing either
|
||||
// PaginateInto's public contract or any shortcut accumulator.
|
||||
func decodePageData[T any](data map[string]interface{}, pageNumber int) (T, error) {
|
||||
var page T
|
||||
if data == nil {
|
||||
return page, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"pagination page %d response has no data object", pageNumber)
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return page, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"encode pagination page %d for typed decoding: %v", pageNumber, err).
|
||||
WithCause(err)
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&page); err != nil {
|
||||
return page, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"decode pagination page %d: %v", pageNumber, err).
|
||||
WithCause(err)
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func clonePageParams(params map[string]interface{}) map[string]interface{} {
|
||||
cloned := make(map[string]interface{}, len(params)+1)
|
||||
for name, value := range params {
|
||||
cloned[name] = value
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func pageTokenParam(params map[string]interface{}) string {
|
||||
switch value := params["page_token"].(type) {
|
||||
case string:
|
||||
return value
|
||||
case []string:
|
||||
if len(value) > 0 {
|
||||
return value[0]
|
||||
}
|
||||
case []interface{}:
|
||||
if len(value) > 0 {
|
||||
pageToken, _ := value[0].(string)
|
||||
return pageToken
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func invalidPageCursor(format string, args ...interface{}) error {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...).
|
||||
WithHint("re-run without --page-all, or report the endpoint: its pagination cursor is inconsistent")
|
||||
}
|
||||
421
shortcuts/common/paginate_into_test.go
Normal file
421
shortcuts/common/paginate_into_test.go
Normal file
@@ -0,0 +1,421 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type paginateIntoTestPage struct {
|
||||
Items []string `json:"items"`
|
||||
HasMore bool `json:"has_more"`
|
||||
PageToken string `json:"page_token"`
|
||||
}
|
||||
|
||||
type paginateIntoTestResult struct {
|
||||
items []string
|
||||
hasMore bool
|
||||
pageToken string
|
||||
pages int
|
||||
}
|
||||
|
||||
func (result *paginateIntoTestResult) AddPage(page paginateIntoTestPage) error {
|
||||
result.items = append(result.items, page.Items...)
|
||||
result.hasMore = page.HasMore
|
||||
result.pageToken = page.PageToken
|
||||
result.pages++
|
||||
return nil
|
||||
}
|
||||
|
||||
func newPaginateIntoTestRuntime(t *testing.T, flags map[string]string) (*RuntimeContext, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
config := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"}
|
||||
factory, _, stderr, registry := cmdutil.TestFactory(t, config)
|
||||
cmd := &cobra.Command{Use: "+list"}
|
||||
cmd.Flags().Bool("page-all", false, "")
|
||||
cmd.Flags().Int("page-limit", 10, "")
|
||||
cmd.Flags().Int("page-delay", pageDelayDefault, "")
|
||||
for name, value := range flags {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s=%s: %v", name, value, err)
|
||||
}
|
||||
}
|
||||
runtime := TestNewRuntimeContextForAPI(context.Background(), cmd, config, factory, core.AsUser)
|
||||
return runtime, stderr, registry
|
||||
}
|
||||
|
||||
func TestPageAllFlagsContract(t *testing.T) {
|
||||
flags := PageAllFlags()
|
||||
if len(flags) != 3 {
|
||||
t.Fatalf("PageAllFlags() returned %d flags, want 3", len(flags))
|
||||
}
|
||||
if got := flags[0]; got.Name != PageAllFlagName || got.Type != "bool" || got.Default != "" {
|
||||
t.Fatalf("page-all flag = %#v", got)
|
||||
}
|
||||
if got := flags[1]; got.Name != pageLimitFlagName || got.Type != "int" || got.Default != strconv.Itoa(pageLimitDefault) || !strings.Contains(got.Desc, strconv.Itoa(pageLimitMaximum)) {
|
||||
t.Fatalf("page-limit flag = %#v", got)
|
||||
}
|
||||
if got := flags[2]; got.Name != pageDelayFlagName || got.Type != "int" || got.Default != strconv.Itoa(pageDelayDefault) || !strings.Contains(got.Desc, strconv.Itoa(pageDelayMaximum)) {
|
||||
t.Fatalf("page-delay flag = %#v", got)
|
||||
}
|
||||
|
||||
flags[0].Desc = "mutated"
|
||||
if PageAllFlags()[0].Desc == "mutated" {
|
||||
t.Fatal("PageAllFlags() reused mutable definitions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateIntoDecodesAndAccumulatesPages(t *testing.T) {
|
||||
runtime, stderr, registry := newPaginateIntoTestRuntime(t, map[string]string{"page-all": "true", "page-delay": "0"})
|
||||
var requestTokens []string
|
||||
for _, data := range []map[string]interface{}{
|
||||
{"items": []string{"first"}, "has_more": true, "page_token": "next"},
|
||||
{"items": []string{"second"}, "has_more": false, "page_token": "final"},
|
||||
} {
|
||||
registry.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{"code": 0, "data": data},
|
||||
OnMatch: func(request *http.Request) {
|
||||
requestTokens = append(requestTokens, request.URL.Query().Get("page_token"))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
params := map[string]interface{}{"page_size": 20}
|
||||
result := &paginateIntoTestResult{}
|
||||
meta, err := PaginateInto(runtime, PageRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/open-apis/test/v1/items",
|
||||
Params: params,
|
||||
}, result)
|
||||
if err != nil {
|
||||
t.Fatalf("PaginateInto() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(result.items, []string{"first", "second"}) {
|
||||
t.Fatalf("items = %v, want [first second]", result.items)
|
||||
}
|
||||
if result.pages != 2 || result.hasMore || result.pageToken != "final" {
|
||||
t.Fatalf("result meta = pages:%d has_more:%v page_token:%q", result.pages, result.hasMore, result.pageToken)
|
||||
}
|
||||
if !meta.Complete || meta.Pages != 2 || meta.NextToken != "" {
|
||||
t.Fatalf("pagination meta = %+v, want complete two-page run", meta)
|
||||
}
|
||||
if !reflect.DeepEqual(requestTokens, []string{"", "next"}) {
|
||||
t.Fatalf("request page tokens = %v, want [\"\" \"next\"]", requestTokens)
|
||||
}
|
||||
if _, mutated := params["page_token"]; mutated {
|
||||
t.Fatalf("PaginateInto mutated caller params: %#v", params)
|
||||
}
|
||||
for _, want := range []string{"[page 1] fetching...", "[page 2] fetching..."} {
|
||||
if !strings.Contains(stderr.String(), want) {
|
||||
t.Fatalf("stderr = %q, want %q", stderr.String(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateIntoWaitsOnlyBetweenPages(t *testing.T) {
|
||||
runtime, _, registry := newPaginateIntoTestRuntime(t, map[string]string{"page-all": "true"})
|
||||
for _, data := range []map[string]interface{}{
|
||||
{"items": []string{"first"}, "has_more": true, "page_token": "next"},
|
||||
{"items": []string{"second"}, "has_more": false},
|
||||
} {
|
||||
registry.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{"code": 0, "data": data},
|
||||
})
|
||||
}
|
||||
|
||||
var waits []time.Duration
|
||||
meta, err := paginateInto(runtime, PageRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/open-apis/test/v1/items",
|
||||
}, &paginateIntoTestResult{}, func(_ context.Context, delay time.Duration) error {
|
||||
waits = append(waits, delay)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("paginateInto() error = %v", err)
|
||||
}
|
||||
if !meta.Complete || meta.Pages != 2 {
|
||||
t.Fatalf("pagination meta = %+v, want complete two-page run", meta)
|
||||
}
|
||||
if !reflect.DeepEqual(waits, []time.Duration{pageDelayDefault * time.Millisecond}) {
|
||||
t.Fatalf("page waits = %v, want one %s wait", waits, pageDelayDefault*time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateIntoDelayCancellationIsTypedAndResumable(t *testing.T) {
|
||||
runtime, _, registry := newPaginateIntoTestRuntime(t, map[string]string{"page-all": "true"})
|
||||
registry.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []string{"first"},
|
||||
"has_more": true,
|
||||
"page_token": "resume",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
meta, err := paginateInto(runtime, PageRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/open-apis/test/v1/items",
|
||||
}, &paginateIntoTestResult{}, func(_ context.Context, _ time.Duration) error {
|
||||
return context.Canceled
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("paginateInto() error = %v, want context.Canceled cause", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("pagination cancellation problem = %#v, %v; want network/transport", problem, ok)
|
||||
}
|
||||
if meta.Pages != 1 || meta.Complete || meta.NextToken != "resume" {
|
||||
t.Fatalf("pagination meta = %+v, want resumable first page", meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitPageDelayHonorsCanceledContextWithoutSleeping(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := waitPageDelay(ctx, time.Hour); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("waitPageDelay() error = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateIntoUsesOnePagePolicyByDefault(t *testing.T) {
|
||||
runtime, stderr, registry := newPaginateIntoTestRuntime(t, nil)
|
||||
registry.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []string{"first"},
|
||||
"has_more": true,
|
||||
"page_token": "next",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result := &paginateIntoTestResult{}
|
||||
meta, err := PaginateInto(runtime, PageRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/open-apis/test/v1/items",
|
||||
}, result)
|
||||
if err != nil {
|
||||
t.Fatalf("PaginateInto() error = %v", err)
|
||||
}
|
||||
if result.pages != 1 || !reflect.DeepEqual(result.items, []string{"first"}) {
|
||||
t.Fatalf("result = %+v, want one accumulated page", result)
|
||||
}
|
||||
if meta.Complete || meta.Pages != 1 || meta.NextToken != "next" {
|
||||
t.Fatalf("pagination meta = %+v, want incomplete one-page run", meta)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("default one-page run wrote progress to stderr: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateIntoStopsAtConfiguredPageLimit(t *testing.T) {
|
||||
runtime, _, registry := newPaginateIntoTestRuntime(t, map[string]string{
|
||||
"page-all": "true",
|
||||
"page-limit": "2",
|
||||
"page-delay": "0",
|
||||
})
|
||||
var calls int
|
||||
for _, data := range []map[string]interface{}{
|
||||
{"items": []string{"first"}, "has_more": true, "page_token": "second"},
|
||||
{"items": []string{"second"}, "has_more": true, "page_token": "resume"},
|
||||
} {
|
||||
registry.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{"code": 0, "data": data},
|
||||
OnMatch: func(_ *http.Request) {
|
||||
calls++
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
result := &paginateIntoTestResult{}
|
||||
meta, err := PaginateInto(runtime, PageRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/open-apis/test/v1/items",
|
||||
}, result)
|
||||
if err != nil {
|
||||
t.Fatalf("PaginateInto() error = %v", err)
|
||||
}
|
||||
if calls != 2 || result.pages != 2 {
|
||||
t.Fatalf("page calls = %d, accumulated pages = %d; want hard stop at 2", calls, result.pages)
|
||||
}
|
||||
if meta.Complete || meta.Pages != 2 || meta.NextToken != "resume" {
|
||||
t.Fatalf("pagination meta = %+v, want incomplete result resumable at %q", meta, "resume")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateIntoContinuesFromExplicitCursorWithPageAll(t *testing.T) {
|
||||
runtime, _, registry := newPaginateIntoTestRuntime(t, map[string]string{"page-all": "true", "page-delay": "0"})
|
||||
var requestTokens []string
|
||||
for _, data := range []map[string]interface{}{
|
||||
{"items": []string{"from-resume"}, "has_more": true, "page_token": "next"},
|
||||
{"items": []string{"after-resume"}, "has_more": false},
|
||||
} {
|
||||
registry.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{"code": 0, "data": data},
|
||||
OnMatch: func(request *http.Request) {
|
||||
requestTokens = append(requestTokens, request.URL.Query().Get("page_token"))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
result := &paginateIntoTestResult{}
|
||||
meta, err := PaginateInto(runtime, PageRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/open-apis/test/v1/items",
|
||||
// SDK request builders represent query values as []string. Pin that
|
||||
// representation here so an explicit resume cursor remains compatible
|
||||
// with both SDK-built and raw map requests.
|
||||
Params: map[string]interface{}{"page_token": []string{"resume"}},
|
||||
}, result)
|
||||
if err != nil {
|
||||
t.Fatalf("PaginateInto() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(requestTokens, []string{"resume", "next"}) {
|
||||
t.Fatalf("request page tokens = %v, want [resume next]", requestTokens)
|
||||
}
|
||||
if !reflect.DeepEqual(result.items, []string{"from-resume", "after-resume"}) || !meta.Complete || meta.Pages != 2 {
|
||||
t.Fatalf("result = %+v meta = %+v", result, meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateIntoRejectsStartingCursorRepeatedByServer(t *testing.T) {
|
||||
runtime, _, registry := newPaginateIntoTestRuntime(t, nil)
|
||||
registry.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []string{"item"},
|
||||
"has_more": true,
|
||||
"page_token": "resume",
|
||||
},
|
||||
},
|
||||
OnMatch: func(request *http.Request) {
|
||||
if token := request.URL.Query().Get("page_token"); token != "resume" {
|
||||
t.Errorf("request page_token = %q, want resume", token)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
_, err := PaginateInto(runtime, PageRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/open-apis/test/v1/items",
|
||||
Params: map[string]interface{}{"page_token": "resume"},
|
||||
}, &paginateIntoTestResult{})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("PaginateInto() error = %v, want typed error", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("problem = (%q, %q), want (%q, %q)",
|
||||
problem.Category, problem.Subtype, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "repeated page token") {
|
||||
t.Fatalf("problem message = %q, want repeated-token diagnosis", problem.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateIntoRejectsPageOutsideTypedContract(t *testing.T) {
|
||||
runtime, _, registry := newPaginateIntoTestRuntime(t, nil)
|
||||
registry.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"unexpected": true}},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := PaginateInto(runtime, PageRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/open-apis/test/v1/items",
|
||||
}, &paginateIntoTestResult{})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("PaginateInto() error = %v, want typed error", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("problem = (%q, %q), want (%q, %q)",
|
||||
problem.Category, problem.Subtype, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "decode pagination page 1") {
|
||||
t.Fatalf("problem message = %q, want page-specific decode diagnosis", problem.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateIntoRejectsPageLimitOutsideSharedBounds(t *testing.T) {
|
||||
for _, limit := range []string{"-1", "0", strconv.Itoa(pageLimitMaximum + 1)} {
|
||||
t.Run(limit, func(t *testing.T) {
|
||||
runtime, _, _ := newPaginateIntoTestRuntime(t, map[string]string{
|
||||
PageAllFlagName: "true",
|
||||
pageLimitFlagName: limit,
|
||||
})
|
||||
|
||||
_, err := PaginateInto(runtime, PageRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/open-apis/test/v1/items",
|
||||
}, &paginateIntoTestResult{})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
var validationErr *errs.ValidationError
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument || !errors.As(err, &validationErr) || validationErr.Param != "--page-limit" {
|
||||
t.Fatalf("PaginateInto() problem = %#v, %v; want invalid --page-limit", problem, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateIntoRejectsPageDelayOutsideSharedBounds(t *testing.T) {
|
||||
for _, delay := range []string{"-1", strconv.Itoa(pageDelayMaximum + 1)} {
|
||||
t.Run(delay, func(t *testing.T) {
|
||||
runtime, _, _ := newPaginateIntoTestRuntime(t, map[string]string{
|
||||
pageDelayFlagName: delay,
|
||||
})
|
||||
|
||||
_, err := PaginateInto(runtime, PageRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: "/open-apis/test/v1/items",
|
||||
}, &paginateIntoTestResult{})
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
var validationErr *errs.ValidationError
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument || !errors.As(err, &validationErr) || validationErr.Param != "--page-delay" {
|
||||
t.Fatalf("PaginateInto() problem = %#v, %v; want invalid --page-delay", problem, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -242,8 +242,10 @@ func (ctx *RuntimeContext) StrSlice(name string) []string {
|
||||
return v
|
||||
}
|
||||
|
||||
// Changed reports whether the user explicitly set the named flag on the
|
||||
// command line, as opposed to the flag carrying its default value.
|
||||
// Changed reports whether parsing or compatibility normalization populated the
|
||||
// named flag, as opposed to the flag carrying only its default value. During a
|
||||
// Normalize hook, check legacy and canonical spellings before SetCanonical to
|
||||
// distinguish which spelling the caller supplied.
|
||||
func (ctx *RuntimeContext) Changed(name string) bool {
|
||||
f := ctx.Cmd.Flags().Lookup(name)
|
||||
if f == nil {
|
||||
@@ -871,10 +873,12 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
|
||||
if shortcut.PostMount != nil {
|
||||
shortcut.PostMount(cmd)
|
||||
}
|
||||
installFlagAliases(cmd, shortcut.Flags)
|
||||
}
|
||||
|
||||
// runShortcut is the execution pipeline for a declarative shortcut.
|
||||
// Each step is a clear phase: identity → config → scopes → context → validate → execute.
|
||||
// Each step is a clear phase: identity → config → scopes → runtime →
|
||||
// canonical validation → execute.
|
||||
func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error {
|
||||
// --print-schema short-circuits everything below: it's pure local
|
||||
// introspection, no identity / scope / network needed. The flag is
|
||||
@@ -900,7 +904,6 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
as, err := resolveShortcutIdentity(cmd, f, s)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -921,12 +924,24 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.Normalize != nil {
|
||||
// Normalize is opt-in and consumes resolved values. Shortcuts without a
|
||||
// normalizer retain the established enum-before-input execution order.
|
||||
if err := resolveInputFlags(rctx, s.Flags); err != nil {
|
||||
return err
|
||||
}
|
||||
flagContext := rctx.FlagContext()
|
||||
if err := s.Normalize(rctx.ctx, flagContext); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := validateEnumFlags(rctx, s.Flags); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := resolveInputFlags(rctx, s.Flags); err != nil {
|
||||
return err
|
||||
if s.Normalize == nil {
|
||||
if err := resolveInputFlags(rctx, s.Flags); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := output.ValidateJqFlags(rctx.JqExpr, "", rctx.Format); err != nil {
|
||||
return err
|
||||
|
||||
200
shortcuts/common/runner_flag_alias_test.go
Normal file
200
shortcuts/common/runner_flag_alias_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func TestShortcutFlagAliasesResolveToCanonicalContract(t *testing.T) {
|
||||
shortcut := Shortcut{
|
||||
Service: "im", Command: "+alias-test", Description: "x",
|
||||
Flags: []Flag{
|
||||
{
|
||||
Name: "order",
|
||||
Aliases: []string{"sort", "sort-order"},
|
||||
Default: "desc",
|
||||
Enum: []string{"asc", "desc"},
|
||||
Required: true,
|
||||
Desc: "message order",
|
||||
},
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
|
||||
cmd := mountTestShortcut(t, shortcut)
|
||||
if cmd.PreRunE != nil || cmd.PreRun != nil {
|
||||
t.Fatal("declarative aliases must not install or take over Cobra PreRun hooks")
|
||||
}
|
||||
if err := cmd.ParseFlags([]string{"--sort-order", "asc"}); err != nil {
|
||||
t.Fatalf("ParseFlags(alias) error = %v", err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("order"); got != "asc" {
|
||||
t.Fatalf("--sort-order resolved order = %q, want asc", got)
|
||||
}
|
||||
if !cmd.Flags().Changed("order") {
|
||||
t.Fatal("alias must mark the canonical flag changed")
|
||||
}
|
||||
if err := cmd.ValidateRequiredFlags(); err != nil {
|
||||
t.Fatalf("alias must satisfy canonical Required contract: %v", err)
|
||||
}
|
||||
if err := validateEnumFlags(&RuntimeContext{Cmd: cmd}, shortcut.Flags); err != nil {
|
||||
t.Fatalf("alias must share canonical Enum contract: %v", err)
|
||||
}
|
||||
|
||||
aliasLookup := cmd.Flags().Lookup("sort-order")
|
||||
if aliasLookup == nil || aliasLookup.Name != "order" {
|
||||
t.Fatalf("Lookup(alias) = %#v, want canonical --order flag", aliasLookup)
|
||||
}
|
||||
if usage := cmd.Flags().FlagUsages(); strings.Contains(usage, "--sort") {
|
||||
t.Fatalf("aliases leaked into help:\n%s", usage)
|
||||
}
|
||||
var registeredAliases []string
|
||||
cmd.Flags().VisitAll(func(flag *pflag.Flag) {
|
||||
if flag.Name == "sort" || flag.Name == "sort-order" {
|
||||
registeredAliases = append(registeredAliases, flag.Name)
|
||||
}
|
||||
})
|
||||
if len(registeredAliases) != 0 {
|
||||
t.Fatalf("aliases were registered as independent flags: %v", registeredAliases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutFlagAliasesUseRepeatedFlagLastWinsSemantics(t *testing.T) {
|
||||
shortcut := Shortcut{
|
||||
Service: "im", Command: "+alias-order", Description: "x",
|
||||
Flags: []Flag{{
|
||||
Name: "order", Aliases: []string{"sort-order"}, Default: "desc",
|
||||
}},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "alias last", args: []string{"--order", "asc", "--sort-order", "desc"}, want: "desc"},
|
||||
{name: "canonical last", args: []string{"--sort-order", "desc", "--order", "asc"}, want: "asc"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cmd := mountTestShortcut(t, shortcut)
|
||||
if err := cmd.ParseFlags(test.args); err != nil {
|
||||
t.Fatalf("ParseFlags(%v) error = %v", test.args, err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("order"); got != test.want {
|
||||
t.Fatalf("order = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutFlagAliasesComposeWithPostMountNormalizer(t *testing.T) {
|
||||
shortcut := Shortcut{
|
||||
Service: "im", Command: "+alias-compose", Description: "x",
|
||||
Flags: []Flag{{
|
||||
Name: "order", Aliases: []string{"sort-order"}, Default: "desc",
|
||||
}},
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
|
||||
})
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
|
||||
cmd := mountTestShortcut(t, shortcut)
|
||||
if err := cmd.ParseFlags([]string{"--sort_order", "asc"}); err != nil {
|
||||
t.Fatalf("composed alias parse error = %v", err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("order"); got != "asc" {
|
||||
t.Fatalf("order = %q, want asc", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutFlagAliasesRejectCollisionsAtMount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flags []Flag
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "canonical collision",
|
||||
flags: []Flag{
|
||||
{Name: "order", Aliases: []string{"query"}},
|
||||
{Name: "query"},
|
||||
},
|
||||
want: "conflicts with registered flag",
|
||||
},
|
||||
{
|
||||
name: "framework flag collision",
|
||||
flags: []Flag{{Name: "order", Aliases: []string{"format"}}},
|
||||
want: "conflicts with registered flag",
|
||||
},
|
||||
{
|
||||
name: "cobra help collision",
|
||||
flags: []Flag{{Name: "order", Aliases: []string{"help"}}},
|
||||
want: "conflicts with registered flag --help",
|
||||
},
|
||||
{
|
||||
name: "ambiguous alias",
|
||||
flags: []Flag{
|
||||
{Name: "order", Aliases: []string{"sort"}},
|
||||
{Name: "field", Aliases: []string{"sort"}},
|
||||
},
|
||||
want: "maps to both",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
recovered := recover()
|
||||
if recovered == nil {
|
||||
t.Fatal("Mount() did not reject alias collision")
|
||||
}
|
||||
if !strings.Contains(fmt.Sprint(recovered), test.want) {
|
||||
t.Fatalf("panic = %q, want %q", recovered, test.want)
|
||||
}
|
||||
}()
|
||||
mountTestShortcut(t, Shortcut{
|
||||
Service: "im", Command: "+alias-collision", Description: "x",
|
||||
Flags: test.flags,
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutFlagAliasesRejectCollisionAfterPostMountNormalization(t *testing.T) {
|
||||
defer func() {
|
||||
recovered := recover()
|
||||
if recovered == nil {
|
||||
t.Fatal("Mount() did not reject normalized alias collision")
|
||||
}
|
||||
if got := fmt.Sprint(recovered); !strings.Contains(got, "conflicts with registered flag --sort-order after normalization") {
|
||||
t.Fatalf("panic = %q", got)
|
||||
}
|
||||
}()
|
||||
|
||||
mountTestShortcut(t, Shortcut{
|
||||
Service: "im", Command: "+alias-normalized-collision", Description: "x",
|
||||
Flags: []Flag{
|
||||
{Name: "order", Aliases: []string{"sort_order"}},
|
||||
{Name: "sort-order"},
|
||||
},
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
|
||||
})
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
})
|
||||
}
|
||||
176
shortcuts/common/runner_normalize_test.go
Normal file
176
shortcuts/common/runner_normalize_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestRunShortcutNormalizesAfterInputAndBeforeCanonicalValidation(t *testing.T) {
|
||||
var phases []string
|
||||
s := &Shortcut{
|
||||
Service: "test",
|
||||
Command: "test-shortcut",
|
||||
AuthTypes: []string{"bot"},
|
||||
Flags: []Flag{
|
||||
{Name: "canonical", Enum: []string{"normalized"}},
|
||||
{Name: "legacy", Input: []string{Stdin}},
|
||||
},
|
||||
Normalize: func(_ context.Context, flags *FlagContext) error {
|
||||
phases = append(phases, "normalize:"+flags.Str("legacy"))
|
||||
return flags.SetCanonical("canonical", "normalized")
|
||||
},
|
||||
Validate: func(_ context.Context, runtime *RuntimeContext) error {
|
||||
phases = append(phases, "validate:"+runtime.Str("canonical"))
|
||||
return nil
|
||||
},
|
||||
Execute: func(_ context.Context, runtime *RuntimeContext) error {
|
||||
phases = append(phases, "execute:"+runtime.Str("canonical"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
factory := newTestFactory()
|
||||
factory.IOStreams.In = strings.NewReader("resolved-input")
|
||||
cmd := newTestShortcutCmd(s, factory)
|
||||
if err := cmd.Flags().Set("legacy", "-"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Flags().Set("as", "bot"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := runShortcut(cmd, factory, s, true); err != nil {
|
||||
t.Fatalf("runShortcut() error = %v", err)
|
||||
}
|
||||
want := "normalize:resolved-input,validate:normalized,execute:normalized"
|
||||
if got := strings.Join(phases, ","); got != want {
|
||||
t.Fatalf("phases = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcutNormalizeFailureStopsCanonicalConsumers(t *testing.T) {
|
||||
s := &Shortcut{
|
||||
Service: "test",
|
||||
Command: "test-shortcut",
|
||||
AuthTypes: []string{"bot"},
|
||||
Normalize: func(context.Context, *FlagContext) error {
|
||||
return ValidationErrorf("legacy compatibility failed").WithParam("--legacy")
|
||||
},
|
||||
Validate: func(context.Context, *RuntimeContext) error {
|
||||
t.Fatal("Validate ran after Normalize failed")
|
||||
return nil
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error {
|
||||
t.Fatal("Execute ran after Normalize failed")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
factory := newTestFactory()
|
||||
cmd := newTestShortcutCmd(s, factory)
|
||||
if err := cmd.Flags().Set("as", "bot"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runShortcut(cmd, factory, s, true); err == nil {
|
||||
t.Fatal("runShortcut() error = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCanonicalFromClassifiesPFlagConversionFailure(t *testing.T) {
|
||||
s := &Shortcut{
|
||||
Service: "test",
|
||||
Command: "test-shortcut",
|
||||
AuthTypes: []string{"bot"},
|
||||
Flags: []Flag{
|
||||
{Name: "canonical", Type: "int"},
|
||||
{Name: "legacy"},
|
||||
},
|
||||
Normalize: func(_ context.Context, flags *FlagContext) error {
|
||||
return flags.SetCanonicalFrom("legacy", "canonical", flags.Str("legacy"))
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
factory := newTestFactory()
|
||||
cmd := newTestShortcutCmd(s, factory)
|
||||
if err := cmd.Flags().Set("legacy", "not-an-int"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Flags().Set("as", "bot"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := runShortcut(cmd, factory, s, true)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T %v, want typed validation error", err, err)
|
||||
}
|
||||
if validationErr.Param != "--legacy" {
|
||||
t.Fatalf("param = %q, want --legacy", validationErr.Param)
|
||||
}
|
||||
if errors.Unwrap(validationErr) == nil {
|
||||
t.Fatal("pflag conversion cause was not preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountedShortcutNormalizeDoesNotExpandCobraPreRun(t *testing.T) {
|
||||
normalizeCalled := false
|
||||
shortcut := Shortcut{
|
||||
Service: "test", Command: "+normalize-required", Description: "x",
|
||||
Flags: []Flag{
|
||||
{Name: "canonical", Required: true},
|
||||
{Name: "legacy", Hidden: true},
|
||||
},
|
||||
Normalize: func(_ context.Context, flags *FlagContext) error {
|
||||
normalizeCalled = true
|
||||
if !flags.Changed("legacy") || flags.Changed("canonical") {
|
||||
return nil
|
||||
}
|
||||
return flags.SetCanonicalFrom("legacy", "canonical", flags.Str("legacy"))
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
cmd := mountTestShortcut(t, shortcut)
|
||||
if err := cmd.ParseFlags([]string{"--legacy", "accepted"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cmd.PreRunE != nil || cmd.PreRun != nil {
|
||||
t.Fatal("Normalize must not install or take over Cobra PreRun hooks")
|
||||
}
|
||||
if err := cmd.ValidateRequiredFlags(); err == nil {
|
||||
t.Fatal("a business Normalize hook must not satisfy Cobra Required")
|
||||
}
|
||||
if normalizeCalled {
|
||||
t.Fatal("Normalize ran before Cobra Required validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChainNormalizersPreservesDeclarationOrderAndStopsOnError(t *testing.T) {
|
||||
var phases []string
|
||||
stop := ValidationErrorf("stop")
|
||||
chain := ChainNormalizers(
|
||||
func(context.Context, *FlagContext) error {
|
||||
phases = append(phases, "first")
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
func(context.Context, *FlagContext) error {
|
||||
phases = append(phases, "second")
|
||||
return stop
|
||||
},
|
||||
func(context.Context, *FlagContext) error {
|
||||
t.Fatal("normalizer ran after an error")
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err := chain(context.Background(), nil); err != stop {
|
||||
t.Fatalf("error = %v, want stop", err)
|
||||
}
|
||||
if got := strings.Join(phases, ","); got != "first,second" {
|
||||
t.Fatalf("phases = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,12 @@ const (
|
||||
|
||||
// Flag describes a CLI flag for a shortcut.
|
||||
type Flag struct {
|
||||
Name string // flag name (e.g. "calendar-id")
|
||||
Type string // "string" (default) | "bool" | "int" | "float64" | "int_array" | "string_array" | "string_slice"
|
||||
Default string // default value as string
|
||||
Desc string // help text
|
||||
Hidden bool // hidden from --help, still readable at runtime
|
||||
Name string // canonical flag name (e.g. "calendar-id")
|
||||
Aliases []string // exact semantic synonyms accepted at parse time; hidden from human help, exported in machine metadata
|
||||
Type string // "string" (default) | "bool" | "int" | "float64" | "int_array" | "string_array" | "string_slice"
|
||||
Default string // default value as string
|
||||
Desc string // help text
|
||||
Hidden bool // hidden from --help, still readable at runtime
|
||||
Required bool
|
||||
Enum []string // allowed values (e.g. ["asc", "desc"]); empty means no constraint
|
||||
Input []string // extra input sources: File (@path), Stdin (-); empty = flag value only
|
||||
@@ -54,9 +55,17 @@ type Shortcut struct {
|
||||
Hidden bool // hide from --help / tab completion (still executable); use when deprecating a command in favor of a replacement
|
||||
|
||||
// Business logic hooks.
|
||||
DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set
|
||||
Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation
|
||||
Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic
|
||||
// Normalize is the business-owned compatibility stage inside shortcut
|
||||
// execution. It runs after Cobra's structural flag checks and framework input
|
||||
// resolution, but before canonical validation.
|
||||
// Use it only when an accepted legacy input has a different value grammar or
|
||||
// meaning. Exact name synonyms belong in Flag.Aliases. Normalize cannot be
|
||||
// used to satisfy a Cobra Required flag; alternatives such as "A or legacy B"
|
||||
// are a business constraint and must be validated as such.
|
||||
Normalize FlagNormalizer
|
||||
DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set
|
||||
Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation
|
||||
Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic
|
||||
|
||||
// OnInvoke, when non-nil, runs from the command's cobra PreRunE — before
|
||||
// cobra validates required flags — so its side effect fires even when the
|
||||
|
||||
@@ -32,6 +32,7 @@ func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlag
|
||||
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().Int("page-limit", 20, "")
|
||||
cmd.Flags().Int("page-delay", 200, "")
|
||||
for name := range stringFlags {
|
||||
if name == "page-limit" {
|
||||
continue
|
||||
@@ -63,15 +64,27 @@ func newChatSearchTestRuntimeContext(t *testing.T, stringFlags map[string]string
|
||||
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().Int("page-size", 20, "")
|
||||
for name := range stringFlags {
|
||||
if name == "page-size" {
|
||||
continue
|
||||
}
|
||||
cmd.Flags().Int("page-limit", 10, "")
|
||||
cmd.Flags().Int("page-delay", 200, "")
|
||||
for _, name := range []string{"query", "search-types", "chat-modes", "member-ids", "sort", "sort-by", "page-token"} {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name := range boolFlags {
|
||||
for name := range stringFlags {
|
||||
if name == "page-size" || name == "page-limit" {
|
||||
continue
|
||||
}
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"is-manager", "disable-search-by-user", "exclude-muted", "page-all", "dry-run"} {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
for name := range boolFlags {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
}
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags() error = %v", err)
|
||||
}
|
||||
@@ -330,7 +343,7 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
"page-size": "0",
|
||||
}, nil)
|
||||
err := ImChatSearch.Validate(context.Background(), runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "--page-size must be an integer between 1 and 100") {
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid --page-size 0: must be between 1 and 100") {
|
||||
t.Fatalf("ImChatSearch.Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
@@ -625,7 +638,7 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
t.Run("ImChatMessageList valid user target", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"user-id": "ou_123",
|
||||
}, nil)
|
||||
}, map[string]bool{"page-all": false})
|
||||
if err := ImChatMessageList.Validate(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("ImChatMessageList.Validate() unexpected error = %v", err)
|
||||
}
|
||||
@@ -688,7 +701,7 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
t.Run("ImThreadsMessagesList valid omt thread", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"thread": "omt_123",
|
||||
}, nil)
|
||||
}, map[string]bool{"page-all": false})
|
||||
if err := ImThreadsMessagesList.Validate(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("ImThreadsMessagesList.Validate() unexpected error = %v", err)
|
||||
}
|
||||
@@ -700,7 +713,7 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
"page-size": "0",
|
||||
}, nil)
|
||||
err := ImMessagesSearch.Validate(context.Background(), runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "--page-size must be an integer between 1 and 50") {
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid --page-size 0: must be between 1 and 50") {
|
||||
t.Fatalf("ImMessagesSearch.Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
@@ -881,7 +894,7 @@ func TestShortcutDryRunShapes(t *testing.T) {
|
||||
t.Run("ImMessagesSearch dry run uses messages search endpoint", func(t *testing.T) {
|
||||
runtime := newMessagesSearchTestRuntimeContext(t, map[string]string{
|
||||
"query": "incident",
|
||||
"page-size": "51",
|
||||
"page-size": "50",
|
||||
"page-token": "next_page",
|
||||
}, nil)
|
||||
got := mustMarshalDryRun(t, ImMessagesSearch.DryRun(context.Background(), runtime))
|
||||
@@ -978,7 +991,7 @@ func TestShortcutDryRunShapes(t *testing.T) {
|
||||
t.Run("ImThreadsMessagesList dry run keeps requested thread params", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"thread": "omt_123",
|
||||
"sort": "desc",
|
||||
"order": "desc",
|
||||
"page-size": "10",
|
||||
}, nil)
|
||||
got := mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), runtime))
|
||||
|
||||
@@ -194,8 +194,8 @@ func TestValidateExplicitMsgType(t *testing.T) {
|
||||
func TestBuildChatMessageListRequest(t *testing.T) {
|
||||
t.Run("valid request", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"sort": "asc",
|
||||
"page-size": "80",
|
||||
"order": "asc",
|
||||
"page-size": "50",
|
||||
"page-token": "next",
|
||||
"start": "2026-03-01T00:00:00+08:00",
|
||||
"end": "2026-03-02T23:59:59+08:00",
|
||||
@@ -245,7 +245,7 @@ func TestBuildChatMessageListRequest(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestChatMessageListOnlyThreadRootMessagesParams(t *testing.T) {
|
||||
got := buildChatMessageListParams("desc", "20", "oc_123")
|
||||
got := buildChatMessageListParams("desc", 20, "oc_123")
|
||||
if vals := got["only_thread_root_messages"]; !reflect.DeepEqual(vals, []string{"true"}) {
|
||||
t.Fatalf("only_thread_root_messages = %#v, want true", vals)
|
||||
}
|
||||
@@ -341,7 +341,7 @@ func TestBuildMessagesSearchRequest(t *testing.T) {
|
||||
"exclude-sender-type": "bot",
|
||||
"start": "2026-03-01T00:00:00+08:00",
|
||||
"end": "2026-03-02T23:59:59+08:00",
|
||||
"page-size": "80",
|
||||
"page-size": "50",
|
||||
"page-token": "next-token",
|
||||
}, map[string]bool{
|
||||
"at-all": true,
|
||||
@@ -435,7 +435,7 @@ func TestBuildSearchChatBodyAdditionalBranches(t *testing.T) {
|
||||
"query": "team-alpha",
|
||||
"search-types": "private,external",
|
||||
"member-ids": "ou_1,ou_2",
|
||||
"sort-by": "member_count",
|
||||
"sort": "member_count",
|
||||
"page-size": "0",
|
||||
"page-token": "next-page",
|
||||
}, map[string]bool{
|
||||
@@ -452,7 +452,7 @@ func TestBuildSearchChatBodyAdditionalBranches(t *testing.T) {
|
||||
"is_manager": true,
|
||||
"disable_search_by_user": true,
|
||||
},
|
||||
"sorter": "member_count",
|
||||
"sorter": "member_count_desc",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("buildSearchChatBody() = %#v, want %#v", got, want)
|
||||
|
||||
@@ -82,12 +82,19 @@ func senderDisplay(sender map[string]interface{}) string {
|
||||
}
|
||||
|
||||
func validateMessageID(input string) (string, error) {
|
||||
return validateMessageIDForParam(input, "--message-id")
|
||||
}
|
||||
|
||||
// validateMessageIDForParam validates a message ID and attributes failures to
|
||||
// the command flag that owns the value. Batch inputs use --message-ids while
|
||||
// single-message shortcuts use --message-id.
|
||||
func validateMessageIDForParam(input, param string) (string, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "message ID cannot be empty").WithParam("--message-id")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "message ID cannot be empty").WithParam(param)
|
||||
}
|
||||
if !strings.HasPrefix(input, "om_") {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid message ID %q: must start with om_", input).WithParam("--message-id")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid message ID %q: must start with om_", input).WithParam(param)
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -14,8 +15,13 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// imChatListPath is the upstream HTTP path for the +chat-list shortcut.
|
||||
const imChatListPath = "/open-apis/im/v1/chats"
|
||||
const (
|
||||
// imChatListPath is the upstream HTTP path for the +chat-list shortcut.
|
||||
imChatListPath = "/open-apis/im/v1/chats"
|
||||
chatListDefaultPageSize = 20
|
||||
// GET /open-apis/im/v1/chats accepts page_size up to 100.
|
||||
chatListMaxPageSize = 100
|
||||
)
|
||||
|
||||
// bot_strip_p2p is the request-level adjustment notice emitted when bot
|
||||
// identity receives a mixed --types containing "p2p": the p2p value is
|
||||
@@ -41,20 +47,21 @@ func writeBotStripP2pWarning(errOut io.Writer) {
|
||||
var ImChatList = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+chat-list",
|
||||
Description: "List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only)",
|
||||
Description: "List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, auto-pagination, --exclude-muted (user-only)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:chat:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "ID type for owner_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
|
||||
{Name: "sort", Default: "create_time", Desc: "sort field: create_time (ascending) | active_time (descending)", Enum: []string{"create_time", "active_time"}},
|
||||
{Name: "sort-type", Hidden: true, Desc: "alias of --sort (hidden)", Enum: []string{"ByCreateTimeAsc", "ByActiveTimeDesc"}},
|
||||
{Name: "sort-type", Hidden: true, Desc: "legacy API sort vocabulary; use --sort", Enum: legacySortValues(chatListSortCompatibilityValues)},
|
||||
{Name: "types", Type: "string_slice", Desc: "chat types to include (group, p2p); omit = groups only (backward compatible); p2p requires user identity"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
|
||||
{Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", chatListDefaultPageSize), Desc: fmt.Sprintf("page size (1-%d)", chatListMaxPageSize)},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
{Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"},
|
||||
},
|
||||
}, common.PageAllFlags()...),
|
||||
Normalize: normalizeChatListSortCompatibility,
|
||||
// DryRun previews the GET /open-apis/im/v1/chats request without executing.
|
||||
// When bot identity strips p2p from --types, emits the same stderr warning
|
||||
// Execute would emit, so DryRun output truthfully reflects what the API
|
||||
@@ -65,15 +72,22 @@ var ImChatList = common.Shortcut{
|
||||
if stripped {
|
||||
writeBotStripP2pWarning(runtime.IO().ErrOut)
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
dry := common.NewDryRunAPI()
|
||||
if runtime.Bool(common.PageAllFlagName) {
|
||||
dry.Desc(pageAllDryRunDescription)
|
||||
}
|
||||
return dry.
|
||||
GET(imChatListPath).
|
||||
Params(buildChatListParams(runtime, effective))
|
||||
},
|
||||
// Validate enforces flag preconditions: page-size bounds, --types element
|
||||
// enum, and the bot + single-p2p rejection (mixed types degrade in Execute).
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if n := runtime.Int("page-size"); n < 1 || n > 100 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 100").WithParam("--page-size")
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", chatListDefaultPageSize, 1, chatListMaxPageSize); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := common.ValidatePageAllFlags(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
parts, err := normalizeTypes(runtime.StrSlice("types"))
|
||||
if err != nil {
|
||||
@@ -85,7 +99,7 @@ var ImChatList = common.Shortcut{
|
||||
}
|
||||
return nil
|
||||
},
|
||||
// Execute fetches one page of chats, optionally applies --exclude-muted
|
||||
// Execute fetches one or more pages of chats, optionally applies --exclude-muted
|
||||
// via MaybeApplyMuteFilter, and renders the result. outData["filter"] is
|
||||
// populated only when --exclude-muted is set (backward compatible).
|
||||
// outData["notices"] is populated only when bot identity strips p2p from
|
||||
@@ -97,22 +111,24 @@ var ImChatList = common.Shortcut{
|
||||
writeBotStripP2pWarning(runtime.IO().ErrOut)
|
||||
}
|
||||
params := buildChatListParams(runtime, effective)
|
||||
resData, err := runtime.CallAPITyped("GET", imChatListPath, params, nil)
|
||||
|
||||
// Fetch stage: one page and --page-all share the same paginator.
|
||||
// The accumulator owns only the endpoint-specific page shape.
|
||||
result := &imMapListResult{}
|
||||
pagination, err := common.PaginateInto(runtime, common.PageRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: imChatListPath,
|
||||
Params: params,
|
||||
}, result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rawItems, _ := resData["items"].([]interface{})
|
||||
hasMore, pageToken := common.PaginationMeta(resData)
|
||||
|
||||
var items []map[string]interface{}
|
||||
for _, raw := range rawItems {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
// Transform stage: filters run once against the complete fetched set, so
|
||||
// their outcome is independent of API page boundaries.
|
||||
items := result.items
|
||||
hasMore := result.hasMore
|
||||
pageToken := result.pageToken
|
||||
|
||||
mfOut, err := MaybeApplyMuteFilter(runtime, MuteFilterInput{
|
||||
ExcludeMuted: runtime.Bool("exclude-muted"),
|
||||
@@ -125,7 +141,11 @@ var ImChatList = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
items = mfOut.Chats
|
||||
pagination.Items = len(items)
|
||||
|
||||
// Presentation stage: business data stays backward compatible while the
|
||||
// output layer carries the authoritative pagination outcome for every
|
||||
// format.
|
||||
outData := map[string]interface{}{
|
||||
"chats": items,
|
||||
"has_more": hasMore,
|
||||
@@ -140,7 +160,9 @@ var ImChatList = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
runtime.OutFormat(outData, &output.Meta{
|
||||
Pagination: pagination,
|
||||
}, func(w io.Writer) {
|
||||
if len(items) == 0 {
|
||||
fmt.Fprintln(w, "No chats found.")
|
||||
if mfOut.Meta.Hint != "" {
|
||||
@@ -180,15 +202,7 @@ var ImChatList = common.Shortcut{
|
||||
rows = append(rows, row)
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
fmt.Fprintf(w, "\n%d chat(s) listed", len(rows))
|
||||
if hasMore {
|
||||
fmt.Fprint(w, " (more available, use --page-token to fetch next page")
|
||||
if pageToken != "" {
|
||||
fmt.Fprintf(w, ", page_token: %s", pageToken)
|
||||
}
|
||||
fmt.Fprint(w, ")")
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, "\n%d chat(s) listed\n", len(rows))
|
||||
if mfOut.Meta.Hint != "" {
|
||||
fmt.Fprintln(w, mfOut.Meta.Hint)
|
||||
}
|
||||
@@ -271,9 +285,6 @@ func buildChatListParams(runtime *common.RuntimeContext, effectiveTypes string)
|
||||
"create_time": "ByCreateTimeAsc",
|
||||
"active_time": "ByActiveTimeDesc",
|
||||
}[runtime.Str("sort")]
|
||||
if old, ok := aliasFlagValue(runtime, "sort-type", "sort"); ok {
|
||||
sortType = old // old value is already the upstream enum -> pass through
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"user_id_type": runtime.Str("user-id-type"),
|
||||
"sort_type": sortType,
|
||||
|
||||
@@ -30,8 +30,13 @@ func newChatListTestRuntimeContextWithIdentity(t *testing.T, stringFlags map[str
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().Int("page-size", 20, "")
|
||||
cmd.Flags().Int("page-limit", 10, "")
|
||||
cmd.Flags().Int("page-delay", 200, "")
|
||||
cmd.Flags().Bool("page-all", false, "")
|
||||
cmd.Flags().String("sort", "create_time", "")
|
||||
cmd.Flags().String("sort-type", "", "")
|
||||
for name := range stringFlags {
|
||||
if name == "page-size" {
|
||||
if name == "page-size" || name == "page-limit" || name == "sort" || name == "sort-type" {
|
||||
continue
|
||||
}
|
||||
if name == "types" {
|
||||
@@ -41,6 +46,9 @@ func newChatListTestRuntimeContextWithIdentity(t *testing.T, stringFlags map[str
|
||||
}
|
||||
}
|
||||
for name := range boolFlags {
|
||||
if name == "page-all" {
|
||||
continue
|
||||
}
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
@@ -67,6 +75,9 @@ func newChatListTestRuntimeContextWithIdentity(t *testing.T, stringFlags map[str
|
||||
ErrOut: &bytes.Buffer{},
|
||||
},
|
||||
}
|
||||
if err := normalizeChatListSortCompatibility(context.Background(), rt.FlagContext()); err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
return rt
|
||||
}
|
||||
|
||||
@@ -296,10 +307,14 @@ func attachChatListCmd(t *testing.T, runtime *common.RuntimeContext, stringFlags
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().Int("page-size", 20, "")
|
||||
cmd.Flags().Int("page-limit", 10, "")
|
||||
cmd.Flags().Int("page-delay", 200, "")
|
||||
cmd.Flags().String("user-id-type", "open_id", "")
|
||||
cmd.Flags().String("sort-type", "ByCreateTimeAsc", "")
|
||||
cmd.Flags().String("sort", "create_time", "")
|
||||
cmd.Flags().String("sort-type", "", "")
|
||||
cmd.Flags().StringSlice("types", nil, "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
cmd.Flags().Bool("page-all", false, "")
|
||||
cmd.Flags().Bool("exclude-muted", false, "")
|
||||
cmd.Flags().Bool("dry-run", false, "")
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
@@ -316,6 +331,9 @@ func attachChatListCmd(t *testing.T, runtime *common.RuntimeContext, stringFlags
|
||||
}
|
||||
}
|
||||
runtime.Cmd = cmd
|
||||
if err := normalizeChatListSortCompatibility(context.Background(), runtime.FlagContext()); err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
runtime.Format = "json"
|
||||
}
|
||||
|
||||
@@ -435,8 +453,8 @@ func TestImChatList_RowRendering_P2pFields(t *testing.T) {
|
||||
|
||||
// TestImChatList_Execute_PrettyOutputRendersP2pRow exercises the pretty-format
|
||||
// rendering closure in Execute, including the new chat_mode=="p2p" branch that
|
||||
// surfaces p2p_target_type / p2p_target_id, and the has_more footer that
|
||||
// echoes back the page_token.
|
||||
// surfaces p2p_target_type / p2p_target_id, plus the shared pagination
|
||||
// summary that carries the resume token.
|
||||
func TestImChatList_Execute_PrettyOutputRendersP2pRow(t *testing.T) {
|
||||
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
body := `{"code":0,"msg":"ok","data":{"items":[
|
||||
@@ -470,8 +488,10 @@ func TestImChatList_Execute_PrettyOutputRendersP2pRow(t *testing.T) {
|
||||
if !strings.Contains(out, "2 chat(s) listed") {
|
||||
t.Fatalf("pretty output missing footer count:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "next_tok") {
|
||||
t.Fatalf("pretty output missing page_token in has_more footer:\n%s", out)
|
||||
for _, want := range []string{"Pagination: incomplete", `resume token: "next_tok"`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("pretty output missing pagination summary %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,6 +611,13 @@ func TestImChatList_Execute_UserMuteFiltersP2p(t *testing.T) {
|
||||
FilteredCount int `json:"filtered_count"`
|
||||
} `json:"filter"`
|
||||
} `json:"data"`
|
||||
Meta struct {
|
||||
Pagination struct {
|
||||
Complete bool `json:"complete"`
|
||||
Pages int `json:"pages"`
|
||||
Items int `json:"items"`
|
||||
} `json:"pagination"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
|
||||
t.Fatalf("Unmarshal output failed: %v; raw: %s", err, out)
|
||||
@@ -610,6 +637,9 @@ func TestImChatList_Execute_UserMuteFiltersP2p(t *testing.T) {
|
||||
if parsed.Data.Chats[0]["chat_id"] != "oc_g" {
|
||||
t.Fatalf("remaining chat = %v; want oc_g", parsed.Data.Chats[0]["chat_id"])
|
||||
}
|
||||
if !parsed.Meta.Pagination.Complete || parsed.Meta.Pagination.Pages != 1 || parsed.Meta.Pagination.Items != 1 {
|
||||
t.Fatalf("pagination meta = %+v; want one complete page and one emitted item", parsed.Meta.Pagination)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatList_SortMapping(t *testing.T) {
|
||||
@@ -628,9 +658,9 @@ func TestChatList_SortMapping(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatList_SortAliasParity proves the hidden --sort-type alias maps to the
|
||||
// exact same upstream request as the equivalent new --sort value (byte-equal).
|
||||
func TestChatList_SortAliasParity(t *testing.T) {
|
||||
// TestChatList_SortCompatibilityParity proves Normalize maps the hidden legacy
|
||||
// vocabulary to the same canonical request (byte-equal).
|
||||
func TestChatList_SortCompatibilityParity(t *testing.T) {
|
||||
pairs := []struct{ newVal, oldVal string }{
|
||||
{"create_time", "ByCreateTimeAsc"},
|
||||
{"active_time", "ByActiveTimeDesc"},
|
||||
@@ -662,16 +692,16 @@ func TestChatList_SortNewWins(t *testing.T) {
|
||||
|
||||
// TestChatList_SortFlagSurface asserts the declared flag structure.
|
||||
func TestChatList_SortFlagSurface(t *testing.T) {
|
||||
var sortFlag, aliasFlag *common.Flag
|
||||
var sortFlag, legacyFlag *common.Flag
|
||||
for i := range ImChatList.Flags {
|
||||
switch ImChatList.Flags[i].Name {
|
||||
case "sort":
|
||||
sortFlag = &ImChatList.Flags[i]
|
||||
case "sort-type":
|
||||
aliasFlag = &ImChatList.Flags[i]
|
||||
legacyFlag = &ImChatList.Flags[i]
|
||||
}
|
||||
}
|
||||
if sortFlag == nil || aliasFlag == nil {
|
||||
if sortFlag == nil || legacyFlag == nil {
|
||||
t.Fatalf("expected both --sort and --sort-type flags declared")
|
||||
}
|
||||
if sortFlag.Default != "create_time" {
|
||||
@@ -683,13 +713,13 @@ func TestChatList_SortFlagSurface(t *testing.T) {
|
||||
if !strings.Contains(sortFlag.Desc, "create_time") || !strings.Contains(sortFlag.Desc, "active_time") {
|
||||
t.Errorf("--sort Desc must document both fields/directions: %q", sortFlag.Desc)
|
||||
}
|
||||
if !aliasFlag.Hidden {
|
||||
if !legacyFlag.Hidden {
|
||||
t.Errorf("--sort-type must be Hidden")
|
||||
}
|
||||
if got := strings.Join(aliasFlag.Enum, ","); got != "ByCreateTimeAsc,ByActiveTimeDesc" {
|
||||
if got := strings.Join(legacyFlag.Enum, ","); got != "ByCreateTimeAsc,ByActiveTimeDesc" {
|
||||
t.Errorf("--sort-type Enum = %q, want ByCreateTimeAsc,ByActiveTimeDesc", got)
|
||||
}
|
||||
if aliasFlag.Default != "" {
|
||||
t.Errorf("--sort-type (hidden alias) must not carry a Default, got %q", aliasFlag.Default)
|
||||
if legacyFlag.Default != "" {
|
||||
t.Errorf("--sort-type compatibility flag must not carry a Default, got %q", legacyFlag.Default)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ import (
|
||||
const (
|
||||
imChatMembersListPathFmt = "/open-apis/im/v1/chats/%s/members/list"
|
||||
chatMembersListDefaultPageSize = 20
|
||||
chatMembersListMaxPageSize = 100
|
||||
// GET /open-apis/im/v1/chats/:chat_id/members/list accepts page_size up to 100.
|
||||
chatMembersListMaxPageSize = 100
|
||||
// chatMembersListDefaultPageDelay throttles --page-all the same way the
|
||||
// generic paginateLoop does (200ms). It matters for tenants WITHOUT the
|
||||
// server-side member cap, where a large group drains many pages back to
|
||||
@@ -48,7 +49,7 @@ var ImChatMembersList = common.Shortcut{
|
||||
{Name: "chat-id", Required: true, Desc: "chat ID (oc_xxx)"},
|
||||
{Name: "member-types", Type: "string_slice", Desc: "member types to return (user, bot); omit = all"},
|
||||
{Name: "member-id-type", Default: "open_id", Desc: "ID type for member_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
|
||||
{Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageSize), Desc: fmt.Sprintf("page size, 1-%d", chatMembersListMaxPageSize)},
|
||||
{Name: "page-size", Aliases: []string{"limit"}, Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageSize), Desc: fmt.Sprintf("page size (1-%d)", chatMembersListMaxPageSize)},
|
||||
{Name: "page-token", Desc: "page token; implies single-page fetch (no auto-pagination)"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (capped by --page-limit)"},
|
||||
{Name: "page-limit", Type: "int", Default: "10", Desc: "max pages to fetch with --page-all (default 10, 0 = unlimited)"},
|
||||
@@ -67,8 +68,8 @@ var ImChatMembersList = common.Shortcut{
|
||||
if !strings.HasPrefix(chatID, "oc_") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --chat-id %q: must be an open_chat_id starting with oc_", chatID).WithParam("--chat-id")
|
||||
}
|
||||
if n := runtime.Int("page-size"); n < 1 || n > chatMembersListMaxPageSize {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and %d", chatMembersListMaxPageSize).WithParam("--page-size")
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", chatMembersListDefaultPageSize, 1, chatMembersListMaxPageSize); err != nil {
|
||||
return err
|
||||
}
|
||||
if n := runtime.Int("page-limit"); n < 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be a non-negative integer").WithParam("--page-limit")
|
||||
|
||||
@@ -17,28 +17,34 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
chatMessagesListDefaultPageSize = 50
|
||||
// GET /open-apis/im/v1/messages accepts page_size up to 50.
|
||||
chatMessagesListMaxPageSize = 50
|
||||
)
|
||||
|
||||
var ImChatMessageList = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+chat-messages-list",
|
||||
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",
|
||||
Description: "List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range, --order asc|desc sorting, auto-pagination",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "chat-id", Desc: "(required, mutually exclusive with --user-id) chat ID (oc_xxx)"},
|
||||
{Name: "user-id", Desc: "(required, mutually exclusive with --chat-id; user identity only) user open_id (ou_xxx)"},
|
||||
{Name: "start", Desc: "start time (ISO 8601)"},
|
||||
{Name: "end", Desc: "end time (ISO 8601)"},
|
||||
{Name: "order", Default: "desc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}},
|
||||
{Name: "sort", Hidden: true, Desc: "alias of --order (hidden)", Enum: []string{"asc", "desc"}},
|
||||
{Name: "page-size", Default: "50", Desc: "page size (1-50)"},
|
||||
{Name: "start", Aliases: []string{"start-time"}, Desc: "start time (ISO 8601)"},
|
||||
{Name: "end", Aliases: []string{"end-time"}, Desc: "end time (ISO 8601)"},
|
||||
{Name: "order", Aliases: []string{"sort-order"}, Default: "desc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}},
|
||||
{Name: "sort", Hidden: true, Desc: "legacy name for --order", Enum: []string{"asc", "desc"}},
|
||||
{Name: "page-size", Aliases: []string{"limit"}, Default: fmt.Sprintf("%d", chatMessagesListDefaultPageSize), Desc: fmt.Sprintf("page size (1-%d)", chatMessagesListMaxPageSize)},
|
||||
{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)"},
|
||||
downloadResourcesFlag,
|
||||
},
|
||||
}, common.PageAllFlags()...),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
d := common.NewDryRunAPI()
|
||||
chatId, err := resolveChatIDForMessagesList(runtime, true)
|
||||
@@ -48,6 +54,9 @@ var ImChatMessageList = common.Shortcut{
|
||||
if runtime.Str("user-id") != "" {
|
||||
d.Desc("(--user-id provided) Will resolve P2P chat_id via POST /open-apis/im/v1/chat_p2p/batch_query at execution time")
|
||||
}
|
||||
if runtime.Bool(common.PageAllFlagName) {
|
||||
d.Desc(pageAllDryRunDescription)
|
||||
}
|
||||
params, err := buildChatMessageListRequest(runtime, chatId)
|
||||
if err != nil {
|
||||
return d.Desc(err.Error())
|
||||
@@ -58,7 +67,7 @@ var ImChatMessageList = common.Shortcut{
|
||||
dryParams[k] = vs[0]
|
||||
}
|
||||
}
|
||||
d = d.GET("/open-apis/im/v1/messages").Params(dryParams)
|
||||
d = d.GET(imMessagesListPath).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.")
|
||||
@@ -97,15 +106,22 @@ var ImChatMessageList = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := common.ValidatePageAllFlags(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
chatId := runtime.Str("chat-id")
|
||||
if chatId == "" {
|
||||
chatId = "<resolved_chat_id>"
|
||||
}
|
||||
_, err := buildChatMessageListRequest(runtime, chatId)
|
||||
return err
|
||||
if _, err := buildChatMessageListRequest(runtime, chatId); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", chatMessagesListDefaultPageSize, 1, chatMessagesListMaxPageSize); err != nil {
|
||||
return err
|
||||
}
|
||||
chatId, err := resolveChatIDForMessagesList(runtime, false)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -115,13 +131,23 @@ var ImChatMessageList = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil)
|
||||
// Fetch: both the default one-page call and --page-all use the same
|
||||
// policy. The IM accumulator preserves message ordering and
|
||||
// final cursor fields without running enrichment per page.
|
||||
result := &imMapListResult{}
|
||||
pagination, err := common.PaginateInto(runtime, common.PageRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: imMessagesListPath,
|
||||
Params: messageListPageParams(params),
|
||||
}, result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rawItems, _ := data["items"].([]interface{})
|
||||
hasMore, nextPageToken := common.PaginationMeta(data)
|
||||
rawItems := result.interfaceItems()
|
||||
hasMore := result.hasMore
|
||||
nextPageToken := result.pageToken
|
||||
|
||||
// Transform: all global enrichment runs once over the merged result.
|
||||
nameCache := make(map[string]string)
|
||||
// Pre-fetch merge_forward sub-messages concurrently before the per-item
|
||||
// conversion loop. Each merge_forward in the page would otherwise issue
|
||||
@@ -134,8 +160,7 @@ var ImChatMessageList = common.Shortcut{
|
||||
|
||||
downloadResources := runtime.Bool("download-resources")
|
||||
messages := make([]map[string]interface{}, 0, len(rawItems))
|
||||
for _, item := range rawItems {
|
||||
m, _ := item.(map[string]interface{})
|
||||
for _, m := range result.items {
|
||||
messages = append(messages, convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources))
|
||||
}
|
||||
|
||||
@@ -149,14 +174,19 @@ var ImChatMessageList = common.Shortcut{
|
||||
if downloadResources {
|
||||
enrichMessageResourceDownloads(runtime, messages)
|
||||
}
|
||||
pagination.Items = len(messages)
|
||||
|
||||
// Emit: pagination completion belongs to framework metadata; the
|
||||
// business payload remains compatible for existing consumers.
|
||||
outData := map[string]interface{}{
|
||||
"messages": messages,
|
||||
"total": len(messages),
|
||||
"has_more": hasMore,
|
||||
"page_token": nextPageToken,
|
||||
}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
runtime.OutFormat(outData, &output.Meta{
|
||||
Pagination: pagination,
|
||||
}, func(w io.Writer) {
|
||||
if len(messages) == 0 {
|
||||
fmt.Fprintln(w, "No messages in this time range.")
|
||||
return
|
||||
@@ -178,11 +208,7 @@ var ImChatMessageList = common.Shortcut{
|
||||
rows = append(rows, row)
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
moreHint := ""
|
||||
if hasMore {
|
||||
moreHint = fmt.Sprintf(" (more available, page_token: %s)", nextPageToken)
|
||||
}
|
||||
fmt.Fprintf(w, "\n%d message(s)%s\ntip: use --format json to view full message content\n", len(messages), moreHint)
|
||||
fmt.Fprintf(w, "\n%d message(s)\ntip: use --format json to view full message content\n", len(messages))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
@@ -190,15 +216,11 @@ var ImChatMessageList = common.Shortcut{
|
||||
|
||||
// buildChatMessageListParams builds the shared API params for DryRun and Execute.
|
||||
// and params map construction that existed verbatim in both DryRun and Execute.
|
||||
func buildChatMessageListParams(sortFlag, pageSizeStr, chatId string) larkcore.QueryParams {
|
||||
func buildChatMessageListParams(sortFlag string, pageSize int, chatId string) larkcore.QueryParams {
|
||||
sortType := "ByCreateTimeDesc"
|
||||
if sortFlag == "asc" {
|
||||
sortType = "ByCreateTimeAsc"
|
||||
}
|
||||
pageSize := 50
|
||||
if n, err := strconv.Atoi(pageSizeStr); err == nil {
|
||||
pageSize = min(max(n, 1), 50)
|
||||
}
|
||||
return larkcore.QueryParams{
|
||||
"container_id_type": []string{"chat"},
|
||||
"container_id": []string{chatId},
|
||||
@@ -214,19 +236,25 @@ func buildChatMessageListParams(sortFlag, pageSizeStr, chatId string) larkcore.Q
|
||||
|
||||
func buildChatMessageListRequest(runtime *common.RuntimeContext, chatId string) (larkcore.QueryParams, error) {
|
||||
dir := runtime.Str("order")
|
||||
if old, ok := aliasFlagValue(runtime, "sort", "order"); ok {
|
||||
dir = old // old value is asc/desc -> must go through the same map, never pass through
|
||||
if legacy, ok := legacyFlagValue(runtime, "sort", "order"); ok {
|
||||
dir = legacy
|
||||
}
|
||||
params := buildChatMessageListParams(dir, runtime.Str("page-size"), chatId)
|
||||
pageSize, err := common.ValidatePageSizeTyped(runtime, "page-size", chatMessagesListDefaultPageSize, 1, chatMessagesListMaxPageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params := buildChatMessageListParams(dir, pageSize, chatId)
|
||||
|
||||
if startFlag := runtime.Str("start"); startFlag != "" {
|
||||
startFlag := runtime.Str("start")
|
||||
if startFlag != "" {
|
||||
startTime, err := common.ParseTime(startFlag)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
|
||||
}
|
||||
params["start_time"] = []string{startTime}
|
||||
}
|
||||
if endFlag := runtime.Str("end"); endFlag != "" {
|
||||
endFlag := runtime.Str("end")
|
||||
if endFlag != "" {
|
||||
endTime, err := common.ParseTime(endFlag, "end")
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||
|
||||
@@ -43,13 +43,13 @@ func TestChatMessagesList_OrderMapping(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatMessagesList_OrderAliasParity: hidden --sort alias (asc/desc) must map
|
||||
// through the SAME table as --order (NOT pass through), producing identical upstream.
|
||||
func TestChatMessagesList_OrderAliasParity(t *testing.T) {
|
||||
// TestChatMessagesList_LegacySortParity proves the command-owned compatibility
|
||||
// stage resolves historical --sort to the canonical --order value.
|
||||
func TestChatMessagesList_LegacySortParity(t *testing.T) {
|
||||
for _, dir := range []string{"asc", "desc"} {
|
||||
t.Run(dir, func(t *testing.T) {
|
||||
newRT := newMsgListTestRT(t, map[string]string{"order": dir})
|
||||
oldRT := newMsgListTestRT(t, map[string]string{"sort": dir})
|
||||
newRT, _ := newMountedIMRuntime(t, &ImChatMessageList, "--chat-id", "oc_test", "--order", dir)
|
||||
oldRT, _ := newMountedIMRuntime(t, &ImChatMessageList, "--chat-id", "oc_test", "--sort", dir)
|
||||
a := mustMarshalDryRun(t, ImChatMessageList.DryRun(context.Background(), newRT))
|
||||
b := mustMarshalDryRun(t, ImChatMessageList.DryRun(context.Background(), oldRT))
|
||||
if a != b {
|
||||
@@ -59,29 +59,34 @@ func TestChatMessagesList_OrderAliasParity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessagesList_OrderNewWins(t *testing.T) {
|
||||
rt := newMsgListTestRT(t, map[string]string{"order": "asc", "sort": "desc"})
|
||||
params, err := buildChatMessageListRequest(rt, "oc_test")
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if got := params["sort_type"][0]; got != "ByCreateTimeAsc" {
|
||||
t.Fatalf("new should win: sort_type=%s, want ByCreateTimeAsc", got)
|
||||
func TestChatMessagesList_CanonicalOrderWinsOverLegacySort(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"--order", "asc", "--sort", "desc"},
|
||||
{"--sort", "desc", "--order", "asc"},
|
||||
} {
|
||||
rt, _ := newMountedIMRuntime(t, &ImChatMessageList, args...)
|
||||
params, err := buildChatMessageListRequest(rt, "oc_test")
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if got := params["sort_type"][0]; got != "ByCreateTimeAsc" {
|
||||
t.Fatalf("canonical --order must win for %v: sort_type=%s", args, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessagesList_OrderFlagSurface(t *testing.T) {
|
||||
var orderFlag, aliasFlag *common.Flag
|
||||
var orderFlag, sortFlag *common.Flag
|
||||
for i := range ImChatMessageList.Flags {
|
||||
switch ImChatMessageList.Flags[i].Name {
|
||||
case "order":
|
||||
if ImChatMessageList.Flags[i].Name == "order" {
|
||||
orderFlag = &ImChatMessageList.Flags[i]
|
||||
case "sort":
|
||||
aliasFlag = &ImChatMessageList.Flags[i]
|
||||
}
|
||||
if ImChatMessageList.Flags[i].Name == "sort" {
|
||||
sortFlag = &ImChatMessageList.Flags[i]
|
||||
}
|
||||
}
|
||||
if orderFlag == nil || aliasFlag == nil {
|
||||
t.Fatalf("expected both --order and --sort flags declared")
|
||||
if orderFlag == nil {
|
||||
t.Fatal("expected canonical --order declaration")
|
||||
}
|
||||
if orderFlag.Default != "desc" {
|
||||
t.Errorf("--order Default = %q, want desc", orderFlag.Default)
|
||||
@@ -89,10 +94,13 @@ func TestChatMessagesList_OrderFlagSurface(t *testing.T) {
|
||||
if got := strings.Join(orderFlag.Enum, ","); got != "asc,desc" {
|
||||
t.Errorf("--order Enum = %q, want asc,desc", got)
|
||||
}
|
||||
if !aliasFlag.Hidden {
|
||||
t.Errorf("--sort must be Hidden")
|
||||
if got := strings.Join(orderFlag.Aliases, ","); got != "sort-order" {
|
||||
t.Errorf("--order Aliases = %q, want sort-order", got)
|
||||
}
|
||||
if got := strings.Join(aliasFlag.Enum, ","); got != "asc,desc" {
|
||||
t.Errorf("--sort (alias) Enum = %q, want asc,desc", got)
|
||||
if sortFlag == nil || !sortFlag.Hidden {
|
||||
t.Fatal("historical --sort must remain an independent hidden compatibility flag")
|
||||
}
|
||||
if got := strings.Join(sortFlag.Enum, ","); got != "asc,desc" {
|
||||
t.Errorf("--sort Enum = %q, want asc,desc", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,64 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
imChatSearchPath = "/open-apis/im/v2/chats/search"
|
||||
chatSearchDefaultPageSize = 20
|
||||
// POST /open-apis/im/v2/chats/search accepts page_size up to 100.
|
||||
chatSearchMaxPageSize = 100
|
||||
)
|
||||
|
||||
type chatSearchPageItem struct {
|
||||
MetaData map[string]interface{} `json:"meta_data"`
|
||||
}
|
||||
|
||||
type chatSearchPage struct {
|
||||
Items []chatSearchPageItem `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Notice string `json:"notice"`
|
||||
HasMore bool `json:"has_more"`
|
||||
PageToken string `json:"page_token"`
|
||||
NextPageToken string `json:"next_page_token"`
|
||||
}
|
||||
|
||||
// chatSearchResult owns the endpoint's merge semantics: meta_data is the
|
||||
// actual business record, total comes from the latest page, and a query notice
|
||||
// is retained even when later pages omit it.
|
||||
type chatSearchResult struct {
|
||||
items []map[string]interface{}
|
||||
total int
|
||||
notice string
|
||||
hasMore bool
|
||||
pageToken string
|
||||
}
|
||||
|
||||
func (result *chatSearchResult) AddPage(page chatSearchPage) error {
|
||||
for _, item := range page.Items {
|
||||
if item.MetaData != nil {
|
||||
result.items = append(result.items, item.MetaData)
|
||||
}
|
||||
}
|
||||
result.total = page.Total
|
||||
if page.Notice != "" {
|
||||
result.notice = page.Notice
|
||||
}
|
||||
result.hasMore = page.HasMore
|
||||
result.pageToken = page.PageToken
|
||||
if result.pageToken == "" {
|
||||
result.pageToken = page.NextPageToken
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ImChatSearch is the +chat-search shortcut: wraps POST /open-apis/im/v2/chats/search
|
||||
// to find visible group chats by keyword and/or member open_ids. Supports
|
||||
// member/type filters, sort order, pagination, and (user identity only) the
|
||||
@@ -23,12 +72,12 @@ import (
|
||||
var ImChatSearch = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+chat-search",
|
||||
Description: "Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only)",
|
||||
Description: "Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, auto-pagination, and --exclude-muted (user identity only)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:chat:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "query", Desc: "search keyword (server may return data.notice for overly long input)"},
|
||||
{Name: "search-types", Desc: "chat types, comma-separated (private, external, public_joined, public_not_joined)"},
|
||||
{Name: "chat-modes", Desc: "filter by chat mode, comma-separated (group, topic)"},
|
||||
@@ -36,17 +85,22 @@ var ImChatSearch = common.Shortcut{
|
||||
{Name: "is-manager", Type: "bool", Desc: "only show chats you created or manage"},
|
||||
{Name: "disable-search-by-user", Type: "bool", Desc: "disable search-by-member-name (default: search by member name first, then group name)"},
|
||||
{Name: "sort", Desc: "sort field (always descending): create_time | update_time | member_count", Enum: []string{"create_time", "update_time", "member_count"}},
|
||||
{Name: "sort-by", Hidden: true, Desc: "alias of --sort (hidden)", Enum: []string{"create_time_desc", "update_time_desc", "member_count_desc"}},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
|
||||
{Name: "sort-by", Hidden: true, Desc: "legacy API sorter vocabulary; use --sort", Enum: legacySortValues(chatSearchSortCompatibilityValues)},
|
||||
{Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", chatSearchDefaultPageSize), Desc: fmt.Sprintf("page size (1-%d)", chatSearchMaxPageSize)},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
{Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"},
|
||||
},
|
||||
}, common.PageAllFlags()...),
|
||||
Normalize: normalizeChatSearchSortCompatibility,
|
||||
// DryRun previews the POST /open-apis/im/v2/chats/search request without executing.
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := buildSearchChatBody(runtime)
|
||||
params := buildSearchChatParams(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/im/v2/chats/search").
|
||||
dry := common.NewDryRunAPI()
|
||||
if runtime.Bool(common.PageAllFlagName) {
|
||||
dry.Desc(pageAllDryRunDescription)
|
||||
}
|
||||
return dry.
|
||||
POST(imChatSearchPath).
|
||||
Params(params).
|
||||
Body(body)
|
||||
},
|
||||
@@ -89,41 +143,37 @@ var ImChatSearch = common.Shortcut{
|
||||
}
|
||||
}
|
||||
}
|
||||
if n := runtime.Int("page-size"); n < 1 || n > 100 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 100").WithParam("--page-size")
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", chatSearchDefaultPageSize, 1, chatSearchMaxPageSize); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return common.ValidatePageAllFlags(runtime)
|
||||
},
|
||||
// Execute fetches one page, extracts per-item meta_data, optionally applies
|
||||
// Execute fetches one or more pages, extracts per-item meta_data, optionally applies
|
||||
// the --exclude-muted client-side filter (with a PreSkipReason when
|
||||
// --search-types is exactly public_not_joined), and renders the result.
|
||||
// outData["filter"] is populated only when --exclude-muted is set.
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body := buildSearchChatBody(runtime)
|
||||
params := buildSearchChatParams(runtime)
|
||||
resData, err := runtime.CallAPITyped("POST", "/open-apis/im/v2/chats/search", params, body)
|
||||
|
||||
// Fetch + project: every page is decoded into the endpoint's typed
|
||||
// wrapper, then its meta_data records are merged in page order.
|
||||
result := &chatSearchResult{}
|
||||
pagination, err := common.PaginateInto(runtime, common.PageRequest{
|
||||
Method: http.MethodPost,
|
||||
Path: imChatSearchPath,
|
||||
Params: params,
|
||||
Body: body,
|
||||
}, result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rawItems, _ := resData["items"].([]interface{})
|
||||
totalF, _ := util.ToFloat64(resData["total"])
|
||||
total := totalF
|
||||
hasMore, pageToken := common.PaginationMeta(resData)
|
||||
|
||||
// Extract MetaData from each item
|
||||
var items []map[string]interface{}
|
||||
for _, raw := range rawItems {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
meta, _ := item["meta_data"].(map[string]interface{})
|
||||
if meta == nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, meta)
|
||||
}
|
||||
// Transform: the mute filter is global to the fetched result and may
|
||||
// batch internally; API page boundaries are irrelevant here.
|
||||
items := result.items
|
||||
hasMore := result.hasMore
|
||||
pageToken := result.pageToken
|
||||
|
||||
preSkipReason := ""
|
||||
if runtime.Bool("exclude-muted") {
|
||||
@@ -141,21 +191,24 @@ var ImChatSearch = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
items = mfOut.Chats
|
||||
pagination.Items = len(items)
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"chats": items,
|
||||
"total": int(total),
|
||||
"total": result.total,
|
||||
"has_more": hasMore,
|
||||
"page_token": pageToken,
|
||||
}
|
||||
if notice, _ := resData["notice"].(string); notice != "" {
|
||||
outData["notice"] = notice
|
||||
if result.notice != "" {
|
||||
outData["notice"] = result.notice
|
||||
}
|
||||
if mfOut.Meta.Applied != "" {
|
||||
outData["filter"] = MuteFilterMetaToMap(mfOut.Meta)
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
runtime.OutFormat(outData, &output.Meta{
|
||||
Pagination: pagination,
|
||||
}, func(w io.Writer) {
|
||||
if len(items) == 0 {
|
||||
fmt.Fprintln(w, "No matching group chats found.")
|
||||
if mfOut.Meta.Hint != "" {
|
||||
@@ -190,15 +243,7 @@ var ImChatSearch = common.Shortcut{
|
||||
rows = append(rows, row)
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
moreHint := ""
|
||||
if hasMore {
|
||||
moreHint = " (more available, use --page-token to fetch next page"
|
||||
if pageToken != "" {
|
||||
moreHint += fmt.Sprintf(", page_token: %s", pageToken)
|
||||
}
|
||||
moreHint += ")"
|
||||
}
|
||||
fmt.Fprintf(w, "\n%d chat(s) found%s\n", int(total), moreHint)
|
||||
fmt.Fprintf(w, "\n%d chat(s) found\n", result.total)
|
||||
if mfOut.Meta.Hint != "" {
|
||||
fmt.Fprintln(w, mfOut.Meta.Hint)
|
||||
}
|
||||
@@ -211,7 +256,7 @@ var ImChatSearch = common.Shortcut{
|
||||
// from the runtime flag values. The query string is normalized via
|
||||
// normalizeChatSearchQuery (hyphenated terms get quoted). The "filter" object
|
||||
// is omitted when no filter flags are set; "sorter" is omitted when --sort
|
||||
// (and its hidden alias --sort-by) is unset.
|
||||
// (and its hidden compatibility input --sort-by) is unset.
|
||||
func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
|
||||
@@ -257,16 +302,13 @@ func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{}
|
||||
body["filter"] = filter
|
||||
}
|
||||
|
||||
// Build sorter (always descending). --sort maps field -> field_desc; the hidden
|
||||
// --sort-by alias is already the upstream value (pass-through). Omitted when unset.
|
||||
// Build sorter (always descending) from the canonical --sort value. The
|
||||
// framework Normalize phase has already translated legacy --sort-by.
|
||||
sorter := map[string]string{
|
||||
"create_time": "create_time_desc",
|
||||
"update_time": "update_time_desc",
|
||||
"member_count": "member_count_desc",
|
||||
}[runtime.Str("sort")]
|
||||
if old, ok := aliasFlagValue(runtime, "sort-by", "sort"); ok {
|
||||
sorter = old
|
||||
}
|
||||
if sorter != "" {
|
||||
body["sorter"] = sorter
|
||||
}
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
package im
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -18,7 +21,14 @@ func newSearchTestRT(t *testing.T, stringFlags map[string]string) *common.Runtim
|
||||
if _, ok := stringFlags["query"]; !ok {
|
||||
stringFlags["query"] = "team"
|
||||
}
|
||||
return newChatListTestRuntimeContext(t, stringFlags, nil)
|
||||
rt := newChatSearchTestRuntimeContext(t, stringFlags, nil)
|
||||
rt.Factory = &cmdutil.Factory{
|
||||
IOStreams: &cmdutil.IOStreams{
|
||||
Out: &bytes.Buffer{},
|
||||
ErrOut: &bytes.Buffer{},
|
||||
},
|
||||
}
|
||||
return rt
|
||||
}
|
||||
|
||||
func TestChatSearch_SortMapping(t *testing.T) {
|
||||
@@ -47,9 +57,9 @@ func TestChatSearch_SortOmittedWhenUnset(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatSearch_SortAliasParity: hidden --sort-by value is already the upstream
|
||||
// sorter (pass-through), so it must equal the mapped new --sort body.
|
||||
func TestChatSearch_SortAliasParity(t *testing.T) {
|
||||
// TestChatSearch_SortCompatibilityParity proves Normalize translates the
|
||||
// legacy upstream sorter vocabulary before the canonical-only builder runs.
|
||||
func TestChatSearch_SortCompatibilityParity(t *testing.T) {
|
||||
pairs := []struct{ newVal, oldVal string }{
|
||||
{"create_time", "create_time_desc"},
|
||||
{"update_time", "update_time_desc"},
|
||||
@@ -58,7 +68,11 @@ func TestChatSearch_SortAliasParity(t *testing.T) {
|
||||
for _, p := range pairs {
|
||||
t.Run(p.newVal, func(t *testing.T) {
|
||||
newBody := buildSearchChatBody(newSearchTestRT(t, map[string]string{"sort": p.newVal}))
|
||||
oldBody := buildSearchChatBody(newSearchTestRT(t, map[string]string{"sort-by": p.oldVal}))
|
||||
oldRT := newSearchTestRT(t, map[string]string{"sort-by": p.oldVal})
|
||||
if err := normalizeChatSearchSortCompatibility(context.Background(), oldRT.FlagContext()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldBody := buildSearchChatBody(oldRT)
|
||||
if newBody["sorter"] != oldBody["sorter"] {
|
||||
t.Fatalf("alias parity: new sorter=%v, old sorter=%v", newBody["sorter"], oldBody["sorter"])
|
||||
}
|
||||
@@ -68,6 +82,9 @@ func TestChatSearch_SortAliasParity(t *testing.T) {
|
||||
|
||||
func TestChatSearch_SortNewWins(t *testing.T) {
|
||||
rt := newSearchTestRT(t, map[string]string{"sort": "member_count", "sort-by": "create_time_desc"})
|
||||
if err := normalizeChatSearchSortCompatibility(context.Background(), rt.FlagContext()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := buildSearchChatBody(rt)
|
||||
if body["sorter"] != "member_count_desc" {
|
||||
t.Fatalf("new should win: sorter=%v, want member_count_desc", body["sorter"])
|
||||
@@ -75,16 +92,16 @@ func TestChatSearch_SortNewWins(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestChatSearch_SortFlagSurface(t *testing.T) {
|
||||
var sortFlag, aliasFlag *common.Flag
|
||||
var sortFlag, legacyFlag *common.Flag
|
||||
for i := range ImChatSearch.Flags {
|
||||
switch ImChatSearch.Flags[i].Name {
|
||||
case "sort":
|
||||
sortFlag = &ImChatSearch.Flags[i]
|
||||
case "sort-by":
|
||||
aliasFlag = &ImChatSearch.Flags[i]
|
||||
legacyFlag = &ImChatSearch.Flags[i]
|
||||
}
|
||||
}
|
||||
if sortFlag == nil || aliasFlag == nil {
|
||||
if sortFlag == nil || legacyFlag == nil {
|
||||
t.Fatalf("expected both --sort and --sort-by flags declared")
|
||||
}
|
||||
if sortFlag.Default != "" {
|
||||
@@ -93,10 +110,18 @@ func TestChatSearch_SortFlagSurface(t *testing.T) {
|
||||
if got := strings.Join(sortFlag.Enum, ","); got != "create_time,update_time,member_count" {
|
||||
t.Errorf("--sort Enum = %q", got)
|
||||
}
|
||||
if !aliasFlag.Hidden {
|
||||
if !legacyFlag.Hidden {
|
||||
t.Errorf("--sort-by must be Hidden")
|
||||
}
|
||||
if got := strings.Join(aliasFlag.Enum, ","); got != "create_time_desc,update_time_desc,member_count_desc" {
|
||||
t.Errorf("--sort-by Enum = %q", got)
|
||||
if got := strings.Join(legacyFlag.Enum, ","); got != "create_time_desc,update_time_desc,member_count_desc" {
|
||||
t.Errorf("--sort-by Enum = %q, want create_time_desc,update_time_desc,member_count_desc", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatSearch_DoesNotDeclareCrossCommandTypesFlag(t *testing.T) {
|
||||
for _, flag := range ImChatSearch.Flags {
|
||||
if flag.Name == "types" {
|
||||
t.Fatal("+chat-search must not expose the +chat-list --types vocabulary")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,7 +422,7 @@ func TestFeedGroupValidationErrors(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{"list missing feed-group-id", ImFeedGroupListItem, map[string]string{}, "--feed-group-id is required"},
|
||||
{"list bad page-size", ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "page-size": "0"}, "--page-size must be an integer between 1 and 50"},
|
||||
{"list bad page-size", ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "page-size": "0"}, "invalid --page-size 0: must be between 1 and 50"},
|
||||
{"list bad page-limit", ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "page-limit": "2000"}, "--page-limit must be an integer between 1 and 1000"},
|
||||
{"list bad start-time", ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "start-time": "notnum"}, "--start-time must be Unix milliseconds"},
|
||||
{"list bad end-time", ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "end-time": "notnum"}, "--end-time must be Unix milliseconds"},
|
||||
|
||||
@@ -15,7 +15,12 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const feedGroupListPath = "/open-apis/im/v1/groups"
|
||||
const (
|
||||
feedGroupListPath = "/open-apis/im/v1/groups"
|
||||
feedGroupListDefaultPageSize = 50
|
||||
// GET /open-apis/im/v1/groups accepts page_size up to 50.
|
||||
feedGroupListMaxPageSize = 50
|
||||
)
|
||||
|
||||
// ImFeedGroupList provides the +feed-group-list shortcut: it lists the caller's
|
||||
// feed groups (tags) with auto-pagination that correctly merges BOTH the live
|
||||
@@ -33,7 +38,7 @@ var ImFeedGroupList = common.Shortcut{
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"},
|
||||
{Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", feedGroupListDefaultPageSize), Desc: fmt.Sprintf("page size (1-%d)", feedGroupListMaxPageSize)},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages"},
|
||||
{Name: "page-limit", Type: "int", Default: "20", Desc: "max pages when auto-pagination is enabled (default 20, max 1000)"},
|
||||
@@ -72,8 +77,8 @@ var ImFeedGroupList = common.Shortcut{
|
||||
}
|
||||
|
||||
func validateFeedGroupListPageOptions(rt *common.RuntimeContext) error {
|
||||
if n := rt.Int("page-size"); n < 1 || n > 50 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 50").WithParam("--page-size")
|
||||
if _, err := common.ValidatePageSizeTyped(rt, "page-size", feedGroupListDefaultPageSize, 1, feedGroupListMaxPageSize); err != nil {
|
||||
return err
|
||||
}
|
||||
if n := rt.Int("page-limit"); n < 1 || n > 1000 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be an integer between 1 and 1000").WithParam("--page-limit")
|
||||
|
||||
@@ -15,6 +15,13 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
feedGroupListItemDefaultPageSize = 50
|
||||
// The endpoint has no published page_size range. Read-only probes show
|
||||
// that 50 succeeds while 51 returns code 230001 "param is invalid".
|
||||
feedGroupListItemMaxPageSize = 50
|
||||
)
|
||||
|
||||
// ImFeedGroupListItem provides the +feed-group-list-item shortcut: it lists the
|
||||
// feed cards inside one feed group and enriches each item with chat_name resolved
|
||||
// from its feed_id.
|
||||
@@ -28,7 +35,7 @@ var ImFeedGroupListItem = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "feed-group-id", Desc: "feed group ID (ofg_xxx); path parameter (required)"},
|
||||
{Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"},
|
||||
{Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", feedGroupListItemDefaultPageSize), Desc: fmt.Sprintf("page size (1-%d)", feedGroupListItemMaxPageSize)},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages"},
|
||||
{Name: "page-limit", Type: "int", Default: "20", Desc: "max pages when auto-pagination is enabled (default 20, max 1000)"},
|
||||
@@ -72,8 +79,8 @@ func validateFeedGroupListOptions(rt *common.RuntimeContext) error {
|
||||
if rt.Str("feed-group-id") == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--feed-group-id is required").WithParam("--feed-group-id")
|
||||
}
|
||||
if n := rt.Int("page-size"); n < 1 || n > 50 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 50").WithParam("--page-size")
|
||||
if _, err := common.ValidatePageSizeTyped(rt, "page-size", feedGroupListItemDefaultPageSize, 1, feedGroupListItemMaxPageSize); err != nil {
|
||||
return err
|
||||
}
|
||||
if n := rt.Int("page-limit"); n < 1 || n > 1000 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be an integer between 1 and 1000").WithParam("--page-limit")
|
||||
|
||||
179
shortcuts/im/im_flag_aliases_test.go
Normal file
179
shortcuts/im/im_flag_aliases_test.go
Normal file
@@ -0,0 +1,179 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newMountedIMRuntime(t *testing.T, shortcut *common.Shortcut, args ...string) (*common.RuntimeContext, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
config := &core.CliConfig{}
|
||||
factory, _, stderr, _ := cmdutil.TestFactory(t, config)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
shortcut.Mount(parent, factory)
|
||||
cmd, _, err := parent.Find([]string{shortcut.Command})
|
||||
if err != nil {
|
||||
t.Fatalf("Find(%s) error = %v", shortcut.Command, err)
|
||||
}
|
||||
if err := cmd.ParseFlags(args); err != nil {
|
||||
t.Fatalf("ParseFlags(%v) error = %v", args, err)
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd, Factory: factory, Config: config}, stderr
|
||||
}
|
||||
|
||||
func TestIMDeclarativeFlagAliases(t *testing.T) {
|
||||
tests := []struct {
|
||||
shortcut *common.Shortcut
|
||||
canonical string
|
||||
aliases []string
|
||||
}{
|
||||
{&ImChatMessageList, "start", []string{"start-time"}},
|
||||
{&ImChatMessageList, "end", []string{"end-time"}},
|
||||
{&ImChatMessageList, "order", []string{"sort-order"}},
|
||||
{&ImChatMessageList, "page-size", []string{"limit"}},
|
||||
{&ImChatMembersList, "page-size", []string{"limit"}},
|
||||
{&ImThreadsMessagesList, "thread", []string{"thread-id"}},
|
||||
{&ImMessagesMGet, "message-ids", []string{"message-id"}},
|
||||
{&ImMessagesSearch, "query", []string{"keyword"}},
|
||||
{&ImMessagesSearch, "page-size", []string{"limit"}},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.shortcut.Command+"/"+test.canonical, func(t *testing.T) {
|
||||
canonical := findIMFlag(t, test.shortcut, test.canonical)
|
||||
if !slices.Equal(canonical.Aliases, test.aliases) {
|
||||
t.Fatalf("--%s aliases = %v, want %v", test.canonical, canonical.Aliases, test.aliases)
|
||||
}
|
||||
for _, alias := range test.aliases {
|
||||
for _, declared := range test.shortcut.Flags {
|
||||
if declared.Name == alias {
|
||||
t.Fatalf("--%s must not be declared as an independent flag", alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMFlagAliasesProduceCanonicalRequests(t *testing.T) {
|
||||
aliasRT, _ := newMountedIMRuntime(t, &ImChatMessageList,
|
||||
"--chat-id", "oc_test",
|
||||
"--start-time", "2026-07-27T00:00:00+08:00",
|
||||
"--end-time", "1785254400",
|
||||
"--sort-order", "asc",
|
||||
"--limit", "25",
|
||||
)
|
||||
canonicalRT, _ := newMountedIMRuntime(t, &ImChatMessageList,
|
||||
"--chat-id", "oc_test",
|
||||
"--start", "2026-07-27T00:00:00+08:00",
|
||||
"--end", "1785254400",
|
||||
"--order", "asc",
|
||||
"--page-size", "25",
|
||||
)
|
||||
aliasParams, err := buildChatMessageListRequest(aliasRT, "oc_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonicalParams, err := buildChatMessageListRequest(canonicalRT, "oc_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(aliasParams, canonicalParams) {
|
||||
t.Fatalf("chat-message alias request = %#v, canonical request = %#v", aliasParams, canonicalParams)
|
||||
}
|
||||
|
||||
aliasRT, _ = newMountedIMRuntime(t, &ImChatMembersList,
|
||||
"--chat-id", "oc_test", "--limit", "25", "--page-all",
|
||||
)
|
||||
canonicalRT, _ = newMountedIMRuntime(t, &ImChatMembersList,
|
||||
"--chat-id", "oc_test", "--page-size", "25", "--page-all",
|
||||
)
|
||||
aliasMemberParams, err := buildChatMembersParams(aliasRT, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonicalMemberParams, err := buildChatMembersParams(canonicalRT, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(aliasMemberParams, canonicalMemberParams) {
|
||||
t.Fatalf("chat-members alias request = %#v, canonical request = %#v", aliasMemberParams, canonicalMemberParams)
|
||||
}
|
||||
|
||||
aliasRT, _ = newMountedIMRuntime(t, &ImMessagesSearch, "--keyword", "project", "--limit", "30")
|
||||
canonicalRT, _ = newMountedIMRuntime(t, &ImMessagesSearch, "--query", "project", "--page-size", "30")
|
||||
aliasSearch, err := buildMessagesSearchRequest(aliasRT)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonicalSearch, err := buildMessagesSearchRequest(canonicalRT)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(aliasSearch, canonicalSearch) {
|
||||
t.Fatalf("message-search alias request = %#v, canonical request = %#v", aliasSearch, canonicalSearch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMAliasValidationReportsCanonicalFlag(t *testing.T) {
|
||||
runtime, _ := newMountedIMRuntime(t, &ImChatMessageList, "--chat-id", "oc_test", "--start-time", "bad-time")
|
||||
_, err := buildChatMessageListRequest(runtime, "oc_test")
|
||||
assertIMValidationError(t, err, "--start", "--start: cannot parse time")
|
||||
|
||||
runtime, _ = newMountedIMRuntime(t, &ImThreadsMessagesList, "--thread-id", "not-a-thread")
|
||||
err = ImThreadsMessagesList.Validate(context.Background(), runtime)
|
||||
assertIMValidationError(t, err, "--thread", `invalid --thread "not-a-thread"`)
|
||||
|
||||
runtime, _ = newMountedIMRuntime(t, &ImMessagesMGet, "--message-id", "not-om")
|
||||
err = ImMessagesMGet.Validate(context.Background(), runtime)
|
||||
assertIMValidationError(t, err, "--message-ids", `invalid message ID "not-om"`)
|
||||
}
|
||||
|
||||
func findIMFlag(t *testing.T, shortcut *common.Shortcut, name string) *common.Flag {
|
||||
t.Helper()
|
||||
for i := range shortcut.Flags {
|
||||
if shortcut.Flags[i].Name == name {
|
||||
return &shortcut.Flags[i]
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s is missing --%s", shortcut.Command, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertIMValidationError(t *testing.T, err error, wantParam, wantMessage string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not typed: %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %#v", problem)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error is not *errs.ValidationError: %T %v", err, err)
|
||||
}
|
||||
if validationErr.Param != wantParam {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, wantParam)
|
||||
}
|
||||
if !strings.Contains(err.Error(), wantMessage) {
|
||||
t.Fatalf("error = %q, want substring %q", err, wantMessage)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,12 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
flagListDefaultPageSize = 50
|
||||
// GET /open-apis/im/v1/flags accepts page_size up to 50.
|
||||
flagListMaxPageSize = 50
|
||||
)
|
||||
|
||||
// ImFlagList provides the +flag-list shortcut for listing bookmarks.
|
||||
// Feed-type thread entries are auto-enriched with message content.
|
||||
var ImFlagList = common.Shortcut{
|
||||
@@ -25,7 +31,7 @@ var ImFlagList = common.Shortcut{
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"},
|
||||
{Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", flagListDefaultPageSize), Desc: fmt.Sprintf("page size (1-%d)", flagListMaxPageSize)},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate, capped by --page-limit"},
|
||||
{Name: "page-limit", Type: "int", Default: "20", Desc: "max pages with --page-all (default 20; configurable range 1-1000)"},
|
||||
@@ -71,8 +77,8 @@ var ImFlagList = common.Shortcut{
|
||||
}
|
||||
|
||||
func validateListOptions(rt *common.RuntimeContext) error {
|
||||
if n := rt.Int("page-size"); n < 1 || n > 50 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 50").WithParam("--page-size")
|
||||
if _, err := common.ValidatePageSizeTyped(rt, "page-size", flagListDefaultPageSize, 1, flagListMaxPageSize); err != nil {
|
||||
return err
|
||||
}
|
||||
if n := rt.Int("page-limit"); n < 1 || n > 1000 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be an integer between 1 and 1000").WithParam("--page-limit")
|
||||
|
||||
671
shortcuts/im/im_list_page_all_test.go
Normal file
671
shortcuts/im/im_list_page_all_test.go
Normal file
@@ -0,0 +1,671 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type listPageAllCase struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
path string
|
||||
method string
|
||||
outputKey string
|
||||
outputID string
|
||||
baseFlags map[string]string
|
||||
makeRawItem func(string) interface{}
|
||||
}
|
||||
|
||||
func listPageAllCases() []listPageAllCase {
|
||||
messageItem := func(id string) interface{} {
|
||||
return map[string]interface{}{
|
||||
"message_id": id,
|
||||
"msg_type": "text",
|
||||
"body": map[string]interface{}{"content": fmt.Sprintf(`{"text":%q}`, id)},
|
||||
"create_time": "0",
|
||||
}
|
||||
}
|
||||
chatItem := func(id string) interface{} {
|
||||
return map[string]interface{}{"chat_id": id, "name": id, "chat_mode": "group"}
|
||||
}
|
||||
searchItem := func(id string) interface{} {
|
||||
return map[string]interface{}{"meta_data": chatItem(id)}
|
||||
}
|
||||
return []listPageAllCase{
|
||||
{
|
||||
name: "chat-messages-list", shortcut: ImChatMessageList,
|
||||
path: "/open-apis/im/v1/messages", method: http.MethodGet,
|
||||
outputKey: "messages", outputID: "message_id",
|
||||
baseFlags: map[string]string{"chat-id": "oc_test", "no-reactions": "true"},
|
||||
makeRawItem: messageItem,
|
||||
},
|
||||
{
|
||||
name: "threads-messages-list", shortcut: ImThreadsMessagesList,
|
||||
path: "/open-apis/im/v1/messages", method: http.MethodGet,
|
||||
outputKey: "messages", outputID: "message_id",
|
||||
baseFlags: map[string]string{"thread": "omt_test", "no-reactions": "true"},
|
||||
makeRawItem: messageItem,
|
||||
},
|
||||
{
|
||||
name: "chat-list", shortcut: ImChatList,
|
||||
path: "/open-apis/im/v1/chats", method: http.MethodGet,
|
||||
outputKey: "chats", outputID: "chat_id",
|
||||
baseFlags: map[string]string{},
|
||||
makeRawItem: chatItem,
|
||||
},
|
||||
{
|
||||
name: "chat-search", shortcut: ImChatSearch,
|
||||
path: "/open-apis/im/v2/chats/search", method: http.MethodPost,
|
||||
outputKey: "chats", outputID: "chat_id",
|
||||
baseFlags: map[string]string{"query": "team"},
|
||||
makeRawItem: searchItem,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newListPageAllCommand(t *testing.T, shortcut common.Shortcut, flags map[string]string) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: shortcut.Command}
|
||||
for _, flag := range shortcut.Flags {
|
||||
switch flag.Type {
|
||||
case "bool":
|
||||
cmd.Flags().Bool(flag.Name, flag.Default == "true", flag.Desc)
|
||||
case "int":
|
||||
defaultValue := 0
|
||||
if flag.Default != "" {
|
||||
defaultValue, _ = strconv.Atoi(flag.Default)
|
||||
}
|
||||
cmd.Flags().Int(flag.Name, defaultValue, flag.Desc)
|
||||
case "string_slice":
|
||||
cmd.Flags().StringSlice(flag.Name, nil, flag.Desc)
|
||||
default:
|
||||
cmd.Flags().String(flag.Name, flag.Default, flag.Desc)
|
||||
}
|
||||
}
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags() error = %v", err)
|
||||
}
|
||||
for name, value := range flags {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s=%s: %v", name, value, err)
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func mergeListPageAllFlags(base map[string]string, overrides map[string]string) map[string]string {
|
||||
flags := make(map[string]string, len(base)+len(overrides))
|
||||
for name, value := range base {
|
||||
flags[name] = value
|
||||
}
|
||||
for name, value := range overrides {
|
||||
flags[name] = value
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
func newListPageAllRuntime(t *testing.T, tc listPageAllCase, flags map[string]string, responder func(*http.Request, int) map[string]interface{}) (*common.RuntimeContext, *int) {
|
||||
t.Helper()
|
||||
calls := 0
|
||||
transport := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.Method != tc.method || req.URL.Path != tc.path {
|
||||
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
|
||||
}
|
||||
calls++
|
||||
data := responder(req, calls)
|
||||
return shortcutJSONResponse(http.StatusOK, map[string]interface{}{"code": 0, "data": data}), nil
|
||||
})
|
||||
runtime := newUserShortcutRuntime(t, transport)
|
||||
allFlags := mergeListPageAllFlags(tc.baseFlags, flags)
|
||||
if _, explicitlyTestingDelay := allFlags["page-delay"]; !explicitlyTestingDelay {
|
||||
// Unit tests exercise pagination semantics without adding wall-clock
|
||||
// latency. The real default remains asserted in the flag-surface test.
|
||||
allFlags["page-delay"] = "0"
|
||||
}
|
||||
runtime.Cmd = newListPageAllCommand(t, tc.shortcut, allFlags)
|
||||
runtime.Format = "json"
|
||||
return runtime, &calls
|
||||
}
|
||||
|
||||
func listPageAllOutputData(t *testing.T, runtime *common.RuntimeContext) map[string]interface{} {
|
||||
t.Helper()
|
||||
envelope := listPageAllOutputEnvelope(t, runtime)
|
||||
data, ok := envelope["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("stdout data has unexpected shape: %#v", envelope["data"])
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func listPageAllOutputEnvelope(t *testing.T, runtime *common.RuntimeContext) map[string]interface{} {
|
||||
t.Helper()
|
||||
out, ok := runtime.IO().Out.(*bytes.Buffer)
|
||||
if !ok {
|
||||
t.Fatal("stdout is not a bytes.Buffer")
|
||||
}
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(out.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("stdout is not JSON: %v\n%s", err, out.String())
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
|
||||
func assertListPaginationMeta(t *testing.T, runtime *common.RuntimeContext, complete bool, pages, items int, nextToken string) {
|
||||
t.Helper()
|
||||
envelope := listPageAllOutputEnvelope(t, runtime)
|
||||
meta, ok := envelope["meta"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("stdout meta has unexpected shape: %#v", envelope["meta"])
|
||||
}
|
||||
pagination, ok := meta["pagination"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("stdout pagination meta has unexpected shape: %#v", meta["pagination"])
|
||||
}
|
||||
if got, _ := pagination["complete"].(bool); got != complete {
|
||||
t.Fatalf("pagination.complete = %v, want %v", got, complete)
|
||||
}
|
||||
if got := int(pagination["pages"].(float64)); got != pages {
|
||||
t.Fatalf("pagination.pages = %d, want %d", got, pages)
|
||||
}
|
||||
if got := int(pagination["items"].(float64)); got != items {
|
||||
t.Fatalf("pagination.items = %d, want %d", got, items)
|
||||
}
|
||||
if got, _ := pagination["next_token"].(string); got != nextToken {
|
||||
t.Fatalf("pagination.next_token = %q, want %q", got, nextToken)
|
||||
}
|
||||
}
|
||||
|
||||
func assertListPageAllOrder(t *testing.T, data map[string]interface{}, tc listPageAllCase, want ...string) {
|
||||
t.Helper()
|
||||
items, ok := data[tc.outputKey].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("%s has unexpected shape: %#v", tc.outputKey, data[tc.outputKey])
|
||||
}
|
||||
if len(items) != len(want) {
|
||||
t.Fatalf("%s length = %d, want %d: %#v", tc.outputKey, len(items), len(want), items)
|
||||
}
|
||||
for i, item := range items {
|
||||
row, _ := item.(map[string]interface{})
|
||||
if got, _ := row[tc.outputID].(string); got != want[i] {
|
||||
t.Fatalf("%s[%d].%s = %q, want %q", tc.outputKey, i, tc.outputID, got, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMListPageAllMergesPagesAndUsesFinalPaginationMeta(t *testing.T) {
|
||||
for _, tc := range listPageAllCases() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var requestTokens []string
|
||||
runtime, calls := newListPageAllRuntime(t, tc, map[string]string{"page-all": "true"}, func(req *http.Request, call int) map[string]interface{} {
|
||||
requestTokens = append(requestTokens, req.URL.Query().Get("page_token"))
|
||||
if call == 1 {
|
||||
return map[string]interface{}{"items": []interface{}{tc.makeRawItem("first")}, "has_more": true, "page_token": "next", "total": 2}
|
||||
}
|
||||
return map[string]interface{}{"items": []interface{}{tc.makeRawItem("second")}, "has_more": false, "page_token": "final", "total": 2}
|
||||
})
|
||||
if err := tc.shortcut.Validate(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
if err := tc.shortcut.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if *calls != 2 {
|
||||
t.Fatalf("API calls = %d, want 2", *calls)
|
||||
}
|
||||
if len(requestTokens) != 2 || requestTokens[0] != "" || requestTokens[1] != "next" {
|
||||
t.Fatalf("request page tokens = %v, want [\"\" \"next\"]", requestTokens)
|
||||
}
|
||||
data := listPageAllOutputData(t, runtime)
|
||||
assertListPageAllOrder(t, data, tc, "first", "second")
|
||||
if hasMore, _ := data["has_more"].(bool); hasMore {
|
||||
t.Fatalf("has_more = true, want final page value false")
|
||||
}
|
||||
if token, _ := data["page_token"].(string); token != "final" {
|
||||
t.Fatalf("page_token = %q, want final", token)
|
||||
}
|
||||
assertListPaginationMeta(t, runtime, true, 2, 2, "")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMListPageAllRejectsRepeatedToken(t *testing.T) {
|
||||
for _, tc := range listPageAllCases() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runtime, calls := newListPageAllRuntime(t, tc, map[string]string{"page-all": "true"}, func(_ *http.Request, call int) map[string]interface{} {
|
||||
return map[string]interface{}{"items": []interface{}{tc.makeRawItem(fmt.Sprintf("item-%d", call))}, "has_more": true, "page_token": "same", "total": 10}
|
||||
})
|
||||
err := tc.shortcut.Execute(context.Background(), runtime)
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("Execute() error = %v, want typed error", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("Execute() problem = (%q, %q), want (%q, %q)",
|
||||
problem.Category, problem.Subtype, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "repeated page token") {
|
||||
t.Fatalf("Execute() message = %q, want repeated-token diagnosis", problem.Message)
|
||||
}
|
||||
if *calls != 2 {
|
||||
t.Fatalf("API calls = %d, want 2", *calls)
|
||||
}
|
||||
if stderr := runtime.IO().ErrOut.(*bytes.Buffer).String(); strings.Contains(stderr, "reached page limit") {
|
||||
t.Fatalf("repeated token must not report a page-limit stop: %q", stderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMListPageAllReportsIncompleteResultOnPageLimit(t *testing.T) {
|
||||
for _, tc := range listPageAllCases() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runtime, calls := newListPageAllRuntime(t, tc, map[string]string{"page-all": "true", "page-limit": "2"}, func(_ *http.Request, call int) map[string]interface{} {
|
||||
return map[string]interface{}{"items": []interface{}{tc.makeRawItem(fmt.Sprintf("item-%d", call))}, "has_more": true, "page_token": fmt.Sprintf("token-%d", call), "total": 10}
|
||||
})
|
||||
if err := tc.shortcut.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if *calls != 2 {
|
||||
t.Fatalf("API calls = %d, want 2", *calls)
|
||||
}
|
||||
data := listPageAllOutputData(t, runtime)
|
||||
assertListPageAllOrder(t, data, tc, "item-1", "item-2")
|
||||
if hasMore, _ := data["has_more"].(bool); !hasMore {
|
||||
t.Fatal("has_more = false, want true for incomplete result")
|
||||
}
|
||||
if token, _ := data["page_token"].(string); token != "token-2" {
|
||||
t.Fatalf("page_token = %q, want token-2", token)
|
||||
}
|
||||
if _, exists := data["pages"]; exists {
|
||||
t.Fatalf("output shape changed: unexpected pages field in %#v", data)
|
||||
}
|
||||
assertListPaginationMeta(t, runtime, false, 2, 2, "token-2")
|
||||
stderr := runtime.IO().ErrOut.(*bytes.Buffer).String()
|
||||
for _, forbidden := range []string{"reached page limit", "result is incomplete", "Increase --page-limit"} {
|
||||
if strings.Contains(stderr, forbidden) {
|
||||
t.Fatalf("stderr contains business pagination warning %q: %s", forbidden, stderr)
|
||||
}
|
||||
}
|
||||
stdout := runtime.IO().Out.(*bytes.Buffer).String()
|
||||
for _, forbidden := range []string{"[pagination]", "result is incomplete", "Increase --page-limit"} {
|
||||
if strings.Contains(stdout, forbidden) {
|
||||
t.Fatalf("stdout contains pagination notice %q: %s", forbidden, stdout)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMListPageAllContinuesFromExplicitPageToken(t *testing.T) {
|
||||
for _, tc := range listPageAllCases() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var requestTokens []string
|
||||
runtime, calls := newListPageAllRuntime(t, tc, map[string]string{"page-all": "true", "page-token": "resume"}, func(req *http.Request, call int) map[string]interface{} {
|
||||
requestTokens = append(requestTokens, req.URL.Query().Get("page_token"))
|
||||
if call == 1 {
|
||||
return map[string]interface{}{"items": []interface{}{tc.makeRawItem("first")}, "has_more": true, "page_token": "next", "total": 2}
|
||||
}
|
||||
return map[string]interface{}{"items": []interface{}{tc.makeRawItem("second")}, "has_more": false, "page_token": "final", "total": 2}
|
||||
})
|
||||
if err := tc.shortcut.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if *calls != 2 || !reflect.DeepEqual(requestTokens, []string{"resume", "next"}) {
|
||||
t.Fatalf("calls=%d page tokens=%v, want two calls from resume to next", *calls, requestTokens)
|
||||
}
|
||||
data := listPageAllOutputData(t, runtime)
|
||||
assertListPageAllOrder(t, data, tc, "first", "second")
|
||||
assertListPaginationMeta(t, runtime, true, 2, 2, "")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMListSinglePageUsesUnifiedPaginationMeta(t *testing.T) {
|
||||
for _, tc := range listPageAllCases() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runtime, calls := newListPageAllRuntime(t, tc, nil, func(_ *http.Request, _ int) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"items": []interface{}{tc.makeRawItem("only")},
|
||||
"has_more": true,
|
||||
"page_token": "next",
|
||||
"total": 1,
|
||||
}
|
||||
})
|
||||
if err := tc.shortcut.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if *calls != 1 {
|
||||
t.Fatalf("API calls = %d, want 1", *calls)
|
||||
}
|
||||
assertListPaginationMeta(t, runtime, false, 1, 1, "next")
|
||||
if stderr := runtime.IO().ErrOut.(*bytes.Buffer).String(); stderr != "" {
|
||||
t.Fatalf("single-page call wrote pagination warning/progress: %q", stderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatListRecordFormatsKeepStdoutPureAndReportPagination(t *testing.T) {
|
||||
var tc listPageAllCase
|
||||
for _, candidate := range listPageAllCases() {
|
||||
if candidate.name == "chat-list" {
|
||||
tc = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, format := range []string{"ndjson", "csv"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
runtime, _ := newListPageAllRuntime(t, tc, nil, func(_ *http.Request, _ int) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"items": []interface{}{tc.makeRawItem("only")},
|
||||
"has_more": true,
|
||||
"page_token": "next",
|
||||
}
|
||||
})
|
||||
runtime.Format = format
|
||||
|
||||
if err := tc.shortcut.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
stdout := runtime.IO().Out.(*bytes.Buffer).String()
|
||||
if !strings.Contains(stdout, "only") {
|
||||
t.Fatalf("%s stdout = %q, want chat record", format, stdout)
|
||||
}
|
||||
if strings.Contains(stdout, "_diagnostic") || strings.Contains(stdout, "next_token") {
|
||||
t.Fatalf("%s stdout contains pagination metadata: %q", format, stdout)
|
||||
}
|
||||
|
||||
var diagnostic map[string]interface{}
|
||||
stderr := runtime.IO().ErrOut.(*bytes.Buffer).Bytes()
|
||||
if err := json.Unmarshal(bytes.TrimSpace(stderr), &diagnostic); err != nil {
|
||||
t.Fatalf("decode stderr diagnostic %q: %v", stderr, err)
|
||||
}
|
||||
if diagnostic["_diagnostic"] != "pagination" || diagnostic["complete"] != false || diagnostic["pages"] != float64(1) || diagnostic["items"] != float64(1) || diagnostic["next_token"] != "next" {
|
||||
t.Fatalf("pagination diagnostic = %#v", diagnostic)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMListPageLimitValidation(t *testing.T) {
|
||||
for _, tc := range listPageAllCases() {
|
||||
for _, limit := range []string{"0", "1001"} {
|
||||
t.Run(tc.name+"/"+limit, func(t *testing.T) {
|
||||
runtime, _ := newListPageAllRuntime(t, tc, map[string]string{"page-limit": limit}, func(_ *http.Request, _ int) map[string]interface{} {
|
||||
t.Fatal("validation must fail before an API request")
|
||||
return nil
|
||||
})
|
||||
err := tc.shortcut.Validate(context.Background(), runtime)
|
||||
assertValidationError(t, tc.name, err, "--page-limit")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMListPageDelayValidation(t *testing.T) {
|
||||
for _, tc := range listPageAllCases() {
|
||||
for _, delay := range []string{"-1", "60001"} {
|
||||
t.Run(tc.name+"/"+delay, func(t *testing.T) {
|
||||
runtime, _ := newListPageAllRuntime(t, tc, map[string]string{"page-delay": delay}, func(_ *http.Request, _ int) map[string]interface{} {
|
||||
t.Fatal("validation must fail before an API request")
|
||||
return nil
|
||||
})
|
||||
err := tc.shortcut.Validate(context.Background(), runtime)
|
||||
assertValidationError(t, tc.name, err, "--page-delay")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMListPageAllDryRunAndFlagSurface(t *testing.T) {
|
||||
for _, tc := range listPageAllCases() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runtime, _ := newListPageAllRuntime(t, tc, map[string]string{"page-all": "true"}, func(_ *http.Request, _ int) map[string]interface{} {
|
||||
t.Fatal("dry-run must not make an API request")
|
||||
return nil
|
||||
})
|
||||
dryRun := mustMarshalDryRun(t, tc.shortcut.DryRun(context.Background(), runtime))
|
||||
var dryRunData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(dryRun), &dryRunData); err != nil {
|
||||
t.Fatalf("decode dry-run: %v", err)
|
||||
}
|
||||
if description, _ := dryRunData["description"].(string); description != pageAllDryRunDescription {
|
||||
t.Fatalf("dry-run missing auto-pagination description: %s", dryRun)
|
||||
}
|
||||
|
||||
flags := make(map[string]common.Flag)
|
||||
for _, flag := range tc.shortcut.Flags {
|
||||
flags[flag.Name] = flag
|
||||
}
|
||||
if flag := flags[common.PageAllFlagName]; flag.Type != "bool" || flag.Desc != "automatically paginate until exhaustion or --page-limit" {
|
||||
t.Fatalf("page-all flag = %#v", flag)
|
||||
}
|
||||
if flag := flags["page-limit"]; flag.Type != "int" || flag.Default != "10" || !strings.Contains(flag.Desc, "1-1000") {
|
||||
t.Fatalf("page-limit flag = %#v", flag)
|
||||
}
|
||||
if flag := flags["page-delay"]; flag.Type != "int" || flag.Default != "200" || !strings.Contains(flag.Desc, "0-60000") {
|
||||
t.Fatalf("page-delay flag = %#v", flag)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageListPageAllEnrichesMergedMessagesOnce(t *testing.T) {
|
||||
messageItem := func(id string) interface{} {
|
||||
return map[string]interface{}{
|
||||
"message_id": id,
|
||||
"msg_type": "text",
|
||||
"body": map[string]interface{}{"content": fmt.Sprintf(`{"text":%q}`, id)},
|
||||
"create_time": "0",
|
||||
}
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
flags map[string]string
|
||||
}{
|
||||
{name: "chat-messages-list", shortcut: ImChatMessageList, flags: map[string]string{"chat-id": "oc_test", "page-all": "true"}},
|
||||
{name: "threads-messages-list", shortcut: ImThreadsMessagesList, flags: map[string]string{"thread": "omt_test", "page-all": "true"}},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pageCalls := 0
|
||||
reactionCalls := 0
|
||||
reactionQueries := 0
|
||||
transport := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.Path {
|
||||
case "/open-apis/im/v1/messages":
|
||||
pageCalls++
|
||||
if pageCalls == 1 {
|
||||
return shortcutJSONResponse(http.StatusOK, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"items": []interface{}{messageItem("first")}, "has_more": true, "page_token": "next"},
|
||||
}), nil
|
||||
}
|
||||
return shortcutJSONResponse(http.StatusOK, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"items": []interface{}{messageItem("second")}, "has_more": false, "page_token": "final"},
|
||||
}), nil
|
||||
case "/open-apis/im/v1/messages/reactions/batch_query":
|
||||
reactionCalls++
|
||||
var body struct {
|
||||
Queries []map[string]interface{} `json:"queries"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode reaction request: %v", err)
|
||||
}
|
||||
reactionQueries = len(body.Queries)
|
||||
return shortcutJSONResponse(http.StatusOK, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"success_msg_reaction_counts": []interface{}{},
|
||||
"success_msg_reaction_details": []interface{}{},
|
||||
},
|
||||
}), nil
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
|
||||
return nil, nil
|
||||
}
|
||||
})
|
||||
runtime := newUserShortcutRuntime(t, transport)
|
||||
runtime.Cmd = newListPageAllCommand(t, tc.shortcut, tc.flags)
|
||||
runtime.Format = "json"
|
||||
|
||||
if err := tc.shortcut.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if pageCalls != 2 {
|
||||
t.Fatalf("message page calls = %d, want 2", pageCalls)
|
||||
}
|
||||
if reactionCalls != 1 {
|
||||
t.Fatalf("reaction batch calls = %d, want 1 after page merge", reactionCalls)
|
||||
}
|
||||
if reactionQueries != 2 {
|
||||
t.Fatalf("reaction query count = %d, want both merged messages", reactionQueries)
|
||||
}
|
||||
assertListPaginationMeta(t, runtime, true, 2, 2, "")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatListPageAllFiltersMergedChatsOnce(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
path string
|
||||
flags map[string]string
|
||||
makeItem func(string) interface{}
|
||||
}{
|
||||
{
|
||||
name: "chat-list", shortcut: ImChatList, path: "/open-apis/im/v1/chats",
|
||||
flags: map[string]string{"page-all": "true", "exclude-muted": "true"},
|
||||
makeItem: func(id string) interface{} {
|
||||
return map[string]interface{}{"chat_id": id, "name": id, "chat_mode": "group"}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "chat-search", shortcut: ImChatSearch, path: "/open-apis/im/v2/chats/search",
|
||||
flags: map[string]string{"query": "team", "page-all": "true", "exclude-muted": "true"},
|
||||
makeItem: func(id string) interface{} {
|
||||
return map[string]interface{}{"meta_data": map[string]interface{}{"chat_id": id, "name": id, "chat_mode": "group"}}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pageCalls := 0
|
||||
muteCalls := 0
|
||||
muteChatIDs := 0
|
||||
transport := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.Path {
|
||||
case tc.path:
|
||||
pageCalls++
|
||||
if pageCalls == 1 {
|
||||
return shortcutJSONResponse(http.StatusOK, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"items": []interface{}{tc.makeItem("oc_first")}, "has_more": true, "page_token": "next"},
|
||||
}), nil
|
||||
}
|
||||
return shortcutJSONResponse(http.StatusOK, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"items": []interface{}{tc.makeItem("oc_second")}, "has_more": false, "page_token": "final"},
|
||||
}), nil
|
||||
case BatchGetMuteStatusPath:
|
||||
muteCalls++
|
||||
var body struct {
|
||||
ChatIDs []string `json:"chat_ids"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode mute-status request: %v", err)
|
||||
}
|
||||
muteChatIDs = len(body.ChatIDs)
|
||||
return shortcutJSONResponse(http.StatusOK, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"chat_id": "oc_first", "is_muted": false},
|
||||
map[string]interface{}{"chat_id": "oc_second", "is_muted": false},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
|
||||
return nil, nil
|
||||
}
|
||||
})
|
||||
runtime := newUserShortcutRuntime(t, transport)
|
||||
runtime.Cmd = newListPageAllCommand(t, tc.shortcut, tc.flags)
|
||||
runtime.Format = "json"
|
||||
|
||||
if err := tc.shortcut.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if pageCalls != 2 {
|
||||
t.Fatalf("chat page calls = %d, want 2", pageCalls)
|
||||
}
|
||||
if muteCalls != 1 {
|
||||
t.Fatalf("mute-status calls = %d, want 1 after page merge", muteCalls)
|
||||
}
|
||||
if muteChatIDs != 2 {
|
||||
t.Fatalf("mute-status chat ID count = %d, want both merged chats", muteChatIDs)
|
||||
}
|
||||
assertListPaginationMeta(t, runtime, true, 2, 2, "")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatSearchPageAllRetainsNoticeFromEarlierPage(t *testing.T) {
|
||||
const notice = "The query was truncated before search."
|
||||
var searchCase listPageAllCase
|
||||
for _, tc := range listPageAllCases() {
|
||||
if tc.name == "chat-search" {
|
||||
searchCase = tc
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
runtime, calls := newListPageAllRuntime(t, searchCase, map[string]string{"page-all": "true"}, func(_ *http.Request, call int) map[string]interface{} {
|
||||
if call == 1 {
|
||||
return map[string]interface{}{
|
||||
"items": []interface{}{searchCase.makeRawItem("oc_first")},
|
||||
"notice": notice,
|
||||
"total": 2,
|
||||
"has_more": true,
|
||||
"page_token": "next",
|
||||
}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"items": []interface{}{searchCase.makeRawItem("oc_second")},
|
||||
"total": 2,
|
||||
"has_more": false,
|
||||
"page_token": "final",
|
||||
}
|
||||
})
|
||||
if err := ImChatSearch.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if *calls != 2 {
|
||||
t.Fatalf("API calls = %d, want 2", *calls)
|
||||
}
|
||||
data := listPageAllOutputData(t, runtime)
|
||||
if got, _ := data["notice"].(string); got != notice {
|
||||
t.Fatalf("notice = %q, want %q", got, notice)
|
||||
}
|
||||
assertListPaginationMeta(t, runtime, true, 2, 2, "")
|
||||
}
|
||||
56
shortcuts/im/im_list_pagination.go
Normal file
56
shortcuts/im/im_list_pagination.go
Normal file
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
const imMessagesListPath = "/open-apis/im/v1/messages"
|
||||
|
||||
const pageAllDryRunDescription = "Auto-paginates until exhaustion or --page-limit is reached"
|
||||
|
||||
// imMapListPage is the page shape shared by IM endpoints whose items are JSON
|
||||
// objects. Endpoint-specific shapes such as chat search stay in their command.
|
||||
type imMapListPage struct {
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
HasMore bool `json:"has_more"`
|
||||
PageToken string `json:"page_token"`
|
||||
NextPageToken string `json:"next_page_token"`
|
||||
}
|
||||
|
||||
type imMapListResult struct {
|
||||
items []map[string]interface{}
|
||||
hasMore bool
|
||||
pageToken string
|
||||
}
|
||||
|
||||
func (result *imMapListResult) AddPage(page imMapListPage) error {
|
||||
for _, item := range page.Items {
|
||||
if item != nil {
|
||||
result.items = append(result.items, item)
|
||||
}
|
||||
}
|
||||
result.hasMore = page.HasMore
|
||||
result.pageToken = page.PageToken
|
||||
if result.pageToken == "" {
|
||||
result.pageToken = page.NextPageToken
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// interfaceItems adapts typed items only at the legacy merge-forward boundary.
|
||||
func (result *imMapListResult) interfaceItems() []interface{} {
|
||||
items := make([]interface{}, len(result.items))
|
||||
for i, item := range result.items {
|
||||
items[i] = item
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// messageListPageParams preserves the SDK query map's repeated-value shape for
|
||||
// the shared paginator.
|
||||
func messageListPageParams(params map[string][]string) map[string]interface{} {
|
||||
out := make(map[string]interface{}, len(params))
|
||||
for name, values := range params {
|
||||
out[name] = append([]string(nil), values...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -28,7 +28,7 @@ var ImMessagesMGet = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "message-ids", Desc: "message IDs, comma-separated (om_xxx,om_yyy)", Required: true},
|
||||
{Name: "message-ids", Aliases: []string{"message-id"}, 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)"},
|
||||
downloadResourcesFlag,
|
||||
},
|
||||
@@ -53,7 +53,7 @@ var ImMessagesMGet = common.Shortcut{
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-ids supports at most %d IDs per request (got %d)", maxMGetMessageIDs, len(ids)).WithParam("--message-ids")
|
||||
}
|
||||
for _, id := range ids {
|
||||
if _, err := validateMessageID(id); err != nil {
|
||||
if _, err := validateMessageIDForParam(id, "--message-ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
messagesSearchDefaultPageSize = 20
|
||||
messagesSearchDefaultPageSize = 20
|
||||
// POST /open-apis/im/v1/messages/search accepts page_size up to 50.
|
||||
messagesSearchMaxPageSize = 50
|
||||
messagesSearchDefaultPageLimit = 20
|
||||
messagesSearchMaxPageLimit = 40
|
||||
@@ -34,7 +35,7 @@ var ImMessagesSearch = common.Shortcut{
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword"},
|
||||
{Name: "query", Aliases: []string{"keyword"}, Desc: "search keyword"},
|
||||
{Name: "chat-id", Desc: "limit to chat IDs, comma-separated"},
|
||||
{Name: "sender", Desc: "sender open_ids, comma-separated"},
|
||||
{Name: "include-attachment-type", Desc: "include attachment type filter", Enum: []string{"file", "image", "video", "link"}},
|
||||
@@ -45,7 +46,7 @@ var ImMessagesSearch = common.Shortcut{
|
||||
{Name: "at-chatter-ids", Desc: "filter by @mentioned user open_ids, comma-separated (also matches messages that @all)"},
|
||||
{Name: "start", Desc: "start time(ISO 8601) with local timezone offset (e.g. 2026-03-24T00:00:00+08:00)"},
|
||||
{Name: "end", Desc: "end time(ISO 8601) with local timezone offset (e.g. 2026-03-25T23:59:59+08:00)"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-50)"},
|
||||
{Name: "page-size", Aliases: []string{"limit"}, Type: "int", Default: fmt.Sprintf("%d", messagesSearchDefaultPageSize), Desc: fmt.Sprintf("page size (1-%d)", messagesSearchMaxPageSize)},
|
||||
{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)"},
|
||||
@@ -365,12 +366,9 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
|
||||
body["filter"] = filter
|
||||
}
|
||||
|
||||
pageSize := runtime.Int("page-size")
|
||||
if pageSize < 1 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 50").WithParam("--page-size")
|
||||
}
|
||||
if pageSize > messagesSearchMaxPageSize {
|
||||
pageSize = messagesSearchMaxPageSize
|
||||
pageSize, err := common.ValidatePageSizeTyped(runtime, "page-size", messagesSearchDefaultPageSize, 1, messagesSearchMaxPageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := larkcore.QueryParams{
|
||||
|
||||
125
shortcuts/im/im_page_size_validation_test.go
Normal file
125
shortcuts/im/im_page_size_validation_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type imPageSizeValidationCase struct {
|
||||
shortcut common.Shortcut
|
||||
flags map[string]string
|
||||
defaultSize int
|
||||
maxSize int
|
||||
}
|
||||
|
||||
func imPageSizeValidationCases() []imPageSizeValidationCase {
|
||||
return []imPageSizeValidationCase{
|
||||
{shortcut: ImThreadsMessagesList, flags: map[string]string{"thread": "omt_test"}, defaultSize: threadsMessagesListDefaultPageSize, maxSize: threadsMessagesListMaxPageSize},
|
||||
{shortcut: ImChatMessageList, flags: map[string]string{"chat-id": "oc_test"}, defaultSize: chatMessagesListDefaultPageSize, maxSize: chatMessagesListMaxPageSize},
|
||||
{shortcut: ImMessagesSearch, flags: map[string]string{"query": "test"}, defaultSize: messagesSearchDefaultPageSize, maxSize: messagesSearchMaxPageSize},
|
||||
{shortcut: ImFlagList, defaultSize: flagListDefaultPageSize, maxSize: flagListMaxPageSize},
|
||||
{shortcut: ImFeedGroupList, defaultSize: feedGroupListDefaultPageSize, maxSize: feedGroupListMaxPageSize},
|
||||
{shortcut: ImFeedGroupListItem, flags: map[string]string{"feed-group-id": "ofg_test"}, defaultSize: feedGroupListItemDefaultPageSize, maxSize: feedGroupListItemMaxPageSize},
|
||||
{shortcut: ImChatSearch, flags: map[string]string{"query": "test"}, defaultSize: chatSearchDefaultPageSize, maxSize: chatSearchMaxPageSize},
|
||||
{shortcut: ImChatList, defaultSize: chatListDefaultPageSize, maxSize: chatListMaxPageSize},
|
||||
{shortcut: ImChatMembersList, flags: map[string]string{"chat-id": "oc_test"}, defaultSize: chatMembersListDefaultPageSize, maxSize: chatMembersListMaxPageSize},
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMPageSizeFlagContracts(t *testing.T) {
|
||||
covered := make(map[string]struct{})
|
||||
for _, tc := range imPageSizeValidationCases() {
|
||||
t.Run(tc.shortcut.Command, func(t *testing.T) {
|
||||
covered[tc.shortcut.Command] = struct{}{}
|
||||
pageSizeFlag := findIMPageSizeFlag(t, &tc.shortcut)
|
||||
if got, want := pageSizeFlag.Default, strconv.Itoa(tc.defaultSize); got != want {
|
||||
t.Fatalf("page-size default = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := pageSizeFlag.Desc, fmt.Sprintf("page size (1-%d)", tc.maxSize); got != want {
|
||||
t.Fatalf("page-size description = %q, want %q", got, want)
|
||||
}
|
||||
if tc.defaultSize < 1 || tc.defaultSize > tc.maxSize {
|
||||
t.Fatalf("page-size default %d is outside 1-%d", tc.defaultSize, tc.maxSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, shortcut := range Shortcuts() {
|
||||
if !hasIMFlag(&shortcut, "page-size") {
|
||||
continue
|
||||
}
|
||||
if _, ok := covered[shortcut.Command]; !ok {
|
||||
t.Errorf("%s has --page-size but no boundary contract test", shortcut.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMPageSizeValidationAcceptsMaximumAndRejectsNextValue(t *testing.T) {
|
||||
for _, tc := range imPageSizeValidationCases() {
|
||||
t.Run(tc.shortcut.Command, func(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
pageSize int
|
||||
wantError bool
|
||||
}{
|
||||
{name: "accepts-server-maximum", pageSize: tc.maxSize},
|
||||
{name: "rejects-maximum-plus-one", pageSize: tc.maxSize + 1, wantError: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
requestCount := 0
|
||||
runtime := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
requestCount++
|
||||
t.Fatalf("validation sent an HTTP request: %s %s", req.Method, req.URL.String())
|
||||
return nil, nil
|
||||
}))
|
||||
flags := mergeListPageAllFlags(tc.flags, map[string]string{"page-size": strconv.Itoa(test.pageSize)})
|
||||
runtime.Cmd = newListPageAllCommand(t, tc.shortcut, flags)
|
||||
|
||||
err := tc.shortcut.Validate(context.Background(), runtime)
|
||||
if !test.wantError {
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
} else {
|
||||
assertValidationError(t, tc.shortcut.Command, err, "--page-size")
|
||||
wantMessage := fmt.Sprintf("invalid --page-size %d: must be between 1 and %d", test.pageSize, tc.maxSize)
|
||||
if err.Error() != wantMessage {
|
||||
t.Fatalf("Validate() error = %q, want %q", err.Error(), wantMessage)
|
||||
}
|
||||
}
|
||||
if requestCount != 0 {
|
||||
t.Fatalf("HTTP request count = %d, want 0", requestCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func findIMPageSizeFlag(t *testing.T, shortcut *common.Shortcut) *common.Flag {
|
||||
t.Helper()
|
||||
for i := range shortcut.Flags {
|
||||
if shortcut.Flags[i].Name == "page-size" {
|
||||
return &shortcut.Flags[i]
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s is missing --page-size", shortcut.Command)
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasIMFlag(shortcut *common.Shortcut, name string) bool {
|
||||
for i := range shortcut.Flags {
|
||||
if shortcut.Flags[i].Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -96,10 +96,12 @@ func newChatSearchNoticeTestCommand(t *testing.T, query string) *cobra.Command {
|
||||
for _, name := range []string{"query", "search-types", "member-ids", "sort-by", "page-token"} {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for _, name := range []string{"is-manager", "disable-search-by-user", "exclude-muted"} {
|
||||
for _, name := range []string{"is-manager", "disable-search-by-user", "exclude-muted", common.PageAllFlagName} {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
cmd.Flags().Int("page-size", 20, "")
|
||||
cmd.Flags().Int("page-limit", 10, "")
|
||||
cmd.Flags().Int("page-delay", 200, "")
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -17,46 +17,55 @@ import (
|
||||
convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib"
|
||||
)
|
||||
|
||||
const threadsMessagesMaxPageSize = 500
|
||||
const (
|
||||
threadsMessagesListDefaultPageSize = 50
|
||||
// GET /open-apis/im/v1/messages accepts page_size up to 50.
|
||||
threadsMessagesListMaxPageSize = 50
|
||||
)
|
||||
|
||||
var ImThreadsMessagesList = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+threads-messages-list",
|
||||
Description: "List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination",
|
||||
Description: "List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports --order asc|desc sorting, auto-pagination",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "thread", Desc: "thread ID (om_xxx or omt_xxx)", Required: true},
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "thread", Aliases: []string{"thread-id"}, Desc: "thread ID (om_xxx or omt_xxx)", Required: true},
|
||||
{Name: "order", Default: "asc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}},
|
||||
{Name: "sort", Hidden: true, Desc: "alias of --order (hidden)", Enum: []string{"asc", "desc"}},
|
||||
{Name: "page-size", Default: "50", Desc: "page size (1-500)"},
|
||||
{Name: "sort", Hidden: true, Desc: "legacy name for --order", Enum: []string{"asc", "desc"}},
|
||||
{Name: "page-size", Default: fmt.Sprintf("%d", threadsMessagesListDefaultPageSize), Desc: fmt.Sprintf("page size (1-%d)", threadsMessagesListMaxPageSize)},
|
||||
{Name: "page-token", Desc: "page token"},
|
||||
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
|
||||
downloadResourcesFlag,
|
||||
},
|
||||
}, common.PageAllFlags()...),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
threadFlag := runtime.Str("thread")
|
||||
dir := resolveThreadsOrder(runtime)
|
||||
pageSizeStr := runtime.Str("page-size")
|
||||
pageToken := runtime.Str("page-token")
|
||||
|
||||
pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
|
||||
|
||||
d := common.NewDryRunAPI()
|
||||
pageSize, err := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesListDefaultPageSize, 1, threadsMessagesListMaxPageSize)
|
||||
if err != nil {
|
||||
return d.Desc(err.Error())
|
||||
}
|
||||
containerID := threadFlag
|
||||
if messageIDRe.MatchString(threadFlag) {
|
||||
d.Desc("(--thread provided as message ID) Will resolve thread_id via GET /open-apis/im/v1/messages/:message_id at execution time")
|
||||
containerID = "<resolved_thread_id>"
|
||||
}
|
||||
if runtime.Bool(common.PageAllFlagName) {
|
||||
d.Desc(pageAllDryRunDescription)
|
||||
}
|
||||
|
||||
params := buildThreadsMessagesListParams(dir, containerID, pageSize, pageToken)
|
||||
|
||||
d = d.
|
||||
GET("/open-apis/im/v1/messages").
|
||||
GET(imMessagesListPath).
|
||||
Params(toDryParams(params)).
|
||||
Set("thread", threadFlag).Set("order", dir).Set("page_size", pageSizeStr)
|
||||
if !runtime.Bool("no-reactions") {
|
||||
@@ -70,34 +79,50 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
threadId := runtime.Str("thread")
|
||||
const threadParam = "--thread"
|
||||
if threadId == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--thread is required (om_xxx or omt_xxx)").WithParam("--thread")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s is required (om_xxx or omt_xxx)", threadParam).WithParam(threadParam)
|
||||
}
|
||||
if !strings.HasPrefix(threadId, "om_") && !strings.HasPrefix(threadId, "omt_") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --thread %q: must start with om_ or omt_", threadId).WithParam("--thread")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s %q: must start with om_ or omt_", threadParam, threadId).WithParam(threadParam)
|
||||
}
|
||||
_, err := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
|
||||
return err
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesListDefaultPageSize, 1, threadsMessagesListMaxPageSize); err != nil {
|
||||
return err
|
||||
}
|
||||
return common.ValidatePageAllFlags(runtime)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
threadId, err := resolveThreadID(runtime, runtime.Str("thread"))
|
||||
pageSize, err := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesListDefaultPageSize, 1, threadsMessagesListMaxPageSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
threadInput := runtime.Str("thread")
|
||||
threadId, err := resolveThreadID(runtime, threadInput)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := resolveThreadsOrder(runtime)
|
||||
pageToken := runtime.Str("page-token")
|
||||
|
||||
pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
|
||||
|
||||
params := buildThreadsMessagesListParams(dir, threadId, pageSize, pageToken)
|
||||
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil)
|
||||
// Fetch: one page and all pages share the common paginator; the
|
||||
// thread command owns only its request and the shared IM page shape.
|
||||
result := &imMapListResult{}
|
||||
pagination, err := common.PaginateInto(runtime, common.PageRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: imMessagesListPath,
|
||||
Params: messageListPageParams(params),
|
||||
}, result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rawItems, _ := data["items"].([]interface{})
|
||||
hasMore, nextPageToken := common.PaginationMeta(data)
|
||||
rawItems := result.interfaceItems()
|
||||
hasMore := result.hasMore
|
||||
nextPageToken := result.pageToken
|
||||
|
||||
// Transform: merge-forward prefetch, sender resolution, reactions and
|
||||
// resource extraction all run once over the merged message set.
|
||||
nameCache := make(map[string]string)
|
||||
// Pre-fetch merge_forward sub-messages concurrently before the per-item
|
||||
// conversion loop. Thread replies that are themselves merge_forward
|
||||
@@ -108,8 +133,7 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
|
||||
downloadResources := runtime.Bool("download-resources")
|
||||
messages := make([]map[string]interface{}, 0, len(rawItems))
|
||||
for _, item := range rawItems {
|
||||
m, _ := item.(map[string]interface{})
|
||||
for _, m := range result.items {
|
||||
messages = append(messages, convertlib.FormatMessageItemWithMergePrefetchOpts(m, runtime, nameCache, mergePrefetch, downloadResources))
|
||||
}
|
||||
|
||||
@@ -122,7 +146,10 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
if downloadResources {
|
||||
enrichMessageResourceDownloads(runtime, messages)
|
||||
}
|
||||
pagination.Items = len(messages)
|
||||
|
||||
// Emit: keep legacy data fields while publishing the authoritative run
|
||||
// outcome through the shared output metadata contract.
|
||||
outData := map[string]interface{}{
|
||||
"thread_id": threadId,
|
||||
"messages": messages,
|
||||
@@ -130,7 +157,9 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
"has_more": hasMore,
|
||||
"page_token": nextPageToken,
|
||||
}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
runtime.OutFormat(outData, &output.Meta{
|
||||
Pagination: pagination,
|
||||
}, func(w io.Writer) {
|
||||
if len(messages) == 0 {
|
||||
fmt.Fprintln(w, "No messages in this thread.")
|
||||
return
|
||||
@@ -152,11 +181,7 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
rows = append(rows, row)
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
moreHint := ""
|
||||
if hasMore {
|
||||
moreHint = fmt.Sprintf(" (more available, page_token: %s)", nextPageToken)
|
||||
}
|
||||
fmt.Fprintf(w, "\n%d thread message(s)%s\ntip: use --format json to view full message content\n", len(messages), moreHint)
|
||||
fmt.Fprintf(w, "\n%d thread message(s)\ntip: use --format json to view full message content\n", len(messages))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
@@ -185,11 +210,10 @@ func buildThreadsMessagesListParams(dir, containerID string, pageSize int, pageT
|
||||
return params
|
||||
}
|
||||
|
||||
// resolveThreadsOrder picks --order, falling back to the hidden --sort alias.
|
||||
func resolveThreadsOrder(runtime *common.RuntimeContext) string {
|
||||
dir := runtime.Str("order")
|
||||
if old, ok := aliasFlagValue(runtime, "sort", "order"); ok {
|
||||
dir = old
|
||||
if legacy, ok := legacyFlagValue(runtime, "sort", "order"); ok {
|
||||
dir = legacy
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ func newThreadsTestRT(t *testing.T, stringFlags map[string]string) *common.Runti
|
||||
stringFlags = map[string]string{}
|
||||
}
|
||||
if _, ok := stringFlags["thread"]; !ok {
|
||||
stringFlags["thread"] = "omt_test"
|
||||
if _, aliasSet := stringFlags["thread-id"]; !aliasSet {
|
||||
stringFlags["thread"] = "omt_test"
|
||||
}
|
||||
}
|
||||
return newChatListTestRuntimeContext(t, stringFlags, nil)
|
||||
}
|
||||
@@ -37,13 +39,13 @@ func TestThreadsMessagesList_OrderMapping(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestThreadsMessagesList_OrderAliasParity proves DryRun(--sort dir) == DryRun(--order dir).
|
||||
// This is the test the refactor exists to make meaningful (single shared mapping).
|
||||
func TestThreadsMessagesList_OrderAliasParity(t *testing.T) {
|
||||
// TestThreadsMessagesList_LegacySortParity proves the compatibility stage maps
|
||||
// historical --sort to canonical --order before command logic runs.
|
||||
func TestThreadsMessagesList_LegacySortParity(t *testing.T) {
|
||||
for _, dir := range []string{"asc", "desc"} {
|
||||
t.Run(dir, func(t *testing.T) {
|
||||
newRT := newThreadsTestRT(t, map[string]string{"order": dir})
|
||||
oldRT := newThreadsTestRT(t, map[string]string{"sort": dir})
|
||||
newRT, _ := newMountedIMRuntime(t, &ImThreadsMessagesList, "--thread", "omt_test", "--order", dir)
|
||||
oldRT, _ := newMountedIMRuntime(t, &ImThreadsMessagesList, "--thread", "omt_test", "--sort", dir)
|
||||
a := mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), newRT))
|
||||
b := mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), oldRT))
|
||||
if a != b {
|
||||
@@ -53,18 +55,30 @@ func TestThreadsMessagesList_OrderAliasParity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreadsMessagesList_OrderFlagSurface(t *testing.T) {
|
||||
var orderFlag, aliasFlag *common.Flag
|
||||
for i := range ImThreadsMessagesList.Flags {
|
||||
switch ImThreadsMessagesList.Flags[i].Name {
|
||||
case "order":
|
||||
orderFlag = &ImThreadsMessagesList.Flags[i]
|
||||
case "sort":
|
||||
aliasFlag = &ImThreadsMessagesList.Flags[i]
|
||||
func TestThreadsMessagesList_CanonicalOrderWinsOverLegacySort(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"--thread", "omt_test", "--order", "desc", "--sort", "asc"},
|
||||
{"--thread", "omt_test", "--sort", "asc", "--order", "desc"},
|
||||
} {
|
||||
rt, _ := newMountedIMRuntime(t, &ImThreadsMessagesList, args...)
|
||||
if got := resolveThreadsOrder(rt); got != "desc" {
|
||||
t.Fatalf("canonical --order must win for %v: order=%q", args, got)
|
||||
}
|
||||
}
|
||||
if orderFlag == nil || aliasFlag == nil {
|
||||
t.Fatalf("expected both --order and --sort flags declared")
|
||||
}
|
||||
|
||||
func TestThreadsMessagesList_OrderFlagSurface(t *testing.T) {
|
||||
var orderFlag, sortFlag *common.Flag
|
||||
for i := range ImThreadsMessagesList.Flags {
|
||||
if ImThreadsMessagesList.Flags[i].Name == "order" {
|
||||
orderFlag = &ImThreadsMessagesList.Flags[i]
|
||||
}
|
||||
if ImThreadsMessagesList.Flags[i].Name == "sort" {
|
||||
sortFlag = &ImThreadsMessagesList.Flags[i]
|
||||
}
|
||||
}
|
||||
if orderFlag == nil {
|
||||
t.Fatal("expected canonical --order declaration")
|
||||
}
|
||||
if orderFlag.Default != "asc" {
|
||||
t.Errorf("--order Default = %q, want asc", orderFlag.Default)
|
||||
@@ -72,10 +86,13 @@ func TestThreadsMessagesList_OrderFlagSurface(t *testing.T) {
|
||||
if got := strings.Join(orderFlag.Enum, ","); got != "asc,desc" {
|
||||
t.Errorf("--order Enum = %q, want asc,desc", got)
|
||||
}
|
||||
if !aliasFlag.Hidden {
|
||||
t.Errorf("--sort must be Hidden")
|
||||
if len(orderFlag.Aliases) != 0 {
|
||||
t.Errorf("--order Aliases = %q, want none", orderFlag.Aliases)
|
||||
}
|
||||
if aliasFlag.Default != "" {
|
||||
t.Errorf("--sort (hidden alias) must not carry a Default, got %q", aliasFlag.Default)
|
||||
if sortFlag == nil || !sortFlag.Hidden {
|
||||
t.Fatal("historical --sort must remain an independent hidden compatibility flag")
|
||||
}
|
||||
if got := strings.Join(sortFlag.Enum, ","); got != "asc,desc" {
|
||||
t.Errorf("--sort Enum = %q, want asc,desc", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,10 @@ package im
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// MuteFilterMeta describes the outcome of a single page's mute filter run.
|
||||
// MuteFilterMeta describes the outcome of one fetched result's mute filter run.
|
||||
// UnknownCount is internal — used to compose the hint, not exposed in JSON.
|
||||
type MuteFilterMeta struct {
|
||||
Applied string
|
||||
@@ -56,9 +55,9 @@ func BuildMuteFilterHint(meta MuteFilterMeta, hasMore bool) string {
|
||||
return "--exclude-muted has no effect under bot identity (mute is a per-user setting, bots have no mute data); returned all results unfiltered. Use --as user to filter."
|
||||
case SkipReasonAllNonMember:
|
||||
if hasMore {
|
||||
return "All results on this page are non-member public groups; mute filter does not apply. Use --page-token to fetch more."
|
||||
return "All fetched results are non-member public groups; mute filter does not apply. Use --page-token to fetch more."
|
||||
}
|
||||
return "All results on this page are non-member public groups; mute filter does not apply. No more pages."
|
||||
return "All fetched results are non-member public groups; mute filter does not apply. No more pages."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -72,10 +71,10 @@ func BuildMuteFilterHint(meta MuteFilterMeta, hasMore bool) string {
|
||||
}
|
||||
|
||||
if meta.UnknownCount > 0 {
|
||||
return fmt.Sprintf("Filtered out %d muted chat(s) on this page (%d remaining, including %d non-member public group(s)); %s",
|
||||
return fmt.Sprintf("Filtered out %d muted chat(s) from the fetched result (%d remaining, including %d non-member public group(s)); %s",
|
||||
meta.FilteredCount, meta.ReturnedCount, meta.UnknownCount, tail)
|
||||
}
|
||||
return fmt.Sprintf("Filtered out %d muted chat(s) on this page (%d remaining); %s",
|
||||
return fmt.Sprintf("Filtered out %d muted chat(s) from the fetched result (%d remaining); %s",
|
||||
meta.FilteredCount, meta.ReturnedCount, tail)
|
||||
}
|
||||
|
||||
@@ -188,7 +187,7 @@ func ApplyMuteFilter(
|
||||
return out, meta
|
||||
}
|
||||
|
||||
// ExtractChatIDs collects unique chat_ids (in input order) from a page of rows.
|
||||
// ExtractChatIDs collects unique chat_ids (in input order) from fetched rows.
|
||||
// Rows missing the key or with an empty value are skipped.
|
||||
func ExtractChatIDs(chats []map[string]interface{}, chatIDKey string) []string {
|
||||
if len(chats) == 0 {
|
||||
@@ -230,8 +229,9 @@ func MuteFilterMetaToMap(meta MuteFilterMeta) map[string]interface{} {
|
||||
}
|
||||
|
||||
// FetchMuteStatus calls batch_get_mute_status for the given chat_ids and
|
||||
// parses the result. Caller MUST ensure len(chatIDs) <= MaxMuteStatusBatchSize
|
||||
// (the shortcuts already cap --page-size at 100, so a single page is safe).
|
||||
// parses the result. Inputs larger than the upstream per-request cap are split
|
||||
// into stable, sequential batches; page aggregation therefore does not leak an
|
||||
// API batch limit into the caller's filtering pipeline.
|
||||
//
|
||||
// Empty input is a no-op (avoids triggering the upstream "chat_ids is empty"
|
||||
// InvalidParam).
|
||||
@@ -239,17 +239,40 @@ func FetchMuteStatus(runtime *common.RuntimeContext, chatIDs []string) (map[stri
|
||||
if len(chatIDs) == 0 {
|
||||
return map[string]bool{}, nil, nil
|
||||
}
|
||||
if len(chatIDs) > MaxMuteStatusBatchSize {
|
||||
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"batch_get_mute_status accepts at most %d chat_ids per call (got %d)",
|
||||
MaxMuteStatusBatchSize, len(chatIDs))
|
||||
|
||||
muted := make(map[string]bool, len(chatIDs))
|
||||
unknownSet := make(map[string]struct{})
|
||||
for start := 0; start < len(chatIDs); start += MaxMuteStatusBatchSize {
|
||||
end := start + MaxMuteStatusBatchSize
|
||||
if end > len(chatIDs) {
|
||||
end = len(chatIDs)
|
||||
}
|
||||
batch := chatIDs[start:end]
|
||||
resp, err := runtime.CallAPITyped("POST", BatchGetMuteStatusPath, nil, BuildBatchGetMuteStatusBody(batch))
|
||||
if err != nil {
|
||||
return nil, nil, wrapIMNetworkErr(err, "fetch mute status")
|
||||
}
|
||||
batchMuted, batchUnknown := ParseBatchGetMuteStatusResponse(batch, resp)
|
||||
for id, isMuted := range batchMuted {
|
||||
muted[id] = isMuted
|
||||
}
|
||||
for _, id := range batchUnknown {
|
||||
unknownSet[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
body := BuildBatchGetMuteStatusBody(chatIDs)
|
||||
resp, err := runtime.CallAPITyped("POST", BatchGetMuteStatusPath, nil, body)
|
||||
if err != nil {
|
||||
return nil, nil, wrapIMNetworkErr(err, "fetch mute status")
|
||||
|
||||
unknown := make([]string, 0, len(unknownSet))
|
||||
seenUnknown := make(map[string]struct{}, len(unknownSet))
|
||||
for _, id := range chatIDs {
|
||||
if _, isUnknown := unknownSet[id]; !isUnknown {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := seenUnknown[id]; duplicate {
|
||||
continue
|
||||
}
|
||||
seenUnknown[id] = struct{}{}
|
||||
unknown = append(unknown, id)
|
||||
}
|
||||
muted, unknown := ParseBatchGetMuteStatusResponse(chatIDs, resp)
|
||||
return muted, unknown, nil
|
||||
}
|
||||
|
||||
@@ -258,7 +281,7 @@ type MuteFilterInput struct {
|
||||
ExcludeMuted bool // value of --exclude-muted
|
||||
IsBot bool // current identity
|
||||
PreSkipReason string // optional caller-supplied skip reason (e.g. SkipReasonAllNonMember); leave empty under bot — IsBot is handled separately
|
||||
Chats []map[string]interface{} // page of result rows
|
||||
Chats []map[string]interface{} // fetched result rows
|
||||
ChatIDKey string // key in row holding the chat_id ("chat_id" for both v1 list and v2 search meta_data)
|
||||
HasMore bool // for hint composition
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
package im
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
@@ -29,37 +31,37 @@ func TestBuildMuteFilterHint(t *testing.T) {
|
||||
name: "2 skipped all non-member, has_more",
|
||||
meta: MuteFilterMeta{Applied: "exclude_muted", Skipped: true, SkipReason: SkipReasonAllNonMember},
|
||||
hasMore: true,
|
||||
want: "All results on this page are non-member public groups; mute filter does not apply. Use --page-token to fetch more.",
|
||||
want: "All fetched results are non-member public groups; mute filter does not apply. Use --page-token to fetch more.",
|
||||
},
|
||||
{
|
||||
name: "3 skipped all non-member, no more",
|
||||
meta: MuteFilterMeta{Applied: "exclude_muted", Skipped: true, SkipReason: SkipReasonAllNonMember},
|
||||
hasMore: false,
|
||||
want: "All results on this page are non-member public groups; mute filter does not apply. No more pages.",
|
||||
want: "All fetched results are non-member public groups; mute filter does not apply. No more pages.",
|
||||
},
|
||||
{
|
||||
name: "4 filtered>0 unknown=0 has_more",
|
||||
meta: MuteFilterMeta{Applied: "exclude_muted", FetchedCount: 20, ReturnedCount: 17, FilteredCount: 3},
|
||||
hasMore: true,
|
||||
want: "Filtered out 3 muted chat(s) on this page (17 remaining); use --page-token to fetch more.",
|
||||
want: "Filtered out 3 muted chat(s) from the fetched result (17 remaining); use --page-token to fetch more.",
|
||||
},
|
||||
{
|
||||
name: "5 filtered>0 unknown=0 no more",
|
||||
meta: MuteFilterMeta{Applied: "exclude_muted", FetchedCount: 20, ReturnedCount: 17, FilteredCount: 3},
|
||||
hasMore: false,
|
||||
want: "Filtered out 3 muted chat(s) on this page (17 remaining); no more pages.",
|
||||
want: "Filtered out 3 muted chat(s) from the fetched result (17 remaining); no more pages.",
|
||||
},
|
||||
{
|
||||
name: "6 filtered>0 unknown>0 has_more",
|
||||
meta: MuteFilterMeta{Applied: "exclude_muted", FetchedCount: 20, ReturnedCount: 19, FilteredCount: 1, UnknownCount: 2},
|
||||
hasMore: true,
|
||||
want: "Filtered out 1 muted chat(s) on this page (19 remaining, including 2 non-member public group(s)); use --page-token to fetch more.",
|
||||
want: "Filtered out 1 muted chat(s) from the fetched result (19 remaining, including 2 non-member public group(s)); use --page-token to fetch more.",
|
||||
},
|
||||
{
|
||||
name: "7 filtered>0 unknown>0 no more",
|
||||
meta: MuteFilterMeta{Applied: "exclude_muted", FetchedCount: 20, ReturnedCount: 19, FilteredCount: 1, UnknownCount: 2},
|
||||
hasMore: false,
|
||||
want: "Filtered out 1 muted chat(s) on this page (19 remaining, including 2 non-member public group(s)); no more pages.",
|
||||
want: "Filtered out 1 muted chat(s) from the fetched result (19 remaining, including 2 non-member public group(s)); no more pages.",
|
||||
},
|
||||
{
|
||||
name: "8 filtered=0 returns empty regardless of unknown/hasMore",
|
||||
@@ -391,7 +393,7 @@ func TestMaybeApplyMuteFilter_PreSkipAllNonMember(t *testing.T) {
|
||||
if !out.Meta.Skipped || out.Meta.SkipReason != SkipReasonAllNonMember {
|
||||
t.Fatalf("meta = %+v", out.Meta)
|
||||
}
|
||||
wantHint := "All results on this page are non-member public groups; mute filter does not apply. Use --page-token to fetch more."
|
||||
wantHint := "All fetched results are non-member public groups; mute filter does not apply. Use --page-token to fetch more."
|
||||
if out.Meta.Hint != wantHint {
|
||||
t.Fatalf("hint = %q", out.Meta.Hint)
|
||||
}
|
||||
@@ -421,15 +423,41 @@ func TestMaybeApplyMuteFilter_EmptyPage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchMuteStatus_OverLimit(t *testing.T) {
|
||||
rt := runtimeForOrchestrator(t)
|
||||
ids := make([]string, MaxMuteStatusBatchSize+1)
|
||||
func TestFetchMuteStatusBatchesAcrossUpstreamLimit(t *testing.T) {
|
||||
var batchSizes []int
|
||||
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.Method != http.MethodPost || req.URL.Path != BatchGetMuteStatusPath {
|
||||
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
|
||||
}
|
||||
var body struct {
|
||||
ChatIDs []string `json:"chat_ids"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
batchSizes = append(batchSizes, len(body.ChatIDs))
|
||||
items := make([]interface{}, 0, len(body.ChatIDs))
|
||||
for _, id := range body.ChatIDs {
|
||||
items = append(items, map[string]interface{}{"chat_id": id, "is_muted": false})
|
||||
}
|
||||
return shortcutJSONResponse(http.StatusOK, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"items": items},
|
||||
}), nil
|
||||
}))
|
||||
ids := make([]string, MaxMuteStatusBatchSize*2+5)
|
||||
for i := range ids {
|
||||
ids[i] = fmt.Sprintf("oc_%d", i)
|
||||
}
|
||||
_, _, err := FetchMuteStatus(rt, ids)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on over-limit batch")
|
||||
muted, unknown, err := FetchMuteStatus(rt, ids)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchMuteStatus() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(batchSizes, []int{100, 100, 5}) {
|
||||
t.Fatalf("batch sizes = %v, want [100 100 5]", batchSizes)
|
||||
}
|
||||
if len(muted) != len(ids) || len(unknown) != 0 {
|
||||
t.Fatalf("muted=%d unknown=%v, want %d known and no unknown", len(muted), unknown, len(ids))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,16 +3,93 @@
|
||||
|
||||
package im
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
// aliasFlagValue handles a renamed sort flag whose old name is kept as a silent
|
||||
// alias. It returns (oldValue, true) only when the old flag was explicitly used
|
||||
// and the new one was not; otherwise ("", false) — meaning "no old flag, or both
|
||||
// given (new wins), so use the new-flag logic". Pure function, no IO: callable
|
||||
// from DryRun, Execute, and minimal test fixtures alike. Never prints anything.
|
||||
func aliasFlagValue(rt *common.RuntimeContext, oldName, newName string) (string, bool) {
|
||||
if rt.Changed(oldName) && !rt.Changed(newName) {
|
||||
return rt.Str(oldName), true
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type sortCompatibilityValue struct {
|
||||
legacy string
|
||||
canonical string
|
||||
}
|
||||
|
||||
// These tables are the single source for both the hidden legacy flag Enum and
|
||||
// the value written into canonical --sort during Normalize.
|
||||
var chatListSortCompatibilityValues = []sortCompatibilityValue{
|
||||
{legacy: "ByCreateTimeAsc", canonical: "create_time"},
|
||||
{legacy: "ByActiveTimeDesc", canonical: "active_time"},
|
||||
}
|
||||
|
||||
var chatSearchSortCompatibilityValues = []sortCompatibilityValue{
|
||||
{legacy: "create_time_desc", canonical: "create_time"},
|
||||
{legacy: "update_time_desc", canonical: "update_time"},
|
||||
{legacy: "member_count_desc", canonical: "member_count"},
|
||||
}
|
||||
|
||||
func legacySortValues(values []sortCompatibilityValue) []string {
|
||||
legacy := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
legacy = append(legacy, value.legacy)
|
||||
}
|
||||
return legacy
|
||||
}
|
||||
|
||||
// legacyFlagValue preserves the precedence contract of an independently
|
||||
// declared historical flag: the canonical flag wins whenever both are set.
|
||||
// This is command-owned compatibility rather than a framework Flag.Alias,
|
||||
// whose scalar contract is intentionally last-occurrence-wins.
|
||||
func legacyFlagValue(runtime *common.RuntimeContext, legacyName, canonicalName string) (string, bool) {
|
||||
if runtime.Changed(legacyName) && !runtime.Changed(canonicalName) {
|
||||
return runtime.Str(legacyName), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// normalizeSortCompatibilityFlag is the IM adapter for the framework Normalize
|
||||
// phase. It translates a legacy sort vocabulary into the canonical flag before
|
||||
// framework enum validation and before Validate/DryRun/Execute. Exact name
|
||||
// synonyms belong in common.Flag.Aliases and never reach this function.
|
||||
func normalizeSortCompatibilityFlag(flags *common.FlagContext, legacyName, canonicalName string, values ...sortCompatibilityValue) error {
|
||||
if !flags.Changed(legacyName) {
|
||||
return nil
|
||||
}
|
||||
legacy := flags.Str(legacyName)
|
||||
if legacy == "" {
|
||||
if flags.Changed(canonicalName) {
|
||||
return nil
|
||||
}
|
||||
if err := flags.SetCanonicalFrom(legacyName, canonicalName, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
allowed := legacySortValues(values)
|
||||
canonical := ""
|
||||
found := false
|
||||
for _, value := range values {
|
||||
if legacy != value.legacy {
|
||||
continue
|
||||
}
|
||||
canonical = value.canonical
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
return common.ValidationErrorf("invalid value %q for --%s, allowed: %s", legacy, legacyName, strings.Join(allowed, ", ")).
|
||||
WithParam("--" + legacyName)
|
||||
}
|
||||
if flags.Changed(canonicalName) {
|
||||
return nil
|
||||
}
|
||||
return flags.SetCanonicalFrom(legacyName, canonicalName, canonical)
|
||||
}
|
||||
|
||||
func normalizeChatListSortCompatibility(_ context.Context, flags *common.FlagContext) error {
|
||||
return normalizeSortCompatibilityFlag(flags, "sort-type", "sort", chatListSortCompatibilityValues...)
|
||||
}
|
||||
|
||||
func normalizeChatSearchSortCompatibility(_ context.Context, flags *common.FlagContext) error {
|
||||
return normalizeSortCompatibilityFlag(flags, "sort-by", "sort", chatSearchSortCompatibilityValues...)
|
||||
}
|
||||
|
||||
@@ -4,16 +4,18 @@
|
||||
package im
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// newAliasTestRT registers a new flag (with a default) and an old flag, then
|
||||
// sets only the flags present in `set` — so Changed() reflects exactly which
|
||||
// flags were "passed on the command line".
|
||||
func newAliasTestRT(t *testing.T, newName, newDefault, oldName string, set map[string]string) *common.RuntimeContext {
|
||||
// newCompatibilityTestRT registers canonical and legacy flags with different
|
||||
// value vocabularies, then marks only the supplied flags changed.
|
||||
func newCompatibilityTestRT(t *testing.T, newName, newDefault, oldName string, set map[string]string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String(newName, newDefault, "")
|
||||
@@ -26,28 +28,70 @@ func newAliasTestRT(t *testing.T, newName, newDefault, oldName string, set map[s
|
||||
t.Fatalf("Set(%q) error = %v", k, err)
|
||||
}
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
return &common.RuntimeContext{
|
||||
Cmd: cmd,
|
||||
Factory: &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
||||
Out: &bytes.Buffer{},
|
||||
ErrOut: &bytes.Buffer{},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliasFlagValue(t *testing.T) {
|
||||
func TestNormalizeSortCompatibilityFlag(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
set map[string]string
|
||||
wantVal string
|
||||
wantOK bool
|
||||
name string
|
||||
set map[string]string
|
||||
want string
|
||||
}{
|
||||
{"only old set", map[string]string{"sort-type": "ByActiveTimeDesc"}, "ByActiveTimeDesc", true},
|
||||
{"neither set", nil, "", false},
|
||||
{"only new set", map[string]string{"sort": "active_time"}, "", false},
|
||||
{"both set new wins", map[string]string{"sort": "active_time", "sort-type": "ByCreateTimeAsc"}, "", false},
|
||||
{"only old set", map[string]string{"sort-type": "ByActiveTimeDesc"}, "active_time"},
|
||||
{"explicit empty old value stays accepted", map[string]string{"sort-type": ""}, ""},
|
||||
{"neither set", nil, "create_time"},
|
||||
{"only new set", map[string]string{"sort": "active_time"}, "active_time"},
|
||||
{"both set new wins", map[string]string{"sort": "active_time", "sort-type": "ByCreateTimeAsc"}, "active_time"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
rt := newAliasTestRT(t, "sort", "create_time", "sort-type", c.set)
|
||||
gotVal, gotOK := aliasFlagValue(rt, "sort-type", "sort")
|
||||
if gotVal != c.wantVal || gotOK != c.wantOK {
|
||||
t.Fatalf("aliasFlagValue() = (%q, %v), want (%q, %v)", gotVal, gotOK, c.wantVal, c.wantOK)
|
||||
rt := newCompatibilityTestRT(t, "sort", "create_time", "sort-type", c.set)
|
||||
if err := normalizeChatListSortCompatibility(context.Background(), rt.FlagContext()); err != nil {
|
||||
t.Fatalf("normalizeChatListSortCompatibility() error = %v", err)
|
||||
}
|
||||
if got := rt.Str("sort"); got != c.want {
|
||||
t.Fatalf("canonical sort = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSortCompatibilityFlagIsSilent(t *testing.T) {
|
||||
rt := newCompatibilityTestRT(t, "sort", "create_time", "sort-type", map[string]string{
|
||||
"sort-type": "ByActiveTimeDesc",
|
||||
})
|
||||
|
||||
for range 2 {
|
||||
if err := normalizeChatListSortCompatibility(context.Background(), rt.FlagContext()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if stderr := rt.IO().ErrOut.(*bytes.Buffer).String(); stderr != "" {
|
||||
t.Fatalf("compatibility normalization emitted stderr noise: %q", stderr)
|
||||
}
|
||||
if stdout := rt.IO().Out.(*bytes.Buffer).String(); stdout != "" {
|
||||
t.Fatalf("alias note leaked to stdout: %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSortCompatibilityFlagValidatesLegacyVocabulary(t *testing.T) {
|
||||
rt := newCompatibilityTestRT(t, "sort", "create_time", "sort-type", map[string]string{
|
||||
"sort-type": "unexpected",
|
||||
})
|
||||
err := normalizeChatListSortCompatibility(context.Background(), rt.FlagContext())
|
||||
assertIMValidationError(t, err, "--sort-type", `invalid value "unexpected" for --sort-type`)
|
||||
|
||||
rt = newCompatibilityTestRT(t, "sort", "create_time", "sort-type", map[string]string{
|
||||
"sort": "active_time",
|
||||
"sort-type": "unexpected",
|
||||
})
|
||||
err = normalizeChatListSortCompatibility(context.Background(), rt.FlagContext())
|
||||
assertIMValidationError(t, err, "--sort-type", `invalid value "unexpected" for --sort-type`)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
// never appear (AC1/AC5). Covers chat-messages-list, threads-messages-list, and the
|
||||
// shared mget URL used by messages-mget and messages-search.
|
||||
func TestReadRequestsSendWithSenderName(t *testing.T) {
|
||||
if got := buildChatMessageListParams("desc", "50", "oc_x")["with_sender_name"]; len(got) != 1 || got[0] != "true" {
|
||||
if got := buildChatMessageListParams("desc", 50, "oc_x")["with_sender_name"]; len(got) != 1 || got[0] != "true" {
|
||||
t.Fatalf("chat-messages-list with_sender_name = %#v, want [true]", got)
|
||||
}
|
||||
if got := buildThreadsMessagesListParams("desc", "t_x", 50, "")["with_sender_name"]; len(got) != 1 || got[0] != "true" {
|
||||
|
||||
@@ -56,8 +56,7 @@ var MailTriage = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, Desc: "output format: table | json | data (json/data output object with pagination fields)"},
|
||||
{Name: "max", Type: "int", Default: "20", Desc: "maximum number of messages to fetch (1-400; auto-paginates internally)"},
|
||||
{Name: "page-size", Type: "int", Desc: "alias for --max"},
|
||||
{Name: "max", Aliases: []string{"page-size"}, Type: "int", Default: "20", Desc: "maximum number of messages to fetch (1-400; auto-paginates internally)"},
|
||||
{Name: "page-token", Desc: "pagination token from a previous response to fetch the next page"},
|
||||
{Name: "filter", Desc: `exact-match condition filter (JSON). Narrow results by folder, label, sender, recipient, etc. Run --print-filter-schema to see all fields. Example: {"folder":"INBOX","from":["alice@example.com"]}`},
|
||||
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
|
||||
@@ -72,7 +71,7 @@ var MailTriage = common.Shortcut{
|
||||
mailbox := resolveMailboxID(runtime)
|
||||
query := runtime.Str("query")
|
||||
showLabels := runtime.Bool("labels")
|
||||
maxCount := resolveTriagePageSize(runtime)
|
||||
maxCount := normalizeTriageMax(runtime.Int("max"))
|
||||
parsed, parseErr := parseTriagePageToken(runtime.Str("page-token"))
|
||||
filter, err := parseTriageFilter(runtime.Str("filter"))
|
||||
d := common.NewDryRunAPI().Set("input_filter", runtime.Str("filter"))
|
||||
@@ -150,7 +149,7 @@ var MailTriage = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
maxCount := resolveTriagePageSize(runtime)
|
||||
maxCount := normalizeTriageMax(runtime.Int("max"))
|
||||
parsed, err := parseTriagePageToken(runtime.Str("page-token"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -979,15 +978,6 @@ func parseTriagePageToken(token string) (triagePageToken, error) {
|
||||
return triagePageToken{Path: path, RawToken: raw}, nil
|
||||
}
|
||||
|
||||
// resolveTriagePageSize returns the effective max count from --page-size or --max.
|
||||
// --page-size is an alias for --max; if both are set, --page-size takes priority.
|
||||
func resolveTriagePageSize(runtime *common.RuntimeContext) int {
|
||||
if ps := runtime.Int("page-size"); ps > 0 {
|
||||
return normalizeTriageMax(ps)
|
||||
}
|
||||
return normalizeTriageMax(runtime.Int("max"))
|
||||
}
|
||||
|
||||
func normalizeTriageMax(maxCount int) int {
|
||||
if maxCount <= 0 {
|
||||
return 20
|
||||
|
||||
@@ -1081,11 +1081,16 @@ func TestBuildSearchParamsPageToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- resolveTriagePageSize ---
|
||||
// --- max normalization ---
|
||||
|
||||
func effectiveTriageMax(t *testing.T, runtime *common.RuntimeContext) int {
|
||||
t.Helper()
|
||||
return normalizeTriageMax(runtime.Int("max"))
|
||||
}
|
||||
|
||||
func TestResolveTriagePageSizeDefaultMax(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, nil) // max=0 (unset) → normalizeTriageMax returns 20
|
||||
got := resolveTriagePageSize(rt)
|
||||
got := effectiveTriageMax(t, rt)
|
||||
if got != 20 {
|
||||
t.Fatalf("expected 20, got %d", got)
|
||||
}
|
||||
@@ -1093,31 +1098,15 @@ func TestResolveTriagePageSizeDefaultMax(t *testing.T) {
|
||||
|
||||
func TestResolveTriagePageSizeFromMax(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, map[string]string{"max": "30"})
|
||||
got := resolveTriagePageSize(rt)
|
||||
got := effectiveTriageMax(t, rt)
|
||||
if got != 30 {
|
||||
t.Fatalf("expected 30, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePageSizeFromPageSize(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, map[string]string{"page-size": "10"})
|
||||
got := resolveTriagePageSize(rt)
|
||||
if got != 10 {
|
||||
t.Fatalf("expected 10, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePageSizePageSizeOverridesMax(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, map[string]string{"max": "30", "page-size": "5"})
|
||||
got := resolveTriagePageSize(rt)
|
||||
if got != 5 {
|
||||
t.Fatalf("expected page-size=5 to override max=30, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTriagePageSizeClamped(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, map[string]string{"page-size": "999"})
|
||||
got := resolveTriagePageSize(rt)
|
||||
rt := runtimeForMailTriageTest(t, map[string]string{"max": "999"})
|
||||
got := effectiveTriageMax(t, rt)
|
||||
if got != 400 {
|
||||
t.Fatalf("expected clamped to 400, got %d", got)
|
||||
}
|
||||
@@ -1219,13 +1208,12 @@ func TestPageTokenBareTokenRejected(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun with page-size ---
|
||||
// --- DryRun with max ---
|
||||
|
||||
func TestMailTriageDryRunPageSizeOverridesMax(t *testing.T) {
|
||||
func TestMailTriageDryRunUsesMax(t *testing.T) {
|
||||
runtime := runtimeForMailTriageTest(t, map[string]string{
|
||||
"max": "50",
|
||||
"page-size": "8",
|
||||
"filter": `{"folder_id":"INBOX"}`,
|
||||
"max": "8",
|
||||
"filter": `{"folder_id":"INBOX"}`,
|
||||
})
|
||||
apis := dryRunAPIsForMailTriageTest(t, MailTriage.DryRun(context.Background(), runtime))
|
||||
if len(apis) < 1 {
|
||||
@@ -1236,14 +1224,14 @@ func TestMailTriageDryRunPageSizeOverridesMax(t *testing.T) {
|
||||
t.Fatalf("page_size type mismatch, got %#v", apis[0].Params["page_size"])
|
||||
}
|
||||
if int(got) != 8 {
|
||||
t.Fatalf("expected page_size=8 (from --page-size), got %d", int(got))
|
||||
t.Fatalf("expected page_size=8 (from --max), got %d", int(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailTriageDryRunSearchPathCapsPageSizeAt15(t *testing.T) {
|
||||
runtime := runtimeForMailTriageTest(t, map[string]string{
|
||||
"query": "hello",
|
||||
"page-size": "30",
|
||||
"query": "hello",
|
||||
"max": "30",
|
||||
})
|
||||
apis := dryRunAPIsForMailTriageTest(t, MailTriage.DryRun(context.Background(), runtime))
|
||||
if len(apis) < 1 {
|
||||
@@ -1415,16 +1403,23 @@ func TestMailTriageDryRunNoPageTokenOmitsParam(t *testing.T) {
|
||||
|
||||
// --- Flag definition checks ---
|
||||
|
||||
func TestMailTriageFlagsIncludePageTokenAndPageSize(t *testing.T) {
|
||||
flagNames := make(map[string]bool)
|
||||
func TestMailTriageDeclaresPageSizeAliasForMax(t *testing.T) {
|
||||
flagNames := make(map[string]common.Flag)
|
||||
for _, fl := range MailTriage.Flags {
|
||||
flagNames[fl.Name] = true
|
||||
flagNames[fl.Name] = fl
|
||||
}
|
||||
for _, name := range []string{"page-token", "page-size", "max"} {
|
||||
if !flagNames[name] {
|
||||
for _, name := range []string{"page-token", "max"} {
|
||||
if _, ok := flagNames[name]; !ok {
|
||||
t.Fatalf("expected flag --%s to be defined", name)
|
||||
}
|
||||
}
|
||||
if _, ok := flagNames["page-size"]; ok {
|
||||
t.Fatal("--page-size must not be registered as an independent flag")
|
||||
}
|
||||
maxFlag := flagNames["max"]
|
||||
if len(maxFlag.Aliases) != 1 || maxFlag.Aliases[0] != "page-size" {
|
||||
t.Fatalf("--max aliases = %v, want [page-size]", maxFlag.Aliases)
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseTriagePageToken(t *testing.T, token string) triagePageToken {
|
||||
|
||||
@@ -23,9 +23,8 @@ import (
|
||||
// --file for --csv) whose unknown-flag error only points at --help, and
|
||||
// enum values imported from CSS / Excel vocabulary ("center" for the
|
||||
// vertical alignment Lark spells "middle"). Both fixes are wired through
|
||||
// the existing PostMount hook — composed onto any prior PostMount in
|
||||
// Shortcuts(), same pattern as withTokenAlias — so the common framework
|
||||
// needs no change at all and no other domain's behavior shifts.
|
||||
// the existing PostMount hook and composed onto any prior PostMount in
|
||||
// Shortcuts(); exact flag-name aliases use common.Flag.Aliases separately.
|
||||
|
||||
// withFlagErgonomics wraps an optional PostMount so that, after it runs,
|
||||
// the command gets the sheets-specific unknown-flag error (valid flags
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
//
|
||||
// History is workbook-level (no sheet selector), mirroring +workbook-info:
|
||||
// the only locator is --url / --spreadsheet-token (XOR), with --token accepted
|
||||
// as a parse-time alias for --spreadsheet-token via the shared PostMount hook.
|
||||
// as a parse-time alias for --spreadsheet-token via the shared flagalias engine.
|
||||
|
||||
// historyLocatorFlags is the --url / --spreadsheet-token XOR locator pair
|
||||
// shared by the three history shortcuts. Mirrors +workbook-info's flag-defs
|
||||
|
||||
@@ -5,8 +5,6 @@ package sheets
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// Shortcuts returns all lark-sheets shortcuts. The list is grouped by
|
||||
@@ -26,48 +24,29 @@ func Shortcuts() []common.Shortcut {
|
||||
if _, ok := commandsWithSchema[all[i].Command]; ok {
|
||||
all[i].PrintFlagSchema = printFlagSchemaFor(all[i].Command)
|
||||
}
|
||||
// Accept --token as a parse-time alias for --spreadsheet-token (the
|
||||
// single highest-frequency reflex misspelling in eval traces) on every
|
||||
// shortcut that registers --spreadsheet-token, so the typo costs zero
|
||||
// round-trips instead of an unknown-flag failure. Wired through the
|
||||
// existing PostMount hook and composed onto any prior PostMount, so the
|
||||
// common framework needs no change at all.
|
||||
if hasFlag(all[i].Flags, "spreadsheet-token") {
|
||||
all[i].PostMount = withTokenAlias(all[i].PostMount)
|
||||
}
|
||||
// Accept the highest-frequency locator misspelling through the common
|
||||
// declarative alias contract. Copy the flag slice before decorating it:
|
||||
// shortcut values are package globals and Shortcuts may be called more
|
||||
// than once in tests or embedders.
|
||||
all[i].Flags = withSpreadsheetTokenAlias(all[i].Flags)
|
||||
// Sheets-scoped flag ergonomics (unknown-flag hints with the valid
|
||||
// flags inlined, enum vocabulary normalization) ride the same
|
||||
// flags inlined, enum vocabulary normalization) ride the existing
|
||||
// PostMount composition, so no other domain's behavior shifts.
|
||||
all[i].PostMount = withFlagErgonomics(all[i].PostMount)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func hasFlag(flags []common.Flag, name string) bool {
|
||||
for _, fl := range flags {
|
||||
if fl.Name == name {
|
||||
return true
|
||||
func withSpreadsheetTokenAlias(flags []common.Flag) []common.Flag {
|
||||
for i := range flags {
|
||||
if flags[i].Name != "spreadsheet-token" {
|
||||
continue
|
||||
}
|
||||
decorated := append([]common.Flag(nil), flags...)
|
||||
decorated[i].Aliases = append(append([]string(nil), decorated[i].Aliases...), "token")
|
||||
return decorated
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// withTokenAlias wraps an optional PostMount so that, after it runs, --token
|
||||
// resolves to --spreadsheet-token at parse time via pflag's normalize hook (no
|
||||
// duplicate flag in --help). It preserves any pre-existing PostMount — e.g.
|
||||
// +csv-put's --range / --start-cell flag-group setup — by running it first.
|
||||
func withTokenAlias(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
|
||||
return func(cmd *cobra.Command) {
|
||||
if prev != nil {
|
||||
prev(cmd)
|
||||
}
|
||||
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
if name == "token" {
|
||||
return pflag.NormalizedName("spreadsheet-token")
|
||||
}
|
||||
return pflag.NormalizedName(name)
|
||||
})
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
func shortcutList() []common.Shortcut {
|
||||
|
||||
@@ -4,50 +4,53 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestWithTokenAlias verifies the PostMount-based --token → --spreadsheet-token
|
||||
// alias: it resolves at parse time, and it composes onto (rather than replaces)
|
||||
// any pre-existing PostMount — the property that lets it coexist with
|
||||
// +csv-put's --range/--start-cell flag-group setup.
|
||||
func TestWithTokenAlias(t *testing.T) {
|
||||
func TestShortcutsDeclareSpreadsheetTokenAlias(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Alias resolves to the canonical flag.
|
||||
cmd := &cobra.Command{Use: "x"}
|
||||
cmd.Flags().String("spreadsheet-token", "", "")
|
||||
withTokenAlias(nil)(cmd)
|
||||
if err := cmd.Flags().Parse([]string{"--token", "shtABC"}); err != nil {
|
||||
t.Fatalf("--token should resolve as an alias: %v", err)
|
||||
count := 0
|
||||
for _, shortcut := range Shortcuts() {
|
||||
for _, flag := range shortcut.Flags {
|
||||
if flag.Name != "spreadsheet-token" {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if !slices.Contains(flag.Aliases, "token") {
|
||||
t.Errorf("%s --spreadsheet-token aliases = %v, want token", shortcut.Command, flag.Aliases)
|
||||
}
|
||||
}
|
||||
}
|
||||
if got := cmd.Flags().Lookup("spreadsheet-token").Value.String(); got != "shtABC" {
|
||||
t.Errorf("--token should set --spreadsheet-token; got %q", got)
|
||||
}
|
||||
|
||||
// Composes with an existing PostMount instead of dropping it.
|
||||
prevRan := false
|
||||
cmd2 := &cobra.Command{Use: "y"}
|
||||
cmd2.Flags().String("spreadsheet-token", "", "")
|
||||
withTokenAlias(func(_ *cobra.Command) { prevRan = true })(cmd2)
|
||||
if !prevRan {
|
||||
t.Error("pre-existing PostMount should still run")
|
||||
}
|
||||
if err := cmd2.Flags().Parse([]string{"--token", "shtZ"}); err != nil {
|
||||
t.Fatalf("--token should still resolve when composed: %v", err)
|
||||
if count == 0 {
|
||||
t.Fatal("expected at least one sheets shortcut with --spreadsheet-token")
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortcuts_TokenAliasOnSpreadsheetTokenCommands asserts every shortcut that
|
||||
// takes --spreadsheet-token ends up with a PostMount (the composed token alias),
|
||||
// so the reflex typo is forgiven wherever the canonical flag exists.
|
||||
func TestShortcuts_TokenAliasOnSpreadsheetTokenCommands(t *testing.T) {
|
||||
func TestShortcutsDoNotAccumulateSpreadsheetTokenAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, s := range Shortcuts() {
|
||||
if hasFlag(s.Flags, "spreadsheet-token") && s.PostMount == nil {
|
||||
t.Errorf("%s takes --spreadsheet-token but has no PostMount (token alias missing)", s.Command)
|
||||
|
||||
for call := 0; call < 2; call++ {
|
||||
for _, shortcut := range Shortcuts() {
|
||||
for _, flag := range shortcut.Flags {
|
||||
if flag.Name != "spreadsheet-token" {
|
||||
continue
|
||||
}
|
||||
if got := countString(flag.Aliases, "token"); got != 1 {
|
||||
t.Fatalf("call %d: %s has %d token aliases: %v", call+1, shortcut.Command, got, flag.Aliases)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func countString(values []string, target string) int {
|
||||
count := 0
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
40
shortcuts/slides/presentation_flag.go
Normal file
40
shortcuts/slides/presentation_flag.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
const presentationRefDescription = "xml_presentation_id, slides URL, or wiki URL that resolves to slides"
|
||||
|
||||
var presentationFlagAliases = []string{
|
||||
"presentation-id",
|
||||
"presentation-token",
|
||||
"token",
|
||||
"presentation_id",
|
||||
"xml-presentation-id",
|
||||
"url",
|
||||
}
|
||||
|
||||
// basePresentationRefFlag declares the shared Slides presentation-locator
|
||||
// contract. Every alias accepts the same token / Slides URL / Wiki URL grammar
|
||||
// as the canonical --presentation flag.
|
||||
func basePresentationRefFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: "presentation",
|
||||
Aliases: append([]string(nil), presentationFlagAliases...),
|
||||
Desc: presentationRefDescription,
|
||||
}
|
||||
}
|
||||
|
||||
func requiredPresentationRefFlag() common.Flag {
|
||||
flag := basePresentationRefFlag()
|
||||
flag.Required = true
|
||||
return flag
|
||||
}
|
||||
|
||||
func listModePresentationRefFlag() common.Flag {
|
||||
flag := basePresentationRefFlag()
|
||||
flag.Desc += "; list mode only"
|
||||
return flag
|
||||
}
|
||||
@@ -3,24 +3,11 @@
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
var presentationFlagAliases = []string{
|
||||
"presentation-id",
|
||||
"presentation-token",
|
||||
"token",
|
||||
"presentation_id",
|
||||
"xml-presentation-id",
|
||||
"url",
|
||||
}
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
// Shortcuts returns all slides shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
all := []common.Shortcut{
|
||||
return []common.Shortcut{
|
||||
SlidesCreate,
|
||||
SlidesMediaUpload,
|
||||
SlidesReplaceSlide,
|
||||
@@ -31,39 +18,4 @@ func Shortcuts() []common.Shortcut {
|
||||
SlidesHistoryRevert,
|
||||
SlidesHistoryRevertStatus,
|
||||
}
|
||||
for i := range all {
|
||||
if hasPresentationFlag(all[i].Flags) {
|
||||
all[i].PostMount = withPresentationFlagAliases(all[i].PostMount)
|
||||
}
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func hasPresentationFlag(flags []common.Flag) bool {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == "presentation" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// withPresentationFlagAliases accepts common agent-generated spellings for
|
||||
// --presentation without registering extra flags. The aliases therefore stay
|
||||
// out of help and completion while resolving to the canonical flag at parse
|
||||
// time, matching the zero-round-trip compatibility used by Sheets.
|
||||
func withPresentationFlagAliases(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
|
||||
return func(cmd *cobra.Command) {
|
||||
if prev != nil {
|
||||
prev(cmd)
|
||||
}
|
||||
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
for _, alias := range presentationFlagAliases {
|
||||
if name == alias {
|
||||
return pflag.NormalizedName("presentation")
|
||||
}
|
||||
}
|
||||
return pflag.NormalizedName(name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,65 +4,60 @@
|
||||
package slides
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestWithPresentationFlagAliases(t *testing.T) {
|
||||
for _, alias := range presentationFlagAliases {
|
||||
t.Run(alias, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("presentation", "", "presentation reference")
|
||||
withPresentationFlagAliases(nil)(cmd)
|
||||
|
||||
if err := cmd.Flags().Parse([]string{"--" + alias, "presABC"}); err != nil {
|
||||
t.Fatalf("--%s should resolve to --presentation: %v", alias, err)
|
||||
}
|
||||
got, err := cmd.Flags().GetString("presentation")
|
||||
if err != nil {
|
||||
t.Fatalf("read --presentation: %v", err)
|
||||
}
|
||||
if got != "presABC" {
|
||||
t.Fatalf("--%s set --presentation to %q, want presABC", alias, got)
|
||||
}
|
||||
if usage := cmd.Flags().FlagUsages(); strings.Contains(usage, "--"+alias) {
|
||||
t.Fatalf("hidden compatibility alias --%s leaked into help:\n%s", alias, usage)
|
||||
}
|
||||
})
|
||||
func TestShortcutsDeclarePresentationFlagAliases(t *testing.T) {
|
||||
wantRequired := map[string]bool{
|
||||
"+media-upload": true,
|
||||
"+replace-slide": true,
|
||||
"+replace-pages": true,
|
||||
"+screenshot": false,
|
||||
"+xml-get": true,
|
||||
"+history-list": true,
|
||||
"+history-revert": true,
|
||||
"+history-revert-status": true,
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutsAttachPresentationFlagAliases(t *testing.T) {
|
||||
count := 0
|
||||
seen := make(map[string]bool, len(wantRequired))
|
||||
for _, shortcut := range Shortcuts() {
|
||||
if !hasPresentationFlag(shortcut.Flags) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if shortcut.PostMount == nil {
|
||||
t.Errorf("%s has --presentation but no compatibility normalizer", shortcut.Command)
|
||||
continue
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: shortcut.Command}
|
||||
cmd.Flags().String("presentation", "", "presentation reference")
|
||||
shortcut.PostMount(cmd)
|
||||
if err := cmd.Flags().Parse([]string{"--token", "presABC"}); err != nil {
|
||||
t.Errorf("%s did not normalize --token: %v", shortcut.Command, err)
|
||||
continue
|
||||
}
|
||||
got, err := cmd.Flags().GetString("presentation")
|
||||
if err != nil {
|
||||
t.Errorf("%s could not read --presentation: %v", shortcut.Command, err)
|
||||
continue
|
||||
}
|
||||
if got != "presABC" {
|
||||
t.Errorf("%s normalized --token to %q, want presABC", shortcut.Command, got)
|
||||
for _, flag := range shortcut.Flags {
|
||||
if flag.Name != "presentation" {
|
||||
continue
|
||||
}
|
||||
required, ok := wantRequired[shortcut.Command]
|
||||
if !ok {
|
||||
t.Errorf("unexpected presentation flag on %s", shortcut.Command)
|
||||
continue
|
||||
}
|
||||
seen[shortcut.Command] = true
|
||||
if !slices.Equal(flag.Aliases, presentationFlagAliases) {
|
||||
t.Errorf("%s --presentation aliases = %v, want %v", shortcut.Command, flag.Aliases, presentationFlagAliases)
|
||||
}
|
||||
if flag.Required != required {
|
||||
t.Errorf("%s --presentation required = %v, want %v", shortcut.Command, flag.Required, required)
|
||||
}
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
t.Fatal("expected at least one slides shortcut with --presentation")
|
||||
for command := range wantRequired {
|
||||
if !seen[command] {
|
||||
t.Errorf("%s is missing the shared --presentation flag", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresentationRefFlagReturnsIndependentAliases(t *testing.T) {
|
||||
first := requiredPresentationRefFlag()
|
||||
second := listModePresentationRefFlag()
|
||||
first.Aliases[0] = "mutated"
|
||||
|
||||
if !slices.Equal(second.Aliases, presentationFlagAliases) {
|
||||
t.Fatalf("second aliases = %v, want independent %v", second.Aliases, presentationFlagAliases)
|
||||
}
|
||||
if first.Desc != presentationRefDescription || !first.Required {
|
||||
t.Fatalf("required flag = %#v", first)
|
||||
}
|
||||
if want := presentationRefDescription + "; list mode only"; second.Desc != want || second.Required {
|
||||
t.Fatalf("optional flag = %#v, want description %q", second, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ var SlidesHistoryList = common.Shortcut{
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
requiredPresentationRefFlag(),
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "history entries to return, range 1-20"},
|
||||
{Name: "page-token", Desc: "pagination token from the previous page's page_token"},
|
||||
},
|
||||
@@ -173,7 +173,7 @@ var SlidesHistoryRevert = common.Shortcut{
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
requiredPresentationRefFlag(),
|
||||
{Name: "history-version-id", Desc: "history_version_id from slides +history-list to revert to", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -236,7 +236,7 @@ var SlidesHistoryRevertStatus = common.Shortcut{
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
requiredPresentationRefFlag(),
|
||||
{Name: "task-id", Desc: "task_id returned by slides +history-revert", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -42,7 +42,7 @@ var SlidesMediaUpload = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file", Desc: "local image path (max 20 MB)", Required: true},
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
requiredPresentationRefFlag(),
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parsePresentationRef(runtime.Str("presentation")); err != nil {
|
||||
|
||||
@@ -32,7 +32,7 @@ var SlidesReplacePages = common.Shortcut{
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
requiredPresentationRefFlag(),
|
||||
{Name: "pages", Desc: "JSON array of page replacements (each: {slide_id, content}); supports @file or -", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "continue-on-error", Type: "bool", Desc: "continue with later pages after a create/delete failure; default false"},
|
||||
{Name: "validate-only", Type: "bool", Desc: "validate input and build the create/delete plan without write calls"},
|
||||
|
||||
@@ -48,7 +48,7 @@ var SlidesReplaceSlide = common.Shortcut{
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
requiredPresentationRefFlag(),
|
||||
{Name: "slide-id", Desc: "slide page identifier (slide_id)", Required: true},
|
||||
{Name: "parts", Desc: "JSON array of replace parts (each: {action: block_replace|block_insert, ...}); max 200", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "revision-id", Type: "int", Default: "-1", Desc: "presentation revision (-1 = latest; pass a specific number for optimistic locking)"},
|
||||
|
||||
@@ -42,7 +42,7 @@ var SlidesScreenshot = common.Shortcut{
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides; list mode only"},
|
||||
listModePresentationRefFlag(),
|
||||
{Name: "slide-id", Type: "string_slice", Desc: "slide page identifier (repeat or comma-separated for multiple slides; max 10 pages per request)"},
|
||||
{Name: "slide-number", Type: "int_array", Desc: "slide page number (repeat for multiple slides; max 10 pages per request)"},
|
||||
{Name: "content", Desc: "slide XML content to render directly instead of fetching existing slides", Input: []string{common.File, common.Stdin}},
|
||||
|
||||
@@ -29,7 +29,7 @@ var SlidesXMLGet = common.Shortcut{
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
requiredPresentationRefFlag(),
|
||||
{Name: "output", Desc: "local XML output path; must be a relative path within the current directory; existing file is overwritten; omit to return XML in the JSON envelope"},
|
||||
{Name: "raw", Type: "bool", Desc: "print raw XML to stdout instead of the JSON envelope; incompatible with --output and --jq"},
|
||||
{Name: "slide-id", Desc: "slide page identifier; omit both slide selectors to fetch full presentation XML"},
|
||||
|
||||
@@ -104,17 +104,17 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager |
|
||||
| [`+chat-list`](references/lark-im-chat-list.md) | List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only) |
|
||||
| [`+chat-list`](references/lark-im-chat-list.md) | List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, auto-pagination, --exclude-muted (user-only) |
|
||||
| [`+chat-members-list`](references/lark-im-chat-members-list.md) | List members of a chat; returns separate users[] / bots[] buckets; callable as user or bot; --member-types filters which kinds to return; --page-all pagination; surfaces truncations[] when the server caps a bucket |
|
||||
| [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | 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 |
|
||||
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only) |
|
||||
| [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range, --order asc|desc sorting, auto-pagination |
|
||||
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, auto-pagination, and --exclude-muted (user identity only) |
|
||||
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description |
|
||||
| [`+messages-mget`](references/lark-im-messages-mget.md) | Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies |
|
||||
| [`+messages-reply`](references/lark-im-messages-reply.md) | Reply to a message (supports thread replies); user/bot; supports text/markdown/post/media replies, reply-in-thread, idempotency key |
|
||||
| [`+messages-resources-download`](references/lark-im-messages-resources-download.md) | Download images/files from a message; user/bot; supports automatic chunked download for large files (8MB chunks), auto-detects file extension from Content-Type |
|
||||
| [`+messages-search`](references/lark-im-messages-search.md) | Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, supports auto-pagination via `--page-all` / `--page-limit`, enriches results via batched mget and chats batch_query |
|
||||
| [`+messages-send`](references/lark-im-messages-send.md) | Send a message to a chat or direct message; user/bot; sends to chat-id or user-id with text/markdown/post/media, supports idempotency key |
|
||||
| [`+threads-messages-list`](references/lark-im-threads-messages-list.md) | List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination |
|
||||
| [`+threads-messages-list`](references/lark-im-threads-messages-list.md) | List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports --order asc|desc sorting, auto-pagination |
|
||||
| [`+flag-create`](references/lark-im-flag-create.md) | Create a bookmark on a message; user-only; defaults to message-layer flag; use --flag-type feed for feed-layer flag (item_type auto-detected from chat mode) |
|
||||
| [`+flag-cancel`](references/lark-im-flag-cancel.md) | Cancel (remove) a bookmark. When no --flag-type is given, best-effort double-cancel: removes message layer and (when chat_type is determinable) feed layer |
|
||||
| [`+flag-list`](references/lark-im-flag-list.md) | List bookmarks; user-only; auto-enriches feed-type thread entries with message content; `--page-all` is capped by `--page-limit` (default 20, max 1000), and `has_more=true` means the result is incomplete |
|
||||
|
||||
@@ -23,6 +23,9 @@ lark-cli im +chat-list --page-size 50
|
||||
# Pagination
|
||||
lark-cli im +chat-list --page-token "xxx"
|
||||
|
||||
# Fetch multiple pages automatically, up to 10 pages by default
|
||||
lark-cli im +chat-list --page-all
|
||||
|
||||
# Drop muted chats (user identity only)
|
||||
lark-cli im +chat-list --exclude-muted
|
||||
|
||||
@@ -51,12 +54,16 @@ lark-cli im +chat-list --as user --types p2p
|
||||
| `--sort <field>` | No | `create_time` (default, ascending), `active_time` (descending) | Result ordering |
|
||||
| `--page-size <n>` | No | 1-100, default 20 | Number of results per page |
|
||||
| `--page-token <token>` | No | - | Pagination token from the previous response |
|
||||
| `--page-all` | No | - | Automatically fetch and merge subsequent pages; capped by `--page-limit` |
|
||||
| `--page-limit <n>` | No | 1-1000, default 10 | Maximum pages fetched by `--page-all` |
|
||||
| `--exclude-muted` | No | User identity only | Drop chats the current user has muted (do-not-disturb). Under `--as bot`, the flag is silently inactive; see "Filtering muted chats" below |
|
||||
| `--format json` | No | - | Output as JSON |
|
||||
| `--dry-run` | No | - | Preview the request without executing it |
|
||||
|
||||
> **Note:** Supports both `--as user` (default) and `--as bot`. When using bot identity, the app must have bot capability enabled.
|
||||
|
||||
By default, the command fetches one page. With `--page-all`, it fetches and merges subsequent pages up to `--page-limit`. If the limit is reached while the output still has `has_more=true`, the result is incomplete; continue with the returned `page_token`, or rerun with a larger `--page-limit`. An explicitly supplied `--page-token` takes precedence and fetches only that page even when `--page-all` is also present.
|
||||
|
||||
## Output Fields
|
||||
|
||||
| Field | Description |
|
||||
@@ -156,7 +163,7 @@ done
|
||||
|
||||
| Symptom | Root Cause | Solution |
|
||||
|---------|---------|---------|
|
||||
| `--page-size must be an integer between 1 and 100` | page-size is out of range or not an integer | Use an integer between 1 and 100 |
|
||||
| `invalid --page-size 101: must be between 1 and 100` | page-size is out of range | Use an integer between 1 and 100 |
|
||||
| Permission denied (99991672) | The bot app does not have `im:chat:read` TAT permission enabled | Enable the permission for the app in the Open Platform console |
|
||||
| Permission denied (99991679) with `--as user` | UAT is not authorized for `im:chat:read` | Run `lark-cli auth login --scope "im:chat:read"` |
|
||||
| `Bot ability is not activated` (232025) | The app does not have bot capability enabled | Enable bot capability in the Open Platform console |
|
||||
|
||||
@@ -78,6 +78,6 @@ A truncated result is *not* fixable by paging further — it is a server-side ca
|
||||
| Symptom | Root Cause | | Solution |
|
||||
|---------|---------|---|---------|
|
||||
| `--chat-id is required` | `--chat-id` omitted | | Provide the `oc_xxx` chat ID |
|
||||
| `--page-size must be an integer between 1 and 100` | out of range | | Use 1-100 |
|
||||
| `invalid --page-size 101: must be between 1 and 100` | out of range | | Use 1-100 |
|
||||
| `--member-types contains invalid value` | value other than `user`/`bot` | | Use `user`, `bot`, or both |
|
||||
| Permission denied | missing `im:chat.members:read` | | Bot: enable the scope in the console. User: `lark-cli auth login --scope "im:chat.members:read"` |
|
||||
|
||||
@@ -29,6 +29,9 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --order asc --page-size 20
|
||||
# Pagination
|
||||
lark-cli im +chat-messages-list --chat-id oc_xxx --page-token "xxx"
|
||||
|
||||
# Fetch multiple pages automatically, up to 10 pages by default
|
||||
lark-cli im +chat-messages-list --chat-id oc_xxx --page-all
|
||||
|
||||
# JSON output
|
||||
lark-cli im +chat-messages-list --chat-id oc_xxx --format json
|
||||
```
|
||||
@@ -44,6 +47,8 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --format json
|
||||
| `--order <order>` | No | Sort order: `asc` / `desc` (default `desc`) |
|
||||
| `--page-size <n>` | No | Page size (default 50, max 50) |
|
||||
| `--page-token <token>` | No | Pagination token |
|
||||
| `--page-all` | No | Automatically fetch and merge subsequent pages; capped by `--page-limit` |
|
||||
| `--page-limit <n>` | No | Maximum pages fetched by `--page-all` (default 10, range 1-1000) |
|
||||
| `--no-reactions` | No | Skip auto-fetching the `reactions` block |
|
||||
| `--download-resources` | No | Download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block. Off by default; no extra requests when omitted |
|
||||
|
||||
@@ -106,12 +111,14 @@ Each message contains:
|
||||
|
||||
## Pagination (`has_more` / `page_token`)
|
||||
|
||||
`im +chat-messages-list` returns `has_more` and `page_token` when more data is available. Use `--page-token` to continue:
|
||||
By default, `im +chat-messages-list` fetches one page. It returns `has_more` and `page_token` when more data is available. Use `--page-token` to continue:
|
||||
|
||||
```bash
|
||||
lark-cli im +chat-messages-list --chat-id oc_xxx --page-token <PAGE_TOKEN>
|
||||
```
|
||||
|
||||
Use `--page-all` to fetch and merge multiple pages. `--page-limit` defaults to 10 and accepts values from 1 to 1000. If the command reaches this limit while the output still has `has_more=true`, the result is incomplete; resume with the returned `page_token`, or rerun with a larger `--page-limit`. An explicitly supplied `--page-token` takes precedence and fetches only that page even when `--page-all` is also present.
|
||||
|
||||
You can also fall back to the generic API:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -33,6 +33,9 @@ lark-cli im +chat-search --query "project" --page-size 10
|
||||
# Pagination
|
||||
lark-cli im +chat-search --query "project" --page-token "xxx"
|
||||
|
||||
# Fetch multiple pages automatically, up to 10 pages by default
|
||||
lark-cli im +chat-search --query "project" --page-all
|
||||
|
||||
# JSON output
|
||||
lark-cli im +chat-search --query "project" --format json
|
||||
|
||||
@@ -53,12 +56,16 @@ lark-cli im +chat-search --query "project" --dry-run
|
||||
| `--sort <field>` | No | `create_time`, `update_time`, `member_count` | Sort field (always descending) |
|
||||
| `--page-size <n>` | No | 1-100, default 20 | Number of results per page |
|
||||
| `--page-token <token>` | No | - | Pagination token from the previous response |
|
||||
| `--page-all` | No | - | Automatically fetch and merge subsequent pages; capped by `--page-limit` |
|
||||
| `--page-limit <n>` | No | 1-1000, default 10 | Maximum pages fetched by `--page-all` |
|
||||
| `--exclude-muted` | No | User identity only | Drop chats the current user has muted (do-not-disturb). Under `--as bot`, the flag is silently inactive (mute is a per-user setting); see "Filtering muted chats" below |
|
||||
| `--format json` | No | - | Output as JSON |
|
||||
| `--dry-run` | No | - | Preview the request without executing it |
|
||||
|
||||
> **Note:** Supports both `--as user` (default) and `--as bot`. When using bot identity, the app must have bot capability enabled.
|
||||
|
||||
By default, the command fetches one page. With `--page-all`, it fetches and merges subsequent pages up to `--page-limit`. If the limit is reached while the output still has `has_more=true`, the result is incomplete; continue with the returned `page_token`, or rerun with a larger `--page-limit`. An explicitly supplied `--page-token` takes precedence and fetches only that page even when `--page-all` is also present.
|
||||
|
||||
> **CAUTION:** `--sort` is **always descending** — the search API only ranks the chosen field high-to-low (e.g. `member_count` = most members first). There is no ascending option. If the user asks for "fewest first / ascending / 从少到多", tell them the search API does not support ascending order; any low-to-high view requires re-sorting the fetched page client-side and is not an upstream sort. Do **not** invent values like `member_count_asc` or pass `asc` (they are rejected).
|
||||
|
||||
## Output Fields
|
||||
@@ -121,7 +128,7 @@ lark-cli im +messages-send --chat-id "$CHAT_ID" --text "Today's progress update"
|
||||
|---------|---------|---------|
|
||||
| `--query and --member-ids cannot both be empty` | Both were omitted | Provide at least `--query` or `--member-ids` |
|
||||
| Empty results | No visible chats matched the keyword or filters | Relax the keyword or filters and try again |
|
||||
| `--page-size must be an integer between 1 and 100` | page-size is out of range or not an integer | Use an integer between 1 and 100 |
|
||||
| `invalid --page-size 101: must be between 1 and 100` | page-size is out of range | Use an integer between 1 and 100 |
|
||||
| Permission denied (99991672) | The bot app does not have `im:chat:read` TAT permission enabled | Enable the permission for the app in the Open Platform console |
|
||||
| Permission denied (99991679) with `--as user` | UAT is not authorized for `im:chat:read` | Run `lark-cli auth login --scope "im:chat:read"` |
|
||||
| `Bot ability is not activated` (232025) | The app does not have bot capability enabled | Enable bot capability in the Open Platform console |
|
||||
|
||||
@@ -10,7 +10,7 @@ Lists **one page** of the **current user's** feed shortcuts.
|
||||
|
||||
- Only **CHAT-type** shortcuts are exposed via OpenAPI today (others in the IDL are not yet whitelisted).
|
||||
- The shortcut is a **thin one-page wrapper** — there is no built-in auto-pagination. Callers drive their own loop when they actually need to paginate.
|
||||
- Server-side page size is controlled by the service; in normal use one page usually covers the list.
|
||||
- Server-side page size is controlled by the service, so this command has no `--page-size` flag; in normal use one page usually covers the list.
|
||||
- Pagination tokens are opaque. If a token is rejected because the shortcut list changed, restart by omitting `--page-token`.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -23,6 +23,9 @@ lark-cli im +threads-messages-list --thread omt_xxx --page-size 20
|
||||
# Pagination
|
||||
lark-cli im +threads-messages-list --thread omt_xxx --page-token <PAGE_TOKEN>
|
||||
|
||||
# Fetch multiple pages automatically, up to 10 pages by default
|
||||
lark-cli im +threads-messages-list --thread omt_xxx --page-all
|
||||
|
||||
# Output format options
|
||||
lark-cli im +threads-messages-list --thread omt_xxx --format pretty
|
||||
lark-cli im +threads-messages-list --thread omt_xxx --format table
|
||||
@@ -43,8 +46,10 @@ lark-cli im +threads-messages-list --thread omt_xxx --dry-run
|
||||
| `--no-reactions` | No | Skip auto-fetching the `reactions` block |
|
||||
| `--download-resources` | No | Download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block. Off by default |
|
||||
| `--order <order>` | No | Sort order: `asc` (default) / `desc` |
|
||||
| `--page-size <n>` | No | Number of items per page (default 50, range 1-500) |
|
||||
| `--page-size <n>` | No | Number of items per page (default 50, range 1-50) |
|
||||
| `--page-token <token>` | No | Pagination token for the next page |
|
||||
| `--page-all` | No | Automatically fetch and merge subsequent pages; capped by `--page-limit` |
|
||||
| `--page-limit <n>` | No | Maximum pages fetched by `--page-all` (default 10, range 1-1000) |
|
||||
| `--format <fmt>` | No | Output format: `json` (default) / `pretty` / `table` / `ndjson` / `csv` |
|
||||
| `--as <identity>` | No | Identity type: `user` (default) / `bot` |
|
||||
| `--dry-run` | No | Print the request only, do not execute it |
|
||||
@@ -61,8 +66,9 @@ Thread messages do not support `start_time` / `end_time` filtering because of Fe
|
||||
|
||||
### 3. Pagination (`has_more` / `page_token`)
|
||||
|
||||
- When the result includes `has_more=true`, use `page_token` to fetch the next page
|
||||
- If you need the complete thread, keep paginating; if you only need an overview, the first page is often enough
|
||||
By default, the command fetches one page. When the result includes `has_more=true`, use `page_token` to fetch the next page, or add `--page-all` to fetch and merge subsequent pages automatically. `--page-limit` defaults to 10 and accepts values from 1 to 1000.
|
||||
|
||||
If automatic pagination reaches the limit while the output still has `has_more=true`, the result is incomplete. Continue with the returned `page_token`, or rerun with a larger `--page-limit`. An explicitly supplied `--page-token` takes precedence and fetches only that page even when `--page-all` is also present.
|
||||
|
||||
### 4. Recommended expansion strategy
|
||||
|
||||
|
||||
@@ -61,28 +61,42 @@ func TestBaseListDryRunAcceptsPageSizeAliasForLimit(t *testing.T) {
|
||||
require.False(t, clie2e.DryRunGet(result.Stdout, "api.0.params.page_size").Exists(), result.Stdout)
|
||||
}
|
||||
|
||||
func TestBaseListDryRunRejectsLimitPageSizeConflict(t *testing.T) {
|
||||
func TestBaseListDryRunUsesLastPaginationSpelling(t *testing.T) {
|
||||
setBaseDryRunConfigEnv(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want int64
|
||||
}{
|
||||
{name: "alias last", args: []string{"--limit", "20", "--page-size", "40"}, want: 40},
|
||||
{name: "canonical last", args: []string{"--page-size", "40", "--limit", "20"}, want: 20},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
args := []string{"base", "+table-list", "--base-token", "app_x"}
|
||||
args = append(args, test.args...)
|
||||
args = append(args, "--dry-run")
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: args, DefaultAs: "bot"})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Equal(t, test.want, clie2e.DryRunGet(result.Stdout, "api.0.params.limit").Int(), result.Stdout)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseListDryRunValidatesPageSizeAliasAsCanonicalLimit(t *testing.T) {
|
||||
setBaseDryRunConfigEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"base", "+table-list",
|
||||
"--base-token", "app_x",
|
||||
"--limit", "20",
|
||||
"--page-size", "40",
|
||||
"--dry-run",
|
||||
},
|
||||
Args: []string{"base", "+table-list", "--base-token", "app_x", "--page-size", "101", "--dry-run"},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
|
||||
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), result.Stderr)
|
||||
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), result.Stderr)
|
||||
require.Equal(t, "--page-size", gjson.Get(result.Stderr, "error.param").String(), result.Stderr)
|
||||
require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), "mutually exclusive")
|
||||
require.Empty(t, result.Stdout)
|
||||
require.Equal(t, "--limit", gjson.Get(result.Stderr, "error.param").String(), result.Stderr)
|
||||
require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), "must be between 1 and 100")
|
||||
}
|
||||
|
||||
128
tests/cli_e2e/im/im_flag_aliases_dryrun_test.go
Normal file
128
tests/cli_e2e/im/im_flag_aliases_dryrun_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIMFlagAliasesDryRun(t *testing.T) {
|
||||
setFlagAliasDryRunEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
aliasArgs []string
|
||||
canonicalArgs []string
|
||||
defaultAs string
|
||||
}{
|
||||
{
|
||||
name: "chat messages",
|
||||
aliasArgs: []string{
|
||||
"im", "+chat-messages-list", "--chat-id", "oc_dryrun",
|
||||
"--start-time", "2026-07-27T00:00:00+08:00",
|
||||
"--end-time", "1785254400",
|
||||
"--sort-order", "asc", "--limit", "25", "--no-reactions", "--dry-run",
|
||||
},
|
||||
canonicalArgs: []string{
|
||||
"im", "+chat-messages-list", "--chat-id", "oc_dryrun",
|
||||
"--start", "2026-07-27T00:00:00+08:00",
|
||||
"--end", "1785254400",
|
||||
"--order", "asc", "--page-size", "25", "--no-reactions", "--dry-run",
|
||||
},
|
||||
defaultAs: "bot",
|
||||
},
|
||||
{
|
||||
name: "chat members page size",
|
||||
aliasArgs: []string{"im", "+chat-members-list", "--chat-id", "oc_dryrun", "--limit", "25", "--page-all", "--dry-run"},
|
||||
canonicalArgs: []string{"im", "+chat-members-list", "--chat-id", "oc_dryrun", "--page-size", "25", "--page-all", "--dry-run"},
|
||||
defaultAs: "bot",
|
||||
},
|
||||
{
|
||||
name: "thread id",
|
||||
aliasArgs: []string{"im", "+threads-messages-list", "--thread-id", "omt_dryrun", "--no-reactions", "--dry-run"},
|
||||
canonicalArgs: []string{"im", "+threads-messages-list", "--thread", "omt_dryrun", "--no-reactions", "--dry-run"},
|
||||
defaultAs: "bot",
|
||||
},
|
||||
{
|
||||
name: "message id",
|
||||
aliasArgs: []string{"im", "+messages-mget", "--message-id", "om_dryrun", "--no-reactions", "--dry-run"},
|
||||
canonicalArgs: []string{"im", "+messages-mget", "--message-ids", "om_dryrun", "--no-reactions", "--dry-run"},
|
||||
defaultAs: "bot",
|
||||
},
|
||||
{
|
||||
name: "message search",
|
||||
aliasArgs: []string{"im", "+messages-search", "--keyword", "project", "--limit", "30", "--no-reactions", "--dry-run"},
|
||||
canonicalArgs: []string{"im", "+messages-search", "--query", "project", "--page-size", "30", "--no-reactions", "--dry-run"},
|
||||
defaultAs: "user",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
aliasResult, err := clie2e.RunCmd(ctx, clie2e.Request{Args: tt.aliasArgs, DefaultAs: tt.defaultAs})
|
||||
require.NoError(t, err)
|
||||
aliasResult.AssertExitCode(t, 0)
|
||||
|
||||
canonicalResult, err := clie2e.RunCmd(ctx, clie2e.Request{Args: tt.canonicalArgs, DefaultAs: tt.defaultAs})
|
||||
require.NoError(t, err)
|
||||
canonicalResult.AssertExitCode(t, 0)
|
||||
|
||||
require.JSONEq(t, canonicalResult.Stdout, aliasResult.Stdout)
|
||||
require.Equal(t, canonicalResult.Stderr, aliasResult.Stderr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMLegacySortInputsNormalizeBeforeDryRun(t *testing.T) {
|
||||
setFlagAliasDryRunEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
resultPath string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "chat list upstream vocabulary",
|
||||
args: []string{"im", "+chat-list", "--sort-type", "ByActiveTimeDesc", "--dry-run"},
|
||||
resultPath: "api.0.params.sort_type",
|
||||
want: "ByActiveTimeDesc",
|
||||
},
|
||||
{
|
||||
name: "chat search upstream vocabulary",
|
||||
args: []string{"im", "+chat-search", "--query", "team", "--sort-by", "update_time_desc", "--dry-run"},
|
||||
resultPath: "api.0.body.sorter",
|
||||
want: "update_time_desc",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: tt.args, DefaultAs: "bot"})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Equal(t, tt.want, clie2e.DryRunGet(result.Stdout, tt.resultPath).String(), result.Stdout)
|
||||
require.Empty(t, result.Stderr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setFlagAliasDryRunEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "alias_dryrun_test")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "alias_dryrun_secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
|
||||
t.Setenv("LARKSUITE_CLI_NO_SKILLS_NOTIFIER", "1")
|
||||
}
|
||||
71
tests/cli_e2e/im/im_list_page_all_dryrun_test.go
Normal file
71
tests/cli_e2e/im/im_list_page_all_dryrun_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIM_ListPageAllDryRun(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")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{
|
||||
name: "chat-messages-list",
|
||||
args: []string{"im", "+chat-messages-list", "--chat-id", "oc_dryrun"},
|
||||
method: http.MethodGet,
|
||||
path: "/open-apis/im/v1/messages",
|
||||
},
|
||||
{
|
||||
name: "threads-messages-list",
|
||||
args: []string{"im", "+threads-messages-list", "--thread", "omt_dryrun"},
|
||||
method: http.MethodGet,
|
||||
path: "/open-apis/im/v1/messages",
|
||||
},
|
||||
{
|
||||
name: "chat-list",
|
||||
args: []string{"im", "+chat-list"},
|
||||
method: http.MethodGet,
|
||||
path: "/open-apis/im/v1/chats",
|
||||
},
|
||||
{
|
||||
name: "chat-search",
|
||||
args: []string{"im", "+chat-search", "--query", "team"},
|
||||
method: http.MethodPost,
|
||||
path: "/open-apis/im/v2/chats/search",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
args := append([]string{}, tc.args...)
|
||||
args = append(args, "--page-all", "--page-limit", "3", "--dry-run")
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: args, DefaultAs: "bot"})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, tc.method, clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, tc.path, clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "Auto-paginates until exhaustion or --page-limit is reached", clie2e.DryRunGet(out, "description").String(), "stdout:\n%s", out)
|
||||
})
|
||||
}
|
||||
}
|
||||
180
tests/cli_e2e/im/im_page_all_live_test.go
Normal file
180
tests/cli_e2e/im/im_page_all_live_test.go
Normal file
@@ -0,0 +1,180 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestIM_PageAllLiveWorkflow exercises the real multi-page pagination added to
|
||||
// the im list shortcuts: pages are fetched until exhaustion or --page-limit,
|
||||
// merged in order, and the merged result carries has_more plus the resume
|
||||
// page_token from the last fetched page.
|
||||
//
|
||||
// Self-contained: creates its own chats and messages. Chat cleanup follows the
|
||||
// repo-wide convention in createChat — lark-cli has no chat-delete command, so
|
||||
// created chats are intentionally left in the test account.
|
||||
//
|
||||
// +chat-search pagination is intentionally not covered live: newly created
|
||||
// chats are not immediately searchable (server-side indexing lag), which would
|
||||
// make the assertion flaky. Its pagination loop is covered by unit and dry-run
|
||||
// tests.
|
||||
func TestIM_PageAllLiveWorkflow(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
chatID := createChat(t, parentT, ctx, "lark-cli-e2e-page-all-"+suffix)
|
||||
// A second chat guarantees the bot is a member of at least two chats, so
|
||||
// +chat-list with --page-size 1 is guaranteed to have a second page.
|
||||
createChat(t, parentT, ctx, "lark-cli-e2e-page-all-b-"+suffix)
|
||||
|
||||
texts := make([]string, 0, 3)
|
||||
var parentMessageID string
|
||||
for i := 1; i <= 3; i++ {
|
||||
text := fmt.Sprintf("lark-cli-e2e-page-all-msg-%d-%s", i, suffix)
|
||||
texts = append(texts, text)
|
||||
id := sendMessage(t, ctx, chatID, text)
|
||||
if i == 1 {
|
||||
parentMessageID = id
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("chat-messages-list stops at page limit with resume token", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "+chat-messages-list", "--chat-id", chatID,
|
||||
"--page-size", "1", "--page-all", "--page-limit", "1"},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.messages.#").Int())
|
||||
require.True(t, gjson.Get(result.Stdout, "data.has_more").Bool(),
|
||||
"3 messages at page-size 1 must not fit in one page")
|
||||
require.NotEmpty(t, gjson.Get(result.Stdout, "data.page_token").String(),
|
||||
"an incomplete merged result must carry the resume token")
|
||||
require.False(t, gjson.Get(result.Stdout, "meta.pagination.complete").Bool())
|
||||
require.Equal(t, int64(1), gjson.Get(result.Stdout, "meta.pagination.pages").Int())
|
||||
require.Equal(t, int64(1), gjson.Get(result.Stdout, "meta.pagination.items").Int())
|
||||
require.Equal(t, gjson.Get(result.Stdout, "data.page_token").String(),
|
||||
gjson.Get(result.Stdout, "meta.pagination.next_token").String())
|
||||
require.NotContains(t, result.Stderr, "result is incomplete")
|
||||
})
|
||||
|
||||
t.Run("chat-messages-list walks every page", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "+chat-messages-list", "--chat-id", chatID,
|
||||
"--page-size", "1", "--page-all"},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
require.GreaterOrEqual(t, gjson.Get(result.Stdout, "data.messages.#").Int(), int64(3))
|
||||
require.False(t, gjson.Get(result.Stdout, "data.has_more").Bool())
|
||||
require.Contains(t, result.Stderr, "[page 2]", "expected a real second page fetch")
|
||||
require.True(t, gjson.Get(result.Stdout, "meta.pagination.complete").Bool())
|
||||
require.GreaterOrEqual(t, gjson.Get(result.Stdout, "meta.pagination.pages").Int(), int64(3))
|
||||
require.Equal(t, gjson.Get(result.Stdout, "data.messages.#").Int(),
|
||||
gjson.Get(result.Stdout, "meta.pagination.items").Int())
|
||||
for _, text := range texts {
|
||||
require.Contains(t, result.Stdout, text, "merged result must contain every sent message")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("threads-messages-list walks a real thread", func(t *testing.T) {
|
||||
for i := 1; i <= 2; i++ {
|
||||
reply, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "+messages-reply",
|
||||
"--message-id", parentMessageID,
|
||||
"--text", fmt.Sprintf("lark-cli-e2e-page-all-reply-%d-%s", i, suffix),
|
||||
"--reply-in-thread",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
reply.AssertExitCode(t, 0)
|
||||
reply.AssertStdoutStatus(t, true)
|
||||
}
|
||||
|
||||
// Thread replies replicate asynchronously; retry until both are visible.
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"im", "+threads-messages-list", "--thread", parentMessageID,
|
||||
"--page-size", "1", "--page-all"},
|
||||
DefaultAs: "bot",
|
||||
}, clie2e.RetryOptions{
|
||||
ShouldRetry: func(result *clie2e.Result) bool {
|
||||
if result == nil || result.ExitCode != 0 {
|
||||
return true
|
||||
}
|
||||
return strings.Count(result.Stdout, "lark-cli-e2e-page-all-reply-") < 2
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
require.GreaterOrEqual(t, gjson.Get(result.Stdout, "data.messages.#").Int(), int64(2))
|
||||
require.False(t, gjson.Get(result.Stdout, "data.has_more").Bool())
|
||||
require.Contains(t, result.Stderr, "[page 2]", "expected a real second page fetch")
|
||||
require.True(t, gjson.Get(result.Stdout, "meta.pagination.complete").Bool())
|
||||
require.GreaterOrEqual(t, gjson.Get(result.Stdout, "meta.pagination.pages").Int(), int64(2))
|
||||
require.Equal(t, gjson.Get(result.Stdout, "data.messages.#").Int(),
|
||||
gjson.Get(result.Stdout, "meta.pagination.items").Int())
|
||||
})
|
||||
|
||||
t.Run("chat-list paginates across chats", func(t *testing.T) {
|
||||
partial, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "+chat-list", "--page-size", "1", "--page-all", "--page-limit", "1"},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
partial.AssertExitCode(t, 0)
|
||||
partial.AssertStdoutStatus(t, true)
|
||||
require.Equal(t, int64(1), gjson.Get(partial.Stdout, "data.chats.#").Int())
|
||||
require.True(t, gjson.Get(partial.Stdout, "data.has_more").Bool(),
|
||||
"the bot is in at least two chats, so page 1 of size 1 must not be the end")
|
||||
require.NotEmpty(t, gjson.Get(partial.Stdout, "data.page_token").String())
|
||||
require.False(t, gjson.Get(partial.Stdout, "meta.pagination.complete").Bool())
|
||||
require.Equal(t, int64(1), gjson.Get(partial.Stdout, "meta.pagination.pages").Int())
|
||||
require.Equal(t, int64(1), gjson.Get(partial.Stdout, "meta.pagination.items").Int())
|
||||
require.Equal(t, gjson.Get(partial.Stdout, "data.page_token").String(),
|
||||
gjson.Get(partial.Stdout, "meta.pagination.next_token").String())
|
||||
require.NotContains(t, partial.Stderr, "result is incomplete")
|
||||
|
||||
// The bot may be a member of many accumulated e2e chats, so a full walk
|
||||
// can legitimately end at the default --page-limit with has_more=true.
|
||||
// Assert the merge itself plus the resume contract instead of exhaustion.
|
||||
full, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "+chat-list", "--page-size", "1", "--page-all"},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
full.AssertExitCode(t, 0)
|
||||
full.AssertStdoutStatus(t, true)
|
||||
require.GreaterOrEqual(t, gjson.Get(full.Stdout, "data.chats.#").Int(), int64(2))
|
||||
require.Contains(t, full.Stderr, "[page 2]", "expected a real second page fetch")
|
||||
require.GreaterOrEqual(t, gjson.Get(full.Stdout, "meta.pagination.pages").Int(), int64(2))
|
||||
require.Equal(t, gjson.Get(full.Stdout, "data.chats.#").Int(), gjson.Get(full.Stdout, "meta.pagination.items").Int())
|
||||
if gjson.Get(full.Stdout, "data.has_more").Bool() {
|
||||
require.NotEmpty(t, gjson.Get(full.Stdout, "data.page_token").String(),
|
||||
"a truncated merged result must carry the resume token")
|
||||
require.False(t, gjson.Get(full.Stdout, "meta.pagination.complete").Bool())
|
||||
require.Equal(t, gjson.Get(full.Stdout, "data.page_token").String(),
|
||||
gjson.Get(full.Stdout, "meta.pagination.next_token").String())
|
||||
} else {
|
||||
require.True(t, gjson.Get(full.Stdout, "meta.pagination.complete").Bool())
|
||||
}
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user