Compare commits

..

1 Commits

Author SHA1 Message Date
zhangheng.023
a7ccd4e636 feat: align im feed shortcut commands with latest oapi 2026-06-12 15:55:33 +08:00
10 changed files with 89 additions and 669 deletions

View File

@@ -1411,7 +1411,7 @@ const (
)
const (
feedShortcutBatchLimit = 10
feedShortcutBatchLimit = 30
feedShortcutWriteScope = "im:feed.shortcut:write"
feedShortcutReadScope = "im:feed.shortcut:read"
)
@@ -1423,7 +1423,8 @@ type shortcutItem struct {
}
// collectChatIDs reads --chat-id values (repeatable + comma-split) and
// returns deduped, validated oc_ IDs. The server batch limit is 10.
// returns deduped, validated oc_ IDs. This CLI enforces a local batch limit
// of 30 even though the upstream API currently documents a higher ceiling.
func collectChatIDs(rt *common.RuntimeContext) ([]string, error) {
raw := rt.StrSlice("chat-id")
if len(raw) == 0 {

View File

@@ -17,7 +17,7 @@ import (
var ImFeedShortcutCreate = common.Shortcut{
Service: "im",
Command: "+feed-shortcut-create",
Description: "Add chats to the user's feed shortcuts; user-only; batch up to 10 chat IDs per call; --head/--tail controls insertion order",
Description: "Add chats to the user's feed shortcuts; user-only; CHAT only; CLI enforces up to 30 chat IDs per call; --head/--tail controls insertion order",
Risk: "write",
UserScopes: []string{feedShortcutWriteScope},
AuthTypes: []string{"user"},
@@ -28,7 +28,7 @@ var ImFeedShortcutCreate = common.Shortcut{
// reported through the structured validation envelope (exit 2)
// instead of cobra's plain-text error.
{Name: "chat-id", Type: "string_slice",
Desc: "open_chat_id to add as a feed shortcut (oc_xxx); required; repeat the flag or pass comma-separated; max 10 per call"},
Desc: "open_chat_id to add as a feed shortcut (oc_xxx); required; repeat the flag or pass comma-separated; CLI max 30 per call"},
{Name: "head", Type: "bool",
Desc: "insert at the top of the shortcut list (default); mutually exclusive with --tail"},
{Name: "tail", Type: "bool",

View File

@@ -5,75 +5,31 @@ package im
import (
"context"
"fmt"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// ImFeedShortcutList provides the +feed-shortcut-list shortcut for listing
// the user's feed shortcuts. The server-controlled page size covers the full
// list in practice, but pagination is version-locked: when the list changes
// between calls the server rejects the stale token and the caller has to
// restart by omitting --page-token.
//
// The shortcut is a thin one-page wrapper — there is no automatic walking.
// Callers are expected to drive their own loop when they actually need to
// paginate, because the version-lock means each page is a real checkpoint
// that the caller must consciously decide what to do with on failure.
// the current user's feed shortcuts. The latest OAPI contract returns the
// full list directly, so the shortcut intentionally exposes no pagination or
// detail-enrichment behavior.
var ImFeedShortcutList = common.Shortcut{
Service: "im",
Command: "+feed-shortcut-list",
Description: "List one page of the user's feed shortcuts; user-only; first call omits --page-token, subsequent calls pass the previous response's page_token; each entry is auto-enriched with the full per-type info object attached as `detail` (pass --no-detail to skip)",
Risk: "read",
UserScopes: []string{feedShortcutReadScope},
ConditionalUserScopes: []string{chatBatchQueryScope},
AuthTypes: []string{"user"},
HasFormat: true,
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."},
{Name: "no-detail", Type: "bool",
Desc: "skip fetching the full info object for each shortcut (default: enrichment enabled — CHAT-type entries call im.chats.batch_query, require im:chat:read, and attach the object under the detail field)"},
},
Service: "im",
Command: "+feed-shortcut-list",
Description: "List the current user's feed shortcuts; user-only; returns the full CHAT shortcut list directly with no pagination or detail lookup",
Risk: "read",
UserScopes: []string{feedShortcutReadScope},
AuthTypes: []string{"user"},
HasFormat: true,
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
d := common.NewDryRunAPI().
GET("/open-apis/im/v2/feed_shortcuts")
if token := runtime.Str("page-token"); token != "" {
d.Params(map[string]any{"page_token": token})
}
if !runtime.Bool("no-detail") {
d.Desc("conditional enrichment: if CHAT-type entries exist, execution also calls POST /open-apis/im/v1/chats/batch_query and requires scope im:chat:read; pass --no-detail to skip this extra call and extra scope")
}
return d
return common.NewDryRunAPI().GET("/open-apis/im/v2/feed_shortcuts")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
data, err := runtime.DoAPIJSONTyped("GET", "/open-apis/im/v2/feed_shortcuts",
feedShortcutListQuery(runtime.Str("page-token")), nil)
data, err := runtime.DoAPIJSONTyped("GET", "/open-apis/im/v2/feed_shortcuts", nil, nil)
if err != nil {
return err
}
if !runtime.Bool("no-detail") {
if err := enrichFeedShortcutDetail(runtime, data); err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: detail enrichment failed: %v\n", err)
// Mirror the warning into the data payload so stdout-only
// consumers can tell "enrichment skipped" from "nothing to
// enrich" (same convention as mail's data-level _notice).
if data != nil {
data["_notice"] = fmt.Sprintf("detail enrichment skipped: %v", err)
}
}
}
runtime.Out(data, nil)
return nil
},
}
// 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 {
if token == "" {
return larkcore.QueryParams{}
}
return larkcore.QueryParams{"page_token": []string{token}}
}

View File

@@ -15,7 +15,7 @@ import (
var ImFeedShortcutRemove = common.Shortcut{
Service: "im",
Command: "+feed-shortcut-remove",
Description: "Remove chats from the user's feed shortcuts; user-only; batch up to 10 chat IDs per call; per-item failures return ok:false with failed_shortcuts",
Description: "Remove chats from the user's feed shortcuts; user-only; CHAT only; CLI enforces up to 30 chat IDs per call; per-item failures return ok:false with failed_shortcuts",
Risk: "write",
UserScopes: []string{feedShortcutWriteScope},
AuthTypes: []string{"user"},
@@ -26,7 +26,7 @@ var ImFeedShortcutRemove = common.Shortcut{
// reported through the structured validation envelope (exit 2)
// instead of cobra's plain-text error.
{Name: "chat-id", Type: "string_slice",
Desc: "open_chat_id to remove from feed shortcuts (oc_xxx); required; repeat the flag or pass comma-separated; max 10 per call"},
Desc: "open_chat_id to remove from feed shortcuts (oc_xxx); required; repeat the flag or pass comma-separated; CLI max 30 per call"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := collectChatIDs(runtime)

View File

@@ -6,7 +6,6 @@ package im
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -49,11 +48,6 @@ func newFeedShortcutRemoveCmd(t *testing.T) *cobra.Command {
func newFeedShortcutListCmd(t *testing.T) *cobra.Command {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("page-token", "", "")
// Default true (skip enrichment) in tests so non-enrichment-focused tests
// don't trigger the batch_query path; tests that exercise detail
// enrichment flip this off.
cmd.Flags().Bool("no-detail", true, "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
@@ -76,11 +70,26 @@ func TestCollectChatIDs(t *testing.T) {
{name: "dedupes", input: []string{"oc_abc", "oc_abc", "oc_def"}, want: []string{"oc_abc", "oc_def"}},
{name: "rejects empty list", input: nil, wantErr: true, errSubstr: "--chat-id is required"},
{name: "rejects bad prefix", input: []string{"om_abc"}, wantErr: true, errSubstr: "must be an open_chat_id"},
{
name: "accepts limit boundary",
input: []string{
"oc_1", "oc_2", "oc_3", "oc_4", "oc_5", "oc_6", "oc_7", "oc_8", "oc_9", "oc_10",
"oc_11", "oc_12", "oc_13", "oc_14", "oc_15", "oc_16", "oc_17", "oc_18", "oc_19", "oc_20",
"oc_21", "oc_22", "oc_23", "oc_24", "oc_25", "oc_26", "oc_27", "oc_28", "oc_29", "oc_30",
},
want: []string{
"oc_1", "oc_2", "oc_3", "oc_4", "oc_5", "oc_6", "oc_7", "oc_8", "oc_9", "oc_10",
"oc_11", "oc_12", "oc_13", "oc_14", "oc_15", "oc_16", "oc_17", "oc_18", "oc_19", "oc_20",
"oc_21", "oc_22", "oc_23", "oc_24", "oc_25", "oc_26", "oc_27", "oc_28", "oc_29", "oc_30",
},
},
{
name: "rejects over limit",
input: []string{
"oc_1", "oc_2", "oc_3", "oc_4", "oc_5",
"oc_6", "oc_7", "oc_8", "oc_9", "oc_10", "oc_11",
"oc_1", "oc_2", "oc_3", "oc_4", "oc_5", "oc_6", "oc_7", "oc_8", "oc_9", "oc_10",
"oc_11", "oc_12", "oc_13", "oc_14", "oc_15", "oc_16", "oc_17", "oc_18", "oc_19", "oc_20",
"oc_21", "oc_22", "oc_23", "oc_24", "oc_25", "oc_26", "oc_27", "oc_28", "oc_29", "oc_30",
"oc_31",
},
wantErr: true,
errSubstr: "too many --chat-id",
@@ -549,24 +558,15 @@ func TestImFeedShortcutListDryRunRendersGet(t *testing.T) {
t.Fatalf("DryRun output = %s, want %q", got, want)
}
}
if strings.Contains(got, "page_token") {
t.Fatalf("DryRun output = %s, should omit page_token on first-page request", got)
}
func TestImFeedShortcutListHasNoCustomFlags(t *testing.T) {
if len(ImFeedShortcutList.Flags) != 0 {
t.Fatalf("ImFeedShortcutList.Flags = %v, want no shortcut-specific flags", ImFeedShortcutList.Flags)
}
}
func TestImFeedShortcutListDryRunIncludesNonEmptyPageToken(t *testing.T) {
cmd := newFeedShortcutListCmd(t)
if err := cmd.Flags().Set("page-token", "tok1"); err != nil {
t.Fatalf("Set page-token error = %v", err)
}
rt := &common.RuntimeContext{Cmd: cmd}
got := ImFeedShortcutList.DryRun(context.Background(), rt).Format()
if !strings.Contains(got, "page_token=tok1") {
t.Fatalf("DryRun output = %s, want page_token=tok1", got)
}
}
func TestImFeedShortcutListHelpDoesNotTreatDetailAsArgName(t *testing.T) {
func TestImFeedShortcutListHelpShowsNoLegacyFlags(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "app", AppSecret: "secret", Brand: core.BrandFeishu,
})
@@ -584,71 +584,13 @@ func TestImFeedShortcutListHelpDoesNotTreatDetailAsArgName(t *testing.T) {
t.Fatalf("Help() error = %v", err)
}
got := out.String()
if strings.Contains(got, "--no-detail detail") {
t.Fatalf("help output treats `detail` as a flag arg name:\n%s", got)
}
if !strings.Contains(got, "--no-detail") {
t.Fatalf("help output missing --no-detail:\n%s", got)
}
}
func TestImFeedShortcutListDryRunMentionsDetailScope(t *testing.T) {
cmd := newFeedShortcutListCmd(t)
if err := cmd.Flags().Set("no-detail", "false"); err != nil {
t.Fatalf("Set no-detail error = %v", err)
}
rt := &common.RuntimeContext{Cmd: cmd}
got := ImFeedShortcutList.DryRun(context.Background(), rt).Format()
for _, want := range []string{
"im:chat:read",
"--no-detail",
"batch_query",
} {
if !strings.Contains(got, want) {
t.Fatalf("DryRun output = %s, want %q", got, want)
for _, banned := range []string{"--no-detail", "--page-token"} {
if strings.Contains(got, banned) {
t.Fatalf("help output should not mention legacy flag %s:\n%s", banned, got)
}
}
}
func TestImFeedShortcutListDoesNotExposeAutoPaginationFlags(t *testing.T) {
// Locks in the design decision: this shortcut is a one-page wrapper.
// If any of these reappear, callers/AI agents will assume auto-walking
// is supported and write code that silently double-fetches.
banned := map[string]bool{"page-all": true, "page-limit": true, "page-size": true}
for _, fl := range ImFeedShortcutList.Flags {
if banned[fl.Name] {
t.Fatalf("ImFeedShortcutList must not expose --%s", fl.Name)
}
}
}
func TestImFeedShortcutListPageTokenIsOptional(t *testing.T) {
// --page-token must NOT be Required: omitting it is the natural first-page
// signal (the server treats "missing" and "" the same). Forcing an empty
// string would just be noise.
for _, fl := range ImFeedShortcutList.Flags {
if fl.Name == "page-token" && fl.Required {
t.Fatalf("--page-token must be optional; omitting it should mean first page")
}
}
}
func TestImFeedShortcutListDetailOnByDefault(t *testing.T) {
// The real flag definition must keep detail enrichment on by default:
// --no-detail is an opt-out bool with a false zero-value default. The
// test-helper command flips it for isolation, so this definition-level
// check is what actually locks the shipped default against a flip.
for _, fl := range ImFeedShortcutList.Flags {
if fl.Name == "no-detail" {
if fl.Default != "" && fl.Default != "false" {
t.Fatalf("--no-detail default = %q, want unset/false (enrichment on by default)", fl.Default)
}
return
}
}
t.Fatalf("--no-detail flag not found on ImFeedShortcutList")
}
func TestFeedShortcutChatIDNotCobraRequired(t *testing.T) {
// --chat-id is mandatory, but must NOT be cobra-Required: cobra would
// intercept a missing flag before Validate runs and emit a plain-text
@@ -663,430 +605,46 @@ func TestFeedShortcutChatIDNotCobraRequired(t *testing.T) {
}
}
func TestFeedShortcutListQueryOmitsEmptyToken(t *testing.T) {
q := feedShortcutListQuery("")
if _, ok := q["page_token"]; ok {
t.Fatalf("feedShortcutListQuery(\"\") = %v, want no page_token key", q)
}
q = feedShortcutListQuery("next")
if v := q["page_token"]; len(v) != 1 || v[0] != "next" {
t.Fatalf("feedShortcutListQuery(\"next\") page_token = %v, want [next]", v)
}
}
func TestImFeedShortcutListExecuteForwardsToken(t *testing.T) {
tests := []struct {
name string
token string
wantSent string // value the server should see in ?page_token=
wantKey bool // whether ?page_token should appear at all
}{
{name: "first page omits param", token: "", wantSent: "", wantKey: false},
{name: "explicit token is forwarded", token: "tok1", wantSent: "tok1", wantKey: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var calls int
var sawKey bool
var gotToken string
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/im/v2/feed_shortcuts") {
return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
}
calls++
_, sawKey = req.URL.Query()["page_token"]
gotToken = req.URL.Query().Get("page_token")
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{
"shortcuts": []any{map[string]any{"feed_card_id": "oc_a", "type": float64(1)}},
"has_more": false,
"page_token": "end",
},
}), nil
}))
cmd := newFeedShortcutListCmd(t)
if err := cmd.Flags().Set("page-token", tt.token); err != nil {
t.Fatalf("Set page-token error = %v", err)
}
setRuntimeField(t, rt, "Cmd", cmd)
if err := ImFeedShortcutList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if calls != 1 {
t.Fatalf("expected 1 API call, got %d", calls)
}
if sawKey != tt.wantKey {
t.Fatalf("page_token query key present = %v, want %v", sawKey, tt.wantKey)
}
if gotToken != tt.wantSent {
t.Fatalf("page_token sent = %q, want %q", gotToken, tt.wantSent)
}
})
}
}
func TestShortcutTypeFromValue(t *testing.T) {
tests := []struct {
name string
v any
want ShortcutType
}{
{name: "float64 1 → chat", v: float64(1), want: ShortcutTypeChat},
{name: "int 1 → chat", v: 1, want: ShortcutTypeChat},
{name: "float64 0 → unknown", v: float64(0), want: ShortcutTypeUnknown},
{name: "unknown numeric → unknown ShortcutType(99)", v: float64(99), want: ShortcutType(99)},
{name: "string defaults to unknown", v: "1", want: ShortcutTypeUnknown},
{name: "nil defaults to unknown", v: nil, want: ShortcutTypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shortcutTypeFromValue(tt.v); got != tt.want {
t.Fatalf("shortcutTypeFromValue(%v) = %v, want %v", tt.v, got, tt.want)
}
})
}
}
func TestResolveChatDetailBatchesAt50(t *testing.T) {
func TestImFeedShortcutListExecuteRequestsFullList(t *testing.T) {
var calls int
var rawQuery string
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/batch_query") {
if !strings.Contains(req.URL.Path, "/open-apis/im/v2/feed_shortcuts") {
return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
}
calls++
// Echo each requested chat_id back with a synthetic name so we can
// confirm both that batching happened and that the response was
// parsed correctly.
body, _ := io.ReadAll(req.Body)
var parsed struct {
ChatIDs []string `json:"chat_ids"`
}
_ = json.Unmarshal(body, &parsed)
items := make([]any, 0, len(parsed.ChatIDs))
for _, id := range parsed.ChatIDs {
items = append(items, map[string]any{"chat_id": id, "name": "group-" + id})
}
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{"items": items},
}), nil
}))
setRuntimeScopes(t, rt, chatBatchQueryScope)
ids := make([]string, 120) // 50 + 50 + 20 → 3 batches
for i := range ids {
ids[i] = fmt.Sprintf("oc_%d", i)
}
got, err := resolveChatDetail(rt, ids)
if err != nil {
t.Fatalf("resolveChatDetail() error = %v", err)
}
if calls != 3 {
t.Fatalf("calls = %d, want 3 (120 ids / 50 batch size)", calls)
}
if len(got) != 120 {
t.Fatalf("resolved size = %d, want 120", len(got))
}
first := got["oc_0"]
last := got["oc_119"]
if first == nil || last == nil {
t.Fatalf("Items missing boundary entries: first=%v last=%v", first, last)
}
if first["name"] != "group-oc_0" || last["name"] != "group-oc_119" {
t.Fatalf("expected name passthrough; got first=%v last=%v", first["name"], last["name"])
}
}
func TestResolveChatDetailIncludesP2PChats(t *testing.T) {
// Unlike the old title-only resolver, the detail resolver keeps p2p chats
// in the result map (their full object carries chat_mode/p2p_target_id);
// only `name` is empty. Locks in that the empty-name skip was removed
// when we switched from `title` (string) to `detail` (full object).
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
rawQuery = req.URL.RawQuery
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{
"items": []any{
map[string]any{"chat_id": "oc_group", "name": "Engineering", "chat_mode": "group"},
map[string]any{"chat_id": "oc_p2p", "name": "", "chat_mode": "p2p", "p2p_target_id": "ou_x"},
"shortcuts": []any{
map[string]any{"feed_card_id": "oc_a", "type": float64(1)},
},
},
}), nil
}))
setRuntimeScopes(t, rt, chatBatchQueryScope)
got, err := resolveChatDetail(rt, []string{"oc_group", "oc_p2p"})
if err != nil {
t.Fatalf("resolveChatDetail() error = %v", err)
}
if got["oc_group"]["name"] != "Engineering" {
t.Fatalf("oc_group name = %v, want Engineering", got["oc_group"]["name"])
}
p2p, ok := got["oc_p2p"]
if !ok {
t.Fatalf("oc_p2p must be in Items even though name is empty (caller decides what to show)")
}
if p2p["chat_mode"] != "p2p" || p2p["p2p_target_id"] != "ou_x" {
t.Fatalf("p2p detail = %v, want chat_mode=p2p with p2p_target_id passthrough", p2p)
}
}
cmd := newFeedShortcutListCmd(t)
setRuntimeField(t, rt, "Cmd", cmd)
func TestResolveChatDetailDropsItemsWithoutChatID(t *testing.T) {
// Defensive: the server should always echo chat_id back, but if it ever
// returns an item missing chat_id we must not write a "" → object entry
// into the map and end up attaching nonsense to entries.
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{
"items": []any{
map[string]any{"chat_id": "oc_ok", "name": "ok"},
map[string]any{"name": "no chat_id"},
},
},
}), nil
}))
setRuntimeScopes(t, rt, chatBatchQueryScope)
got, err := resolveChatDetail(rt, []string{"oc_ok"})
if err != nil {
t.Fatalf("resolveChatDetail() error = %v", err)
}
if len(got) != 1 {
t.Fatalf("resolved size = %d, want 1 (entry without chat_id must be dropped)", len(got))
}
if _, ok := got[""]; ok {
t.Fatalf("got[\"\"] must not exist; got %v", got[""])
}
}
func TestResolveChatDetailPropagatesScopeError(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Fatalf("resolver should fail scope pre-flight before calling API: %s", req.URL.Path)
return nil, nil
}))
// Token resolves with a known-but-wrong scope set so the missing-scope
// branch (not the unknown-metadata warning branch) fires.
setRuntimeScopes(t, rt, "search:message")
_, err := resolveChatDetail(rt, []string{"oc_abc"})
if err == nil {
t.Fatalf("resolveChatDetail() expected scope error, got nil")
}
if !strings.Contains(err.Error(), chatBatchQueryScope) {
t.Fatalf("resolveChatDetail() error = %v, want mention of %s", err, chatBatchQueryScope)
}
}
func TestEnrichFeedShortcutDetailAttachesAndDedupes(t *testing.T) {
var calls int
var capturedIDs []string
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/batch_query") {
return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
}
calls++
body, _ := io.ReadAll(req.Body)
var parsed struct {
ChatIDs []string `json:"chat_ids"`
}
_ = json.Unmarshal(body, &parsed)
capturedIDs = append(capturedIDs, parsed.ChatIDs...)
items := make([]any, 0, len(parsed.ChatIDs))
for _, id := range parsed.ChatIDs {
items = append(items, map[string]any{
"chat_id": id,
"name": "name-of-" + id,
"chat_mode": "group",
})
}
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{"items": items},
}), nil
}))
setRuntimeScopes(t, rt, chatBatchQueryScope)
data := map[string]any{
"shortcuts": []any{
map[string]any{"feed_card_id": "oc_a", "type": float64(1)},
map[string]any{"feed_card_id": "oc_b", "type": float64(1)},
map[string]any{"feed_card_id": "oc_a", "type": float64(1)}, // duplicate
// Unknown type — must be skipped without aborting the whole call.
map[string]any{"feed_card_id": "doc_xxx", "type": float64(3)},
},
}
if err := enrichFeedShortcutDetail(rt, data); err != nil {
t.Fatalf("enrichFeedShortcutDetail() error = %v", err)
if err := ImFeedShortcutList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if calls != 1 {
t.Fatalf("calls = %d, want 1 (single batch covers all CHAT ids)", calls)
t.Fatalf("expected 1 API call, got %d", calls)
}
if len(capturedIDs) != 2 {
t.Fatalf("server saw chat_ids = %v, want 2 dedup'd ids", capturedIDs)
if rawQuery != "" {
t.Fatalf("request query = %q, want empty query string", rawQuery)
}
items := data["shortcuts"].([]any)
for _, ix := range []int{0, 1, 2} { // 2 is the duplicate of 0
detail, ok := items[ix].(map[string]any)["detail"].(map[string]any)
if !ok {
t.Fatalf("item[%d] missing detail field; got %v", ix, items[ix])
}
// The full chat object is passed through verbatim — not just a name.
if detail["chat_mode"] != "group" {
t.Fatalf("item[%d] detail.chat_mode = %v, want group (full object passthrough)", ix, detail["chat_mode"])
}
wantName := "name-of-" + items[ix].(map[string]any)["feed_card_id"].(string)
if detail["name"] != wantName {
t.Fatalf("item[%d] detail.name = %v, want %q", ix, detail["name"], wantName)
}
}
if _, ok := items[3].(map[string]any)["detail"]; ok {
t.Fatalf("item[3] (unknown type) should not have detail set")
}
}
func TestEnrichFeedShortcutDetailNoOpWhenEmpty(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Fatalf("must not call API for empty list: %s", req.URL.Path)
return nil, nil
}))
if err := enrichFeedShortcutDetail(rt, map[string]any{}); err != nil {
t.Fatalf("enrichFeedShortcutDetail(empty data) error = %v", err)
}
if err := enrichFeedShortcutDetail(rt, map[string]any{"shortcuts": []any{}}); err != nil {
t.Fatalf("enrichFeedShortcutDetail(empty shortcuts) error = %v", err)
}
}
func TestEnrichFeedShortcutDetailSkipsWhenNoSupportedType(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Fatalf("must not call batch_query when no resolvable types: %s", req.URL.Path)
return nil, nil
}))
data := map[string]any{
"shortcuts": []any{
map[string]any{"feed_card_id": "doc_1", "type": float64(3)}, // DOC, not exposed
map[string]any{"feed_card_id": "app_1", "type": float64(4)}, // OPENAPP, not exposed
map[string]any{"feed_card_id": "biz_1", "type": float64(13)}, // APP_FEED, not exposed
},
}
if err := enrichFeedShortcutDetail(rt, data); err != nil {
t.Fatalf("enrichFeedShortcutDetail() error = %v", err)
}
for i, it := range data["shortcuts"].([]any) {
if _, ok := it.(map[string]any)["detail"]; ok {
t.Fatalf("item[%d] should not have a detail (unknown type)", i)
}
}
}
func TestImFeedShortcutListExecuteEnrichesDetailByDefault(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/im/v2/feed_shortcuts"):
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{
"shortcuts": []any{
map[string]any{"feed_card_id": "oc_a", "type": float64(1)},
},
"has_more": false,
"page_token": "",
},
}), nil
case strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/batch_query"):
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{
"items": []any{
map[string]any{
"chat_id": "oc_a",
"name": "Team Alpha",
"chat_mode": "group",
},
},
},
}), nil
}
return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
}))
setRuntimeScopes(t, rt, feedShortcutReadScope+" "+chatBatchQueryScope)
cmd := newFeedShortcutListCmd(t)
if err := cmd.Flags().Set("no-detail", "false"); err != nil {
t.Fatalf("Set no-detail error = %v", err)
}
setRuntimeField(t, rt, "Cmd", cmd)
if err := ImFeedShortcutList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() error = %v", err)
}
out := rt.Factory.IOStreams.Out.(interface{ String() string }).String()
// Verify both the attach-field name and the full-object passthrough,
// so future regressions that drop fields (e.g. only keeping `name`)
// fail loudly here.
for _, want := range []string{
`"detail":`,
`"chat_mode": "group"`,
`"name": "Team Alpha"`,
} {
if !strings.Contains(out, want) {
t.Fatalf("stdout missing %q, got:\n%s", want, out)
}
}
}
func TestImFeedShortcutListExecuteWarnsOnEnrichFailure(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/im/v2/feed_shortcuts"):
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{
"shortcuts": []any{
map[string]any{"feed_card_id": "oc_a", "type": float64(1)},
},
"has_more": false,
"page_token": "",
},
}), nil
case strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/batch_query"):
return nil, fmt.Errorf("batch_query network failure")
}
return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
}))
setRuntimeScopes(t, rt, feedShortcutReadScope+" "+chatBatchQueryScope)
cmd := newFeedShortcutListCmd(t)
if err := cmd.Flags().Set("no-detail", "false"); err != nil {
t.Fatalf("Set no-detail error = %v", err)
}
setRuntimeField(t, rt, "Cmd", cmd)
// Listing should still succeed even when enrichment can't reach the API —
// failure becomes a stderr warning, not a hard exit.
if err := ImFeedShortcutList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() error = %v", err)
}
stderr := rt.Factory.IOStreams.ErrOut.(interface{ String() string }).String()
if !strings.Contains(stderr, "detail enrichment failed") {
t.Fatalf("stderr = %q, want enrichment warning", stderr)
}
// And the shortcut itself still appears, just without `detail`.
stdout := rt.Factory.IOStreams.Out.(interface{ String() string }).String()
if !strings.Contains(stdout, `"feed_card_id": "oc_a"`) {
t.Fatalf("stdout should still contain the bare shortcut entry; got:\n%s", stdout)
for _, want := range []string{`"feed_card_id": "oc_a"`, `"type": 1`} {
if !strings.Contains(stdout, want) {
t.Fatalf("stdout = %s, want %q", stdout, want)
}
}
if strings.Contains(stdout, `"detail"`) {
t.Fatalf("stdout should NOT contain detail when enrichment failed; got:\n%s", stdout)
}
// The degradation is mirrored as a machine-readable data field so
// stdout-only consumers can tell "skipped" from "nothing to enrich".
if !strings.Contains(stdout, `"_notice": "detail enrichment skipped`) {
t.Fatalf("stdout should carry the _notice degradation marker; got:\n%s", stdout)
for _, banned := range []string{`"detail"`, `"_notice"`, `"page_token"`, `"has_more"`} {
if strings.Contains(stdout, banned) {
t.Fatalf("stdout should not contain legacy field %s; got:\n%s", banned, stdout)
}
}
}

View File

@@ -92,13 +92,13 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
|----------|------|
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager |
| [`+chat-list`](references/lark-im-chat-list.md) | List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only) |
| [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation; user/bot; accepts `--chat-id` or `--user-id`, resolves P2P chat_id; key params: `--page-size` (not `--page-limit`), `--start`/`--end` for time range, `--sort asc\|desc`; **read reference before use** |
| [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination |
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only) |
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description |
| [`+messages-mget`](references/lark-im-messages-mget.md) | Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies |
| [`+messages-reply`](references/lark-im-messages-reply.md) | Reply to a message (supports thread replies); user/bot; supports text/markdown/post/media replies, reply-in-thread, idempotency key |
| [`+messages-resources-download`](references/lark-im-messages-resources-download.md) | Download images/files from a message; user/bot; supports automatic chunked download for large files (8MB chunks), auto-detects file extension from Content-Type |
| [`+messages-search`](references/lark-im-messages-search.md) | Search messages across chats; **user-only** (`--as user`); key params: `--query`, `--chat-id` (single, comma-sep; **not** `--chat-ids`), `--sender`, `--start`/`--end`, `--page-all`/`--page-limit`; **read reference before use** |
| [`+messages-search`](references/lark-im-messages-search.md) | Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, supports auto-pagination via `--page-all` / `--page-limit`, enriches results via batched mget and chats batch_query |
| [`+messages-send`](references/lark-im-messages-send.md) | Send a message to a chat or direct message; user/bot; sends to chat-id or user-id with text/markdown/post/media, supports idempotency key |
| [`+threads-messages-list`](references/lark-im-threads-messages-list.md) | List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination |
| [`+flag-create`](references/lark-im-flag-create.md) | Create a bookmark on a message; user-only; defaults to message-layer flag; use --flag-type feed for feed-layer flag (item_type auto-detected from chat mode) |

View File

@@ -9,7 +9,7 @@ This skill maps to shortcut: `lark-cli im +feed-shortcut-create`. Underlying API
Adds one or more chats to the **current user's** feed shortcuts — equivalent to right-clicking a chat in the Feishu client and pinning it to the feed sidebar.
- Only **CHAT-type** shortcuts are exposed by the OpenAPI gateway right now (`feed_card_id` must be an `oc_xxx` open_chat_id).
- Batch up to **10 chat IDs per call**; pass more by issuing multiple calls.
- The upstream OAPI currently documents up to 50 items per write call, but this CLI intentionally enforces a stricter **30 chat IDs per call** local limit; pass more by issuing multiple calls.
- Currently only supports **user identity** (`--as user`); bot identity is not allowed by the server.
- If you only know a group name, resolve its `oc_xxx` first with `im +chat-search` or `im +chat-list`.
@@ -34,7 +34,7 @@ lark-cli im +feed-shortcut-create --as user --chat-id oc_xxx --dry-run
| Parameter | Default | Description |
|------|------|------|
| `--chat-id <oc_xxx>` | required | open_chat_id to add as a feed shortcut; repeatable or comma-separated; **max 10 per call** |
| `--chat-id <oc_xxx>` | required | open_chat_id to add as a feed shortcut; repeatable or comma-separated; **CLI max 30 per call** |
| `--head` | true (implied) | Insert at the top of the shortcut list; mutually exclusive with `--tail` |
| `--tail` | false | Append at the bottom of the shortcut list |
| `--as user` | required | Server only accepts user_access_token for this API |

View File

@@ -6,45 +6,32 @@ This skill maps to shortcut: `lark-cli im +feed-shortcut-list`. Underlying API:
## What it does
Lists **one page** of the **current user's** feed shortcuts.
Lists the **current user's full** feed shortcut list.
- Only **CHAT-type** shortcuts are exposed via OpenAPI today (others in the IDL are not yet whitelisted).
- The shortcut is a **thin one-page wrapper** — there is no built-in auto-pagination. Callers drive their own loop when they actually need to paginate.
- Server-side page size is controlled by the service; in normal use one page usually covers the list.
- Pagination tokens are opaque. If a token is rejected because the shortcut list changed, restart by omitting `--page-token`.
- The latest OAPI contract returns the whole list directly, so this shortcut exposes **no pagination flags**.
- The shortcut also does **not** perform any follow-up `im.chats.batch_query` detail enrichment.
## Commands
```bash
# First page (the only call most users ever need — --page-token omitted)
# List the current user's full shortcut list
lark-cli im +feed-shortcut-list --as user
# Continue from the previous response's page_token
lark-cli im +feed-shortcut-list --as user --page-token <token-from-previous-response>
# Skip detail enrichment when only IDs are needed; avoids the extra im:chat:read lookup
lark-cli im +feed-shortcut-list --as user --no-detail -q '.data.shortcuts[].feed_card_id'
```
> If you need to walk every page, write the loop yourself: read `data.page_token` from each response and pass it back in until `has_more=false`. The shortcut intentionally does not auto-walk because page-token errors require the caller to decide whether to restart from the first page.
## Parameters
| Parameter | Required | Description |
|------|------|------|
| `--page-token <token>` | no | Opaque pagination token from the previous response. **Omit it for the first page.** |
| `--no-detail` | no (default `false`) | Skip fetching each entry's full info object. By default enrichment is enabled: CHAT-type entries call `im.chats.batch_query`, need `im:chat:read`, and attach the object under the `detail` field. Pass `--no-detail` to skip the extra call and scope. |
| `--as user` | yes | Server only accepts user_access_token for this API |
## Response Structure
| Field | Type | Description |
|------|------|------|
| `shortcuts` | array | Feed shortcut entries; each has `feed_card_id` (oc_xxx) and `type` (1=CHAT). By default (without `--no-detail`), each entry also has a `detail` field with the full per-type info object. |
| `has_more` | boolean | Whether more pages exist |
| `page_token` | string | Opaque token to pass to the next call when continuing pagination |
| `shortcuts` | array | Feed shortcut entries; each has `feed_card_id` (oc_xxx) and `type` (1=CHAT). |
Example (with detail enrichment, CHAT type):
Example:
```json
{
@@ -52,52 +39,18 @@ Example (with detail enrichment, CHAT type):
"shortcuts": [
{
"feed_card_id": "oc_092f0100fe59c35995727db1039777a8",
"type": 1,
"detail": {
"chat_id": "oc_092f0100fe59c35995727db1039777a8",
"chat_mode": "group",
"name": "Engineering",
"avatar": "https://...",
"description": "",
"external": false,
"owner_id": "ou_xxx",
"owner_id_type": "open_id",
"tenant_key": "..."
}
"type": 1
},
{
"feed_card_id": "oc_c82061d126a06635aa3569587b134bb1",
"type": 1,
"detail": {
"chat_id": "oc_c82061d126a06635aa3569587b134bb1",
"chat_mode": "p2p",
"name": "",
"p2p_target_id": "ou_xxx",
"p2p_target_type": "user",
"avatar": "",
"description": "",
"external": false,
"tenant_key": "..."
}
"type": 1
}
],
"has_more": false,
"page_token": "v1.example-opaque-token"
]
}
}
```
## Detail Enrichment
The `detail` payload is dispatched **per `type`**. Today only CHAT is wired in; future shortcut types can attach different object shapes. Callers should `switch` on `type` before parsing `detail`. For CHAT (`type=1`):
- **Source**: `POST /open-apis/im/v1/chats/batch_query` (50 ids per call, server limit).
- **Payload**: the **full chat object** is passed through verbatim — `chat_id`, `chat_mode` (`group` / `p2p` / `topic`), `name`, `avatar`, `description`, `external`, `tenant_key`, plus type-specific fields (`owner_id*` for groups, `p2p_target_*` for p2p).
- **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".
## Permissions
- Required scope: `im:feed.shortcut:read`
- Conditional scope (default detail path only): `im:chat:read`; pass `--no-detail` to avoid this extra scope and lookup.
- Only available with user identity (`--as user`).

View File

@@ -9,7 +9,7 @@ This skill maps to shortcut: `lark-cli im +feed-shortcut-remove`. Underlying API
Removes one or more chats from the **current user's** feed shortcuts.
- Only **CHAT-type** shortcuts are supported (`feed_card_id` must be an `oc_xxx`).
- Batch up to **10 chat IDs per call**.
- The upstream OAPI currently documents up to 50 items per write call, but this CLI intentionally enforces a stricter **30 chat IDs per call** local limit.
- Currently only supports **user identity** (`--as user`).
- Removing a chat that is not currently in the shortcut list is idempotent success: the call returns `ok:true`, `failure_count=0`, and no `failed_shortcuts` entry for that chat.
@@ -31,7 +31,7 @@ lark-cli im +feed-shortcut-remove --as user --chat-id oc_xxx --dry-run
| Parameter | Required | Description |
|------|------|------|
| `--chat-id <oc_xxx>` | yes | open_chat_id to remove from feed shortcuts; repeatable or comma-separated; max 10 per call |
| `--chat-id <oc_xxx>` | yes | open_chat_id to remove from feed shortcuts; repeatable or comma-separated; CLI max 30 per call |
| `--as user` | yes | Server only accepts user_access_token for this API |
## Response
@@ -45,4 +45,4 @@ The response uses the same batch ledger as [`+feed-shortcut-create`](lark-im-fee
## Note
- To see what is currently in the shortcut list before removing, run [`+feed-shortcut-list`](lark-im-feed-shortcut-list.md). Use `--no-detail` when you only need the `feed_card_id` values.
- To see what is currently in the shortcut list before removing, run [`+feed-shortcut-list`](lark-im-feed-shortcut-list.md).

View File

@@ -55,7 +55,7 @@ func TestIM_FeedShortcutWorkflowAsUser(t *testing.T) {
}
})
t.Run("list feed shortcuts as user with detail enrichment", func(t *testing.T) {
t.Run("list feed shortcuts as user", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-list",
@@ -85,43 +85,13 @@ func TestIM_FeedShortcutWorkflowAsUser(t *testing.T) {
}
found = true
require.Equal(t, int64(1), item.Get("type").Int(), "type should be 1 (CHAT)")
// detail enrichment is on by default — the chat we just created
// must come back with the chat info object attached.
require.True(t, item.Get("detail").Exists(),
"detail field should be attached when enrichment is enabled")
require.Equal(t, chatID, item.Get("detail.chat_id").String(),
"detail.chat_id should echo feed_card_id")
require.Equal(t, chatName, item.Get("detail.name").String(),
"detail.name should carry the chat's group name")
require.False(t, item.Get("detail").Exists(),
"detail field should not exist in the direct list contract")
break
}
require.True(t, found, "expected chat %s in feed shortcut list", chatID)
})
t.Run("list feed shortcuts with --no-detail skips lookup", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-list",
"--no-detail",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
var foundEntry gjson.Result
for _, item := range gjson.Get(result.Stdout, "data.shortcuts").Array() {
if item.Get("feed_card_id").String() == chatID {
foundEntry = item
break
}
}
require.True(t, foundEntry.Exists(), "expected our chat in the bare list")
require.False(t, foundEntry.Get("detail").Exists(),
"detail field should NOT be present with --no-detail")
})
t.Run("unpin chat from feed as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
@@ -143,7 +113,6 @@ func TestIM_FeedShortcutWorkflowAsUser(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-list",
"--no-detail",
},
DefaultAs: "user",
}, clie2e.RetryOptions{
@@ -277,7 +246,7 @@ func cleanupFeedShortcuts(parentT *testing.T, defaultAs string, chatIDs ...strin
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
listResult, listErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"im", "+feed-shortcut-list", "--no-detail"},
Args: []string{"im", "+feed-shortcut-list"},
DefaultAs: defaultAs,
})
clie2e.ReportCleanupFailure(parentT, "cleanup feed shortcuts list", listResult, listErr)
@@ -410,7 +379,7 @@ func TestIM_FeedShortcutDryRun(t *testing.T) {
require.NotContains(t, result.Stdout, "is_header", "remove must not send is_header")
})
t.Run("list dry-run mentions detail enrichment path", func(t *testing.T) {
t.Run("list dry-run hits feed_shortcuts endpoint directly", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-list",
@@ -422,24 +391,7 @@ func TestIM_FeedShortcutDryRun(t *testing.T) {
result.AssertExitCode(t, 0)
require.Contains(t, result.Stdout, "GET")
require.Contains(t, result.Stdout, "/open-apis/im/v2/feed_shortcuts")
// Enrichment is on by default → DryRun adds a desc about the extra
// chats.batch_query call and the conditional scope.
require.Contains(t, result.Stdout, "im:chat:read")
require.Contains(t, result.Stdout, "batch_query")
})
t.Run("list dry-run with --no-detail omits the extra-scope note", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-list",
"--no-detail",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.NotContains(t, result.Stdout, "im:chat:read",
"with --no-detail, dry-run must not mention im:chat:read")
require.NotContains(t, result.Stdout, "im:chat:read")
require.NotContains(t, result.Stdout, "batch_query")
})
}