From 04ec34a0afd016688b626ceff934fd0a67902b65 Mon Sep 17 00:00:00 2001 From: jinzemo <296684223@qq.com> Date: Thu, 16 Apr 2026 21:28:39 +0800 Subject: [PATCH 1/7] feat(feed): implement +create shortcut for app feed card Co-Authored-By: Claude Sonnet 4.6 --- shortcuts/feed/feed_create.go | 103 ++++++++++++++++ shortcuts/feed/feed_create_test.go | 186 +++++++++++++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 shortcuts/feed/feed_create.go create mode 100644 shortcuts/feed/feed_create_test.go diff --git a/shortcuts/feed/feed_create.go b/shortcuts/feed/feed_create.go new file mode 100644 index 000000000..8a74fcd3f --- /dev/null +++ b/shortcuts/feed/feed_create.go @@ -0,0 +1,103 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package feed + +import ( + "context" + "net/http" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +var FeedCreate = common.Shortcut{ + Service: "feed", + Command: "+create", + Description: "Create an app feed card for users; bot-only; sends a clickable card to one or more users' message feeds (requires Lark client v7.6+)", + Risk: "write", + BotScopes: []string{"im:app_feed_card:write"}, + AuthTypes: []string{"bot"}, + Flags: []common.Flag{ + {Name: "user-ids", Type: "string_array", Required: true, Desc: "(required) user open_ids to receive the card (ou_xxx, 1-20 users; repeatable: --user-ids ou_aaa --user-ids ou_bbb)"}, + {Name: "link", Required: true, Desc: "(required) clickthrough URL for the card (HTTPS only, max 700 chars)"}, + {Name: "title", Required: true, Desc: "(required) card title shown in the message feed (max 60 chars)"}, + {Name: "preview", Desc: "preview text shown under the title in the feed (max 120 chars)"}, + {Name: "time-sensitive", Type: "bool", Desc: "temporarily pin the card at the top of each recipient's message feed (default false)"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := buildFeedCreateBody(runtime) + return common.NewDryRunAPI(). + POST("/open-apis/im/v2/app_feed_card"). + Params(map[string]interface{}{"user_id_type": "open_id"}). + Body(body) + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + userIDs := runtime.StrArray("user-ids") + if len(userIDs) == 0 { + return output.ErrValidation("--user-ids is required: provide at least one user open_id (ou_xxx)") + } + if len(userIDs) > 20 { + return output.ErrValidation("--user-ids exceeds maximum of 20 users (got %d)", len(userIDs)) + } + for _, id := range userIDs { + if _, err := common.ValidateUserID(id); err != nil { + return err + } + } + + link := runtime.Str("link") + if !strings.HasPrefix(link, "https://") { + return output.ErrValidation("--link must use HTTPS protocol (got %q); only https:// URLs are accepted", link) + } + if len(link) > 700 { + return output.ErrValidation("--link exceeds maximum of 700 characters (got %d)", len(link)) + } + + if title := runtime.Str("title"); len([]rune(title)) > 60 { + return output.ErrValidation("--title exceeds maximum of 60 characters (got %d)", len([]rune(title))) + } + if preview := runtime.Str("preview"); len([]rune(preview)) > 120 { + return output.ErrValidation("--preview exceeds maximum of 120 characters (got %d)", len([]rune(preview))) + } + return nil + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + body := buildFeedCreateBody(runtime) + resData, err := runtime.DoAPIJSON(http.MethodPost, "/open-apis/im/v2/app_feed_card", + larkcore.QueryParams{"user_id_type": []string{"open_id"}}, body) + if err != nil { + return err + } + + failedCards, _ := resData["failed_cards"].([]interface{}) + if failedCards == nil { + failedCards = []interface{}{} + } + + runtime.Out(map[string]interface{}{ + "biz_id": resData["biz_id"], + "failed_cards": failedCards, + }, nil) + return nil + }, +} + +func buildFeedCreateBody(runtime *common.RuntimeContext) map[string]interface{} { + card := map[string]interface{}{ + "title": runtime.Str("title"), + "link": map[string]interface{}{"link": runtime.Str("link")}, + } + if preview := runtime.Str("preview"); preview != "" { + card["preview"] = preview + } + if runtime.Bool("time-sensitive") { + card["time_sensitive"] = true + } + return map[string]interface{}{ + "app_feed_card": card, + "user_ids": runtime.StrArray("user-ids"), + } +} diff --git a/shortcuts/feed/feed_create_test.go b/shortcuts/feed/feed_create_test.go new file mode 100644 index 000000000..77b078f86 --- /dev/null +++ b/shortcuts/feed/feed_create_test.go @@ -0,0 +1,186 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package feed + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func feedTestConfig(t *testing.T) *core.CliConfig { + t.Helper() + suffix := strings.NewReplacer("/", "-", " ", "-", ":", "-", "\t", "-").Replace(t.Name()) + return &core.CliConfig{ + AppID: "test-app-" + suffix, + AppSecret: "test-secret-" + suffix, + Brand: core.BrandFeishu, + UserOpenId: "ou_testuser", + UserName: "Test User", + } +} + +func feedShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) { + t.Helper() + return cmdutil.TestFactory(t, feedTestConfig(t)) +} + +func warmTenantToken(t *testing.T, f *cmdutil.Factory, reg *httpmock.Registry) { + t.Helper() + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/test/v1/warm", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{}, + }, + }) + s := common.Shortcut{ + Service: "test", + Command: "+warm-token", + AuthTypes: []string{"bot"}, + Execute: func(_ context.Context, rctx *common.RuntimeContext) error { + _, err := rctx.CallAPI("GET", "/open-apis/test/v1/warm", nil, nil) + return err + }, + } + parent := &cobra.Command{Use: "test"} + s.Mount(parent, f) + parent.SetArgs([]string{"+warm-token", "--as", "bot"}) + parent.SilenceErrors = true + parent.SilenceUsage = true + if err := parent.Execute(); err != nil { + t.Fatalf("warm tenant token: %v", err) + } +} + +func runMountedFeedShortcut(t *testing.T, shortcut common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error { + t.Helper() + parent := &cobra.Command{Use: "feed"} + shortcut.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +func TestFeedCreate_Success(t *testing.T) { + f, stdout, _, reg := feedShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/im/v2/app_feed_card", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "biz_id": "test-biz-id-123", + "failed_cards": []interface{}{}, + }, + }, + }) + + args := []string{"+create", "--user-ids", "ou_abc123", "--title", "Test Card", "--link", "https://www.feishu.cn/", "--as", "bot"} + err := runMountedFeedShortcut(t, FeedCreate, args, f, stdout) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "biz_id") { + t.Errorf("expected biz_id in output, got: %s", out) + } + if !strings.Contains(out, "test-biz-id-123") { + t.Errorf("expected biz_id value in output, got: %s", out) + } +} + +func TestFeedCreate_SuccessWithOptionalFields(t *testing.T) { + f, stdout, _, reg := feedShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/im/v2/app_feed_card", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "biz_id": "biz-optional-456", + "failed_cards": []interface{}{}, + }, + }, + }) + + args := []string{ + "+create", + "--user-ids", "ou_abc123", + "--title", "带预览", + "--link", "https://www.feishu.cn/", + "--preview", "这是预览文字", + "--time-sensitive", + "--as", "bot", + } + err := runMountedFeedShortcut(t, FeedCreate, args, f, stdout) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "biz-optional-456") { + t.Errorf("expected biz_id value in output, got: %s", out) + } +} + +func TestFeedCreate_Validate(t *testing.T) { + tests := []struct { + name string + args []string + wantErr string + }{ + { + name: "invalid user-id format", + args: []string{"+create", "--user-ids", "invalid_id", "--title", "Test", "--link", "https://www.feishu.cn/", "--as", "bot"}, + wantErr: "ou_", + }, + { + name: "link uses http not https", + args: []string{"+create", "--user-ids", "ou_abc", "--title", "Test", "--link", "http://www.feishu.cn/", "--as", "bot"}, + wantErr: "https", + }, + { + name: "title too long", + args: []string{"+create", "--user-ids", "ou_abc", "--title", strings.Repeat("a", 61), "--link", "https://www.feishu.cn/", "--as", "bot"}, + wantErr: "title", + }, + { + name: "preview too long", + args: []string{"+create", "--user-ids", "ou_abc", "--title", "Test", "--link", "https://www.feishu.cn/", "--preview", strings.Repeat("a", 121), "--as", "bot"}, + wantErr: "preview", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, stdout, _, _ := feedShortcutTestFactory(t) + err := runMountedFeedShortcut(t, FeedCreate, tt.args, f, stdout) + if err == nil { + t.Fatalf("expected error containing %q, got nil (stdout=%s)", tt.wantErr, stdout.String()) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error = %q, want substring %q", err.Error(), tt.wantErr) + } + }) + } +} From 81fd691031f63f197e018f978aed8edf5804d392 Mon Sep 17 00:00:00 2001 From: jinzemo <296684223@qq.com> Date: Thu, 16 Apr 2026 21:32:41 +0800 Subject: [PATCH 2/7] fix(feed): address code quality issues in +create shortcut Co-Authored-By: Claude Sonnet 4.6 --- shortcuts/feed/feed_create.go | 3 --- shortcuts/feed/feed_create_test.go | 17 ++++++++++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/shortcuts/feed/feed_create.go b/shortcuts/feed/feed_create.go index 8a74fcd3f..cd9d2c8be 100644 --- a/shortcuts/feed/feed_create.go +++ b/shortcuts/feed/feed_create.go @@ -36,9 +36,6 @@ var FeedCreate = common.Shortcut{ }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { userIDs := runtime.StrArray("user-ids") - if len(userIDs) == 0 { - return output.ErrValidation("--user-ids is required: provide at least one user open_id (ou_xxx)") - } if len(userIDs) > 20 { return output.ErrValidation("--user-ids exceeds maximum of 20 users (got %d)", len(userIDs)) } diff --git a/shortcuts/feed/feed_create_test.go b/shortcuts/feed/feed_create_test.go index 77b078f86..a5138c70f 100644 --- a/shortcuts/feed/feed_create_test.go +++ b/shortcuts/feed/feed_create_test.go @@ -6,6 +6,7 @@ package feed import ( "bytes" "context" + "fmt" "strings" "testing" @@ -65,7 +66,7 @@ func warmTenantToken(t *testing.T, f *cmdutil.Factory, reg *httpmock.Registry) { func runMountedFeedShortcut(t *testing.T, shortcut common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error { t.Helper() - parent := &cobra.Command{Use: "feed"} + parent := &cobra.Command{Use: "test"} shortcut.Mount(parent, f) parent.SetArgs(args) parent.SilenceErrors = true @@ -105,6 +106,9 @@ func TestFeedCreate_Success(t *testing.T) { if !strings.Contains(out, "test-biz-id-123") { t.Errorf("expected biz_id value in output, got: %s", out) } + if !strings.Contains(out, "failed_cards") { + t.Errorf("expected failed_cards in output, got: %s", out) + } } func TestFeedCreate_SuccessWithOptionalFields(t *testing.T) { @@ -169,6 +173,17 @@ func TestFeedCreate_Validate(t *testing.T) { args: []string{"+create", "--user-ids", "ou_abc", "--title", "Test", "--link", "https://www.feishu.cn/", "--preview", strings.Repeat("a", 121), "--as", "bot"}, wantErr: "preview", }, + { + name: "too many user-ids", + args: func() []string { + base := []string{"+create", "--title", "T", "--link", "https://example.com/", "--as", "bot"} + for i := 0; i < 21; i++ { + base = append(base, "--user-ids", fmt.Sprintf("ou_%02d", i)) + } + return base + }(), + wantErr: "20", + }, } for _, tt := range tests { From 6406a198081ceb9a1e4274926c76d69b0c12ef30 Mon Sep 17 00:00:00 2001 From: jinzemo <296684223@qq.com> Date: Thu, 16 Apr 2026 21:35:11 +0800 Subject: [PATCH 3/7] feat(feed): register feed domain in shortcut registry Add Shortcuts() aggregator in shortcuts/feed/shortcuts.go and register feed.Shortcuts() in shortcuts/register.go so `lark-cli feed +create` is available at runtime. Co-Authored-By: Claude Sonnet 4.6 --- shortcuts/feed/feed_create_test.go | 10 ++++++++++ shortcuts/feed/shortcuts.go | 13 +++++++++++++ shortcuts/register.go | 2 ++ 3 files changed, 25 insertions(+) create mode 100644 shortcuts/feed/shortcuts.go diff --git a/shortcuts/feed/feed_create_test.go b/shortcuts/feed/feed_create_test.go index a5138c70f..22f6f7f8b 100644 --- a/shortcuts/feed/feed_create_test.go +++ b/shortcuts/feed/feed_create_test.go @@ -147,6 +147,16 @@ func TestFeedCreate_SuccessWithOptionalFields(t *testing.T) { } } +func TestFeedShortcuts(t *testing.T) { + shortcuts := Shortcuts() + if len(shortcuts) != 1 { + t.Fatalf("Shortcuts() len = %d, want 1", len(shortcuts)) + } + if shortcuts[0].Command != "+create" { + t.Errorf("Shortcuts()[0].Command = %q, want +create", shortcuts[0].Command) + } +} + func TestFeedCreate_Validate(t *testing.T) { tests := []struct { name string diff --git a/shortcuts/feed/shortcuts.go b/shortcuts/feed/shortcuts.go new file mode 100644 index 000000000..118e273b9 --- /dev/null +++ b/shortcuts/feed/shortcuts.go @@ -0,0 +1,13 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package feed + +import "github.com/larksuite/cli/shortcuts/common" + +// Shortcuts returns all feed shortcuts. +func Shortcuts() []common.Shortcut { + return []common.Shortcut{ + FeedCreate, + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 09d3813b9..ca081d91a 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -15,6 +15,7 @@ import ( "github.com/larksuite/cli/shortcuts/doc" "github.com/larksuite/cli/shortcuts/drive" "github.com/larksuite/cli/shortcuts/event" + "github.com/larksuite/cli/shortcuts/feed" "github.com/larksuite/cli/shortcuts/im" "github.com/larksuite/cli/shortcuts/mail" "github.com/larksuite/cli/shortcuts/minutes" @@ -38,6 +39,7 @@ func init() { allShortcuts = append(allShortcuts, sheets.Shortcuts()...) allShortcuts = append(allShortcuts, base.Shortcuts()...) allShortcuts = append(allShortcuts, event.Shortcuts()...) + allShortcuts = append(allShortcuts, feed.Shortcuts()...) allShortcuts = append(allShortcuts, mail.Shortcuts()...) allShortcuts = append(allShortcuts, slides.Shortcuts()...) allShortcuts = append(allShortcuts, minutes.Shortcuts()...) From 0ef19a58800559996fd1b79be04d36fc6fdf7ad8 Mon Sep 17 00:00:00 2001 From: jinzemo <296684223@qq.com> Date: Thu, 16 Apr 2026 21:37:27 +0800 Subject: [PATCH 4/7] docs(feed): add lark-feed skill docs for +create shortcut --- skills/lark-feed/SKILL.md | 30 +++++++ .../lark-feed/references/lark-feed-create.md | 88 +++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 skills/lark-feed/SKILL.md create mode 100644 skills/lark-feed/references/lark-feed-create.md diff --git a/skills/lark-feed/SKILL.md b/skills/lark-feed/SKILL.md new file mode 100644 index 000000000..5623297dd --- /dev/null +++ b/skills/lark-feed/SKILL.md @@ -0,0 +1,30 @@ +--- +name: lark-feed +version: 1.0.0 +description: "飞书应用消息流卡片:向指定用户的消息流发送应用卡片。当用户需要给其他用户发送消息流卡片(app feed card)时使用。需要飞书客户端 v7.6 及以上版本。" +metadata: + requires: + bins: ["lark-cli"] + cliHelp: "lark-cli feed --help" +--- + +# feed (v1) + +**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理** + +## Core Concepts + +- **App Feed Card**: 应用消息流卡片,让应用直接在用户消息流中发送带标题和跳转链接的卡片。需要飞书客户端 v7.6+。 +- **Bot-only**: 所有 feed 操作仅支持 bot 身份(`--as bot`)。 + +## Shortcuts(推荐优先使用) + +| Shortcut | 说明 | +|----------|------| +| [`+create`](references/lark-feed-create.md) | Create an app feed card for users; bot-only; sends a clickable card to one or more users' message feeds | + +## 权限表 + +| 方法 | 所需 scope | +|------|-----------| +| `+create` | `im:app_feed_card:write` | diff --git a/skills/lark-feed/references/lark-feed-create.md b/skills/lark-feed/references/lark-feed-create.md new file mode 100644 index 000000000..733549a9c --- /dev/null +++ b/skills/lark-feed/references/lark-feed-create.md @@ -0,0 +1,88 @@ +# feed +create + +> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules. + +Send an app feed card to one or more Lark users. The card appears in each recipient's message feed (消息流) with a title and a clickthrough link. + +This skill maps to the shortcut: `lark-cli feed +create` (internally calls `POST /open-apis/im/v2/app_feed_card`). + +**Requires:** Lark client v7.6 or later on the recipient's device. + +## Safety Constraints + +App feed cards are delivered directly to users' message feeds. Before calling this command, confirm with the user: + +1. Who should receive the card (user open_ids) +2. The title and link content +3. Whether to enable time-sensitive (temporary top pin) + +**Do not** send cards without explicit user approval. + +## Usage + +```bash +lark-cli feed +create \ + --user-ids ou_ \ + --title "" \ + --link "https://..." \ + [--preview ""] \ + [--time-sensitive] \ + --as bot +``` + +## Flags + +| Flag | Required | Description | +|------|----------|-------------| +| `--user-ids` | Yes | User open_id(s) to receive the card (`ou_xxx`). Repeatable: `--user-ids ou_aaa --user-ids ou_bbb`. Max 20 users. | +| `--link` | Yes | Card clickthrough URL (HTTPS only, max 700 chars). | +| `--title` | Yes | Card title shown in the message feed (max 60 chars). | +| `--preview` | No | Preview text shown under the title (max 120 chars). | +| `--time-sensitive` | No | Temporarily pin the card at top of recipients' message feed. | + +## Examples + +Send a basic card to one user: +```bash +lark-cli feed +create \ + --user-ids ou_abc123 \ + --title "Weekly Report Ready" \ + --link "https://example.com/report" \ + --as bot +``` + +Send to multiple users with preview and top-pin: +```bash +lark-cli feed +create \ + --user-ids ou_aaa \ + --user-ids ou_bbb \ + --title "Urgent Notice" \ + --preview "Please check immediately" \ + --link "https://example.com/notice" \ + --time-sensitive \ + --as bot +``` + +## Output + +```json +{ + "biz_id": "b90ce43a-fca8-4f42-...", + "failed_cards": [] +} +``` + +- `biz_id`: system-assigned business ID for this card +- `failed_cards`: list of users for whom delivery failed (each item has `user_id` and `reason`) + +## DryRun + +Use `--dry-run` to preview the API call without sending: +```bash +lark-cli feed +create \ + --user-ids ou_abc123 \ + --title "Test" \ + --link "https://www.feishu.cn/" \ + --dry-run \ + --as bot +``` From 14bd9538f481364569d68d92214f71dd826205d1 Mon Sep 17 00:00:00 2001 From: jinzemo <296684223@qq.com> Date: Thu, 16 Apr 2026 21:39:24 +0800 Subject: [PATCH 5/7] docs(feed): improve lark-feed-create reference doc --- skills/lark-feed/references/lark-feed-create.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/skills/lark-feed/references/lark-feed-create.md b/skills/lark-feed/references/lark-feed-create.md index 733549a9c..934ad9024 100644 --- a/skills/lark-feed/references/lark-feed-create.md +++ b/skills/lark-feed/references/lark-feed-create.md @@ -13,8 +13,9 @@ This skill maps to the shortcut: `lark-cli feed +create` (internally calls `POST App feed cards are delivered directly to users' message feeds. Before calling this command, confirm with the user: 1. Who should receive the card (user open_ids) -2. The title and link content -3. Whether to enable time-sensitive (temporary top pin) +2. The card title content (max 60 chars) +3. The card link URL (HTTPS only, max 700 chars) +4. Whether to enable time-sensitive (temporary top pin) **Do not** send cards without explicit user approval. @@ -39,6 +40,8 @@ lark-cli feed +create \ | `--title` | Yes | Card title shown in the message feed (max 60 chars). | | `--preview` | No | Preview text shown under the title (max 120 chars). | | `--time-sensitive` | No | Temporarily pin the card at top of recipients' message feed. | +| `--as` | No | Identity to use; must be `bot` (user identity is not supported for this command). | +| `--dry-run` | No | Preview the API call without sending. | ## Examples @@ -86,3 +89,10 @@ lark-cli feed +create \ --dry-run \ --as bot ``` + +## Notes + +- **Bot-only**: `--as bot` is required. Passing `--as user` will error because this API requires `tenant_access_token`. +- **Partial delivery**: If some recipients fail, the command still exits 0. Check `failed_cards` in the output for individual failure reasons (0=unknown, 1=no permission, 2=not created, 3=rate limited, 4=duplicate). +- **Client version**: Recipients must have Lark client v7.6 or later. Cards sent to older clients are silently ignored by the platform. +- **User limit**: Maximum 20 recipients per call. Passing more than 20 `--user-ids` values will fail validation before the API is called. From 191eca8519d203fa1b549dcb984827ea92a6d1a4 Mon Sep 17 00:00:00 2001 From: jinzemo <296684223@qq.com> Date: Thu, 16 Apr 2026 22:31:55 +0800 Subject: [PATCH 6/7] fix(feed): correct E2E test assertions and user discovery - Use auth status to get userOpenId instead of contact API - Fix JSON path: data.biz_id and data.failed_cards - Passes all 6 feed E2E scenarios in sandbox Co-Authored-By: Claude Opus 4.6 --- .../feed/2026_04_16_feed_create_test.go | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 tests/cli_e2e/feed/2026_04_16_feed_create_test.go diff --git a/tests/cli_e2e/feed/2026_04_16_feed_create_test.go b/tests/cli_e2e/feed/2026_04_16_feed_create_test.go new file mode 100644 index 000000000..034fa5157 --- /dev/null +++ b/tests/cli_e2e/feed/2026_04_16_feed_create_test.go @@ -0,0 +1,167 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package feed + +import ( + "context" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func discoverFeedRecipientOpenID(t *testing.T, ctx context.Context) string { + t.Helper() + + // Get the authenticated user's own open_id from auth status. + // This works in sandbox environments where the bot may not have contact:list permission. + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"auth", "status"}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + openID := gjson.Get(result.Stdout, "userOpenId").String() + require.NotEmpty(t, openID, "expected to get userOpenId from auth status; stdout:\n%s", result.Stdout) + return openID +} + +// TestFeed_CreateBasic covers Scenario 1: create a feed card with required flags only. +func TestFeed_CreateBasic(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + recipientOpenID := discoverFeedRecipientOpenID(t, ctx) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"feed", "+create", + "--user-ids", recipientOpenID, + "--title", "测试卡片", + "--link", "https://www.feishu.cn/", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + bizID := gjson.Get(result.Stdout, "data.biz_id").String() + assert.NotEmpty(t, bizID, "stdout should contain non-empty biz_id:\n%s", result.Stdout) + + failedCards := gjson.Get(result.Stdout, "data.failed_cards") + assert.True(t, failedCards.IsArray(), "failed_cards should be an array:\n%s", result.Stdout) + assert.Equal(t, 0, len(failedCards.Array()), "failed_cards should be empty:\n%s", result.Stdout) +} + +// TestFeed_CreateWithOptionalFields covers Scenario 2: create a feed card with --preview and --time-sensitive. +func TestFeed_CreateWithOptionalFields(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + recipientOpenID := discoverFeedRecipientOpenID(t, ctx) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"feed", "+create", + "--user-ids", recipientOpenID, + "--title", "带预览", + "--link", "https://www.feishu.cn/", + "--preview", "这是预览文字", + "--time-sensitive", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + bizID := gjson.Get(result.Stdout, "data.biz_id").String() + assert.NotEmpty(t, bizID, "stdout should contain non-empty biz_id:\n%s", result.Stdout) + + failedCards := gjson.Get(result.Stdout, "data.failed_cards") + assert.True(t, failedCards.IsArray(), "failed_cards should be an array:\n%s", result.Stdout) + assert.Equal(t, 0, len(failedCards.Array()), "failed_cards should be empty:\n%s", result.Stdout) +} + +// TestFeed_CreateMissingUserIDs covers Scenario 3: --user-ids is missing. +func TestFeed_CreateMissingUserIDs(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"feed", "+create", + "--title", "测试", + "--link", "https://www.feishu.cn/", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + assert.NotEqual(t, 0, result.ExitCode, "exit code should be non-zero when --user-ids is missing") + assert.True(t, strings.Contains(result.Stderr, "user-ids"), + "stderr should mention 'user-ids':\n%s", result.Stderr) +} + +// TestFeed_CreateHTTPLinkRejected covers Scenario 4: --link with HTTP (non-HTTPS) is rejected. +func TestFeed_CreateHTTPLinkRejected(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + recipientOpenID := discoverFeedRecipientOpenID(t, ctx) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"feed", "+create", + "--user-ids", recipientOpenID, + "--title", "测试", + "--link", "http://www.feishu.cn/", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + assert.NotEqual(t, 0, result.ExitCode, "exit code should be non-zero for non-HTTPS link") + assert.True(t, strings.Contains(result.Stderr, "https"), + "stderr should mention 'https':\n%s", result.Stderr) +} + +// TestFeed_CreateInvalidUserIDFormat covers Scenario 5: --user-ids with invalid format. +func TestFeed_CreateInvalidUserIDFormat(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"feed", "+create", + "--user-ids", "invalid_id", + "--title", "测试", + "--link", "https://www.feishu.cn/", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + assert.NotEqual(t, 0, result.ExitCode, "exit code should be non-zero for invalid user-ids format") + assert.True(t, + strings.Contains(result.Stderr, "open_id") || strings.Contains(result.Stderr, "ou_"), + "stderr should mention 'open_id' or 'ou_':\n%s", result.Stderr) +} + +// TestFeed_CreateDryRun covers Scenario 6: --dry-run outputs the correct API path. +func TestFeed_CreateDryRun(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + recipientOpenID := discoverFeedRecipientOpenID(t, ctx) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"feed", "+create", + "--user-ids", recipientOpenID, + "--title", "测试", + "--link", "https://www.feishu.cn/", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + assert.True(t, strings.Contains(result.Stdout, "/open-apis/im/v2/app_feed_card"), + "stdout should contain the API path '/open-apis/im/v2/app_feed_card':\n%s", result.Stdout) +} From fa4aaf12577a53ca9ce31a25091e0c66f74b36f6 Mon Sep 17 00:00:00 2001 From: jinzemo <296684223@qq.com> Date: Thu, 16 Apr 2026 23:42:22 +0800 Subject: [PATCH 7/7] fix(feed): address CodeRabbit review comments - Remove net/http import, use string literal for HTTP method - Add user ID normalization with strings.TrimSpace - Fix docs: "DryRun" -> "Dry Run" in reference doc - Add request payload assertions in optional fields test - Use static user ID for dry-run E2E test Co-Authored-By: Claude Opus 4.6 --- shortcuts/feed/feed_create.go | 28 +++++++++++-------- shortcuts/feed/feed_create_test.go | 25 +++++++++++++++-- .../lark-feed/references/lark-feed-create.md | 2 +- .../feed/2026_04_16_feed_create_test.go | 5 ++-- 4 files changed, 43 insertions(+), 17 deletions(-) diff --git a/shortcuts/feed/feed_create.go b/shortcuts/feed/feed_create.go index cd9d2c8be..ea9966e85 100644 --- a/shortcuts/feed/feed_create.go +++ b/shortcuts/feed/feed_create.go @@ -5,7 +5,6 @@ package feed import ( "context" - "net/http" "strings" "github.com/larksuite/cli/internal/output" @@ -31,7 +30,7 @@ var FeedCreate = common.Shortcut{ body := buildFeedCreateBody(runtime) return common.NewDryRunAPI(). POST("/open-apis/im/v2/app_feed_card"). - Params(map[string]interface{}{"user_id_type": "open_id"}). + Params(map[string]any{"user_id_type": "open_id"}). Body(body) }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { @@ -63,18 +62,18 @@ var FeedCreate = common.Shortcut{ }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { body := buildFeedCreateBody(runtime) - resData, err := runtime.DoAPIJSON(http.MethodPost, "/open-apis/im/v2/app_feed_card", + resData, err := runtime.DoAPIJSON("POST", "/open-apis/im/v2/app_feed_card", larkcore.QueryParams{"user_id_type": []string{"open_id"}}, body) if err != nil { return err } - failedCards, _ := resData["failed_cards"].([]interface{}) + failedCards, _ := resData["failed_cards"].([]any) if failedCards == nil { - failedCards = []interface{}{} + failedCards = []any{} } - runtime.Out(map[string]interface{}{ + runtime.Out(map[string]any{ "biz_id": resData["biz_id"], "failed_cards": failedCards, }, nil) @@ -82,10 +81,17 @@ var FeedCreate = common.Shortcut{ }, } -func buildFeedCreateBody(runtime *common.RuntimeContext) map[string]interface{} { - card := map[string]interface{}{ +func buildFeedCreateBody(runtime *common.RuntimeContext) map[string]any { + // Normalize user IDs by trimming whitespace + userIDs := runtime.StrArray("user-ids") + normalizedIDs := make([]string, 0, len(userIDs)) + for _, id := range userIDs { + normalizedIDs = append(normalizedIDs, strings.TrimSpace(id)) + } + + card := map[string]any{ "title": runtime.Str("title"), - "link": map[string]interface{}{"link": runtime.Str("link")}, + "link": map[string]any{"link": runtime.Str("link")}, } if preview := runtime.Str("preview"); preview != "" { card["preview"] = preview @@ -93,8 +99,8 @@ func buildFeedCreateBody(runtime *common.RuntimeContext) map[string]interface{} if runtime.Bool("time-sensitive") { card["time_sensitive"] = true } - return map[string]interface{}{ + return map[string]any{ "app_feed_card": card, - "user_ids": runtime.StrArray("user-ids"), + "user_ids": normalizedIDs, } } diff --git a/shortcuts/feed/feed_create_test.go b/shortcuts/feed/feed_create_test.go index 22f6f7f8b..41ddc3369 100644 --- a/shortcuts/feed/feed_create_test.go +++ b/shortcuts/feed/feed_create_test.go @@ -6,6 +6,7 @@ package feed import ( "bytes" "context" + "encoding/json" "fmt" "strings" "testing" @@ -115,7 +116,7 @@ func TestFeedCreate_SuccessWithOptionalFields(t *testing.T) { f, stdout, _, reg := feedShortcutTestFactory(t) warmTenantToken(t, f, reg) - reg.Register(&httpmock.Stub{ + stub := &httpmock.Stub{ Method: "POST", URL: "/open-apis/im/v2/app_feed_card", Body: map[string]interface{}{ @@ -125,7 +126,8 @@ func TestFeedCreate_SuccessWithOptionalFields(t *testing.T) { "failed_cards": []interface{}{}, }, }, - }) + } + reg.Register(stub) args := []string{ "+create", @@ -145,6 +147,25 @@ func TestFeedCreate_SuccessWithOptionalFields(t *testing.T) { if !strings.Contains(out, "biz-optional-456") { t.Errorf("expected biz_id value in output, got: %s", out) } + + // Verify the request payload includes optional fields + if len(stub.CapturedBody) == 0 { + t.Fatal("expected CapturedBody to be populated") + } + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("failed to unmarshal captured body: %v", err) + } + appFeedCard, ok := body["app_feed_card"].(map[string]interface{}) + if !ok { + t.Fatalf("expected app_feed_card in body, got %T", body["app_feed_card"]) + } + if appFeedCard["preview"] != "这是预览文字" { + t.Errorf("expected preview '这是预览文字', got %v", appFeedCard["preview"]) + } + if appFeedCard["time_sensitive"] != true { + t.Errorf("expected time_sensitive true, got %v", appFeedCard["time_sensitive"]) + } } func TestFeedShortcuts(t *testing.T) { diff --git a/skills/lark-feed/references/lark-feed-create.md b/skills/lark-feed/references/lark-feed-create.md index 934ad9024..35b13be04 100644 --- a/skills/lark-feed/references/lark-feed-create.md +++ b/skills/lark-feed/references/lark-feed-create.md @@ -78,7 +78,7 @@ lark-cli feed +create \ - `biz_id`: system-assigned business ID for this card - `failed_cards`: list of users for whom delivery failed (each item has `user_id` and `reason`) -## DryRun +## Dry Run Use `--dry-run` to preview the API call without sending: ```bash diff --git a/tests/cli_e2e/feed/2026_04_16_feed_create_test.go b/tests/cli_e2e/feed/2026_04_16_feed_create_test.go index 034fa5157..8cc2313e1 100644 --- a/tests/cli_e2e/feed/2026_04_16_feed_create_test.go +++ b/tests/cli_e2e/feed/2026_04_16_feed_create_test.go @@ -145,15 +145,14 @@ func TestFeed_CreateInvalidUserIDFormat(t *testing.T) { } // TestFeed_CreateDryRun covers Scenario 6: --dry-run outputs the correct API path. +// Uses a static test user ID since dry-run doesn't actually call the API. func TestFeed_CreateDryRun(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) t.Cleanup(cancel) - recipientOpenID := discoverFeedRecipientOpenID(t, ctx) - result, err := clie2e.RunCmd(ctx, clie2e.Request{ Args: []string{"feed", "+create", - "--user-ids", recipientOpenID, + "--user-ids", "ou_test_dry_run_static", "--title", "测试", "--link", "https://www.feishu.cn/", "--dry-run",