Compare commits

..

1 Commits

Author SHA1 Message Date
liangshuo-1
e32d7cb42e feat: add framework flag aliases and unified IM pagination
Introduce declarative exact-name flag aliases at the shortcut framework boundary while keeping semantic compatibility domain-owned. Add a shared, format-aware IM pagination pipeline with consistent flags, metadata, safety bounds, resumable cursors, and request throttling.
2026-08-03 03:47:34 +08:00
102 changed files with 4933 additions and 934 deletions

View 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...)
}

View 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)
}
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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{}

View File

@@ -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())
}
}

View File

@@ -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

View File

@@ -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"
}
}
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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")},

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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}

View File

@@ -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
View 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")
}

View 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)
}
}

View File

@@ -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() {

View File

@@ -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,

View File

@@ -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

View File

@@ -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")
}

View File

@@ -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)
}
}
})

View File

@@ -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 == "" {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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{

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View 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)
}

View 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()
}

View 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
}

View 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")
}

View 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)
}
})
}
}

View File

@@ -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

View 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 },
})
}

View 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)
}
}

View File

@@ -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

View File

@@ -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))

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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)
}
}

View File

@@ -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")

View File

@@ -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")

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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")
}
}
}

View File

@@ -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"},

View File

@@ -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")

View File

@@ -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")

View 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)
}
}

View File

@@ -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")

View 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, "")
}

View 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
}

View File

@@ -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
}
}

View File

@@ -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{

View 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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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))
}
}

View File

@@ -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...)
}

View File

@@ -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`)
}

View File

@@ -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" {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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
}

View 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
}

View File

@@ -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)
})
}
}

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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"},

View File

@@ -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)"},

View File

@@ -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}},

View File

@@ -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"},

View File

@@ -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 |

View File

@@ -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 |

View File

@@ -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"` |

View File

@@ -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

View File

@@ -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 |

View File

@@ -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

View File

@@ -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

View File

@@ -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")
}

View 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")
}

View 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)
})
}
}

View 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