Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions shortcuts/feed/feed_create.go
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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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,
}
}
232 changes: 232 additions & 0 deletions shortcuts/feed/feed_create_test.go
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)
}
})
}
}
13 changes: 13 additions & 0 deletions shortcuts/feed/shortcuts.go
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,
}
}
2 changes: 2 additions & 0 deletions shortcuts/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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()...)
Expand Down
Loading
Loading