mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Compare commits
1 Commits
v1.0.53
...
feat/feed-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7ccd4e636 |
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user