diff --git a/shortcuts/im/helpers_test.go b/shortcuts/im/helpers_test.go index 2596b8f0e..2beb05b90 100644 --- a/shortcuts/im/helpers_test.go +++ b/shortcuts/im/helpers_test.go @@ -644,6 +644,10 @@ func TestShortcuts(t *testing.T) { } want := []string{ + "+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_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/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 e2fff3ba4..3f3b65ca4 100644 --- a/shortcuts/im/shortcuts.go +++ b/shortcuts/im/shortcuts.go @@ -8,6 +8,10 @@ import "github.com/larksuite/cli/shortcuts/common" // Shortcuts returns all im shortcuts. func Shortcuts() []common.Shortcut { return []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 new file mode 100644 index 000000000..87fa23a1a --- /dev/null +++ b/tests/cli_e2e/im/app_feed_card_workflow_test.go @@ -0,0 +1,279 @@ +// 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_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) + + 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) + + 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) + }) + + 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{ + 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") +}