Skip to content
Closed
2 changes: 1 addition & 1 deletion shortcuts/common/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const (
// Flag describes a CLI flag for a shortcut.
type Flag struct {
Name string // flag name (e.g. "calendar-id")
Type string // "string" (default) | "bool" | "int" | "string_array"
Type string // "string" (default) | "bool" | "int" | "string_array" | "string_slice"
Default string // default value as string
Desc string // help text
Hidden bool // hidden from --help, still readable at runtime
Expand Down
119 changes: 119 additions & 0 deletions shortcuts/feed/feed_sensitive.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package feed

import (
"context"
"fmt"
"io"

"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)

var FeedSensitive = common.Shortcut{
Service: "feed",
Command: "+sensitive",
Description: "Set or unset time-sensitive (即时提醒) status for a feed card (group chat) for specified users; bot only",
Risk: "write",
BotScopes: []string{"im:datasync.feed_card.time_sensitive:write"},
AuthTypes: []string{"bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "feed-card-id", Desc: "feed card ID (oc_xxx); group chats only", Required: true},
{Name: "enable", Type: "bool", Desc: "enable time-sensitive (pin card to top for specified users)"},
{Name: "disable", Type: "bool", Desc: "disable time-sensitive"},
{Name: "user-ids", Type: "string_slice", Desc: "user ID list (comma-separated or repeatable); must be members of the feed card chat", Required: true},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type", Enum: []string{"open_id", "union_id", "user_id"}},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
feedCardID := runtime.Str("feed-card-id")
userIDs := runtime.StrSlice("user-ids")
userIDType := runtime.Str("user-id-type")
timeSensitive := runtime.Changed("enable")
return common.NewDryRunAPI().
PATCH(fmt.Sprintf("/open-apis/im/v2/feed_cards/%s", validate.EncodePathSegment(feedCardID))).
Params(map[string]any{"user_id_type": userIDType}).
Body(map[string]any{
"time_sensitive": timeSensitive,
"user_ids": userIDs,
})
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
feedCardID := runtime.Str("feed-card-id")
if _, err := common.ValidateChatID(feedCardID); err != nil {
return err
}

enableChanged := runtime.Changed("enable")
disableChanged := runtime.Changed("disable")
if enableChanged && disableChanged {
return common.FlagErrorf("--enable and --disable are mutually exclusive")
}
if !enableChanged && !disableChanged {
return common.FlagErrorf("specify exactly one of --enable or --disable")
}

userIDs := runtime.StrSlice("user-ids")
if len(userIDs) == 0 {
return output.ErrValidation("--user-ids is required and must not be empty")
}
Comment on lines +59 to +62
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Strengthen --user-ids validation to reject blank elements.
Line 60 only checks list length; values like --user-ids "," or whitespace-only entries can still pass.

🛠️ Suggested patch
 import (
 	"context"
 	"fmt"
 	"io"
+	"strings"

 	"github.com/larksuite/cli/internal/output"
 	"github.com/larksuite/cli/internal/validate"
 	"github.com/larksuite/cli/shortcuts/common"
 )
@@
 		userIDs := runtime.StrSlice("user-ids")
 		if len(userIDs) == 0 {
 			return output.ErrValidation("--user-ids is required and must not be empty")
 		}
+		for _, id := range userIDs {
+			if strings.TrimSpace(id) == "" {
+				return output.ErrValidation("--user-ids must not contain empty values")
+			}
+		}

 		return nil
 	},
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
userIDs := runtime.StrSlice("user-ids")
if len(userIDs) == 0 {
return output.ErrValidation("--user-ids is required and must not be empty")
}
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// ... (other code)
Validate: func(ctx context.Context, _ *flags.FlagSet) *output.Result {
userIDs := runtime.StrSlice("user-ids")
if len(userIDs) == 0 {
return output.ErrValidation("--user-ids is required and must not be empty")
}
for _, id := range userIDs {
if strings.TrimSpace(id) == "" {
return output.ErrValidation("--user-ids must not contain empty values")
}
}
return nil
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/feed/feed_sensitive.go` around lines 59 - 62, The current check
only ensures userIDs has non-zero length but allows blank or whitespace-only
elements; after retrieving userIDs := runtime.StrSlice("user-ids"), iterate the
slice, trim each entry using strings.TrimSpace and reject if any trimmed value
== "" (return output.ErrValidation with a clear message like "--user-ids must
not contain empty values"); optionally replace the slice with the
cleaned/trimmed values (e.g., build a new slice of trimmed IDs) before further
use so downstream code receives normalized IDs.


return nil
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
feedCardID := runtime.Str("feed-card-id")
userIDs := runtime.StrSlice("user-ids")
userIDType := runtime.Str("user-id-type")
timeSensitive := runtime.Changed("enable")

resp, err := runtime.CallAPI("PATCH",
fmt.Sprintf("/open-apis/im/v2/feed_cards/%s", validate.EncodePathSegment(feedCardID)),
map[string]any{"user_id_type": userIDType},
map[string]any{"time_sensitive": timeSensitive, "user_ids": userIDs},
)
if err != nil {
return err
}

failedReasons := make([]map[string]any, 0)
if raw, ok := resp["failed_user_reasons"]; ok && raw != nil {
if slice, ok := raw.([]any); ok {
for _, item := range slice {
if m, ok := item.(map[string]any); ok {
failedReasons = append(failedReasons, m)
}
}
}
}

outData := map[string]any{
"feed_card_id": feedCardID,
"time_sensitive": timeSensitive,
"failed_user_reasons": failedReasons,
}

runtime.OutFormat(outData, nil, func(w io.Writer) {
successCount := len(userIDs) - len(failedReasons)
if successCount > 0 {
fmt.Fprintf(w, "Time-sensitive updated for %d user(s) (feed_card_id: %s)\n", successCount, feedCardID)
}
if len(failedReasons) > 0 {
fmt.Fprintf(runtime.IO().ErrOut, "warning: %d user(s) failed:\n", len(failedReasons))
for _, r := range failedReasons {
fmt.Fprintf(runtime.IO().ErrOut, " %v: %v\n", r["user_id"], r["error_message"])
}
}
})

if len(failedReasons) > 0 {
if len(failedReasons) == len(userIDs) {
return output.Errorf(output.ExitAPI, "partial_failure", "all %d user(s) failed to update time-sensitive status", len(userIDs))
}
return output.Errorf(output.ExitAPI, "partial_failure", "%d user(s) failed to update time-sensitive status", len(failedReasons))
}
return nil
},
}
206 changes: 206 additions & 0 deletions shortcuts/feed/feed_sensitive_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package feed

import (
"bytes"
"strings"
"testing"

"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)

func defaultConfig() *core.CliConfig {
return &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
}

func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
parent := &cobra.Command{Use: "test"}
s.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}

func TestFeedSensitive_Validate_MissingEnableDisable(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, FeedSensitive, []string{
"+sensitive",
"--feed-card-id", "oc_abc123",
"--user-ids", "ou_user1",
}, f, nil)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), "--enable") || !strings.Contains(err.Error(), "--disable") {
t.Errorf("error should mention --enable and --disable, got: %v", err)
}
}

func TestFeedSensitive_Validate_BothEnableAndDisable(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, FeedSensitive, []string{
"+sensitive",
"--feed-card-id", "oc_abc123",
"--enable",
"--disable",
"--user-ids", "ou_user1",
}, f, nil)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("error should mention mutually exclusive, got: %v", err)
}
}

func TestFeedSensitive_Validate_InvalidFeedCardID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, FeedSensitive, []string{
"+sensitive",
"--feed-card-id", "invalid_id",
"--enable",
"--user-ids", "ou_user1",
}, f, nil)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), "oc_") {
t.Errorf("error should mention oc_ prefix, got: %v", err)
}
}

func TestFeedSensitive_Validate_EmptyUserIDs(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, FeedSensitive, []string{
"+sensitive",
"--feed-card-id", "oc_abc123",
"--enable",
}, f, nil)
if err == nil {
t.Fatal("expected validation error for missing --user-ids, got nil")
}
if !strings.Contains(err.Error(), "user-ids") {
t.Errorf("error should mention user-ids, got: %v", err)
}
}

func TestFeedSensitive_Execute_AllSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/im/v2/feed_cards/oc_abc123",
Body: map[string]any{
"code": 0, "msg": "success",
"data": map[string]any{
"failed_user_reasons": []any{},
},
},
})

err := mountAndRun(t, FeedSensitive, []string{
"+sensitive",
"--feed-card-id", "oc_abc123",
"--enable",
"--user-ids", "ou_user1",
"--as", "bot",
"--format", "pretty",
}, f, stdout)

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "Time-sensitive updated for 1 user(s)") {
t.Errorf("stdout should contain success message, got: %s", stdout.String())
}
}

func TestFeedSensitive_Execute_PartialFailure(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/im/v2/feed_cards/oc_abc123",
Body: map[string]any{
"code": 0, "msg": "success",
"data": map[string]any{
"failed_user_reasons": []any{
map[string]any{
"error_code": 0,
"error_message": "The user is not in the chat",
"user_id": "ou_user2",
},
},
},
},
})

err := mountAndRun(t, FeedSensitive, []string{
"+sensitive",
"--feed-card-id", "oc_abc123",
"--enable",
"--user-ids", "ou_user1,ou_user2",
"--as", "bot",
"--format", "pretty",
}, f, stdout)

if err == nil {
t.Fatal("expected error for partial failure, got nil")
}
if !strings.Contains(stderr.String(), "warning:") {
t.Errorf("stderr should contain warning, got: %s", stderr.String())
}
if !strings.Contains(stdout.String(), "Time-sensitive updated for 1 user(s)") {
t.Errorf("stdout should report 1 success, got: %s", stdout.String())
}
}

func TestFeedSensitive_Execute_AllFailed(t *testing.T) {
f, _, stderr, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/im/v2/feed_cards/oc_abc123",
Body: map[string]any{
"code": 0, "msg": "success",
"data": map[string]any{
"failed_user_reasons": []any{
map[string]any{
"error_code": 0,
"error_message": "The user is not in the chat",
"user_id": "ou_user1",
},
},
},
},
})

err := mountAndRun(t, FeedSensitive, []string{
"+sensitive",
"--feed-card-id", "oc_abc123",
"--enable",
"--user-ids", "ou_user1",
"--as", "bot",
"--format", "pretty",
}, f, nil)

if err == nil {
t.Fatal("expected error when all users fail, got nil")
}
if !strings.Contains(err.Error(), "all") {
t.Errorf("error should mention 'all', got: %v", err)
}
if !strings.Contains(stderr.String(), "warning:") {
t.Errorf("stderr should contain warning, got: %s", stderr.String())
}
}
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{
FeedSensitive,
}
}
20 changes: 20 additions & 0 deletions shortcuts/feed/shortcuts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package feed

import "testing"

func TestShortcuts_Registration(t *testing.T) {
shortcuts := Shortcuts()
if len(shortcuts) != 1 {
t.Fatalf("expected 1 shortcut, got %d", len(shortcuts))
}
s := shortcuts[0]
if s.Service != "feed" {
t.Errorf("expected Service=%q, got %q", "feed", s.Service)
}
if s.Command != "+sensitive" {
t.Errorf("expected Command=%q, got %q", "+sensitive", s.Command)
}
}
2 changes: 2 additions & 0 deletions shortcuts/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package shortcuts
import (
"context"

"github.com/larksuite/cli/shortcuts/feed"
"github.com/larksuite/cli/shortcuts/okr"
"github.com/spf13/cobra"

Expand Down Expand Up @@ -49,6 +50,7 @@ func init() {
allShortcuts = append(allShortcuts, whiteboard.Shortcuts()...)
allShortcuts = append(allShortcuts, wiki.Shortcuts()...)
allShortcuts = append(allShortcuts, okr.Shortcuts()...)
allShortcuts = append(allShortcuts, feed.Shortcuts()...)
}

// AllShortcuts returns a copy of all registered shortcuts (for dump-shortcuts).
Expand Down
Loading
Loading