-
Notifications
You must be signed in to change notification settings - Fork 588
feat(feed): add feed +create shortcut for app feed card #526
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
04ec34a
feat(feed): implement +create shortcut for app feed card
jinzemo 81fd691
fix(feed): address code quality issues in +create shortcut
jinzemo 6406a19
feat(feed): register feed domain in shortcut registry
jinzemo 0ef19a5
docs(feed): add lark-feed skill docs for +create shortcut
jinzemo 14bd953
docs(feed): improve lark-feed-create reference doc
jinzemo 191eca8
fix(feed): correct E2E test assertions and user discovery
jinzemo fa4aaf1
fix(feed): address CodeRabbit review comments
jinzemo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
| }) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.