feat(im): add feed shortcut create, list, and remove shortcuts (#1273)

Adds feed shortcut management to the im domain: pin chats to the user's feed sidebar, list pinned entries, and unpin them. Three new shortcuts wrap the im/v2/feed_shortcuts OpenAPI routes, which currently expose CHAT-type entries only and accept user identity only.
This commit is contained in:
evandance
2026-06-05 16:42:48 +08:00
committed by GitHub
parent a75420f72c
commit be5527ca4e
14 changed files with 2356 additions and 20 deletions

View File

@@ -1405,3 +1405,302 @@ func resolveThreadFeedItemType(rt *common.RuntimeContext, chatID string) (ItemTy
}
return ItemTypeMsgThread, nil
}
// ShortcutType enumerates the OpenAPI feed-shortcut types.
// Currently the server only opens CHAT (1) externally; other internal values
// (DOC, OPENAPP, etc.) are not yet whitelisted on the OAPI gateway.
type ShortcutType int
const (
ShortcutTypeUnknown ShortcutType = 0
ShortcutTypeChat ShortcutType = 1
)
const (
feedShortcutBatchLimit = 10
feedShortcutWriteScope = "im:feed.shortcut:write"
feedShortcutReadScope = "im:feed.shortcut:read"
)
// shortcutItem is one entry in the feed_shortcuts API body.
type shortcutItem struct {
FeedCardID string `json:"feed_card_id"`
Type int `json:"type"`
}
// collectChatIDs reads --chat-id values (repeatable + comma-split) and
// returns deduped, validated oc_ IDs. The server batch limit is 10.
func collectChatIDs(rt *common.RuntimeContext) ([]string, error) {
raw := rt.StrSlice("chat-id")
if len(raw) == 0 {
return nil, output.ErrValidation("--chat-id is required (oc_xxx); repeat the flag or pass comma-separated values")
}
seen := make(map[string]struct{}, len(raw))
out := make([]string, 0, len(raw))
for _, v := range raw {
v = strings.TrimSpace(v)
if v == "" {
continue
}
if !strings.HasPrefix(v, "oc_") {
return nil, output.ErrValidation(
"invalid --chat-id %q: must be an open_chat_id starting with oc_", v)
}
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
if len(out) == 0 {
return nil, output.ErrValidation("--chat-id is required (oc_xxx)")
}
if len(out) > feedShortcutBatchLimit {
return nil, output.ErrValidation(
"too many --chat-id values (%d); the server accepts up to %d per request",
len(out), feedShortcutBatchLimit)
}
return out, nil
}
// buildShortcutItems converts chat IDs to API payload entries (type=CHAT).
func buildShortcutItems(ids []string) []shortcutItem {
items := make([]shortcutItem, 0, len(ids))
for _, id := range ids {
items = append(items, shortcutItem{FeedCardID: id, Type: int(ShortcutTypeChat)})
}
return items
}
// shortcutFailedReasonString converts the numeric failed-reason enum returned
// by the server into a human-readable label. Used to enrich the response
// when the API reports per-item failures.
func shortcutFailedReasonString(reason int) string {
switch reason {
case 0:
return "unknown"
case 1:
return "no_permission"
case 2:
return "invalid_item"
case 3:
return "has_pending_delete"
case 4:
return "type_not_support"
case 5:
return "internal_error"
}
return "unknown"
}
// chatBatchQueryScope is the scope required by im.chats.batch_query, which
// the CHAT detail resolver depends on. Surfaced as a conditional scope on
// +feed-shortcut-list so the framework's scope diagnostics know about it.
const chatBatchQueryScope = "im:chat:read"
// chatBatchQuerySize matches the server-side limit on /im/v1/chats/batch_query.
const chatBatchQuerySize = 50
// shortcutTypeFromValue parses the type field as returned by the v2
// feed_shortcuts API. JSON numbers come back as float64 after generic
// unmarshal; we also tolerate the int form for forward-compat.
func shortcutTypeFromValue(v any) ShortcutType {
switch n := v.(type) {
case float64:
return ShortcutType(int(n))
case int:
return ShortcutType(n)
}
return ShortcutTypeUnknown
}
// queryChatBatch fetches one im.chats.batch_query page (at most
// chatBatchQuerySize ids) and merges the full chat objects into dst keyed by
// chat_id. Shared by feed-shortcut detail enrichment and message-search chat
// context lookup, which apply their own per-chunk error policies.
func queryChatBatch(rt *common.RuntimeContext, batch []string, dst map[string]map[string]any) error {
res, err := rt.DoAPIJSON(http.MethodPost, "/open-apis/im/v1/chats/batch_query",
larkcore.QueryParams{"user_id_type": []string{"open_id"}},
map[string]any{"chat_ids": batch})
if err != nil {
return err
}
items, _ := res["items"].([]any)
for _, ci := range items {
cm, _ := ci.(map[string]any)
if cm == nil {
continue
}
if id := asString(cm["chat_id"]); id != "" {
dst[id] = cm
}
}
return nil
}
// resolveChatDetail batch-fetches the full chat object via
// im.chats.batch_query (50 ids per request — server limit) and returns the
// objects keyed by chat_id, verbatim, so the caller can decide which fields
// to surface. The server's `name` field is empty for p2p chats (client UI
// shows the partner's display name there), but the full object still carries
// `chat_mode`, `p2p_target_id`, `description`, etc., so callers can render
// p2p entries however they want.
func resolveChatDetail(rt *common.RuntimeContext, ids []string) (map[string]map[string]any, error) {
out := map[string]map[string]any{}
if len(ids) == 0 {
return out, nil
}
if err := checkFlagRequiredScopes(rt.Ctx(), rt, []string{chatBatchQueryScope}); err != nil {
return nil, err
}
for _, batch := range chunkStrings(ids, chatBatchQuerySize) {
if err := queryChatBatch(rt, batch, out); err != nil {
return nil, err
}
}
return out, nil
}
// enrichFeedShortcutDetail walks the list response and attaches the full chat
// object under `detail` for CHAT-type entries — the only type the OpenAPI
// gateway exposes today. Mutates data in place.
//
// Failures are returned to the caller so it can decide whether to hard-fail
// the command or downgrade to a warning. Listing the shortcuts succeeds even
// if enrichment is unavailable (missing scope, network error, etc.).
func enrichFeedShortcutDetail(rt *common.RuntimeContext, data map[string]any) error {
items, _ := data["shortcuts"].([]any)
if len(items) == 0 {
return nil
}
seen := map[string]struct{}{}
ids := make([]string, 0, len(items))
for _, it := range items {
m, _ := it.(map[string]any)
if m == nil || shortcutTypeFromValue(m["type"]) != ShortcutTypeChat {
continue
}
id := asString(m["feed_card_id"])
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
if len(ids) == 0 {
return nil
}
details, err := resolveChatDetail(rt, ids)
if err != nil {
return err
}
// Missing items (server didn't return one for an id we asked about) are
// left untouched, so the presence of `detail` signals a successful lookup.
for _, it := range items {
m, _ := it.(map[string]any)
if m == nil || shortcutTypeFromValue(m["type"]) != ShortcutTypeChat {
continue
}
if info, ok := details[asString(m["feed_card_id"])]; ok {
m["detail"] = info
}
}
return nil
}
// annotateFailedShortcuts walks the API response and attaches a
// reason_label string next to each numeric reason. Mutates data in place.
func annotateFailedShortcuts(data map[string]any) {
items, ok := data["failed_shortcuts"].([]any)
if !ok {
return
}
for _, it := range items {
m, _ := it.(map[string]any)
if m == nil {
continue
}
// reason is serialized as a JSON number → float64 after generic unmarshal.
switch r := m["reason"].(type) {
case float64:
m["reason_label"] = shortcutFailedReasonString(int(r))
case int:
m["reason_label"] = shortcutFailedReasonString(r)
}
}
}
// emitFeedShortcutWriteResult preserves the server payload while adding a
// batch ledger. A feed-shortcut write can return HTTP/API success with
// failed_shortcuts populated; callers still need a complete account of which
// requested entries succeeded and which failed.
func emitFeedShortcutWriteResult(rt *common.RuntimeContext, requested []shortcutItem, data map[string]any) error {
// A fully-successful write can come back as code:0 with data:null, in
// which case DoAPIJSON hands us a nil map; the caller is still owed a
// ledger, so start from an empty object instead of panicking on write.
if data == nil {
data = map[string]any{}
}
annotateFailedShortcuts(data)
addFeedShortcutWriteLedger(data, requested)
if hasFailedShortcuts(data) {
return rt.OutPartialFailure(data, nil)
}
rt.Out(data, nil)
return nil
}
func addFeedShortcutWriteLedger(data map[string]any, requested []shortcutItem) {
failed := failedShortcutItems(data)
// Failed entries are matched back to requested items by feed_card_id
// alone: every requested item is CHAT-type, so the id is the identity,
// and a failed echo with a missing or zero type still excludes its item
// from the success list.
failedIDs := map[string]struct{}{}
for _, it := range failed {
m, _ := it.(map[string]any)
if m == nil {
continue
}
shortcut, _ := m["shortcut"].(map[string]any)
if shortcut == nil {
continue
}
if id := asString(shortcut["feed_card_id"]); id != "" {
failedIDs[id] = struct{}{}
}
}
succeeded := make([]shortcutItem, 0, len(requested))
for _, it := range requested {
if _, isFailed := failedIDs[it.FeedCardID]; isFailed {
continue
}
succeeded = append(succeeded, it)
}
// Counts are derived from the requested-item accounting alone so the
// success+failure==total invariant holds even if the server echoes a
// failed entry twice or reports one we never asked about;
// failed_shortcuts still carries the raw server report.
data["total"] = len(requested)
data["success_count"] = len(succeeded)
data["failure_count"] = len(requested) - len(succeeded)
data["succeeded_shortcuts"] = succeeded
}
func hasFailedShortcuts(data map[string]any) bool {
return len(failedShortcutItems(data)) > 0
}
func failedShortcutItems(data map[string]any) []any {
items, _ := data["failed_shortcuts"].([]any)
return items
}

View File

@@ -606,6 +606,9 @@ func TestShortcuts(t *testing.T) {
"+flag-create",
"+flag-cancel",
"+flag-list",
"+feed-shortcut-create",
"+feed-shortcut-remove",
"+feed-shortcut-list",
}
if !reflect.DeepEqual(commands, want) {
t.Fatalf("Shortcuts() commands = %#v, want %#v", commands, want)

View File

@@ -0,0 +1,97 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// ImFeedShortcutCreate provides the +feed-shortcut-create shortcut for adding
// chats to the user's feed shortcuts. Currently only CHAT-type shortcuts are
// exposed by the OpenAPI gateway; feed_card_id must be an open_chat_id
// (oc_xxx).
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",
Risk: "write",
UserScopes: []string{feedShortcutWriteScope},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
// chat-id is mandatory but intentionally not cobra-Required: the
// requiredness check lives in collectChatIDs so a missing flag is
// 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"},
{Name: "head", Type: "bool",
Desc: "insert at the top of the shortcut list (default); mutually exclusive with --tail"},
{Name: "tail", Type: "bool",
Desc: "append at the bottom of the shortcut list; mutually exclusive with --head"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := collectChatIDs(runtime); err != nil {
return err
}
_, err := resolveIsHeader(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ids, err := collectChatIDs(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
isHeader, err := resolveIsHeader(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST("/open-apis/im/v2/feed_shortcuts").
Body(map[string]any{
"shortcuts": buildShortcutItems(ids),
"is_header": isHeader,
})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ids, err := collectChatIDs(runtime)
if err != nil {
return err
}
isHeader, err := resolveIsHeader(runtime)
if err != nil {
return err
}
items := buildShortcutItems(ids)
data, err := runtime.DoAPIJSON("POST", "/open-apis/im/v2/feed_shortcuts", nil,
map[string]any{
"shortcuts": items,
"is_header": isHeader,
})
if err != nil {
return err
}
return emitFeedShortcutWriteResult(runtime, items, data)
},
}
// resolveIsHeader determines the insertion position.
// - default (neither flag set) → true (head)
// - --head → true
// - --tail → false
// - both set → error
func resolveIsHeader(rt *common.RuntimeContext) (bool, error) {
head := rt.Bool("head")
tail := rt.Bool("tail")
if head && tail {
return false, output.ErrValidation("--head and --tail are mutually exclusive")
}
if tail {
return false, nil
}
return true, nil
}

View File

@@ -0,0 +1,79 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
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.
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)"},
},
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
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
data, err := runtime.DoAPIJSON("GET", "/open-apis/im/v2/feed_shortcuts",
feedShortcutListQuery(runtime.Str("page-token")), 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

@@ -0,0 +1,57 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
// ImFeedShortcutRemove provides the +feed-shortcut-remove shortcut for
// removing chats from the user's feed shortcuts. Per-item failures are kept
// in stdout and returned as a partial-failure exit.
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",
Risk: "write",
UserScopes: []string{feedShortcutWriteScope},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
// chat-id is mandatory but intentionally not cobra-Required: the
// requiredness check lives in collectChatIDs so a missing flag is
// 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"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := collectChatIDs(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ids, err := collectChatIDs(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST("/open-apis/im/v2/feed_shortcuts/remove").
Body(map[string]any{"shortcuts": buildShortcutItems(ids)})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ids, err := collectChatIDs(runtime)
if err != nil {
return err
}
items := buildShortcutItems(ids)
data, err := runtime.DoAPIJSON("POST", "/open-apis/im/v2/feed_shortcuts/remove", nil,
map[string]any{"shortcuts": items})
if err != nil {
return err
}
return emitFeedShortcutWriteResult(runtime, items, data)
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,6 @@ const (
messagesSearchDefaultPageLimit = 20
messagesSearchMaxPageLimit = 40
messagesSearchMGetBatchSize = 50
messagesSearchChatBatchSize = 50
)
var ImMessagesSearch = common.Shortcut{
@@ -459,23 +458,9 @@ func batchMGetMessages(runtime *common.RuntimeContext, messageIds []string) ([]i
func batchQueryChatContexts(runtime *common.RuntimeContext, chatIds []string) map[string]map[string]interface{} {
chatContexts := map[string]map[string]interface{}{}
for _, batch := range chunkStrings(chatIds, messagesSearchChatBatchSize) {
chatRes, chatErr := runtime.DoAPIJSON(
http.MethodPost, "/open-apis/im/v1/chats/batch_query",
larkcore.QueryParams{"user_id_type": []string{"open_id"}},
map[string]interface{}{"chat_ids": batch},
)
if chatErr != nil {
continue
}
if chatItems, ok := chatRes["items"].([]interface{}); ok {
for _, ci := range chatItems {
cm, _ := ci.(map[string]interface{})
if cid, _ := cm["chat_id"].(string); cid != "" {
chatContexts[cid] = cm
}
}
}
// Best-effort: a failed chunk only loses its own entries.
for _, batch := range chunkStrings(chatIds, chatBatchQuerySize) {
_ = queryChatBatch(runtime, batch, chatContexts)
}
return chatContexts
}

View File

@@ -22,5 +22,8 @@ func Shortcuts() []common.Shortcut {
ImFlagCreate,
ImFlagCancel,
ImFlagList,
ImFeedShortcutCreate,
ImFeedShortcutRemove,
ImFeedShortcutList,
}
}

View File

@@ -5,6 +5,7 @@
- **Thread**: A reply thread under a message, identified by `thread_id` (om_xxx or omt_xxx).
- **Reaction**: An emoji reaction on a message.
- **Flag**: A bookmark on a message or thread.
- **Feed Shortcut**: A chat pinned to the current user's feed sidebar, identified by `feed_card_id` (an `oc_xxx` open_chat_id for CHAT type).
## Resource Relationships
@@ -51,3 +52,15 @@ Flags support two layers:
Item types for feed-layer flags:
- **ItemTypeThread** (4) = thread in a topic-style chat
- **ItemTypeMsgThread** (11) = thread in a regular chat
### Feed Shortcut
Feed shortcuts add chats to the **current user's** feed sidebar. They are distinct from flags:
- **Flag** = bookmark on a message/thread, scoped to the user's bookmark list.
- **Feed shortcut** = entry in the user's feed sidebar (currently only chats).
Key limits:
- Only **CHAT-type** (`feed_card_id` is `oc_xxx`) is exposed via OpenAPI; doc/app/subscription shortcuts exist internally but are not yet whitelisted.
- All three operations (create/remove/list) are **user-identity only** — they sign with `user_access_token`.
- Batch size is **10 per call** for create/remove; list is a one-page wrapper with opaque `page_token` pagination.

View File

@@ -1,7 +1,7 @@
---
name: lark-im
version: 1.0.0
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复、发送应用内/短信/电话加急。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据时使用。"
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复、发送应用内/短信/电话加急。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据、管理 Feed 置顶(添加/移除/查询置顶会话)时使用。"
metadata:
requires:
bins: ["lark-cli"]
@@ -19,6 +19,7 @@ metadata:
- **Thread**: A reply thread under a message, identified by `thread_id` (om_xxx or omt_xxx).
- **Reaction**: An emoji reaction on a message.
- **Flag**: A bookmark on a message or thread.
- **Feed Shortcut**: A chat pinned to the current user's feed sidebar, identified by `feed_card_id` (an `oc_xxx` open_chat_id for CHAT type).
## Resource Relationships
@@ -66,6 +67,18 @@ Item types for feed-layer flags:
- **ItemTypeThread** (4) = thread in a topic-style chat
- **ItemTypeMsgThread** (11) = thread in a regular chat
### Feed Shortcut
Feed shortcuts add chats to the current user's feed sidebar. They are distinct from flags:
- **Flag** = bookmark on a message/thread, scoped to the user's bookmark list.
- **Feed shortcut** = entry in the user's feed sidebar (currently only chats).
Key limits:
- Only **CHAT-type** (`feed_card_id` is `oc_xxx`) is exposed via OpenAPI; doc/app/subscription shortcuts exist internally but are not yet whitelisted.
- All three operations (create/remove/list) are **user-identity only** — they sign with `user_access_token`.
- Batch size is **10 per call** for create/remove; list is a one-page wrapper with opaque `page_token` pagination.
## Shortcuts推荐优先使用
Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。有 Shortcut 的操作优先使用。
@@ -86,6 +99,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
| [`+flag-create`](references/lark-im-flag-create.md) | Create a bookmark on a message or thread; user-only; defaults to message-layer flag; feed-layer flag requires explicit --item-type + --flag-type |
| [`+flag-cancel`](references/lark-im-flag-cancel.md) | Cancel (remove) a bookmark. When no --flag-type is given, checks if the message is a thread root message; if so, cancels both message and feed layers |
| [`+flag-list`](references/lark-im-flag-list.md) | List bookmarks; user-only; auto-enriches feed-type thread entries with message content; supports `--page-all` auto-pagination |
| [`+feed-shortcut-create`](references/lark-im-feed-shortcut-create.md) | Add chats to the user's feed shortcuts; user-only; oc_xxx chat IDs only; batch up to 10 per call; `--head`/`--tail` controls insertion order; partial failures return an `ok:false` ledger |
| [`+feed-shortcut-remove`](references/lark-im-feed-shortcut-remove.md) | Remove chats from the user's feed shortcuts; user-only; batch up to 10 per call; removing an absent shortcut is idempotent success; real per-item failures return an `ok:false` ledger |
| [`+feed-shortcut-list`](references/lark-im-feed-shortcut-list.md) | List one page of the user's feed shortcuts; user-only; omit `--page-token` for the first page; default output enriches CHAT entries under `detail`; pass `--no-detail` to skip the extra lookup and `im:chat:read` scope |
## API Resources
@@ -169,4 +185,3 @@ lark-cli im <resource> <method> [flags] # 调用 API
| `pins.create` | `im:message.pins:write_only` |
| `pins.delete` | `im:message.pins:write_only` |
| `pins.list` | `im:message.pins:read` |

View File

@@ -0,0 +1,97 @@
# im +feed-shortcut-create
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) for authentication, global parameters, and security rules.
This skill maps to shortcut: `lark-cli im +feed-shortcut-create`. Underlying API: `POST /open-apis/im/v2/feed_shortcuts`.
## What it does
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.
- 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`.
## Commands
```bash
# Add a single chat as a feed shortcut (defaults to head/top insertion)
lark-cli im +feed-shortcut-create --as user --chat-id oc_xxx
# Add multiple chats; comma-separated or repeated flag both work
lark-cli im +feed-shortcut-create --as user --chat-id oc_a,oc_b,oc_c
lark-cli im +feed-shortcut-create --as user --chat-id oc_a --chat-id oc_b
# Append at the bottom of the shortcut list instead of the top
lark-cli im +feed-shortcut-create --as user --chat-id oc_xxx --tail
# Preview the request without sending
lark-cli im +feed-shortcut-create --as user --chat-id oc_xxx --dry-run
```
## Parameters
| 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** |
| `--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 |
## Response
The response is a batch ledger. A full success exits `0` with `ok:true`. Any non-empty `failed_shortcuts` is a partial failure: the process exits non-zero (currently exit `1`), stdout carries `ok:false`, and the full ledger remains machine-readable:
| Field | Meaning |
|------|------|
| `total` | Number of requested shortcuts |
| `success_count` | Number of requested shortcuts not reported in `failed_shortcuts` |
| `failure_count` | Number of requested shortcuts reported as failed; `failed_shortcuts` preserves the raw server failure list |
| `succeeded_shortcuts` | Requested shortcut entries that succeeded |
| `failed_shortcuts` | Per-item failures returned by the server, enriched with `reason_label` |
The shortcut adds a `reason_label` field next to each numeric `reason`:
| `reason` | `reason_label` | Meaning |
|---:|------|------|
| 1 | `no_permission` | User has no permission on the feed card |
| 2 | `invalid_item` | `feed_card_id` is invalid or type doesn't match |
| 3 | `has_pending_delete` | The chat is being deleted |
| 4 | `type_not_support` | Type is not whitelisted (only CHAT is open now) |
| 5 | `internal_error` | Server internal error |
Example:
```json
{
"ok": false,
"data": {
"total": 2,
"success_count": 1,
"failure_count": 1,
"succeeded_shortcuts": [
{ "feed_card_id": "oc_good", "type": 1 }
],
"failed_shortcuts": [
{
"shortcut": { "feed_card_id": "oc_bad", "type": 1 },
"reason": 2,
"reason_label": "invalid_item"
}
]
}
}
```
## Permissions
- Required scope: `im:feed.shortcut:write`
- Only available with user identity (`--as user`). The CLI will reject `--as bot` for this shortcut.
## Note
- The shortcut list is **per user**: the call adds shortcuts for the currently authenticated user only.
- Adding the same chat twice is **idempotent at the user level** (re-adding an existing shortcut is a no-op rather than an error).
- Scripts should check the process exit code, top-level `ok`, and ledger counts. Partial failures intentionally keep machine-readable success and failure details on stdout.
- To inspect the current shortcut list, use [`+feed-shortcut-list`](lark-im-feed-shortcut-list.md). To remove a shortcut, use [`+feed-shortcut-remove`](lark-im-feed-shortcut-remove.md).

View File

@@ -0,0 +1,103 @@
# im +feed-shortcut-list
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) for authentication, global parameters, and security rules.
This skill maps to shortcut: `lark-cli im +feed-shortcut-list`. Underlying API: `GET /open-apis/im/v2/feed_shortcuts`.
## What it does
Lists **one page** of the **current user's** feed shortcuts.
- 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`.
## Commands
```bash
# First page (the only call most users ever need — --page-token omitted)
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 |
Example (with detail enrichment, CHAT type):
```json
{
"data": {
"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": "..."
}
},
{
"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": "..."
}
}
],
"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

@@ -0,0 +1,48 @@
# im +feed-shortcut-remove
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) for authentication, global parameters, and security rules.
This skill maps to shortcut: `lark-cli im +feed-shortcut-remove`. Underlying API: `POST /open-apis/im/v2/feed_shortcuts/remove`.
## What it does
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**.
- 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.
## Commands
```bash
# Remove a single feed shortcut
lark-cli im +feed-shortcut-remove --as user --chat-id oc_xxx
# Remove multiple feed shortcuts in one call
lark-cli im +feed-shortcut-remove --as user --chat-id oc_a,oc_b
lark-cli im +feed-shortcut-remove --as user --chat-id oc_a --chat-id oc_b
# Preview the request
lark-cli im +feed-shortcut-remove --as user --chat-id oc_xxx --dry-run
```
## Parameters
| Parameter | Required | Description |
|------|------|------|
| `--chat-id <oc_xxx>` | yes | open_chat_id to remove from feed shortcuts; repeatable or comma-separated; max 10 per call |
| `--as user` | yes | Server only accepts user_access_token for this API |
## Response
The response uses the same batch ledger as [`+feed-shortcut-create`](lark-im-feed-shortcut-create.md#response): `total`, `success_count`, `failure_count`, `succeeded_shortcuts`, and `failed_shortcuts`. A non-empty `failed_shortcuts` is a partial failure: stdout carries `ok:false` with the full ledger and the process exits non-zero (currently exit `1`).
## Permissions
- Required scope: `im:feed.shortcut:write`
- Only available with user identity (`--as user`).
## 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.

View File

@@ -0,0 +1,445 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestIM_FeedShortcutWorkflowAsUser walks the full create → list → remove
// loop for a single CHAT-type feed shortcut, mirroring the +flag-* workflow
// test. The feed_shortcuts API is user-identity only and version-locked, so
// the assertion strategy uses RunCmdWithRetry against the list endpoint
// rather than assuming the index is updated synchronously.
func TestIM_FeedShortcutWorkflowAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
parentT := t
suffix := clie2e.GenerateSuffix()
chatName := "im-feed-shortcut-" + suffix
var chatID string
t.Cleanup(func() {
cleanupFeedShortcuts(parentT, "user", chatID)
})
t.Run("create chat as user", func(t *testing.T) {
chatID = createChatAs(t, parentT, ctx, chatName, "user")
})
t.Run("pin chat to feed as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-create",
"--chat-id", chatID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
// failed_shortcuts may be absent (server returns {} on full success)
// or present-and-empty — either way it must not contain our chat.
for _, item := range gjson.Get(result.Stdout, "data.failed_shortcuts").Array() {
require.NotEqual(t, chatID, item.Get("shortcut.feed_card_id").String(),
"create should not report our chat as failed")
}
})
t.Run("list feed shortcuts as user with detail enrichment", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-list",
},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
for _, item := range gjson.Get(result.Stdout, "data.shortcuts").Array() {
if item.Get("feed_card_id").String() == chatID {
return false
}
}
return true
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
var found bool
for _, item := range gjson.Get(result.Stdout, "data.shortcuts").Array() {
if item.Get("feed_card_id").String() != chatID {
continue
}
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")
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{
"im", "+feed-shortcut-remove",
"--chat-id", chatID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
for _, item := range gjson.Get(result.Stdout, "data.failed_shortcuts").Array() {
require.NotEqual(t, chatID, item.Get("shortcut.feed_card_id").String(),
"remove should not report our chat as failed")
}
})
t.Run("verify chat removed from feed", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-list",
"--no-detail",
},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
for _, item := range gjson.Get(result.Stdout, "data.shortcuts").Array() {
if item.Get("feed_card_id").String() == chatID {
return true // still there, retry
}
}
return false
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
for _, item := range gjson.Get(result.Stdout, "data.shortcuts").Array() {
require.NotEqual(t, chatID, item.Get("feed_card_id").String(),
"chat should not be in feed list after remove")
}
})
}
// TestIM_FeedShortcutBatchAsUser exercises batch create / remove with two
// chats in a single API call. The per-item failure path (failed_shortcuts)
// is checked by mixing a real chat with an obviously invalid id.
func TestIM_FeedShortcutBatchAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
parentT := t
suffix := clie2e.GenerateSuffix()
var chatA, chatB string
t.Cleanup(func() {
cleanupFeedShortcuts(parentT, "user", chatA, chatB)
})
t.Run("create two chats as user", func(t *testing.T) {
chatA = createChatAs(t, parentT, ctx, "im-feed-batch-a-"+suffix, "user")
chatB = createChatAs(t, parentT, ctx, "im-feed-batch-b-"+suffix, "user")
})
t.Run("batch pin both with --tail", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-create",
"--chat-id", chatA + "," + chatB,
"--tail",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("batch pin with one invalid id reports per-item failure", func(t *testing.T) {
// Re-pinning chatA (already pinned) should still succeed without
// noting it as failure; the synthetic invalid id should land in
// failed_shortcuts with reason_label=invalid_item.
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-create",
"--chat-id", chatA,
"--chat-id", "oc_definitely_not_a_real_chat_id_" + suffix,
},
DefaultAs: "user",
})
require.NoError(t, err)
// Partial failure exits with the generic API failure code while the
// full ledger stays machine-readable on stdout.
result.AssertExitCode(t, 1)
result.AssertStdoutStatus(t, false)
require.Equal(t, int64(2), gjson.Get(result.Stdout, "data.total").Int())
require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.success_count").Int())
require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.failure_count").Int())
var sawInvalid bool
for _, item := range gjson.Get(result.Stdout, "data.failed_shortcuts").Array() {
require.NotEqual(t, chatA, item.Get("shortcut.feed_card_id").String(),
"real chat should not appear in failed_shortcuts")
if item.Get("shortcut.feed_card_id").String() == "oc_definitely_not_a_real_chat_id_"+suffix {
sawInvalid = true
require.Equal(t, "invalid_item", item.Get("reason_label").String(),
"invalid id should be labeled invalid_item")
}
}
require.True(t, sawInvalid, "expected the bogus chat id in failed_shortcuts")
var sawSucceeded bool
for _, item := range gjson.Get(result.Stdout, "data.succeeded_shortcuts").Array() {
if item.Get("feed_card_id").String() == chatA {
sawSucceeded = true
require.Equal(t, int64(1), item.Get("type").Int(), "succeeded shortcut type should be CHAT")
}
}
require.True(t, sawSucceeded, "expected the real chat id in succeeded_shortcuts")
})
t.Run("batch remove both", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-remove",
"--chat-id", chatA,
"--chat-id", chatB,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
}
func cleanupFeedShortcuts(parentT *testing.T, defaultAs string, chatIDs ...string) {
parentT.Helper()
var ids []string
for _, id := range chatIDs {
if id != "" {
ids = append(ids, id)
}
}
if len(ids) == 0 {
return
}
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
listResult, listErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"im", "+feed-shortcut-list", "--no-detail"},
DefaultAs: defaultAs,
})
clie2e.ReportCleanupFailure(parentT, "cleanup feed shortcuts list", listResult, listErr)
if listErr != nil || listResult == nil || listResult.ExitCode != 0 {
return
}
present := make(map[string]bool, len(ids))
for _, id := range ids {
present[id] = false
}
for _, item := range gjson.Get(listResult.Stdout, "data.shortcuts").Array() {
id := item.Get("feed_card_id").String()
if _, ok := present[id]; ok {
present[id] = true
}
}
args := []string{"im", "+feed-shortcut-remove"}
for _, id := range ids {
if present[id] {
args = append(args, "--chat-id", id)
}
}
if len(args) == 2 {
return
}
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: args,
DefaultAs: defaultAs,
})
clie2e.ReportCleanupFailure(parentT, "cleanup feed shortcuts", result, err)
}
// TestIM_FeedShortcutDryRun covers all three shortcuts in --dry-run mode
// using fake credentials. This is the only test that runs without a real
// user token because dry-run short-circuits before any network call.
func TestIM_FeedShortcutDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_USER_ACCESS_TOKEN", "fake_user_token")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
t.Run("create dry-run", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-create",
"--chat-id", "oc_test_dry_run",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, result.Stdout, "POST")
require.Contains(t, result.Stdout, "/open-apis/im/v2/feed_shortcuts")
require.Contains(t, result.Stdout, "oc_test_dry_run")
// --head is the default so the body should be is_header=true
require.Contains(t, result.Stdout, `"is_header"`)
})
t.Run("create dry-run with --tail flips is_header to false", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-create",
"--chat-id", "oc_test_dry_run",
"--tail",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, result.Stdout, `"is_header": false`)
})
t.Run("create dry-run rejects --head + --tail", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-create",
"--chat-id", "oc_test_dry_run",
"--head",
"--tail",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
// Validation errors exit 2 with the structured error envelope;
// assert against combined output to stay channel-agnostic.
result.AssertExitCode(t, 2)
combined := result.Stdout + "\n" + result.Stderr
require.Contains(t, combined, "mutually exclusive")
})
t.Run("create dry-run rejects non-oc chat ids", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-create",
"--chat-id", "om_not_a_chat",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
combined := result.Stdout + "\n" + result.Stderr
require.Contains(t, combined, "must be an open_chat_id")
})
t.Run("remove dry-run hits /remove endpoint", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-remove",
"--chat-id", "oc_a,oc_b",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, result.Stdout, "POST")
require.Contains(t, result.Stdout, "/open-apis/im/v2/feed_shortcuts/remove")
require.Contains(t, result.Stdout, "oc_a")
require.Contains(t, result.Stdout, "oc_b")
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) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-list",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
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")
})
}