diff --git a/shortcuts/feed/feed_create.go b/shortcuts/feed/feed_create.go new file mode 100644 index 000000000..ea9966e85 --- /dev/null +++ b/shortcuts/feed/feed_create.go @@ -0,0 +1,106 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package feed + +import ( + "context" + "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]any{"user_id_type": "open_id"}). + Body(body) + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + userIDs := runtime.StrArray("user-ids") + 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("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"].([]any) + if failedCards == nil { + failedCards = []any{} + } + + runtime.Out(map[string]any{ + "biz_id": resData["biz_id"], + "failed_cards": failedCards, + }, nil) + return nil + }, +} + +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]any{"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]any{ + "app_feed_card": card, + "user_ids": normalizedIDs, + } +} diff --git a/shortcuts/feed/feed_create_test.go b/shortcuts/feed/feed_create_test.go new file mode 100644 index 000000000..41ddc3369 --- /dev/null +++ b/shortcuts/feed/feed_create_test.go @@ -0,0 +1,232 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package feed + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "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: "test"} + 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) + } + if !strings.Contains(out, "failed_cards") { + t.Errorf("expected failed_cards in output, got: %s", out) + } +} + +func TestFeedCreate_SuccessWithOptionalFields(t *testing.T) { + f, stdout, _, reg := feedShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + stub := &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{}{}, + }, + }, + } + reg.Register(stub) + + 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) + } + + // 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) { + 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 + 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", + }, + { + 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 { + 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) + } + }) + } +} 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()...) 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..35b13be04 --- /dev/null +++ b/skills/lark-feed/references/lark-feed-create.md @@ -0,0 +1,98 @@ +# 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 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. + +## 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. | +| `--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 + +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`) + +## Dry Run + +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 +``` + +## 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. 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..8cc2313e1 --- /dev/null +++ b/tests/cli_e2e/feed/2026_04_16_feed_create_test.go @@ -0,0 +1,166 @@ +// 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. +// 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) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"feed", "+create", + "--user-ids", "ou_test_dry_run_static", + "--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) +}