mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
chore(im): align help skills and contract coverage
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/affordance"
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/imcontract"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -161,6 +162,7 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
}
|
||||
}
|
||||
|
||||
writeContractHelp(&b, cmd)
|
||||
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
|
||||
b.WriteString(ann[paramsOnlyAnnotation])
|
||||
|
||||
@@ -191,12 +193,16 @@ func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceShortcut {
|
||||
return false
|
||||
}
|
||||
raw, ok := affordanceRaw(cmd)
|
||||
if !ok {
|
||||
return false
|
||||
var a meta.Affordance
|
||||
hasAffordance := false
|
||||
if raw, ok := affordanceRaw(cmd); ok {
|
||||
if parsed, parsedOK := (meta.Method{Affordance: raw}).ParsedAffordance(); parsedOK {
|
||||
a = parsed
|
||||
hasAffordance = true
|
||||
}
|
||||
}
|
||||
a, ok := (meta.Method{Affordance: raw}).ParsedAffordance()
|
||||
if !ok {
|
||||
contractHelp := imcontract.HelpText(cmd)
|
||||
if !hasAffordance && contractHelp == "" {
|
||||
return false
|
||||
}
|
||||
if len(a.Tips) == 0 {
|
||||
@@ -210,12 +216,23 @@ func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(block)
|
||||
}
|
||||
if contractHelp != "" {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(contractHelp)
|
||||
}
|
||||
writeRelatedSkills(&b, a.Skills, skillFS)
|
||||
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
}
|
||||
|
||||
func writeContractHelp(b *strings.Builder, cmd *cobra.Command) {
|
||||
if text := imcontract.HelpText(cmd); text != "" {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(text)
|
||||
}
|
||||
}
|
||||
|
||||
// writeRisk appends the "Risk: <level>" line, warning agents not to self-approve
|
||||
// high-risk-write commands. A no-op when the command has no risk annotation.
|
||||
func writeRisk(b *strings.Builder, cmd *cobra.Command) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
313
internal/imcontract/catalog/registry.go
Normal file
313
internal/imcontract/catalog/registry.go
Normal file
@@ -0,0 +1,313 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ack(key string) Contract {
|
||||
return Contract{Key: ContractKey(key), Strategy: Strategy{Kind: AuthoritativeAckKind}, ReplayMode: ReplayForbidden}
|
||||
}
|
||||
|
||||
func required(key string, result RequiredSpec, replay ReplayMode) Contract {
|
||||
return Contract{
|
||||
Key: ContractKey(key),
|
||||
Strategy: Strategy{Kind: RequiredResultKind, Required: result},
|
||||
ReplayMode: replay,
|
||||
}
|
||||
}
|
||||
|
||||
func batch(key string, request EvidenceSpec, failures ...EvidenceSpec) Contract {
|
||||
return Contract{
|
||||
Key: ContractKey(key),
|
||||
PartialRecovery: PartialRecoveryFailedItemsOnly,
|
||||
Strategy: Strategy{
|
||||
Kind: BatchPartialKind,
|
||||
Request: request,
|
||||
Failures: failures,
|
||||
},
|
||||
ReplayMode: ReplayForbidden,
|
||||
}
|
||||
}
|
||||
|
||||
func read(key string, kind StrategyKind) Contract {
|
||||
return Contract{
|
||||
Key: ContractKey(key),
|
||||
Strategy: Strategy{Kind: kind},
|
||||
}
|
||||
}
|
||||
|
||||
func search(key, collectionField string) Contract {
|
||||
return Contract{
|
||||
Key: ContractKey(key),
|
||||
Strategy: Strategy{
|
||||
Kind: SearchReadKind,
|
||||
CollectionField: collectionField,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func topString(field string) RequiredSpec {
|
||||
return RequiredSpec{Shape: RequiredTopString, Field: field}
|
||||
}
|
||||
|
||||
func topObject(field string) RequiredSpec {
|
||||
return RequiredSpec{Shape: RequiredTopObject, Field: field}
|
||||
}
|
||||
|
||||
func nestedString(field, child string) RequiredSpec {
|
||||
return RequiredSpec{Shape: RequiredNestedString, Field: field, Child: child}
|
||||
}
|
||||
|
||||
func stringsFrom(field string) EvidenceSpec {
|
||||
return EvidenceSpec{Shape: EvidenceStrings, Field: field}
|
||||
}
|
||||
|
||||
func objectsFrom(field, idField string) EvidenceSpec {
|
||||
return EvidenceSpec{Shape: EvidenceObjects, Field: field, IDField: idField}
|
||||
}
|
||||
|
||||
func nestedObjectsFrom(field, container, idField string) EvidenceSpec {
|
||||
return EvidenceSpec{
|
||||
Shape: EvidenceNestedObjects, Field: field, Container: container, IDField: idField,
|
||||
}
|
||||
}
|
||||
|
||||
func feedObjectsFrom(field string) EvidenceSpec {
|
||||
return EvidenceSpec{Shape: EvidenceFeedObjects, Field: field}
|
||||
}
|
||||
|
||||
func nestedFeedObjectsFrom(field, container string) EvidenceSpec {
|
||||
return EvidenceSpec{Shape: EvidenceNestedFeedObjects, Field: field, Container: container}
|
||||
}
|
||||
|
||||
func statusObjectsFrom(field, idField string) EvidenceSpec {
|
||||
return EvidenceSpec{Shape: EvidenceStatusObjects, Field: field, IDField: idField}
|
||||
}
|
||||
|
||||
var contracts = buildContracts()
|
||||
|
||||
func buildContracts() map[ContractKey]Contract {
|
||||
all := []Contract{
|
||||
read("im +feed-group-query-item", EntityReadKind),
|
||||
read("im +messages-mget", EntityReadKind),
|
||||
read("im chat.nickname get", EntityReadKind),
|
||||
read("im chat.user_setting batch_query", EntityReadKind),
|
||||
read("im chats get", EntityReadKind),
|
||||
read("im feed.groups batch_query", EntityReadKind),
|
||||
func() Contract {
|
||||
c := read("im reactions batch_query", EntityReadKind)
|
||||
c.Strategy.ReadHint = HintBatchReactions
|
||||
return c
|
||||
}(),
|
||||
|
||||
read("im +chat-list", CollectionReadKind),
|
||||
read("im +chat-members-list", CollectionReadKind),
|
||||
read("im +chat-messages-list", CollectionReadKind),
|
||||
read("im +feed-group-list", CollectionReadKind),
|
||||
read("im +feed-group-list-item", CollectionReadKind),
|
||||
read("im +feed-shortcut-list", CollectionReadKind),
|
||||
read("im +flag-list", CollectionReadKind),
|
||||
read("im +threads-messages-list", CollectionReadKind),
|
||||
read("im chat.members bots", CollectionReadKind),
|
||||
read("im chat.members get", CollectionReadKind),
|
||||
read("im chat.moderation get", CollectionReadKind),
|
||||
read("im messages read_users", CollectionReadKind),
|
||||
read("im pins list", CollectionReadKind),
|
||||
read("im reactions list", CollectionReadKind),
|
||||
|
||||
search("im +chat-search", "chats"),
|
||||
search("im +messages-search", "messages"),
|
||||
|
||||
read("im +messages-resources-download", MaterializeReadKind),
|
||||
|
||||
ack("im +chat-update"),
|
||||
ack("im +flag-create"),
|
||||
ack("im chat.nickname delete"),
|
||||
ack("im chat.nickname update"),
|
||||
ack("im chats update"),
|
||||
ack("im feed.groups delete"),
|
||||
ack("im feed.groups update"),
|
||||
ack("im messages delete"),
|
||||
ack("im pins delete"),
|
||||
|
||||
required("im +chat-create", topString("chat_id"), ReplayForbidden),
|
||||
required("im +messages-reply", topString("message_id"), ReplaySameIdempotencyKey),
|
||||
required("im +messages-send", topString("message_id"), ReplaySameIdempotencyKey),
|
||||
required("im chats create", topString("chat_id"), ReplaySameIdempotencyKey),
|
||||
required("im chats link", topString("share_link"), ReplayForbidden),
|
||||
required("im feed.groups create", topString("group_id"), ReplayForbidden),
|
||||
required("im images create", topString("image_key"), ReplayForbidden),
|
||||
required("im messages forward", topString("message_id"), ReplaySameIdempotencyKey),
|
||||
required("im pins create", topObject("pin"), ReplayForbidden),
|
||||
required("im reactions create", topString("reaction_id"), ReplayForbidden),
|
||||
required("im reactions delete", topString("reaction_id"), ReplayForbidden),
|
||||
required("im threads forward", topString("message_id"), ReplaySameIdempotencyKey),
|
||||
|
||||
func() Contract {
|
||||
c := batch(
|
||||
"im +feed-shortcut-create",
|
||||
objectsFrom("shortcuts", "feed_card_id"),
|
||||
nestedObjectsFrom("failed_shortcuts", "shortcut", "feed_card_id"),
|
||||
)
|
||||
c.ReplayMode = ReplaySafe
|
||||
c.PartialRecovery = PartialRecoveryWholeRequest
|
||||
return c
|
||||
}(),
|
||||
func() Contract {
|
||||
c := batch(
|
||||
"im +feed-shortcut-remove",
|
||||
objectsFrom("shortcuts", "feed_card_id"),
|
||||
nestedObjectsFrom("failed_shortcuts", "shortcut", "feed_card_id"),
|
||||
)
|
||||
c.ReplayMode = ReplaySafe
|
||||
c.PartialRecovery = PartialRecoveryWholeRequest
|
||||
return c
|
||||
}(),
|
||||
{
|
||||
Key: "im +flag-cancel",
|
||||
PartialRecovery: PartialRecoveryWholeRequest,
|
||||
Strategy: Strategy{
|
||||
Kind: BatchPartialKind,
|
||||
ResultLedger: ptrEvidence(statusObjectsFrom("results", "flag_type")),
|
||||
},
|
||||
ReplayMode: ReplaySafe,
|
||||
},
|
||||
{
|
||||
Key: "im chat.members create",
|
||||
Strategy: Strategy{
|
||||
Kind: BatchPartialKind,
|
||||
Request: stringsFrom("id_list"),
|
||||
Failures: []EvidenceSpec{
|
||||
stringsFrom("invalid_id_list"),
|
||||
stringsFrom("not_existed_id_list"),
|
||||
},
|
||||
Pending: []EvidenceSpec{stringsFrom("pending_approval_id_list")},
|
||||
},
|
||||
ReplayMode: ReplayForbidden,
|
||||
},
|
||||
batch("im chat.members delete", stringsFrom("id_list"), stringsFrom("invalid_id_list")),
|
||||
batch(
|
||||
"im chat.user_setting batch_update",
|
||||
objectsFrom("chat_settings", "chat_id"),
|
||||
objectsFrom("invalid_ids", "id"),
|
||||
),
|
||||
{
|
||||
Key: "im feed.groups batch_add_item",
|
||||
Strategy: Strategy{
|
||||
Kind: BatchPartialKind,
|
||||
Request: feedObjectsFrom("items"),
|
||||
Failures: []EvidenceSpec{nestedFeedObjectsFrom("failed_items", "item")},
|
||||
},
|
||||
ReplayMode: ReplayForbidden,
|
||||
},
|
||||
{
|
||||
Key: "im feed.groups batch_remove_item",
|
||||
Strategy: Strategy{
|
||||
Kind: BatchPartialKind,
|
||||
Request: feedObjectsFrom("items"),
|
||||
Failures: []EvidenceSpec{nestedFeedObjectsFrom("failed_items", "item")},
|
||||
},
|
||||
ReplayMode: ReplayForbidden,
|
||||
},
|
||||
batch("im messages urgent_app", stringsFrom("user_id_list"), stringsFrom("invalid_user_id_list")),
|
||||
batch("im messages urgent_phone", stringsFrom("user_id_list"), stringsFrom("invalid_user_id_list")),
|
||||
batch("im messages urgent_sms", stringsFrom("user_id_list"), stringsFrom("invalid_user_id_list")),
|
||||
{
|
||||
Key: "im messages merge_forward",
|
||||
Strategy: Strategy{
|
||||
Kind: RequiredResultBatchPartialKind,
|
||||
Required: nestedString("message", "message_id"),
|
||||
Request: stringsFrom("message_id_list"),
|
||||
Failures: []EvidenceSpec{stringsFrom("invalid_message_id_list")},
|
||||
},
|
||||
ReplayMode: ReplaySameIdempotencyKey,
|
||||
},
|
||||
{
|
||||
Key: "im chat.managers add_managers",
|
||||
Strategy: Strategy{
|
||||
Kind: ResponseSetAssertionKind,
|
||||
Request: stringsFrom("manager_ids"),
|
||||
ResponseSets: []EvidenceSpec{stringsFrom("chat_managers"), stringsFrom("chat_bot_managers")},
|
||||
Assertion: AssertRequestedPresent,
|
||||
},
|
||||
ReplayMode: ReplayForbidden,
|
||||
},
|
||||
{
|
||||
Key: "im chat.managers delete_managers",
|
||||
Strategy: Strategy{
|
||||
Kind: ResponseSetAssertionKind,
|
||||
Request: stringsFrom("manager_ids"),
|
||||
ResponseSets: []EvidenceSpec{stringsFrom("chat_managers"), stringsFrom("chat_bot_managers")},
|
||||
Assertion: AssertRequestedAbsent,
|
||||
},
|
||||
ReplayMode: ReplayForbidden,
|
||||
},
|
||||
{
|
||||
Key: "im chat.moderation update",
|
||||
Strategy: Strategy{Kind: ExemptionKind},
|
||||
ReplayMode: ReplayForbidden,
|
||||
Exemption: &Exemption{
|
||||
Reason: "OpenAPI lacks per-item results",
|
||||
Owner: "IM backend",
|
||||
Expiry: "2026-10-25",
|
||||
},
|
||||
},
|
||||
}
|
||||
out := make(map[ContractKey]Contract, len(all))
|
||||
for _, c := range all {
|
||||
if c.PartialRecovery == "" &&
|
||||
(c.Strategy.Kind == BatchPartialKind || c.Strategy.Kind == RequiredResultBatchPartialKind) {
|
||||
c.PartialRecovery = PartialRecoveryFailedItemsOnly
|
||||
}
|
||||
switch {
|
||||
case c.Strategy.Kind == CollectionReadKind || c.Strategy.Kind == SearchReadKind:
|
||||
c.HelpPolicy = HelpCompleteness
|
||||
case c.Strategy.Kind == ExemptionKind:
|
||||
c.HelpPolicy = HelpAcceptanceOnly
|
||||
}
|
||||
out[c.Key] = c
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ptrEvidence(spec EvidenceSpec) *EvidenceSpec {
|
||||
return &spec
|
||||
}
|
||||
|
||||
func Lookup(key ContractKey) (Contract, bool) {
|
||||
c, ok := contracts[key]
|
||||
return c, ok
|
||||
}
|
||||
|
||||
func All() []Contract {
|
||||
out := make([]Contract, 0, len(contracts))
|
||||
for _, c := range contracts {
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
|
||||
return out
|
||||
}
|
||||
|
||||
func ValidateRegistry(now time.Time) error {
|
||||
for key, c := range contracts {
|
||||
if key == "" || c.Strategy.Kind == "" {
|
||||
return fmt.Errorf("invalid IM contract %q", key)
|
||||
}
|
||||
if c.Exemption == nil {
|
||||
continue
|
||||
}
|
||||
expiry, err := c.Exemption.ExpiryTime()
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid exemption expiry for %q: %w", key, err)
|
||||
}
|
||||
if now.After(expiry.Add(24 * time.Hour)) {
|
||||
return fmt.Errorf("IM contract exemption expired for %q (owner: %s)", key, c.Exemption.Owner)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
32
internal/imcontract/catalog/registry_test.go
Normal file
32
internal/imcontract/catalog/registry_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package catalog
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestWholeRequestPartialRecoveryContracts(t *testing.T) {
|
||||
for _, key := range []ContractKey{
|
||||
"im +feed-shortcut-create",
|
||||
"im +feed-shortcut-remove",
|
||||
"im +flag-cancel",
|
||||
} {
|
||||
contract, ok := Lookup(key)
|
||||
if !ok {
|
||||
t.Fatalf("missing contract %q", key)
|
||||
}
|
||||
if contract.PartialRecovery != PartialRecoveryWholeRequest {
|
||||
t.Fatalf("%s partial recovery = %q", key, contract.PartialRecovery)
|
||||
}
|
||||
}
|
||||
|
||||
remove, _ := Lookup("im +feed-shortcut-remove")
|
||||
if remove.ReplayMode != ReplaySafe {
|
||||
t.Fatalf("feed shortcut remove replay mode = %q", remove.ReplayMode)
|
||||
}
|
||||
|
||||
urgent, _ := Lookup("im messages urgent_app")
|
||||
if urgent.PartialRecovery != PartialRecoveryFailedItemsOnly {
|
||||
t.Fatalf("urgent app partial recovery = %q", urgent.PartialRecovery)
|
||||
}
|
||||
}
|
||||
151
internal/imcontract/catalog/types.go
Normal file
151
internal/imcontract/catalog/types.go
Normal file
@@ -0,0 +1,151 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package catalog defines the static IM command completion contract catalog.
|
||||
package catalog
|
||||
|
||||
import "time"
|
||||
|
||||
type ContractKey string
|
||||
|
||||
type StrategyKind string
|
||||
|
||||
const (
|
||||
EntityReadKind StrategyKind = "entity_read"
|
||||
CollectionReadKind StrategyKind = "collection_read"
|
||||
SearchReadKind StrategyKind = "search_read"
|
||||
MaterializeReadKind StrategyKind = "materialize_read"
|
||||
AuthoritativeAckKind StrategyKind = "authoritative_ack"
|
||||
RequiredResultKind StrategyKind = "required_result"
|
||||
BatchPartialKind StrategyKind = "batch_partial"
|
||||
RequiredResultBatchPartialKind StrategyKind = "required_result_batch_partial"
|
||||
ResponseSetAssertionKind StrategyKind = "response_set_assertion"
|
||||
ExemptionKind StrategyKind = "exemption"
|
||||
)
|
||||
|
||||
func (k StrategyKind) IsWrite() bool {
|
||||
switch k {
|
||||
case AuthoritativeAckKind, RequiredResultKind, BatchPartialKind,
|
||||
RequiredResultBatchPartialKind, ResponseSetAssertionKind, ExemptionKind:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (k StrategyKind) IsRead() bool {
|
||||
switch k {
|
||||
case EntityReadKind, CollectionReadKind, SearchReadKind, MaterializeReadKind:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type ReplayMode string
|
||||
|
||||
const (
|
||||
ReplayForbidden ReplayMode = "forbidden"
|
||||
ReplaySafe ReplayMode = "safe"
|
||||
ReplaySameIdempotencyKey ReplayMode = "same_idempotency_key"
|
||||
)
|
||||
|
||||
type PartialRecoveryMode string
|
||||
|
||||
const (
|
||||
PartialRecoveryWholeRequest PartialRecoveryMode = "whole_request"
|
||||
PartialRecoveryFailedItemsOnly PartialRecoveryMode = "failed_items_only"
|
||||
)
|
||||
|
||||
type AssertionMode string
|
||||
|
||||
const (
|
||||
AssertRequestedPresent AssertionMode = "requested_present"
|
||||
AssertRequestedAbsent AssertionMode = "requested_absent"
|
||||
)
|
||||
|
||||
type RequiredShape uint8
|
||||
|
||||
const (
|
||||
RequiredTopString RequiredShape = iota + 1
|
||||
RequiredTopObject
|
||||
RequiredNestedString
|
||||
)
|
||||
|
||||
type EvidenceShape uint8
|
||||
|
||||
const (
|
||||
EvidenceStrings EvidenceShape = iota + 1
|
||||
EvidenceObjects
|
||||
EvidenceNestedObjects
|
||||
EvidenceFeedObjects
|
||||
EvidenceNestedFeedObjects
|
||||
EvidenceStatusObjects
|
||||
)
|
||||
|
||||
type RequiredSpec struct {
|
||||
Shape RequiredShape
|
||||
Field string
|
||||
Child string
|
||||
}
|
||||
|
||||
type EvidenceSpec struct {
|
||||
Shape EvidenceShape
|
||||
Field string
|
||||
IDField string
|
||||
Container string
|
||||
}
|
||||
|
||||
type Strategy struct {
|
||||
Kind StrategyKind
|
||||
Required RequiredSpec
|
||||
Request EvidenceSpec
|
||||
Failures []EvidenceSpec
|
||||
Pending []EvidenceSpec
|
||||
ResponseSets []EvidenceSpec
|
||||
Assertion AssertionMode
|
||||
ResultLedger *EvidenceSpec
|
||||
// CollectionField is only used by the two fixed IM search strategies to
|
||||
// determine whether an exhausted search returned no candidates. It is not
|
||||
// a general response path or field extractor.
|
||||
CollectionField string
|
||||
ReadHint string
|
||||
}
|
||||
|
||||
type HelpPolicy string
|
||||
|
||||
const (
|
||||
HelpCompleteness HelpPolicy = "completeness"
|
||||
HelpAcceptanceOnly HelpPolicy = "acceptance_only"
|
||||
HintBatchReactions = "This result covers only the returned reaction fragments; use `im reactions list` to exhaust one message's reactions."
|
||||
)
|
||||
|
||||
func (p HelpPolicy) Text() string {
|
||||
switch p {
|
||||
case HelpCompleteness:
|
||||
return "Completeness: use --page-all --page-limit 0 for exhaustive output; only meta.complete=true proves completion."
|
||||
case HelpAcceptanceOnly:
|
||||
return "Guarantee: success confirms request acceptance only; independently query the final moderator state before claiming completion."
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
type Exemption struct {
|
||||
Reason string
|
||||
Owner string
|
||||
Expiry string
|
||||
}
|
||||
|
||||
func (e Exemption) ExpiryTime() (time.Time, error) {
|
||||
return time.Parse("2006-01-02", e.Expiry)
|
||||
}
|
||||
|
||||
type Contract struct {
|
||||
Key ContractKey
|
||||
Strategy Strategy
|
||||
ReplayMode ReplayMode
|
||||
PartialRecovery PartialRecoveryMode
|
||||
HelpPolicy HelpPolicy
|
||||
Exemption *Exemption
|
||||
}
|
||||
31
internal/imcontract/help.go
Normal file
31
internal/imcontract/help.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package imcontract
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
const (
|
||||
helpContractAnnotation = "imcontract.help.contract-key"
|
||||
)
|
||||
|
||||
func AnnotateHelpContract(cmd *cobra.Command, key ContractKey) {
|
||||
if cmd == nil || key == "" {
|
||||
return
|
||||
}
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[helpContractAnnotation] = string(key)
|
||||
}
|
||||
|
||||
func HelpText(cmd *cobra.Command) string {
|
||||
if cmd == nil || !cmd.Runnable() || cmd.Annotations == nil {
|
||||
return ""
|
||||
}
|
||||
contract, ok := Lookup(ContractKey(cmd.Annotations[helpContractAnnotation]))
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return contract.HelpPolicy.Text()
|
||||
}
|
||||
65
internal/imcontract/help_test.go
Normal file
65
internal/imcontract/help_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package imcontract
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestHelpPolicyTextUsesOnlyApprovedTemplates(t *testing.T) {
|
||||
tests := []struct {
|
||||
policy HelpPolicy
|
||||
want string
|
||||
}{
|
||||
{HelpCompleteness, "Completeness: use --page-all --page-limit 0 for exhaustive output; only meta.complete=true proves completion."},
|
||||
{HelpAcceptanceOnly, "Guarantee: success confirms request acceptance only; independently query the final moderator state before claiming completion."},
|
||||
{HelpPolicy("unknown"), ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := tt.policy.Text(); got != tt.want {
|
||||
t.Fatalf("HelpPolicy(%q).Text() = %q, want %q", tt.policy, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryHelpPolicies(t *testing.T) {
|
||||
tests := []struct {
|
||||
key ContractKey
|
||||
want HelpPolicy
|
||||
}{
|
||||
{"im +chat-list", HelpCompleteness},
|
||||
{"im +messages-search", HelpCompleteness},
|
||||
{"im +messages-send", ""},
|
||||
{"im messages merge_forward", ""},
|
||||
{"im chat.moderation update", HelpAcceptanceOnly},
|
||||
{"im +flag-create", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
contract, ok := Lookup(tt.key)
|
||||
if !ok {
|
||||
t.Fatalf("missing contract %q", tt.key)
|
||||
}
|
||||
if contract.HelpPolicy != tt.want {
|
||||
t.Fatalf("%s HelpPolicy = %q, want %q", tt.key, contract.HelpPolicy, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpTextIsLazyAndRunnableOnly(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "+chat-list", Short: "List chats", Run: func(*cobra.Command, []string) {}}
|
||||
AnnotateHelpContract(cmd, "im +chat-list")
|
||||
if cmd.Long != "" || cmd.Short != "List chats" {
|
||||
t.Fatalf("annotation changed visible help fields: Short=%q Long=%q", cmd.Short, cmd.Long)
|
||||
}
|
||||
if got := HelpText(cmd); got != HelpCompleteness.Text() {
|
||||
t.Fatalf("HelpText() = %q", got)
|
||||
}
|
||||
parent := &cobra.Command{Use: "im"}
|
||||
AnnotateHelpContract(parent, "im +chat-list")
|
||||
if got := HelpText(parent); got != "" {
|
||||
t.Fatalf("parent HelpText() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
116
internal/qualitygate/rules/imcontract.go
Normal file
116
internal/qualitygate/rules/imcontract.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
imcatalog "github.com/larksuite/cli/internal/imcontract/catalog"
|
||||
"github.com/larksuite/cli/internal/qualitygate/manifest"
|
||||
"github.com/larksuite/cli/internal/qualitygate/report"
|
||||
)
|
||||
|
||||
const (
|
||||
imContractCoverageRule = "im_contract_coverage"
|
||||
expectedIMLeafCommands = 60
|
||||
)
|
||||
|
||||
func CheckIMContractCoverage(commandIndex manifest.Manifest, contracts []imcatalog.Contract, now time.Time) []report.Diagnostic {
|
||||
leafKeys := imLeafCommandKeys(commandIndex)
|
||||
leafSet := make(map[string]struct{}, len(leafKeys))
|
||||
for _, key := range leafKeys {
|
||||
leafSet[key] = struct{}{}
|
||||
}
|
||||
contractSet := make(map[string]imcatalog.Contract, len(contracts))
|
||||
for _, contract := range contracts {
|
||||
contractSet[string(contract.Key)] = contract
|
||||
}
|
||||
|
||||
var diags []report.Diagnostic
|
||||
if len(leafKeys) != expectedIMLeafCommands {
|
||||
diags = append(diags, imContractDiagnostic(
|
||||
"",
|
||||
fmt.Sprintf("IM leaf command count is %d, want %d", len(leafKeys), expectedIMLeafCommands),
|
||||
))
|
||||
}
|
||||
for _, key := range leafKeys {
|
||||
if _, ok := contractSet[key]; !ok {
|
||||
diags = append(diags, imContractDiagnostic(key, "IM leaf command has no completion contract"))
|
||||
}
|
||||
}
|
||||
for _, contract := range contracts {
|
||||
key := string(contract.Key)
|
||||
if _, ok := leafSet[key]; !ok {
|
||||
diags = append(diags, imContractDiagnostic(key, "IM contract key does not match a runnable leaf command"))
|
||||
}
|
||||
if contract.Strategy.Kind != imcatalog.ExemptionKind {
|
||||
continue
|
||||
}
|
||||
if contract.Exemption == nil {
|
||||
diags = append(diags, imContractDiagnostic(key, "IM contract exemption is missing owner, reason, and expiry"))
|
||||
continue
|
||||
}
|
||||
exemption := contract.Exemption
|
||||
if exemption.Owner == "" || exemption.Reason == "" || exemption.Expiry == "" {
|
||||
diags = append(diags, imContractDiagnostic(
|
||||
key,
|
||||
fmt.Sprintf("IM contract exemption is incomplete (owner=%q reason=%q expiry=%q)", exemption.Owner, exemption.Reason, exemption.Expiry),
|
||||
))
|
||||
continue
|
||||
}
|
||||
expiry, err := exemption.ExpiryTime()
|
||||
if err != nil {
|
||||
diags = append(diags, imContractDiagnostic(
|
||||
key,
|
||||
fmt.Sprintf("IM contract exemption has invalid expiry %q (owner=%q reason=%q)", exemption.Expiry, exemption.Owner, exemption.Reason),
|
||||
))
|
||||
continue
|
||||
}
|
||||
if !now.Before(expiry.AddDate(0, 0, 1)) {
|
||||
diags = append(diags, imContractDiagnostic(
|
||||
key,
|
||||
fmt.Sprintf("IM contract exemption expired on %s (owner=%q reason=%q)", exemption.Expiry, exemption.Owner, exemption.Reason),
|
||||
))
|
||||
}
|
||||
}
|
||||
return diags
|
||||
}
|
||||
|
||||
func imLeafCommandKeys(commandIndex manifest.Manifest) []string {
|
||||
var candidates []string
|
||||
for _, cmd := range commandIndex.Commands {
|
||||
if cmd.Domain == "im" && cmd.Runnable {
|
||||
candidates = append(candidates, cmd.Path)
|
||||
}
|
||||
}
|
||||
sort.Strings(candidates)
|
||||
leaves := make([]string, 0, len(candidates))
|
||||
for _, path := range candidates {
|
||||
parent := false
|
||||
for _, other := range candidates {
|
||||
if other != path && strings.HasPrefix(other, path+" ") {
|
||||
parent = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !parent {
|
||||
leaves = append(leaves, path)
|
||||
}
|
||||
}
|
||||
return leaves
|
||||
}
|
||||
|
||||
func imContractDiagnostic(commandPath, message string) report.Diagnostic {
|
||||
return report.Diagnostic{
|
||||
Rule: imContractCoverageRule,
|
||||
Action: report.ActionReject,
|
||||
File: "command-index",
|
||||
Message: message,
|
||||
SubjectType: "command",
|
||||
CommandPath: commandPath,
|
||||
}
|
||||
}
|
||||
109
internal/qualitygate/rules/imcontract_test.go
Normal file
109
internal/qualitygate/rules/imcontract_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
imcatalog "github.com/larksuite/cli/internal/imcontract/catalog"
|
||||
qdiff "github.com/larksuite/cli/internal/qualitygate/diff"
|
||||
"github.com/larksuite/cli/internal/qualitygate/manifest"
|
||||
"github.com/larksuite/cli/internal/qualitygate/report"
|
||||
)
|
||||
|
||||
func TestIMLeafCommandsExcludeParentsAndOtherDomains(t *testing.T) {
|
||||
index := manifest.Manifest{Commands: []manifest.Command{
|
||||
{Path: "im chat", Domain: "im", Runnable: true},
|
||||
{Path: "im chat get", Domain: "im", Runnable: true},
|
||||
{Path: "im chat list", Domain: "im", Runnable: false},
|
||||
{Path: "docs chat get", Domain: "docs", Runnable: true},
|
||||
}}
|
||||
got := imLeafCommandKeys(index)
|
||||
if len(got) != 1 || got[0] != "im chat get" {
|
||||
t.Fatalf("IM leaves = %#v, want only runnable child", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMContractCoverageReportsMissingAndStaleKeys(t *testing.T) {
|
||||
index, contracts := completeIMCoverageFixture()
|
||||
contracts = contracts[1:]
|
||||
contracts = append(contracts, imcatalog.Contract{
|
||||
Key: "im stale command", Strategy: imcatalog.Strategy{Kind: imcatalog.EntityReadKind},
|
||||
})
|
||||
diags := CheckIMContractCoverage(index, contracts, time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC))
|
||||
if !hasIMContractDiagnostic(diags, "im resource command00", "no completion contract") {
|
||||
t.Fatalf("missing-command diagnostic absent: %#v", diags)
|
||||
}
|
||||
if !hasIMContractDiagnostic(diags, "im stale command", "does not match") {
|
||||
t.Fatalf("stale-key diagnostic absent: %#v", diags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMContractCoverageReportsExpiredExemptionWithOwnerAndReason(t *testing.T) {
|
||||
index, contracts := completeIMCoverageFixture()
|
||||
contracts[0] = imcatalog.Contract{
|
||||
Key: contracts[0].Key,
|
||||
Strategy: imcatalog.Strategy{Kind: imcatalog.ExemptionKind},
|
||||
Exemption: &imcatalog.Exemption{
|
||||
Owner: "IM backend", Reason: "OpenAPI lacks evidence", Expiry: "2026-10-25",
|
||||
},
|
||||
}
|
||||
diags := CheckIMContractCoverage(index, contracts, time.Date(2026, 10, 26, 0, 0, 0, 0, time.UTC))
|
||||
if !hasIMContractDiagnostic(diags, string(contracts[0].Key), "owner=\"IM backend\"") ||
|
||||
!hasIMContractDiagnostic(diags, string(contracts[0].Key), "reason=\"OpenAPI lacks evidence\"") {
|
||||
t.Fatalf("expired exemption diagnostic lacks static ownership facts: %#v", diags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMContractCoverageReportsMissingIMDomain(t *testing.T) {
|
||||
index := manifest.Manifest{Commands: []manifest.Command{
|
||||
{Path: "docs +fetch", Domain: "docs", Runnable: true},
|
||||
}}
|
||||
if leaves := imLeafCommandKeys(index); len(leaves) != 0 {
|
||||
t.Fatalf("IM leaves = %#v, want none", leaves)
|
||||
}
|
||||
diags := CheckIMContractCoverage(index, imcatalog.All(), time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC))
|
||||
if !hasIMContractDiagnostic(diags, "", "IM leaf command count is 0, want 60") {
|
||||
t.Fatalf("missing-domain diagnostic absent: %#v", diags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMContractCoverageDiagnosticIsNotChangedFileFiltered(t *testing.T) {
|
||||
diag := imContractDiagnostic("im +chat-list", "missing")
|
||||
got := filterPRDiagnostics(
|
||||
".",
|
||||
"origin/main",
|
||||
qdiff.FromChangedFiles([]string{"skills/lark-doc/SKILL.md"}),
|
||||
manifest.Manifest{},
|
||||
[]report.Diagnostic{diag},
|
||||
)
|
||||
if len(got) != 1 || got[0].Rule != imContractCoverageRule {
|
||||
t.Fatalf("global IM coverage diagnostic was filtered: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func completeIMCoverageFixture() (manifest.Manifest, []imcatalog.Contract) {
|
||||
index := manifest.Manifest{SchemaVersion: 1}
|
||||
contracts := make([]imcatalog.Contract, 0, expectedIMLeafCommands)
|
||||
for i := 0; i < expectedIMLeafCommands; i++ {
|
||||
key := fmt.Sprintf("im resource command%02d", i)
|
||||
index.Commands = append(index.Commands, manifest.Command{Path: key, Domain: "im", Runnable: true})
|
||||
contracts = append(contracts, imcatalog.Contract{
|
||||
Key: imcatalog.ContractKey(key), Strategy: imcatalog.Strategy{Kind: imcatalog.EntityReadKind},
|
||||
})
|
||||
}
|
||||
return index, contracts
|
||||
}
|
||||
|
||||
func hasIMContractDiagnostic(diags []report.Diagnostic, key, text string) bool {
|
||||
for _, diag := range diags {
|
||||
if diag.CommandPath == key && strings.Contains(diag.Message, text) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,24 +116,24 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
|
||||
|----------|------|
|
||||
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager |
|
||||
| [`+chat-list`](references/lark-im-chat-list.md) | List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only) |
|
||||
| [`+chat-members-list`](references/lark-im-chat-members-list.md) | List members of a chat; returns separate users[] / bots[] buckets; callable as user or bot; --member-types filters which kinds to return; --page-all pagination; surfaces truncations[] when the server caps a bucket |
|
||||
| [`+chat-members-list`](references/lark-im-chat-members-list.md) | List members of a chat; returns separate users[] / bots[] buckets; callable as user or bot; --member-types filters which kinds to return; surfaces truncations[] when the server caps a bucket |
|
||||
| [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination |
|
||||
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only) |
|
||||
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description |
|
||||
| [`+messages-mget`](references/lark-im-messages-mget.md) | Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies |
|
||||
| [`+messages-reply`](references/lark-im-messages-reply.md) | Reply to a message (supports thread replies); user/bot; supports text/markdown/post/media replies, reply-in-thread, idempotency key |
|
||||
| [`+messages-resources-download`](references/lark-im-messages-resources-download.md) | Download images/files from a message; user/bot; supports automatic chunked download for large files (8MB chunks), auto-detects file extension from Content-Type |
|
||||
| [`+messages-search`](references/lark-im-messages-search.md) | Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, supports auto-pagination via `--page-all` / `--page-limit`, enriches results via batched mget and chats batch_query |
|
||||
| [`+messages-search`](references/lark-im-messages-search.md) | Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time and enriches results via batched mget and chats batch_query |
|
||||
| [`+messages-send`](references/lark-im-messages-send.md) | Send a message to a chat or direct message; user/bot; sends to chat-id or user-id with text/markdown/post/media, supports idempotency key |
|
||||
| [`+threads-messages-list`](references/lark-im-threads-messages-list.md) | List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination |
|
||||
| [`+flag-create`](references/lark-im-flag-create.md) | Create a bookmark on a message; user-only; defaults to message-layer flag; use --flag-type feed for feed-layer flag (item_type auto-detected from chat mode) |
|
||||
| [`+flag-cancel`](references/lark-im-flag-cancel.md) | Cancel (remove) a bookmark. When no --flag-type is given, best-effort double-cancel: removes message layer and (when chat_type is determinable) feed layer |
|
||||
| [`+flag-list`](references/lark-im-flag-list.md) | List bookmarks; user-only; auto-enriches feed-type thread entries with message content; `--page-all` is capped by `--page-limit` (default 20, max 1000), and `has_more=true` means the result is incomplete |
|
||||
| [`+flag-list`](references/lark-im-flag-list.md) | List bookmarks; user-only; auto-enriches feed-type thread entries with message content |
|
||||
| [`+feed-shortcut-create`](references/lark-im-feed-shortcut-create.md) | Add chats to the user's feed shortcuts; user-only; oc_xxx chat IDs only; batch up to 10 per call; `--head`/`--tail` controls insertion order; partial failures return an `ok:false` ledger |
|
||||
| [`+feed-shortcut-remove`](references/lark-im-feed-shortcut-remove.md) | Remove chats from the user's feed shortcuts; user-only; batch up to 10 per call; removing an absent shortcut is idempotent success; real per-item failures return an `ok:false` ledger |
|
||||
| [`+feed-shortcut-list`](references/lark-im-feed-shortcut-list.md) | List one page of the user's feed shortcuts; user-only; omit `--page-token` for the first page; default output enriches CHAT entries under `detail`; pass `--no-detail` to skip the extra lookup and `im:chat:read` scope |
|
||||
| [`+feed-group-list`](references/lark-im-feed-group-list.md) | List the caller's feed groups (tags); user-only; supports `--page-all` auto-pagination |
|
||||
| [`+feed-group-list-item`](references/lark-im-feed-group-list-item.md) | List feed cards in a feed group (tag); user-only; enriches each item with chat_name resolved from feed_id; supports --page-all auto-pagination |
|
||||
| [`+feed-shortcut-list`](references/lark-im-feed-shortcut-list.md) | List the user's feed shortcuts; user-only; default output enriches CHAT entries under `detail`; pass `--no-detail` to skip the extra lookup and `im:chat:read` scope |
|
||||
| [`+feed-group-list`](references/lark-im-feed-group-list.md) | List the caller's feed groups (tags); user-only; preserves both live and soft-deleted groups |
|
||||
| [`+feed-group-list-item`](references/lark-im-feed-group-list-item.md) | List feed cards in a feed group (tag); user-only; enriches each item with chat_name resolved from feed_id |
|
||||
| [`+feed-group-query-item`](references/lark-im-feed-group-query-item.md) | Look up specific feed cards in a feed group (tag) by ID; user-only; enriches each item with chat_name resolved from feed_id |
|
||||
|
||||
## API Resources
|
||||
|
||||
@@ -17,12 +17,6 @@ lark-cli im +chat-list
|
||||
# Sort by recent activity (most recently active first)
|
||||
lark-cli im +chat-list --sort active_time
|
||||
|
||||
# Limit page size
|
||||
lark-cli im +chat-list --page-size 50
|
||||
|
||||
# Pagination
|
||||
lark-cli im +chat-list --page-token "xxx"
|
||||
|
||||
# Drop muted chats (user identity only)
|
||||
lark-cli im +chat-list --exclude-muted
|
||||
|
||||
@@ -49,8 +43,6 @@ lark-cli im +chat-list --as user --types p2p
|
||||
| `--user-id-type <type>` | No | `open_id` (default), `union_id`, `user_id` | ID type used for `owner_id` in the response |
|
||||
| `--types <strings>` | No | `group`, `p2p` (comma-separated or repeated) | Chat types to include. Omitted = groups only (backward compatible). `p2p` requires user identity (`--as user`); under `--as bot`, `--types=p2p` alone is rejected and `--types=p2p,group` is silently downgraded to `group` |
|
||||
| `--sort <field>` | No | `create_time` (default, ascending), `active_time` (descending) | Result ordering |
|
||||
| `--page-size <n>` | No | 1-100, default 20 | Number of results per page |
|
||||
| `--page-token <token>` | No | - | Pagination token from the previous response |
|
||||
| `--exclude-muted` | No | User identity only | Drop chats the current user has muted (do-not-disturb). Under `--as bot`, the flag is silently inactive; see "Filtering muted chats" below |
|
||||
| `--format json` | No | - | Output as JSON |
|
||||
| `--dry-run` | No | - | Preview the request without executing it |
|
||||
@@ -139,24 +131,12 @@ lark-cli im +chat-list --sort active_time --page-size 10
|
||||
lark-cli im +chat-list --sort active_time --exclude-muted
|
||||
```
|
||||
|
||||
### Scenario 3: Iterate all my chats programmatically
|
||||
|
||||
```bash
|
||||
TOKEN=""
|
||||
while :; do
|
||||
RESP=$(lark-cli im +chat-list --page-size 100 --page-token "$TOKEN" --format json)
|
||||
echo "$RESP" | jq -r '.data.chats[].chat_id'
|
||||
HAS_MORE=$(echo "$RESP" | jq -r '.data.has_more')
|
||||
[ "$HAS_MORE" = "true" ] || break
|
||||
TOKEN=$(echo "$RESP" | jq -r '.data.page_token')
|
||||
done
|
||||
```
|
||||
If the task requires every visible chat, inspect this concrete command's `--help` before executing.
|
||||
|
||||
## Common Errors and Troubleshooting
|
||||
|
||||
| Symptom | Root Cause | Solution |
|
||||
|---------|---------|---------|
|
||||
| `--page-size must be an integer between 1 and 100` | page-size is out of range or not an integer | Use an integer between 1 and 100 |
|
||||
| Permission denied (99991672) | The bot app does not have `im:chat:read` TAT permission enabled | Enable the permission for the app in the Open Platform console |
|
||||
| Permission denied (99991679) with `--as user` | UAT is not authorized for `im:chat:read` | Run `lark-cli auth login --scope "im:chat:read"` |
|
||||
| `Bot ability is not activated` (232025) | The app does not have bot capability enabled | Enable bot capability in the Open Platform console |
|
||||
|
||||
@@ -16,12 +16,6 @@ lark-cli im +chat-members-list --chat-id oc_xxx
|
||||
lark-cli im +chat-members-list --chat-id oc_xxx --member-types user
|
||||
lark-cli im +chat-members-list --chat-id oc_xxx --member-types user,bot
|
||||
|
||||
# Walk every page (capped by --page-limit; 0 = unlimited)
|
||||
lark-cli im +chat-members-list --chat-id oc_xxx --page-all --page-limit 0
|
||||
|
||||
# Resume from a specific cursor (single page; --page-all is ignored)
|
||||
lark-cli im +chat-members-list --chat-id oc_xxx --page-token "xxx"
|
||||
|
||||
# JSON output / preview the request
|
||||
lark-cli im +chat-members-list --chat-id oc_xxx --format json
|
||||
lark-cli im +chat-members-list --chat-id oc_xxx --dry-run
|
||||
@@ -34,11 +28,6 @@ lark-cli im +chat-members-list --chat-id oc_xxx --dry-run
|
||||
| `--chat-id <id>` | Yes | `oc_xxx` | Target chat |
|
||||
| `--member-types <strings>` | No | `user`, `bot` (comma-separated or repeated) | Member types to return. Omitted = all |
|
||||
| `--member-id-type <type>` | No | `open_id` (default), `union_id`, `user_id` | ID type for `member_id` in the response |
|
||||
| `--page-size <n>` | No | 1-100, default 20 | Results per page. With `--page-all` and no explicit `--page-size`, the max (100) is used automatically to minimize round-trips |
|
||||
| `--page-token <token>` | No | - | Pagination cursor; **implies a single-page fetch** (disables auto-pagination) |
|
||||
| `--page-all` | No | - | Automatically walk every page (capped by `--page-limit`) |
|
||||
| `--page-limit <n>` | No | default 10, `0` = unlimited | Max pages to fetch with `--page-all` |
|
||||
| `--page-delay <ms>` | No | default 200, `0` = no delay | Delay between pages during `--page-all` (throttle to avoid rate limits on large lists) |
|
||||
| `--format json` | No | - | Output as JSON |
|
||||
| `--dry-run` | No | - | Preview the request without executing it |
|
||||
|
||||
@@ -64,20 +53,14 @@ The server applies a security cap to large member lists. When a bucket is capped
|
||||
|
||||
A truncated result is *not* fixable by paging further — it is a server-side cap. Treat `users`/`bots` as a partial list whenever `truncations` is non-empty.
|
||||
|
||||
## Pagination notes
|
||||
## Result scope
|
||||
|
||||
- Default fetches a single page. Pass `--page-all` to drain every page.
|
||||
- With `--page-all` and no explicit `--page-size`, the shortcut uses the maximum page size (100) so a full walk takes the fewest round-trips. An explicit `--page-size` is always honored.
|
||||
- `--page-all` sleeps `--page-delay` ms (default 200) between pages to avoid hammering the API when a tenant has no server-side member cap and the list spans many pages. Set `--page-delay 0` to disable.
|
||||
- `--page-all` stops at `--page-limit` pages (default 10). When it stops early, `has_more` stays `true` so you know the result is incomplete; re-run with `--page-limit 0` for everything.
|
||||
- `--page-token` and `--page-all` together: `--page-token` wins (single-page fetch from the supplied cursor); a stderr warning is emitted.
|
||||
- Across pages, `users[]` and `bots[]` are concatenated; `truncations` / `has_more` / `page_token` come from the last page fetched.
|
||||
For pagination controls, inspect this concrete command's `--help`. Exhausting pages does not bypass the server-side security cap described above; a non-empty `truncations` array still means the member list is incomplete.
|
||||
|
||||
## Common Errors and Troubleshooting
|
||||
|
||||
| Symptom | Root Cause | | Solution |
|
||||
|---------|---------|---|---------|
|
||||
| `--chat-id is required` | `--chat-id` omitted | | Provide the `oc_xxx` chat ID |
|
||||
| `--page-size must be an integer between 1 and 100` | out of range | | Use 1-100 |
|
||||
| `--member-types contains invalid value` | value other than `user`/`bot` | | Use `user`, `bot`, or both |
|
||||
| Permission denied | missing `im:chat.members:read` | | Bot: enable the scope in the console. User: `lark-cli auth login --scope "im:chat.members:read"` |
|
||||
|
||||
@@ -23,11 +23,8 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --start "2026-03-10T00:00:00+08
|
||||
# Specify a time range (date only)
|
||||
lark-cli im +chat-messages-list --chat-id oc_xxx --start 2026-03-10 --end 2026-03-11
|
||||
|
||||
# Control sort order and page size (max 50)
|
||||
lark-cli im +chat-messages-list --chat-id oc_xxx --order asc --page-size 20
|
||||
|
||||
# Pagination
|
||||
lark-cli im +chat-messages-list --chat-id oc_xxx --page-token "xxx"
|
||||
# Control sort order
|
||||
lark-cli im +chat-messages-list --chat-id oc_xxx --order asc
|
||||
|
||||
# JSON output
|
||||
lark-cli im +chat-messages-list --chat-id oc_xxx --format json
|
||||
@@ -42,8 +39,6 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --format json
|
||||
| `--start <time>` | No | Start time (ISO 8601 or date only) |
|
||||
| `--end <time>` | No | End time (ISO 8601 or date only) |
|
||||
| `--order <order>` | No | Sort order: `asc` / `desc` (default `desc`) |
|
||||
| `--page-size <n>` | No | Page size (default 50, max 50) |
|
||||
| `--page-token <token>` | No | Pagination token |
|
||||
| `--no-reactions` | No | Skip auto-fetching the `reactions` block |
|
||||
| `--download-resources` | No | Download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block. Off by default; no extra requests when omitted |
|
||||
|
||||
@@ -77,8 +72,8 @@ lark-cli im +threads-messages-list --thread omt_xxx
|
||||
|
||||
| Scenario | Recommendation |
|
||||
|------|------|
|
||||
| You need context | Call `im +threads-messages-list --order desc --page-size 10` for the discovered thread_id to inspect recent replies |
|
||||
| The user asks for the "full discussion" | Use `im +threads-messages-list --order asc --page-size 50`, then paginate if needed |
|
||||
| You need context | Call `im +threads-messages-list --order desc` for the discovered thread_id to inspect recent replies |
|
||||
| The user asks for the "full discussion" | Inspect the thread command's `--help` for full-read controls, then read in chronological order |
|
||||
| You only need an overview | Skip thread expansion |
|
||||
|
||||
## Output Fields
|
||||
@@ -104,20 +99,7 @@ Each message contains:
|
||||
| `mentions` | Array of @mentions in the message; each item contains `{id, key, name}`. Present only when the message contains @mentions |
|
||||
| `thread_id` | Thread ID (`omt_xxx`) if the message has replies in a thread. Present only when replies exist |
|
||||
|
||||
## Pagination (`has_more` / `page_token`)
|
||||
|
||||
`im +chat-messages-list` returns `has_more` and `page_token` when more data is available. Use `--page-token` to continue:
|
||||
|
||||
```bash
|
||||
lark-cli im +chat-messages-list --chat-id oc_xxx --page-token <PAGE_TOKEN>
|
||||
```
|
||||
|
||||
You can also fall back to the generic API:
|
||||
|
||||
```bash
|
||||
lark-cli api GET /open-apis/im/v1/messages \
|
||||
--params 'container_id_type=chat&container_id=oc_xxx&page_size=50&page_token=<PAGE_TOKEN>'
|
||||
```
|
||||
If the task requires the complete conversation, inspect this concrete command's `--help` before executing.
|
||||
|
||||
## Common Errors and Troubleshooting
|
||||
|
||||
@@ -147,9 +129,9 @@ lark-cli api GET /open-apis/im/v1/messages \
|
||||
7. **Application/bot identity + named group history:** If the user says "使用应用身份/以 bot 身份" and asks to list or read historical messages for a named group, use bot identity for both steps:
|
||||
```bash
|
||||
lark-cli im +chat-search --as bot --query "<chat name keyword>" --format json
|
||||
lark-cli im +chat-messages-list --as bot --chat-id <chat_id> --page-size 50 --format json
|
||||
lark-cli im +chat-messages-list --as bot --chat-id <chat_id> --format json
|
||||
```
|
||||
Do not use `im +messages-search --as bot`; `+messages-search` is user-only. Continue with `--page-token` if `has_more=true`.
|
||||
Do not use `im +messages-search --as bot`; `+messages-search` is user-only. Inspect `+chat-messages-list --help` first when the task requires complete history.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -27,12 +27,6 @@ lark-cli im +chat-search --member-ids "ou_xxx,ou_yyy"
|
||||
# Only show chats you created or manage
|
||||
lark-cli im +chat-search --query "project" --is-manager
|
||||
|
||||
# Set page size
|
||||
lark-cli im +chat-search --query "project" --page-size 10
|
||||
|
||||
# Pagination
|
||||
lark-cli im +chat-search --query "project" --page-token "xxx"
|
||||
|
||||
# JSON output
|
||||
lark-cli im +chat-search --query "project" --format json
|
||||
|
||||
@@ -51,8 +45,6 @@ lark-cli im +chat-search --query "project" --dry-run
|
||||
| `--is-manager` | No | - | Only show chats you created or manage |
|
||||
| `--disable-search-by-user` | No | - | Disable member-name-based matching and search by group name only |
|
||||
| `--sort <field>` | No | `create_time`, `update_time`, `member_count` | Sort field (always descending) |
|
||||
| `--page-size <n>` | No | 1-100, default 20 | Number of results per page |
|
||||
| `--page-token <token>` | No | - | Pagination token from the previous response |
|
||||
| `--exclude-muted` | No | User identity only | Drop chats the current user has muted (do-not-disturb). Under `--as bot`, the flag is silently inactive (mute is a per-user setting); see "Filtering muted chats" below |
|
||||
| `--format json` | No | - | Output as JSON |
|
||||
| `--dry-run` | No | - | Preview the request without executing it |
|
||||
@@ -121,7 +113,6 @@ lark-cli im +messages-send --chat-id "$CHAT_ID" --text "Today's progress update"
|
||||
|---------|---------|---------|
|
||||
| `--query and --member-ids cannot both be empty` | Both were omitted | Provide at least `--query` or `--member-ids` |
|
||||
| Empty results | No visible chats matched the keyword or filters | Relax the keyword or filters and try again |
|
||||
| `--page-size must be an integer between 1 and 100` | page-size is out of range or not an integer | Use an integer between 1 and 100 |
|
||||
| Permission denied (99991672) | The bot app does not have `im:chat:read` TAT permission enabled | Enable the permission for the app in the Open Platform console |
|
||||
| Permission denied (99991679) with `--as user` | UAT is not authorized for `im:chat:read` | Run `lark-cli auth login --scope "im:chat:read"` |
|
||||
| `Bot ability is not activated` (232025) | The app does not have bot capability enabled | Enable bot capability in the Open Platform console |
|
||||
@@ -132,7 +123,7 @@ When the user asks to search chats, follow these rules:
|
||||
|
||||
1. **At least one filter required:** `--query` and `--member-ids` cannot both be empty. Either alone or combined together are valid.
|
||||
2. **Search scope is limited:** only chats visible to the current user or bot can be found (joined chats plus public chats). This is not a global search over all chats.
|
||||
3. **Control result volume:** the result set may be large. Use `--page-size` deliberately.
|
||||
3. **Result scope:** if the task requires exhaustive search, inspect this concrete command's `--help` before executing.
|
||||
4. **Suggest follow-up actions:** after finding a chat, common next steps include listing recent messages (`im +chat-messages-list`) or sending a message (`im +messages-send`).
|
||||
5. **NEVER fall back to chats list:** If `+chat-search` returns empty results, do NOT attempt to use `+chat-list` or `GET /open-apis/im/v1/chats` as a fallback. The list API is not a search API — it returns all chats without keyword filtering and will not help locate the target chat. Instead, ask the user to refine the keyword or check whether the chat is visible to the current identity.
|
||||
|
||||
|
||||
@@ -23,9 +23,9 @@ Because chat-name resolution always runs, this shortcut needs **two** user scope
|
||||
# First page, enriched with chat names
|
||||
lark-cli im +feed-group-list-item --as user --feed-group-id ofg_xxx
|
||||
|
||||
# Auto-paginate through everything within a time window
|
||||
# List items within a time window
|
||||
lark-cli im +feed-group-list-item --as user --feed-group-id ofg_xxx \
|
||||
--page-all --start-time 1767196800000 --end-time 1767200000000
|
||||
--start-time 1767196800000 --end-time 1767200000000
|
||||
```
|
||||
|
||||
## Flags
|
||||
@@ -33,14 +33,10 @@ lark-cli im +feed-group-list-item --as user --feed-group-id ofg_xxx \
|
||||
| Flag | Required | Description |
|
||||
|---|---|---|
|
||||
| `--feed-group-id` | Yes | Feed group ID (`ofg_xxx`); path parameter |
|
||||
| `--page-size` | No | Records per page, 1–50 (default 50) |
|
||||
| `--page-token` | No | Continuation token for a specific page |
|
||||
| `--page-all` | No | Auto-paginate and merge all pages |
|
||||
| `--page-limit` | No | Max pages when `--page-all` is set, 1–1000 (default 20) |
|
||||
| `--start-time` | No | Update-time window start (Unix milliseconds as a decimal string) |
|
||||
| `--end-time` | No | Update-time window end (Unix milliseconds as a decimal string) |
|
||||
|
||||
When `--page-token` is set explicitly, it wins over `--page-all` (you get exactly that page).
|
||||
For pagination controls, inspect this concrete command's `--help`.
|
||||
|
||||
## Output
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# +feed-group-list
|
||||
|
||||
> Shortcut for `lark-cli im +feed-group-list`. List the caller's feed groups (tags) with auto-pagination that correctly merges both the live and soft-deleted lists.
|
||||
> Shortcut for `lark-cli im +feed-group-list`. List the caller's feed groups (tags) while preserving both the live and soft-deleted lists.
|
||||
|
||||
`+feed-group-list` is the only CLI surface for listing feed groups — there is no raw `feed.groups list` command. The list response carries two parallel arrays — `groups` (live) and `deleted_groups` (soft-deleted). The shortcut paginates this dual-list response correctly: its `--page-all` merges **both** arrays across pages (a naive single-array pager would silently drop one list's later pages). It adds no enrichment.
|
||||
`+feed-group-list` is the only CLI surface for listing feed groups — there is no raw `feed.groups list` command. The list response carries two parallel arrays — `groups` (live) and `deleted_groups` (soft-deleted). When traversing multiple pages, the shortcut merges **both** arrays (a naive single-array pager would silently drop one list's later pages). It adds no enrichment.
|
||||
|
||||
## Identity
|
||||
|
||||
@@ -18,11 +18,8 @@ User-only. Run with `--as user`.
|
||||
# First page
|
||||
lark-cli im +feed-group-list --as user
|
||||
|
||||
# Auto-paginate through all your feed groups (both live and deleted)
|
||||
lark-cli im +feed-group-list --as user --page-all
|
||||
|
||||
# Within an update-time window
|
||||
lark-cli im +feed-group-list --as user --page-all \
|
||||
lark-cli im +feed-group-list --as user \
|
||||
--start-time 1767196800000 --end-time 1767200000000
|
||||
```
|
||||
|
||||
@@ -30,18 +27,14 @@ lark-cli im +feed-group-list --as user --page-all \
|
||||
|
||||
| Flag | Required | Description |
|
||||
|---|---|---|
|
||||
| `--page-size` | No | Records per page, 1–50 (default 50). Caps the combined `groups` + `deleted_groups` count, so a page may hold fewer live groups than the size suggests |
|
||||
| `--page-token` | No | Continuation token for a specific page |
|
||||
| `--page-all` | No | Auto-paginate and merge all pages (both lists) |
|
||||
| `--page-limit` | No | Max pages when `--page-all` is set, 1–1000 (default 20) |
|
||||
| `--start-time` | No | Update-time window start (Unix milliseconds as a decimal string) |
|
||||
| `--end-time` | No | Update-time window end (Unix milliseconds as a decimal string) |
|
||||
|
||||
When `--page-token` is set explicitly, it wins over `--page-all` (you get exactly that page).
|
||||
For pagination controls, inspect this concrete command's `--help`. The dual-list merge guarantee applies when multiple pages are fetched.
|
||||
|
||||
## Output
|
||||
|
||||
JSON keeps the raw envelope; with `--page-all` both lists are returned fully merged:
|
||||
JSON keeps the raw envelope. When multiple pages are fetched, both lists are returned fully merged:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -56,7 +49,7 @@ JSON keeps the raw envelope; with `--page-all` both lists are returned fully mer
|
||||
}
|
||||
```
|
||||
|
||||
> `page_size` counts live and deleted groups together, and the per-page count can be smaller still when entries are filtered — so never infer completeness from counts. Pagination is governed solely by `has_more`.
|
||||
> Page size counts live and deleted groups together, and the per-page count can be smaller still when entries are filtered — so never infer completeness from counts.
|
||||
|
||||
## See also
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ Three typed `+` shortcuts cover the feed-group read paths. All are user-only.
|
||||
|
||||
| Shortcut | Purpose | Notes |
|
||||
|---|---|---|
|
||||
| [`+feed-group-list`](lark-im-feed-group-list.md) | List your feed groups | Its `--page-all` correctly merges the live and soft-deleted lists. No enrichment |
|
||||
| [`+feed-group-list`](lark-im-feed-group-list.md) | List your feed groups | Preserves and merges both the live and soft-deleted lists. No enrichment |
|
||||
| [`+feed-group-list-item`](lark-im-feed-group-list-item.md) | List the feed cards inside a group | Enriches each card with `chat_name` |
|
||||
| [`+feed-group-query-item`](lark-im-feed-group-query-item.md) | Look up feed cards in a group by ID | Enriches each card with `chat_name` |
|
||||
|
||||
@@ -242,7 +242,7 @@ Each element carries `group_id`, `type`, `name`, and (when defined) `rules`.
|
||||
|
||||
## list
|
||||
|
||||
Shortcut-only: [`+feed-group-list`](lark-im-feed-group-list.md). Lists the caller's feed groups, optionally filtered by an update-time window. Its `--page-all` correctly merges the live (`groups`) and soft-deleted (`deleted_groups`) lists across pages. There is no raw command — flags and response shape are in the linked shortcut doc.
|
||||
Shortcut-only: [`+feed-group-list`](lark-im-feed-group-list.md). Lists the caller's feed groups, optionally filtered by an update-time window, and correctly merges the live (`groups`) and soft-deleted (`deleted_groups`) lists across pages. There is no raw command — flags and response shape are in the linked shortcut doc.
|
||||
|
||||
## batch_add_item
|
||||
|
||||
@@ -326,7 +326,7 @@ Shortcut-only: [`+feed-group-query-item`](lark-im-feed-group-query-item.md). Loo
|
||||
|
||||
## list_item
|
||||
|
||||
Shortcut-only: [`+feed-group-list-item`](lark-im-feed-group-list-item.md). Lists the feed cards inside a group (paginated, `--page-all` supported) and enriches each with `chat_name`. There is no raw command — flags and response shape are in the linked shortcut doc.
|
||||
Shortcut-only: [`+feed-group-list-item`](lark-im-feed-group-list-item.md). Lists the feed cards inside a group and enriches each with `chat_name`. There is no raw command — flags and response shape are in the linked shortcut doc.
|
||||
|
||||
## Enums
|
||||
|
||||
|
||||
@@ -6,33 +6,29 @@ This skill maps to shortcut: `lark-cli im +feed-shortcut-list`. Underlying API:
|
||||
|
||||
## What it does
|
||||
|
||||
Lists **one page** of the **current user's** feed shortcuts.
|
||||
Lists the **current user's** feed shortcuts.
|
||||
|
||||
- Only **CHAT-type** shortcuts are exposed via OpenAPI today (others in the IDL are not yet whitelisted).
|
||||
- The shortcut is a **thin one-page wrapper** — there is no built-in auto-pagination. Callers drive their own loop when they actually need to paginate.
|
||||
- Pagination controls are defined by the concrete command's `--help`.
|
||||
- Server-side page size is controlled by the service; in normal use one page usually covers the list.
|
||||
- Pagination tokens are opaque. If a token is rejected because the shortcut list changed, restart by omitting `--page-token`.
|
||||
- Pagination tokens are opaque and can become invalid when the shortcut list changes.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# First page (the only call most users ever need — --page-token omitted)
|
||||
# List shortcuts
|
||||
lark-cli im +feed-shortcut-list --as user
|
||||
|
||||
# Continue from the previous response's page_token
|
||||
lark-cli im +feed-shortcut-list --as user --page-token <token-from-previous-response>
|
||||
|
||||
# Skip detail enrichment when only IDs are needed; avoids the extra im:chat:read lookup
|
||||
lark-cli im +feed-shortcut-list --as user --no-detail -q '.data.shortcuts[].feed_card_id'
|
||||
lark-cli im +feed-shortcut-list --as user --no-detail
|
||||
```
|
||||
|
||||
> If you need to walk every page, write the loop yourself: read `data.page_token` from each response and pass it back in until `has_more=false`. The shortcut intentionally does not auto-walk because page-token errors require the caller to decide whether to restart from the first page.
|
||||
> If the task requires every shortcut, inspect the concrete command's `--help` before executing. If a continuation token is rejected after the shortcut list changes, restart from the beginning.
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|------|------|------|
|
||||
| `--page-token <token>` | no | Opaque pagination token from the previous response. **Omit it for the first page.** |
|
||||
| `--no-detail` | no (default `false`) | Skip fetching each entry's full info object. By default enrichment is enabled: CHAT-type entries call `im.chats.batch_query`, need `im:chat:read`, and attach the object under the `detail` field. Pass `--no-detail` to skip the extra call and scope. |
|
||||
| `--as user` | yes | Server only accepts user_access_token for this API |
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ A message can have flags on both layers simultaneously:
|
||||
- Message layer: `(default, message)`
|
||||
- Feed layer: `(thread, feed)` or `(msg_thread, feed)` depending on chat type
|
||||
|
||||
**When no `--flag-type` is specified, the shortcut performs best-effort double-cancel**: the message-layer flag is always removed; the feed-layer flag is also removed when the chat type can be determined (otherwise a warning is printed on stderr and the feed layer is skipped). The server handles cancel requests for non-existent flags idempotently, so this is safe.
|
||||
**When no `--flag-type` is specified, the shortcut performs best-effort double-cancel**: it attempts the message-layer cancellation and also cancels the feed layer when the chat type can be determined. If the feed layer cannot be resolved, that layer remains unresolved in the per-layer result. Cancelling a non-existent flag is idempotent.
|
||||
|
||||
**Feed layer item_type is determined by chat_mode**:
|
||||
- Topic-style chat (`chat_mode=topic`) → `item_type=thread`
|
||||
@@ -63,5 +63,7 @@ If you have message content but not the message ID:
|
||||
|
||||
```bash
|
||||
# Search by message content to find message_id
|
||||
lark-cli im +messages-search --as user --query "message content here" -q '.data.items[0].message_id'
|
||||
lark-cli im +messages-search --as user --query "message content here"
|
||||
```
|
||||
|
||||
Read the chosen result's `message_id` from the structured output before cancelling it.
|
||||
|
||||
@@ -6,9 +6,7 @@ This skill maps to shortcut: `lark-cli im +flag-list`. Underlying API: `GET /ope
|
||||
|
||||
## Sorting Rules (Important)
|
||||
|
||||
The API returns data sorted by `update_time` in **ascending order**, meaning **oldest first, newest last**. When `has_more=true`, continue pagination until `has_more=false`; only then is the last item in the merged result authoritative as the newest flag. If pagination stops while `has_more=true`, the last item is only the newest observed flag.
|
||||
|
||||
`--page-all` enables automatic pagination but is still capped by `--page-limit`. The default cap is 20 pages; **20 is not the hard maximum**. Set `--page-limit` between 1 and 1000 when a larger scan is required. A response with `has_more=true` is incomplete, even when `flag_items` is empty; increase the limit or resume from the returned `page_token` before reporting an authoritative latest item or count.
|
||||
The API returns data sorted by `update_time` in **ascending order**, meaning **oldest first, newest last**. When the result is incomplete, you cannot simply take the first page's items as the latest flags. Inspect this concrete command's `--help` for full-read controls, then take the last item only after the result reports complete.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -16,33 +14,14 @@ The API returns data sorted by `update_time` in **ascending order**, meaning **o
|
||||
# Fetch first page (default page-size=50)
|
||||
lark-cli im +flag-list --as user
|
||||
|
||||
# Manual pagination with custom page size
|
||||
lark-cli im +flag-list --as user --page-size 30 --page-token <page_token>
|
||||
|
||||
# Auto-paginate, capped at the default 20 pages
|
||||
lark-cli im +flag-list --as user --page-all
|
||||
|
||||
# Auto-paginate + get the latest flag
|
||||
lark-cli im +flag-list --as user --page-all -q '.data.flag_items[-1]'
|
||||
|
||||
# Auto-paginate + get only item_id list
|
||||
lark-cli im +flag-list --as user --page-all -q '.data.flag_items[].item_id'
|
||||
|
||||
# Disable auto-enrichment of message content (enabled by default)
|
||||
lark-cli im +flag-list --as user --page-all --enrich-feed-thread=false
|
||||
|
||||
# Use the largest supported page limit for a broader scan
|
||||
lark-cli im +flag-list --as user --page-all --page-limit 1000
|
||||
lark-cli im +flag-list --as user --enrich-feed-thread=false
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|------|------|------|
|
||||
| `--page-size <n>` | 50 | Range 1-50 (server max is 50) |
|
||||
| `--page-token <token>` | empty | Pagination token from previous page; empty string must still be provided |
|
||||
| `--page-all` | false | Auto-paginate and merge results, capped by `--page-limit` |
|
||||
| `--page-limit <n>` | 20 | Max pages in `--page-all` mode; configurable range 1-1000 (20 is only the default) |
|
||||
| `--enrich-feed-thread` | true | Auto-enrich feed-layer thread entries with message content (calls `im.messages.mget`) |
|
||||
| `--as user` | Required | Currently only supports user identity |
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Search Feishu messages across conversations. This shortcut automatically performs a multi-step workflow: search for message IDs, batch fetch message details, then enrich the results with chat context.
|
||||
|
||||
By default each result message also carries a `reactions` block (counts + details from `im.reactions.batch_query`) when the server has reactions for it, and `update_time` for messages that were actually edited. With `--page-all`, every page is enriched; pass `--no-reactions` to skip the extra round-trip. See [message enrichment](lark-im-message-enrichment.md) for the full contract.
|
||||
By default each result message also carries a `reactions` block (counts + details from `im.reactions.batch_query`) when the server has reactions for it, and `update_time` for messages that were actually edited. Every fetched page is enriched; pass `--no-reactions` to skip the extra round-trip. See [message enrichment](lark-im-message-enrichment.md) for the full contract.
|
||||
|
||||
> **User identity only** (`--as user`). Bot identity is not supported.
|
||||
|
||||
@@ -51,15 +51,6 @@ lark-cli im +messages-search --query "test" --format pretty
|
||||
lark-cli im +messages-search --query "test" --format table
|
||||
lark-cli im +messages-search --query "test" --format csv
|
||||
|
||||
# Pagination
|
||||
lark-cli im +messages-search --query "test" --page-token <PAGE_TOKEN>
|
||||
|
||||
# Auto-pagination across multiple pages
|
||||
lark-cli im +messages-search --query "test" --page-all --format json
|
||||
|
||||
# Auto-pagination with an explicit page cap
|
||||
lark-cli im +messages-search --query "test" --page-limit 5 --format json
|
||||
|
||||
# Preview the request without executing it
|
||||
lark-cli im +messages-search --query "test" --dry-run
|
||||
```
|
||||
@@ -79,10 +70,6 @@ lark-cli im +messages-search --query "test" --dry-run
|
||||
| `--at-chatter-ids <ids>` | No | Filter by @mentioned user open_ids, comma-separated (`ou_xxx,ou_yyy`). Matched results also include messages that `@all` |
|
||||
| `--start <time>` | No | Start time with local timezone offset required (e.g. `2026-03-24T00:00:00+08:00`) |
|
||||
| `--end <time>` | No | End time with local timezone offset required (e.g. `2026-03-25T23:59:59+08:00`) |
|
||||
| `--page-size <n>` | No | Page size (default 20, range 1-50) |
|
||||
| `--page-token <token>` | No | Pagination token for the next page |
|
||||
| `--page-all` | No | Automatically paginate through all result pages (up to 40 pages) |
|
||||
| `--page-limit <n>` | No | Max pages to fetch when auto-pagination is enabled (default 20, max 40). Setting it explicitly also enables auto-pagination |
|
||||
| `--format <fmt>` | No | Output format: `json` (default) / `pretty` / `table` / `ndjson` / `csv` |
|
||||
| `--as <identity>` | No | Identity type (defaults to and only supports `user`) |
|
||||
| `--dry-run` | No | Print the request only, do not execute it |
|
||||
@@ -101,7 +88,7 @@ The shortcut automatically performs:
|
||||
2. The **mget API** fetches full message content for those message IDs in batch
|
||||
3. Chat context lookup is fetched in batch and attached to each message
|
||||
|
||||
The user does not need to manage the orchestration manually. When search results span multiple pages, the shortcut can also paginate automatically with `--page-all` or `--page-limit`.
|
||||
The user does not need to manage the search, detail fetch, or chat-context lookup manually.
|
||||
|
||||
### 3. Conversation context is enriched automatically
|
||||
|
||||
@@ -130,15 +117,7 @@ Each message in JSON output contains:
|
||||
| `mentions` | Array of @mentions in the message; each item contains `{id, key, name}`. Present only when the message contains @mentions |
|
||||
| `thread_id` | Thread ID (`omt_xxx`) if the message has replies in a thread. Present only when replies exist |
|
||||
|
||||
### 4. Pagination behavior
|
||||
|
||||
- Default behavior is still **single-page**.
|
||||
- `--page-token` is the manual continuation mechanism when you already have a token from a previous response.
|
||||
- `--page-all` enables auto-pagination and uses a default cap of **40 pages**.
|
||||
- `--page-limit <n>` enables auto-pagination with an explicit cap. If you pass `--page-limit` without `--page-all`, auto-pagination is still enabled.
|
||||
- When auto-pagination stops because of the configured page cap, the response still includes the last `has_more` / `page_token` so you can continue manually.
|
||||
|
||||
### 5. Search results contain follow-up clues
|
||||
### 4. Search results contain follow-up clues
|
||||
|
||||
In JSON output, each message includes `chat_id` and `thread_id` (when present). Use them with other shortcuts for deeper inspection:
|
||||
|
||||
@@ -166,7 +145,7 @@ This guidance applies only when using user identity. `im +messages-search` is us
|
||||
|
||||
```bash
|
||||
# Review recent bot interactions without forcing a keyword
|
||||
lark-cli im +messages-search --query "" --sender-type bot --start "<YYYY-MM-DDT00:00:00+08:00>" --end "<YYYY-MM-DDT23:59:59+08:00>" --page-all --format json
|
||||
lark-cli im +messages-search --query "" --sender-type bot --start "<YYYY-MM-DDT00:00:00+08:00>" --end "<YYYY-MM-DDT23:59:59+08:00>" --format json
|
||||
```
|
||||
|
||||
Replace the time placeholders at execution time. For example, "最近一周" means computing the start date and end date from the current day before running the command; do not copy date literals from this reference into answers for relative requests.
|
||||
@@ -189,33 +168,26 @@ lark-cli im +messages-search --query "keyword" --chat-id <chat_id>
|
||||
|
||||
## Work Summary / Report Generation
|
||||
|
||||
When the user asks you to summarize work, generate a weekly report, or compile activity from chat messages, you should **paginate through all available results** to get a complete picture. A single page is rarely enough for thorough summarization.
|
||||
When the user asks you to summarize work, generate a weekly report, or compile activity from chat messages, require a complete result before summarizing. A partial result is rarely enough for a thorough summary.
|
||||
|
||||
### Strategy
|
||||
|
||||
1. **Start with targeted filters** — use `--chat-id`, `--sender`, `--start`, `--end` to narrow the scope as much as possible before paginating.
|
||||
2. **Prefer auto-pagination** — for report and summary tasks, use `--page-all --format json` by default. If you need a bounded run, use `--page-limit <n> --format json`.
|
||||
3. **Accumulate before summarizing** — collect all pages of messages first, then analyze and summarize. Do not summarize after the first page alone — you will miss important context.
|
||||
4. **Fall back to `--page-token` when resuming** — if auto-pagination hits the configured page cap and the response still has `has_more=true`, continue from the returned `page_token`.
|
||||
5. **Use `--format json`** — JSON output includes `has_more` and `page_token` fields needed for pagination. `pretty` and `table` formats are useful for reading but not for resuming pagination reliably.
|
||||
1. **Start with targeted filters** — use `--chat-id`, `--sender`, `--start`, `--end` to narrow the scope.
|
||||
2. **Inspect the leaf help before execution** — the concrete command's `--help` owns full-read controls and result guarantees.
|
||||
3. **Accumulate before summarizing** — fetch a complete result, then analyze and summarize. Do not summarize a partial response.
|
||||
4. **Use structured output** — JSON preserves message IDs and completion metadata needed to verify the evidence set.
|
||||
|
||||
### Example: Weekly work summary from a project chat
|
||||
|
||||
```bash
|
||||
# Preferred: fetch automatically
|
||||
lark-cli im +messages-search --query "" --chat-id oc_xxx --sender ou_me --start "2026-03-18T00:00:00+08:00" --end "2026-03-25T23:59:59+08:00" --page-size 50 --page-all --format json
|
||||
|
||||
# If you need to cap the run explicitly
|
||||
lark-cli im +messages-search --query "" --chat-id oc_xxx --sender ou_me --start "2026-03-18T00:00:00+08:00" --end "2026-03-25T23:59:59+08:00" --page-size 50 --page-limit 5 --format json
|
||||
|
||||
# If the bounded run still returns has_more=true, continue manually
|
||||
lark-cli im +messages-search --query "" --chat-id oc_xxx --sender ou_me --start "2026-03-18T00:00:00+08:00" --end "2026-03-25T23:59:59+08:00" --page-size 50 --page-token <token_from_previous_run> --format json
|
||||
# Inspect full-read controls first, then execute the filtered search.
|
||||
lark-cli im +messages-search --help
|
||||
lark-cli im +messages-search --query "" --chat-id oc_xxx --sender ou_me --start "2026-03-18T00:00:00+08:00" --end "2026-03-25T23:59:59+08:00" --format json
|
||||
```
|
||||
|
||||
### Key points
|
||||
|
||||
- **Always paginate exhaustively** for summary tasks. A single page of 20-50 messages is usually insufficient for a meaningful work summary.
|
||||
- Prefer `--page-all`; use `--page-limit` only when you need to bound runtime or output volume.
|
||||
- **Require complete evidence** for summary tasks. A partial response is insufficient for a meaningful work summary.
|
||||
- If the user does not specify a time range, default to the current week (Monday to today) for weekly reports, or ask for clarification.
|
||||
- When summarizing, group messages by topic/thread rather than by chronological order for better readability.
|
||||
|
||||
|
||||
@@ -177,6 +177,8 @@ The response shape is similar to `create`, and usually echoes:
|
||||
|
||||
Query reactions for multiple messages in one request.
|
||||
|
||||
`batch_query` covers only the reaction fragments returned for each query. When complete reactions for one message are required, use `im reactions list` and exhaust its pagination instead of treating an empty or partial batch fragment as complete.
|
||||
|
||||
```bash
|
||||
lark-cli im reactions batch_query \
|
||||
--params '{"user_id_type":"open_id"}' \
|
||||
|
||||
@@ -17,12 +17,6 @@ lark-cli im +threads-messages-list --thread omt_xxx
|
||||
# Reverse chronological order (latest first)
|
||||
lark-cli im +threads-messages-list --thread omt_xxx --order desc
|
||||
|
||||
# Control page size
|
||||
lark-cli im +threads-messages-list --thread omt_xxx --page-size 20
|
||||
|
||||
# Pagination
|
||||
lark-cli im +threads-messages-list --thread omt_xxx --page-token <PAGE_TOKEN>
|
||||
|
||||
# Output format options
|
||||
lark-cli im +threads-messages-list --thread omt_xxx --format pretty
|
||||
lark-cli im +threads-messages-list --thread omt_xxx --format table
|
||||
@@ -43,8 +37,6 @@ lark-cli im +threads-messages-list --thread omt_xxx --dry-run
|
||||
| `--no-reactions` | No | Skip auto-fetching the `reactions` block |
|
||||
| `--download-resources` | No | Download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block. Off by default |
|
||||
| `--order <order>` | No | Sort order: `asc` (default) / `desc` |
|
||||
| `--page-size <n>` | No | Number of items per page (default 50, range 1-500) |
|
||||
| `--page-token <token>` | No | Pagination token for the next page |
|
||||
| `--format <fmt>` | No | Output format: `json` (default) / `pretty` / `table` / `ndjson` / `csv` |
|
||||
| `--as <identity>` | No | Identity type: `user` (default) / `bot` |
|
||||
| `--dry-run` | No | Print the request only, do not execute it |
|
||||
@@ -57,20 +49,7 @@ lark-cli im +threads-messages-list --thread omt_xxx --dry-run
|
||||
|
||||
### 2. No time filtering support
|
||||
|
||||
Thread messages do not support `start_time` / `end_time` filtering because of Feishu API limitations. Use pagination and sort order to control the scope.
|
||||
|
||||
### 3. Pagination (`has_more` / `page_token`)
|
||||
|
||||
- When the result includes `has_more=true`, use `page_token` to fetch the next page
|
||||
- If you need the complete thread, keep paginating; if you only need an overview, the first page is often enough
|
||||
|
||||
### 4. Recommended expansion strategy
|
||||
|
||||
| Scenario | Recommended Parameters |
|
||||
|------|---------|
|
||||
| Quickly inspect recent replies | `--order desc --page-size 10` |
|
||||
| Read the full thread in chronological order | `--order asc --page-size 50`, then paginate as needed |
|
||||
| Just confirm whether replies exist | `--order desc --page-size 1` |
|
||||
Thread messages do not support `start_time` / `end_time` filtering because of Feishu API limitations. Use sort order to control ordering, and inspect this concrete command's `--help` when the task requires the complete thread.
|
||||
|
||||
## Usage Scenarios
|
||||
|
||||
@@ -84,16 +63,6 @@ lark-cli im +chat-messages-list --chat-id oc_xxx
|
||||
lark-cli im +threads-messages-list --thread omt_xxx
|
||||
```
|
||||
|
||||
### Scenario 2: Paginate through a long thread
|
||||
|
||||
```bash
|
||||
# First page
|
||||
lark-cli im +threads-messages-list --thread omt_xxx
|
||||
|
||||
# If has_more=true is returned, continue with page_token
|
||||
lark-cli im +threads-messages-list --thread omt_xxx --page-token <PAGE_TOKEN>
|
||||
```
|
||||
|
||||
## Resource Rendering
|
||||
|
||||
Thread replies are rendered into human-readable text. Image messages appear as placeholders such as ``; by default resource binaries are **not** downloaded.
|
||||
|
||||
Reference in New Issue
Block a user