From c3708c2e78ad5489718dde208c36db26a5163247 Mon Sep 17 00:00:00 2001 From: luozhixiong Date: Mon, 22 Jun 2026 20:28:31 +0800 Subject: [PATCH] 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. --- internal/output/jq.go | 30 +- internal/schema/types.go | 4 + shortcuts/common/projection.go | 177 +++++++++ shortcuts/common/projection_test.go | 374 ++++++++++++++++++ shortcuts/common/runner.go | 63 ++- shortcuts/common/types.go | 15 + shortcuts/im/convert_lib/content_convert.go | 18 +- shortcuts/im/convert_lib/helpers.go | 41 +- shortcuts/im/im_chat_list.go | 31 +- shortcuts/im/im_chat_messages_list.go | 24 +- shortcuts/im/im_chat_search.go | 33 +- shortcuts/im/im_feed_group_list.go | 29 +- shortcuts/im/im_feed_shortcut_list.go | 21 + shortcuts/im/im_flag_list.go | 38 +- shortcuts/im/im_messages_mget.go | 19 +- shortcuts/im/im_messages_search.go | 15 +- shortcuts/im/im_threads_messages_list.go | 19 +- .../lark-im/references/lark-im-chat-list.md | 9 + .../references/lark-im-chat-messages-list.md | 2 + .../lark-im/references/lark-im-chat-search.md | 9 + .../references/lark-im-feed-group-list.md | 9 + .../references/lark-im-feed-shortcut-list.md | 9 + .../lark-im/references/lark-im-flag-list.md | 9 + .../references/lark-im-messages-mget.md | 2 + .../references/lark-im-messages-search.md | 2 + .../lark-im-threads-messages-list.md | 4 + skills/lark-shared/SKILL.md | 4 + 27 files changed, 918 insertions(+), 92 deletions(-) create mode 100644 shortcuts/common/projection.go create mode 100644 shortcuts/common/projection_test.go diff --git a/internal/output/jq.go b/internal/output/jq.go index 414a8cde6..ed599b24c 100644 --- a/internal/output/jq.go +++ b/internal/output/jq.go @@ -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, diff --git a/internal/schema/types.go b/internal/schema/types.go index 084ca4691..02d8fe9b9 100644 --- a/internal/schema/types.go +++ b/internal/schema/types.go @@ -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. diff --git a/shortcuts/common/projection.go b/shortcuts/common/projection.go new file mode 100644 index 000000000..9b9e9acc5 --- /dev/null +++ b/shortcuts/common/projection.go @@ -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} +} diff --git a/shortcuts/common/projection_test.go b/shortcuts/common/projection_test.go new file mode 100644 index 000000000..a9bc45dd5 --- /dev/null +++ b/shortcuts/common/projection_test.go @@ -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) + } +} diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index f375714c0..cef606844 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -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 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 (--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) } diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index afc4f1a82..46ca92ae1 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -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 diff --git a/shortcuts/im/convert_lib/content_convert.go b/shortcuts/im/convert_lib/content_convert.go index 1292b7d33..665af534a 100644 --- a/shortcuts/im/convert_lib/content_convert.go +++ b/shortcuts/im/convert_lib/content_convert.go @@ -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", " ") diff --git a/shortcuts/im/convert_lib/helpers.go b/shortcuts/im/convert_lib/helpers.go index a9cec1d74..10a347324 100644 --- a/shortcuts/im/convert_lib/helpers.go +++ b/shortcuts/im/convert_lib/helpers.go @@ -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) { diff --git a/shortcuts/im/im_chat_list.go b/shortcuts/im/im_chat_list.go index 2ae1ffd46..22ddc231a 100644 --- a/shortcuts/im/im_chat_list.go +++ b/shortcuts/im/im_chat_list.go @@ -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, diff --git a/shortcuts/im/im_chat_messages_list.go b/shortcuts/im/im_chat_messages_list.go index 471a3fb92..abbd13908 100644 --- a/shortcuts/im/im_chat_messages_list.go +++ b/shortcuts/im/im_chat_messages_list.go @@ -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)"}, diff --git a/shortcuts/im/im_chat_search.go b/shortcuts/im/im_chat_search.go index ef46946ec..4ad464925 100644 --- a/shortcuts/im/im_chat_search.go +++ b/shortcuts/im/im_chat_search.go @@ -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 diff --git a/shortcuts/im/im_feed_group_list.go b/shortcuts/im/im_feed_group_list.go index c6bfdd0ef..8df1f60db 100644 --- a/shortcuts/im/im_feed_group_list.go +++ b/shortcuts/im/im_feed_group_list.go @@ -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") diff --git a/shortcuts/im/im_feed_shortcut_list.go b/shortcuts/im/im_feed_shortcut_list.go index 75194c873..6d4a9b406 100644 --- a/shortcuts/im/im_feed_shortcut_list.go +++ b/shortcuts/im/im_feed_shortcut_list.go @@ -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 { diff --git a/shortcuts/im/im_flag_list.go b/shortcuts/im/im_flag_list.go index d4761e124..3784122db 100644 --- a/shortcuts/im/im_flag_list.go +++ b/shortcuts/im/im_flag_list.go @@ -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") diff --git a/shortcuts/im/im_messages_mget.go b/shortcuts/im/im_messages_mget.go index 931fbba36..7a5bdc7ca 100644 --- a/shortcuts/im/im_messages_mget.go +++ b/shortcuts/im/im_messages_mget.go @@ -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)"}, diff --git a/shortcuts/im/im_messages_search.go b/shortcuts/im/im_messages_search.go index 6cffd432e..6a586aa42 100644 --- a/shortcuts/im/im_messages_search.go +++ b/shortcuts/im/im_messages_search.go @@ -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"}, diff --git a/shortcuts/im/im_threads_messages_list.go b/shortcuts/im/im_threads_messages_list.go index 7fc1d524a..18f9610df 100644 --- a/shortcuts/im/im_threads_messages_list.go +++ b/shortcuts/im/im_threads_messages_list.go @@ -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"}}, diff --git a/skills/lark-im/references/lark-im-chat-list.md b/skills/lark-im/references/lark-im-chat-list.md index 0d6ca2b28..5f604c7b3 100644 --- a/skills/lark-im/references/lark-im-chat-list.md +++ b/skills/lark-im/references/lark-im-chat-list.md @@ -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 `** (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`: diff --git a/skills/lark-im/references/lark-im-chat-messages-list.md b/skills/lark-im/references/lark-im-chat-messages-list.md index d0f5af185..d18ad45f1 100644 --- a/skills/lark-im/references/lark-im-chat-messages-list.md +++ b/skills/lark-im/references/lark-im-chat-messages-list.md @@ -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: diff --git a/skills/lark-im/references/lark-im-chat-search.md b/skills/lark-im/references/lark-im-chat-search.md index bdd93c0da..ff620fc97 100644 --- a/skills/lark-im/references/lark-im-chat-search.md +++ b/skills/lark-im/references/lark-im-chat-search.md @@ -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 `** (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. diff --git a/skills/lark-im/references/lark-im-feed-group-list.md b/skills/lark-im/references/lark-im-feed-group-list.md index bc5aca82a..5a00870d7 100644 --- a/skills/lark-im/references/lark-im-feed-group-list.md +++ b/skills/lark-im/references/lark-im-feed-group-list.md @@ -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 `** (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 diff --git a/skills/lark-im/references/lark-im-feed-shortcut-list.md b/skills/lark-im/references/lark-im-feed-shortcut-list.md index 6a22893cd..d9a484a57 100644 --- a/skills/lark-im/references/lark-im-feed-shortcut-list.md +++ b/skills/lark-im/references/lark-im-feed-shortcut-list.md @@ -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 `** (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` diff --git a/skills/lark-im/references/lark-im-flag-list.md b/skills/lark-im/references/lark-im-flag-list.md index 913a89056..edf809ac7 100644 --- a/skills/lark-im/references/lark-im-flag-list.md +++ b/skills/lark-im/references/lark-im-flag-list.md @@ -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 `. +## 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 `** (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 diff --git a/skills/lark-im/references/lark-im-messages-mget.md b/skills/lark-im/references/lark-im-messages-mget.md index 852efcea1..e73cfd347 100644 --- a/skills/lark-im/references/lark-im-messages-mget.md +++ b/skills/lark-im/references/lark-im-messages-mget.md @@ -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 diff --git a/skills/lark-im/references/lark-im-messages-search.md b/skills/lark-im/references/lark-im-messages-search.md index b466d5412..5d617618e 100644 --- a/skills/lark-im/references/lark-im-messages-search.md +++ b/skills/lark-im/references/lark-im-messages-search.md @@ -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**. diff --git a/skills/lark-im/references/lark-im-threads-messages-list.md b/skills/lark-im/references/lark-im-threads-messages-list.md index 61c4acec7..62c62d7d7 100644 --- a/skills/lark-im/references/lark-im-threads-messages-list.md +++ b/skills/lark-im/references/lark-im-threads-messages-list.md @@ -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 diff --git a/skills/lark-shared/SKILL.md b/skills/lark-shared/SKILL.md index 8b4813ecc..0010621c5 100644 --- a/skills/lark-shared/SKILL.md +++ b/skills/lark-shared/SKILL.md @@ -121,6 +121,10 @@ lark-cli 命令执行后,如果检测到新版本,JSON 输出中会包含 `_ **规则**:不要静默忽略更新提示。即使当前任务与更新无关,也应在完成用户请求后补充告知。 +## 读命令的精选视图与 `--full` + +不少 lark-cli 读命令默认返回**精选视图**,把冗长/低价值字段(如 `avatar`、`tenant_key`)裁掉省上下文——是有意为之,不是字段不存在。需要的字段不在默认输出里时:别据此断言“没这个字段”,也别转头猜别的命令或 meta API——给**同一条命令加 `--full`** 取回完整 payload(只取一个字段就 `--full --jq `)。 + ## 安全规则 - **禁止输出密钥**(appSecret、accessToken)到终端明文。