mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 18:13:01 +08:00
feat: add slash-command-create shortcut with --force idempotent re-run
This commit is contained in:
@@ -11,5 +11,6 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
SlashCommandList,
|
||||
SlashCommandCreate,
|
||||
}
|
||||
}
|
||||
|
||||
109
shortcuts/application/slash_command_create.go
Normal file
109
shortcuts/application/slash_command_create.go
Normal file
@@ -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 <lang>=<text> (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
|
||||
},
|
||||
}
|
||||
143
shortcuts/application/slash_command_create_test.go
Normal file
143
shortcuts/application/slash_command_create_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user