mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
97
shortcuts/im/im_feed_shortcut_create.go
Normal file
97
shortcuts/im/im_feed_shortcut_create.go
Normal 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
|
||||
}
|
||||
79
shortcuts/im/im_feed_shortcut_list.go
Normal file
79
shortcuts/im/im_feed_shortcut_list.go
Normal 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}}
|
||||
}
|
||||
57
shortcuts/im/im_feed_shortcut_remove.go
Normal file
57
shortcuts/im/im_feed_shortcut_remove.go
Normal 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)
|
||||
},
|
||||
}
|
||||
1092
shortcuts/im/im_feed_shortcut_test.go
Normal file
1092
shortcuts/im/im_feed_shortcut_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
@@ -22,5 +22,8 @@ func Shortcuts() []common.Shortcut {
|
||||
ImFlagCreate,
|
||||
ImFlagCancel,
|
||||
ImFlagList,
|
||||
ImFeedShortcutCreate,
|
||||
ImFeedShortcutRemove,
|
||||
ImFeedShortcutList,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
97
skills/lark-im/references/lark-im-feed-shortcut-create.md
Normal file
97
skills/lark-im/references/lark-im-feed-shortcut-create.md
Normal 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).
|
||||
103
skills/lark-im/references/lark-im-feed-shortcut-list.md
Normal file
103
skills/lark-im/references/lark-im-feed-shortcut-list.md
Normal 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`).
|
||||
48
skills/lark-im/references/lark-im-feed-shortcut-remove.md
Normal file
48
skills/lark-im/references/lark-im-feed-shortcut-remove.md
Normal 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.
|
||||
445
tests/cli_e2e/im/feed_shortcut_workflow_test.go
Normal file
445
tests/cli_e2e/im/feed_shortcut_workflow_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user