diff --git a/internal/registry/service_descriptions.json b/internal/registry/service_descriptions.json index b6fef7caf..3c9c29c21 100644 --- a/internal/registry/service_descriptions.json +++ b/internal/registry/service_descriptions.json @@ -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 页面和应用" } diff --git a/shortcuts/application/slash_command_common.go b/shortcuts/application/slash_command_common.go new file mode 100644 index 000000000..3f4661e2e --- /dev/null +++ b/shortcuts/application/slash_command_common.go @@ -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 ("=", +// 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 = (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") +} diff --git a/shortcuts/application/slash_command_common_test.go b/shortcuts/application/slash_command_common_test.go new file mode 100644 index 000000000..383ae1976 --- /dev/null +++ b/shortcuts/application/slash_command_common_test.go @@ -0,0 +1,90 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package application + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/errs" +) + +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") + } +}