chore(im): align help skills and contract coverage

This commit is contained in:
luozhixiong
2026-07-27 19:29:19 +08:00
parent d639a390de
commit fd6b8d3934
40 changed files with 1359 additions and 685 deletions

View File

@@ -12,6 +12,7 @@ import (
"github.com/larksuite/cli/internal/affordance"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/imcontract"
"github.com/larksuite/cli/internal/meta"
"github.com/spf13/cobra"
)
@@ -161,6 +162,7 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
}
}
writeContractHelp(&b, cmd)
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
b.WriteString(ann[paramsOnlyAnnotation])
@@ -191,12 +193,16 @@ func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceShortcut {
return false
}
raw, ok := affordanceRaw(cmd)
if !ok {
return false
var a meta.Affordance
hasAffordance := false
if raw, ok := affordanceRaw(cmd); ok {
if parsed, parsedOK := (meta.Method{Affordance: raw}).ParsedAffordance(); parsedOK {
a = parsed
hasAffordance = true
}
}
a, ok := (meta.Method{Affordance: raw}).ParsedAffordance()
if !ok {
contractHelp := imcontract.HelpText(cmd)
if !hasAffordance && contractHelp == "" {
return false
}
if len(a.Tips) == 0 {
@@ -210,12 +216,23 @@ func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
b.WriteString("\n\n")
b.WriteString(block)
}
if contractHelp != "" {
b.WriteString("\n\n")
b.WriteString(contractHelp)
}
writeRelatedSkills(&b, a.Skills, skillFS)
cmd.Long = b.String()
return true
}
func writeContractHelp(b *strings.Builder, cmd *cobra.Command) {
if text := imcontract.HelpText(cmd); text != "" {
b.WriteString("\n\n")
b.WriteString(text)
}
}
// writeRisk appends the "Risk: <level>" line, warning agents not to self-approve
// high-risk-write commands. A no-op when the command has no risk annotation.
func writeRisk(b *strings.Builder, cmd *cobra.Command) {

View File

@@ -11,6 +11,7 @@ import (
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/imcontract"
"github.com/larksuite/cli/internal/meta"
"github.com/spf13/cobra"
)
@@ -142,6 +143,49 @@ func TestPrepareMethodHelp(t *testing.T) {
}
}
func TestPrepareMethodHelpPreservesAffordanceAndAddsContractOnce(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
return json.RawMessage(`{
"use_when":["forward one message"],
"avoid_when":["a new send is required"],
"prerequisites":["source message is visible"],
"examples":[{"description":"forward","command":"lark-cli im messages forward ..."}],
"skills":["lark-im"]
}`), true
}
skillFS := fstest.MapFS{"lark-im/SKILL.md": {Data: []byte("# IM")}}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
m := map[string]interface{}{
"id": "chat.moderation.update", "path": "chats/{chat_id}/moderation", "httpMethod": "PUT", "description": "Update moderation",
}
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "update", "chat.moderation", nil)
if strings.Contains(cmd.Long, "Guarantee:") {
t.Fatalf("contract help must stay lazy at build time:\n%s", cmd.Long)
}
for range 2 {
if !PrepareMethodHelp(cmd, skillFS) {
t.Fatal("PrepareMethodHelp returned false")
}
}
for _, want := range []string{
"When to use:", "Avoid when:", "Prerequisites:", "Examples:",
"Related skills", "Full parameter schema:",
imcontract.HelpAcceptanceOnly.Text(),
} {
if n := strings.Count(cmd.Long, want); n != 1 {
t.Fatalf("%q appears %d times, want once:\n%s", want, n, cmd.Long)
}
}
contractAt := strings.Index(cmd.Long, imcontract.HelpAcceptanceOnly.Text())
schemaAt := strings.Index(cmd.Long, "Full parameter schema:")
if contractAt < 0 || schemaAt < 0 || contractAt > schemaAt {
t.Fatalf("contract help must precede schema pointer:\n%s", cmd.Long)
}
}
// PrepareShortcutHelp composes a shortcut's Long from its overlay with the same
// top layout as method help (no schema pointer), folding declarative tips when
// the overlay declares none, and leaves shortcuts without an overlay entry (and
@@ -190,6 +234,29 @@ func TestPrepareShortcutHelp(t *testing.T) {
}
}
func TestPrepareShortcutHelpAddsContractWithoutAffordance(t *testing.T) {
sc := &cobra.Command{
Use: "+chat-list", Short: "List chats",
Run: func(*cobra.Command, []string) {},
}
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
cmdmeta.SetAffordanceRef(sc, "im", "+chat-list")
cmdutil.SetRisk(sc, "read")
imcontract.AnnotateHelpContract(sc, "im +chat-list")
for range 2 {
if !PrepareShortcutHelp(sc, nil) {
t.Fatal("PrepareShortcutHelp returned false for contract-bearing shortcut")
}
}
if n := strings.Count(sc.Long, imcontract.HelpCompleteness.Text()); n != 1 {
t.Fatalf("contract help appears %d times, want once:\n%s", n, sc.Long)
}
if sc.Short != "List chats" || !strings.HasPrefix(sc.Long, "List chats") {
t.Fatalf("visible description changed: Short=%q Long=%q", sc.Short, sc.Long)
}
}
// Related-skill pointers are gated on existence: a skill that resolves in the
// skill FS renders, a typo is dropped (never print an unopenable `skills read`),
// and a nil skill FS suppresses the whole block.

View File

@@ -338,6 +338,7 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
paramsOnly := opts.binder.paramsOnlyHelp()
cmd.Long = methodLong(m.Description, spec.schemaPath, paramsOnly)
setMethodHelpData(cmd, spec.serviceName, m.ID, spec.schemaPath, paramsOnly)
imcontract.AnnotateHelpContract(cmd, spec.contractKey)
// Group flags for the grouped --help renderer (typed param flags are grouped
// as API Parameters by the binder). tagFlagGroup is a no-op for flags not

View File

@@ -1203,7 +1203,7 @@ func TestGeneratedIMModerationAlwaysReportsAcceptedUnverified(t *testing.T) {
}
completion := env["data"].(map[string]any)["completion"].(map[string]any)
if completion["status"] != "accepted_unverified" || completion["final_state_verified"] != false ||
env["hint"] != "The request was accepted, but the final moderator state was not verified." {
env["hint"] != nil {
t.Fatalf("unexpected envelope: %#v", env)
}
}

View File

