diff --git a/cmd/service/affordance.go b/cmd/service/affordance.go index 36f41113b..3b8ee6550 100644 --- a/cmd/service/affordance.go +++ b/cmd/service/affordance.go @@ -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: " 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) { diff --git a/cmd/service/affordance_test.go b/cmd/service/affordance_test.go index 3202ee432..6aae29ac7 100644 --- a/cmd/service/affordance_test.go +++ b/cmd/service/affordance_test.go @@ -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. diff --git a/cmd/service/service.go b/cmd/service/service.go index 8da8a268c..c306b3323 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -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 diff --git a/cmd/service/service_test.go b/cmd/service/service_test.go index cfb88b755..680eecd6a 100644 --- a/cmd/service/service_test.go +++ b/cmd/service/service_test.go @@ -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) } } diff --git a/internal/imcontract/catalog/registry.go b/internal/imcontract/catalog/registry.go new file mode 100644 index 000000000..d1d544826 --- /dev/null +++ b/internal/imcontract/catalog/registry.go @@ -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 +} diff --git a/internal/imcontract/catalog/registry_test.go b/internal/imcontract/catalog/registry_test.go new file mode 100644 index 000000000..a339b30df --- /dev/null +++ b/internal/imcontract/catalog/registry_test.go @@ -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) + } +} diff --git a/internal/imcontract/catalog/types.go b/internal/imcontract/catalog/types.go new file mode 100644 index 000000000..84e02b5d8 --- /dev/null +++ b/internal/imcontract/catalog/types.go @@ -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 +} diff --git a/internal/imcontract/help.go b/internal/imcontract/help.go new file mode 100644 index 000000000..028dd2fa8 --- /dev/null +++ b/internal/imcontract/help.go @@ -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() +} diff --git a/internal/imcontract/help_test.go b/internal/imcontract/help_test.go new file mode 100644 index 000000000..f0c4c05a7 --- /dev/null +++ b/internal/imcontract/help_test.go @@ -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) + } +} diff --git a/internal/imcontract/ledger.go b/internal/imcontract/ledger.go index 125a7b8c1..017b092eb 100644 --- a/internal/imcontract/ledger.go +++ b/internal/imcontract/ledger.go @@ -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" } } diff --git a/internal/imcontract/read.go b/internal/imcontract/read.go index 7cb794b2e..083d43331 100644 --- a/internal/imcontract/read.go +++ b/internal/imcontract/read.go @@ -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 diff --git a/internal/imcontract/registry.go b/internal/imcontract/registry.go index c0bf69fa4..e3858534d 100644 --- a/internal/imcontract/registry.go +++ b/internal/imcontract/registry.go @@ -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} } diff --git a/internal/imcontract/session.go b/internal/imcontract/session.go index 252493b3c..001be4e62 100644 --- a/internal/imcontract/session.go +++ b/internal/imcontract/session.go @@ -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 { diff --git a/internal/imcontract/types.go b/internal/imcontract/types.go index d4e7ff5df..ac9b2f6e4 100644 --- a/internal/imcontract/types.go +++ b/internal/imcontract/types.go @@ -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 ( diff --git a/internal/imcontract/write.go b/internal/imcontract/write.go index 53316b41b..b49f3c9be 100644 --- a/internal/imcontract/write.go +++ b/internal/imcontract/write.go @@ -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 } diff --git a/internal/imcontract/write_test.go b/internal/imcontract/write_test.go index 05f05bd3e..073a82b5b 100644 --- a/internal/imcontract/write_test.go +++ b/internal/imcontract/write_test.go @@ -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) } diff --git a/internal/qualitygate/cmd/manifest-export/main_test.go b/internal/qualitygate/cmd/manifest-export/main_test.go index 644736e85..89756443a 100644 --- a/internal/qualitygate/cmd/manifest-export/main_test.go +++ b/internal/qualitygate/cmd/manifest-export/main_test.go @@ -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) diff --git a/internal/qualitygate/rules/imcontract.go b/internal/qualitygate/rules/imcontract.go new file mode 100644 index 000000000..48c8034f6 --- /dev/null +++ b/internal/qualitygate/rules/imcontract.go @@ -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, + } +} diff --git a/internal/qualitygate/rules/imcontract_test.go b/internal/qualitygate/rules/imcontract_test.go new file mode 100644 index 000000000..4791ac669 --- /dev/null +++ b/internal/qualitygate/rules/imcontract_test.go @@ -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 +} diff --git a/internal/qualitygate/rules/run.go b/internal/qualitygate/rules/run.go index 35acea1ca..96fd96566 100644 --- a/internal/qualitygate/rules/run.go +++ b/internal/qualitygate/rules/run.go @@ -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) } diff --git a/internal/qualitygate/rules/run_test.go b/internal/qualitygate/rules/run_test.go index e7c2fc348..7f651ed93 100644 --- a/internal/qualitygate/rules/run_test.go +++ b/internal/qualitygate/rules/run_test.go @@ -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) } diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index f7c24436b..07b11a65d 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -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) diff --git a/shortcuts/common/runner_flag_completion_test.go b/shortcuts/common/runner_flag_completion_test.go index 49da9a275..71b52b3b5 100644 --- a/shortcuts/common/runner_flag_completion_test.go +++ b/shortcuts/common/runner_flag_completion_test.go @@ -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 diff --git a/shortcuts/im/im_feed_shortcut_test.go b/shortcuts/im/im_feed_shortcut_test.go index 1b92cf69d..839b41457 100644 --- a/shortcuts/im/im_feed_shortcut_test.go +++ b/shortcuts/im/im_feed_shortcut_test.go @@ -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} diff --git a/shortcuts/im/im_flag_cancel.go b/shortcuts/im/im_flag_cancel.go index 7a5425515..9ef3d5d07 100644 --- a/shortcuts/im/im_flag_cancel.go +++ b/shortcuts/im/im_flag_cancel.go @@ -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) diff --git a/shortcuts/im/im_flag_test.go b/shortcuts/im/im_flag_test.go index 34d566eb2..6eadbb108 100644 --- a/shortcuts/im/im_flag_test.go +++ b/shortcuts/im/im_flag_test.go @@ -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) } } diff --git a/skills/lark-im/SKILL.md b/skills/lark-im/SKILL.md index a7c357230..f5e133d6f 100644 --- a/skills/lark-im/SKILL.md +++ b/skills/lark-im/SKILL.md @@ -116,24 +116,24 @@ Shortcut 是对常用操作的高级封装(`lark-cli im + [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 diff --git a/skills/lark-im/references/lark-im-chat-list.md b/skills/lark-im/references/lark-im-chat-list.md index 0d6ca2b28..f22d9b999 100644 --- a/skills/lark-im/references/lark-im-chat-list.md +++ b/skills/lark-im/references/lark-im-chat-list.md @@ -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 ` | No | `open_id` (default), `union_id`, `user_id` | ID type used for `owner_id` in the response | | `--types ` | 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 ` | No | `create_time` (default, ascending), `active_time` (descending) | Result ordering | -| `--page-size ` | No | 1-100, default 20 | Number of results per page | -| `--page-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 | diff --git a/skills/lark-im/references/lark-im-chat-members-list.md b/skills/lark-im/references/lark-im-chat-members-list.md index 9a22b9aed..ab1ad3bb1 100644 --- a/skills/lark-im/references/lark-im-chat-members-list.md +++ b/skills/lark-im/references/lark-im-chat-members-list.md @@ -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 ` | Yes | `oc_xxx` | Target chat | | `--member-types ` | No | `user`, `bot` (comma-separated or repeated) | Member types to return. Omitted = all | | `--member-id-type ` | No | `open_id` (default), `union_id`, `user_id` | ID type for `member_id` in the response | -| `--page-size ` | 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 ` | No | - | Pagination cursor; **implies a single-page fetch** (disables auto-pagination) | -| `--page-all` | No | - | Automatically walk every page (capped by `--page-limit`) | -| `--page-limit ` | No | default 10, `0` = unlimited | Max pages to fetch with `--page-all` | -| `--page-delay ` | 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"` | diff --git a/skills/lark-im/references/lark-im-chat-messages-list.md b/skills/lark-im/references/lark-im-chat-messages-list.md index d0f5af185..1af21d17b 100644 --- a/skills/lark-im/references/lark-im-chat-messages-list.md +++ b/skills/lark-im/references/lark-im-chat-messages-list.md @@ -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