mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Compare commits
1 Commits
feat/lark-
...
feat/outpu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3708c2e78 |
@@ -19,7 +19,8 @@ import (
|
||||
// Complex values (maps, arrays) are printed as indented JSON with Go's default
|
||||
// HTML escaping (<, >, & → <, >, &).
|
||||
func JqFilter(w io.Writer, data interface{}, expr string) error {
|
||||
return jqFilter(w, data, expr, false)
|
||||
_, err := jqFilter(w, data, expr, false)
|
||||
return err
|
||||
}
|
||||
|
||||
// JqFilterRaw is like JqFilter but disables HTML escaping when re-marshaling
|
||||
@@ -27,17 +28,30 @@ func JqFilter(w io.Writer, data interface{}, expr string) error {
|
||||
// carries XML/HTML content that must survive --jq '.data.document' style
|
||||
// projections without getting mangled into < escapes.
|
||||
func JqFilterRaw(w io.Writer, data interface{}, expr string) error {
|
||||
_, err := jqFilter(w, data, expr, true)
|
||||
return err
|
||||
}
|
||||
|
||||
// JqFilterCount applies expr to data, writes results to w, and returns the
|
||||
// number of result values produced (0 = empty result; drives the on-demand
|
||||
// full-only miss hint).
|
||||
func JqFilterCount(w io.Writer, data interface{}, expr string) (int, error) {
|
||||
return jqFilter(w, data, expr, false)
|
||||
}
|
||||
|
||||
// JqFilterRawCount is JqFilterCount with HTML escaping disabled (see JqFilterRaw).
|
||||
func JqFilterRawCount(w io.Writer, data interface{}, expr string) (int, error) {
|
||||
return jqFilter(w, data, expr, true)
|
||||
}
|
||||
|
||||
func jqFilter(w io.Writer, data interface{}, expr string, raw bool) error {
|
||||
func jqFilter(w io.Writer, data interface{}, expr string, raw bool) (int, error) {
|
||||
query, err := gojq.Parse(expr)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid jq expression: %s", err).WithCause(err)
|
||||
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid jq expression: %s", err).WithCause(err)
|
||||
}
|
||||
code, err := gojq.Compile(query)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid jq expression: %s", err).WithCause(err)
|
||||
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid jq expression: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Normalize data through toGeneric so typed structs become map[string]any.
|
||||
@@ -45,6 +59,7 @@ func jqFilter(w io.Writer, data interface{}, expr string, raw bool) error {
|
||||
// Convert json.Number values to gojq-compatible types.
|
||||
normalized = convertNumbers(normalized)
|
||||
|
||||
var count int
|
||||
iter := code.Run(normalized)
|
||||
for {
|
||||
v, ok := iter.Next()
|
||||
@@ -52,13 +67,14 @@ func jqFilter(w io.Writer, data interface{}, expr string, raw bool) error {
|
||||
break
|
||||
}
|
||||
if err, isErr := v.(error); isErr {
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "jq error: %s", err).WithCause(err)
|
||||
return count, errs.NewAPIError(errs.SubtypeUnknown, "jq error: %s", err).WithCause(err)
|
||||
}
|
||||
if err := writeJqValue(w, v, raw); err != nil {
|
||||
return err
|
||||
return count, err
|
||||
}
|
||||
count++
|
||||
}
|
||||
return nil
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ValidateJqFlags checks --jq flag compatibility with --output and --format flags,
|
||||
|
||||
@@ -61,6 +61,10 @@ type Property struct {
|
||||
Required []string `json:"required,omitempty"`
|
||||
Properties *OrderedProps `json:"properties,omitempty"`
|
||||
Items *Property `json:"items,omitempty"`
|
||||
// Projected marks a response field for the schema-curated default view: the
|
||||
// output projection engine keeps it by default and hides unmarked fields
|
||||
// (recoverable via --full). Carries no security/permission meaning.
|
||||
Projected bool `json:"projected,omitempty"`
|
||||
}
|
||||
|
||||
// Meta is the Lark-specific extension namespace.
|
||||
|
||||
177
shortcuts/common/projection.go
Normal file
177
shortcuts/common/projection.go
Normal file
@@ -0,0 +1,177 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
)
|
||||
|
||||
// ProjectBySchema trims data to the schema-curated default view.
|
||||
//
|
||||
// full==true / props==nil → passthrough (fail-open, never drops information).
|
||||
// Otherwise the input is normalized to canonical JSON values first, then the
|
||||
// core keeps only fields whose Projected==true (a field shows iff p.Projected
|
||||
// || full). It rebuilds maps/slices (never mutates input), skips missing keys
|
||||
// (no null padding), and carries zero business knowledge.
|
||||
//
|
||||
// Normalizing first is what makes this robust by construction: whatever native
|
||||
// Go type a command emits — a typed slice ([]map[string]interface{}), a struct,
|
||||
// a typed map, anything JSON-serializable — collapses to canonical
|
||||
// map[string]interface{} / []interface{} / scalars, so the core's finite switch
|
||||
// is COMPLETE. No command output, present or future, can fall through unprojected.
|
||||
func ProjectBySchema(data interface{}, props *schema.OrderedProps, full bool) interface{} {
|
||||
if full || props == nil {
|
||||
return data
|
||||
}
|
||||
return projectCanonical(canonicalize(data), props)
|
||||
}
|
||||
|
||||
// projectCanonical projects already-canonical JSON data. props==nil is a kept
|
||||
// leaf with no child schema: pass its value through verbatim.
|
||||
func projectCanonical(data interface{}, props *schema.OrderedProps) interface{} {
|
||||
if props == nil {
|
||||
return data
|
||||
}
|
||||
switch v := data.(type) {
|
||||
case map[string]interface{}:
|
||||
out := map[string]interface{}{}
|
||||
for _, key := range props.Order {
|
||||
p := props.Map[key]
|
||||
if !p.Projected {
|
||||
continue
|
||||
}
|
||||
if val, ok := v[key]; ok {
|
||||
out[key] = projectCanonical(val, childProps(p))
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []interface{}:
|
||||
out := make([]interface{}, len(v))
|
||||
for i := range v {
|
||||
out[i] = projectCanonical(v[i], props)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
// Canonical scalar (string / float64 / bool / nil): a leaf value, kept verbatim.
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
// canonicalize converts any JSON-serializable value to canonical JSON values
|
||||
// (map[string]interface{} / []interface{} / scalars) via a marshal round-trip,
|
||||
// decoupling projection from a command's concrete Go types. Fail-open: input
|
||||
// that does not serialize (never the case for command output) is returned as-is.
|
||||
func canonicalize(data interface{}) interface{} {
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return data
|
||||
}
|
||||
var out interface{}
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
return data
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// childProps returns the schema to recurse with for a field's value. Array
|
||||
// fields keep their element schema in Items.Properties; object fields use
|
||||
// Properties directly. Without this, an array-typed field would recurse with
|
||||
// nil props and pass its elements through unprojected.
|
||||
func childProps(p schema.Property) *schema.OrderedProps {
|
||||
if p.Items != nil && p.Items.Properties != nil {
|
||||
return p.Items.Properties
|
||||
}
|
||||
return p.Properties
|
||||
}
|
||||
|
||||
// droppedFieldNames returns the set of field names that projection removes from
|
||||
// data under props (present in data but not declared projected, at any depth).
|
||||
//
|
||||
// Unlike a static schema scan, it diffs the actual response against the schema:
|
||||
// with positive polarity the hidden fields are simply absent from the
|
||||
// OutputSchema (not marked "false"), so the only way to name a trimmed field is
|
||||
// to see it in the real data and find it missing from the projected set. Drives
|
||||
// the on-demand jq-miss hint. Names only (no values) — never echoes user input
|
||||
// or upstream data.
|
||||
func droppedFieldNames(data interface{}, props *schema.OrderedProps) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
var walk func(d interface{}, p *schema.OrderedProps)
|
||||
walk = func(d interface{}, p *schema.OrderedProps) {
|
||||
switch v := d.(type) {
|
||||
case map[string]interface{}:
|
||||
for key := range v {
|
||||
var pr schema.Property
|
||||
ok := false
|
||||
if p != nil {
|
||||
pr, ok = p.Map[key]
|
||||
}
|
||||
if !ok || !pr.Projected {
|
||||
out[key] = true
|
||||
continue
|
||||
}
|
||||
// Kept field: recurse only when it has a child schema (object /
|
||||
// array element); a projected leaf keeps its whole value, so
|
||||
// nothing inside it is dropped.
|
||||
if cp := childProps(pr); cp != nil {
|
||||
walk(v[key], cp)
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for _, e := range v {
|
||||
walk(e, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(canonicalize(data), props) // canonical input → the two-case walk is complete
|
||||
return out
|
||||
}
|
||||
|
||||
// anyProjected reports whether any field in the tree carries Projected==true.
|
||||
// Used as a guard: a Projectable command whose OutputSchema marks nothing is
|
||||
// treated as pass-through rather than trimming everything away.
|
||||
func anyProjected(props *schema.OrderedProps) bool {
|
||||
if props == nil {
|
||||
return false
|
||||
}
|
||||
for _, key := range props.Order {
|
||||
p := props.Map[key]
|
||||
if p.Projected {
|
||||
return true
|
||||
}
|
||||
if anyProjected(p.Properties) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── OutputSchema builders ──
|
||||
//
|
||||
// These let a shortcut declare its OutputSchema inline in Go, shaped to match
|
||||
// the data it emits. A field declared here shows in the default (projected)
|
||||
// view; anything not declared is hidden until --full.
|
||||
|
||||
// KeepFields returns an OrderedProps with each name marked projected as a leaf
|
||||
// field — the common case for a flat group of scalar fields kept by default.
|
||||
func KeepFields(names ...string) *schema.OrderedProps {
|
||||
props := &schema.OrderedProps{}
|
||||
for _, n := range names {
|
||||
props.Set(n, schema.Property{Projected: true})
|
||||
}
|
||||
return props
|
||||
}
|
||||
|
||||
// ArrayOf returns a projected array-typed property whose elements are projected
|
||||
// by elem. Use for a default-shown list field: root.Set("chats", ArrayOf(chat)).
|
||||
func ArrayOf(elem *schema.OrderedProps) schema.Property {
|
||||
return schema.Property{Projected: true, Items: &schema.Property{Properties: elem}}
|
||||
}
|
||||
|
||||
// ObjectOf returns a projected nested-object property whose sub-fields are
|
||||
// projected by child. Use for a default-shown object field.
|
||||
func ObjectOf(child *schema.OrderedProps) schema.Property {
|
||||
return schema.Property{Projected: true, Properties: child}
|
||||
}
|
||||
374
shortcuts/common/projection_test.go
Normal file
374
shortcuts/common/projection_test.go
Normal file
@@ -0,0 +1,374 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
)
|
||||
|
||||
func props(marked ...string) *schema.OrderedProps {
|
||||
op := &schema.OrderedProps{}
|
||||
set := map[string]bool{}
|
||||
for _, m := range marked {
|
||||
set[m] = true
|
||||
}
|
||||
for _, k := range []string{"name", "avatar", "tenant_key"} {
|
||||
op.Set(k, schema.Property{Type: "string", Projected: set[k]})
|
||||
}
|
||||
return op
|
||||
}
|
||||
|
||||
func TestProjectBySchema_PositivePolarity(t *testing.T) {
|
||||
in := map[string]interface{}{"name": "g", "avatar": "u", "tenant_key": "t"}
|
||||
out := ProjectBySchema(in, props("name"), false)
|
||||
want := map[string]interface{}{"name": "g"}
|
||||
if !reflect.DeepEqual(out, want) {
|
||||
t.Fatalf("got %v want %v", out, want)
|
||||
}
|
||||
if len(in) != 3 {
|
||||
t.Fatalf("input map mutated: %v", in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectBySchema_FailOpen(t *testing.T) {
|
||||
in := map[string]interface{}{"name": "g", "avatar": "u"}
|
||||
if got := ProjectBySchema(in, props("name"), true); !reflect.DeepEqual(got, in) {
|
||||
t.Fatalf("full=true should passthrough, got %v", got)
|
||||
}
|
||||
if got := ProjectBySchema(in, nil, false); !reflect.DeepEqual(got, in) {
|
||||
t.Fatalf("nil props should passthrough, got %v", got)
|
||||
}
|
||||
if got := ProjectBySchema("scalar", props("name"), false); got != "scalar" {
|
||||
t.Fatalf("non-map should passthrough, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectBySchema_Array(t *testing.T) {
|
||||
in := []interface{}{
|
||||
map[string]interface{}{"name": "a", "avatar": "x"},
|
||||
map[string]interface{}{"name": "b", "avatar": "y"},
|
||||
}
|
||||
out := ProjectBySchema(in, props("name"), false).([]interface{})
|
||||
for i, it := range out {
|
||||
m := it.(map[string]interface{})
|
||||
if _, ok := m["avatar"]; ok {
|
||||
t.Fatalf("elem %d kept avatar", i)
|
||||
}
|
||||
if m["name"] == nil {
|
||||
t.Fatalf("elem %d dropped projected name", i)
|
||||
}
|
||||
}
|
||||
// original input slice must be untouched (no in-place mutation)
|
||||
for i, it := range in {
|
||||
m := it.(map[string]interface{})
|
||||
if _, ok := m["avatar"]; !ok {
|
||||
t.Fatalf("input elem %d was mutated (avatar removed)", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectBySchema_Nested(t *testing.T) {
|
||||
child := &schema.OrderedProps{}
|
||||
child.Set("chat_id", schema.Property{Type: "string", Projected: true})
|
||||
child.Set("avatar", schema.Property{Type: "string"})
|
||||
root := &schema.OrderedProps{}
|
||||
root.Set("detail", schema.Property{Type: "object", Projected: true, Properties: child})
|
||||
|
||||
in := map[string]interface{}{"detail": map[string]interface{}{"chat_id": "oc", "avatar": "u"}}
|
||||
out := ProjectBySchema(in, root, false).(map[string]interface{})
|
||||
d := out["detail"].(map[string]interface{})
|
||||
if _, ok := d["avatar"]; ok {
|
||||
t.Fatalf("nested avatar should be dropped")
|
||||
}
|
||||
if d["chat_id"] != "oc" {
|
||||
t.Fatalf("nested chat_id should be kept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectBySchema_ArrayFieldInMap(t *testing.T) {
|
||||
// Mirrors the real list shape: data = {chats: [ {chat_id, avatar}, ... ], page_token}.
|
||||
// The element schema lives in the "chats" field's Items.Properties.
|
||||
elem := &schema.OrderedProps{}
|
||||
elem.Set("chat_id", schema.Property{Type: "string", Projected: true})
|
||||
elem.Set("avatar", schema.Property{Type: "string"}) // full-only
|
||||
root := &schema.OrderedProps{}
|
||||
root.Set("chats", schema.Property{
|
||||
Type: "array",
|
||||
Projected: true,
|
||||
Items: &schema.Property{Type: "object", Properties: elem},
|
||||
})
|
||||
root.Set("page_token", schema.Property{Type: "string", Projected: true})
|
||||
root.Set("has_more", schema.Property{Type: "boolean"}) // unmarked → dropped
|
||||
|
||||
in := map[string]interface{}{
|
||||
"chats": []interface{}{map[string]interface{}{"chat_id": "oc", "avatar": "u"}},
|
||||
"page_token": "pt",
|
||||
"has_more": true,
|
||||
}
|
||||
out := ProjectBySchema(in, root, false).(map[string]interface{})
|
||||
if out["page_token"] != "pt" {
|
||||
t.Fatalf("projected pagination field page_token should survive")
|
||||
}
|
||||
if _, ok := out["has_more"]; ok {
|
||||
t.Fatalf("unmarked has_more should be dropped")
|
||||
}
|
||||
chats := out["chats"].([]interface{})
|
||||
c0 := chats[0].(map[string]interface{})
|
||||
if c0["chat_id"] != "oc" {
|
||||
t.Fatalf("element projected field chat_id should survive")
|
||||
}
|
||||
if _, ok := c0["avatar"]; ok {
|
||||
t.Fatalf("element full-only avatar should be dropped (array element must be projected via Items.Properties)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectBySchema_MissingFieldSkipped(t *testing.T) {
|
||||
in := map[string]interface{}{"name": "g"} // avatar missing
|
||||
out := ProjectBySchema(in, props("name", "avatar"), false).(map[string]interface{})
|
||||
if _, ok := out["avatar"]; ok {
|
||||
t.Fatalf("missing field should not be added as null")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDroppedFieldNames(t *testing.T) {
|
||||
// props("name") declares only "name" projected; avatar/tenant_key are absent
|
||||
// from the schema (positive polarity), so the engine trims them. droppedFieldNames
|
||||
// must find them by diffing the real data against the schema.
|
||||
op := props("name")
|
||||
data := map[string]interface{}{"name": "g", "avatar": "x", "tenant_key": "t"}
|
||||
got := droppedFieldNames(data, op)
|
||||
|
||||
if !got["avatar"] || !got["tenant_key"] {
|
||||
t.Errorf("avatar/tenant_key should be reported dropped, got %v", got)
|
||||
}
|
||||
if got["name"] {
|
||||
t.Error("projected field name must not be reported dropped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDroppedFieldNames_Nested(t *testing.T) {
|
||||
// root keeps "detail" (object) with only "chat_id" projected; the response's
|
||||
// detail.avatar and root-level total are not declared, so both are dropped.
|
||||
child := &schema.OrderedProps{}
|
||||
child.Set("chat_id", schema.Property{Type: "string", Projected: true})
|
||||
root := &schema.OrderedProps{}
|
||||
root.Set("detail", schema.Property{Type: "object", Projected: true, Properties: child})
|
||||
|
||||
data := map[string]interface{}{
|
||||
"detail": map[string]interface{}{"chat_id": "oc_1", "avatar": "x"},
|
||||
"total": 5,
|
||||
}
|
||||
got := droppedFieldNames(data, root)
|
||||
|
||||
if !got["avatar"] {
|
||||
t.Error("nested detail.avatar should be reported dropped")
|
||||
}
|
||||
if !got["total"] {
|
||||
t.Error("undeclared root-level total should be reported dropped")
|
||||
}
|
||||
if got["detail"] || got["chat_id"] {
|
||||
t.Errorf("projected detail/chat_id must not be dropped, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyProjected(t *testing.T) {
|
||||
none := &schema.OrderedProps{}
|
||||
none.Set("a", schema.Property{})
|
||||
if anyProjected(none) {
|
||||
t.Fatalf("no marks should report false")
|
||||
}
|
||||
some := &schema.OrderedProps{}
|
||||
some.Set("a", schema.Property{})
|
||||
some.Set("b", schema.Property{Projected: true})
|
||||
if !anyProjected(some) {
|
||||
t.Fatalf("a mark should report true")
|
||||
}
|
||||
child := &schema.OrderedProps{}
|
||||
child.Set("c", schema.Property{Projected: true})
|
||||
nested := &schema.OrderedProps{}
|
||||
nested.Set("obj", schema.Property{Type: "object", Properties: child})
|
||||
if !anyProjected(nested) {
|
||||
t.Fatalf("nested mark should report true")
|
||||
}
|
||||
if anyProjected(nil) {
|
||||
t.Fatalf("nil should report false")
|
||||
}
|
||||
}
|
||||
|
||||
// ── OutputSchema builder tests ──
|
||||
|
||||
// TestKeepFields verifies KeepFields marks every named field projected, as a
|
||||
// leaf (no Items/Properties), preserving declaration order.
|
||||
func TestKeepFields(t *testing.T) {
|
||||
op := KeepFields("chat_id", "name", "owner_id")
|
||||
if want := []string{"chat_id", "name", "owner_id"}; !reflect.DeepEqual(op.Order, want) {
|
||||
t.Fatalf("Order = %v, want %v", op.Order, want)
|
||||
}
|
||||
for _, k := range op.Order {
|
||||
p := op.Map[k]
|
||||
if !p.Projected {
|
||||
t.Errorf("field %q should be projected", k)
|
||||
}
|
||||
if p.Items != nil || p.Properties != nil {
|
||||
t.Errorf("field %q should be a leaf (no Items/Properties)", k)
|
||||
}
|
||||
}
|
||||
if len(KeepFields().Order) != 0 {
|
||||
t.Fatalf("KeepFields() with no names should yield empty props")
|
||||
}
|
||||
}
|
||||
|
||||
// TestArrayOf verifies ArrayOf returns a projected property whose elements are
|
||||
// projected by the supplied element schema (carried in Items.Properties), and
|
||||
// that childProps recurses into that element schema.
|
||||
func TestArrayOf(t *testing.T) {
|
||||
elem := KeepFields("chat_id", "name")
|
||||
p := ArrayOf(elem)
|
||||
if !p.Projected {
|
||||
t.Fatalf("ArrayOf should mark the array field projected")
|
||||
}
|
||||
if p.Items == nil || p.Items.Properties != elem {
|
||||
t.Fatalf("ArrayOf should carry the element schema in Items.Properties")
|
||||
}
|
||||
if childProps(p) != elem {
|
||||
t.Fatalf("childProps should recurse into the array element schema")
|
||||
}
|
||||
}
|
||||
|
||||
// TestObjectOf verifies ObjectOf returns a projected object property whose
|
||||
// sub-fields are projected by the supplied child schema (carried in Properties),
|
||||
// and that childProps recurses into that child schema.
|
||||
func TestObjectOf(t *testing.T) {
|
||||
child := KeepFields("chat_id", "chat_mode")
|
||||
p := ObjectOf(child)
|
||||
if !p.Projected {
|
||||
t.Fatalf("ObjectOf should mark the object field projected")
|
||||
}
|
||||
if p.Properties != child {
|
||||
t.Fatalf("ObjectOf should carry the child schema in Properties")
|
||||
}
|
||||
if p.Items != nil {
|
||||
t.Fatalf("ObjectOf should not set Items")
|
||||
}
|
||||
if childProps(p) != child {
|
||||
t.Fatalf("childProps should recurse into the object child schema")
|
||||
}
|
||||
}
|
||||
|
||||
// chatListShapeSchema mirrors the +chat-list OutputSchema built with the
|
||||
// KeepFields/ArrayOf builders: root marks pagination + the chats wrapper; each
|
||||
// chat keeps a curated field set while avatar stays full-only.
|
||||
func chatListShapeSchema() *schema.OrderedProps {
|
||||
chat := KeepFields("chat_id", "name", "owner_id")
|
||||
root := KeepFields("has_more", "page_token")
|
||||
root.Set("chats", ArrayOf(chat))
|
||||
return root
|
||||
}
|
||||
|
||||
// TestProjectBySchema_ChatListShape is the integration check the task asks for:
|
||||
// against a chat-list-shaped map, projected fields (wrapper, pagination, the
|
||||
// curated chat fields) survive, unmarked fields (top-level total, per-chat
|
||||
// avatar) are trimmed, and --full passes the whole envelope through verbatim.
|
||||
func TestProjectBySchema_ChatListShape(t *testing.T) {
|
||||
envelope := func() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"chats": []interface{}{
|
||||
map[string]interface{}{
|
||||
"chat_id": "oc_1",
|
||||
"name": "Team",
|
||||
"owner_id": "ou_owner",
|
||||
"avatar": "http://img/1.png", // full-only
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "pt_next",
|
||||
"total": 1, // unmarked top-level key → trimmed by default
|
||||
}
|
||||
}
|
||||
|
||||
// Default (projected) view.
|
||||
out := ProjectBySchema(envelope(), chatListShapeSchema(), false).(map[string]interface{})
|
||||
if out["has_more"] != true || out["page_token"] != "pt_next" {
|
||||
t.Fatalf("projected pagination fields should survive, got %v", out)
|
||||
}
|
||||
if _, ok := out["total"]; ok {
|
||||
t.Fatalf("unmarked top-level total should be trimmed")
|
||||
}
|
||||
chats := out["chats"].([]interface{})
|
||||
c0 := chats[0].(map[string]interface{})
|
||||
for _, k := range []string{"chat_id", "name", "owner_id"} {
|
||||
if _, ok := c0[k]; !ok {
|
||||
t.Fatalf("projected chat field %q should survive", k)
|
||||
}
|
||||
}
|
||||
if _, ok := c0["avatar"]; ok {
|
||||
t.Fatalf("full-only chat field avatar should be trimmed by default")
|
||||
}
|
||||
|
||||
// --full passthrough: identical to the untouched envelope.
|
||||
full := ProjectBySchema(envelope(), chatListShapeSchema(), true)
|
||||
if !reflect.DeepEqual(full, envelope()) {
|
||||
t.Fatalf("--full should pass the whole envelope through verbatim, got %v", full)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectBySchema_TypedMapSlice guards the []map[string]interface{} case:
|
||||
// +chat-list re-collects API items into a typed []map[string]interface{} (not
|
||||
// []interface{}). Before the fix that slice fell through to the default branch
|
||||
// and was returned unprojected — avatar/tenant_key leaked into the default view.
|
||||
// Regression test for that real E2E failure.
|
||||
func TestProjectBySchema_TypedMapSlice(t *testing.T) {
|
||||
envelope := map[string]interface{}{
|
||||
"chats": []map[string]interface{}{ // typed slice, NOT []interface{}
|
||||
{"chat_id": "oc_1", "name": "Team", "owner_id": "ou_o", "avatar": "http://img"},
|
||||
},
|
||||
"has_more": true,
|
||||
}
|
||||
out := ProjectBySchema(envelope, chatListShapeSchema(), false).(map[string]interface{})
|
||||
chats, ok := out["chats"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("typed []map slice should be recursed into []interface{}, got %T", out["chats"])
|
||||
}
|
||||
c0 := chats[0].(map[string]interface{})
|
||||
if _, ok := c0["avatar"]; ok {
|
||||
t.Fatalf("full-only avatar must be trimmed from a []map[string]interface{} element, got %v", c0)
|
||||
}
|
||||
if c0["chat_id"] != "oc_1" || c0["name"] != "Team" {
|
||||
t.Fatalf("projected fields must survive, got %v", c0)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectBySchema_AnyGoType is the robustness proof: a command may emit any
|
||||
// JSON-serializable Go type — here a []struct, a shape the engine never special-
|
||||
// cases. Normalization collapses it to canonical JSON, so projection still trims
|
||||
// it. This is what makes the engine immune to "the next weird box".
|
||||
func TestProjectBySchema_AnyGoType(t *testing.T) {
|
||||
type chatStruct struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
Name string `json:"name"`
|
||||
Avatar string `json:"avatar"` // not declared projected → must be trimmed
|
||||
}
|
||||
root := KeepFields("has_more")
|
||||
root.Set("chats", ArrayOf(KeepFields("chat_id", "name")))
|
||||
|
||||
data := map[string]interface{}{
|
||||
"has_more": true,
|
||||
"chats": []chatStruct{{ChatID: "oc_1", Name: "Team", Avatar: "http://img"}},
|
||||
}
|
||||
out := ProjectBySchema(data, root, false).(map[string]interface{})
|
||||
chats, ok := out["chats"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("a []struct must normalize + recurse into []interface{}, got %T", out["chats"])
|
||||
}
|
||||
c0 := chats[0].(map[string]interface{})
|
||||
if _, ok := c0["avatar"]; ok {
|
||||
t.Fatalf("avatar must be trimmed even from a []struct, got %v", c0)
|
||||
}
|
||||
if c0["chat_id"] != "oc_1" || c0["name"] != "Team" {
|
||||
t.Fatalf("projected fields must survive, got %v", c0)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -30,6 +31,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
@@ -50,6 +52,8 @@ type RuntimeContext struct {
|
||||
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
|
||||
larkSDK *lark.Client // eagerly initialized in mountDeclarative
|
||||
stdinConsumed bool // set when an Input flag has consumed stdin (`-`); guards against a second flag also using `-` within the same call
|
||||
projectable bool // set when Shortcut.Projectable is true
|
||||
outputProps *schema.OrderedProps // command OutputSchema props when it marks fields; nil = pass-through
|
||||
}
|
||||
|
||||
// ── Identity ──
|
||||
@@ -694,19 +698,49 @@ func (ctx *RuntimeContext) emit(data interface{}, meta *output.Meta, raw, ok boo
|
||||
return
|
||||
}
|
||||
|
||||
var droppedNames map[string]bool
|
||||
if ctx.projectable && ctx.outputProps != nil {
|
||||
if !ctx.Bool("full") {
|
||||
// Capture what projection trims from the real response (before the
|
||||
// reassign below) so the jq-miss hint can name the specific field.
|
||||
droppedNames = droppedFieldNames(data, ctx.outputProps)
|
||||
}
|
||||
data = ProjectBySchema(data, ctx.outputProps, ctx.Bool("full"))
|
||||
}
|
||||
|
||||
env := output.Envelope{OK: ok, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()}
|
||||
if scanResult.Alert != nil {
|
||||
env.ContentSafetyAlert = scanResult.Alert
|
||||
}
|
||||
|
||||
if ctx.JqExpr != "" {
|
||||
filter := output.JqFilter
|
||||
var err error
|
||||
if raw {
|
||||
filter = output.JqFilterRaw
|
||||
_, err = output.JqFilterRawCount(ctx.IO().Out, env, ctx.JqExpr)
|
||||
} else {
|
||||
_, err = output.JqFilterCount(ctx.IO().Out, env, ctx.JqExpr)
|
||||
}
|
||||
if err := filter(ctx.IO().Out, env, ctx.JqExpr); err != nil {
|
||||
if err != nil {
|
||||
fmt.Fprintf(ctx.IO().ErrOut, "error: %v\n", err)
|
||||
ctx.outputErrOnce.Do(func() { ctx.outputErr = err })
|
||||
return
|
||||
}
|
||||
// Fire whenever the agent's jq references a field projection dropped —
|
||||
// regardless of result count. A jq path to a trimmed field yields null
|
||||
// (one result), not an empty result, so a count==0 gate would miss it.
|
||||
if len(droppedNames) > 0 {
|
||||
keys := make([]string, 0, len(droppedNames))
|
||||
for n := range droppedNames {
|
||||
keys = append(keys, n)
|
||||
}
|
||||
sort.Strings(keys) // deterministic
|
||||
for _, name := range keys {
|
||||
if strings.Contains(ctx.JqExpr, name) {
|
||||
fmt.Fprintf(ctx.IO().ErrOut,
|
||||
"note: field `%s` exists but is full-only in this command; re-run with --full (or --full --jq <path> for just that field)\n", name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -778,6 +812,11 @@ func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, pretty
|
||||
if !formatOK {
|
||||
fmt.Fprintf(ctx.IO().ErrOut, "warning: unknown format %q, falling back to json\n", ctx.Format)
|
||||
}
|
||||
// Projection applies to non-JSON formats too, so pretty/table/csv match
|
||||
// --json (all formats show the same curated view; --full returns all).
|
||||
if ctx.projectable && ctx.outputProps != nil {
|
||||
data = ProjectBySchema(data, ctx.outputProps, ctx.Bool("full"))
|
||||
}
|
||||
output.FormatValue(ctx.IO().Out, data, format)
|
||||
}
|
||||
}
|
||||
@@ -943,6 +982,15 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
|
||||
if err := output.ValidateJqFlags(rctx.JqExpr, "", rctx.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Projectable {
|
||||
rctx.projectable = true
|
||||
if anyProjected(s.OutputSchema) {
|
||||
rctx.outputProps = s.OutputSchema
|
||||
}
|
||||
}
|
||||
if s.NoFullViewHint != "" && rctx.Bool("full") {
|
||||
fmt.Fprintln(rctx.IO().ErrOut, s.NoFullViewHint)
|
||||
}
|
||||
if s.Validate != nil {
|
||||
if err := s.Validate(rctx.ctx, rctx); err != nil {
|
||||
return err
|
||||
@@ -1238,6 +1286,15 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
cmd.Flags().String("flag-name", "", "flag whose schema to print (omit to list introspectable flags); used with --print-schema")
|
||||
}
|
||||
}
|
||||
if s.Projectable && cmd.Flags().Lookup("full") == nil {
|
||||
cmd.Flags().Bool("full", false,
|
||||
"return the complete upstream payload instead of the schema-curated default view; "+
|
||||
"to fetch one hidden field, prefer --jq <path> (--full returns everything)")
|
||||
}
|
||||
if s.NoFullViewHint != "" && cmd.Flags().Lookup("full") == nil {
|
||||
cmd.Flags().Bool("full", false, "(this command has no full view — see the note printed when used)")
|
||||
_ = cmd.Flags().MarkHidden("full")
|
||||
}
|
||||
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
|
||||
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package common
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -53,6 +54,20 @@ type Shortcut struct {
|
||||
Tips []string // optional tips shown in --help output
|
||||
Hidden bool // hide from --help / tab completion (still executable); use when deprecating a command in favor of a replacement
|
||||
|
||||
// Projectable opts the command into framework output projection: the framework
|
||||
// auto-injects a --full flag and, after Execute, trims the envelope data by the
|
||||
// command's OutputSchema. No-op/fail-open when OutputSchema is nil or marks nothing.
|
||||
Projectable bool
|
||||
// OutputSchema describes the data this command emits, with projected marks
|
||||
// selecting the default view (build it with KeepFields/ArrayOf/ObjectOf). Consulted
|
||||
// only when Projectable is true; lives on the command, no registry dependency.
|
||||
OutputSchema *schema.OrderedProps
|
||||
// NoFullViewHint, when non-empty, registers a hidden --full flag for a command
|
||||
// that has no full view (e.g. message commands whose output is already a curated
|
||||
// layer). Passing --full prints this redirect to stderr and otherwise runs
|
||||
// normally — turning a flailing "unknown flag" retry into one clear path.
|
||||
NoFullViewHint string
|
||||
|
||||
// Business logic hooks.
|
||||
DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set
|
||||
Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation
|
||||
|
||||
@@ -180,7 +180,7 @@ func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
|
||||
"message_id": messageId,
|
||||
"msg_type": msgType,
|
||||
"content": content,
|
||||
"sender": m["sender"],
|
||||
"sender": projectSender(m["sender"]),
|
||||
"create_time": common.FormatTime(m["create_time"]),
|
||||
"deleted": deleted,
|
||||
"updated": updated,
|
||||
@@ -417,6 +417,22 @@ func extractMentionOpenId(id interface{}) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// projectSender tightens the message sender to {id, sender_type, name}. It keeps
|
||||
// sender_type (real signal: user vs app coexist) and drops the constant id_type
|
||||
// (always open_id) and tenant_key (constant within a tenant). name is injected
|
||||
// later by the contact-name resolver.
|
||||
func projectSender(v interface{}) interface{} {
|
||||
s, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return v
|
||||
}
|
||||
out := map[string]interface{}{"id": s["id"]}
|
||||
if st, ok := s["sender_type"]; ok {
|
||||
out["sender_type"] = st
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TruncateContent truncates a string for table display.
|
||||
func TruncateContent(s string, max int) string {
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
|
||||
@@ -113,25 +113,10 @@ func ResolveSenderNames(runtime *common.RuntimeContext, messages []map[string]in
|
||||
}
|
||||
}
|
||||
|
||||
// Collect sender IDs still missing a name
|
||||
seen := make(map[string]bool)
|
||||
var missingIDs []string
|
||||
for _, msg := range messages {
|
||||
sender, ok := msg["sender"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
senderType, _ := sender["sender_type"].(string)
|
||||
if senderType != "user" {
|
||||
continue
|
||||
}
|
||||
id, _ := sender["id"].(string)
|
||||
if id == "" || !strings.HasPrefix(id, "ou_") || seen[id] || nameMap[id] != "" {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
missingIDs = append(missingIDs, id)
|
||||
}
|
||||
// Collect sender IDs still missing a name, identified by ou_ prefix.
|
||||
// Decoupled from sender_type: projectSender runs before resolution and strips
|
||||
// noise fields; keying on the ou_ prefix is order-independent and robust.
|
||||
missingIDs := unresolvedUserSenderIDs(messages, nameMap)
|
||||
if len(missingIDs) == 0 {
|
||||
return nameMap
|
||||
}
|
||||
@@ -215,6 +200,24 @@ func batchResolveUsers(runtime *common.RuntimeContext, missingIDs []string, name
|
||||
}
|
||||
}
|
||||
|
||||
// unresolvedUserSenderIDs collects distinct ou_-prefixed (user) sender ids that
|
||||
// still need a name. Decoupled from sender_type on purpose: tightening runs
|
||||
// before resolution, so keying on the ou_ prefix is order-independent and robust.
|
||||
func unresolvedUserSenderIDs(messages []map[string]interface{}, nameMap map[string]string) []string {
|
||||
var ids []string
|
||||
seen := map[string]bool{}
|
||||
for _, msg := range messages {
|
||||
s, _ := msg["sender"].(map[string]interface{})
|
||||
id, _ := s["id"].(string)
|
||||
if id == "" || !strings.HasPrefix(id, "ou_") || seen[id] || nameMap[id] != "" {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// AttachSenderNames enriches message sender objects with resolved display names.
|
||||
// Senders whose name could not be resolved are left unchanged (id is preserved).
|
||||
func AttachSenderNames(messages []map[string]interface{}, nameMap map[string]string) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -39,13 +40,15 @@ func writeBotStripP2pWarning(errOut io.Writer) {
|
||||
// list groups the current user/bot is a member of. Supports sort order,
|
||||
// pagination, and (user identity only) muted-chat filtering via --exclude-muted.
|
||||
var ImChatList = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+chat-list",
|
||||
Description: "List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:chat:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Service: "im",
|
||||
Command: "+chat-list",
|
||||
Description: "List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:chat:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Projectable: true,
|
||||
OutputSchema: chatListOutputSchema(),
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "ID type for owner_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
|
||||
{Name: "sort", Default: "create_time", Desc: "sort field: create_time (ascending) | active_time (descending)", Enum: []string{"create_time", "active_time"}},
|
||||
@@ -197,6 +200,20 @@ var ImChatList = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// chatListOutputSchema declares the default (projected) view for +chat-list:
|
||||
// the chat object's useful fields + pagination + filter/notices metadata. The
|
||||
// chat's avatar / tenant_key / owner_id_type are left unmarked, so they are
|
||||
// hidden by default and recoverable via --full (§6.7 initial setting).
|
||||
func chatListOutputSchema() *schema.OrderedProps {
|
||||
chat := common.KeepFields(
|
||||
"chat_id", "name", "description", "owner_id", "external",
|
||||
"chat_status", "chat_mode", "p2p_target_type", "p2p_target_id",
|
||||
)
|
||||
root := common.KeepFields("has_more", "page_token", "filter", "notices")
|
||||
root.Set("chats", common.ArrayOf(chat))
|
||||
return root
|
||||
}
|
||||
|
||||
// normalizeTypes validates and normalizes the --types slice already parsed by cobra.
|
||||
// cobra's StringSlice handles the CSV split automatically — both --types=p2p,group
|
||||
// and repeated --types p2p --types group arrive here as a 2-element []string,
|
||||
|
||||
@@ -17,16 +17,22 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// imMessageNoFullHint redirects an agent that reflexively passes --full to a
|
||||
// message command (which has no full view) to the right path instead of leaving
|
||||
// it to flail on an "unknown flag" error.
|
||||
const imMessageNoFullHint = "note: messages have no --full view; sender is intentionally minimal ({id, sender_type, name}). Do not retry with --full — for fuller sender details resolve the id via the contact domain (e.g. `lark-cli contact +search-user`, or contact user get)."
|
||||
|
||||
var ImChatMessageList = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+chat-messages-list",
|
||||
Description: "List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.base:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Service: "im",
|
||||
Command: "+chat-messages-list",
|
||||
Description: "List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.base:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
NoFullViewHint: imMessageNoFullHint,
|
||||
Flags: []common.Flag{
|
||||
{Name: "chat-id", Desc: "(required, mutually exclusive with --user-id) chat ID (oc_xxx)"},
|
||||
{Name: "user-id", Desc: "(required, mutually exclusive with --chat-id; user identity only) user open_id (ou_xxx)"},
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -21,13 +22,15 @@ import (
|
||||
// member/type filters, sort order, pagination, and (user identity only) the
|
||||
// --exclude-muted client-side mute filter.
|
||||
var ImChatSearch = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+chat-search",
|
||||
Description: "Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:chat:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Service: "im",
|
||||
Command: "+chat-search",
|
||||
Description: "Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:chat:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Projectable: true,
|
||||
OutputSchema: chatSearchOutputSchema(),
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword (server may return data.notice for overly long input)"},
|
||||
{Name: "search-types", Desc: "chat types, comma-separated (private, external, public_joined, public_not_joined)"},
|
||||
@@ -207,6 +210,22 @@ var ImChatSearch = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// chatSearchOutputSchema declares the default (projected) view for +chat-search.
|
||||
// Mirrors +chat-list's chat object plus create_time (the search meta_data carries
|
||||
// it), and marks the wrapper key (chats), pagination (has_more/page_token), and
|
||||
// the total count so they survive trimming. The chat's avatar / tenant_key /
|
||||
// owner_id_type stay unmarked → hidden by default, recoverable via --full.
|
||||
func chatSearchOutputSchema() *schema.OrderedProps {
|
||||
chat := common.KeepFields(
|
||||
"chat_id", "name", "description", "owner_id", "external",
|
||||
"chat_status", "chat_mode", "p2p_target_type", "p2p_target_id",
|
||||
"create_time",
|
||||
)
|
||||
root := common.KeepFields("has_more", "page_token", "total", "filter")
|
||||
root.Set("chats", common.ArrayOf(chat))
|
||||
return root
|
||||
}
|
||||
|
||||
// buildSearchChatBody builds the JSON request body for POST /im/v2/chats/search
|
||||
// from the runtime flag values. The query string is normalized via
|
||||
// normalizeChatSearchQuery (hyphenated terms get quoted). The "filter" object
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
@@ -25,13 +26,15 @@ const feedGroupListPath = "/open-apis/im/v1/groups"
|
||||
// which follows only one array field and silently drops the other list's later
|
||||
// pages; this shortcut paginates the dual-list response itself.
|
||||
var ImFeedGroupList = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+feed-group-list",
|
||||
Description: "List the caller's feed groups (tags); user-only; supports `--page-all` auto-pagination",
|
||||
Risk: "read",
|
||||
UserScopes: []string{feedGroupReadScope},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Service: "im",
|
||||
Command: "+feed-group-list",
|
||||
Description: "List the caller's feed groups (tags); user-only; supports `--page-all` auto-pagination",
|
||||
Risk: "read",
|
||||
UserScopes: []string{feedGroupReadScope},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Projectable: true,
|
||||
OutputSchema: feedGroupListOutputSchema(),
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
@@ -71,6 +74,18 @@ var ImFeedGroupList = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// feedGroupListOutputSchema declares the default (projected) view for
|
||||
// +feed-group-list. Each group keeps group_id/name/type; its rules stay
|
||||
// full-only. Both list wrappers (groups, deleted_groups) and pagination
|
||||
// (has_more/page_token) are marked so they survive trimming.
|
||||
func feedGroupListOutputSchema() *schema.OrderedProps {
|
||||
group := common.KeepFields("group_id", "name", "type")
|
||||
root := common.KeepFields("has_more", "page_token")
|
||||
root.Set("groups", common.ArrayOf(group))
|
||||
root.Set("deleted_groups", common.ArrayOf(group))
|
||||
return root
|
||||
}
|
||||
|
||||
func validateFeedGroupListPageOptions(rt *common.RuntimeContext) error {
|
||||
if n := rt.Int("page-size"); n < 1 || n > 50 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 50").WithParam("--page-size")
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
@@ -30,6 +31,8 @@ var ImFeedShortcutList = common.Shortcut{
|
||||
ConditionalUserScopes: []string{chatBatchQueryScope},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Projectable: true,
|
||||
OutputSchema: feedShortcutListOutputSchema(),
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-token",
|
||||
Desc: "opaque pagination token from the previous response; omit for the first page. If a token is rejected because the list changed, restart by omitting it."},
|
||||
@@ -69,6 +72,24 @@ var ImFeedShortcutList = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// feedShortcutListOutputSchema declares the default (projected) view for
|
||||
// +feed-shortcut-list. Each entry keeps feed_card_id + type, and the enriched
|
||||
// `detail` (full chat object from im.chats.batch_query) is narrowed to
|
||||
// chat_id/name/chat_mode — its avatar / tenant_key / owner_id* / description /
|
||||
// external stay full-only. The wrapper key (shortcuts), pagination
|
||||
// (has_more/page_token), and the data-level _notice the command may emit on
|
||||
// enrichment failure are all marked so they survive trimming.
|
||||
func feedShortcutListOutputSchema() *schema.OrderedProps {
|
||||
detail := common.KeepFields("chat_id", "name", "chat_mode")
|
||||
|
||||
item := common.KeepFields("feed_card_id", "type")
|
||||
item.Set("detail", common.ObjectOf(detail))
|
||||
|
||||
root := common.KeepFields("has_more", "page_token", "_notice")
|
||||
root.Set("shortcuts", common.ArrayOf(item))
|
||||
return root
|
||||
}
|
||||
|
||||
// feedShortcutListQuery omits the page_token key entirely when the token is
|
||||
// empty, so the server treats the call as a first-page request.
|
||||
func feedShortcutListQuery(token string) larkcore.QueryParams {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/schema"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
@@ -17,13 +18,15 @@ import (
|
||||
// ImFlagList provides the +flag-list shortcut for listing bookmarks.
|
||||
// Feed-type thread entries are auto-enriched with message content.
|
||||
var ImFlagList = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+flag-list",
|
||||
Description: "List bookmarks; user-only; auto-enriches feed-type thread entries with message content; supports `--page-all` auto-pagination",
|
||||
Risk: "read",
|
||||
UserScopes: []string{flagReadScope},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Service: "im",
|
||||
Command: "+flag-list",
|
||||
Description: "List bookmarks; user-only; auto-enriches feed-type thread entries with message content; supports `--page-all` auto-pagination",
|
||||
Risk: "read",
|
||||
UserScopes: []string{flagReadScope},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Projectable: true,
|
||||
OutputSchema: flagListOutputSchema(),
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
@@ -70,6 +73,27 @@ var ImFlagList = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// flagListOutputSchema declares the default (projected) view for +flag-list.
|
||||
// Each bookmark item keeps its identity/type/timestamps; the enriched feed-thread
|
||||
// `message` is narrowed to message_id + msg_type + body.content (the rest of the
|
||||
// raw mget message — sender, mentions, chat_id, etc. — stays full-only). The two
|
||||
// list wrappers (flag_items, delete_flag_items), the inline messages array, and
|
||||
// pagination (has_more/page_token) are all marked so they survive trimming.
|
||||
func flagListOutputSchema() *schema.OrderedProps {
|
||||
body := common.KeepFields("content")
|
||||
message := common.KeepFields("message_id", "msg_type")
|
||||
message.Set("body", common.ObjectOf(body))
|
||||
|
||||
item := common.KeepFields("item_id", "flag_type", "item_type", "create_time", "update_time")
|
||||
item.Set("message", common.ObjectOf(message))
|
||||
|
||||
root := common.KeepFields("has_more", "page_token")
|
||||
root.Set("flag_items", common.ArrayOf(item))
|
||||
root.Set("delete_flag_items", common.ArrayOf(item))
|
||||
root.Set("messages", common.ArrayOf(message))
|
||||
return root
|
||||
}
|
||||
|
||||
func validateListOptions(rt *common.RuntimeContext) error {
|
||||
if n := rt.Int("page-size"); n < 1 || n > 50 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 50").WithParam("--page-size")
|
||||
|
||||
@@ -18,15 +18,16 @@ import (
|
||||
const maxMGetMessageIDs = 50
|
||||
|
||||
var ImMessagesMGet = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+messages-mget",
|
||||
Description: "Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read", "contact:user.base:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Service: "im",
|
||||
Command: "+messages-mget",
|
||||
Description: "Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read", "contact:user.base:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
NoFullViewHint: imMessageNoFullHint,
|
||||
Flags: []common.Flag{
|
||||
{Name: "message-ids", Desc: "message IDs, comma-separated (om_xxx,om_yyy)", Required: true},
|
||||
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
|
||||
|
||||
@@ -26,13 +26,14 @@ const (
|
||||
)
|
||||
|
||||
var ImMessagesSearch = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+messages-search",
|
||||
Description: "Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, enriches results via mget and chats batch_query",
|
||||
Risk: "read",
|
||||
Scopes: []string{"search:message", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Service: "im",
|
||||
Command: "+messages-search",
|
||||
Description: "Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, enriches results via mget and chats batch_query",
|
||||
Risk: "read",
|
||||
Scopes: []string{"search:message", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
NoFullViewHint: imMessageNoFullHint,
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword"},
|
||||
{Name: "chat-id", Desc: "limit to chat IDs, comma-separated"},
|
||||
|
||||
@@ -20,15 +20,16 @@ import (
|
||||
const threadsMessagesMaxPageSize = 500
|
||||
|
||||
var ImThreadsMessagesList = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+threads-messages-list",
|
||||
Description: "List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read", "contact:user.base:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Service: "im",
|
||||
Command: "+threads-messages-list",
|
||||
Description: "List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination",
|
||||
Risk: "read",
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
|
||||
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read", "contact:user.base:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
NoFullViewHint: imMessageNoFullHint,
|
||||
Flags: []common.Flag{
|
||||
{Name: "thread", Desc: "thread ID (om_xxx or omt_xxx)", Required: true},
|
||||
{Name: "order", Default: "asc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}},
|
||||
|
||||
@@ -71,6 +71,15 @@ lark-cli im +chat-list --as user --types p2p
|
||||
| `p2p_target_type` | Peer type, e.g., `user` |
|
||||
| `p2p_target_id` | Peer ID (type controlled by `--user-id-type`) |
|
||||
|
||||
## Default view and `--full`
|
||||
|
||||
Output is a **curated view**: only the fields above. Verbose / low-value fields on each chat (`avatar`, `tenant_key`, `owner_id_type`) are **hidden by default**.
|
||||
|
||||
- `--full` returns the complete upstream payload (all hidden fields included).
|
||||
- Need one hidden field? Use **`--full --jq <path>`** (e.g. `--full --jq '.data.chats[].avatar'`). The `--full` is required even with `--jq`: `--jq` filters the curated view, where hidden fields are already trimmed away — so a bare `--jq '.data.chats[].avatar'` returns `null` (and stderr prints a `... is full-only ... re-run with --full` note). Pairing `--jq` with `--full` keeps the output to just that field, so you avoid dumping the whole payload back into context.
|
||||
- A field missing from the default view does **not** mean it doesn't exist — it may be full-only. Don't conclude "no such field" from its absence here.
|
||||
- Don't try `lark-cli schema` to introspect this command (it isn't in the catalog); the field list is in this doc.
|
||||
|
||||
## Including p2p single chats
|
||||
|
||||
Default behavior lists groups only — same as before this feature. To include p2p, pass `--types`:
|
||||
|
||||
@@ -104,6 +104,8 @@ 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 |
|
||||
|
||||
The `sender` sub-object is **intentionally minimal**: `{id, sender_type, name}`. This command has **no `--full` view** — passing `--full` just prints a one-line note to stderr and outputs normally. For fuller sender details, take `sender.id` to the **contact domain** (e.g. `lark-cli contact +search-user`, or contact user get). Do **not** retry this command with `--full` to expand the sender.
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -72,6 +72,15 @@ lark-cli im +chat-search --query "project" --dry-run
|
||||
| `external` | Whether the chat is external |
|
||||
| `chat_status` | Chat status (`normal` / `dissolved` / `dissolved_save`) |
|
||||
|
||||
## Default view and `--full`
|
||||
|
||||
Output is a **curated view**: only the fields above (plus `create_time`). Verbose / low-value fields on each chat (`avatar`, `tenant_key`, `owner_id_type`) are **hidden by default**.
|
||||
|
||||
- `--full` returns the complete upstream payload (all hidden fields included).
|
||||
- Need one hidden field? Use **`--full --jq <path>`** (e.g. `--full --jq '.data.chats[].avatar'`). The `--full` is required even with `--jq`: `--jq` filters the curated view, where hidden fields are already trimmed away — so a bare `--jq '.data.chats[].avatar'` returns `null` (and stderr prints a `... is full-only ... re-run with --full` note). Pairing `--jq` with `--full` keeps the output to just that field, so you avoid dumping the whole payload back into context.
|
||||
- A field missing from the default view does **not** mean it doesn't exist — it may be full-only. Don't conclude "no such field" from its absence here.
|
||||
- Don't try `lark-cli schema` to introspect this command (it isn't in the catalog); the field list is in this doc.
|
||||
|
||||
## Filtering muted chats
|
||||
|
||||
`--exclude-muted` (user identity only) drops chats the current user has set to do-not-disturb. After the search call, the CLI batches the page's chat_ids through `POST /open-apis/im/v1/chat_user_setting/batch_get_mute_status` and filters client-side. Under `--as bot`, the mute API is UAT-only and the filter is silently skipped.
|
||||
|
||||
@@ -58,6 +58,15 @@ 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`.
|
||||
|
||||
## Default view and `--full`
|
||||
|
||||
Output is a **curated view**: each group keeps `group_id`, `name`, and `type`. The `rules` object (shown in the example above) is **hidden by default**.
|
||||
|
||||
- `--full` returns the complete upstream payload (`rules` included).
|
||||
- Need `rules`? Use **`--full --jq <path>`** (e.g. `--full --jq '.data.groups[].rules'`). The `--full` is required even with `--jq`: `--jq` filters the curated view, where hidden fields are already trimmed away — so a bare `--jq '.data.groups[].rules'` returns `null` (and stderr prints a `... is full-only ... re-run with --full` note). Pairing `--jq` with `--full` keeps the output to just that field, so you avoid dumping the whole payload back into context.
|
||||
- A field missing from the default view does **not** mean it doesn't exist — it may be full-only. Don't conclude "no such field" from its absence here.
|
||||
- Don't try `lark-cli schema` to introspect this command (it isn't in the catalog); the field list is in this doc.
|
||||
|
||||
## See also
|
||||
|
||||
- [lark-im-feed-groups.md](lark-im-feed-groups.md) — raw `feed.groups.*` APIs, enums, and rule guidance
|
||||
|
||||
@@ -96,6 +96,15 @@ The `detail` payload is dispatched **per `type`**. Today only CHAT is wired in;
|
||||
- **P2P chats** return an empty `name` because the Feishu client renders the partner's display name there. The rest of the object (especially `p2p_target_id`) still flows through, so callers can resolve the partner via `+contact-search` if a display title is needed.
|
||||
- **Lookup failure** (missing scope, network error) → the list still returns successfully; a warning is printed to stderr, the data payload carries a `_notice` field (`"detail enrichment skipped: ..."`), and affected entries simply lack the `detail` field. Check `_notice` to tell "enrichment skipped" from "nothing to enrich".
|
||||
|
||||
## Default view and `--full`
|
||||
|
||||
Output is a **curated view**. The `detail` object is narrowed to `chat_id`, `name`, and `chat_mode`; its other chat fields (`avatar`, `tenant_key`, `owner_id*`, `description`, `external`, `p2p_target_*`) shown in the example above are **hidden by default**.
|
||||
|
||||
- `--full` returns the complete upstream payload (full `detail` chat objects included).
|
||||
- Need one hidden field? Use **`--full --jq <path>`** (e.g. `--full --jq '.data.shortcuts[].detail.p2p_target_id'`). The `--full` is required even with `--jq`: `--jq` filters the curated view, where hidden fields are already trimmed away — so a bare `--jq '.data.shortcuts[].detail.p2p_target_id'` returns `null` (and stderr prints a `... is full-only ... re-run with --full` note). Pairing `--jq` with `--full` keeps the output to just that field, so you avoid dumping the whole payload back into context.
|
||||
- A field missing from the default view does **not** mean it doesn't exist — it may be full-only. Don't conclude "no such field" from its absence here.
|
||||
- Don't try `lark-cli schema` to introspect this command (it isn't in the catalog); the field list is in this doc.
|
||||
|
||||
## Permissions
|
||||
|
||||
- Required scope: `im:feed.shortcut:read`
|
||||
|
||||
@@ -64,6 +64,15 @@ Note: `(thread, feed)` / `(msg_thread, feed)` entries are automatically enriched
|
||||
|
||||
- **delete_flag_items are not enriched**: Message content is only fetched for active flags (`flag_items`), not canceled flags (`delete_flag_items`). If you need message content for a canceled flag, query the message separately using `+messages-mget --message-ids <item_id>`.
|
||||
|
||||
## Default view and `--full`
|
||||
|
||||
Output is a **curated view**. The enriched feed-thread `message` on each item is narrowed to `message_id`, `msg_type`, and `body.content`; the rest of the raw mget message (`sender`, `mentions`, `chat_id`, etc.) is **hidden by default**.
|
||||
|
||||
- `--full` returns the complete upstream payload (full message objects included).
|
||||
- Need one hidden field? Use **`--full --jq <path>`** (e.g. `--full --jq '.data.flag_items[].message.sender'`). The `--full` is required even with `--jq`: `--jq` filters the curated view, where hidden fields are already trimmed away — so a bare `--jq '.data.flag_items[].message.sender'` returns `null` (and stderr prints a `... is full-only ... re-run with --full` note). Pairing `--jq` with `--full` keeps the output to just that field, so you avoid dumping the whole payload back into context.
|
||||
- A field missing from the default view does **not** mean it doesn't exist — it may be full-only. Don't conclude "no such field" from its absence here.
|
||||
- Don't try `lark-cli schema` to introspect this command (it isn't in the catalog); the field list is in this doc.
|
||||
|
||||
## Response Example (Sanitized)
|
||||
|
||||
```json
|
||||
|
||||
@@ -51,6 +51,8 @@ Each message contains:
|
||||
| `sender` | Sender information (includes `name`) |
|
||||
| `content` | Message content |
|
||||
|
||||
The `sender` sub-object is **intentionally minimal**: `{id, sender_type, name}`. This command has **no `--full` view** — passing `--full` just prints a one-line note to stderr and outputs normally. For fuller sender details, take `sender.id` to the **contact domain** (e.g. `lark-cli contact +search-user`, or contact user get). Do **not** retry this command with `--full` to expand the sender.
|
||||
|
||||
## Usage Scenarios
|
||||
|
||||
### Scenario 1: Fetch the full content of a specific message
|
||||
|
||||
@@ -130,6 +130,8 @@ 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 |
|
||||
|
||||
The `sender` sub-object is **intentionally minimal**: `{id, sender_type, name}`. This command has **no `--full` view** — passing `--full` just prints a one-line note to stderr and outputs normally. For fuller sender details, take `sender.id` to the **contact domain** (e.g. `lark-cli contact +search-user`, or contact user get). Do **not** retry this command with `--full` to expand the sender.
|
||||
|
||||
### 4. Pagination behavior
|
||||
|
||||
- Default behavior is still **single-page**.
|
||||
|
||||
@@ -72,6 +72,10 @@ Thread messages do not support `start_time` / `end_time` filtering because of Fe
|
||||
| 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` |
|
||||
|
||||
### 5. Sender is minimal; no `--full` view
|
||||
|
||||
The `sender` sub-object on each reply is **intentionally minimal**: `{id, sender_type, name}`. This command has **no `--full` view** — passing `--full` just prints a one-line note to stderr and outputs normally. For fuller sender details, take `sender.id` to the **contact domain** (e.g. `lark-cli contact +search-user`, or contact user get). Do **not** retry this command with `--full` to expand the sender.
|
||||
|
||||
## Usage Scenarios
|
||||
|
||||
### Scenario 1: Expand a thread discovered in group messages
|
||||
|
||||
@@ -121,6 +121,10 @@ lark-cli 命令执行后,如果检测到新版本,JSON 输出中会包含 `_
|
||||
|
||||
**规则**:不要静默忽略更新提示。即使当前任务与更新无关,也应在完成用户请求后补充告知。
|
||||
|
||||
## 读命令的精选视图与 `--full`
|
||||
|
||||
不少 lark-cli 读命令默认返回**精选视图**,把冗长/低价值字段(如 `avatar`、`tenant_key`)裁掉省上下文——是有意为之,不是字段不存在。需要的字段不在默认输出里时:别据此断言“没这个字段”,也别转头猜别的命令或 meta API——给**同一条命令加 `--full`** 取回完整 payload(只取一个字段就 `--full --jq <path>`)。
|
||||
|
||||
## 安全规则
|
||||
|
||||
- **禁止输出密钥**(appSecret、accessToken)到终端明文。
|
||||
|
||||
Reference in New Issue
Block a user