mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 18:13:01 +08:00
Compare commits
19 Commits
feat-ppe-s
...
feat/appli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78acceb910 | ||
|
|
cb8d09d802 | ||
|
|
42dc50f5fb | ||
|
|
59ccffb09a | ||
|
|
d4f62490d0 | ||
|
|
d8327726b0 | ||
|
|
3a297df5b0 | ||
|
|
dce04f792a | ||
|
|
fd00d43406 | ||
|
|
f2f49a68f4 | ||
|
|
42c4b7d155 | ||
|
|
96c8e638a1 | ||
|
|
ba8368504d | ||
|
|
c3adfc1789 | ||
|
|
a290aa628f | ||
|
|
edeccaa419 | ||
|
|
84962a1927 | ||
|
|
a72a562c7d | ||
|
|
deb866f981 |
@@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
|
||||
// (not backed by from_meta service specs). Descriptions are now centralized in
|
||||
// service_descriptions.json.
|
||||
func getShortcutOnlyDomainNames() []string {
|
||||
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
|
||||
return []string{"application", "base", "contact", "docs", "markdown", "apps", "note"}
|
||||
}
|
||||
|
||||
@@ -248,10 +248,18 @@ func TestLoadPlatformAutoApproveSet(t *testing.T) {
|
||||
|
||||
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
|
||||
allowSet := LoadOverrideAutoApproveAllow()
|
||||
// recommend.allow in scope_overrides.json is intentionally empty:
|
||||
// no scopes are special-cased into the auto-approve set anymore.
|
||||
if len(allowSet) != 0 {
|
||||
t.Errorf("expected empty override allow set, got %d entries", len(allowSet))
|
||||
// recommend.allow special-cases scopes absent from scope_priorities.json
|
||||
// (application v7 is not in the platform catalog yet) so interactive
|
||||
// login's "common scopes" tier still offers them. Only the read scope is
|
||||
// admitted: write stays out of the recommended tier by design.
|
||||
if !allowSet["application:app_slash_command:read"] {
|
||||
t.Error("expected application:app_slash_command:read in override allow set")
|
||||
}
|
||||
if allowSet["application:app_slash_command:write"] {
|
||||
t.Error("write scope must NOT be in the recommended tier")
|
||||
}
|
||||
if len(allowSet) != 1 {
|
||||
t.Errorf("expected exactly 1 override allow entry, got %d", len(allowSet))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
"vc:meeting.meetingevent:read": 75
|
||||
},
|
||||
"recommend": {
|
||||
"allow": [],
|
||||
"allow": [
|
||||
"application:app_slash_command:read"
|
||||
],
|
||||
"deny": [
|
||||
"im:chat",
|
||||
"im:message.send_as_user"
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"en": { "title": "Approval", "description": "Approval instance, and task management" },
|
||||
"zh": { "title": "审批", "description": "审批实例、审批任务管理" }
|
||||
},
|
||||
"application": {
|
||||
"en": { "title": "Application", "description": "Open Platform app self-management: slash commands of the current bound app (NOT Miaoda low-code apps)" },
|
||||
"zh": { "title": "应用管理", "description": "开放平台应用自管理:当前绑定应用的斜杠指令管理(非妙搭低代码应用)" }
|
||||
},
|
||||
"apps": {
|
||||
"en": { "title": "Apps", "description": "Develop, deploy HTML, web pages and applications" },
|
||||
"zh": { "title": "应用", "description": "开发、部署 HTML、Web 页面和应用" }
|
||||
|
||||
18
shortcuts/application/shortcuts.go
Normal file
18
shortcuts/application/shortcuts.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package application provides shortcuts for Open Platform app
|
||||
// self-management (slash commands of the current bound app).
|
||||
package application
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
// Shortcuts returns all shortcuts of the application domain.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
SlashCommandList,
|
||||
SlashCommandCreate,
|
||||
SlashCommandUpdate,
|
||||
SlashCommandDelete,
|
||||
}
|
||||
}
|
||||
98
shortcuts/application/slash_command_common.go
Normal file
98
shortcuts/application/slash_command_common.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// slashCommandBasePath is the raw v7 endpoint (not in meta_data.json / SDK).
|
||||
const slashCommandBasePath = "/open-apis/application/v7/app_slash_commands"
|
||||
|
||||
// clientCacheHint is printed to stderr after every successful write.
|
||||
const clientCacheHint = "note: changes take ~5 minutes to appear in Feishu clients (client-side cache); the server state is already updated - list reflects it immediately."
|
||||
|
||||
// parseDescriptionI18n parses repeated --description-i18n values ("<lang>=<text>",
|
||||
// split on the FIRST '='). Returns nil for empty input. Duplicate langs rejected.
|
||||
func parseDescriptionI18n(values []string) (map[string]string, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
m := make(map[string]string, len(values))
|
||||
for _, v := range values {
|
||||
idx := strings.Index(v, "=")
|
||||
if idx <= 0 || idx == len(v)-1 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid --description-i18n value %q: expected <lang>=<text> (e.g. zh_cn=你好)", v).
|
||||
WithParam("--description-i18n")
|
||||
}
|
||||
lang := strings.TrimSpace(v[:idx])
|
||||
text := v[idx+1:]
|
||||
if lang == "" || strings.TrimSpace(text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid --description-i18n value %q: language and text must be non-empty", v).
|
||||
WithParam("--description-i18n")
|
||||
}
|
||||
if _, dup := m[lang]; dup {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"duplicate language %q in --description-i18n", lang).
|
||||
WithParam("--description-i18n")
|
||||
}
|
||||
m[lang] = text
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// validateCommandName rejects empty and slash-prefixed command names.
|
||||
func validateCommandName(name, flagName string) error {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s must not be empty", flagName).WithParam(flagName)
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "/") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s must not start with \"/\" - the slash is implied (use %q)",
|
||||
flagName, strings.TrimPrefix(trimmed, "/")).WithParam(flagName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildSlashCommandBody assembles a create/update request body. Only provided
|
||||
// fields are included: PATCH is field-level partial (absent top-level fields
|
||||
// are preserved server-side; a provided i18n map REPLACES the whole map).
|
||||
// icon sits at the top level, sibling of description (verified live; the
|
||||
// official create sample nesting icon inside description is a doc bug).
|
||||
func buildSlashCommandBody(command, description string, i18n map[string]string, iconKey string) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
if command != "" {
|
||||
body["command"] = command
|
||||
}
|
||||
if description != "" || len(i18n) > 0 {
|
||||
desc := map[string]interface{}{}
|
||||
if description != "" {
|
||||
desc["default_value"] = description
|
||||
}
|
||||
if len(i18n) > 0 {
|
||||
desc["i18n"] = i18n
|
||||
}
|
||||
body["description"] = desc
|
||||
}
|
||||
if iconKey != "" {
|
||||
body["icon"] = map[string]interface{}{"icon_key": iconKey}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// isCommandExists reports whether err is the server-side name-collision error
|
||||
// (code=40000000, message contains "command already exists"; verified live).
|
||||
func isCommandExists(err error) bool {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(p.Message, "command already exists")
|
||||
}
|
||||
155
shortcuts/application/slash_command_common_test.go
Normal file
155
shortcuts/application/slash_command_common_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestParseDescriptionI18n_OK(t *testing.T) {
|
||||
m, err := parseDescriptionI18n([]string{"zh_cn=你好", "en_us=Hello=World"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if m["zh_cn"] != "你好" {
|
||||
t.Errorf("zh_cn = %q", m["zh_cn"])
|
||||
}
|
||||
// 只按首个 = 分割:值内可含 =
|
||||
if m["en_us"] != "Hello=World" {
|
||||
t.Errorf("en_us = %q", m["en_us"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDescriptionI18n_Empty(t *testing.T) {
|
||||
m, err := parseDescriptionI18n(nil)
|
||||
if err != nil || m != nil {
|
||||
t.Fatalf("nil input: m=%v err=%v", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDescriptionI18n_BadFormat(t *testing.T) {
|
||||
for _, bad := range []string{"zh_cn", "=text", "zh_cn=", " =x"} {
|
||||
_, err := parseDescriptionI18n([]string{bad})
|
||||
if err == nil {
|
||||
t.Errorf("%q: expected error", bad)
|
||||
continue
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation {
|
||||
t.Errorf("%q: expected validation problem, got %v", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDescriptionI18n_DuplicateLang(t *testing.T) {
|
||||
_, err := parseDescriptionI18n([]string{"zh_cn=a", "zh_cn=b"})
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate language") {
|
||||
t.Fatalf("expected duplicate language error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCommandName(t *testing.T) {
|
||||
if err := validateCommandName("greet", "--command"); err != nil {
|
||||
t.Fatalf("greet: %v", err)
|
||||
}
|
||||
for _, bad := range []string{"", " ", "/greet"} {
|
||||
if err := validateCommandName(bad, "--command"); err == nil {
|
||||
t.Errorf("%q: expected error", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSlashCommandBody(t *testing.T) {
|
||||
body := buildSlashCommandBody("greet", "hi", map[string]string{"zh_cn": "你好"}, "skill_outlined")
|
||||
if body["command"] != "greet" {
|
||||
t.Errorf("command = %v", body["command"])
|
||||
}
|
||||
desc := body["description"].(map[string]interface{})
|
||||
if desc["default_value"] != "hi" {
|
||||
t.Errorf("default_value = %v", desc["default_value"])
|
||||
}
|
||||
if desc["i18n"].(map[string]string)["zh_cn"] != "你好" {
|
||||
t.Errorf("i18n = %v", desc["i18n"])
|
||||
}
|
||||
// icon 与 description 顶层平级(实测钉死,文档 create 示例是笔误)
|
||||
if body["icon"].(map[string]interface{})["icon_key"] != "skill_outlined" {
|
||||
t.Errorf("icon = %v", body["icon"])
|
||||
}
|
||||
// partial:不提供的字段不出现(PATCH 语义依赖)
|
||||
partial := buildSlashCommandBody("", "", nil, "skill_outlined")
|
||||
if _, has := partial["command"]; has {
|
||||
t.Error("empty command must be omitted")
|
||||
}
|
||||
if _, has := partial["description"]; has {
|
||||
t.Error("empty description must be omitted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlashCommandShortcuts_SharedScopesAcrossIdentities locks in the
|
||||
// reversal of the OAuth-isolation design: all four slash-command shortcuts
|
||||
// declare identical scopes for the bot and user identities (plain Scopes /
|
||||
// ConditionalScopes, no per-identity overrides), so a user-identity
|
||||
// pre-flight sees the same scope set a bot identity would.
|
||||
func TestSlashCommandShortcuts_SharedScopesAcrossIdentities(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantScope string
|
||||
wantConditional string
|
||||
hasConditional bool
|
||||
}{
|
||||
{
|
||||
name: "list",
|
||||
shortcut: SlashCommandList,
|
||||
wantScope: "application:app_slash_command:read",
|
||||
},
|
||||
{
|
||||
name: "create",
|
||||
shortcut: SlashCommandCreate,
|
||||
wantScope: "application:app_slash_command:write",
|
||||
wantConditional: "application:app_slash_command:read",
|
||||
hasConditional: true,
|
||||
},
|
||||
{
|
||||
name: "update",
|
||||
shortcut: SlashCommandUpdate,
|
||||
wantScope: "application:app_slash_command:write",
|
||||
wantConditional: "application:app_slash_command:read",
|
||||
hasConditional: true,
|
||||
},
|
||||
{
|
||||
name: "delete",
|
||||
shortcut: SlashCommandDelete,
|
||||
wantScope: "application:app_slash_command:write",
|
||||
wantConditional: "application:app_slash_command:read",
|
||||
hasConditional: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for _, identity := range []string{"user", "bot"} {
|
||||
declared := tc.shortcut.DeclaredScopesForIdentity(identity)
|
||||
if !containsStr(declared, tc.wantScope) {
|
||||
t.Errorf("%s: DeclaredScopesForIdentity(%q) = %v, want to contain %q", tc.name, identity, declared, tc.wantScope)
|
||||
}
|
||||
if tc.hasConditional && !containsStr(declared, tc.wantConditional) {
|
||||
t.Errorf("%s: DeclaredScopesForIdentity(%q) = %v, want to contain conditional %q", tc.name, identity, declared, tc.wantConditional)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func containsStr(list []string, want string) bool {
|
||||
for _, v := range list {
|
||||
if v == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
117
shortcuts/application/slash_command_create.go
Normal file
117
shortcuts/application/slash_command_create.go
Normal file
@@ -0,0 +1,117 @@
|
||||
// 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",
|
||||
Scopes: []string{"application:app_slash_command:write"},
|
||||
ConditionalScopes: []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)
|
||||
rewrapped := 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).
|
||||
WithCause(err)
|
||||
if p.Code != 0 {
|
||||
rewrapped = rewrapped.WithCode(p.Code)
|
||||
}
|
||||
if p.LogID != "" {
|
||||
rewrapped = rewrapped.WithLogID(p.LogID)
|
||||
}
|
||||
return rewrapped
|
||||
}
|
||||
// --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
|
||||
},
|
||||
}
|
||||
182
shortcuts/application/slash_command_create_test.go
Normal file
182
shortcuts/application/slash_command_create_test.go
Normal file
@@ -0,0 +1,182 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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)
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("rewrapped error must be *errs.APIError, got %T", err)
|
||||
}
|
||||
if errors.Unwrap(apiErr) == nil {
|
||||
t.Fatal("rewrapped conflict error must preserve the original cause via WithCause")
|
||||
}
|
||||
}
|
||||
|
||||
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 createIconInvalidStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/application/v7/app_slash_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 40000031, "msg": "Invalid Param 'icon_key'. icon_key is invalid.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlashCommandCreate_ForceDoesNotConvertNonConflict guards against --force
|
||||
// blindly treating ANY POST failure as a name collision: only the
|
||||
// "command already exists" (40000000) shape may fall through to the
|
||||
// GET+PATCH idempotent-update path. No PATCH stub is registered here, so if
|
||||
// the code mistakenly attempted a PATCH, the httpmock registry would fail
|
||||
// the unexpected request and surface a different (registry) error instead
|
||||
// of the original icon_key failure asserted below.
|
||||
func TestSlashCommandCreate_ForceDoesNotConvertNonConflict(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(createIconInvalidStub())
|
||||
|
||||
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
|
||||
"--command", "greet", "--description", "hi", "--icon-key", "bogus", "--force", "--as", "bot"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected the original icon_key error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "icon_key") {
|
||||
t.Fatalf("expected original icon_key failure to surface unchanged, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
83
shortcuts/application/slash_command_delete.go
Normal file
83
shortcuts/application/slash_command_delete.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// SlashCommandDelete removes a slash command (irreversible; command_id is not
|
||||
// reused - recreating the same name yields a NEW id).
|
||||
var SlashCommandDelete = common.Shortcut{
|
||||
Service: "application",
|
||||
Command: "+slash-command-delete",
|
||||
Description: "Delete a slash command from the current bound app (high-risk: irreversible; recreating the same name yields a new command_id)",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"application:app_slash_command:write"},
|
||||
ConditionalScopes: []string{
|
||||
"application:app_slash_command:read", // only the --command by-name path
|
||||
},
|
||||
AuthTypes: []string{"bot", "user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "command-id", Desc: "target command_id; 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"},
|
||||
},
|
||||
Tips: []string{
|
||||
"lark-cli application +slash-command-delete --command greet --yes --as bot",
|
||||
"deleted commands may linger in clients for ~5 minutes (client cache)",
|
||||
},
|
||||
Validate: func(ctx context.Context, 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 != "" {
|
||||
return validateCommandName(name, "--command")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
d := common.NewDryRunAPI().Desc("HIGH-RISK: delete a slash command (irreversible; same-name recreate gets a NEW command_id)")
|
||||
target := runtime.Str("command-id")
|
||||
if target == "" {
|
||||
d.GET(slashCommandBasePath).
|
||||
Desc(fmt.Sprintf("resolve command_id by name %q via GET list first", runtime.Str("command")))
|
||||
target = "<resolved_command_id>"
|
||||
}
|
||||
return d.DELETE(slashCommandBasePath + "/" + target)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
id := strings.TrimSpace(runtime.Str("command-id"))
|
||||
name := strings.TrimSpace(runtime.Str("command"))
|
||||
if id == "" {
|
||||
resolved, _, err := resolveCommandID(runtime, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id = resolved
|
||||
}
|
||||
if _, err := runtime.CallAPITyped("DELETE", slashCommandBasePath+"/"+validate.EncodePathSegment(id), nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
out := map[string]interface{}{"action": "deleted", "command_id": id}
|
||||
if name != "" {
|
||||
out["command"] = name
|
||||
}
|
||||
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "note: recreating the same command name will yield a NEW command_id.")
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "deleted command_id %s\n", id)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
109
shortcuts/application/slash_command_delete_test.go
Normal file
109
shortcuts/application/slash_command_delete_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// 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 deleteOKStub(id string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: slashCommandBasePath + "/" + id,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_RequiresYes(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
|
||||
"--command-id", "id1", "--as", "bot"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation_required without --yes")
|
||||
}
|
||||
if errs.CategoryOf(err) != errs.CategoryConfirmation {
|
||||
t.Fatalf("expected confirmation category, got %v (%v)", errs.CategoryOf(err), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_ByIDWithYes(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(deleteOKStub("id1"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
|
||||
"--command-id", "id1", "--yes", "--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{})
|
||||
// 上游 DELETE 返回空对象;CLI 必须补 action/command_id(写操作返回资源 ID)
|
||||
if data["action"] != "deleted" || data["command_id"] != "id1" {
|
||||
t.Fatalf("data = %v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_ByNameWithYes(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(listStub([]interface{}{sampleItem("greet", "id7")}))
|
||||
reg.Register(deleteOKStub("id7"))
|
||||
|
||||
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
|
||||
"--command", "greet", "--yes", "--format", "json", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
_ = json.Unmarshal(stdout.Bytes(), &got)
|
||||
data := got["data"].(map[string]interface{})
|
||||
if data["command"] != "greet" || data["command_id"] != "id7" {
|
||||
t.Fatalf("data = %v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_ByNameDryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
|
||||
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
|
||||
"--command", "greet", "--dry-run", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
// 两条 Desc 都必须保留:top-level HIGH-RISK 说明和 GET 调用的 resolve 说明
|
||||
// 不能被覆盖(DryRunAPI.Desc 在没有 call 时设置 top-level,append 后设置 per-call)。
|
||||
if !strings.Contains(out, "HIGH-RISK") {
|
||||
t.Fatalf("dry-run must keep top-level HIGH-RISK desc: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "resolve command_id") {
|
||||
t.Fatalf("dry-run must keep per-call resolve desc: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandDelete_Validate(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
for _, args := range [][]string{
|
||||
{"+slash-command-delete", "--yes", "--as", "bot"},
|
||||
{"+slash-command-delete", "--command-id", "id1", "--command", "greet", "--yes", "--as", "bot"},
|
||||
} {
|
||||
err := mountAndRun(t, SlashCommandDelete, args, f, stdout)
|
||||
if err == nil {
|
||||
t.Errorf("%v: expected validation error", args)
|
||||
continue
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation {
|
||||
t.Errorf("%v: expected validation problem, got %v", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
58
shortcuts/application/slash_command_list.go
Normal file
58
shortcuts/application/slash_command_list.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlashCommandList lists all slash commands of the current bound app.
|
||||
var SlashCommandList = common.Shortcut{
|
||||
Service: "application",
|
||||
Command: "+slash-command-list",
|
||||
Description: "List all slash commands (/ commands) registered on the current bound Open Platform app; source of command_id for update/delete (NOT for Miaoda apps - use the apps domain for those)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"application:app_slash_command:read"},
|
||||
AuthTypes: []string{"bot", "user"},
|
||||
Tips: []string{
|
||||
"lark-cli application +slash-command-list --as bot",
|
||||
"user identity needs explicit authorization first: lark-cli auth login --scope application:app_slash_command:read",
|
||||
"the upstream API returns all commands at once (max 100 per app, no pagination)",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("List all slash commands of the current bound app (read-only)").
|
||||
GET(slashCommandBasePath)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
data, err := runtime.CallAPITyped("GET", slashCommandBasePath, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
if items == nil {
|
||||
items = []interface{}{}
|
||||
}
|
||||
out := map[string]interface{}{"items": items, "count": len(items)}
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%d slash command(s)\n", len(items))
|
||||
for _, it := range items {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
desc := ""
|
||||
if d, ok := m["description"].(map[string]interface{}); ok {
|
||||
desc, _ = d["default_value"].(string)
|
||||
}
|
||||
fmt.Fprintf(w, " /%v\t%v\t%s\n", m["command"], m["command_id"], desc)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
115
shortcuts/application/slash_command_list_test.go
Normal file
115
shortcuts/application/slash_command_list_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"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 appTestConfig() *core.CliConfig {
|
||||
return &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
|
||||
}
|
||||
|
||||
// mountAndRun mounts the shortcut under a parent cobra command and runs it.
|
||||
// Mirrors shortcuts/contact tests.
|
||||
func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "application"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
func listStub(items []interface{}) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/application/v7/app_slash_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"items": items},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func sampleItem(name, id string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"command": name, "command_id": id,
|
||||
"create_time": "1783318553", "update_time": "1783318553",
|
||||
"description": map[string]interface{}{"default_value": "desc of " + name},
|
||||
"icon": map[string]interface{}{"icon_key": "skill_outlined"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandList_JSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(listStub([]interface{}{sampleItem("greet", "id1"), sampleItem("weather", "id2")}))
|
||||
|
||||
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--format", "json", "--as", "bot"}, f, stdout); 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{})
|
||||
items := data["items"].([]interface{})
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("items = %d", len(items))
|
||||
}
|
||||
if data["count"] != float64(2) {
|
||||
t.Fatalf("count = %v", data["count"])
|
||||
}
|
||||
first := items[0].(map[string]interface{})
|
||||
for _, k := range []string{"command", "command_id", "description", "icon", "create_time", "update_time"} {
|
||||
if _, ok := first[k]; !ok {
|
||||
t.Errorf("missing item key %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandList_Empty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
|
||||
reg.Register(listStub(nil))
|
||||
|
||||
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--format", "json", "--as", "bot"}, f, stdout); 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{})
|
||||
items, ok := data["items"].([]interface{})
|
||||
if !ok || len(items) != 0 {
|
||||
t.Fatalf("empty list must be [] not %v", data["items"])
|
||||
}
|
||||
if data["count"] != float64(0) {
|
||||
t.Fatalf("count = %v", data["count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashCommandList_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
|
||||
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--dry-run", "--as", "bot"}, f, stdout); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/open-apis/application/v7/app_slash_commands") || !strings.Contains(out, "GET") {
|
||||
t.Fatalf("dry-run must show GET path, got %s", out)
|
||||
}
|
||||
}
|
||||
53
shortcuts/application/slash_command_resolve.go
Normal file
53
shortcuts/application/slash_command_resolve.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// matchCommandItem finds the item whose "command" equals name (exact match -
|
||||
// the server enforces name uniqueness, so first hit is the only hit).
|
||||
func matchCommandItem(items []interface{}, name string) (string, map[string]interface{}) {
|
||||
for _, it := range items {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if m["command"] == name {
|
||||
id, _ := m["command_id"].(string)
|
||||
if id != "" {
|
||||
return id, m
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// commandNotFoundError reports a resolution miss against the live list as an
|
||||
// API-category not-found error (the name is a valid argument shape; the
|
||||
// resource simply does not exist server-side - this is not a validation
|
||||
// failure of caller input).
|
||||
func commandNotFoundError(name string) error {
|
||||
return errs.NewAPIError(errs.SubtypeNotFound,
|
||||
"slash command %q not found in the current bound app", name).
|
||||
WithHint("run `lark-cli application +slash-command-list` to see registered commands")
|
||||
}
|
||||
|
||||
// resolveCommandID resolves a command name to its command_id via the live
|
||||
// list endpoint (in-memory only; never touches local files). Requires the
|
||||
// read scope on the current identity.
|
||||
func resolveCommandID(runtime *common.RuntimeContext, name string) (string, map[string]interface{}, error) {
|
||||
data, err := runtime.CallAPITyped("GET", slashCommandBasePath, nil, nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
id, item := matchCommandItem(items, name)
|
||||
if id == "" {
|
||||
return "", nil, commandNotFoundError(name)
|
||||
}
|
||||
return id, item, nil
|
||||
}
|
||||
39
shortcuts/application/slash_command_resolve_test.go
Normal file
39
shortcuts/application/slash_command_resolve_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMatchCommandItem(t *testing.T) {
|
||||
items := []interface{}{
|
||||
sampleItem("greet", "id1"),
|
||||
sampleItem("weather", "id2"),
|
||||
}
|
||||
id, item := matchCommandItem(items, "weather")
|
||||
if id != "id2" || item == nil {
|
||||
t.Fatalf("got id=%q item=%v", id, item)
|
||||
}
|
||||
id, item = matchCommandItem(items, "nope")
|
||||
if id != "" || item != nil {
|
||||
t.Fatalf("miss should return empty, got id=%q", id)
|
||||
}
|
||||
// 精确匹配:大小写与空白不做宽容
|
||||
id, _ = matchCommandItem(items, "Greet")
|
||||
if id != "" {
|
||||
t.Fatalf("match must be exact, got %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNotFoundErrorShape(t *testing.T) {
|
||||
err := commandNotFoundError("nope")
|
||||
if err == nil || !strings.Contains(err.Error(), `"nope"`) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("err should say not found: %v", err)
|
||||
}
|
||||
}
|
||||
119
shortcuts/application/slash_command_update.go
Normal file
119
shortcuts/application/slash_command_update.go
Normal file
@@ -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",
|
||||
Scopes: []string{"application:app_slash_command:write"},
|
||||
ConditionalScopes: []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 <lang>=<text>; 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 = "<resolved_command_id>"
|
||||
}
|
||||
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
|
||||
},
|
||||
}
|
||||
79
shortcuts/application/slash_command_update_test.go
Normal file
79
shortcuts/application/slash_command_update_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts/application"
|
||||
"github.com/larksuite/cli/shortcuts/apps"
|
||||
"github.com/larksuite/cli/shortcuts/base"
|
||||
"github.com/larksuite/cli/shortcuts/calendar"
|
||||
@@ -61,6 +62,7 @@ var allShortcuts []common.Shortcut
|
||||
|
||||
func init() {
|
||||
allShortcuts = append(allShortcuts, apps.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, application.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, calendar.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, doc.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, drive.Shortcuts()...)
|
||||
|
||||
20
skill-template/domains/application.md
Normal file
20
skill-template/domains/application.md
Normal file
@@ -0,0 +1,20 @@
|
||||
## 开放平台应用自管理(application)域介绍
|
||||
|
||||
本域面向飞书开放平台的**应用自身**做自管理操作——即应用对着自己的配置做增删改查(而不是操作某个用户/群组的业务资源)。当前唯一承载的能力是**斜杠指令(Slash Command)**:应用注册在客户端指令面板里可见的 `/` 命令(也叫机器人命令),用户在会话输入框敲出后会触发对应的机器人交互。
|
||||
|
||||
- **App(应用)**:当前 lark-cli 绑定的开放平台应用本身,通过 appId/appSecret 识别,没有独立的 `app_id` 参数——所有操作都作用于"当前绑定应用"
|
||||
- **Slash Command(斜杠指令)**:应用注册的 `/` 命令对象,含 `command_id`(服务端生成、不可复用)、`command`(指令名,不带前导 `/`)、`description`(默认描述 + 多语言 i18n)、`icon`
|
||||
|
||||
### 与 apps(妙搭)域的区别
|
||||
|
||||
两者经常被用户口语混淆,务必区分:
|
||||
|
||||
| | application(本域) | apps(妙搭) |
|
||||
|---|---|---|
|
||||
| 对象 | 开放平台应用自身的配置(自管理) | 低代码/无代码平台上创建的妙搭应用 |
|
||||
| 典型能力 | 斜杠指令管理 | 应用创建、HTML 发布、可用范围管理 |
|
||||
| 触发词 | 斜杠指令、指令面板、机器人命令、应用自管理 | 妙搭、Miaoda、低代码应用 |
|
||||
|
||||
### 当前能力与未来规划
|
||||
|
||||
当前仅实现斜杠指令的列出/创建/更新/删除。**bot 菜单管理、应用版本发布、应用使用统计**等其他"应用自管理"类能力规划中、尚未实现——遇到相关用户诉求应如实告知暂不支持,不要臆造对应命令。
|
||||
38
skills/lark-application/SKILL.md
Normal file
38
skills/lark-application/SKILL.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: lark-application
|
||||
version: 1.0.0
|
||||
description: "飞书开放平台应用自管理:目前仅实现管理当前绑定应用的斜杠指令(Slash Command 是一种允许用户在飞书聊天框中通过输入 / 快速触发机器人服务的交互方式)——列出、创建、更新、删除。当用户提到Slash Command、发消息斜杠指令面板、斜杠指令时使用。注意区分:这不是妙搭应用(lark-apps);也不负责指令触发后的回调消费(lark-event)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli application --help"
|
||||
---
|
||||
|
||||
# application
|
||||
|
||||
## 身份权限和风险处理
|
||||
|
||||
开始前先读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)(认证、权限处理、`--dry-run`、exit 10 高风险确认协议)。
|
||||
`--as bot` 或 `--as user`均支持。
|
||||
两个权限:`application:app_slash_command:read`(列出)/ `application:app_slash_command:write`(创建/更新/删除)
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+slash-command-list`](references/lark-application-slash-command-list.md) | 列出当前绑定应用的全部斜杠指令;是 update/delete 定位 `command_id` 的数据来源 |
|
||||
| [`+slash-command-create`](references/lark-application-slash-command-create.md) | 注册新斜杠指令;`--force` 可把撞名冲突转成幂等的 update |
|
||||
| [`+slash-command-update`](references/lark-application-slash-command-update.md) | 更新已有指令的描述 / 多语言描述 / 图标,按 `--command-id` 或 `--command` 定位 |
|
||||
| [`+slash-command-delete`](references/lark-application-slash-command-delete.md) | 删除斜杠指令(高危写操作,走 exit 10 确认协议) |
|
||||
|
||||
## 关键语义
|
||||
|
||||
- **`--command` 不带前导 `/`**:传 `/greet` 会被拒绝,正确写法是 `--command greet`(斜杠是隐含的,客户端展示时自动加上)。
|
||||
- **客户端缓存 ~5 分钟,服务端立即生效**:写操作成功后 `+slash-command-list` 立刻能看到最新状态,但 Feishu 客户端指令面板可能仍显示旧值,这是客户端缓存导致,不代表写入失败,不要因为客户端没刷新就重试写操作。
|
||||
- **改名 = 删除 + 创建,`command_id` 会变**:API 不支持直接修改 `command` 字段本身(`+slash-command-update` 只能改描述/图标)。要重命名指令,必须先 `+slash-command-delete` 旧指令,再 `+slash-command-create` 新名字,产生的是全新 `command_id`,不是原地重命名。
|
||||
|
||||
## 不在本 skill 范围
|
||||
|
||||
- 妙搭应用的创建、发布、可见范围管理 → [lark-apps](../lark-apps/SKILL.md)
|
||||
- 指令触发后的消息以普通 IM 消息送达(`im.message.receive_v1` 事件),消费走 lark-event(`lark-cli event consume`) → [lark-event](../lark-event/SKILL.md)
|
||||
- bot 菜单、应用版本管理、应用统计(未实现)
|
||||
@@ -0,0 +1,67 @@
|
||||
# application +slash-command-create
|
||||
|
||||
在当前绑定应用上注册一个新的斜杠指令。
|
||||
|
||||
## 命令格式
|
||||
|
||||
```bash
|
||||
lark-cli application +slash-command-create --command <name> --description <text> [flags]
|
||||
```
|
||||
|
||||
## 参数表
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `--command` | string | 是 | 指令名,**不带前导 `/`**(服务端按应用维度校验唯一,最多 100 条指令) |
|
||||
| `--description` | string | 是 | 客户端指令面板默认展示的描述(写入 `description.default_value`) |
|
||||
| `--description-i18n` | string_array(可重复) | 否 | 多语言描述,格式 `<lang>=<text>`(如 `zh_cn=发送问候`),可重复多次传不同语言;语言码原样透传给服务端,不做校验 |
|
||||
| `--icon-key` | string | 否 | 图标 key;不传时服务端默认 `skill_outlined`;非法 key 会被服务端拒绝(code `40000031`) |
|
||||
| `--force` | bool | 否 | 撞名时不报错,改为解析出已存在指令的 `command_id` 并对其执行 PATCH(等价于幂等重跑,类似 `gh label create --force`) |
|
||||
|
||||
> **CAUTION**:`--force` 撞名转 PATCH 时,`description`(含 `i18n` map)按整体替换处理,不是增量合并。若重跑命令时不带上完整的 `--description-i18n`,指令已有的多语言翻译会被静默清空。要做真正幂等的重跑,必须把之前设置过的所有语言连同新值一起重新传入 `--description-i18n`。
|
||||
|
||||
## 返回值
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "bot",
|
||||
"data": {
|
||||
"command_id": "7123456789012345678",
|
||||
"command": "greet",
|
||||
"description": { "default_value": "say hi", "i18n": { "zh_cn": "问候" } },
|
||||
"icon": { "icon_key": "skill_outlined" },
|
||||
"action": "created"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`action` 为 `created` 或 `updated`(后者只在 `--force` 命中撞名分支时出现)。命令执行成功后 stderr 会打印一行客户端缓存提示,不是错误。
|
||||
|
||||
## 典型用法
|
||||
|
||||
```bash
|
||||
# 创建一个新指令,附带中文本地化描述
|
||||
lark-cli application +slash-command-create \
|
||||
--command greet --description "say hi" --description-i18n zh_cn=问候 --as bot
|
||||
|
||||
# 幂等重跑:如果 greet 已存在则直接更新它,而不是报错退出
|
||||
# 注意:若 greet 已有 --description-i18n(如 zh_cn=问候),这里不带上就会被清空
|
||||
lark-cli application +slash-command-create \
|
||||
--command greet --description "say hi v2" --description-i18n zh_cn=问候 --force --as bot
|
||||
```
|
||||
|
||||
## 错误与恢复
|
||||
|
||||
| 场景 | 识别 | 处理 |
|
||||
|---|---|---|
|
||||
| 指令名已存在 | 错误 `code=40000000`,message 含 `command already exists` | 未加 `--force` 时直接报错并提示改用 `+slash-command-update` 或加 `--force` 重跑;已加 `--force` 时 CLI 会自动列出全部指令解析 `command_id` 后转为 PATCH,无需人工介入 |
|
||||
| `--icon-key` 非法 | 错误 `code=40000031`,原样透传服务端 message | 换成合法 icon key,或省略该参数使用默认值 |
|
||||
| bot 缺少 `application:app_slash_command:write`(`--force` 撞名分支还需 `:read`) | 错误信封携带 `console_url` | 把 `console_url` 给用户,引导去开发者后台开通对应 scope 并**发布新版本**;不要对 bot 执行 `auth login` |
|
||||
| user 缺少对应 scope | 错误提示缺失 scope | `lark-cli auth login --scope application:app_slash_command:write`(`--force` 场景还需 `:read`) |
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-application](../SKILL.md) — skill 入口与路由,含"改名=删除+创建"等关键语义
|
||||
- [+slash-command-list](lark-application-slash-command-list.md)
|
||||
- [+slash-command-update](lark-application-slash-command-update.md)
|
||||
@@ -0,0 +1,65 @@
|
||||
# application +slash-command-delete
|
||||
|
||||
删除当前绑定应用上的一个斜杠指令。**`Risk: high-risk-write`,不可逆。**
|
||||
|
||||
## 命令格式
|
||||
|
||||
```bash
|
||||
lark-cli application +slash-command-delete (--command-id <id> | --command <name>) --yes
|
||||
```
|
||||
|
||||
## 参数表
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `--command-id` | string | 与 `--command` 二选一 | 目标指令的 `command_id` |
|
||||
| `--command` | string | 与 `--command-id` 二选一 | 按名字定位(不带前导 `/`),CLI 会先调用 list 解析出 `command_id`,需要 read scope |
|
||||
| `--yes` | bool | 高风险操作确认必需 | 见下方"高风险确认协议" |
|
||||
|
||||
## 高风险确认协议(CRITICAL)
|
||||
|
||||
本命令 `Risk: high-risk-write`。不带 `--yes` 调用会以 exit code `10` 失败,stderr 返回 `error.type == "confirmation_required"` 的结构化 envelope——完整协议见 [lark-shared 高风险操作的审批协议(exit 10)](../../lark-shared/SKILL.md),本节只补充本命令的领域特有部分。
|
||||
|
||||
**Agent 必须在追加 `--yes` 重试前,先把要删除的指令名 / `command_id` 明确展示给用户并取得同意**——不要因为 exit 10 是"标准协议"就默认自动重试。判断"是否已获得授权"时,用户必须在当前对话中明确点名了具体指令;仅泛泛说"清理一下指令"不构成授权。
|
||||
|
||||
## 返回值
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "bot",
|
||||
"data": { "action": "deleted", "command_id": "7123456789012345678", "command": "greet" }
|
||||
}
|
||||
```
|
||||
|
||||
`command` 字段只在通过 `--command` 定位时才会出现(`--command-id` 直接删除时无法反查名字,字段缺省)。
|
||||
|
||||
## 典型用法
|
||||
|
||||
```bash
|
||||
# 先不加 --yes 试探,看到 confirmation_required 后向用户确认
|
||||
lark-cli application +slash-command-delete --command greet
|
||||
|
||||
# 用户明确同意后,原样追加 --yes 重试
|
||||
lark-cli application +slash-command-delete --command greet --yes --as bot
|
||||
```
|
||||
|
||||
## 错误与恢复
|
||||
|
||||
| 场景 | 识别 | 处理 |
|
||||
|---|---|---|
|
||||
| 未加 `--yes` | exit code `10`,`error.type == "confirmation_required"` | 展示待删除指令给用户确认,同意后在原始命令末尾追加 `--yes` 重试;不要静默重试 |
|
||||
| 按 `--command` 找不到指令 | 报错"未找到",hint 指向 list | 先跑 `+slash-command-list` 确认名字 |
|
||||
| bot 缺少 `application:app_slash_command:write`(按名定位还需 `:read`) | 错误信封携带 `console_url` | 引导用户去开发者后台开通并发版;不要对 bot 执行 `auth login` |
|
||||
| user 缺少对应 scope | 提示缺失 scope | `lark-cli auth login --scope application:app_slash_command:write`(按名定位还需 `--scope application:app_slash_command:read`) |
|
||||
| 删除后客户端指令面板仍显示旧指令 | 正常现象,约 5 分钟客户端缓存 | 服务端已立即生效(`+slash-command-list` 会立刻反映删除),不要因客户端未刷新而重复删除 |
|
||||
|
||||
## 恢复注意
|
||||
|
||||
删除是不可逆操作,且**没有"撤销删除"**。如果需要恢复同名指令,只能重新 `+slash-command-create`,得到的是**全新的 `command_id`**(与原指令无关联)。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-application](../SKILL.md) — skill 入口与路由
|
||||
- [+slash-command-list](lark-application-slash-command-list.md)
|
||||
- [+slash-command-create](lark-application-slash-command-create.md)
|
||||
@@ -0,0 +1,62 @@
|
||||
# application +slash-command-list
|
||||
|
||||
列出当前绑定应用的全部斜杠指令。只读操作,也是 `+slash-command-update` / `+slash-command-delete` 按名字定位 `command_id` 的数据来源。
|
||||
|
||||
## 命令格式
|
||||
|
||||
```bash
|
||||
lark-cli application +slash-command-list --as bot
|
||||
```
|
||||
|
||||
无参数、无分页——上游 API 一次性返回全部指令(每个应用最多 100 条)。
|
||||
|
||||
## 参数表
|
||||
|
||||
本命令不接受除全局 `--as` / `--format` 之外的业务参数。
|
||||
|
||||
## 返回值
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "bot",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"command_id": "7123456789012345678",
|
||||
"command": "greet",
|
||||
"description": { "default_value": "say hi", "i18n": { "zh_cn": "问候" } },
|
||||
"icon": { "icon_key": "skill_outlined" }
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`count` 是 `items` 的长度,供快速判断是否为空列表;不要用它替代实际遍历 `items`。
|
||||
|
||||
## 典型用法
|
||||
|
||||
```bash
|
||||
# 查看当前应用注册了哪些指令
|
||||
lark-cli application +slash-command-list --as bot
|
||||
|
||||
# 按名字找出某条指令的 command_id,供 update/delete 使用
|
||||
lark-cli application +slash-command-list --as bot --format json | jq '.data.items[] | select(.command=="greet")'
|
||||
```
|
||||
|
||||
## 错误与恢复
|
||||
|
||||
| 场景 | 处理 |
|
||||
|---|---|
|
||||
| bot 缺少 `application:app_slash_command:read` scope | 错误信封携带 `console_url`;把它原样给用户,引导去开发者后台开通并**发布新版本**——scope 变更需要应用发版才能对线上 bot 生效,不要对 bot 执行 `auth login` |
|
||||
| user 缺少该 scope | 提示执行 `lark-cli auth login --scope application:app_slash_command:read` |
|
||||
| 返回空列表 | 正常状态(应用还没注册任何指令),不是错误 |
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-application](../SKILL.md) — skill 入口与路由
|
||||
- [+slash-command-create](lark-application-slash-command-create.md)
|
||||
- [+slash-command-update](lark-application-slash-command-update.md)
|
||||
- [+slash-command-delete](lark-application-slash-command-delete.md)
|
||||
@@ -0,0 +1,74 @@
|
||||
# application +slash-command-update
|
||||
|
||||
更新一个已有斜杠指令的描述 / 多语言描述 / 图标。
|
||||
|
||||
## 命令格式
|
||||
|
||||
```bash
|
||||
lark-cli application +slash-command-update (--command-id <id> | --command <name>) [flags]
|
||||
```
|
||||
|
||||
## 参数表
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `--command-id` | string | 与 `--command` 二选一 | 目标指令的 `command_id`(来自 `+slash-command-list` 或 create 的输出) |
|
||||
| `--command` | string | 与 `--command-id` 二选一 | 按名字定位(不带前导 `/`),CLI 会先调用 list 解析出 `command_id`,需要 read scope |
|
||||
| `--description` | string | 否 | 新的默认描述(`description.default_value`) |
|
||||
| `--description-i18n` | string_array(可重复) | 否 | 多语言描述,格式 `<lang>=<text>`;**整体替换**已有 i18n map(未传入的语言会被服务端丢弃,不是增量合并);**必须伴随 `--description` 一起传**,否则报错 |
|
||||
| `--icon-key` | string | 否 | 新图标 key;非法 key 服务端拒绝(code `40000031`) |
|
||||
|
||||
`--description` / `--description-i18n` / `--icon-key` 三者至少提供一个,否则报错"没有可更新字段"。
|
||||
|
||||
## 关键语义
|
||||
|
||||
- **`--command-id` 与 `--command` 互斥,且必须提供其中之一**——两个都不传或都传都会报错。
|
||||
- **PATCH 是字段级局部更新**:未显式传入的字段(如只传了 `--icon-key`)在服务端保持原值不变。
|
||||
- **`--description-i18n` 是整体替换,不是增量合并**:如果指令原本有 `zh_cn` + `ja_jp` 两个语言的描述,本次只传 `--description-i18n en_us=hi`,结果是只剩 `en_us` 一个语言,`zh_cn`/`ja_jp` 会丢失。要保留原有语言,需要把它们连同新值一起重新传入。
|
||||
- **指令名本身不可改**(API 限制):`+slash-command-update` 不支持修改 `command` 字段。要"重命名",必须走 [+slash-command-delete](lark-application-slash-command-delete.md) + [+slash-command-create](lark-application-slash-command-create.md)(详见 [SKILL.md 关键语义](../SKILL.md#关键语义)),会产生一个全新的 `command_id`。
|
||||
|
||||
## 返回值
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "bot",
|
||||
"data": {
|
||||
"command_id": "7123456789012345678",
|
||||
"command": "greet",
|
||||
"description": { "default_value": "new text", "i18n": { "en_us": "hi" } },
|
||||
"icon": { "icon_key": "skill_outlined" },
|
||||
"action": "updated"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 典型用法
|
||||
|
||||
```bash
|
||||
# 按名字更新描述(CLI 内部先 list 解析 command_id 再 PATCH)
|
||||
lark-cli application +slash-command-update --command greet --description "new text" --as bot
|
||||
|
||||
# 只改图标,不动描述
|
||||
lark-cli application +slash-command-update --command-id 7123456789012345678 --icon-key chat_outlined --as bot
|
||||
|
||||
# 同时替换默认描述和多语言描述(记得带上所有要保留的语言)
|
||||
lark-cli application +slash-command-update --command greet \
|
||||
--description "say hi" --description-i18n zh_cn=你好 --description-i18n en_us=hi --as bot
|
||||
```
|
||||
|
||||
## 错误与恢复
|
||||
|
||||
| 场景 | 识别 | 处理 |
|
||||
|---|---|---|
|
||||
| `--description-i18n` 未伴随 `--description` | 校验期报错,提示两者必须同传 | 补上 `--description`(哪怕值和原来一样),因为 PATCH 会整体替换 description 对象,缺 `default_value` 会导致其被清空 |
|
||||
| 按 `--command` 找不到指令 | 报错"未找到",hint 指向 list | 先跑 `+slash-command-list` 确认指令名拼写和是否存在 |
|
||||
| `--icon-key` 非法 | 错误 `code=40000031` | 换合法 icon key |
|
||||
| bot 缺少 `application:app_slash_command:write`(按名定位还需 `:read`) | 错误信封携带 `console_url` | 引导用户去开发者后台开通并发版;不要对 bot 执行 `auth login` |
|
||||
| user 缺少对应 scope | 提示缺失 scope | `lark-cli auth login --scope application:app_slash_command:write`(按名定位还需 `--scope application:app_slash_command:read`) |
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-application](../SKILL.md) — skill 入口与路由
|
||||
- [+slash-command-list](lark-application-slash-command-list.md)
|
||||
- [+slash-command-delete](lark-application-slash-command-delete.md)
|
||||
166
tests/cli_e2e/application/slash_command_dryrun_test.go
Normal file
166
tests/cli_e2e/application/slash_command_dryrun_test.go
Normal file
@@ -0,0 +1,166 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// setSlashCommandDryRunEnv isolates config and supplies stub credentials so
|
||||
// dry-run / the pre-Execute confirmation gate short-circuit before identity
|
||||
// resolution touches a real keychain. Mirrors tests/cli_e2e/apps/helpers_test.go
|
||||
// and tests/cli_e2e/calendar/calendar_update_dryrun_test.go.
|
||||
func setSlashCommandDryRunEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "application_dryrun_test")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "application_dryrun_secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
}
|
||||
|
||||
const slashCommandBasePath = "/open-apis/application/v7/app_slash_commands"
|
||||
|
||||
// TestSlashCommandList_DryRunShowsGetPath pins the read-only GET shape for
|
||||
// `application +slash-command-list --dry-run`.
|
||||
func TestSlashCommandList_DryRunShowsGetPath(t *testing.T) {
|
||||
setSlashCommandDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"application", "+slash-command-list",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
assert.Equal(t, "GET", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
assert.Equal(t, slashCommandBasePath, gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
}
|
||||
|
||||
// TestSlashCommandCreate_DryRunShowsPostBody pins the POST body shape for
|
||||
// `application +slash-command-create --dry-run`: icon sits at the TOP LEVEL,
|
||||
// a sibling of description (not nested inside description) - the official
|
||||
// create sample nesting icon inside description is a documented doc bug -
|
||||
// and description.i18n carries the localized map.
|
||||
func TestSlashCommandCreate_DryRunShowsPostBody(t *testing.T) {
|
||||
setSlashCommandDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"application", "+slash-command-create",
|
||||
"--command", "greet",
|
||||
"--description", "say hi",
|
||||
"--description-i18n", "zh_cn=你好",
|
||||
"--icon-key", "skill_outlined",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
assert.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
assert.Equal(t, slashCommandBasePath, gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
assert.Equal(t, "greet", gjson.Get(out, "api.0.body.command").String(), "stdout:\n%s", out)
|
||||
assert.Equal(t, "say hi", gjson.Get(out, "api.0.body.description.default_value").String(), "stdout:\n%s", out)
|
||||
assert.Equal(t, "你好", gjson.Get(out, "api.0.body.description.i18n.zh_cn").String(), "stdout:\n%s", out)
|
||||
// icon is a top-level key, sibling of description.
|
||||
assert.Equal(t, "skill_outlined", gjson.Get(out, "api.0.body.icon.icon_key").String(), "stdout:\n%s", out)
|
||||
assert.False(t, gjson.Get(out, "api.0.body.description.icon").Exists(), "icon must not be nested inside description:\n%s", out)
|
||||
}
|
||||
|
||||
// TestSlashCommandUpdate_DryRunShowsPatchPath pins the PATCH shape for
|
||||
// `application +slash-command-update --command-id --dry-run`.
|
||||
func TestSlashCommandUpdate_DryRunShowsPatchPath(t *testing.T) {
|
||||
setSlashCommandDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"application", "+slash-command-update",
|
||||
"--command-id", "id_dry",
|
||||
"--description", "updated description",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
assert.Equal(t, "PATCH", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
assert.Equal(t, slashCommandBasePath+"/id_dry", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
assert.Equal(t, "updated description", gjson.Get(out, "api.0.body.description.default_value").String(), "stdout:\n%s", out)
|
||||
}
|
||||
|
||||
// TestSlashCommandDelete_DryRunShowsDeletePath pins the DELETE shape for
|
||||
// `application +slash-command-delete --command-id --yes --dry-run`. Dry-run
|
||||
// short-circuits before the high-risk-write confirmation gate (see
|
||||
// shortcuts/common/runner.go), but --yes is passed anyway to match the
|
||||
// eventual real invocation the agent would run.
|
||||
func TestSlashCommandDelete_DryRunShowsDeletePath(t *testing.T) {
|
||||
setSlashCommandDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"application", "+slash-command-delete",
|
||||
"--command-id", "id_dry",
|
||||
"--dry-run",
|
||||
},
|
||||
Yes: true,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
assert.Equal(t, "DELETE", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
assert.Equal(t, slashCommandBasePath+"/id_dry", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
}
|
||||
|
||||
// TestSlashCommandDelete_WithoutYesRequiresConfirmation asserts the
|
||||
// high-risk-write gate fires BEFORE any HTTP call: no --dry-run, no --yes ->
|
||||
// exit 10 (ExitConfirmationRequired) with a confirmation_required envelope on
|
||||
// stderr (see internal/output/exitcode.go and cmd/root.go handleRootError).
|
||||
func TestSlashCommandDelete_WithoutYesRequiresConfirmation(t *testing.T) {
|
||||
setSlashCommandDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"application", "+slash-command-delete",
|
||||
"--command-id", "id_dry",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 10)
|
||||
|
||||
assert.Equal(t, "confirmation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr)
|
||||
assert.Equal(t, "confirmation_required", gjson.Get(result.Stderr, "error.subtype").String(), "stderr:\n%s", result.Stderr)
|
||||
}
|
||||
Reference in New Issue
Block a user