Compare commits

...

1 Commits

Author SHA1 Message Date
luozhixiong
c3708c2e78 feat: add framework-level output projection with --full
Adds a domain-agnostic output-projection engine to the shortcut runtime; a Projectable shortcut declares an OutputSchema and the runtime trims the default view to the curated whitelist, with a boolean --full to restore the full upstream payload. The 5 im read shortcuts are the first adopters; other domains are unchanged (projection is opt-in via OutputSchema). Message senders are tightened to {id, sender_type, name}; adds a jq-miss stderr hint.
2026-06-26 17:08:59 +08:00
27 changed files with 918 additions and 92 deletions

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -121,6 +121,10 @@ lark-cli 命令执行后如果检测到新版本JSON 输出中会包含 `_
**规则**:不要静默忽略更新提示。即使当前任务与更新无关,也应在完成用户请求后补充告知。
## 读命令的精选视图与 `--full`
不少 lark-cli 读命令默认返回**精选视图**,把冗长/低价值字段(如 `avatar`、`tenant_key`)裁掉省上下文——是有意为之,不是字段不存在。需要的字段不在默认输出里时:别据此断言“没这个字段”,也别转头猜别的命令或 meta API——给**同一条命令加 `--full`** 取回完整 payload只取一个字段就 `--full --jq <path>`)。
## 安全规则
- **禁止输出密钥**appSecret、accessToken到终端明文。