From 7b695c61f6ce23441a9d693edd85cb1d6adba10d Mon Sep 17 00:00:00 2001 From: renxianwei Date: Mon, 20 Apr 2026 19:46:45 +0800 Subject: [PATCH 1/2] feat: support app feed card Add IM shortcuts for app feed card create, update, and delete. The shortcuts wrap POST /open-apis/im/v2/app_feed_card and PUT/DELETE /open-apis/im/v2/app_feed_card/batch with bot identity. Support common card fields through flags, including title, preview, link, status label, buttons, time_sensitive, and notification settings. Also support raw JSON inputs for app_feed_card, buttons, and batch feed_cards payloads. Normalize app feed card update_fields from friendly names such as title, preview, link, and notify to API enum values such as 1, 3, 12, and 103 to avoid field validation failures. Address PR review feedback by validating raw feed_cards user IDs consistently with flag-built paths, rejecting hostless HTTPS URLs and url_page buttons without a non-empty HTTPS URL, keeping dry-run E2E focused on stdout request JSON, registering live workflow cleanup before response biz_id assertions, aligning tests with common.ValidateUserID errors, and avoiding shared app_feed_card map references across batch update entries. Verification: go test ./shortcuts/im -run 'TestBuildAppFeedCard|TestAppFeedCard|TestShortcuts' -count=1; go test ./shortcuts/im ./shortcuts -count=1; go vet ./shortcuts/im; go build -o /tmp/lark-cli-app-feed-card-test .; dry-run E2E for create/update/delete. Live workflow is permission-aware and skips when im:app_feed_card:write is unavailable. --- shortcuts/im/helpers_test.go | 3 + shortcuts/im/im_app_feed_card_create.go | 892 ++++++++++++++++++ shortcuts/im/im_app_feed_card_create_test.go | 561 +++++++++++ shortcuts/im/shortcuts.go | 3 + .../cli_e2e/im/app_feed_card_workflow_test.go | 238 +++++ 5 files changed, 1697 insertions(+) create mode 100644 shortcuts/im/im_app_feed_card_create.go create mode 100644 shortcuts/im/im_app_feed_card_create_test.go create mode 100644 tests/cli_e2e/im/app_feed_card_workflow_test.go diff --git a/shortcuts/im/helpers_test.go b/shortcuts/im/helpers_test.go index 2596b8f0e..0137651e3 100644 --- a/shortcuts/im/helpers_test.go +++ b/shortcuts/im/helpers_test.go @@ -644,6 +644,9 @@ func TestShortcuts(t *testing.T) { } want := []string{ + "+app-feed-card-create", + "+app-feed-card-delete", + "+app-feed-card-update", "+chat-create", "+chat-messages-list", "+chat-search", diff --git a/shortcuts/im/im_app_feed_card_create.go b/shortcuts/im/im_app_feed_card_create.go new file mode 100644 index 000000000..ac09868d2 --- /dev/null +++ b/shortcuts/im/im_app_feed_card_create.go @@ -0,0 +1,892 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +const appFeedCardCreatePath = "/open-apis/im/v2/app_feed_card" +const appFeedCardBatchPath = "/open-apis/im/v2/app_feed_card/batch" + +var ImAppFeedCardCreate = common.Shortcut{ + Service: "im", + Command: "+app-feed-card-create", + Description: "Create an app feed card for users; bot only; supports title, preview, link, status label, buttons, notification settings, and raw card JSON", + Risk: "write", + Scopes: []string{"im:app_feed_card:write"}, + AuthTypes: []string{"bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "user-ids", Desc: "recipient user IDs, comma-separated; IDs must match --user-id-type", Required: true}, + {Name: "user-id-type", Default: "open_id", Desc: "recipient ID type", Enum: []string{"open_id", "union_id", "user_id"}}, + {Name: "card-json", Desc: "app_feed_card JSON object; scalar card flags override matching fields", Input: []string{common.File, common.Stdin}}, + {Name: "biz-id", Desc: "custom business ID; if omitted, API returns a generated biz_id"}, + {Name: "title", Desc: "card title"}, + {Name: "avatar-key", Desc: "card avatar key"}, + {Name: "preview", Desc: "card preview text"}, + {Name: "link", Desc: "card redirect link; HTTPS or applink, required unless provided in --card-json"}, + {Name: "status-label-text", Desc: "status label text"}, + {Name: "status-label-type", Default: "primary", Desc: "status label type", Enum: []string{"primary", "secondary", "success", "danger"}}, + {Name: "time-sensitive", Type: "bool", Desc: "temporarily pin the card at the top of the feed"}, + {Name: "buttons-json", Desc: `buttons JSON; accepts {"buttons":[...]} or an array of button objects`, Input: []string{common.File, common.Stdin}}, + {Name: "button-text", Desc: "single convenience button text; use --buttons-json for multiple buttons"}, + {Name: "button-url", Desc: "single convenience button HTTPS URL"}, + {Name: "button-action-type", Default: "url_page", Desc: "single convenience button action type", Enum: []string{"url_page", "webhook"}}, + {Name: "button-type", Default: "default", Desc: "single convenience button style", Enum: []string{"default", "primary", "success"}}, + {Name: "button-action-map-json", Desc: "single convenience button action_map JSON object", Input: []string{common.File, common.Stdin}}, + {Name: "close-notify", Type: "bool", Desc: "disable normal notification for this feed card"}, + {Name: "with-custom-sound", Type: "bool", Desc: "play custom sound on mobile devices"}, + {Name: "custom-sound-text", Desc: "custom mobile sound notification text"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body, _ := buildAppFeedCardCreateBody(runtime) + return common.NewDryRunAPI(). + POST(appFeedCardCreatePath). + Params(map[string]interface{}{"user_id_type": appFeedCardUserIDType(runtime)}). + Body(body) + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := buildAppFeedCardCreateBody(runtime) + return err + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + body, err := buildAppFeedCardCreateBody(runtime) + if err != nil { + return err + } + + resData, err := runtime.DoAPIJSON(http.MethodPost, appFeedCardCreatePath, + larkcore.QueryParams{"user_id_type": []string{appFeedCardUserIDType(runtime)}}, + body, + ) + if err != nil { + return err + } + + userIDs, _ := body["user_ids"].([]string) + failedCards := normalizeAppFeedFailedCards(resData["failed_cards"]) + out := map[string]interface{}{ + "biz_id": resData["biz_id"], + "requested_user_count": len(userIDs), + "failed_count": len(failedCards), + "failed_cards": failedCards, + } + runtime.OutFormat(out, nil, func(w io.Writer) { + output.PrintTable(w, []map[string]interface{}{{ + "biz_id": out["biz_id"], + "requested_user_count": out["requested_user_count"], + "failed_count": out["failed_count"], + }}) + if len(failedCards) > 0 { + fmt.Fprintln(w, "\nFailed cards:") + output.PrintTable(w, failedCards) + } + }) + return nil + }, +} + +var ImAppFeedCardUpdate = common.Shortcut{ + Service: "im", + Command: "+app-feed-card-update", + Description: "Update app feed cards for users; bot only; supports single-card flags and raw feed_cards JSON", + Risk: "write", + Scopes: []string{"im:app_feed_card:write"}, + AuthTypes: []string{"bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "user-ids", Desc: "recipient user IDs, comma-separated; used with --biz-id to update the same card for each user"}, + {Name: "user-id-type", Default: "open_id", Desc: "recipient ID type", Enum: []string{"open_id", "union_id", "user_id"}}, + {Name: "feed-cards-json", Desc: `raw feed_cards JSON; accepts {"feed_cards":[...]} or an array`, Input: []string{common.File, common.Stdin}}, + {Name: "update-fields", Desc: "fields to update, comma-separated; accepts names or API enum values: title=1, avatar_key=2, preview=3, status_label=10, buttons=11, link=12, time_sensitive=13, notify=103; defaults to card fields supplied by flags"}, + {Name: "card-json", Desc: "app_feed_card JSON object; scalar card flags override matching fields", Input: []string{common.File, common.Stdin}}, + {Name: "biz-id", Desc: "business ID of the app feed card to update"}, + {Name: "title", Desc: "card title"}, + {Name: "avatar-key", Desc: "card avatar key"}, + {Name: "preview", Desc: "card preview text"}, + {Name: "link", Desc: "card redirect link; HTTPS or applink"}, + {Name: "status-label-text", Desc: "status label text"}, + {Name: "status-label-type", Default: "primary", Desc: "status label type", Enum: []string{"primary", "secondary", "success", "danger"}}, + {Name: "time-sensitive", Type: "bool", Desc: "temporarily pin the card at the top of the feed"}, + {Name: "buttons-json", Desc: `buttons JSON; accepts {"buttons":[...]} or an array of button objects`, Input: []string{common.File, common.Stdin}}, + {Name: "button-text", Desc: "single convenience button text; use --buttons-json for multiple buttons"}, + {Name: "button-url", Desc: "single convenience button HTTPS URL"}, + {Name: "button-action-type", Default: "url_page", Desc: "single convenience button action type", Enum: []string{"url_page", "webhook"}}, + {Name: "button-type", Default: "default", Desc: "single convenience button style", Enum: []string{"default", "primary", "success"}}, + {Name: "button-action-map-json", Desc: "single convenience button action_map JSON object", Input: []string{common.File, common.Stdin}}, + {Name: "close-notify", Type: "bool", Desc: "disable normal notification for this feed card"}, + {Name: "with-custom-sound", Type: "bool", Desc: "play custom sound on mobile devices"}, + {Name: "custom-sound-text", Desc: "custom mobile sound notification text"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body, _ := buildAppFeedCardUpdateBody(runtime) + return common.NewDryRunAPI(). + PUT(appFeedCardBatchPath). + Params(map[string]interface{}{"user_id_type": appFeedCardUserIDType(runtime)}). + Body(body) + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := buildAppFeedCardUpdateBody(runtime) + return err + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + body, err := buildAppFeedCardUpdateBody(runtime) + if err != nil { + return err + } + resData, err := runtime.DoAPIJSON(http.MethodPut, appFeedCardBatchPath, + larkcore.QueryParams{"user_id_type": []string{appFeedCardUserIDType(runtime)}}, + body, + ) + if err != nil { + return err + } + return outputAppFeedCardBatchResult(runtime, body, resData) + }, +} + +var ImAppFeedCardDelete = common.Shortcut{ + Service: "im", + Command: "+app-feed-card-delete", + Description: "Delete app feed cards for users by biz_id; bot only; supports raw feed_cards JSON", + Risk: "write", + Scopes: []string{"im:app_feed_card:write"}, + AuthTypes: []string{"bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "user-ids", Desc: "recipient user IDs, comma-separated; used with --biz-id to delete the same card for each user"}, + {Name: "user-id-type", Default: "open_id", Desc: "recipient ID type", Enum: []string{"open_id", "union_id", "user_id"}}, + {Name: "feed-cards-json", Desc: `raw feed_cards JSON; accepts {"feed_cards":[...]} or an array`, Input: []string{common.File, common.Stdin}}, + {Name: "biz-id", Desc: "business ID of the app feed card to delete"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body, _ := buildAppFeedCardDeleteBody(runtime) + return common.NewDryRunAPI(). + DELETE(appFeedCardBatchPath). + Params(map[string]interface{}{"user_id_type": appFeedCardUserIDType(runtime)}). + Body(body) + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := buildAppFeedCardDeleteBody(runtime) + return err + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + body, err := buildAppFeedCardDeleteBody(runtime) + if err != nil { + return err + } + resData, err := runtime.DoAPIJSON(http.MethodDelete, appFeedCardBatchPath, + larkcore.QueryParams{"user_id_type": []string{appFeedCardUserIDType(runtime)}}, + body, + ) + if err != nil { + return err + } + return outputAppFeedCardBatchResult(runtime, body, resData) + }, +} + +func appFeedCardUserIDType(runtime *common.RuntimeContext) string { + if v := runtime.Str("user-id-type"); v != "" { + return v + } + return "open_id" +} + +func buildAppFeedCardCreateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) { + userIDs := common.SplitCSV(runtime.Str("user-ids")) + if err := validateAppFeedUserIDs(userIDs, appFeedCardUserIDType(runtime), "--user-ids"); err != nil { + return nil, err + } + + card, err := buildAppFeedCardObject(runtime) + if err != nil { + return nil, err + } + if err := validateAppFeedCardObject(card); err != nil { + return nil, err + } + + return map[string]interface{}{ + "app_feed_card": card, + "user_ids": userIDs, + }, nil +} + +func buildAppFeedCardUpdateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) { + if raw := strings.TrimSpace(runtime.Str("feed-cards-json")); raw != "" { + feedCards, err := parseAppFeedCardsJSON(raw, "--feed-cards-json") + if err != nil { + return nil, err + } + if err := validateAppFeedCardUpdateItems(feedCards, appFeedCardUserIDType(runtime)); err != nil { + return nil, err + } + return map[string]interface{}{"feed_cards": feedCards}, nil + } + + userIDs := common.SplitCSV(runtime.Str("user-ids")) + if err := validateAppFeedUserIDs(userIDs, appFeedCardUserIDType(runtime), "--user-ids"); err != nil { + return nil, err + } + + card, err := buildAppFeedCardObject(runtime) + if err != nil { + return nil, err + } + if err := validateAppFeedCardUpdateObject(card); err != nil { + return nil, err + } + + updateFields, err := normalizeAppFeedCardUpdateFields(common.SplitCSV(runtime.Str("update-fields"))) + if err != nil { + return nil, err + } + if len(updateFields) == 0 { + updateFields = deriveAppFeedCardUpdateFields(card) + } + if len(updateFields) == 0 { + return nil, output.ErrValidation("--update-fields is required when no updatable card fields are supplied") + } + + feedCards := make([]interface{}, 0, len(userIDs)) + for _, userID := range userIDs { + feedCards = append(feedCards, map[string]interface{}{ + "app_feed_card": cloneAppFeedValue(card), + "user_id": userID, + "update_fields": append([]string(nil), updateFields...), + }) + } + return map[string]interface{}{"feed_cards": feedCards}, nil +} + +func buildAppFeedCardDeleteBody(runtime *common.RuntimeContext) (map[string]interface{}, error) { + if raw := strings.TrimSpace(runtime.Str("feed-cards-json")); raw != "" { + feedCards, err := parseAppFeedCardsJSON(raw, "--feed-cards-json") + if err != nil { + return nil, err + } + if err := validateAppFeedCardDeleteItems(feedCards, appFeedCardUserIDType(runtime)); err != nil { + return nil, err + } + return map[string]interface{}{"feed_cards": feedCards}, nil + } + + userIDs := common.SplitCSV(runtime.Str("user-ids")) + if err := validateAppFeedUserIDs(userIDs, appFeedCardUserIDType(runtime), "--user-ids"); err != nil { + return nil, err + } + bizID := strings.TrimSpace(runtime.Str("biz-id")) + if bizID == "" { + return nil, output.ErrValidation("--biz-id is required unless --feed-cards-json is provided") + } + + feedCards := make([]interface{}, 0, len(userIDs)) + for _, userID := range userIDs { + feedCards = append(feedCards, map[string]interface{}{ + "biz_id": bizID, + "user_id": userID, + }) + } + return map[string]interface{}{"feed_cards": feedCards}, nil +} + +func buildAppFeedCardObject(runtime *common.RuntimeContext) (map[string]interface{}, error) { + card := map[string]interface{}{} + if raw := strings.TrimSpace(runtime.Str("card-json")); raw != "" { + parsed, err := parseAppFeedJSONObject(raw, "--card-json") + if err != nil { + return nil, err + } + if nested, ok := parsed["app_feed_card"].(map[string]interface{}); ok { + parsed = nested + } + card = parsed + } + normalizeAppFeedLink(card) + + setStringField(card, "biz-id", "biz_id", runtime) + setStringField(card, "title", "title", runtime) + setStringField(card, "avatar-key", "avatar_key", runtime) + setStringField(card, "preview", "preview", runtime) + if link := strings.TrimSpace(runtime.Str("link")); link != "" { + card["link"] = map[string]interface{}{"link": link} + } + if flagChanged(runtime, "time-sensitive") { + card["time_sensitive"] = runtime.Bool("time-sensitive") + } + + if err := setAppFeedStatusLabel(card, runtime); err != nil { + return nil, err + } + if err := setAppFeedButtons(card, runtime); err != nil { + return nil, err + } + if err := setAppFeedNotify(card, runtime); err != nil { + return nil, err + } + return card, nil +} + +func setStringField(card map[string]interface{}, flagName, fieldName string, runtime *common.RuntimeContext) { + if v := runtime.Str(flagName); v != "" { + card[fieldName] = v + } +} + +func setAppFeedStatusLabel(card map[string]interface{}, runtime *common.RuntimeContext) error { + text := runtime.Str("status-label-text") + if text == "" { + if flagChanged(runtime, "status-label-type") { + return output.ErrValidation("--status-label-type requires --status-label-text") + } + return nil + } + status := objectField(card, "status_label") + status["text"] = text + status["type"] = runtime.Str("status-label-type") + if status["type"] == "" { + status["type"] = "primary" + } + return nil +} + +func setAppFeedButtons(card map[string]interface{}, runtime *common.RuntimeContext) error { + if raw := strings.TrimSpace(runtime.Str("buttons-json")); raw != "" { + if simpleButtonFlagsSet(runtime) { + return output.ErrValidation("--buttons-json cannot be combined with --button-text, --button-url, --button-action-type, --button-type, or --button-action-map-json") + } + buttons, err := parseAppFeedButtonsJSON(raw) + if err != nil { + return err + } + card["buttons"] = buttons + return nil + } + if !simpleButtonFlagsSet(runtime) { + return nil + } + + text := runtime.Str("button-text") + actionType := runtime.Str("button-action-type") + if actionType == "" { + actionType = "url_page" + } + if text == "" { + return output.ErrValidation("--button-text is required when setting a convenience button") + } + button := map[string]interface{}{ + "action_type": actionType, + "text": map[string]interface{}{"text": text}, + } + if buttonType := runtime.Str("button-type"); buttonType != "" { + button["button_type"] = buttonType + } + if buttonURL := strings.TrimSpace(runtime.Str("button-url")); buttonURL != "" { + button["multi_url"] = map[string]interface{}{"url": buttonURL} + } + if rawActionMap := strings.TrimSpace(runtime.Str("button-action-map-json")); rawActionMap != "" { + actionMap, err := parseAppFeedJSONObject(rawActionMap, "--button-action-map-json") + if err != nil { + return err + } + button["action_map"] = actionMap + } + card["buttons"] = map[string]interface{}{"buttons": []interface{}{button}} + return nil +} + +func setAppFeedNotify(card map[string]interface{}, runtime *common.RuntimeContext) error { + if !flagChanged(runtime, "close-notify") && !flagChanged(runtime, "with-custom-sound") && runtime.Str("custom-sound-text") == "" { + return nil + } + notify := objectField(card, "notify") + if flagChanged(runtime, "close-notify") { + notify["close_notify"] = runtime.Bool("close-notify") + } + if flagChanged(runtime, "with-custom-sound") { + notify["with_custom_sound"] = runtime.Bool("with-custom-sound") + } + if text := runtime.Str("custom-sound-text"); text != "" { + notify["custom_sound_text"] = text + } + return nil +} + +func simpleButtonFlagsSet(runtime *common.RuntimeContext) bool { + return runtime.Str("button-text") != "" || + runtime.Str("button-url") != "" || + flagChanged(runtime, "button-action-type") || + flagChanged(runtime, "button-type") || + strings.TrimSpace(runtime.Str("button-action-map-json")) != "" +} + +func objectField(parent map[string]interface{}, key string) map[string]interface{} { + if existing, ok := parent[key].(map[string]interface{}); ok { + return existing + } + next := map[string]interface{}{} + parent[key] = next + return next +} + +func normalizeAppFeedLink(card map[string]interface{}) { + if raw, ok := card["link"].(string); ok { + card["link"] = map[string]interface{}{"link": raw} + } +} + +func parseAppFeedJSONObject(raw, flagName string) (map[string]interface{}, error) { + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return nil, output.ErrValidation("%s invalid JSON object: %s", flagName, err) + } + if parsed == nil { + return nil, output.ErrValidation("%s must be a JSON object", flagName) + } + return parsed, nil +} + +func parseAppFeedButtonsJSON(raw string) (map[string]interface{}, error) { + var parsed interface{} + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return nil, output.ErrValidation("--buttons-json invalid JSON: %s", err) + } + switch v := parsed.(type) { + case map[string]interface{}: + if _, ok := v["buttons"]; ok { + return v, nil + } + if _, ok := v["text"]; ok { + return map[string]interface{}{"buttons": []interface{}{v}}, nil + } + if _, ok := v["action_type"]; ok { + return map[string]interface{}{"buttons": []interface{}{v}}, nil + } + return nil, output.ErrValidation(`--buttons-json object must contain "buttons" or be a single button object`) + case []interface{}: + return map[string]interface{}{"buttons": v}, nil + default: + return nil, output.ErrValidation("--buttons-json must be a JSON object or array") + } +} + +func parseAppFeedCardsJSON(raw, flagName string) ([]interface{}, error) { + var parsed interface{} + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return nil, output.ErrValidation("%s invalid JSON: %s", flagName, err) + } + switch v := parsed.(type) { + case map[string]interface{}: + if rawFeedCards, ok := v["feed_cards"]; ok { + feedCards, ok := rawFeedCards.([]interface{}) + if !ok { + return nil, output.ErrValidation(`%s.feed_cards must be a JSON array`, flagName) + } + if len(feedCards) == 0 { + return nil, output.ErrValidation("%s.feed_cards must contain at least one card", flagName) + } + return feedCards, nil + } + return []interface{}{v}, nil + case []interface{}: + if len(v) == 0 { + return nil, output.ErrValidation("%s must contain at least one card", flagName) + } + return v, nil + default: + return nil, output.ErrValidation("%s must be a JSON object or array", flagName) + } +} + +func validateAppFeedCardObject(card map[string]interface{}) error { + if card == nil { + return output.ErrValidation("app_feed_card cannot be empty") + } + normalizeAppFeedLink(card) + link := appFeedCardLinkValue(card) + if link == "" { + return output.ErrValidation("--link is required unless --card-json contains app_feed_card.link.link") + } + if err := validateAppFeedURL("--link", link, true); err != nil { + return err + } + if err := validateAppFeedStatusLabel(card); err != nil { + return err + } + if err := validateAppFeedButtons(card); err != nil { + return err + } + return nil +} + +func validateAppFeedCardUpdateObject(card map[string]interface{}) error { + if len(card) == 0 { + return output.ErrValidation("app_feed_card cannot be empty") + } + normalizeAppFeedLink(card) + if strings.TrimSpace(stringField(card, "biz_id")) == "" { + return output.ErrValidation("--biz-id is required unless --card-json contains app_feed_card.biz_id") + } + if link := appFeedCardLinkValue(card); link != "" { + if err := validateAppFeedURL("--link", link, true); err != nil { + return err + } + } + if err := validateAppFeedStatusLabel(card); err != nil { + return err + } + if err := validateAppFeedButtons(card); err != nil { + return err + } + return nil +} + +func validateAppFeedUserIDs(userIDs []string, userIDType, flagName string) error { + if len(userIDs) == 0 { + return output.ErrValidation("%s is required and must contain at least one recipient ID", flagName) + } + if userIDType == "open_id" { + for _, id := range userIDs { + if _, err := common.ValidateUserID(id); err != nil { + return err + } + } + } + return nil +} + +func validateAppFeedCardDeleteItems(feedCards []interface{}, userIDType string) error { + for i, raw := range feedCards { + item, ok := raw.(map[string]interface{}) + if !ok { + return output.ErrValidation("feed_cards[%d] must be a JSON object", i) + } + if strings.TrimSpace(stringField(item, "biz_id")) == "" { + return output.ErrValidation("feed_cards[%d].biz_id is required", i) + } + userID := strings.TrimSpace(stringField(item, "user_id")) + if userID == "" { + return output.ErrValidation("feed_cards[%d].user_id is required", i) + } + if err := validateAppFeedUserIDs([]string{userID}, userIDType, fmt.Sprintf("feed_cards[%d].user_id", i)); err != nil { + return err + } + } + return nil +} + +func validateAppFeedCardUpdateItems(feedCards []interface{}, userIDType string) error { + for i, raw := range feedCards { + item, ok := raw.(map[string]interface{}) + if !ok { + return output.ErrValidation("feed_cards[%d] must be a JSON object", i) + } + userID := strings.TrimSpace(stringField(item, "user_id")) + if userID == "" { + return output.ErrValidation("feed_cards[%d].user_id is required", i) + } + if err := validateAppFeedUserIDs([]string{userID}, userIDType, fmt.Sprintf("feed_cards[%d].user_id", i)); err != nil { + return err + } + card, ok := item["app_feed_card"].(map[string]interface{}) + if !ok { + return output.ErrValidation("feed_cards[%d].app_feed_card must be a JSON object", i) + } + if err := validateAppFeedCardUpdateObject(card); err != nil { + return err + } + updateFields, err := normalizeAppFeedCardUpdateFields(stringSliceField(item, "update_fields")) + if err != nil { + return err + } + if len(updateFields) == 0 { + return output.ErrValidation("feed_cards[%d].update_fields must contain at least one field", i) + } + item["update_fields"] = updateFields + } + return nil +} + +func appFeedCardLinkValue(card map[string]interface{}) string { + linkObj, _ := card["link"].(map[string]interface{}) + if linkObj == nil { + return "" + } + link, _ := linkObj["link"].(string) + return strings.TrimSpace(link) +} + +func validateAppFeedStatusLabel(card map[string]interface{}) error { + status, ok := card["status_label"].(map[string]interface{}) + if !ok || len(status) == 0 { + return nil + } + text, _ := status["text"].(string) + labelType, _ := status["type"].(string) + if strings.TrimSpace(text) == "" { + return output.ErrValidation("status_label.text is required when status_label is set") + } + if !oneOf(labelType, "primary", "secondary", "success", "danger") { + return output.ErrValidation("status_label.type must be one of: primary, secondary, success, danger") + } + return nil +} + +func validateAppFeedButtons(card map[string]interface{}) error { + buttonsObj, ok := card["buttons"].(map[string]interface{}) + if !ok || len(buttonsObj) == 0 { + return nil + } + rawButtons, ok := buttonsObj["buttons"].([]interface{}) + if !ok { + return output.ErrValidation("buttons.buttons must be a JSON array") + } + if len(rawButtons) > 2 { + return output.ErrValidation("buttons.buttons supports at most 2 buttons (got %d)", len(rawButtons)) + } + for i, raw := range rawButtons { + button, ok := raw.(map[string]interface{}) + if !ok { + return output.ErrValidation("buttons.buttons[%d] must be a JSON object", i) + } + if err := validateAppFeedButton(i, button); err != nil { + return err + } + } + return nil +} + +func validateAppFeedButton(index int, button map[string]interface{}) error { + actionType, _ := button["action_type"].(string) + if actionType == "" { + return output.ErrValidation("buttons.buttons[%d].action_type is required", index) + } + if !oneOf(actionType, "url_page", "webhook") { + return output.ErrValidation("buttons.buttons[%d].action_type must be url_page or webhook", index) + } + textObj, _ := button["text"].(map[string]interface{}) + text, _ := textObj["text"].(string) + if strings.TrimSpace(text) == "" { + return output.ErrValidation("buttons.buttons[%d].text.text is required", index) + } + if buttonType, _ := button["button_type"].(string); buttonType != "" && !oneOf(buttonType, "default", "primary", "success") { + return output.ErrValidation("buttons.buttons[%d].button_type must be one of: default, primary, success", index) + } + if multiURL, _ := button["multi_url"].(map[string]interface{}); len(multiURL) > 0 { + hasURL := false + for _, key := range []string{"url", "android_url", "ios_url", "pc_url"} { + rawValue, exists := multiURL[key] + if !exists { + continue + } + raw, ok := rawValue.(string) + if !ok { + return output.ErrValidation("buttons.buttons[%d].multi_url.%s must be a string", index, key) + } + if strings.TrimSpace(raw) != "" { + hasURL = true + if err := validateAppFeedURL(fmt.Sprintf("buttons.buttons[%d].multi_url.%s", index, key), raw, false); err != nil { + return err + } + } + } + if actionType == "url_page" && !hasURL { + return output.ErrValidation("buttons.buttons[%d].multi_url must contain at least one HTTPS URL when action_type is url_page", index) + } + } + if actionType == "url_page" { + multiURL, _ := button["multi_url"].(map[string]interface{}) + if len(multiURL) == 0 { + return output.ErrValidation("buttons.buttons[%d].multi_url is required when action_type is url_page", index) + } + } + return nil +} + +func validateAppFeedURL(fieldName, raw string, allowAppLink bool) error { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || u.Scheme == "" { + return output.ErrValidation("%s must be a valid URL", fieldName) + } + switch u.Scheme { + case "https": + if u.Host == "" { + return output.ErrValidation("%s must be a valid HTTPS URL", fieldName) + } + return nil + case "applink": + if allowAppLink { + return nil + } + } + if allowAppLink { + return output.ErrValidation("%s must use HTTPS or applink", fieldName) + } + return output.ErrValidation("%s must use HTTPS", fieldName) +} + +func oneOf(value string, allowed ...string) bool { + for _, item := range allowed { + if value == item { + return true + } + } + return false +} + +func cloneAppFeedValue(value interface{}) interface{} { + switch v := value.(type) { + case map[string]interface{}: + cloned := make(map[string]interface{}, len(v)) + for key, item := range v { + cloned[key] = cloneAppFeedValue(item) + } + return cloned + case []interface{}: + cloned := make([]interface{}, len(v)) + for i, item := range v { + cloned[i] = cloneAppFeedValue(item) + } + return cloned + case []string: + return append([]string(nil), v...) + default: + return v + } +} + +func deriveAppFeedCardUpdateFields(card map[string]interface{}) []string { + fields := make([]string, 0, len(card)) + for _, field := range []string{"title", "avatar_key", "preview", "status_label", "buttons", "link", "time_sensitive", "notify"} { + if _, ok := card[field]; ok { + fields = append(fields, appFeedCardUpdateFieldCodes[field]) + } + } + return fields +} + +var appFeedCardUpdateFieldCodes = map[string]string{ + "1": "1", + "title": "1", + "2": "2", + "avatar": "2", + "avatar-key": "2", + "avatar_key": "2", + "3": "3", + "preview": "3", + "10": "10", + "status-label": "10", + "status_label": "10", + "11": "11", + "button": "11", + "buttons": "11", + "12": "12", + "link": "12", + "13": "13", + "time-sensitive": "13", + "time_sensitive": "13", + "101": "101", + "display-time-to-current": "101", + "display_time_to_current": "101", + "102": "102", + "rerank-to-current": "102", + "rerank_time_to_current": "102", + "rerank_to_current": "102", + "103": "103", + "notify": "103", + "with-notify": "103", + "with_notify": "103", +} + +func normalizeAppFeedCardUpdateFields(fields []string) ([]string, error) { + if len(fields) == 0 { + return nil, nil + } + result := make([]string, 0, len(fields)) + for _, field := range fields { + key := strings.ToLower(strings.TrimSpace(field)) + code, ok := appFeedCardUpdateFieldCodes[key] + if !ok { + return nil, output.ErrValidation("invalid --update-fields value %q; valid values include title, avatar_key, preview, status_label, buttons, link, time_sensitive, notify, 1, 2, 3, 10, 11, 12, 13, 101, 102, 103", field) + } + result = append(result, code) + } + return result, nil +} + +func stringField(m map[string]interface{}, key string) string { + value, _ := m[key].(string) + return value +} + +func stringSliceField(m map[string]interface{}, key string) []string { + raw, ok := m[key].([]interface{}) + if !ok { + if values, ok := m[key].([]string); ok { + return values + } + return nil + } + values := make([]string, 0, len(raw)) + for _, item := range raw { + value, ok := item.(string) + if !ok { + return nil + } + values = append(values, value) + } + return values +} + +func flagChanged(runtime *common.RuntimeContext, name string) bool { + flag := runtime.Cmd.Flags().Lookup(name) + return flag != nil && flag.Changed +} + +func normalizeAppFeedFailedCards(raw interface{}) []map[string]interface{} { + items, _ := raw.([]interface{}) + if len(items) == 0 { + return nil + } + result := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + if m, ok := item.(map[string]interface{}); ok { + result = append(result, m) + } + } + return result +} + +func outputAppFeedCardBatchResult(runtime *common.RuntimeContext, body map[string]interface{}, resData map[string]interface{}) error { + feedCards, _ := body["feed_cards"].([]interface{}) + failedCards := normalizeAppFeedFailedCards(resData["failed_cards"]) + out := map[string]interface{}{ + "requested_card_count": len(feedCards), + "failed_count": len(failedCards), + "failed_cards": failedCards, + } + runtime.OutFormat(out, nil, func(w io.Writer) { + output.PrintTable(w, []map[string]interface{}{{ + "requested_card_count": out["requested_card_count"], + "failed_count": out["failed_count"], + }}) + if len(failedCards) > 0 { + fmt.Fprintln(w, "\nFailed cards:") + output.PrintTable(w, failedCards) + } + }) + return nil +} diff --git a/shortcuts/im/im_app_feed_card_create_test.go b/shortcuts/im/im_app_feed_card_create_test.go new file mode 100644 index 000000000..641558fda --- /dev/null +++ b/shortcuts/im/im_app_feed_card_create_test.go @@ -0,0 +1,561 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "bytes" + "context" + "encoding/json" + "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 TestBuildAppFeedCardCreateBody(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "user-ids": "ou_1, ou_2", + "title": "Order ready", + "preview": "Tap to view details", + "link": "https://example.com/orders/1", + "status-label-text": "Open", + "button-text": "View", + "button-url": "https://example.com/orders/1/action", + "custom-sound-text": "New order", + }, map[string]bool{ + "time-sensitive": true, + "close-notify": true, + }) + + body, err := buildAppFeedCardCreateBody(runtime) + if err != nil { + t.Fatalf("buildAppFeedCardCreateBody() error = %v", err) + } + userIDs, _ := body["user_ids"].([]string) + if len(userIDs) != 2 || userIDs[0] != "ou_1" || userIDs[1] != "ou_2" { + t.Fatalf("user_ids = %#v", body["user_ids"]) + } + + card, _ := body["app_feed_card"].(map[string]interface{}) + if card["title"] != "Order ready" || card["preview"] != "Tap to view details" || card["time_sensitive"] != true { + t.Fatalf("app_feed_card = %#v", card) + } + status, _ := card["status_label"].(map[string]interface{}) + if status["text"] != "Open" || status["type"] != "primary" { + t.Fatalf("status_label = %#v", status) + } + notify, _ := card["notify"].(map[string]interface{}) + if notify["close_notify"] != true || notify["custom_sound_text"] != "New order" { + t.Fatalf("notify = %#v", notify) + } + buttons, _ := card["buttons"].(map[string]interface{}) + rawButtons, _ := buttons["buttons"].([]interface{}) + if len(rawButtons) != 1 { + t.Fatalf("buttons = %#v", buttons) + } + button, _ := rawButtons[0].(map[string]interface{}) + if button["action_type"] != "url_page" { + t.Fatalf("button = %#v", button) + } +} + +func TestBuildAppFeedCardCreateBodyAcceptsRawJSON(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "user-ids": "ou_1", + "card-json": `{ + "app_feed_card": { + "title": "From JSON", + "link": {"link": "https://example.com/card"} + } + }`, + "buttons-json": `[ + { + "action_type": "url_page", + "text": {"text": "Open"}, + "multi_url": {"url": "https://example.com/open"} + } + ]`, + }, nil) + + body, err := buildAppFeedCardCreateBody(runtime) + if err != nil { + t.Fatalf("buildAppFeedCardCreateBody() error = %v", err) + } + card, _ := body["app_feed_card"].(map[string]interface{}) + if card["title"] != "From JSON" { + t.Fatalf("card title = %#v", card["title"]) + } + buttons, _ := card["buttons"].(map[string]interface{}) + if rawButtons, _ := buttons["buttons"].([]interface{}); len(rawButtons) != 1 { + t.Fatalf("buttons = %#v", buttons) + } +} + +func TestBuildAppFeedCardCreateBodyValidation(t *testing.T) { + invalidOpenIDErr := invalidOpenIDError(t) + tests := []struct { + name string + strFlags map[string]string + boolFlags map[string]bool + wantErr string + }{ + { + name: "missing link", + strFlags: map[string]string{ + "user-ids": "ou_1", + "title": "Missing link", + }, + wantErr: "--link is required", + }, + { + name: "invalid recipient open id", + strFlags: map[string]string{ + "user-ids": "bad_user", + "link": "https://example.com/card", + }, + wantErr: invalidOpenIDErr, + }, + { + name: "http link rejected", + strFlags: map[string]string{ + "user-ids": "ou_1", + "link": "http://example.com/card", + }, + wantErr: "must use HTTPS or applink", + }, + { + name: "hostless https link rejected", + strFlags: map[string]string{ + "user-ids": "ou_1", + "link": "https:example.com/card", + }, + wantErr: "must be a valid HTTPS URL", + }, + { + name: "too many buttons", + strFlags: map[string]string{ + "user-ids": "ou_1", + "link": "https://example.com/card", + "buttons-json": `[{"action_type":"url_page","text":{"text":"1"},"multi_url":{"url":"https://example.com/1"}},{"action_type":"url_page","text":{"text":"2"},"multi_url":{"url":"https://example.com/2"}},{"action_type":"url_page","text":{"text":"3"},"multi_url":{"url":"https://example.com/3"}}]`, + }, + wantErr: "at most 2 buttons", + }, + { + name: "url page button requires non-empty url", + strFlags: map[string]string{ + "user-ids": "ou_1", + "link": "https://example.com/card", + "buttons-json": `[{"action_type":"url_page","text":{"text":"Open"},"multi_url":{"url":""}}]`, + }, + wantErr: "must contain at least one HTTPS URL", + }, + { + name: "url page button rejects hostless https", + strFlags: map[string]string{ + "user-ids": "ou_1", + "link": "https://example.com/card", + "buttons-json": `[{"action_type":"url_page","text":{"text":"Open"},"multi_url":{"url":"https:example.com/open"}}]`, + }, + wantErr: "must be a valid HTTPS URL", + }, + { + name: "url page button url must be string", + strFlags: map[string]string{ + "user-ids": "ou_1", + "link": "https://example.com/card", + "buttons-json": `[{"action_type":"url_page","text":{"text":"Open"},"multi_url":{"url":123}}]`, + }, + wantErr: "must be a string", + }, + { + name: "status type needs text", + strFlags: map[string]string{ + "user-ids": "ou_1", + "link": "https://example.com/card", + "status-label-type": "success", + }, + wantErr: "--status-label-type requires --status-label-text", + }, + { + name: "union id bypasses open id prefix validation", + strFlags: map[string]string{ + "user-ids": "onion_1", + "user-id-type": "union_id", + "link": "https://example.com/card", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runtime := newTestRuntimeContext(t, tt.strFlags, tt.boolFlags) + _, err := buildAppFeedCardCreateBody(runtime) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error = %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want substring %q", err, tt.wantErr) + } + }) + } +} + +func TestBuildAppFeedCardUpdateBody(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "user-ids": "ou_1,ou_2", + "biz-id": "biz_123", + "title": "Updated title", + "preview": "Updated preview", + "link": "https://example.com/updated", + }, nil) + + body, err := buildAppFeedCardUpdateBody(runtime) + if err != nil { + t.Fatalf("buildAppFeedCardUpdateBody() error = %v", err) + } + feedCards, _ := body["feed_cards"].([]interface{}) + if len(feedCards) != 2 { + t.Fatalf("feed_cards = %#v", body["feed_cards"]) + } + first, _ := feedCards[0].(map[string]interface{}) + card, _ := first["app_feed_card"].(map[string]interface{}) + if card["biz_id"] != "biz_123" || card["title"] != "Updated title" { + t.Fatalf("app_feed_card = %#v", card) + } + updateFields, _ := first["update_fields"].([]string) + if strings.Join(updateFields, ",") != "1,3,12" { + t.Fatalf("update_fields = %#v", first["update_fields"]) + } + second, _ := feedCards[1].(map[string]interface{}) + secondCard, _ := second["app_feed_card"].(map[string]interface{}) + card["title"] = "mutated title" + cardLink, _ := card["link"].(map[string]interface{}) + cardLink["link"] = "https://example.com/mutated" + secondLink, _ := secondCard["link"].(map[string]interface{}) + if secondCard["title"] != "Updated title" || secondLink["link"] != "https://example.com/updated" { + t.Fatalf("app_feed_card maps should not be shared across feed_cards: first=%#v second=%#v", card, secondCard) + } +} + +func TestBuildAppFeedCardUpdateBodyAcceptsRawJSON(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "feed-cards-json": `[ + { + "user_id": "ou_1", + "app_feed_card": {"biz_id": "biz_123", "title": "Updated"}, + "update_fields": ["title"] + } + ]`, + }, nil) + + body, err := buildAppFeedCardUpdateBody(runtime) + if err != nil { + t.Fatalf("buildAppFeedCardUpdateBody() error = %v", err) + } + feedCards, _ := body["feed_cards"].([]interface{}) + if len(feedCards) != 1 { + t.Fatalf("feed_cards = %#v", body["feed_cards"]) + } + first, _ := feedCards[0].(map[string]interface{}) + updateFields, _ := first["update_fields"].([]string) + if strings.Join(updateFields, ",") != "1" { + t.Fatalf("update_fields = %#v", first["update_fields"]) + } +} + +func TestBuildAppFeedCardUpdateBodyAcceptsUpdateFieldNamesAndCodes(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "user-ids": "ou_1", + "biz-id": "biz_123", + "preview": "Updated preview", + "link": "https://example.com/updated", + "update-fields": "preview,12,notify", + }, nil) + + body, err := buildAppFeedCardUpdateBody(runtime) + if err != nil { + t.Fatalf("buildAppFeedCardUpdateBody() error = %v", err) + } + feedCards, _ := body["feed_cards"].([]interface{}) + first, _ := feedCards[0].(map[string]interface{}) + updateFields, _ := first["update_fields"].([]string) + if strings.Join(updateFields, ",") != "3,12,103" { + t.Fatalf("update_fields = %#v", first["update_fields"]) + } +} + +func TestBuildAppFeedCardDeleteBody(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "user-ids": "ou_1,ou_2", + "biz-id": "biz_123", + }, nil) + + body, err := buildAppFeedCardDeleteBody(runtime) + if err != nil { + t.Fatalf("buildAppFeedCardDeleteBody() error = %v", err) + } + feedCards, _ := body["feed_cards"].([]interface{}) + if len(feedCards) != 2 { + t.Fatalf("feed_cards = %#v", body["feed_cards"]) + } + first, _ := feedCards[0].(map[string]interface{}) + if first["biz_id"] != "biz_123" || first["user_id"] != "ou_1" { + t.Fatalf("feed_cards[0] = %#v", first) + } +} + +func TestBuildAppFeedCardBatchBodyValidation(t *testing.T) { + invalidOpenIDErr := invalidOpenIDError(t) + tests := []struct { + name string + build func(*common.RuntimeContext) (map[string]interface{}, error) + flags map[string]string + wantErr string + }{ + { + name: "update missing biz id", + build: buildAppFeedCardUpdateBody, + flags: map[string]string{"user-ids": "ou_1", "title": "Updated"}, + wantErr: "--biz-id is required", + }, + { + name: "update missing fields", + build: buildAppFeedCardUpdateBody, + flags: map[string]string{"user-ids": "ou_1", "biz-id": "biz_123"}, + wantErr: "--update-fields is required", + }, + { + name: "delete missing biz id", + build: buildAppFeedCardDeleteBody, + flags: map[string]string{"user-ids": "ou_1"}, + wantErr: "--biz-id is required", + }, + { + name: "delete raw missing user id", + build: buildAppFeedCardDeleteBody, + flags: map[string]string{"feed-cards-json": `[{"biz_id":"biz_123"}]`}, + wantErr: "user_id is required", + }, + { + name: "update raw invalid open id", + build: buildAppFeedCardUpdateBody, + flags: map[string]string{"feed-cards-json": `[{"user_id":"bad_user","app_feed_card":{"biz_id":"biz_123","title":"Updated"},"update_fields":["title"]}]`}, + wantErr: invalidOpenIDErr, + }, + { + name: "delete raw invalid open id", + build: buildAppFeedCardDeleteBody, + flags: map[string]string{"feed-cards-json": `[{"user_id":"bad_user","biz_id":"biz_123"}]`}, + wantErr: invalidOpenIDErr, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runtime := newTestRuntimeContext(t, tt.flags, nil) + _, err := tt.build(runtime) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want substring %q", err, tt.wantErr) + } + }) + } +} + +func TestAppFeedCardCreateDryRunShape(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "user-ids": "ou_1", + "title": "Dry run card", + "preview": "Preview", + "link": "https://example.com/card", + "button-text": "Open", + "button-url": "https://example.com/open", + }, nil) + + got := mustMarshalDryRun(t, ImAppFeedCardCreate.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"/open-apis/im/v2/app_feed_card"`) || + !strings.Contains(got, `"user_id_type":"open_id"`) || + !strings.Contains(got, `"user_ids":["ou_1"]`) || + !strings.Contains(got, `"title":"Dry run card"`) || + !strings.Contains(got, `"action_type":"url_page"`) { + t.Fatalf("ImAppFeedCardCreate.DryRun() = %s", got) + } +} + +func TestAppFeedCardBatchDryRunShape(t *testing.T) { + updateRuntime := newTestRuntimeContext(t, map[string]string{ + "user-ids": "ou_1", + "biz-id": "biz_123", + "title": "Updated title", + }, nil) + updateGot := mustMarshalDryRun(t, ImAppFeedCardUpdate.DryRun(context.Background(), updateRuntime)) + if !strings.Contains(updateGot, `"method":"PUT"`) || + !strings.Contains(updateGot, `"/open-apis/im/v2/app_feed_card/batch"`) || + !strings.Contains(updateGot, `"update_fields":["1"]`) { + t.Fatalf("ImAppFeedCardUpdate.DryRun() = %s", updateGot) + } + + deleteRuntime := newTestRuntimeContext(t, map[string]string{ + "user-ids": "ou_1", + "biz-id": "biz_123", + }, nil) + deleteGot := mustMarshalDryRun(t, ImAppFeedCardDelete.DryRun(context.Background(), deleteRuntime)) + if !strings.Contains(deleteGot, `"method":"DELETE"`) || + !strings.Contains(deleteGot, `"/open-apis/im/v2/app_feed_card/batch"`) || + !strings.Contains(deleteGot, `"biz_id":"biz_123"`) { + t.Fatalf("ImAppFeedCardDelete.DryRun() = %s", deleteGot) + } +} + +func TestAppFeedCardCreateExecute(t *testing.T) { + factory, stdout, reg := newIMExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "POST", + URL: appFeedCardCreatePath, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "biz_id": "biz_123", + "failed_cards": []interface{}{}, + }, + }, + } + reg.Register(stub) + + err := mountAndRunIMShortcut(t, ImAppFeedCardCreate, []string{ + "+app-feed-card-create", + "--user-ids", "ou_1", + "--title", "Execute card", + "--link", "https://example.com/card", + }, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunIMShortcut() error = %v", err) + } + + got := stdout.String() + if !strings.Contains(got, `"biz_id": "biz_123"`) || !strings.Contains(got, `"failed_count": 0`) { + t.Fatalf("stdout = %s", got) + } + var captured map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil { + t.Fatalf("captured body JSON = %s, err=%v", string(stub.CapturedBody), err) + } + if _, ok := captured["app_feed_card"].(map[string]interface{}); !ok { + t.Fatalf("captured body missing app_feed_card: %#v", captured) + } +} + +func TestAppFeedCardUpdateExecute(t *testing.T) { + factory, stdout, reg := newIMExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "PUT", + URL: appFeedCardBatchPath, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "failed_cards": []interface{}{}, + }, + }, + } + reg.Register(stub) + + err := mountAndRunIMShortcut(t, ImAppFeedCardUpdate, []string{ + "+app-feed-card-update", + "--user-ids", "ou_1", + "--biz-id", "biz_123", + "--title", "Updated title", + }, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunIMShortcut() error = %v", err) + } + if !strings.Contains(stdout.String(), `"requested_card_count": 1`) { + t.Fatalf("stdout = %s", stdout.String()) + } + + var captured map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil { + t.Fatalf("captured body JSON = %s, err=%v", string(stub.CapturedBody), err) + } + feedCards, _ := captured["feed_cards"].([]interface{}) + if len(feedCards) != 1 { + t.Fatalf("captured body missing feed_cards: %#v", captured) + } +} + +func TestAppFeedCardDeleteExecute(t *testing.T) { + factory, stdout, reg := newIMExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "DELETE", + URL: appFeedCardBatchPath, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "failed_cards": []interface{}{}, + }, + }, + } + reg.Register(stub) + + err := mountAndRunIMShortcut(t, ImAppFeedCardDelete, []string{ + "+app-feed-card-delete", + "--user-ids", "ou_1", + "--biz-id", "biz_123", + }, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunIMShortcut() error = %v", err) + } + if !strings.Contains(stdout.String(), `"requested_card_count": 1`) { + t.Fatalf("stdout = %s", stdout.String()) + } + + var captured map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil { + t.Fatalf("captured body JSON = %s, err=%v", string(stub.CapturedBody), err) + } + feedCards, _ := captured["feed_cards"].([]interface{}) + first, _ := feedCards[0].(map[string]interface{}) + if first["biz_id"] != "biz_123" || first["user_id"] != "ou_1" { + t.Fatalf("captured feed_cards = %#v", feedCards) + } +} + +func invalidOpenIDError(t *testing.T) string { + t.Helper() + _, err := common.ValidateUserID("bad_user") + if err == nil { + t.Fatal("common.ValidateUserID(bad_user) returned nil") + } + return err.Error() +} + +func newIMExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) { + t.Helper() + config := &core.CliConfig{ + AppID: "test-app-" + strings.ReplaceAll(strings.ToLower(t.Name()), "/", "-"), + AppSecret: "test-secret", + Brand: core.BrandFeishu, + } + factory, stdout, _, reg := cmdutil.TestFactory(t, config) + return factory, stdout, reg +} + +func mountAndRunIMShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error { + t.Helper() + parent := &cobra.Command{Use: "im"} + shortcut.Mount(parent, factory) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + stdout.Reset() + return parent.ExecuteContext(context.Background()) +} diff --git a/shortcuts/im/shortcuts.go b/shortcuts/im/shortcuts.go index e2fff3ba4..66d4691b1 100644 --- a/shortcuts/im/shortcuts.go +++ b/shortcuts/im/shortcuts.go @@ -8,6 +8,9 @@ import "github.com/larksuite/cli/shortcuts/common" // Shortcuts returns all im shortcuts. func Shortcuts() []common.Shortcut { return []common.Shortcut{ + ImAppFeedCardCreate, + ImAppFeedCardDelete, + ImAppFeedCardUpdate, ImChatCreate, ImChatMessageList, ImChatSearch, diff --git a/tests/cli_e2e/im/app_feed_card_workflow_test.go b/tests/cli_e2e/im/app_feed_card_workflow_test.go new file mode 100644 index 000000000..5172e4a2d --- /dev/null +++ b/tests/cli_e2e/im/app_feed_card_workflow_test.go @@ -0,0 +1,238 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "encoding/json" + "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 TestIM_AppFeedCardCreateDryRun(t *testing.T) { + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + result, err := clie2e.RunCmd(context.Background(), clie2e.Request{ + Args: []string{ + "im", "+app-feed-card-create", + "--user-ids", "ou_dryrun", + "--title", "Dry run app feed card", + "--preview", "Preview", + "--link", "https://example.com/card", + "--button-text", "Open", + "--button-url", "https://example.com/open", + "--dry-run", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + entry := firstIMDryRunRequest(t, result.Stdout) + assert.Equal(t, "POST", entry["method"]) + assert.Equal(t, "/open-apis/im/v2/app_feed_card", entry["url"]) + assert.Equal(t, map[string]any{"user_id_type": "open_id"}, entry["params"]) + + body, ok := entry["body"].(map[string]any) + require.True(t, ok, "body should be an object: %#v", entry["body"]) + assert.Equal(t, []any{"ou_dryrun"}, body["user_ids"]) + + card, ok := body["app_feed_card"].(map[string]any) + require.True(t, ok, "app_feed_card should be an object: %#v", body["app_feed_card"]) + assert.Equal(t, "Dry run app feed card", card["title"]) + link, ok := card["link"].(map[string]any) + require.True(t, ok, "link should be an object: %#v", card["link"]) + assert.Equal(t, "https://example.com/card", link["link"]) +} + +func TestIM_AppFeedCardBatchDryRun(t *testing.T) { + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + updateResult, err := clie2e.RunCmd(context.Background(), clie2e.Request{ + Args: []string{ + "im", "+app-feed-card-update", + "--user-ids", "ou_dryrun", + "--biz-id", "biz_dryrun", + "--title", "Updated app feed card", + "--dry-run", + }, + }) + require.NoError(t, err) + updateResult.AssertExitCode(t, 0) + + updateEntry := firstIMDryRunRequest(t, updateResult.Stdout) + assert.Equal(t, "PUT", updateEntry["method"]) + assert.Equal(t, "/open-apis/im/v2/app_feed_card/batch", updateEntry["url"]) + updateBody, ok := updateEntry["body"].(map[string]any) + require.True(t, ok, "update body should be an object: %#v", updateEntry["body"]) + updateCards, ok := updateBody["feed_cards"].([]any) + require.True(t, ok, "feed_cards should be an array: %#v", updateBody["feed_cards"]) + require.Len(t, updateCards, 1) + updateCard, ok := updateCards[0].(map[string]any) + require.True(t, ok, "feed_cards[0] should be an object: %#v", updateCards[0]) + assert.Equal(t, []any{"1"}, updateCard["update_fields"]) + + deleteResult, err := clie2e.RunCmd(context.Background(), clie2e.Request{ + Args: []string{ + "im", "+app-feed-card-delete", + "--user-ids", "ou_dryrun", + "--biz-id", "biz_dryrun", + "--dry-run", + }, + }) + require.NoError(t, err) + deleteResult.AssertExitCode(t, 0) + + deleteEntry := firstIMDryRunRequest(t, deleteResult.Stdout) + assert.Equal(t, "DELETE", deleteEntry["method"]) + assert.Equal(t, "/open-apis/im/v2/app_feed_card/batch", deleteEntry["url"]) + deleteBody, ok := deleteEntry["body"].(map[string]any) + require.True(t, ok, "delete body should be an object: %#v", deleteEntry["body"]) + deleteCards, ok := deleteBody["feed_cards"].([]any) + require.True(t, ok, "feed_cards should be an array: %#v", deleteBody["feed_cards"]) + require.Len(t, deleteCards, 1) + deleteCard, ok := deleteCards[0].(map[string]any) + require.True(t, ok, "feed_cards[0] should be an object: %#v", deleteCards[0]) + assert.Equal(t, "biz_dryrun", deleteCard["biz_id"]) +} + +func TestIM_AppFeedCardCreateWorkflowAsBot(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + clie2e.SkipWithoutUserToken(t) + + selfOpenID := getCurrentUserOpenIDForIM(t, ctx) + bizID := "lark-cli-e2e-feed-" + clie2e.GenerateSuffix() + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+app-feed-card-create", + "--user-ids", selfOpenID, + "--biz-id", bizID, + "--title", "lark-cli e2e app feed card", + "--preview", "created by lark-cli e2e", + "--link", "https://www.larksuite.com/", + "--close-notify", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + if result.ExitCode != 0 && isAppFeedPermissionFailure(result) { + t.Skipf("skipped: app feed card API permission is unavailable in this environment: %s", result.Stderr) + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + returnedBizID := gjson.Get(result.Stdout, "data.biz_id").String() + cleanupBizID := returnedBizID + if cleanupBizID == "" { + cleanupBizID = bizID + } + + deleted := false + t.Cleanup(func() { + if deleted { + return + } + cleanupCtx, cleanupCancel := clie2e.CleanupContext() + defer cleanupCancel() + cleanupResult, cleanupErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ + Args: []string{"api", "DELETE", "/open-apis/im/v2/app_feed_card/batch"}, + DefaultAs: "bot", + Params: map[string]any{"user_id_type": "open_id"}, + Data: map[string]any{ + "feed_cards": []map[string]string{{ + "biz_id": cleanupBizID, + "user_id": selfOpenID, + }}, + }, + }) + clie2e.ReportCleanupFailure(t, "delete app feed card", cleanupResult, cleanupErr) + }) + require.NotEmpty(t, returnedBizID, "stdout:\n%s", result.Stdout) + + updateResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+app-feed-card-update", + "--user-ids", selfOpenID, + "--biz-id", returnedBizID, + "--title", "lark-cli e2e app feed card updated", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + if updateResult.ExitCode != 0 && isAppFeedPermissionFailure(updateResult) { + t.Skipf("skipped: app feed card update permission is unavailable in this environment: %s", updateResult.Stderr) + } + updateResult.AssertExitCode(t, 0) + updateResult.AssertStdoutStatus(t, true) + + deleteResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "im", "+app-feed-card-delete", + "--user-ids", selfOpenID, + "--biz-id", returnedBizID, + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + if deleteResult.ExitCode != 0 && isAppFeedPermissionFailure(deleteResult) { + t.Skipf("skipped: app feed card delete permission is unavailable in this environment: %s", deleteResult.Stderr) + } + deleteResult.AssertExitCode(t, 0) + deleteResult.AssertStdoutStatus(t, true) + deleted = true +} + +func getCurrentUserOpenIDForIM(t *testing.T, ctx context.Context) string { + t.Helper() + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"contact", "+get-user"}, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + openID := gjson.Get(result.Stdout, "data.user.open_id").String() + require.NotEmpty(t, openID, "stdout:\n%s", result.Stdout) + return openID +} + +func firstIMDryRunRequest(t *testing.T, stdout string) map[string]any { + t.Helper() + + const prefix = "=== Dry Run ===\n" + stdout = strings.TrimPrefix(stdout, prefix) + var payload map[string]any + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatalf("parse dry-run payload: %v\nstdout:\n%s", err, stdout) + } + + apiEntries, ok := payload["api"].([]any) + require.True(t, ok, "payload missing api array: %#v", payload) + require.Len(t, apiEntries, 1) + + entry, ok := apiEntries[0].(map[string]any) + require.True(t, ok, "api entry is not an object: %#v", apiEntries[0]) + return entry +} + +func isAppFeedPermissionFailure(result *clie2e.Result) bool { + raw := strings.ToLower(result.Stdout + "\n" + result.Stderr) + return strings.Contains(raw, "permission") || + strings.Contains(raw, "missing_scope") || + strings.Contains(raw, "im:app_feed_card:write") || + strings.Contains(raw, "999916") +} From a4adaa29957faf3e3991c5c4209254ff3d79575d Mon Sep 17 00:00:00 2001 From: renxianwei98 Date: Mon, 20 Apr 2026 21:48:03 +0800 Subject: [PATCH 2/2] feat(im): support feed card time sensitive Add bot-only IM shortcuts for feed card temporary pinning: +feed-card-bot-time-sensitive wraps PATCH /open-apis/im/v2/feed_cards/bot_time_sentive, and +feed-card-time-sensitive wraps PATCH /open-apis/im/v2/feed_cards/{feed_card_id}. Both shortcuts accept --user-ids, --user-id-type, and an explicit --time-sensitive flag so true pins and false unpins are both represented in the request body. The feed_card_id path segment is escaped before execution. Add unit tests for payload construction, validation, dry-run shape, and execute requests, plus CLI dry-run E2E coverage for both new commands. Address PR review feedback by registering the app feed card live workflow cleanup immediately after a successful create exit before stdout assertions can fail. Verification: go test ./shortcuts/im -run 'TestBuildFeedCard|TestFeedCard|TestShortcuts' -count=1; go test ./shortcuts/im ./shortcuts -count=1; go vet ./shortcuts/im; go build -o /tmp/lark-cli-feed-card-test .; env LARK_CLI_BIN=/tmp/lark-cli-feed-card-test go test ./tests/cli_e2e/im -run 'TestIM_(AppFeedCard|FeedCard).*DryRun' -count=1. Signed-off-by: renxianwei --- shortcuts/im/helpers_test.go | 1 + shortcuts/im/im_feed_card_time_sensitive.go | 165 ++++++++++++++++++ .../im/im_feed_card_time_sensitive_test.go | 156 +++++++++++++++++ shortcuts/im/shortcuts.go | 1 + .../cli_e2e/im/app_feed_card_workflow_test.go | 55 +++++- 5 files changed, 371 insertions(+), 7 deletions(-) create mode 100644 shortcuts/im/im_feed_card_time_sensitive.go create mode 100644 shortcuts/im/im_feed_card_time_sensitive_test.go diff --git a/shortcuts/im/helpers_test.go b/shortcuts/im/helpers_test.go index 0137651e3..2beb05b90 100644 --- a/shortcuts/im/helpers_test.go +++ b/shortcuts/im/helpers_test.go @@ -647,6 +647,7 @@ func TestShortcuts(t *testing.T) { "+app-feed-card-create", "+app-feed-card-delete", "+app-feed-card-update", + "+feed-card-time-sensitive", "+chat-create", "+chat-messages-list", "+chat-search", diff --git a/shortcuts/im/im_feed_card_time_sensitive.go b/shortcuts/im/im_feed_card_time_sensitive.go new file mode 100644 index 000000000..2210fe9cb --- /dev/null +++ b/shortcuts/im/im_feed_card_time_sensitive.go @@ -0,0 +1,165 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +const feedCardTimeSensitiveBasePath = "/open-apis/im/v2/feed_cards" +const feedCardTimeSensitivePathTemplate = feedCardTimeSensitiveBasePath + "/:feed_card_id" + +const feedCardTimeSensitiveScope = "im:datasync.feed_card.time_sensitive:write" + +var ImFeedCardTimeSensitive = common.Shortcut{ + Service: "im", + Command: "+feed-card-time-sensitive", + Description: "Set a feed card as temporarily pinned or unpinned for users by feed_card_id; bot only", + Risk: "write", + Scopes: []string{feedCardTimeSensitiveScope}, + AuthTypes: []string{"bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "feed-card-id", Desc: "feed_card_id to update", Required: true}, + {Name: "user-ids", Desc: "recipient user IDs, comma-separated; IDs must match --user-id-type", Required: true}, + {Name: "user-id-type", Default: "open_id", Desc: "recipient ID type", Enum: []string{"open_id", "union_id", "user_id"}}, + {Name: "time-sensitive", Desc: "temporary pin status", Required: true, Enum: []string{"true", "false"}}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body, _ := buildFeedCardTimeSensitiveBody(runtime) + return common.NewDryRunAPI(). + PATCH(feedCardTimeSensitivePathTemplate). + Set("feed_card_id", strings.TrimSpace(runtime.Str("feed-card-id"))). + Params(map[string]interface{}{"user_id_type": appFeedCardUserIDType(runtime)}). + Body(body) + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := feedCardIDForTimeSensitive(runtime); err != nil { + return err + } + _, err := buildFeedCardTimeSensitiveBody(runtime) + return err + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + requestPath, feedCardID, err := feedCardTimeSensitiveRequestPath(runtime) + if err != nil { + return err + } + body, err := buildFeedCardTimeSensitiveBody(runtime) + if err != nil { + return err + } + if err := doFeedCardTimeSensitivePatch(runtime, requestPath, body); err != nil { + return err + } + return outputFeedCardTimeSensitiveResult(runtime, feedCardID, body) + }, +} + +func buildFeedCardTimeSensitiveBody(runtime *common.RuntimeContext) (map[string]interface{}, error) { + userIDs := common.SplitCSV(runtime.Str("user-ids")) + if err := validateAppFeedUserIDs(userIDs, appFeedCardUserIDType(runtime), "--user-ids"); err != nil { + return nil, err + } + timeSensitive, err := feedCardTimeSensitiveValue(runtime) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "user_ids": userIDs, + "time_sensitive": timeSensitive, + }, nil +} + +func feedCardTimeSensitiveValue(runtime *common.RuntimeContext) (bool, error) { + raw := strings.TrimSpace(runtime.Str("time-sensitive")) + if raw == "" { + return false, output.ErrValidation("--time-sensitive is required; pass --time-sensitive true to pin or --time-sensitive false to unpin") + } + value, err := strconv.ParseBool(raw) + if err != nil { + return false, output.ErrValidation("--time-sensitive must be true or false") + } + return value, nil +} + +func feedCardIDForTimeSensitive(runtime *common.RuntimeContext) (string, error) { + feedCardID := strings.TrimSpace(runtime.Str("feed-card-id")) + if feedCardID == "" { + return "", output.ErrValidation("--feed-card-id is required") + } + if !strings.HasPrefix(feedCardID, "oc_") { + return "", output.ErrWithHint(output.ExitValidation, "validation", + `--feed-card-id must be a group feed_card_id starting with "oc_"`, + `pass a group feed_card_id such as oc_xxx to "lark-cli im +feed-card-time-sensitive"`) + } + return feedCardID, nil +} + +func feedCardTimeSensitiveRequestPath(runtime *common.RuntimeContext) (string, string, error) { + feedCardID, err := feedCardIDForTimeSensitive(runtime) + if err != nil { + return "", "", err + } + return feedCardTimeSensitiveBasePath + "/" + validate.EncodePathSegment(feedCardID), feedCardID, nil +} + +func doFeedCardTimeSensitivePatch(runtime *common.RuntimeContext, requestPath string, body map[string]interface{}) error { + resp, err := runtime.DoAPIStream(runtime.Ctx(), &larkcore.ApiReq{ + HttpMethod: http.MethodPatch, + ApiPath: requestPath, + QueryParams: larkcore.QueryParams{ + "user_id_type": []string{appFeedCardUserIDType(runtime)}, + }, + Body: body, + }) + if err != nil { + return err + } + defer resp.Body.Close() + rawBody, err := io.ReadAll(resp.Body) + if err != nil { + return output.ErrNetwork("read response body: %v", err) + } + if len(strings.TrimSpace(string(rawBody))) == 0 { + return nil + } + + var envelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + if err := json.Unmarshal(rawBody, &envelope); err != nil { + return output.Errorf(output.ExitAPI, "api", "unmarshal response: %v", err) + } + if envelope.Code != 0 { + return output.ErrAPI(envelope.Code, envelope.Msg, nil) + } + return nil +} + +func outputFeedCardTimeSensitiveResult(runtime *common.RuntimeContext, feedCardID string, body map[string]interface{}) error { + userIDs, _ := body["user_ids"].([]string) + out := map[string]interface{}{ + "requested_user_count": len(userIDs), + "time_sensitive": body["time_sensitive"], + } + if feedCardID != "" { + out["feed_card_id"] = feedCardID + } + runtime.OutFormat(out, nil, func(w io.Writer) { + output.PrintTable(w, []map[string]interface{}{out}) + }) + return nil +} diff --git a/shortcuts/im/im_feed_card_time_sensitive_test.go b/shortcuts/im/im_feed_card_time_sensitive_test.go new file mode 100644 index 000000000..4e887de75 --- /dev/null +++ b/shortcuts/im/im_feed_card_time_sensitive_test.go @@ -0,0 +1,156 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestBuildFeedCardTimeSensitiveBody(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "user-ids": "ou_1, ou_2", + "time-sensitive": "true", + }, nil) + + body, err := buildFeedCardTimeSensitiveBody(runtime) + if err != nil { + t.Fatalf("buildFeedCardTimeSensitiveBody() error = %v", err) + } + userIDs, _ := body["user_ids"].([]string) + if len(userIDs) != 2 || userIDs[0] != "ou_1" || userIDs[1] != "ou_2" { + t.Fatalf("user_ids = %#v", body["user_ids"]) + } + if body["time_sensitive"] != true { + t.Fatalf("time_sensitive = %#v", body["time_sensitive"]) + } +} + +func TestBuildFeedCardTimeSensitiveBodyAcceptsFalse(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "user-ids": "ou_1", + "time-sensitive": "false", + }, nil) + + body, err := buildFeedCardTimeSensitiveBody(runtime) + if err != nil { + t.Fatalf("buildFeedCardTimeSensitiveBody() error = %v", err) + } + if body["time_sensitive"] != false { + t.Fatalf("time_sensitive = %#v", body["time_sensitive"]) + } +} + +func TestBuildFeedCardTimeSensitiveBodyValidation(t *testing.T) { + invalidOpenIDErr := invalidOpenIDError(t) + tests := []struct { + name string + strFlags map[string]string + boolFlags map[string]bool + wantErr string + }{ + { + name: "missing time sensitive", + strFlags: map[string]string{"user-ids": "ou_1"}, + wantErr: "--time-sensitive is required", + }, + { + name: "invalid time sensitive", + strFlags: map[string]string{"user-ids": "ou_1", "time-sensitive": "maybe"}, + wantErr: "--time-sensitive must be true or false", + }, + { + name: "invalid recipient open id", + strFlags: map[string]string{"user-ids": "bad_user", "time-sensitive": "true"}, + wantErr: invalidOpenIDErr, + }, + { + name: "union id bypasses open id prefix validation", + strFlags: map[string]string{"user-ids": "onion_1", "user-id-type": "union_id", "time-sensitive": "true"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runtime := newTestRuntimeContext(t, tt.strFlags, tt.boolFlags) + _, err := buildFeedCardTimeSensitiveBody(runtime) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error = %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want substring %q", err, tt.wantErr) + } + }) + } +} + +func TestFeedCardIDForTimeSensitiveValidation(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{"feed-card-id": " "}, nil) + if _, err := feedCardIDForTimeSensitive(runtime); err == nil || !strings.Contains(err.Error(), "--feed-card-id is required") { + t.Fatalf("feedCardIDForTimeSensitive() error = %v", err) + } + + runtime = newTestRuntimeContext(t, map[string]string{"feed-card-id": "om_123"}, nil) + if _, err := feedCardIDForTimeSensitive(runtime); err == nil || + !strings.Contains(err.Error(), `starting with "oc_"`) { + t.Fatalf("feedCardIDForTimeSensitive() non-group feed card error = %v", err) + } +} + +func TestFeedCardTimeSensitiveDryRunShape(t *testing.T) { + cardRuntime := newTestRuntimeContext(t, map[string]string{ + "feed-card-id": "oc_dryrun", + "user-ids": "ou_1", + "time-sensitive": "false", + }, nil) + cardGot := mustMarshalDryRun(t, ImFeedCardTimeSensitive.DryRun(context.Background(), cardRuntime)) + if !strings.Contains(cardGot, `"method":"PATCH"`) || + !strings.Contains(cardGot, `"/open-apis/im/v2/feed_cards/oc_dryrun"`) || + !strings.Contains(cardGot, `"time_sensitive":false`) { + t.Fatalf("ImFeedCardTimeSensitive.DryRun() = %s", cardGot) + } +} + +func TestFeedCardTimeSensitiveExecute(t *testing.T) { + factory, stdout, reg := newIMExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/im/v2/feed_cards/oc_123", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{}, + }, + } + reg.Register(stub) + + err := mountAndRunIMShortcut(t, ImFeedCardTimeSensitive, []string{ + "+feed-card-time-sensitive", + "--feed-card-id", "oc_123", + "--user-ids", "ou_1", + "--time-sensitive", "false", + }, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunIMShortcut() error = %v", err) + } + if !strings.Contains(stdout.String(), `"feed_card_id": "oc_123"`) || !strings.Contains(stdout.String(), `"time_sensitive": false`) { + t.Fatalf("stdout = %s", stdout.String()) + } + + var captured map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil { + t.Fatalf("captured body JSON = %s, err=%v", string(stub.CapturedBody), err) + } + userIDs, _ := captured["user_ids"].([]interface{}) + if len(userIDs) != 1 || captured["time_sensitive"] != false { + t.Fatalf("captured body = %#v", captured) + } +} diff --git a/shortcuts/im/shortcuts.go b/shortcuts/im/shortcuts.go index 66d4691b1..3f3b65ca4 100644 --- a/shortcuts/im/shortcuts.go +++ b/shortcuts/im/shortcuts.go @@ -11,6 +11,7 @@ func Shortcuts() []common.Shortcut { ImAppFeedCardCreate, ImAppFeedCardDelete, ImAppFeedCardUpdate, + ImFeedCardTimeSensitive, ImChatCreate, ImChatMessageList, ImChatSearch, diff --git a/tests/cli_e2e/im/app_feed_card_workflow_test.go b/tests/cli_e2e/im/app_feed_card_workflow_test.go index 5172e4a2d..87fa23a1a 100644 --- a/tests/cli_e2e/im/app_feed_card_workflow_test.go +++ b/tests/cli_e2e/im/app_feed_card_workflow_test.go @@ -106,6 +106,47 @@ func TestIM_AppFeedCardBatchDryRun(t *testing.T) { assert.Equal(t, "biz_dryrun", deleteCard["biz_id"]) } +func TestIM_FeedCardTimeSensitiveDryRun(t *testing.T) { + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + cardResult, err := clie2e.RunCmd(context.Background(), clie2e.Request{ + Args: []string{ + "im", "+feed-card-time-sensitive", + "--feed-card-id", "oc_dryrun", + "--user-ids", "ou_dryrun", + "--time-sensitive", "false", + "--dry-run", + }, + }) + require.NoError(t, err) + cardResult.AssertExitCode(t, 0) + + cardEntry := firstIMDryRunRequest(t, cardResult.Stdout) + assert.Equal(t, "PATCH", cardEntry["method"]) + assert.Equal(t, "/open-apis/im/v2/feed_cards/oc_dryrun", cardEntry["url"]) + assert.Equal(t, map[string]any{"user_id_type": "open_id"}, cardEntry["params"]) + cardBody, ok := cardEntry["body"].(map[string]any) + require.True(t, ok, "card body should be an object: %#v", cardEntry["body"]) + assert.Equal(t, []any{"ou_dryrun"}, cardBody["user_ids"]) + assert.Equal(t, false, cardBody["time_sensitive"]) + + invalidCardResult, err := clie2e.RunCmd(context.Background(), clie2e.Request{ + Args: []string{ + "im", "+feed-card-time-sensitive", + "--feed-card-id", "om_dryrun", + "--user-ids", "ou_dryrun", + "--time-sensitive", "true", + "--dry-run", + }, + }) + require.NoError(t, err) + invalidCardResult.AssertExitCode(t, 2) + assert.Equal(t, "validation", gjson.Get(invalidCardResult.Stderr, "error.type").String(), "stderr:\n%s", invalidCardResult.Stderr) + assert.Contains(t, gjson.Get(invalidCardResult.Stderr, "error.message").String(), `starting with "oc_"`) +} + func TestIM_AppFeedCardCreateWorkflowAsBot(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) @@ -132,14 +173,8 @@ func TestIM_AppFeedCardCreateWorkflowAsBot(t *testing.T) { t.Skipf("skipped: app feed card API permission is unavailable in this environment: %s", result.Stderr) } result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - - returnedBizID := gjson.Get(result.Stdout, "data.biz_id").String() - cleanupBizID := returnedBizID - if cleanupBizID == "" { - cleanupBizID = bizID - } + cleanupBizID := bizID deleted := false t.Cleanup(func() { if deleted { @@ -160,6 +195,12 @@ func TestIM_AppFeedCardCreateWorkflowAsBot(t *testing.T) { }) clie2e.ReportCleanupFailure(t, "delete app feed card", cleanupResult, cleanupErr) }) + + result.AssertStdoutStatus(t, true) + returnedBizID := gjson.Get(result.Stdout, "data.biz_id").String() + if returnedBizID != "" { + cleanupBizID = returnedBizID + } require.NotEmpty(t, returnedBizID, "stdout:\n%s", result.Stdout) updateResult, err := clie2e.RunCmd(ctx, clie2e.Request{