diff --git a/shortcuts/application/shortcuts.go b/shortcuts/application/shortcuts.go index a1f83ae49..549455d0a 100644 --- a/shortcuts/application/shortcuts.go +++ b/shortcuts/application/shortcuts.go @@ -11,5 +11,6 @@ import "github.com/larksuite/cli/shortcuts/common" func Shortcuts() []common.Shortcut { return []common.Shortcut{ SlashCommandList, + SlashCommandCreate, } } diff --git a/shortcuts/application/slash_command_create.go b/shortcuts/application/slash_command_create.go new file mode 100644 index 000000000..19540cd06 --- /dev/null +++ b/shortcuts/application/slash_command_create.go @@ -0,0 +1,109 @@ +// 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" +) + +// SlashCommandCreate registers a new slash command on the current bound app. +var SlashCommandCreate = common.Shortcut{ + Service: "application", + Command: "+slash-command-create", + Description: "Register a slash command (/ command) on the current bound Open Platform app; --force converts a name collision into an update (idempotent re-run)", + Risk: "write", + BotScopes: []string{"application:app_slash_command:write"}, + ConditionalBotScopes: []string{ + "application:app_slash_command:read", // only the --force collision path lists to resolve the id + }, + AuthTypes: []string{"bot", "user"}, + Flags: []common.Flag{ + {Name: "command", Desc: "command name WITHOUT the leading slash (server enforces uniqueness per app; max 100 commands)", Required: true}, + {Name: "description", Desc: "default description shown in the client command panel (description.default_value)", Required: true}, + {Name: "description-i18n", Type: "string_array", Desc: "localized description, repeatable, format = (e.g. zh_cn=发送问候); language codes are passed through to the server"}, + {Name: "icon-key", Desc: "icon key (server default: skill_outlined; invalid keys are rejected server-side with code 40000031)"}, + {Name: "force", Type: "bool", Desc: "on name collision, resolve the existing command by name and PATCH it instead (like `gh label create --force`)"}, + }, + Tips: []string{ + `lark-cli application +slash-command-create --command greet --description "say hi" --description-i18n zh_cn=问候 --as bot`, + "changes take ~5 minutes to appear in clients (client-side cache); the server updates immediately", + "user identity needs explicit authorization first: lark-cli auth login --scope application:app_slash_command:write", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if err := validateCommandName(runtime.Str("command"), "--command"); err != nil { + return err + } + if len(strings.TrimSpace(runtime.Str("description"))) == 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--description must not be blank").WithParam("--description") + } + if _, err := parseDescriptionI18n(runtime.StrArray("description-i18n")); err != nil { + return err + } + return nil + }, + 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("command"), runtime.Str("description"), i18n, runtime.Str("icon-key")) + d := common.NewDryRunAPI(). + Desc("Create a slash command on the current bound app"). + POST(slashCommandBasePath). + Body(body) + if runtime.Bool("force") { + d.Desc("--force: on 'command already exists' (code 40000000), GET list to resolve command_id then PATCH the same body") + } + return d + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + name := runtime.Str("command") + i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n")) + if err != nil { + return err + } + body := buildSlashCommandBody(name, runtime.Str("description"), i18n, runtime.Str("icon-key")) + + data, err := runtime.CallAPITyped("POST", slashCommandBasePath, nil, body) + action := "created" + if err != nil { + if !isCommandExists(err) { + return err + } + if !runtime.Bool("force") { + p, _ := errs.ProblemOf(err) + return errs.NewAPIError(p.Subtype, "slash command %q already exists", name). + WithHint("rerun with --force to update it, or use `lark-cli application +slash-command-update --command %q`", name) + } + // --force: name collision -> resolve id -> PATCH (idempotent re-run). + id, _, rerr := resolveCommandID(runtime, name) + if rerr != nil { + return rerr + } + patchBody := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key")) + data, err = runtime.CallAPITyped("PATCH", slashCommandBasePath+"/"+validate.EncodePathSegment(id), nil, patchBody) + if err != nil { + return err + } + action = "updated" + } + if data == nil { + data = map[string]interface{}{} + } + data["action"] = action + fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint) + runtime.OutFormat(data, nil, func(w io.Writer) { + fmt.Fprintf(w, "%s /%v (command_id: %v)\n", action, data["command"], data["command_id"]) + }) + return nil + }, +} diff --git a/shortcuts/application/slash_command_create_test.go b/shortcuts/application/slash_command_create_test.go new file mode 100644 index 000000000..62ecec0c5 --- /dev/null +++ b/shortcuts/application/slash_command_create_test.go @@ -0,0 +1,143 @@ +// 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" + "github.com/larksuite/cli/internal/httpmock" +) + +func createOKStub() *httpmock.Stub { + return &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/application/v7/app_slash_commands", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": sampleItem("greet", "id-new"), + }, + } +} + +func createConflictStub() *httpmock.Stub { + return &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/application/v7/app_slash_commands", + Body: map[string]interface{}{ + "code": 40000000, "msg": "Invalid Param 'command'. command already exists.", + }, + } +} + +func patchOKStub(id string) *httpmock.Stub { + return &httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/application/v7/app_slash_commands/" + id, + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": sampleItem("greet", id), + }, + } +} + +func TestSlashCommandCreate_OK(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig()) + reg.Register(createOKStub()) + + err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create", + "--command", "greet", "--description", "hi", + "--description-i18n", "zh_cn=你好", "--description-i18n", "en_us=Hello", + "--icon-key", "skill_outlined", "--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\n%s", err, stdout.String()) + } + data := got["data"].(map[string]interface{}) + if data["action"] != "created" { + t.Fatalf("action = %v", data["action"]) + } + if data["command_id"] != "id-new" { + t.Fatalf("command_id = %v", data["command_id"]) + } +} + +func TestSlashCommandCreate_ValidateRejects(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig()) + cases := [][]string{ + {"+slash-command-create", "--command", "/greet", "--description", "hi", "--as", "bot"}, + {"+slash-command-create", "--command", "greet", "--description", "hi", "--description-i18n", "bad", "--as", "bot"}, + {"+slash-command-create", "--command", "greet", "--description", "hi", "--description-i18n", "zh_cn=a", "--description-i18n", "zh_cn=b", "--as", "bot"}, + {"+slash-command-create", "--command", "greet", "--description", " ", "--as", "bot"}, + } + for i, args := range cases { + err := mountAndRun(t, SlashCommandCreate, args, f, stdout) + if err == nil { + t.Errorf("case %d: expected validation error", i) + continue + } + p, ok := errs.ProblemOf(err) + if !ok || p.Category != errs.CategoryValidation { + t.Errorf("case %d: expected validation problem, got %v", i, err) + } + } +} + +func TestSlashCommandCreate_ConflictNoForce(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig()) + reg.Register(createConflictStub()) + + err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create", + "--command", "greet", "--description", "hi", "--as", "bot"}, f, stdout) + if err == nil { + t.Fatal("expected conflict error") + } + p, _ := errs.ProblemOf(err) + if !strings.Contains(p.Hint, "--force") || !strings.Contains(p.Hint, "+slash-command-update") { + t.Fatalf("hint must offer --force and update, got %q", p.Hint) + } +} + +func TestSlashCommandCreate_ForceConvertsToUpdate(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig()) + reg.Register(createConflictStub()) + reg.Register(listStub([]interface{}{sampleItem("greet", "id-exist")})) + reg.Register(patchOKStub("id-exist")) + + err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create", + "--command", "greet", "--description", "hi2", "--force", "--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 (force must convert to update)", data["action"]) + } +} + +func TestSlashCommandCreate_DryRun(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig()) + if err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create", + "--command", "greet", "--description", "hi", "--icon-key", "skill_outlined", "--dry-run", "--as", "bot"}, f, stdout); err != nil { + t.Fatalf("execute: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "POST") || !strings.Contains(out, slashCommandBasePath) { + t.Fatalf("dry-run must show POST path: %s", out) + } + // icon 顶层:dry-run body 里 icon 不嵌套在 description 内 + if !strings.Contains(out, "icon_key") { + t.Fatalf("dry-run must include body: %s", out) + } +}