diff --git a/shortcuts/application/shortcuts.go b/shortcuts/application/shortcuts.go index 549455d0a..f6fdc9b63 100644 --- a/shortcuts/application/shortcuts.go +++ b/shortcuts/application/shortcuts.go @@ -12,5 +12,6 @@ func Shortcuts() []common.Shortcut { return []common.Shortcut{ SlashCommandList, SlashCommandCreate, + SlashCommandUpdate, } } diff --git a/shortcuts/application/slash_command_update.go b/shortcuts/application/slash_command_update.go new file mode 100644 index 000000000..369292791 --- /dev/null +++ b/shortcuts/application/slash_command_update.go @@ -0,0 +1,119 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package application + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// validateUpdateTarget enforces: exactly one of --command-id/--command, and at +// least one editable field; --description-i18n requires --description (PATCH +// replaces the whole description object - sending i18n alone would drop +// default_value; conservative rule, see spec amendment #3). +func validateUpdateTarget(runtime *common.RuntimeContext) error { + id := strings.TrimSpace(runtime.Str("command-id")) + name := strings.TrimSpace(runtime.Str("command")) + if (id == "") == (name == "") { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "provide exactly one of --command-id or --command").WithParam("--command-id") + } + if name != "" { + if err := validateCommandName(name, "--command"); err != nil { + return err + } + } + hasDesc := strings.TrimSpace(runtime.Str("description")) != "" + hasI18n := len(runtime.StrArray("description-i18n")) > 0 + hasIcon := strings.TrimSpace(runtime.Str("icon-key")) != "" + if !hasDesc && !hasI18n && !hasIcon { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "provide at least one of --description / --description-i18n / --icon-key").WithParam("--description") + } + if hasI18n && !hasDesc { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--description-i18n requires --description: PATCH replaces the whole description object, so default_value must be provided together").WithParam("--description-i18n") + } + if _, err := parseDescriptionI18n(runtime.StrArray("description-i18n")); err != nil { + return err + } + return nil +} + +// SlashCommandUpdate updates description/i18n/icon of an existing slash command. +var SlashCommandUpdate = common.Shortcut{ + Service: "application", + Command: "+slash-command-update", + Description: "Update description / localized descriptions / icon of a slash command on the current bound app, addressed by --command-id or by name via --command", + Risk: "write", + BotScopes: []string{"application:app_slash_command:write"}, + ConditionalBotScopes: []string{ + "application:app_slash_command:read", // only the --command by-name path lists to resolve the id + }, + AuthTypes: []string{"bot", "user"}, + Flags: []common.Flag{ + {Name: "command-id", Desc: "target command_id (from +slash-command-list or create output); mutually exclusive with --command"}, + {Name: "command", Desc: "target command name WITHOUT leading slash; resolved via live list (needs read scope); mutually exclusive with --command-id"}, + {Name: "description", Desc: "new default description (description.default_value)"}, + {Name: "description-i18n", Type: "string_array", Desc: "localized description, repeatable =; REPLACES the whole i18n map (missing languages are dropped); requires --description"}, + {Name: "icon-key", Desc: "new icon key (invalid keys rejected server-side with code 40000031)"}, + }, + Tips: []string{ + `lark-cli application +slash-command-update --command greet --description "new text" --as bot`, + "PATCH is field-level partial: fields you do not pass are preserved server-side", + "the command NAME itself cannot be changed (API limitation): rename = delete + create (new command_id)", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateUpdateTarget(runtime) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n")) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + body := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key")) + d := common.NewDryRunAPI() + target := runtime.Str("command-id") + if target == "" { + d.Desc(fmt.Sprintf("resolve command_id by name %q via GET list first", runtime.Str("command"))). + GET(slashCommandBasePath) + target = "" + } + return d.PATCH(slashCommandBasePath + "/" + target).Body(body) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + id := strings.TrimSpace(runtime.Str("command-id")) + if id == "" { + resolved, _, err := resolveCommandID(runtime, strings.TrimSpace(runtime.Str("command"))) + if err != nil { + return err + } + id = resolved + } + i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n")) + if err != nil { + return err + } + body := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key")) + data, err := runtime.CallAPITyped("PATCH", slashCommandBasePath+"/"+validate.EncodePathSegment(id), nil, body) + if err != nil { + return err + } + if data == nil { + data = map[string]interface{}{} + } + data["action"] = "updated" + fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint) + runtime.OutFormat(data, nil, func(w io.Writer) { + fmt.Fprintf(w, "updated /%v (command_id: %v)\n", data["command"], data["command_id"]) + }) + return nil + }, +} diff --git a/shortcuts/application/slash_command_update_test.go b/shortcuts/application/slash_command_update_test.go new file mode 100644 index 000000000..7533bafbd --- /dev/null +++ b/shortcuts/application/slash_command_update_test.go @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package application + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" +) + +func TestSlashCommandUpdate_ByID(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig()) + reg.Register(patchOKStub("id1")) + + err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update", + "--command-id", "id1", "--description", "new", "--format", "json", "--as", "bot"}, f, stdout) + if err != nil { + t.Fatalf("execute: %v", err) + } + var got map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("json: %v", err) + } + data := got["data"].(map[string]interface{}) + if data["action"] != "updated" { + t.Fatalf("action = %v", data["action"]) + } +} + +func TestSlashCommandUpdate_ByName(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig()) + reg.Register(listStub([]interface{}{sampleItem("greet", "id9")})) + reg.Register(patchOKStub("id9")) + + err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update", + "--command", "greet", "--icon-key", "skill_outlined", "--format", "json", "--as", "bot"}, f, stdout) + if err != nil { + t.Fatalf("execute: %v", err) + } +} + +func TestSlashCommandUpdate_ByNameNotFound(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig()) + reg.Register(listStub(nil)) + + err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update", + "--command", "nope", "--description", "x", "--as", "bot"}, f, stdout) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected not found, got %v", err) + } +} + +func TestSlashCommandUpdate_Validate(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig()) + cases := []struct { + name string + args []string + }{ + {"both id and name", []string{"+slash-command-update", "--command-id", "id1", "--command", "greet", "--description", "x", "--as", "bot"}}, + {"neither id nor name", []string{"+slash-command-update", "--description", "x", "--as", "bot"}}, + {"no editable field", []string{"+slash-command-update", "--command-id", "id1", "--as", "bot"}}, + {"i18n without description", []string{"+slash-command-update", "--command-id", "id1", "--description-i18n", "zh_cn=x", "--as", "bot"}}, + } + for _, c := range cases { + err := mountAndRun(t, SlashCommandUpdate, c.args, f, stdout) + if err == nil { + t.Errorf("%s: expected validation error", c.name) + continue + } + p, ok := errs.ProblemOf(err) + if !ok || p.Category != errs.CategoryValidation { + t.Errorf("%s: expected validation problem, got %v", c.name, err) + } + } +}