-
Notifications
You must be signed in to change notification settings - Fork 615
feat(feed): add +sensitive shortcut for time-sensitive status #718
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
10 commits
Select commit
Hold shift + click to select a range
df065ce
feat(feed): scaffold feed domain and +sensitive stub
01d222a
test(feed): add feed package registration smoke test; fix Flag.Type doc
d2b8c37
feat(feed): add +sensitive validation and dry-run
57bd058
fix(feed): clean up execute stub and strengthen validation tests
6f04650
feat(feed): implement execute with partial failure handling
82a2cef
feat(feed): implement execute with partial failure handling
f9b43c4
docs(feed): add lark-feed skill docs for +sensitive shortcut
a5e2a26
test(feed): fix e2e fixture - use sandbox user instead of cross-app Mo
277cdf3
test(feed): fix e2e gjson paths to use data envelope wrapper
715b2da
test(feed): add user to chat before feed +sensitive e2e tests
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
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,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") | ||
| } | ||
|
|
||
| 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 | ||
| }, | ||
| } | ||
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,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()) | ||
| } | ||
| } |
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{ | ||
| FeedSensitive, | ||
| } | ||
| } |
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,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) | ||
| } | ||
| } |
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Strengthen
--user-idsvalidation to reject blank elements.Line 60 only checks list length; values like
--user-ids ","or whitespace-only entries can still pass.🛠️ Suggested patch
📝 Committable suggestion
🤖 Prompt for AI Agents