@@ -0,0 +1,313 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package catalog
import (
"fmt"
"sort"
"time"
)
func ack(key string) Contract {
return Contract{Key: ContractKey(key), Strategy: Strategy{Kind: AuthoritativeAckKind}, ReplayMode: ReplayForbidden}
}
func required(key string, result RequiredSpec, replay ReplayMode) Contract {
return Contract{
Key: ContractKey(key),
Strategy: Strategy{Kind: RequiredResultKind, Required: result},
ReplayMode: replay,
}
}
func batch(key string, request EvidenceSpec, failures ...EvidenceSpec) Contract {
return Contract{
Key: ContractKey(key),
PartialRecovery: PartialRecoveryFailedItemsOnly,
Strategy: Strategy{
Kind: BatchPartialKind,
Request: request,
Failures: failures,
},
ReplayMode: ReplayForbidden,
}
}
func read(key string, kind StrategyKind) Contract {
return Contract{
Key: ContractKey(key),
Strategy: Strategy{Kind: kind},
}
}
func search(key, collectionField string) Contract {
return Contract{
Key: ContractKey(key),
Strategy: Strategy{
Kind: SearchReadKind,
CollectionField: collectionField,
},
}
}
func topString(field string) RequiredSpec {
return RequiredSpec{Shape: RequiredTopString, Field: field}
}
func topObject(field string) RequiredSpec {
return RequiredSpec{Shape: RequiredTopObject, Field: field}
}
func nestedString(field, child string) RequiredSpec {
return RequiredSpec{Shape: RequiredNestedString, Field: field, Child: child}
}
func stringsFrom(field string) EvidenceSpec {
return EvidenceSpec{Shape: EvidenceStrings, Field: field}
}
func objectsFrom(field, idField string) EvidenceSpec {
return EvidenceSpec{Shape: EvidenceObjects, Field: field, IDField: idField}
}
func nestedObjectsFrom(field, container, idField string) EvidenceSpec {
return EvidenceSpec{
Shape: EvidenceNestedObjects, Field: field, Container: container, IDField: idField,
}
}
func feedObjectsFrom(field string) EvidenceSpec {
return EvidenceSpec{Shape: EvidenceFeedObjects, Field: field}
}
func nestedFeedObjectsFrom(field, container string) EvidenceSpec {
return EvidenceSpec{Shape: EvidenceNestedFeedObjects, Field: field, Container: container}
}
func statusObjectsFrom(field, idField string) EvidenceSpec {
return EvidenceSpec{Shape: EvidenceStatusObjects, Field: field, IDField: idField}
}
var contracts = buildContracts()
func buildContracts() map[ContractKey]Contract {
all := []Contract{
read("im +feed-group-query-item", EntityReadKind),
read("im +messages-mget", EntityReadKind),
read("im chat.nickname get", EntityReadKind),
read("im chat.user_setting batch_query", EntityReadKind),
read("im chats get", EntityReadKind),
read("im feed.groups batch_query", EntityReadKind),
func() Contract {
c := read("im reactions batch_query", EntityReadKind)
c.Strategy.ReadHint = HintBatchReactions
return c
}(),
read("im +chat-list", CollectionReadKind),
read("im +chat-members-list", CollectionReadKind),
read("im +chat-messages-list", CollectionReadKind),
read("im +feed-group-list", CollectionReadKind),
read("im +feed-group-list-item", CollectionReadKind),
read("im +feed-shortcut-list", CollectionReadKind),
read("im +flag-list", CollectionReadKind),
read("im +threads-messages-list", CollectionReadKind),
read("im chat.members bots", CollectionReadKind),
read("im chat.members get", CollectionReadKind),
read("im chat.moderation get", CollectionReadKind),
read("im messages read_users", CollectionReadKind),
read("im pins list", CollectionReadKind),
read("im reactions list", CollectionReadKind),
search("im +chat-search", "chats"),
search("im +messages-search", "messages"),
read("im +messages-resources-download", MaterializeReadKind),
ack("im +chat-update"),
ack("im +flag-create"),
ack("im chat.nickname delete"),
ack("im chat.nickname update"),
ack("im chats update"),
ack("im feed.groups delete"),
ack("im feed.groups update"),
ack("im messages delete"),
ack("im pins delete"),
required("im +chat-create", topString("chat_id"), ReplayForbidden),
required("im +messages-reply", topString("message_id"), ReplaySameIdempotencyKey),
required("im +messages-send", topString("message_id"), ReplaySameIdempotencyKey),
required("im chats create", topString("chat_id"), ReplaySameIdempotencyKey),
required("im chats link", topString("share_link"), ReplayForbidden),
required("im feed.groups create", topString("group_id"), ReplayForbidden),
required("im images create", topString("image_key"), ReplayForbidden),
required("im messages forward", topString("message_id"), ReplaySameIdempotencyKey),
required("im pins create", topObject("pin"), ReplayForbidden),
required("im reactions create", topString("reaction_id"), ReplayForbidden),
required("im reactions delete", topString("reaction_id"), ReplayForbidden),
required("im threads forward", topString("message_id"), ReplaySameIdempotencyKey),
func() Contract {
c := batch(
"im +feed-shortcut-create",
objectsFrom("shortcuts", "feed_card_id"),
nestedObjectsFrom("failed_shortcuts", "shortcut", "feed_card_id"),
)
c.ReplayMode = ReplaySafe
c.PartialRecovery = PartialRecoveryWholeRequest
return c
}(),
func() Contract {
c := batch(
"im +feed-shortcut-remove",
objectsFrom("shortcuts", "feed_card_id"),
nestedObjectsFrom("failed_shortcuts", "shortcut", "feed_card_id"),
)
c.ReplayMode = ReplaySafe
c.PartialRecovery = PartialRecoveryWholeRequest
return c
}(),
{
Key: "im +flag-cancel",
PartialRecovery: PartialRecoveryWholeRequest,
Strategy: Strategy{
Kind: BatchPartialKind,
ResultLedger: ptrEvidence(statusObjectsFrom("results", "flag_type")),
},
ReplayMode: ReplaySafe,
},
{
Key: "im chat.members create",
Strategy: Strategy{
Kind: BatchPartialKind,
Request: stringsFrom("id_list"),
Failures: []EvidenceSpec{
stringsFrom("invalid_id_list"),
stringsFrom("not_existed_id_list"),
},
Pending: []EvidenceSpec{stringsFrom("pending_approval_id_list")},
},
ReplayMode: ReplayForbidden,
},
batch("im chat.members delete", stringsFrom("id_list"), stringsFrom("invalid_id_list")),
batch(
"im chat.user_setting batch_update",
objectsFrom("chat_settings", "chat_id"),
objectsFrom("invalid_ids", "id"),
),
{
Key: "im feed.groups batch_add_item",
Strategy: Strategy{
Kind: BatchPartialKind,
Request: feedObjectsFrom("items"),
Failures: []EvidenceSpec{nestedFeedObjectsFrom("failed_items", "item")},
},
ReplayMode: ReplayForbidden,
},
{
Key: "im feed.groups batch_remove_item",
Strategy: Strategy{
Kind: BatchPartialKind,
Request: feedObjectsFrom("items"),
Failures: []EvidenceSpec{nestedFeedObjectsFrom("failed_items", "item")},
},
ReplayMode: ReplayForbidden,
},
batch("im messages urgent_app", stringsFrom("user_id_list"), stringsFrom("invalid_user_id_list")),
batch("im messages urgent_phone", stringsFrom("user_id_list"), stringsFrom("invalid_user_id_list")),
batch("im messages urgent_sms", stringsFrom("user_id_list"), stringsFrom("invalid_user_id_list")),
{
Key: "im messages merge_forward",
Strategy: Strategy{
Kind: RequiredResultBatchPartialKind,
Required: nestedString("message", "message_id"),
Request: stringsFrom("message_id_list"),
Failures: []EvidenceSpec{stringsFrom("invalid_message_id_list")},
},
ReplayMode: ReplaySameIdempotencyKey,
},
{
Key: "im chat.managers add_managers",
Strategy: Strategy{
Kind: ResponseSetAssertionKind,
Request: stringsFrom("manager_ids"),
ResponseSets: []EvidenceSpec{stringsFrom("chat_managers"), stringsFrom("chat_bot_managers")},
Assertion: AssertRequestedPresent,
},
ReplayMode: ReplayForbidden,
},
{
Key: "im chat.managers delete_managers",
Strategy: Strategy{
Kind: ResponseSetAssertionKind,
Request: stringsFrom("manager_ids"),
ResponseSets: []EvidenceSpec{stringsFrom("chat_managers"), stringsFrom("chat_bot_managers")},
Assertion: AssertRequestedAbsent,
},
ReplayMode: ReplayForbidden,
},
{
Key: "im chat.moderation update",
Strategy: Strategy{Kind: ExemptionKind},
ReplayMode: ReplayForbidden,
Exemption: &Exemption{
Reason: "OpenAPI lacks per-item results",
Owner: "IM backend",
Expiry: "2026-10-25",
},
},
}
out := make(map[ContractKey]Contract, len(all))
for _, c := range all {
if c.PartialRecovery == "" &&
(c.Strategy.Kind == BatchPartialKind || c.Strategy.Kind == RequiredResultBatchPartialKind) {
c.PartialRecovery = PartialRecoveryFailedItemsOnly
}
switch {
case c.Strategy.Kind == CollectionReadKind || c.Strategy.Kind == SearchReadKind:
c.HelpPolicy = HelpCompleteness
case c.Strategy.Kind == ExemptionKind:
c.HelpPolicy = HelpAcceptanceOnly
}
out[c.Key] = c
}
return out
}
func ptrEvidence(spec EvidenceSpec) *EvidenceSpec {
return &spec
}
func Lookup(key ContractKey) (Contract, bool) {
c, ok := contracts[key]
return c, ok
}
func All() []Contract {
out := make([]Contract, 0, len(contracts))
for _, c := range contracts {
out = append(out, c)
}
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
return out
}
func ValidateRegistry(now time.Time) error {
for key, c := range contracts {
if key == "" || c.Strategy.Kind == "" {
return fmt.Errorf("invalid IM contract %q", key)
}
if c.Exemption == nil {
continue
}
expiry, err := c.Exemption.ExpiryTime()
if err != nil {
return fmt.Errorf("invalid exemption expiry for %q: %w", key, err)
}
if now.After(expiry.Add(24 * time.Hour)) {
return fmt.Errorf("IM contract exemption expired for %q (owner: %s)", key, c.Exemption.Owner)
}
}
return nil
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package catalog
import "testing"
func TestWholeRequestPartialRecoveryContracts(t *testing.T) {
for _, key := range []ContractKey{
"im +feed-shortcut-create",
"im +feed-shortcut-remove",
"im +flag-cancel",
} {
contract, ok := Lookup(key)
if !ok {
t.Fatalf("missing contract %q", key)
}
if contract.PartialRecovery != PartialRecoveryWholeRequest {
t.Fatalf("%s partial recovery = %q", key, contract.PartialRecovery)
}
}
remove, _ := Lookup("im +feed-shortcut-remove")
if remove.ReplayMode != ReplaySafe {
t.Fatalf("feed shortcut remove replay mode = %q", remove.ReplayMode)
}
urgent, _ := Lookup("im messages urgent_app")
if urgent.PartialRecovery != PartialRecoveryFailedItemsOnly {
t.Fatalf("urgent app partial recovery = %q", urgent.PartialRecovery)
}
}

View File

@@ -0,0 +1,151 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package catalog defines the static IM command completion contract catalog.
package catalog
import "time"
type ContractKey string
type StrategyKind string
const (
EntityReadKind StrategyKind = "entity_read"
CollectionReadKind StrategyKind = "collection_read"
SearchReadKind StrategyKind = "search_read"
MaterializeReadKind StrategyKind = "materialize_read"
AuthoritativeAckKind StrategyKind = "authoritative_ack"
RequiredResultKind StrategyKind = "required_result"
BatchPartialKind StrategyKind = "batch_partial"
RequiredResultBatchPartialKind StrategyKind = "required_result_batch_partial"
ResponseSetAssertionKind StrategyKind = "response_set_assertion"
ExemptionKind StrategyKind = "exemption"
)
func (k StrategyKind) IsWrite() bool {
switch k {
case AuthoritativeAckKind, RequiredResultKind, BatchPartialKind,
RequiredResultBatchPartialKind, ResponseSetAssertionKind, ExemptionKind:
return true
default:
return false
}
}
func (k StrategyKind) IsRead() bool {
switch k {
case EntityReadKind, CollectionReadKind, SearchReadKind, MaterializeReadKind:
return true
default:
return false
}
}
type ReplayMode string
const (
ReplayForbidden ReplayMode = "forbidden"
ReplaySafe ReplayMode = "safe"
ReplaySameIdempotencyKey ReplayMode = "same_idempotency_key"
)
type PartialRecoveryMode string
const (
PartialRecoveryWholeRequest PartialRecoveryMode = "whole_request"
PartialRecoveryFailedItemsOnly PartialRecoveryMode = "failed_items_only"
)
type AssertionMode string
const (
AssertRequestedPresent AssertionMode = "requested_present"
AssertRequestedAbsent AssertionMode = "requested_absent"
)
type RequiredShape uint8
const (
RequiredTopString RequiredShape = iota + 1
RequiredTopObject
RequiredNestedString
)
type EvidenceShape uint8
const (
EvidenceStrings EvidenceShape = iota + 1
EvidenceObjects
EvidenceNestedObjects
EvidenceFeedObjects
EvidenceNestedFeedObjects
EvidenceStatusObjects
)
type RequiredSpec struct {
Shape RequiredShape
Field string
Child string
}
type EvidenceSpec struct {
Shape EvidenceShape
Field string
IDField string
Container string
}
type Strategy struct {
Kind StrategyKind
Required RequiredSpec
Request EvidenceSpec
Failures []EvidenceSpec
Pending []EvidenceSpec
ResponseSets []EvidenceSpec
Assertion AssertionMode
ResultLedger *EvidenceSpec
// CollectionField is only used by the two fixed IM search strategies to
// determine whether an exhausted search returned no candidates. It is not
// a general response path or field extractor.
CollectionField string
ReadHint string
}
type HelpPolicy string
const (
HelpCompleteness HelpPolicy = "completeness"
HelpAcceptanceOnly HelpPolicy = "acceptance_only"
HintBatchReactions = "This result covers only the returned reaction fragments; use `im reactions list` to exhaust one message's reactions."
)
func (p HelpPolicy) Text() string {
switch p {
case HelpCompleteness:
return "Completeness: use --page-all --page-limit 0 for exhaustive output; only meta.complete=true proves completion."
case HelpAcceptanceOnly:
return "Guarantee: success confirms request acceptance only; independently query the final moderator state before claiming completion."
default:
return ""
}
}
type Exemption struct {
Reason string
Owner string
Expiry string
}
func (e Exemption) ExpiryTime() (time.Time, error) {
return time.Parse("2006-01-02", e.Expiry)
}
type Contract struct {
Key ContractKey
Strategy Strategy
ReplayMode ReplayMode
PartialRecovery PartialRecoveryMode
HelpPolicy HelpPolicy
Exemption *Exemption
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package imcontract
import "github.com/spf13/cobra"
const (
helpContractAnnotation = "imcontract.help.contract-key"
)
func AnnotateHelpContract(cmd *cobra.Command, key ContractKey) {
if cmd == nil || key == "" {
return
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[helpContractAnnotation] = string(key)
}
func HelpText(cmd *cobra.Command) string {
if cmd == nil || !cmd.Runnable() || cmd.Annotations == nil {
return ""
}
contract, ok := Lookup(ContractKey(cmd.Annotations[helpContractAnnotation]))
if !ok {
return ""
}
return contract.HelpPolicy.Text()
}

View File

@@ -0,0 +1,65 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package imcontract
import (
"testing"
"github.com/spf13/cobra"
)
func TestHelpPolicyTextUsesOnlyApprovedTemplates(t *testing.T) {
tests := []struct {
policy HelpPolicy
want string
}{
{HelpCompleteness, "Completeness: use --page-all --page-limit 0 for exhaustive output; only meta.complete=true proves completion."},
{HelpAcceptanceOnly, "Guarantee: success confirms request acceptance only; independently query the final moderator state before claiming completion."},
{HelpPolicy("unknown"), ""},
}
for _, tt := range tests {
if got := tt.policy.Text(); got != tt.want {
t.Fatalf("HelpPolicy(%q).Text() = %q, want %q", tt.policy, got, tt.want)
}
}
}
func TestRegistryHelpPolicies(t *testing.T) {
tests := []struct {
key ContractKey
want HelpPolicy
}{
{"im +chat-list", HelpCompleteness},
{"im +messages-search", HelpCompleteness},
{"im +messages-send", ""},
{"im messages merge_forward", ""},
{"im chat.moderation update", HelpAcceptanceOnly},
{"im +flag-create", ""},
}
for _, tt := range tests {
contract, ok := Lookup(tt.key)
if !ok {
t.Fatalf("missing contract %q", tt.key)
}
if contract.HelpPolicy != tt.want {
t.Fatalf("%s HelpPolicy = %q, want %q", tt.key, contract.HelpPolicy, tt.want)
}
}
}
func TestHelpTextIsLazyAndRunnableOnly(t *testing.T) {
cmd := &cobra.Command{Use: "+chat-list", Short: "List chats", Run: func(*cobra.Command, []string) {}}
AnnotateHelpContract(cmd, "im +chat-list")
if cmd.Long != "" || cmd.Short != "List chats" {
t.Fatalf("annotation changed visible help fields: Short=%q Long=%q", cmd.Short, cmd.Long)
}
if got := HelpText(cmd); got != HelpCompleteness.Text() {
t.Fatalf("HelpText() = %q", got)
}
parent := &cobra.Command{Use: "im"}
AnnotateHelpContract(parent, "im +chat-list")
if got := HelpText(parent); got != "" {
t.Fatalf("parent HelpText() = %q, want empty", got)
}
}

View File

@@ -35,10 +35,10 @@ type extraction struct {
}
func extract(root map[string]any, spec evidenceSpec) extraction {
if root == nil || spec.field == "" {
if root == nil || spec.Field == "" {
return extraction{}
}
raw, present := root[spec.field]
raw, present := root[spec.Field]
if !present {
return extraction{}
}
@@ -63,7 +63,7 @@ func extract(root map[string]any, spec evidenceSpec) extraction {
}
func extractItem(value any, spec evidenceSpec) (ledgerItem, bool) {
switch spec.shape {
switch spec.Shape {
case evidenceStrings:
return stringItem(value)
case evidenceObjects:
@@ -71,13 +71,13 @@ func extractItem(value any, spec evidenceSpec) (ledgerItem, bool) {
if !ok {
return ledgerItem{}, false
}
return stringItem(object[spec.idField])
return stringItem(object[spec.IDField])
case evidenceNestedObjects:
object, ok := nestedObject(value, spec.container)
object, ok := nestedObject(value, spec.Container)
if !ok {
return ledgerItem{}, false
}
return stringItem(object[spec.idField])
return stringItem(object[spec.IDField])
case evidenceFeedObjects:
object, ok := value.(map[string]any)
if !ok {
@@ -85,7 +85,7 @@ func extractItem(value any, spec evidenceSpec) (ledgerItem, bool) {
}
return feedItem(object)
case evidenceNestedFeedObjects:
object, ok := nestedObject(value, spec.container)
object, ok := nestedObject(value, spec.Container)
if !ok {
return ledgerItem{}, false
}
@@ -99,7 +99,7 @@ func extractItem(value any, spec evidenceSpec) (ledgerItem, bool) {
if status != "ok" && status != "failed" {
return ledgerItem{}, false
}
return stringItem(object[spec.idField])
return stringItem(object[spec.IDField])
default:
return ledgerItem{}, false
}
@@ -173,7 +173,7 @@ func uniqueItems(items []ledgerItem) []ledgerItem {
return out
}
func completion(requested, failed, pending []ledgerItem) Completion {
func completion(requested, failed, pending []ledgerItem, recovery PartialRecoveryMode) Completion {
requested = uniqueItems(requested)
requestedSet := make(map[string]struct{}, len(requested))
for _, item := range requested {
@@ -218,7 +218,12 @@ func completion(requested, failed, pending []ledgerItem) Completion {
retryScope := "none"
if len(failed) > 0 || len(pending) > 0 {
status = "partial"
if len(failed) > 0 {
switch {
case len(pending) > 0:
retryScope = "none"
case recovery == PartialRecoveryWholeRequest:
retryScope = "whole_request"
default:
retryScope = "failed_items_only"
}
}

View File

@@ -17,7 +17,6 @@ const (
hintStartPage = "This read started from a supplied page token and does not prove the collection was exhausted from the beginning."
hintServerTruncate = "The server truncated the result. Narrow the query range before retrying."
hintSearchEmpty = "The search was exhausted, but an empty search result does not prove that the resource does not exist."
hintBatchReactions = "This result covers only the returned reaction fragments; use `im reactions list` to exhaust one message's reactions."
)
type ReadOptions struct {
@@ -72,7 +71,7 @@ func (s *ReadSession) Finalize(data any) (ReadResult, error) {
return ReadResult{
OK: true,
Data: data,
Hint: s.contract.Strategy.readHint,
Hint: s.contract.Strategy.ReadHint,
}, nil
case CollectionReadKind, SearchReadKind:
if !s.observed {
@@ -95,7 +94,7 @@ func (s *ReadSession) Finalize(data any) (ReadResult, error) {
}
if s.contract.Strategy.Kind == SearchReadKind &&
s.status.StopReason == client.StopReasonExhausted &&
searchCollectionEmpty(data, s.contract.Strategy.collectionField) {
searchCollectionEmpty(data, s.contract.Strategy.CollectionField) {
result.Hint = joinHints(result.Hint, hintSearchEmpty)
}
return result, nil

View File

@@ -4,292 +4,23 @@
package imcontract
import (
"fmt"
"sort"
"time"
"github.com/larksuite/cli/internal/imcontract/catalog"
)
func ack(key string) Contract {
return Contract{Key: ContractKey(key), Strategy: Strategy{Kind: AuthoritativeAckKind}, ReplayMode: ReplayForbidden}
}
func required(key string, result requiredSpec, replay ReplayMode) Contract {
return Contract{
Key: ContractKey(key),
Strategy: Strategy{Kind: RequiredResultKind, required: result},
ReplayMode: replay,
}
}
func batch(key string, request evidenceSpec, failures ...evidenceSpec) Contract {
return Contract{
Key: ContractKey(key),
Strategy: Strategy{
Kind: BatchPartialKind,
request: request,
failures: failures,
},
ReplayMode: ReplayForbidden,
}
}
func read(key string, kind StrategyKind) Contract {
return Contract{
Key: ContractKey(key),
Strategy: Strategy{Kind: kind},
}
}
func search(key, collectionField string) Contract {
return Contract{
Key: ContractKey(key),
Strategy: Strategy{
Kind: SearchReadKind,
collectionField: collectionField,
},
}
}
func topString(field string) requiredSpec {
return requiredSpec{shape: requiredTopString, field: field}
}
func topObject(field string) requiredSpec {
return requiredSpec{shape: requiredTopObject, field: field}
}
func nestedString(field, child string) requiredSpec {
return requiredSpec{shape: requiredNestedString, field: field, child: child}
}
func stringsFrom(field string) evidenceSpec {
return evidenceSpec{shape: evidenceStrings, field: field}
}
func objectsFrom(field, idField string) evidenceSpec {
return evidenceSpec{shape: evidenceObjects, field: field, idField: idField}
}
func nestedObjectsFrom(field, container, idField string) evidenceSpec {
return evidenceSpec{
shape: evidenceNestedObjects, field: field, container: container, idField: idField,
}
}
func feedObjectsFrom(field string) evidenceSpec {
return evidenceSpec{shape: evidenceFeedObjects, field: field}
}
func nestedFeedObjectsFrom(field, container string) evidenceSpec {
return evidenceSpec{shape: evidenceNestedFeedObjects, field: field, container: container}
}
func statusObjectsFrom(field, idField string) evidenceSpec {
return evidenceSpec{shape: evidenceStatusObjects, field: field, idField: idField}
}
var contracts = buildContracts()
func buildContracts() map[ContractKey]Contract {
all := []Contract{
read("im +feed-group-query-item", EntityReadKind),
read("im +messages-mget", EntityReadKind),
read("im chat.nickname get", EntityReadKind),
read("im chat.user_setting batch_query", EntityReadKind),
read("im chats get", EntityReadKind),
read("im feed.groups batch_query", EntityReadKind),
func() Contract {
c := read("im reactions batch_query", EntityReadKind)
c.Strategy.readHint = hintBatchReactions
return c
}(),
read("im +chat-list", CollectionReadKind),
read("im +chat-members-list", CollectionReadKind),
read("im +chat-messages-list", CollectionReadKind),
read("im +feed-group-list", CollectionReadKind),
read("im +feed-group-list-item", CollectionReadKind),
read("im +feed-shortcut-list", CollectionReadKind),
read("im +flag-list", CollectionReadKind),
read("im +threads-messages-list", CollectionReadKind),
read("im chat.members bots", CollectionReadKind),
read("im chat.members get", CollectionReadKind),
read("im chat.moderation get", CollectionReadKind),
read("im messages read_users", CollectionReadKind),
read("im pins list", CollectionReadKind),
read("im reactions list", CollectionReadKind),
search("im +chat-search", "chats"),
search("im +messages-search", "messages"),
read("im +messages-resources-download", MaterializeReadKind),
ack("im +chat-update"),
ack("im +flag-create"),
ack("im chat.nickname delete"),
ack("im chat.nickname update"),
ack("im chats update"),
ack("im feed.groups delete"),
ack("im feed.groups update"),
ack("im messages delete"),
ack("im pins delete"),
required("im +chat-create", topString("chat_id"), ReplayForbidden),
required("im +messages-reply", topString("message_id"), ReplaySameIdempotencyKey),
required("im +messages-send", topString("message_id"), ReplaySameIdempotencyKey),
required("im chats create", topString("chat_id"), ReplaySameIdempotencyKey),
required("im chats link", topString("share_link"), ReplayForbidden),
required("im feed.groups create", topString("group_id"), ReplayForbidden),
required("im images create", topString("image_key"), ReplayForbidden),
required("im messages forward", topString("message_id"), ReplaySameIdempotencyKey),
required("im pins create", topObject("pin"), ReplayForbidden),
required("im reactions create", topString("reaction_id"), ReplayForbidden),
required("im reactions delete", topString("reaction_id"), ReplayForbidden),
required("im threads forward", topString("message_id"), ReplaySameIdempotencyKey),
func() Contract {
c := batch(
"im +feed-shortcut-create",
objectsFrom("shortcuts", "feed_card_id"),
nestedObjectsFrom("failed_shortcuts", "shortcut", "feed_card_id"),
)
c.ReplayMode = ReplaySafe
return c
}(),
batch(
"im +feed-shortcut-remove",
objectsFrom("shortcuts", "feed_card_id"),
nestedObjectsFrom("failed_shortcuts", "shortcut", "feed_card_id"),
),
{
Key: "im +flag-cancel",
Strategy: Strategy{
Kind: BatchPartialKind,
resultLedger: ptrEvidence(statusObjectsFrom("results", "flag_type")),
},
ReplayMode: ReplaySafe,
},
{
Key: "im chat.members create",
Strategy: Strategy{
Kind: BatchPartialKind,
request: stringsFrom("id_list"),
failures: []evidenceSpec{
stringsFrom("invalid_id_list"),
stringsFrom("not_existed_id_list"),
},
pending: []evidenceSpec{stringsFrom("pending_approval_id_list")},
},
ReplayMode: ReplayForbidden,
},
batch("im chat.members delete", stringsFrom("id_list"), stringsFrom("invalid_id_list")),
batch(
"im chat.user_setting batch_update",
objectsFrom("chat_settings", "chat_id"),
objectsFrom("invalid_ids", "id"),
),
{
Key: "im feed.groups batch_add_item",
Strategy: Strategy{
Kind: BatchPartialKind,
request: feedObjectsFrom("items"),
failures: []evidenceSpec{nestedFeedObjectsFrom("failed_items", "item")},
},
ReplayMode: ReplayForbidden,
},
{
Key: "im feed.groups batch_remove_item",
Strategy: Strategy{
Kind: BatchPartialKind,
request: feedObjectsFrom("items"),
failures: []evidenceSpec{nestedFeedObjectsFrom("failed_items", "item")},
},
ReplayMode: ReplayForbidden,
},
batch("im messages urgent_app", stringsFrom("user_id_list"), stringsFrom("invalid_user_id_list")),
batch("im messages urgent_phone", stringsFrom("user_id_list"), stringsFrom("invalid_user_id_list")),
batch("im messages urgent_sms", stringsFrom("user_id_list"), stringsFrom("invalid_user_id_list")),
{
Key: "im messages merge_forward",
Strategy: Strategy{
Kind: RequiredResultBatchPartialKind,
required: nestedString("message", "message_id"),
request: stringsFrom("message_id_list"),
failures: []evidenceSpec{stringsFrom("invalid_message_id_list")},
},
ReplayMode: ReplaySameIdempotencyKey,
},
{
Key: "im chat.managers add_managers",
Strategy: Strategy{
Kind: ResponseSetAssertionKind,
request: stringsFrom("manager_ids"),
responseSets: []evidenceSpec{stringsFrom("chat_managers"), stringsFrom("chat_bot_managers")},
assertion: AssertRequestedPresent,
},
ReplayMode: ReplayForbidden,
},
{
Key: "im chat.managers delete_managers",
Strategy: Strategy{
Kind: ResponseSetAssertionKind,
request: stringsFrom("manager_ids"),
responseSets: []evidenceSpec{stringsFrom("chat_managers"), stringsFrom("chat_bot_managers")},
assertion: AssertRequestedAbsent,
},
ReplayMode: ReplayForbidden,
},
{
Key: "im chat.moderation update",
Strategy: Strategy{Kind: ExemptionKind},
ReplayMode: ReplayForbidden,
Exemption: &Exemption{
Reason: "OpenAPI lacks per-item results",
Owner: "IM backend",
Expiry: "2026-10-25",
},
},
}
out := make(map[ContractKey]Contract, len(all))
for _, c := range all {
out[c.Key] = c
}
return out
}
func ptrEvidence(spec evidenceSpec) *evidenceSpec {
return &spec
}
func Lookup(key ContractKey) (Contract, bool) {
c, ok := contracts[key]
return c, ok
return catalog.Lookup(key)
}
func All() []Contract {
out := make([]Contract, 0, len(contracts))
for _, c := range contracts {
out = append(out, c)
}
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
return out
return catalog.All()
}
func ValidateRegistry(now time.Time) error {
for key, c := range contracts {
if key == "" || c.Strategy.Kind == "" {
return fmt.Errorf("invalid IM contract %q", key)
}
if c.Exemption == nil {
continue
}
expiry, err := c.Exemption.ExpiryTime()
if err != nil {
return fmt.Errorf("invalid exemption expiry for %q: %w", key, err)
}
if now.After(expiry.Add(24 * time.Hour)) {
return fmt.Errorf("IM contract exemption expired for %q (owner: %s)", key, c.Exemption.Owner)
}
}
return nil
return catalog.ValidateRegistry(now)
}
func stringsFrom(field string) evidenceSpec {
return evidenceSpec{Shape: evidenceStrings, Field: field}
}

View File

@@ -26,7 +26,7 @@ func (s *Session) Contract() Contract {
}
func (s *Session) ObserveRequest(body map[string]any) error {
if spec := s.contract.Strategy.request; spec.field != "" {
if spec := s.contract.Strategy.Request; spec.Field != "" {
evidence := extract(body, spec)
if !evidence.present || evidence.selectedCount == 0 ||
evidence.rejectedCount != 0 ||
@@ -34,7 +34,7 @@ func (s *Session) ObserveRequest(body map[string]any) error {
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"IM write request field %q has an unsupported shape",
spec.field,
spec.Field,
)
}
s.requested = uniqueItems(append(s.requested, evidence.items...))
@@ -74,8 +74,8 @@ func (s *Session) FinalizeSuccess(data any) (Result, error) {
case AuthoritativeAckKind:
return Result{OK: true, Data: data}, nil
case RequiredResultKind:
if !requiredResultPresent(data, s.contract.Strategy.required) {
return Result{}, s.FinalizeError(invalidRequiredResult(requiredLabel(s.contract.Strategy.required)))
if !requiredResultPresent(data, s.contract.Strategy.Required) {
return Result{}, s.FinalizeError(invalidRequiredResult(requiredLabel(s.contract.Strategy.Required)))
}
return Result{OK: true, Data: data}, nil
case BatchPartialKind:
@@ -88,8 +88,8 @@ func (s *Session) FinalizeSuccess(data any) (Result, error) {
if !result.OK {
return result, nil
}
if !requiredResultPresent(data, s.contract.Strategy.required) {
return Result{}, s.FinalizeError(invalidRequiredResult(requiredLabel(s.contract.Strategy.required)))
if !requiredResultPresent(data, s.contract.Strategy.Required) {
return Result{}, s.FinalizeError(invalidRequiredResult(requiredLabel(s.contract.Strategy.Required)))
}
return result, nil
case ResponseSetAssertionKind:
@@ -104,7 +104,7 @@ func (s *Session) FinalizeSuccess(data any) (Result, error) {
"final_state_verified": false,
"retry_scope": "none",
}
return Result{OK: true, Data: m, Hint: hintAcceptedUnverified}, nil
return Result{OK: true, Data: m}, nil
default:
return Result{}, errs.NewInternalError(
errs.SubtypeInvalidResponse,
@@ -115,10 +115,10 @@ func (s *Session) FinalizeSuccess(data any) (Result, error) {
}
func requiredLabel(spec requiredSpec) string {
if spec.child == "" {
return spec.field
if spec.Child == "" {
return spec.Field
}
return spec.field + "/" + spec.child
return spec.Field + "/" + spec.Child
}
func (s *Session) FinalizeError(err error) error {

View File

@@ -4,127 +4,58 @@
// Package imcontract evaluates IM command completion evidence.
package imcontract
import "time"
import "github.com/larksuite/cli/internal/imcontract/catalog"
type ContractKey string
type ContractKey = catalog.ContractKey
type StrategyKind = catalog.StrategyKind
type ReplayMode = catalog.ReplayMode
type PartialRecoveryMode = catalog.PartialRecoveryMode
type AssertionMode = catalog.AssertionMode
type Strategy = catalog.Strategy
type HelpPolicy = catalog.HelpPolicy
type Exemption = catalog.Exemption
type Contract = catalog.Contract
type StrategyKind string
type requiredSpec = catalog.RequiredSpec
type evidenceSpec = catalog.EvidenceSpec
const (
EntityReadKind StrategyKind = "entity_read"
CollectionReadKind StrategyKind = "collection_read"
SearchReadKind StrategyKind = "search_read"
MaterializeReadKind StrategyKind = "materialize_read"
AuthoritativeAckKind StrategyKind = "authoritative_ack"
RequiredResultKind StrategyKind = "required_result"
BatchPartialKind StrategyKind = "batch_partial"
RequiredResultBatchPartialKind StrategyKind = "required_result_batch_partial"
ResponseSetAssertionKind StrategyKind = "response_set_assertion"
ExemptionKind StrategyKind = "exemption"
EntityReadKind = catalog.EntityReadKind
CollectionReadKind = catalog.CollectionReadKind
SearchReadKind = catalog.SearchReadKind
MaterializeReadKind = catalog.MaterializeReadKind
AuthoritativeAckKind = catalog.AuthoritativeAckKind
RequiredResultKind = catalog.RequiredResultKind
BatchPartialKind = catalog.BatchPartialKind
RequiredResultBatchPartialKind = catalog.RequiredResultBatchPartialKind
ResponseSetAssertionKind = catalog.ResponseSetAssertionKind
ExemptionKind = catalog.ExemptionKind
ReplayForbidden = catalog.ReplayForbidden
ReplaySafe = catalog.ReplaySafe
ReplaySameIdempotencyKey = catalog.ReplaySameIdempotencyKey
PartialRecoveryWholeRequest = catalog.PartialRecoveryWholeRequest
PartialRecoveryFailedItemsOnly = catalog.PartialRecoveryFailedItemsOnly
AssertRequestedPresent = catalog.AssertRequestedPresent
AssertRequestedAbsent = catalog.AssertRequestedAbsent
requiredTopString = catalog.RequiredTopString
requiredTopObject = catalog.RequiredTopObject
requiredNestedString = catalog.RequiredNestedString
evidenceStrings = catalog.EvidenceStrings
evidenceObjects = catalog.EvidenceObjects
evidenceNestedObjects = catalog.EvidenceNestedObjects
evidenceFeedObjects = catalog.EvidenceFeedObjects
evidenceNestedFeedObjects = catalog.EvidenceNestedFeedObjects
evidenceStatusObjects = catalog.EvidenceStatusObjects
HelpCompleteness = catalog.HelpCompleteness
HelpAcceptanceOnly = catalog.HelpAcceptanceOnly
)
func (k StrategyKind) IsWrite() bool {
switch k {
case AuthoritativeAckKind, RequiredResultKind, BatchPartialKind,
RequiredResultBatchPartialKind, ResponseSetAssertionKind, ExemptionKind:
return true
default:
return false
}
}
func (k StrategyKind) IsRead() bool {
switch k {
case EntityReadKind, CollectionReadKind, SearchReadKind, MaterializeReadKind:
return true
default:
return false
}
}
type ReplayMode string
const (
ReplayForbidden ReplayMode = "forbidden"
ReplaySafe ReplayMode = "safe"
ReplaySameIdempotencyKey ReplayMode = "same_idempotency_key"
)
type AssertionMode string
const (
AssertRequestedPresent AssertionMode = "requested_present"
AssertRequestedAbsent AssertionMode = "requested_absent"
)
type requiredShape uint8
const (
requiredTopString requiredShape = iota + 1
requiredTopObject
requiredNestedString
)
type evidenceShape uint8
const (
evidenceStrings evidenceShape = iota + 1
evidenceObjects
evidenceNestedObjects
evidenceFeedObjects
evidenceNestedFeedObjects
evidenceStatusObjects
)
type requiredSpec struct {
shape requiredShape
field string
child string
}
type evidenceSpec struct {
shape evidenceShape
field string
idField string
container string
}
type Strategy struct {
Kind StrategyKind
required requiredSpec
request evidenceSpec
failures []evidenceSpec
pending []evidenceSpec
responseSets []evidenceSpec
assertion AssertionMode
resultLedger *evidenceSpec
// collectionField is only used by the two fixed IM search strategies to
// determine whether an exhausted search returned no candidates. It is not
// a general response path or field extractor.
collectionField string
readHint string
}
type HelpPolicy string
type Exemption struct {
Reason string
Owner string
Expiry string
}
func (e Exemption) ExpiryTime() (time.Time, error) {
return time.Parse("2006-01-02", e.Expiry)
}
type Contract struct {
Key ContractKey
Strategy Strategy
ReplayMode ReplayMode
HelpPolicy HelpPolicy
Exemption *Exemption
}
type FactKind string
const (

View File

@@ -11,12 +11,10 @@ import (
)
const (
hintReplayForbidden = "The write result is unknown. Do not replay the original request."
hintReplaySafe = "The write result is unknown. Retrying the original request is safe."
hintSameKey = "The write result is unknown. Retry only with the same idempotency key."
hintPartial = "Continue only with completion.failed_items after correcting their errors. Do not replay completion.succeeded_items or completion.pending_items."
hintAcceptedUnverified = "The request was accepted, but the final moderator state was not verified."
hintUnsafeEvidence = "The server response could not be safely mapped to the original request. Do not retry the write based on this response."
hintReplayForbidden = "The write result is unknown. Do not replay the original request."
hintReplaySafe = "The write result is unknown. Retrying the original request is safe."
hintSameKey = "The write result is unknown. Retry only with the same idempotency key."
hintUnsafeEvidence = "The server response could not be safely mapped to the original request. Do not retry the write based on this response."
)
func invalidRequiredResult(field string) error {
@@ -51,15 +49,15 @@ func requiredResultPresent(data any, spec requiredSpec) bool {
if !ok {
return false
}
switch spec.shape {
switch spec.Shape {
case requiredTopString:
return nonEmptyString(root[spec.field]) != ""
return nonEmptyString(root[spec.Field]) != ""
case requiredTopObject:
object, ok := root[spec.field].(map[string]any)
object, ok := root[spec.Field].(map[string]any)
return ok && len(object) > 0
case requiredNestedString:
object, ok := root[spec.field].(map[string]any)
return ok && nonEmptyString(object[spec.child]) != ""
object, ok := root[spec.Field].(map[string]any)
return ok && nonEmptyString(object[spec.Child]) != ""
default:
return false
}
@@ -103,18 +101,18 @@ func finalizeBatch(s *Session, data any) (Result, error) {
}
requested := append([]ledgerItem{}, s.requested...)
failed := make([]ledgerItem, 0)
for _, spec := range s.contract.Strategy.failures {
for _, spec := range s.contract.Strategy.Failures {
evidence := extract(root, spec)
if err := validateEvidence(evidence, requested, spec.field, true); err != nil {
if err := validateEvidence(evidence, requested, spec.Field, true); err != nil {
return Result{}, err
}
failed = append(failed, evidence.items...)
}
responsePending := make([]ledgerItem, 0)
for _, spec := range s.contract.Strategy.pending {
for _, spec := range s.contract.Strategy.Pending {
evidence := extract(root, spec)
if err := validateEvidence(evidence, requested, spec.field, true); err != nil {
if err := validateEvidence(evidence, requested, spec.Field, true); err != nil {
return Result{}, err
}
responsePending = append(responsePending, evidence.items...)
@@ -125,9 +123,9 @@ func finalizeBatch(s *Session, data any) (Result, error) {
syntheticPending = append(syntheticPending, ledgerItem{key: "feed", value: "feed"})
}
if spec := s.contract.Strategy.resultLedger; spec != nil {
if spec := s.contract.Strategy.ResultLedger; spec != nil {
evidence := extract(root, *spec)
if err := validateEvidence(evidence, nil, spec.field, false); err != nil {
if err := validateEvidence(evidence, nil, spec.Field, false); err != nil {
return Result{}, err
}
requested = append(requested, evidence.items...)
@@ -138,25 +136,24 @@ func finalizeBatch(s *Session, data any) (Result, error) {
// represents a logical sub-request performed by a shortcut.
requested = append(requested, syntheticPending...)
pending := append(responsePending, syntheticPending...)
ledger := completion(requested, failed, pending)
ledger := completion(requested, failed, pending, s.contract.PartialRecovery)
root["completion"] = ledger
result := Result{OK: ledger.Status == "complete", Data: root}
if !result.OK {
result.ExitCode = output.ExitAPI
result.Hint = hintPartial
}
return result, nil
}
func statusFailures(root map[string]any, spec evidenceSpec) []ledgerItem {
values, _ := root[spec.field].([]any)
values, _ := root[spec.Field].([]any)
failed := make([]ledgerItem, 0)
for _, value := range values {
object, _ := value.(map[string]any)
if fmt.Sprint(object["status"]) != "failed" {
continue
}
item, ok := stringItem(object[spec.idField])
item, ok := stringItem(object[spec.IDField])
if ok {
failed = append(failed, item)
}
@@ -171,9 +168,9 @@ func finalizeAssertion(s *Session, data any) (Result, error) {
}
actual := make(map[string]struct{})
responseSetPresent := false
for _, spec := range s.contract.Strategy.responseSets {
for _, spec := range s.contract.Strategy.ResponseSets {
evidence := extract(root, spec)
if err := validateEvidence(evidence, nil, spec.field, false); err != nil {
if err := validateEvidence(evidence, nil, spec.Field, false); err != nil {
return Result{}, err
}
responseSetPresent = responseSetPresent || evidence.present
@@ -187,17 +184,16 @@ func finalizeAssertion(s *Session, data any) (Result, error) {
failed := make([]ledgerItem, 0)
for _, item := range s.requested {
_, exists := actual[item.key]
if (s.contract.Strategy.assertion == AssertRequestedPresent && !exists) ||
(s.contract.Strategy.assertion == AssertRequestedAbsent && exists) {
if (s.contract.Strategy.Assertion == AssertRequestedPresent && !exists) ||
(s.contract.Strategy.Assertion == AssertRequestedAbsent && exists) {
failed = append(failed, item)
}
}
ledger := completion(s.requested, failed, nil)
ledger := completion(s.requested, failed, nil, PartialRecoveryFailedItemsOnly)
root["completion"] = ledger
result := Result{OK: ledger.Status == "complete", Data: root}
if !result.OK {
result.ExitCode = output.ExitAPI
result.Hint = hintPartial
}
return result, nil
}

View File

@@ -168,7 +168,7 @@ func TestModerationAcceptedUnverified(t *testing.T) {
if completion["status"] != "accepted_unverified" || completion["final_state_verified"] != false {
t.Fatalf("completion = %#v", completion)
}
if got.Hint != hintAcceptedUnverified {
if got.Hint != "" {
t.Fatalf("hint = %q", got.Hint)
}
}
@@ -224,6 +224,67 @@ func TestReplaySafety(t *testing.T) {
}
}
func TestBatchPartialRecoveryMatrix(t *testing.T) {
tests := []struct {
name string
command ContractKey
request map[string]any
response map[string]any
fact *Fact
wantScope string
}{
{
name: "pending always forbids retry",
command: "im +flag-cancel",
response: map[string]any{"results": []any{
map[string]any{"flag_type": "message", "status": "ok"},
}},
fact: &Fact{Kind: FactFlagFeedLayerPending},
wantScope: "none",
},
{
name: "whole request recovery",
command: "im +feed-shortcut-create",
request: map[string]any{"shortcuts": []any{
map[string]any{"feed_card_id": "oc_a"},
}},
response: map[string]any{"failed_shortcuts": []any{
map[string]any{"shortcut": map[string]any{"feed_card_id": "oc_a"}},
}},
wantScope: "whole_request",
},
{
name: "failed items only recovery",
command: "im messages urgent_app",
request: map[string]any{"user_id_list": []any{"ou_a", "ou_b"}},
response: map[string]any{"invalid_user_id_list": []any{"ou_b"}},
wantScope: "failed_items_only",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
contract, _ := Lookup(tc.command)
session := NewSession(contract)
if tc.request != nil {
if err := session.ObserveRequest(tc.request); err != nil {
t.Fatal(err)
}
}
if tc.fact != nil {
session.RecordFact(*tc.fact)
}
result, err := session.FinalizeSuccess(tc.response)
if err != nil {
t.Fatal(err)
}
completion := result.Data.(map[string]any)["completion"].(Completion)
if completion.RetryScope != tc.wantScope || result.Hint != "" {
t.Fatalf("completion=%#v hint=%q", completion, result.Hint)
}
})
}
}
func TestBatchRejectsUnmappableFailureEvidence(t *testing.T) {
for _, tc := range []struct {
name string
@@ -456,7 +517,7 @@ func TestCompletionIsClosedOverRequestedItems(t *testing.T) {
},
} {
t.Run(tc.name, func(t *testing.T) {
got := completion(tc.requested, tc.failed, tc.pending)
got := completion(tc.requested, tc.failed, tc.pending, PartialRecoveryFailedItemsOnly)
if got.RequestedCount != got.SucceededCount+got.FailedCount+got.PendingCount {
t.Fatalf("non-exclusive counts: %#v", got)
}

View File

@@ -9,8 +9,11 @@ import (
"os"
"path/filepath"
"testing"
"time"
imcatalog "github.com/larksuite/cli/internal/imcontract/catalog"
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/qualitygate/rules"
)
func TestManifestExportWritesManifestAndCommandIndex(t *testing.T) {
@@ -45,6 +48,20 @@ func TestManifestExportWritesManifestAndCommandIndex(t *testing.T) {
}
}
func TestExportedCommandIndexMatchesIMContractCatalog(t *testing.T) {
index, err := collectCommandIndex(context.Background())
if err != nil {
t.Fatalf("collectCommandIndex() error = %v", err)
}
if diags := rules.CheckIMContractCoverage(
index,
imcatalog.All(),
time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC),
); len(diags) != 0 {
t.Fatalf("exported IM contract diagnostics = %#v", diags)
}
}
func TestManifestExportRequiresOutputPaths(t *testing.T) {
var stderr bytes.Buffer
code := runManifestExport(nil, &stderr)

View File

@@ -0,0 +1,116 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package rules
import (
"fmt"
"sort"
"strings"
"time"
imcatalog "github.com/larksuite/cli/internal/imcontract/catalog"
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/qualitygate/report"
)
const (
imContractCoverageRule = "im_contract_coverage"
expectedIMLeafCommands = 60
)
func CheckIMContractCoverage(commandIndex manifest.Manifest, contracts []imcatalog.Contract, now time.Time) []report.Diagnostic {
leafKeys := imLeafCommandKeys(commandIndex)
leafSet := make(map[string]struct{}, len(leafKeys))
for _, key := range leafKeys {
leafSet[key] = struct{}{}
}
contractSet := make(map[string]imcatalog.Contract, len(contracts))
for _, contract := range contracts {
contractSet[string(contract.Key)] = contract
}
var diags []report.Diagnostic
if len(leafKeys) != expectedIMLeafCommands {
diags = append(diags, imContractDiagnostic(
"",
fmt.Sprintf("IM leaf command count is %d, want %d", len(leafKeys), expectedIMLeafCommands),
))
}
for _, key := range leafKeys {
if _, ok := contractSet[key]; !ok {
diags = append(diags, imContractDiagnostic(key, "IM leaf command has no completion contract"))
}
}
for _, contract := range contracts {
key := string(contract.Key)
if _, ok := leafSet[key]; !ok {
diags = append(diags, imContractDiagnostic(key, "IM contract key does not match a runnable leaf command"))
}
if contract.Strategy.Kind != imcatalog.ExemptionKind {
continue
}
if contract.Exemption == nil {
diags = append(diags, imContractDiagnostic(key, "IM contract exemption is missing owner, reason, and expiry"))
continue
}
exemption := contract.Exemption
if exemption.Owner == "" || exemption.Reason == "" || exemption.Expiry == "" {
diags = append(diags, imContractDiagnostic(
key,
fmt.Sprintf("IM contract exemption is incomplete (owner=%q reason=%q expiry=%q)", exemption.Owner, exemption.Reason, exemption.Expiry),
))
continue
}
expiry, err := exemption.ExpiryTime()
if err != nil {
diags = append(diags, imContractDiagnostic(
key,
fmt.Sprintf("IM contract exemption has invalid expiry %q (owner=%q reason=%q)", exemption.Expiry, exemption.Owner, exemption.Reason),
))
continue
}
if !now.Before(expiry.AddDate(0, 0, 1)) {
diags = append(diags, imContractDiagnostic(
key,
fmt.Sprintf("IM contract exemption expired on %s (owner=%q reason=%q)", exemption.Expiry, exemption.Owner, exemption.Reason),
))
}
}
return diags
}
func imLeafCommandKeys(commandIndex manifest.Manifest) []string {
var candidates []string
for _, cmd := range commandIndex.Commands {
if cmd.Domain == "im" && cmd.Runnable {
candidates = append(candidates, cmd.Path)
}
}
sort.Strings(candidates)
leaves := make([]string, 0, len(candidates))
for _, path := range candidates {
parent := false
for _, other := range candidates {
if other != path && strings.HasPrefix(other, path+" ") {
parent = true
break
}
}
if !parent {
leaves = append(leaves, path)
}
}
return leaves
}
func imContractDiagnostic(commandPath, message string) report.Diagnostic {
return report.Diagnostic{
Rule: imContractCoverageRule,
Action: report.ActionReject,
File: "command-index",
Message: message,
SubjectType: "command",
CommandPath: commandPath,
}
}

View File

@@ -0,0 +1,109 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package rules
import (
"fmt"
"strings"
"testing"
"time"
imcatalog "github.com/larksuite/cli/internal/imcontract/catalog"
qdiff "github.com/larksuite/cli/internal/qualitygate/diff"
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/qualitygate/report"
)
func TestIMLeafCommandsExcludeParentsAndOtherDomains(t *testing.T) {
index := manifest.Manifest{Commands: []manifest.Command{
{Path: "im chat", Domain: "im", Runnable: true},
{Path: "im chat get", Domain: "im", Runnable: true},
{Path: "im chat list", Domain: "im", Runnable: false},
{Path: "docs chat get", Domain: "docs", Runnable: true},
}}
got := imLeafCommandKeys(index)
if len(got) != 1 || got[0] != "im chat get" {
t.Fatalf("IM leaves = %#v, want only runnable child", got)
}
}
func TestIMContractCoverageReportsMissingAndStaleKeys(t *testing.T) {
index, contracts := completeIMCoverageFixture()
contracts = contracts[1:]
contracts = append(contracts, imcatalog.Contract{
Key: "im stale command", Strategy: imcatalog.Strategy{Kind: imcatalog.EntityReadKind},
})
diags := CheckIMContractCoverage(index, contracts, time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC))
if !hasIMContractDiagnostic(diags, "im resource command00", "no completion contract") {
t.Fatalf("missing-command diagnostic absent: %#v", diags)
}
if !hasIMContractDiagnostic(diags, "im stale command", "does not match") {
t.Fatalf("stale-key diagnostic absent: %#v", diags)
}
}
func TestIMContractCoverageReportsExpiredExemptionWithOwnerAndReason(t *testing.T) {
index, contracts := completeIMCoverageFixture()
contracts[0] = imcatalog.Contract{
Key: contracts[0].Key,
Strategy: imcatalog.Strategy{Kind: imcatalog.ExemptionKind},
Exemption: &imcatalog.Exemption{
Owner: "IM backend", Reason: "OpenAPI lacks evidence", Expiry: "2026-10-25",
},
}
diags := CheckIMContractCoverage(index, contracts, time.Date(2026, 10, 26, 0, 0, 0, 0, time.UTC))
if !hasIMContractDiagnostic(diags, string(contracts[0].Key), "owner=\"IM backend\"") ||
!hasIMContractDiagnostic(diags, string(contracts[0].Key), "reason=\"OpenAPI lacks evidence\"") {
t.Fatalf("expired exemption diagnostic lacks static ownership facts: %#v", diags)
}
}
func TestIMContractCoverageReportsMissingIMDomain(t *testing.T) {
index := manifest.Manifest{Commands: []manifest.Command{
{Path: "docs +fetch", Domain: "docs", Runnable: true},
}}
if leaves := imLeafCommandKeys(index); len(leaves) != 0 {
t.Fatalf("IM leaves = %#v, want none", leaves)
}
diags := CheckIMContractCoverage(index, imcatalog.All(), time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC))
if !hasIMContractDiagnostic(diags, "", "IM leaf command count is 0, want 60") {
t.Fatalf("missing-domain diagnostic absent: %#v", diags)
}
}
func TestIMContractCoverageDiagnosticIsNotChangedFileFiltered(t *testing.T) {
diag := imContractDiagnostic("im +chat-list", "missing")
got := filterPRDiagnostics(
".",
"origin/main",
qdiff.FromChangedFiles([]string{"skills/lark-doc/SKILL.md"}),
manifest.Manifest{},
[]report.Diagnostic{diag},
)
if len(got) != 1 || got[0].Rule != imContractCoverageRule {
t.Fatalf("global IM coverage diagnostic was filtered: %#v", got)
}
}
func completeIMCoverageFixture() (manifest.Manifest, []imcatalog.Contract) {
index := manifest.Manifest{SchemaVersion: 1}
contracts := make([]imcatalog.Contract, 0, expectedIMLeafCommands)
for i := 0; i < expectedIMLeafCommands; i++ {
key := fmt.Sprintf("im resource command%02d", i)
index.Commands = append(index.Commands, manifest.Command{Path: key, Domain: "im", Runnable: true})
contracts = append(contracts, imcatalog.Contract{
Key: imcatalog.ContractKey(key), Strategy: imcatalog.Strategy{Kind: imcatalog.EntityReadKind},
})
}
return index, contracts
}
func hasIMContractDiagnostic(diags []report.Diagnostic, key, text string) bool {
for _, diag := range diags {
if diag.CommandPath == key && strings.Contains(diag.Message, text) {
return true
}
}
return false
}

View File

@@ -10,7 +10,9 @@ import (
"path/filepath"
"sort"
"strings"
"time"
imcatalog "github.com/larksuite/cli/internal/imcontract/catalog"
qdiff "github.com/larksuite/cli/internal/qualitygate/diff"
manifestexamples "github.com/larksuite/cli/internal/qualitygate/examples"
"github.com/larksuite/cli/internal/qualitygate/facts"
@@ -43,6 +45,7 @@ func Run(ctx context.Context, opts Options) ([]report.Diagnostic, facts.Facts, e
if err := validateCommandIndexCoversManifest(m, commandIndex); err != nil {
return nil, facts.Facts{}, err
}
imContractDiags := CheckIMContractCoverage(commandIndex, imcatalog.All(), time.Now())
changed, err := qdiff.ChangedFiles(ctx, opts.Repo, opts.ChangedFrom)
if err != nil {
return nil, facts.Facts{}, err
@@ -110,6 +113,7 @@ func Run(ctx context.Context, opts Options) ([]report.Diagnostic, facts.Facts, e
}
diags = append(diags, publicContentDiagnostics(publicContent)...)
diags = filterPRDiagnostics(opts.Repo, opts.ChangedFrom, scope, m, diags)
diags = append(diags, imContractDiags...)
builtFacts := facts.BuildWithCommandLookup(m, commandIndex, skillFacts, skillQualityFacts, errorFacts, exampleFacts, outputFacts, diags, scope.Files)
return diags, facts.WithPublicContent(builtFacts, publicContentFacts(publicContent)), nil
@@ -212,6 +216,10 @@ func filterPRDiagnostics(repo, changedFrom string, scope qdiff.Scope, m manifest
commandScope := diagnosticCommandScopeFromFiles(scope.Files)
var out []report.Diagnostic
for _, diag := range diags {
if diag.Rule == imContractCoverageRule {
out = append(out, diag)
continue
}
if prDiagnosticRelevant(repo, scope.Files, commandScope, m, diag) {
out = append(out, diag)
}

View File

@@ -11,6 +11,7 @@ import (
"strings"
"testing"
imcatalog "github.com/larksuite/cli/internal/imcontract/catalog"
qdiff "github.com/larksuite/cli/internal/qualitygate/diff"
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/qualitygate/report"
@@ -103,6 +104,55 @@ func TestRunRequiresCommandIndexToCoverManifest(t *testing.T) {
}
}
func TestRunReportsMissingIMDomain(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
if err := vfs.WriteFile(filepath.Join(repo, "README.md"), []byte("# test\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, repo, "add", "README.md")
runGit(t, repo, "commit", "-m", "base")
if err := vfs.MkdirAll(filepath.Join(repo, "skills"), 0o755); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(repo, "command-manifest.json")
indexPath := filepath.Join(repo, "command-index.json")
m := manifest.Manifest{SchemaVersion: 1, Commands: []manifest.Command{{
Path: "docs +fetch", Domain: "docs", Source: manifest.SourceShortcut,
}}}
index := manifest.Manifest{SchemaVersion: 1, Commands: []manifest.Command{
{
Path: "docs +fetch", Domain: "docs", Source: manifest.SourceShortcut, Runnable: true,
},
{
Path: "drive files get", Domain: "drive", Source: manifest.SourceService, Generated: true, Runnable: true,
},
}}
if err := manifest.WriteFile(manifestPath, manifest.KindCommandManifest, m); err != nil {
t.Fatal(err)
}
if err := manifest.WriteFile(indexPath, manifest.KindCommandIndex, index); err != nil {
t.Fatal(err)
}
diags, _, err := Run(context.Background(), Options{
Repo: repo,
CLIBin: "./lark-cli",
ChangedFrom: "HEAD",
ManifestPath: manifestPath,
CommandIndexPath: indexPath,
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if !hasIMContractDiagnostic(diags, "", "IM leaf command count is 0, want 60") {
t.Fatalf("Run() missing-domain diagnostic absent: %#v", diags)
}
}
func TestRunReadsManifestFilesAndAcceptsServiceReferences(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
@@ -160,6 +210,11 @@ description: Manage Drive comments with service command references.
},
},
}}
for _, contract := range imcatalog.All() {
idx.Commands = append(idx.Commands, manifest.Command{
Path: string(contract.Key), Domain: "im", Source: manifest.SourceBuiltin, Runnable: true,
})
}
if err := manifest.WriteFile(manifestPath, manifest.KindCommandManifest, m); err != nil {
t.Fatal(err)
}

View File

@@ -969,6 +969,10 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
}
cmdmeta.SetSource(cmd, cmdmeta.SourceShortcut, false)
cmdmeta.SetAffordanceRef(cmd, shortcut.Service, shortcut.Command)
contractKey := imcontract.ContractKey(shortcut.Service + " " + shortcut.Command)
if _, ok := imcontract.Lookup(contractKey); ok {
imcontract.AnnotateHelpContract(cmd, contractKey)
}
cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes)
registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut)
cmdutil.SetTips(cmd, shortcut.Tips)

View File

@@ -8,9 +8,32 @@ import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/imcontract"
"github.com/spf13/cobra"
)
func TestShortcutMountStoresOnlyLazyIMContractHelpKey(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "im"}
shortcut := Shortcut{
Service: "im",
Command: "+chat-list",
Description: "List chats",
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+chat-list"})
if err != nil {
t.Fatal(err)
}
if cmd.Long != "" || cmd.Short != "List chats" {
t.Fatalf("mount changed visible help fields: Short=%q Long=%q", cmd.Short, cmd.Long)
}
if got := imcontract.HelpText(cmd); got != imcontract.HelpCompleteness.Text() {
t.Fatalf("lazy contract help = %q", got)
}
}
// TestShortcutMount_FlagCompletionsRegistered exercises the two
// cmdutil.RegisterFlagCompletion call sites in registerShortcutFlagsWithContext:
// the per-flag enum completion (runner.go:879) and the auto-injected --format

View File

@@ -491,6 +491,8 @@ func TestImFeedShortcutCreateExecuteCallsAPI(t *testing.T) {
t.Fatalf("Set chat-id error = %v", err)
}
setRuntimeField(t, rt, "Cmd", cmd)
contract, _ := imcontract.Lookup("im +feed-shortcut-create")
setRuntimeField(t, rt, "contractSession", imcontract.NewSession(contract))
err := ImFeedShortcutCreate.Execute(context.Background(), rt)
var pfErr *output.PartialFailureError
@@ -526,6 +528,20 @@ func TestImFeedShortcutCreateExecuteCallsAPI(t *testing.T) {
t.Fatalf("stdout = %s, want %q", out, want)
}
}
var envelope struct {
Hint string `json:"hint"`
Data struct {
Completion imcontract.Completion `json:"completion"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(out), &envelope); err != nil {
t.Fatalf("stdout is not JSON: %v\n%s", err, out)
}
if envelope.Data.Completion.RetryScope != "whole_request" ||
envelope.Data.Completion.FailedCount != 1 ||
envelope.Hint != "" {
t.Fatalf("completion = %#v, hint = %q", envelope.Data.Completion, envelope.Hint)
}
}
func TestImFeedShortcutCreateMalformedEvidenceStaysNonReplayable(t *testing.T) {
@@ -662,6 +678,53 @@ func TestImFeedShortcutRemoveExecuteCallsRemovePath(t *testing.T) {
}
}
func TestImFeedShortcutRemovePartialFailureUsesWholeRequestRecovery(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{
"failed_shortcuts": []any{
map[string]any{
"reason": float64(2),
"shortcut": map[string]any{
"feed_card_id": "oc_abc",
"type": float64(1),
},
},
},
},
}), nil
}))
cmd := newFeedShortcutRemoveCmd(t)
if err := cmd.Flags().Set("chat-id", "oc_abc"); err != nil {
t.Fatalf("Set chat-id error = %v", err)
}
setRuntimeField(t, rt, "Cmd", cmd)
contract, _ := imcontract.Lookup("im +feed-shortcut-remove")
setRuntimeField(t, rt, "contractSession", imcontract.NewSession(contract))
err := ImFeedShortcutRemove.Execute(context.Background(), rt)
var partialErr *output.PartialFailureError
if !errors.As(err, &partialErr) {
t.Fatalf("Execute() error = %T %v, want partial failure", err, err)
}
var envelope struct {
Hint string `json:"hint"`
Data struct {
Completion imcontract.Completion `json:"completion"`
} `json:"data"`
}
out := rt.Factory.IOStreams.Out.(*bytes.Buffer).Bytes()
if err := json.Unmarshal(out, &envelope); err != nil {
t.Fatalf("stdout is not JSON: %v\n%s", err, out)
}
if envelope.Data.Completion.RetryScope != "whole_request" ||
envelope.Data.Completion.FailedCount != 1 ||
envelope.Hint != "" {
t.Fatalf("completion = %#v, hint = %q", envelope.Data.Completion, envelope.Hint)
}
}
func TestImFeedShortcutListDryRunRendersGet(t *testing.T) {
cmd := newFeedShortcutListCmd(t)
rt := &common.RuntimeContext{Cmd: cmd}

View File

@@ -44,7 +44,7 @@ var ImFlagCancel = common.Shortcut{
POST("/open-apis/im/v1/flags/cancel").
Body(map[string]any{"flag_items": items})
if len(items) > 1 {
d.Desc("double-cancel: tries both message and feed layers (best-effort); feed-layer skipped if chat_type undeterminable")
d.Desc("double-cancel: tries both message and feed layers; an unresolved feed layer is reported as pending")
}
return d
},
@@ -128,7 +128,7 @@ func buildCancelItemsForPreview(rt *common.RuntimeContext) ([]any, bool, error)
// 1. If --flag-type is explicitly provided, do a single targeted delete.
// 2. Otherwise, perform double-cancel: remove both message layer and feed layer.
// - Message layer is always included (uses known message_id with ItemTypeDefault)
// - Feed layer is best-effort: if chat_type cannot be determined, skip with warning
// - Feed layer is best-effort: if chat_type cannot be determined, record it as pending
// - Each layer is independent; failure to cancel one doesn't block the other
func buildCancelItems(rt *common.RuntimeContext) ([]flagItem, error) {
id, err := flagMessageID(rt)

View File

@@ -1574,9 +1574,11 @@ func TestFlagCancelExecuteSummarizesPartialFailure(t *testing.T) {
}
var envelope struct {
OK bool `json:"ok"`
OK bool `json:"ok"`
Hint string `json:"hint"`
Data struct {
Results []map[string]any `json:"results"`
Results []map[string]any `json:"results"`
Completion imcontract.Completion `json:"completion"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(out), &envelope); err != nil {
@@ -1588,6 +1590,11 @@ func TestFlagCancelExecuteSummarizesPartialFailure(t *testing.T) {
if envelope.OK {
t.Fatalf("stdout ok = true, want false for partial failure")
}
if envelope.Data.Completion.RetryScope != "whole_request" ||
envelope.Data.Completion.FailedCount != 1 ||
envelope.Hint != "" {
t.Fatalf("completion = %#v, hint = %q", envelope.Data.Completion, envelope.Hint)
}
if errOut := rt.Factory.IOStreams.ErrOut.(*bytes.Buffer).String(); errOut != "" {
t.Fatalf("stderr = %q, want empty for partial failure result envelope", errOut)
}
@@ -1617,7 +1624,8 @@ func TestFlagCancelExecuteSkippedFeedLayerProducesPendingLedger(t *testing.T) {
t.Fatalf("Execute() error = %v", err)
}
var envelope struct {
OK bool `json:"ok"`
OK bool `json:"ok"`
Hint string `json:"hint"`
Data struct {
Completion imcontract.Completion `json:"completion"`
} `json:"data"`
@@ -1628,7 +1636,9 @@ func TestFlagCancelExecuteSkippedFeedLayerProducesPendingLedger(t *testing.T) {
}
if envelope.OK || envelope.Data.Completion.PendingCount != 1 ||
len(envelope.Data.Completion.PendingItems) != 1 ||
envelope.Data.Completion.PendingItems[0] != "feed" {
envelope.Data.Completion.PendingItems[0] != "feed" ||
envelope.Data.Completion.RetryScope != "none" ||
envelope.Hint != "" {
t.Fatalf("pending completion = %#v", envelope.Data.Completion)
}
if errOut := rt.Factory.IOStreams.ErrOut.(*bytes.Buffer).String(); errOut != "" {
@@ -1795,8 +1805,14 @@ func TestExecuteListAllPages(t *testing.T) {
t.Fatalf("ParseFlags() error = %v", err)
}
setRuntimeField(t, rt, "Cmd", cmd)
contract, _ := imcontract.Lookup("im +flag-list")
session, err := imcontract.NewReadSession(contract, imcontract.ReadOptions{FullRead: true})
if err != nil {
t.Fatalf("NewReadSession() error = %v", err)
}
setRuntimeField(t, rt, "readSession", session)
err := executeListAllPages(rt)
err = executeListAllPages(rt)
if err != nil {
t.Fatalf("executeListAllPages() error = %v", err)
}
@@ -1888,8 +1904,14 @@ func TestExecuteListAllPages_PageLimit(t *testing.T) {
t.Fatalf("ParseFlags() error = %v", err)
}
setRuntimeField(t, rt, "Cmd", cmd)
contract, _ := imcontract.Lookup("im +flag-list")
session, err := imcontract.NewReadSession(contract, imcontract.ReadOptions{FullRead: true})
if err != nil {
t.Fatalf("NewReadSession() error = %v", err)
}
setRuntimeField(t, rt, "readSession", session)
err := executeListAllPages(rt)
err = executeListAllPages(rt)
if err != nil {
t.Fatalf("executeListAllPages() error = %v", err)
}
@@ -1897,14 +1919,8 @@ func TestExecuteListAllPages_PageLimit(t *testing.T) {
if callCount != 3 {
t.Fatalf("expected 3 API calls (page limit), got %d", callCount)
}
stderr := rt.IO().ErrOut.(*bytes.Buffer).String()
for _, want := range []string{"reached page limit (3)", "has_more=true", "result is incomplete", "up to 1000", "page_token returned in stdout"} {
if !strings.Contains(stderr, want) {
t.Fatalf("stderr = %q, want %q", stderr, want)
}
}
if strings.Contains(stderr, "token_3") {
t.Fatalf("stderr must not expose the continuation token, got %q", stderr)
if stderr := rt.IO().ErrOut.(*bytes.Buffer).String(); stderr != "" {
t.Fatalf("stderr = %q, want structured recovery guidance in stdout", stderr)
}
var envelope map[string]any
@@ -1921,6 +1937,13 @@ func TestExecuteListAllPages_PageLimit(t *testing.T) {
if _, exists := data["truncated"]; exists {
t.Fatalf("output schema must remain unchanged; unexpected truncated field in %#v", data)
}
meta, _ := envelope["meta"].(map[string]any)
if meta["complete"] != false || meta["stop_reason"] != "page_limit" {
t.Fatalf("meta = %#v, want incomplete page-limit result", meta)
}
if hint, _ := envelope["hint"].(string); !strings.Contains(hint, "--page-limit 0") {
t.Fatalf("hint = %q, want exhaustive-read recovery", hint)
}
}
func TestExecuteListAllPages_RepeatedTokenDoesNotReportPageLimit(t *testing.T) {
@@ -1945,24 +1968,35 @@ func TestExecuteListAllPages_RepeatedTokenDoesNotReportPageLimit(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 50, "")
cmd.Flags().Int("page-limit", 10, "")
cmd.Flags().Bool("page-all", true, "")
cmd.Flags().Bool("enrich-feed-thread", false, "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
setRuntimeField(t, rt, "Cmd", cmd)
contract, _ := imcontract.Lookup("im +flag-list")
session, err := imcontract.NewReadSession(contract, imcontract.ReadOptions{FullRead: true})
if err != nil {
t.Fatalf("NewReadSession() error = %v", err)
}
setRuntimeField(t, rt, "readSession", session)
if err := executeListAllPages(rt); err != nil {
if err = executeListAllPages(rt); err != nil {
t.Fatalf("executeListAllPages() error = %v", err)
}
if callCount != 2 {
t.Fatalf("API calls = %d, want 2 before repeated-token stop", callCount)
}
stderr := rt.IO().ErrOut.(*bytes.Buffer).String()
if !strings.Contains(stderr, "page_token did not change") {
t.Fatalf("stderr = %q, want non-advancing token warning", stderr)
if stderr := rt.IO().ErrOut.(*bytes.Buffer).String(); stderr != "" {
t.Fatalf("stderr = %q, want structured repeated-token result in stdout", stderr)
}
if strings.Contains(stderr, "reached page limit") {
t.Fatalf("stderr = %q, repeated token must not be reported as a page-limit stop", stderr)
var envelope map[string]any
if err := json.Unmarshal(rt.IO().Out.(*bytes.Buffer).Bytes(), &envelope); err != nil {
t.Fatalf("decode stdout: %v", err)
}
meta, _ := envelope["meta"].(map[string]any)
if envelope["ok"] != false || meta["stop_reason"] != "repeated_token" {
t.Fatalf("envelope = %#v, want attributed incomplete read", envelope)
}
}

View File

@@ -116,24 +116,24 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
|----------|------|
| [`+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-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-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; 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-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-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 and 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 |
| [`+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 |
| [`+flag-list`](references/lark-im-flag-list.md) | List bookmarks; user-only; auto-enriches feed-type thread entries with message content |
| [`+feed-shortcut-create`](references/lark-im-feed-shortcut-create.md) | Add chats to the user's feed shortcuts; user-only; oc_xxx chat IDs only; batch up to 10 per call; `--head`/`--tail` controls insertion order; partial failures return an `ok:false` ledger |
| [`+feed-shortcut-remove`](references/lark-im-feed-shortcut-remove.md) | Remove chats from the user's feed shortcuts; user-only; batch up to 10 per call; removing an absent shortcut is idempotent success; real per-item failures return an `ok:false` ledger |
| [`+feed-shortcut-list`](references/lark-im-feed-shortcut-list.md) | List one page of the user's feed shortcuts; user-only; omit `--page-token` for the first page; default output enriches CHAT entries under `detail`; pass `--no-detail` to skip the extra lookup and `im:chat:read` scope |
| [`+feed-group-list`](references/lark-im-feed-group-list.md) | List the caller's feed groups (tags); user-only; supports `--page-all` auto-pagination |
| [`+feed-group-list-item`](references/lark-im-feed-group-list-item.md) | List feed cards in a feed group (tag); user-only; enriches each item with chat_name resolved from feed_id; supports --page-all auto-pagination |
| [`+feed-shortcut-list`](references/lark-im-feed-shortcut-list.md) | List the user's feed shortcuts; user-only; default output enriches CHAT entries under `detail`; pass `--no-detail` to skip the extra lookup and `im:chat:read` scope |
| [`+feed-group-list`](references/lark-im-feed-group-list.md) | List the caller's feed groups (tags); user-only; preserves both live and soft-deleted groups |
| [`+feed-group-list-item`](references/lark-im-feed-group-list-item.md) | List feed cards in a feed group (tag); user-only; enriches each item with chat_name resolved from feed_id |
| [`+feed-group-query-item`](references/lark-im-feed-group-query-item.md) | Look up specific feed cards in a feed group (tag) by ID; user-only; enriches each item with chat_name resolved from feed_id |
## API Resources

View File

@@ -17,12 +17,6 @@ lark-cli im +chat-list
# Sort by recent activity (most recently active first)
lark-cli im +chat-list --sort active_time
# Limit page size
lark-cli im +chat-list --page-size 50
# Pagination
lark-cli im +chat-list --page-token "xxx"
# Drop muted chats (user identity only)
lark-cli im +chat-list --exclude-muted
@@ -49,8 +43,6 @@ lark-cli im +chat-list --as user --types p2p
| `--user-id-type <type>` | No | `open_id` (default), `union_id`, `user_id` | ID type used for `owner_id` in the response |
| `--types <strings>` | No | `group`, `p2p` (comma-separated or repeated) | Chat types to include. Omitted = groups only (backward compatible). `p2p` requires user identity (`--as user`); under `--as bot`, `--types=p2p` alone is rejected and `--types=p2p,group` is silently downgraded to `group` |
| `--sort <field>` | No | `create_time` (default, ascending), `active_time` (descending) | Result ordering |
| `--page-size <n>` | No | 1-100, default 20 | Number of results per page |
| `--page-token <token>` | No | - | Pagination token from the previous response |
| `--exclude-muted` | No | User identity only | Drop chats the current user has muted (do-not-disturb). Under `--as bot`, the flag is silently inactive; see "Filtering muted chats" below |
| `--format json` | No | - | Output as JSON |
| `--dry-run` | No | - | Preview the request without executing it |
@@ -139,24 +131,12 @@ lark-cli im +chat-list --sort active_time --page-size 10
lark-cli im +chat-list --sort active_time --exclude-muted
```
### Scenario 3: Iterate all my chats programmatically
```bash
TOKEN=""
while :; do
RESP=$(lark-cli im +chat-list --page-size 100 --page-token "$TOKEN" --format json)
echo "$RESP" | jq -r '.data.chats[].chat_id'
HAS_MORE=$(echo "$RESP" | jq -r '.data.has_more')
[ "$HAS_MORE" = "true" ] || break
TOKEN=$(echo "$RESP" | jq -r '.data.page_token')
done
```
If the task requires every visible chat, inspect this concrete command's `--help` before executing.
## Common Errors and Troubleshooting
| 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 |
| 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

@@ -16,12 +16,6 @@ lark-cli im +chat-members-list --chat-id oc_xxx
lark-cli im +chat-members-list --chat-id oc_xxx --member-types user
lark-cli im +chat-members-list --chat-id oc_xxx --member-types user,bot
# Walk every page (capped by --page-limit; 0 = unlimited)
lark-cli im +chat-members-list --chat-id oc_xxx --page-all --page-limit 0
# Resume from a specific cursor (single page; --page-all is ignored)
lark-cli im +chat-members-list --chat-id oc_xxx --page-token "xxx"
# JSON output / preview the request
lark-cli im +chat-members-list --chat-id oc_xxx --format json
lark-cli im +chat-members-list --chat-id oc_xxx --dry-run
@@ -34,11 +28,6 @@ lark-cli im +chat-members-list --chat-id oc_xxx --dry-run
| `--chat-id <id>` | Yes | `oc_xxx` | Target chat |
| `--member-types <strings>` | No | `user`, `bot` (comma-separated or repeated) | Member types to return. Omitted = all |
| `--member-id-type <type>` | No | `open_id` (default), `union_id`, `user_id` | ID type for `member_id` in the response |
| `--page-size <n>` | No | 1-100, default 20 | Results per page. With `--page-all` and no explicit `--page-size`, the max (100) is used automatically to minimize round-trips |
| `--page-token <token>` | No | - | Pagination cursor; **implies a single-page fetch** (disables auto-pagination) |
| `--page-all` | No | - | Automatically walk every page (capped by `--page-limit`) |
| `--page-limit <n>` | No | default 10, `0` = unlimited | Max pages to fetch with `--page-all` |
| `--page-delay <ms>` | No | default 200, `0` = no delay | Delay between pages during `--page-all` (throttle to avoid rate limits on large lists) |
| `--format json` | No | - | Output as JSON |
| `--dry-run` | No | - | Preview the request without executing it |
@@ -64,20 +53,14 @@ The server applies a security cap to large member lists. When a bucket is capped
A truncated result is *not* fixable by paging further — it is a server-side cap. Treat `users`/`bots` as a partial list whenever `truncations` is non-empty.
## Pagination notes
## Result scope
- Default fetches a single page. Pass `--page-all` to drain every page.
- With `--page-all` and no explicit `--page-size`, the shortcut uses the maximum page size (100) so a full walk takes the fewest round-trips. An explicit `--page-size` is always honored.
- `--page-all` sleeps `--page-delay` ms (default 200) between pages to avoid hammering the API when a tenant has no server-side member cap and the list spans many pages. Set `--page-delay 0` to disable.
- `--page-all` stops at `--page-limit` pages (default 10). When it stops early, `has_more` stays `true` so you know the result is incomplete; re-run with `--page-limit 0` for everything.
- `--page-token` and `--page-all` together: `--page-token` wins (single-page fetch from the supplied cursor); a stderr warning is emitted.
- Across pages, `users[]` and `bots[]` are concatenated; `truncations` / `has_more` / `page_token` come from the last page fetched.
For pagination controls, inspect this concrete command's `--help`. Exhausting pages does not bypass the server-side security cap described above; a non-empty `truncations` array still means the member list is incomplete.
## Common Errors and Troubleshooting
| 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 |
| `--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

@@ -23,11 +23,8 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --start "2026-03-10T00:00:00+08
# Specify a time range (date only)
lark-cli im +chat-messages-list --chat-id oc_xxx --start 2026-03-10 --end 2026-03-11
# Control sort order and page size (max 50)
lark-cli im +chat-messages-list --chat-id oc_xxx --order asc --page-size 20
# Pagination
lark-cli im +chat-messages-list --chat-id oc_xxx --page-token "xxx"
# Control sort order
lark-cli im +chat-messages-list --chat-id oc_xxx --order asc
# JSON output
lark-cli im +chat-messages-list --chat-id oc_xxx --format json
@@ -42,8 +39,6 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --format json
| `--start <time>` | No | Start time (ISO 8601 or date only) |
| `--end <time>` | No | End time (ISO 8601 or date only) |
| `--order <order>` | No | Sort order: `asc` / `desc` (default `desc`) |
| `--page-size <n>` | No | Page size (default 50, max 50) |
| `--page-token <token>` | No | Pagination token |
| `--no-reactions` | No | Skip auto-fetching the `reactions` block |
| `--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 |
@@ -77,8 +72,8 @@ lark-cli im +threads-messages-list --thread omt_xxx
| Scenario | Recommendation |
|------|------|
| You need context | Call `im +threads-messages-list --order desc --page-size 10` for the discovered thread_id to inspect recent replies |
| The user asks for the "full discussion" | Use `im +threads-messages-list --order asc --page-size 50`, then paginate if needed |
| You need context | Call `im +threads-messages-list --order desc` for the discovered thread_id to inspect recent replies |
| The user asks for the "full discussion" | Inspect the thread command's `--help` for full-read controls, then read in chronological order |
| You only need an overview | Skip thread expansion |
## Output Fields
@@ -104,20 +99,7 @@ Each message contains:
| `mentions` | Array of @mentions in the message; each item contains `{id, key, name}`. Present only when the message contains @mentions |
| `thread_id` | Thread ID (`omt_xxx`) if the message has replies in a thread. Present only when replies exist |
## 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:
```bash
lark-cli im +chat-messages-list --chat-id oc_xxx --page-token <PAGE_TOKEN>
```
You can also fall back to the generic API:
```bash
lark-cli api GET /open-apis/im/v1/messages \
--params 'container_id_type=chat&container_id=oc_xxx&page_size=50&page_token=<PAGE_TOKEN>'
```
If the task requires the complete conversation, inspect this concrete command's `--help` before executing.
## Common Errors and Troubleshooting
@@ -147,9 +129,9 @@ lark-cli api GET /open-apis/im/v1/messages \
7. **Application/bot identity + named group history:** If the user says "使用应用身份/以 bot 身份" and asks to list or read historical messages for a named group, use bot identity for both steps:
```bash
lark-cli im +chat-search --as bot --query "<chat name keyword>" --format json
lark-cli im +chat-messages-list --as bot --chat-id <chat_id> --page-size 50 --format json
lark-cli im +chat-messages-list --as bot --chat-id <chat_id> --format json
```
Do not use `im +messages-search --as bot`; `+messages-search` is user-only. Continue with `--page-token` if `has_more=true`.
Do not use `im +messages-search --as bot`; `+messages-search` is user-only. Inspect `+chat-messages-list --help` first when the task requires complete history.
## References

View File

@@ -27,12 +27,6 @@ lark-cli im +chat-search --member-ids "ou_xxx,ou_yyy"
# Only show chats you created or manage
lark-cli im +chat-search --query "project" --is-manager
# Set page size
lark-cli im +chat-search --query "project" --page-size 10
# Pagination
lark-cli im +chat-search --query "project" --page-token "xxx"
# JSON output
lark-cli im +chat-search --query "project" --format json
@@ -51,8 +45,6 @@ lark-cli im +chat-search --query "project" --dry-run
| `--is-manager` | No | - | Only show chats you created or manage |
| `--disable-search-by-user` | No | - | Disable member-name-based matching and search by group name only |
| `--sort <field>` | No | `create_time`, `update_time`, `member_count` | Sort field (always descending) |
| `--page-size <n>` | No | 1-100, default 20 | Number of results per page |
| `--page-token <token>` | No | - | Pagination token from the previous response |
| `--exclude-muted` | No | User identity only | Drop chats the current user has muted (do-not-disturb). Under `--as bot`, the flag is silently inactive (mute is a per-user setting); see "Filtering muted chats" below |
| `--format json` | No | - | Output as JSON |
| `--dry-run` | No | - | Preview the request without executing it |
@@ -121,7 +113,6 @@ 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 |
| 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 |
@@ -132,7 +123,7 @@ When the user asks to search chats, follow these rules:
1. **At least one filter required:** `--query` and `--member-ids` cannot both be empty. Either alone or combined together are valid.
2. **Search scope is limited:** only chats visible to the current user or bot can be found (joined chats plus public chats). This is not a global search over all chats.
3. **Control result volume:** the result set may be large. Use `--page-size` deliberately.
3. **Result scope:** if the task requires exhaustive search, inspect this concrete command's `--help` before executing.
4. **Suggest follow-up actions:** after finding a chat, common next steps include listing recent messages (`im +chat-messages-list`) or sending a message (`im +messages-send`).
5. **NEVER fall back to chats list:** If `+chat-search` returns empty results, do NOT attempt to use `+chat-list` or `GET /open-apis/im/v1/chats` as a fallback. The list API is not a search API — it returns all chats without keyword filtering and will not help locate the target chat. Instead, ask the user to refine the keyword or check whether the chat is visible to the current identity.

View File

@@ -23,9 +23,9 @@ Because chat-name resolution always runs, this shortcut needs **two** user scope
# First page, enriched with chat names
lark-cli im +feed-group-list-item --as user --feed-group-id ofg_xxx
# Auto-paginate through everything within a time window
# List items within a time window
lark-cli im +feed-group-list-item --as user --feed-group-id ofg_xxx \
--page-all --start-time 1767196800000 --end-time 1767200000000
--start-time 1767196800000 --end-time 1767200000000
```
## Flags
@@ -33,14 +33,10 @@ lark-cli im +feed-group-list-item --as user --feed-group-id ofg_xxx \
| Flag | Required | Description |
|---|---|---|
| `--feed-group-id` | Yes | Feed group ID (`ofg_xxx`); path parameter |
| `--page-size` | No | Records per page, 150 (default 50) |
| `--page-token` | No | Continuation token for a specific page |
| `--page-all` | No | Auto-paginate and merge all pages |
| `--page-limit` | No | Max pages when `--page-all` is set, 11000 (default 20) |
| `--start-time` | No | Update-time window start (Unix milliseconds as a decimal string) |
| `--end-time` | No | Update-time window end (Unix milliseconds as a decimal string) |
When `--page-token` is set explicitly, it wins over `--page-all` (you get exactly that page).
For pagination controls, inspect this concrete command's `--help`.
## Output

View File

@@ -1,8 +1,8 @@
# +feed-group-list
> Shortcut for `lark-cli im +feed-group-list`. List the caller's feed groups (tags) with auto-pagination that correctly merges both the live and soft-deleted lists.
> Shortcut for `lark-cli im +feed-group-list`. List the caller's feed groups (tags) while preserving both the live and soft-deleted lists.
`+feed-group-list` is the only CLI surface for listing feed groups — there is no raw `feed.groups list` command. The list response carries two parallel arrays — `groups` (live) and `deleted_groups` (soft-deleted). The shortcut paginates this dual-list response correctly: its `--page-all` merges **both** arrays across pages (a naive single-array pager would silently drop one list's later pages). It adds no enrichment.
`+feed-group-list` is the only CLI surface for listing feed groups — there is no raw `feed.groups list` command. The list response carries two parallel arrays — `groups` (live) and `deleted_groups` (soft-deleted). When traversing multiple pages, the shortcut merges **both** arrays (a naive single-array pager would silently drop one list's later pages). It adds no enrichment.
## Identity
@@ -18,11 +18,8 @@ User-only. Run with `--as user`.
# First page
lark-cli im +feed-group-list --as user
# Auto-paginate through all your feed groups (both live and deleted)
lark-cli im +feed-group-list --as user --page-all
# Within an update-time window
lark-cli im +feed-group-list --as user --page-all \
lark-cli im +feed-group-list --as user \
--start-time 1767196800000 --end-time 1767200000000
```
@@ -30,18 +27,14 @@ lark-cli im +feed-group-list --as user --page-all \
| Flag | Required | Description |
|---|---|---|
| `--page-size` | No | Records per page, 150 (default 50). Caps the combined `groups` + `deleted_groups` count, so a page may hold fewer live groups than the size suggests |
| `--page-token` | No | Continuation token for a specific page |
| `--page-all` | No | Auto-paginate and merge all pages (both lists) |
| `--page-limit` | No | Max pages when `--page-all` is set, 11000 (default 20) |
| `--start-time` | No | Update-time window start (Unix milliseconds as a decimal string) |
| `--end-time` | No | Update-time window end (Unix milliseconds as a decimal string) |
When `--page-token` is set explicitly, it wins over `--page-all` (you get exactly that page).
For pagination controls, inspect this concrete command's `--help`. The dual-list merge guarantee applies when multiple pages are fetched.
## Output
JSON keeps the raw envelope; with `--page-all` both lists are returned fully merged:
JSON keeps the raw envelope. When multiple pages are fetched, both lists are returned fully merged:
```json
{
@@ -56,7 +49,7 @@ JSON keeps the raw envelope; with `--page-all` both lists are returned fully mer
}
```
> `page_size` counts live and deleted groups together, and the per-page count can be smaller still when entries are filtered — so never infer completeness from counts. Pagination is governed solely by `has_more`.
> Page size counts live and deleted groups together, and the per-page count can be smaller still when entries are filtered — so never infer completeness from counts.
## See also

View File

@@ -30,7 +30,7 @@ Three typed `+` shortcuts cover the feed-group read paths. All are user-only.
| Shortcut | Purpose | Notes |
|---|---|---|
| [`+feed-group-list`](lark-im-feed-group-list.md) | List your feed groups | Its `--page-all` correctly merges the live and soft-deleted lists. No enrichment |
| [`+feed-group-list`](lark-im-feed-group-list.md) | List your feed groups | Preserves and merges both the live and soft-deleted lists. No enrichment |
| [`+feed-group-list-item`](lark-im-feed-group-list-item.md) | List the feed cards inside a group | Enriches each card with `chat_name` |
| [`+feed-group-query-item`](lark-im-feed-group-query-item.md) | Look up feed cards in a group by ID | Enriches each card with `chat_name` |
@@ -242,7 +242,7 @@ Each element carries `group_id`, `type`, `name`, and (when defined) `rules`.
## list
Shortcut-only: [`+feed-group-list`](lark-im-feed-group-list.md). Lists the caller's feed groups, optionally filtered by an update-time window. Its `--page-all` correctly merges the live (`groups`) and soft-deleted (`deleted_groups`) lists across pages. There is no raw command — flags and response shape are in the linked shortcut doc.
Shortcut-only: [`+feed-group-list`](lark-im-feed-group-list.md). Lists the caller's feed groups, optionally filtered by an update-time window, and correctly merges the live (`groups`) and soft-deleted (`deleted_groups`) lists across pages. There is no raw command — flags and response shape are in the linked shortcut doc.
## batch_add_item
@@ -326,7 +326,7 @@ Shortcut-only: [`+feed-group-query-item`](lark-im-feed-group-query-item.md). Loo
## list_item
Shortcut-only: [`+feed-group-list-item`](lark-im-feed-group-list-item.md). Lists the feed cards inside a group (paginated, `--page-all` supported) and enriches each with `chat_name`. There is no raw command — flags and response shape are in the linked shortcut doc.
Shortcut-only: [`+feed-group-list-item`](lark-im-feed-group-list-item.md). Lists the feed cards inside a group and enriches each with `chat_name`. There is no raw command — flags and response shape are in the linked shortcut doc.
## Enums

View File

@@ -6,33 +6,29 @@ This skill maps to shortcut: `lark-cli im +feed-shortcut-list`. Underlying API:
## What it does
Lists **one page** of the **current user's** feed shortcuts.
Lists 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.
- Pagination controls are defined by the concrete command's `--help`.
- Server-side page size is controlled by the service; 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`.
- Pagination tokens are opaque and can become invalid when the shortcut list changes.
## Commands
```bash
# First page (the only call most users ever need — --page-token omitted)
# List shortcuts
lark-cli im +feed-shortcut-list --as user
# Continue from the previous response's page_token
lark-cli im +feed-shortcut-list --as user --page-token <token-from-previous-response>
# Skip detail enrichment when only IDs are needed; avoids the extra im:chat:read lookup
lark-cli im +feed-shortcut-list --as user --no-detail -q '.data.shortcuts[].feed_card_id'
lark-cli im +feed-shortcut-list --as user --no-detail
```
> If you need to walk every page, write the loop yourself: read `data.page_token` from each response and pass it back in until `has_more=false`. The shortcut intentionally does not auto-walk because page-token errors require the caller to decide whether to restart from the first page.
> If the task requires every shortcut, inspect the concrete command's `--help` before executing. If a continuation token is rejected after the shortcut list changes, restart from the beginning.
## Parameters
| Parameter | Required | Description |
|------|------|------|
| `--page-token <token>` | no | Opaque pagination token from the previous response. **Omit it for the first page.** |
| `--no-detail` | no (default `false`) | Skip fetching each entry's full info object. By default enrichment is enabled: CHAT-type entries call `im.chats.batch_query`, need `im:chat:read`, and attach the object under the `detail` field. Pass `--no-detail` to skip the extra call and scope. |
| `--as user` | yes | Server only accepts user_access_token for this API |

View File

@@ -10,7 +10,7 @@ A message can have flags on both layers simultaneously:
- Message layer: `(default, message)`
- Feed layer: `(thread, feed)` or `(msg_thread, feed)` depending on chat type
**When no `--flag-type` is specified, the shortcut performs best-effort double-cancel**: the message-layer flag is always removed; the feed-layer flag is also removed when the chat type can be determined (otherwise a warning is printed on stderr and the feed layer is skipped). The server handles cancel requests for non-existent flags idempotently, so this is safe.
**When no `--flag-type` is specified, the shortcut performs best-effort double-cancel**: it attempts the message-layer cancellation and also cancels the feed layer when the chat type can be determined. If the feed layer cannot be resolved, that layer remains unresolved in the per-layer result. Cancelling a non-existent flag is idempotent.
**Feed layer item_type is determined by chat_mode**:
- Topic-style chat (`chat_mode=topic`) → `item_type=thread`
@@ -63,5 +63,7 @@ If you have message content but not the message ID:
```bash
# Search by message content to find message_id
lark-cli im +messages-search --as user --query "message content here" -q '.data.items[0].message_id'
lark-cli im +messages-search --as user --query "message content here"
```
Read the chosen result's `message_id` from the structured output before cancelling it.

View File

@@ -6,9 +6,7 @@ This skill maps to shortcut: `lark-cli im +flag-list`. Underlying API: `GET /ope
## Sorting Rules (Important)
The API returns data sorted by `update_time` in **ascending order**, meaning **oldest first, newest last**. When `has_more=true`, continue pagination until `has_more=false`; only then is the last item in the merged result authoritative as the newest flag. If pagination stops while `has_more=true`, the last item is only the newest observed flag.
`--page-all` enables automatic pagination but is still capped by `--page-limit`. The default cap is 20 pages; **20 is not the hard maximum**. Set `--page-limit` between 1 and 1000 when a larger scan is required. A response with `has_more=true` is incomplete, even when `flag_items` is empty; increase the limit or resume from the returned `page_token` before reporting an authoritative latest item or count.
The API returns data sorted by `update_time` in **ascending order**, meaning **oldest first, newest last**. When the result is incomplete, you cannot simply take the first page's items as the latest flags. Inspect this concrete command's `--help` for full-read controls, then take the last item only after the result reports complete.
## Commands
@@ -16,33 +14,14 @@ The API returns data sorted by `update_time` in **ascending order**, meaning **o
# Fetch first page (default page-size=50)
lark-cli im +flag-list --as user
# Manual pagination with custom page size
lark-cli im +flag-list --as user --page-size 30 --page-token <page_token>
# Auto-paginate, capped at the default 20 pages
lark-cli im +flag-list --as user --page-all
# Auto-paginate + get the latest flag
lark-cli im +flag-list --as user --page-all -q '.data.flag_items[-1]'
# Auto-paginate + get only item_id list
lark-cli im +flag-list --as user --page-all -q '.data.flag_items[].item_id'
# Disable auto-enrichment of message content (enabled by default)
lark-cli im +flag-list --as user --page-all --enrich-feed-thread=false
# Use the largest supported page limit for a broader scan
lark-cli im +flag-list --as user --page-all --page-limit 1000
lark-cli im +flag-list --as user --enrich-feed-thread=false
```
## Parameters
| Parameter | Default | Description |
|------|------|------|
| `--page-size <n>` | 50 | Range 1-50 (server max is 50) |
| `--page-token <token>` | empty | Pagination token from previous page; empty string must still be provided |
| `--page-all` | false | Auto-paginate and merge results, capped by `--page-limit` |
| `--page-limit <n>` | 20 | Max pages in `--page-all` mode; configurable range 1-1000 (20 is only the default) |
| `--enrich-feed-thread` | true | Auto-enrich feed-layer thread entries with message content (calls `im.messages.mget`) |
| `--as user` | Required | Currently only supports user identity |

View File

@@ -4,7 +4,7 @@
Search Feishu messages across conversations. This shortcut automatically performs a multi-step workflow: search for message IDs, batch fetch message details, then enrich the results with chat context.
By default each result message also carries a `reactions` block (counts + details from `im.reactions.batch_query`) when the server has reactions for it, and `update_time` for messages that were actually edited. With `--page-all`, every page is enriched; pass `--no-reactions` to skip the extra round-trip. See [message enrichment](lark-im-message-enrichment.md) for the full contract.
By default each result message also carries a `reactions` block (counts + details from `im.reactions.batch_query`) when the server has reactions for it, and `update_time` for messages that were actually edited. Every fetched page is enriched; pass `--no-reactions` to skip the extra round-trip. See [message enrichment](lark-im-message-enrichment.md) for the full contract.
> **User identity only** (`--as user`). Bot identity is not supported.
@@ -51,15 +51,6 @@ lark-cli im +messages-search --query "test" --format pretty
lark-cli im +messages-search --query "test" --format table
lark-cli im +messages-search --query "test" --format csv
# Pagination
lark-cli im +messages-search --query "test" --page-token <PAGE_TOKEN>
# Auto-pagination across multiple pages
lark-cli im +messages-search --query "test" --page-all --format json
# Auto-pagination with an explicit page cap
lark-cli im +messages-search --query "test" --page-limit 5 --format json
# Preview the request without executing it
lark-cli im +messages-search --query "test" --dry-run
```
@@ -79,10 +70,6 @@ lark-cli im +messages-search --query "test" --dry-run
| `--at-chatter-ids <ids>` | No | Filter by @mentioned user open_ids, comma-separated (`ou_xxx,ou_yyy`). Matched results also include messages that `@all` |
| `--start <time>` | No | Start time with local timezone offset required (e.g. `2026-03-24T00:00:00+08:00`) |
| `--end <time>` | No | End time with local timezone offset required (e.g. `2026-03-25T23:59:59+08:00`) |
| `--page-size <n>` | No | Page size (default 20, range 1-50) |
| `--page-token <token>` | No | Pagination token for the next page |
| `--page-all` | No | Automatically paginate through all result pages (up to 40 pages) |
| `--page-limit <n>` | No | Max pages to fetch when auto-pagination is enabled (default 20, max 40). Setting it explicitly also enables auto-pagination |
| `--format <fmt>` | No | Output format: `json` (default) / `pretty` / `table` / `ndjson` / `csv` |
| `--as <identity>` | No | Identity type (defaults to and only supports `user`) |
| `--dry-run` | No | Print the request only, do not execute it |
@@ -101,7 +88,7 @@ The shortcut automatically performs:
2. The **mget API** fetches full message content for those message IDs in batch
3. Chat context lookup is fetched in batch and attached to each message
The user does not need to manage the orchestration manually. When search results span multiple pages, the shortcut can also paginate automatically with `--page-all` or `--page-limit`.
The user does not need to manage the search, detail fetch, or chat-context lookup manually.
### 3. Conversation context is enriched automatically
@@ -130,15 +117,7 @@ Each message in JSON output contains:
| `mentions` | Array of @mentions in the message; each item contains `{id, key, name}`. Present only when the message contains @mentions |
| `thread_id` | Thread ID (`omt_xxx`) if the message has replies in a thread. Present only when replies exist |
### 4. Pagination behavior
- Default behavior is still **single-page**.
- `--page-token` is the manual continuation mechanism when you already have a token from a previous response.
- `--page-all` enables auto-pagination and uses a default cap of **40 pages**.
- `--page-limit <n>` enables auto-pagination with an explicit cap. If you pass `--page-limit` without `--page-all`, auto-pagination is still enabled.
- When auto-pagination stops because of the configured page cap, the response still includes the last `has_more` / `page_token` so you can continue manually.
### 5. Search results contain follow-up clues
### 4. Search results contain follow-up clues
In JSON output, each message includes `chat_id` and `thread_id` (when present). Use them with other shortcuts for deeper inspection:
@@ -166,7 +145,7 @@ This guidance applies only when using user identity. `im +messages-search` is us
```bash
# Review recent bot interactions without forcing a keyword
lark-cli im +messages-search --query "" --sender-type bot --start "<YYYY-MM-DDT00:00:00+08:00>" --end "<YYYY-MM-DDT23:59:59+08:00>" --page-all --format json
lark-cli im +messages-search --query "" --sender-type bot --start "<YYYY-MM-DDT00:00:00+08:00>" --end "<YYYY-MM-DDT23:59:59+08:00>" --format json
```
Replace the time placeholders at execution time. For example, "最近一周" means computing the start date and end date from the current day before running the command; do not copy date literals from this reference into answers for relative requests.
@@ -189,33 +168,26 @@ lark-cli im +messages-search --query "keyword" --chat-id <chat_id>
## Work Summary / Report Generation
When the user asks you to summarize work, generate a weekly report, or compile activity from chat messages, you should **paginate through all available results** to get a complete picture. A single page is rarely enough for thorough summarization.
When the user asks you to summarize work, generate a weekly report, or compile activity from chat messages, require a complete result before summarizing. A partial result is rarely enough for a thorough summary.
### Strategy
1. **Start with targeted filters** — use `--chat-id`, `--sender`, `--start`, `--end` to narrow the scope as much as possible before paginating.
2. **Prefer auto-pagination** — for report and summary tasks, use `--page-all --format json` by default. If you need a bounded run, use `--page-limit <n> --format json`.
3. **Accumulate before summarizing**collect all pages of messages first, then analyze and summarize. Do not summarize after the first page alone — you will miss important context.
4. **Fall back to `--page-token` when resuming** — if auto-pagination hits the configured page cap and the response still has `has_more=true`, continue from the returned `page_token`.
5. **Use `--format json`** — JSON output includes `has_more` and `page_token` fields needed for pagination. `pretty` and `table` formats are useful for reading but not for resuming pagination reliably.
1. **Start with targeted filters** — use `--chat-id`, `--sender`, `--start`, `--end` to narrow the scope.
2. **Inspect the leaf help before execution** — the concrete command's `--help` owns full-read controls and result guarantees.
3. **Accumulate before summarizing**fetch a complete result, then analyze and summarize. Do not summarize a partial response.
4. **Use structured output** — JSON preserves message IDs and completion metadata needed to verify the evidence set.
### Example: Weekly work summary from a project chat
```bash
# Preferred: fetch automatically
lark-cli im +messages-search --query "" --chat-id oc_xxx --sender ou_me --start "2026-03-18T00:00:00+08:00" --end "2026-03-25T23:59:59+08:00" --page-size 50 --page-all --format json
# If you need to cap the run explicitly
lark-cli im +messages-search --query "" --chat-id oc_xxx --sender ou_me --start "2026-03-18T00:00:00+08:00" --end "2026-03-25T23:59:59+08:00" --page-size 50 --page-limit 5 --format json
# If the bounded run still returns has_more=true, continue manually
lark-cli im +messages-search --query "" --chat-id oc_xxx --sender ou_me --start "2026-03-18T00:00:00+08:00" --end "2026-03-25T23:59:59+08:00" --page-size 50 --page-token <token_from_previous_run> --format json
# Inspect full-read controls first, then execute the filtered search.
lark-cli im +messages-search --help
lark-cli im +messages-search --query "" --chat-id oc_xxx --sender ou_me --start "2026-03-18T00:00:00+08:00" --end "2026-03-25T23:59:59+08:00" --format json
```
### Key points
- **Always paginate exhaustively** for summary tasks. A single page of 20-50 messages is usually insufficient for a meaningful work summary.
- Prefer `--page-all`; use `--page-limit` only when you need to bound runtime or output volume.
- **Require complete evidence** for summary tasks. A partial response is insufficient for a meaningful work summary.
- If the user does not specify a time range, default to the current week (Monday to today) for weekly reports, or ask for clarification.
- When summarizing, group messages by topic/thread rather than by chronological order for better readability.

View File

@@ -177,6 +177,8 @@ The response shape is similar to `create`, and usually echoes:
Query reactions for multiple messages in one request.
`batch_query` covers only the reaction fragments returned for each query. When complete reactions for one message are required, use `im reactions list` and exhaust its pagination instead of treating an empty or partial batch fragment as complete.
```bash
lark-cli im reactions batch_query \
--params '{"user_id_type":"open_id"}' \

View File

@@ -17,12 +17,6 @@ lark-cli im +threads-messages-list --thread omt_xxx
# Reverse chronological order (latest first)
lark-cli im +threads-messages-list --thread omt_xxx --order desc
# Control page size
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>
# 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 +37,6 @@ 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-token <token>` | No | Pagination token for the next page |
| `--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 |
@@ -57,20 +49,7 @@ lark-cli im +threads-messages-list --thread omt_xxx --dry-run
### 2. No time filtering support
Thread messages do not support `start_time` / `end_time` filtering because of Feishu API limitations. Use pagination and sort order to control the scope.
### 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
### 4. Recommended expansion strategy
| Scenario | Recommended Parameters |
|------|---------|
| Quickly inspect recent replies | `--order desc --page-size 10` |
| Read the full thread in chronological order | `--order asc --page-size 50`, then paginate as needed |
| Just confirm whether replies exist | `--order desc --page-size 1` |
Thread messages do not support `start_time` / `end_time` filtering because of Feishu API limitations. Use sort order to control ordering, and inspect this concrete command's `--help` when the task requires the complete thread.
## Usage Scenarios
@@ -84,16 +63,6 @@ lark-cli im +chat-messages-list --chat-id oc_xxx
lark-cli im +threads-messages-list --thread omt_xxx
```
### Scenario 2: Paginate through a long thread
```bash
# First page
lark-cli im +threads-messages-list --thread omt_xxx
# If has_more=true is returned, continue with page_token
lark-cli im +threads-messages-list --thread omt_xxx --page-token <PAGE_TOKEN>
```
## Resource Rendering
Thread replies are rendered into human-readable text. Image messages appear as placeholders such as `![Image](img_xxx)`; by default resource binaries are **not** downloaded.