From 6455fd5c341de7b25e327681a505292f9827e030 Mon Sep 17 00:00:00 2001 From: "bin.shen" Date: Tue, 12 May 2026 23:26:13 +0800 Subject: [PATCH] feat(lingo): add Feishu dictionary shortcuts and skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a new `lingo` service covering the six Feishu Lingo / 百科 dictionary entry endpoints as shortcuts, plus the accompanying lark-lingo skill with per-verb reference docs. Shortcuts (shortcuts/lingo/): +search POST /lingo/v1/entities/search read +match POST /lingo/v1/entities/match read +get GET /lingo/v1/entities/:id read +create POST /lingo/v1/entities write (bot-only) +update PUT /lingo/v1/entities/:id write (bot-only, full overwrite) +delete DELETE /lingo/v1/entities/:id high-risk-write (bot-only) Each shortcut has validate / dry-run / execute hooks and unit tests covering flag validation, dry-run payload, successful execute, and API-error paths. Dry-run e2e tests added under tests/cli_e2e/lingo/. The skill (skills/lark-lingo/) follows the lark-markdown pattern: concise SKILL.md index + one references/lark-lingo-.md per shortcut. References capture the real-world gotchas we hit during integration: * write endpoints reject user_access_token (99991668); bot-only * bot scope is configured in the developer console, not via `auth login --scope` (which only affects user scope) * +update is PUT full-body overwrite — read current via +get and merge before writing * +create entries enter the review queue unless the app has baike:entity:exempt_review (self-built apps only) Also: * internal/registry/service_descriptions.json: add lingo entry * shortcuts/register.go: register lingo.Shortcuts() * README.md / README.zh.md: new Lingo row in both feature and skill tables Co-Authored-By: Claude Opus 4.7 --- README.md | 2 + README.zh.md | 2 + internal/registry/service_descriptions.json | 4 + shortcuts/lingo/helpers_test.go | 58 +++++++ shortcuts/lingo/lingo_entity_create.go | 139 +++++++++++++++ shortcuts/lingo/lingo_entity_create_test.go | 142 ++++++++++++++++ shortcuts/lingo/lingo_entity_delete.go | 56 ++++++ shortcuts/lingo/lingo_entity_delete_test.go | 85 ++++++++++ shortcuts/lingo/lingo_entity_get.go | 116 +++++++++++++ shortcuts/lingo/lingo_entity_get_test.go | 108 ++++++++++++ shortcuts/lingo/lingo_entity_match.go | 68 ++++++++ shortcuts/lingo/lingo_entity_match_test.go | 114 +++++++++++++ shortcuts/lingo/lingo_entity_search.go | 122 ++++++++++++++ shortcuts/lingo/lingo_entity_search_test.go | 159 ++++++++++++++++++ shortcuts/lingo/lingo_entity_update.go | 118 +++++++++++++ shortcuts/lingo/lingo_entity_update_test.go | 100 +++++++++++ shortcuts/lingo/shortcuts.go | 20 +++ shortcuts/register.go | 2 + skills/lark-lingo/SKILL.md | 87 ++++++++++ .../references/lark-lingo-create.md | 107 ++++++++++++ .../references/lark-lingo-delete.md | 70 ++++++++ .../lark-lingo/references/lark-lingo-get.md | 64 +++++++ .../lark-lingo/references/lark-lingo-match.md | 81 +++++++++ .../references/lark-lingo-search.md | 70 ++++++++ .../references/lark-lingo-update.md | 88 ++++++++++ tests/cli_e2e/lingo/lingo_dryrun_test.go | 154 +++++++++++++++++ 26 files changed, 2136 insertions(+) create mode 100644 shortcuts/lingo/helpers_test.go create mode 100644 shortcuts/lingo/lingo_entity_create.go create mode 100644 shortcuts/lingo/lingo_entity_create_test.go create mode 100644 shortcuts/lingo/lingo_entity_delete.go create mode 100644 shortcuts/lingo/lingo_entity_delete_test.go create mode 100644 shortcuts/lingo/lingo_entity_get.go create mode 100644 shortcuts/lingo/lingo_entity_get_test.go create mode 100644 shortcuts/lingo/lingo_entity_match.go create mode 100644 shortcuts/lingo/lingo_entity_match_test.go create mode 100644 shortcuts/lingo/lingo_entity_search.go create mode 100644 shortcuts/lingo/lingo_entity_search_test.go create mode 100644 shortcuts/lingo/lingo_entity_update.go create mode 100644 shortcuts/lingo/lingo_entity_update_test.go create mode 100644 shortcuts/lingo/shortcuts.go create mode 100644 skills/lark-lingo/SKILL.md create mode 100644 skills/lark-lingo/references/lark-lingo-create.md create mode 100644 skills/lark-lingo/references/lark-lingo-delete.md create mode 100644 skills/lark-lingo/references/lark-lingo-get.md create mode 100644 skills/lark-lingo/references/lark-lingo-match.md create mode 100644 skills/lark-lingo/references/lark-lingo-search.md create mode 100644 skills/lark-lingo/references/lark-lingo-update.md create mode 100644 tests/cli_e2e/lingo/lingo_dryrun_test.go diff --git a/README.md b/README.md index 449d255038..fd5fc2fd6d 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t | 🕐 Attendance | Query personal attendance check-in records | | ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances | | 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. | +| 📚 Lingo | Search, match, get, create, update, delete enterprise dictionary entries (Feishu Baike) | | 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) | ## Installation & Quick Start @@ -159,6 +160,7 @@ lark-cli auth status | `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report | | `lark-workflow-standup-report` | Workflow: agenda & todo summary | | `lark-okr` | Query, create, update OKRs; manage objective & key results, alignments and indicators. | +| `lark-lingo` | Enterprise dictionary entry search, match, get, create, update, delete | ## Authentication diff --git a/README.zh.md b/README.zh.md index 2af94f918c..495139b3cc 100644 --- a/README.zh.md +++ b/README.zh.md @@ -40,6 +40,7 @@ | 🕐 考勤打卡 | 查询个人考勤打卡记录 | | ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 | | 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 | +| 📚 词典 | 搜索、精准匹配、获取详情、创建、修改、删除企业词典词条(飞书百科) | | 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) | ## 安装与快速开始 @@ -160,6 +161,7 @@ lark-cli auth status | `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 | | `lark-workflow-standup-report` | 工作流:日程待办摘要 | | `lark-okr` | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 | +| `lark-lingo` | 企业词典词条搜索、精准匹配、获取详情、创建、修改、删除 | ## 认证 diff --git a/internal/registry/service_descriptions.json b/internal/registry/service_descriptions.json index 35d7d43ff6..5b52252c51 100644 --- a/internal/registry/service_descriptions.json +++ b/internal/registry/service_descriptions.json @@ -71,5 +71,9 @@ "okr": { "en": { "title": "OKR", "description": "Lark OKR objectives, key results, alignments, indicators, progresses" }, "zh": { "title": "OKR", "description": "飞书 OKR 目标、关键结果、对齐、量化指标、进展记录" } + }, + "lingo": { + "en": { "title": "Lingo", "description": "Dictionary entry search, match, get, create, update, delete" }, + "zh": { "title": "词典", "description": "词条搜索、精准匹配、获取详情、创建、修改、删除" } } } diff --git a/shortcuts/lingo/helpers_test.go b/shortcuts/lingo/helpers_test.go new file mode 100644 index 0000000000..e1a0439cee --- /dev/null +++ b/shortcuts/lingo/helpers_test.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package lingo + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/shortcuts/common" +) + +// lingoTestConfig builds an isolated CliConfig per test so cached state +// (tokens, profile) cannot leak across parallel tests. +func lingoTestConfig(t *testing.T) *core.CliConfig { + t.Helper() + suffix := strings.NewReplacer("/", "-", " ", "-").Replace(strings.ToLower(t.Name())) + return &core.CliConfig{ + AppID: "test-lingo-" + suffix, + AppSecret: "secret-lingo-" + suffix, + Brand: core.BrandFeishu, + } +} + +// runLingoShortcut mounts the given shortcut under a fresh "lingo" parent +// and executes it with the given args. +func runLingoShortcut(t *testing.T, s common.Shortcut, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error { + t.Helper() + parent := &cobra.Command{Use: "lingo"} + s.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +// decodeEnvelope parses the success envelope and returns the data field. +func decodeEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} { + t.Helper() + var envelope map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("failed to decode output: %v\nraw=%s", err, stdout.String()) + } + data, _ := envelope["data"].(map[string]interface{}) + if data == nil { + t.Fatalf("missing data in output envelope: %#v", envelope) + } + return data +} diff --git a/shortcuts/lingo/lingo_entity_create.go b/shortcuts/lingo/lingo_entity_create.go new file mode 100644 index 0000000000..53c2d2c103 --- /dev/null +++ b/shortcuts/lingo/lingo_entity_create.go @@ -0,0 +1,139 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package lingo + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +// LingoEntityCreate creates a new dictionary entry. +// Entries created via this API default to the review queue unless the +// app has been granted baike:entity:exempt_review. +var LingoEntityCreate = common.Shortcut{ + Service: "lingo", + Command: "+create", + Description: "Create a dictionary entry (enters review queue by default)", + Risk: "write", + Scopes: []string{"baike:entity"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "main-key", Desc: "main key (required, e.g. \"飞书\")", Required: true}, + {Name: "aliases", Desc: "comma-separated alias list (optional)"}, + {Name: "description", Desc: "entry description text", Input: []string{common.File, common.Stdin}}, + {Name: "repo-id", Desc: "dictionary repo ID; empty = shared company dictionary"}, + {Name: "allow-highlight", Type: "bool", Default: "true", Desc: "whether the entry is highlighted in documents"}, + {Name: "allow-search", Type: "bool", Default: "true", Desc: "whether the entry participates in search"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + mainKey := strings.TrimSpace(runtime.Str("main-key")) + if mainKey == "" { + return common.FlagErrorf("--main-key cannot be empty") + } + if err := validate.RejectControlChars(mainKey, "main-key"); err != nil { + return err + } + if v := runtime.Str("aliases"); v != "" { + if err := validate.RejectControlChars(v, "aliases"); err != nil { + return err + } + } + if v := runtime.Str("description"); v != "" { + if err := validate.RejectControlChars(v, "description"); err != nil { + return err + } + } + if v := runtime.Str("repo-id"); v != "" { + if err := validate.RejectControlChars(v, "repo-id"); err != nil { + return err + } + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := buildCreateBody(runtime) + return common.NewDryRunAPI(). + POST("/open-apis/lingo/v1/entities"). + Body(body). + Desc("Create dictionary entry") + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + body := buildCreateBody(runtime) + data, err := runtime.DoAPIJSON("POST", "/open-apis/lingo/v1/entities", larkcore.QueryParams{}, body) + if err != nil { + return err + } + + runtime.OutFormat(data, nil, func(w io.Writer) { + entity, _ := data["entity"].(map[string]interface{}) + if entity == nil { + fmt.Fprintln(w, "Created (no entity echoed)") + return + } + id, _ := entity["id"].(string) + fmt.Fprintf(w, "Created entity [%s] %s\n", id, mainKeyText(entity)) + fmt.Fprintln(w, " (entries enter review queue unless app has baike:entity:exempt_review)") + }) + return nil + }, +} + +// buildCreateBody assembles the create request body from flags. +func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} { + display := map[string]interface{}{ + "allow_highlight": runtime.Bool("allow-highlight"), + "allow_search": runtime.Bool("allow-search"), + } + + mainKey := map[string]interface{}{ + "key": runtime.Str("main-key"), + "display_status": display, + } + + body := map[string]interface{}{ + "main_keys": []map[string]interface{}{mainKey}, + } + + if aliasStr := runtime.Str("aliases"); aliasStr != "" { + aliases := splitAliases(aliasStr, display) + if len(aliases) > 0 { + body["aliases"] = aliases + } + } + + if desc := runtime.Str("description"); desc != "" { + body["description"] = desc + } + + if repo := runtime.Str("repo-id"); repo != "" { + body["repo_id"] = repo + } + + return body +} + +// splitAliases splits a comma-separated alias list and pairs each with the display_status block. +// Empty values (after trim) are skipped. +func splitAliases(s string, display map[string]interface{}) []map[string]interface{} { + parts := strings.Split(s, ",") + out := make([]map[string]interface{}, 0, len(parts)) + for _, p := range parts { + k := strings.TrimSpace(p) + if k == "" { + continue + } + out = append(out, map[string]interface{}{ + "key": k, + "display_status": display, + }) + } + return out +} diff --git a/shortcuts/lingo/lingo_entity_create_test.go b/shortcuts/lingo/lingo_entity_create_test.go new file mode 100644 index 0000000000..8465515060 --- /dev/null +++ b/shortcuts/lingo/lingo_entity_create_test.go @@ -0,0 +1,142 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package lingo + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestEntityCreateValidate_MissingMainKey(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, lingoTestConfig(t)) + err := runLingoShortcut(t, LingoEntityCreate, f, stdout, []string{"+create"}) + if err == nil { + t.Fatal("expected error for missing --main-key") + } + if !strings.Contains(err.Error(), "main-key") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestEntityCreateValidate_BlankMainKey(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, lingoTestConfig(t)) + err := runLingoShortcut(t, LingoEntityCreate, f, stdout, []string{ + "+create", + "--main-key", " ", + }) + if err == nil { + t.Fatal("expected error for whitespace-only --main-key") + } + if !strings.Contains(err.Error(), "main-key") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestEntityCreateDryRun_MinimalBody(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, lingoTestConfig(t)) + err := runLingoShortcut(t, LingoEntityCreate, f, stdout, []string{ + "+create", + "--main-key", "KYC", + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "/open-apis/lingo/v1/entities") { + t.Fatalf("dry-run output missing API path, got: %s", out) + } + if !strings.Contains(out, "KYC") { + t.Fatalf("dry-run output missing main-key, got: %s", out) + } + if !strings.Contains(out, "main_keys") { + t.Fatalf("dry-run body should contain main_keys, got: %s", out) + } + // No aliases / description provided → those keys must be absent. + if strings.Contains(out, "\"aliases\"") { + t.Fatalf("dry-run body should NOT contain aliases when flag absent, got: %s", out) + } + if strings.Contains(out, "\"description\"") { + t.Fatalf("dry-run body should NOT contain description when flag absent, got: %s", out) + } +} + +func TestEntityCreateDryRun_AliasesAndDescription(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, lingoTestConfig(t)) + err := runLingoShortcut(t, LingoEntityCreate, f, stdout, []string{ + "+create", + "--main-key", "飞书", + "--aliases", "Lark, FeiShu , 飞书办公", + "--description", "企业协作平台", + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + for _, want := range []string{"Lark", "FeiShu", "飞书办公", "企业协作平台"} { + if !strings.Contains(out, want) { + t.Fatalf("dry-run missing %q, got: %s", want, out) + } + } +} + +func TestEntityCreateExecute_OK(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, lingoTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/lingo/v1/entities", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "entity": map[string]interface{}{ + "id": "ent-new", + "main_keys": []interface{}{map[string]interface{}{"key": "KYC"}}, + }, + }, + }, + }) + err := runLingoShortcut(t, LingoEntityCreate, f, stdout, []string{ + "+create", + "--main-key", "KYC", + "--description", "Know Your Customer", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeEnvelope(t, stdout) + entity, _ := data["entity"].(map[string]interface{}) + if entity == nil || entity["id"] != "ent-new" { + t.Fatalf("missing or wrong entity in data: %#v", data) + } +} + +func TestEntityCreateExecute_PermissionError(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, lingoTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/lingo/v1/entities", + Body: map[string]interface{}{ + "code": 99991672, + "msg": "Permission denied", + }, + }) + err := runLingoShortcut(t, LingoEntityCreate, f, stdout, []string{ + "+create", + "--main-key", "KYC", + }) + if err == nil { + t.Fatal("expected error for API permission failure") + } +} diff --git a/shortcuts/lingo/lingo_entity_delete.go b/shortcuts/lingo/lingo_entity_delete.go new file mode 100644 index 0000000000..9383a61fca --- /dev/null +++ b/shortcuts/lingo/lingo_entity_delete.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package lingo + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +// LingoEntityDelete deletes a dictionary entry by entity_id. Irreversible. +// The framework auto-injects --yes for high-risk-write and refuses to run +// without it. +var LingoEntityDelete = common.Shortcut{ + Service: "lingo", + Command: "+delete", + Description: "Delete a dictionary entry by entity_id (irreversible; requires --yes)", + Risk: "high-risk-write", + Scopes: []string{"baike:entity"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "entity-id", Desc: "dictionary entity ID (required)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validate.RejectControlChars(runtime.Str("entity-id"), "entity-id") + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + DELETE("/open-apis/lingo/v1/entities/:entity_id"). + Set("entity_id", runtime.Str("entity-id")). + Desc("Delete dictionary entry (irreversible)") + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + entityID := runtime.Str("entity-id") + path := fmt.Sprintf("/open-apis/lingo/v1/entities/%s", entityID) + _, err := runtime.DoAPIJSON("DELETE", path, larkcore.QueryParams{}, nil) + if err != nil { + return err + } + + result := map[string]interface{}{ + "deleted": true, + "entity_id": entityID, + } + runtime.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "Deleted entity %s\n", entityID) + }) + return nil + }, +} diff --git a/shortcuts/lingo/lingo_entity_delete_test.go b/shortcuts/lingo/lingo_entity_delete_test.go new file mode 100644 index 0000000000..92ef175c91 --- /dev/null +++ b/shortcuts/lingo/lingo_entity_delete_test.go @@ -0,0 +1,85 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package lingo + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestEntityDeleteValidate_MissingEntityID(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, lingoTestConfig(t)) + err := runLingoShortcut(t, LingoEntityDelete, f, stdout, []string{"+delete", "--yes"}) + if err == nil { + t.Fatal("expected error for missing --entity-id") + } + if !strings.Contains(err.Error(), "entity-id") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestEntityDeleteWithoutYes_Refused(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, lingoTestConfig(t)) + err := runLingoShortcut(t, LingoEntityDelete, f, stdout, []string{ + "+delete", + "--entity-id", "ent-1", + }) + if err == nil { + t.Fatal("expected high-risk-write to refuse without --yes") + } +} + +func TestEntityDeleteDryRun(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, lingoTestConfig(t)) + err := runLingoShortcut(t, LingoEntityDelete, f, stdout, []string{ + "+delete", + "--entity-id", "ent-1", + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "DELETE") { + t.Fatalf("dry-run should be DELETE, got: %s", out) + } + if !strings.Contains(out, "/open-apis/lingo/v1/entities/ent-1") { + t.Fatalf("dry-run output missing resolved path, got: %s", out) + } +} + +func TestEntityDeleteExecute_OK(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, lingoTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/lingo/v1/entities/ent-1", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{}, + }, + }) + err := runLingoShortcut(t, LingoEntityDelete, f, stdout, []string{ + "+delete", + "--entity-id", "ent-1", + "--yes", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeEnvelope(t, stdout) + if deleted, _ := data["deleted"].(bool); !deleted { + t.Fatalf("expected deleted=true, got data=%#v", data) + } + if data["entity_id"] != "ent-1" { + t.Fatalf("entity_id = %v, want ent-1", data["entity_id"]) + } +} diff --git a/shortcuts/lingo/lingo_entity_get.go b/shortcuts/lingo/lingo_entity_get.go new file mode 100644 index 0000000000..6082efc5f0 --- /dev/null +++ b/shortcuts/lingo/lingo_entity_get.go @@ -0,0 +1,116 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package lingo + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +// LingoEntityGet retrieves a single dictionary entry by entity_id. +var LingoEntityGet = common.Shortcut{ + Service: "lingo", + Command: "+get", + Description: "Get a dictionary entry by entity_id", + Risk: "read", + Scopes: []string{"baike:entity:readonly"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "entity-id", Desc: "dictionary entity ID (required)", Required: true}, + {Name: "provider", Desc: "external provider name (used together with --outer-id)"}, + {Name: "outer-id", Desc: "external provider ID (used together with --provider)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if err := validate.RejectControlChars(runtime.Str("entity-id"), "entity-id"); err != nil { + return err + } + if v := runtime.Str("provider"); v != "" { + if err := validate.RejectControlChars(v, "provider"); err != nil { + return err + } + } + if v := runtime.Str("outer-id"); v != "" { + if err := validate.RejectControlChars(v, "outer-id"); err != nil { + return err + } + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + params := buildGetParams(runtime) + return common.NewDryRunAPI(). + GET("/open-apis/lingo/v1/entities/:entity_id"). + Set("entity_id", runtime.Str("entity-id")). + Params(params). + Desc("Get dictionary entry detail") + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + queryParams := make(larkcore.QueryParams) + for k, v := range buildGetParams(runtime) { + queryParams.Set(k, fmt.Sprintf("%v", v)) + } + path := fmt.Sprintf("/open-apis/lingo/v1/entities/%s", runtime.Str("entity-id")) + data, err := runtime.DoAPIJSON("GET", path, queryParams, nil) + if err != nil { + return err + } + + runtime.OutFormat(data, nil, func(w io.Writer) { + entity, _ := data["entity"].(map[string]interface{}) + if entity == nil { + fmt.Fprintln(w, "(no entity returned)") + return + } + id, _ := entity["id"].(string) + mainKey := mainKeyText(entity) + desc, _ := entity["description"].(string) + fmt.Fprintf(w, "Entity [%s] %s\n", id, mainKey) + if desc != "" { + fmt.Fprintf(w, " Description: %s\n", desc) + } + if aliases, ok := entity["aliases"].([]interface{}); ok && len(aliases) > 0 { + fmt.Fprintf(w, " Aliases: %s\n", joinKeys(aliases)) + } + }) + return nil + }, +} + +// buildGetParams collects optional get-by-outer-info query params. +func buildGetParams(runtime *common.RuntimeContext) map[string]interface{} { + params := map[string]interface{}{} + if v := runtime.Str("provider"); v != "" { + params["provider"] = v + } + if v := runtime.Str("outer-id"); v != "" { + params["outer_id"] = v + } + return params +} + +// joinKeys formats an aliases array (each element has a "key" field) as comma-separated text. +func joinKeys(items []interface{}) string { + out := "" + for i, it := range items { + m, ok := it.(map[string]interface{}) + if !ok { + continue + } + k, _ := m["key"].(string) + if k == "" { + continue + } + if i > 0 && out != "" { + out += ", " + } + out += k + } + return out +} diff --git a/shortcuts/lingo/lingo_entity_get_test.go b/shortcuts/lingo/lingo_entity_get_test.go new file mode 100644 index 0000000000..c3121efd06 --- /dev/null +++ b/shortcuts/lingo/lingo_entity_get_test.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package lingo + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestEntityGetValidate_MissingEntityID(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, lingoTestConfig(t)) + err := runLingoShortcut(t, LingoEntityGet, f, stdout, []string{"+get"}) + if err == nil { + t.Fatal("expected error for missing --entity-id") + } + if !strings.Contains(err.Error(), "entity-id") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestEntityGetValidate_ControlCharsInEntityID(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, lingoTestConfig(t)) + err := runLingoShortcut(t, LingoEntityGet, f, stdout, []string{ + "+get", + "--entity-id", "ent\t-1", + }) + if err == nil { + t.Fatal("expected error for control chars in --entity-id") + } +} + +func TestEntityGetDryRun(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, lingoTestConfig(t)) + err := runLingoShortcut(t, LingoEntityGet, f, stdout, []string{ + "+get", + "--entity-id", "ent-1", + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "/open-apis/lingo/v1/entities/ent-1") { + t.Fatalf("dry-run output missing resolved path, got: %s", out) + } +} + +func TestEntityGetDryRun_WithOuterInfo(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, lingoTestConfig(t)) + err := runLingoShortcut(t, LingoEntityGet, f, stdout, []string{ + "+get", + "--entity-id", "ent-1", + "--provider", "myhr", + "--outer-id", "EMP-001", + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "myhr") || !strings.Contains(out, "EMP-001") { + t.Fatalf("dry-run output missing outer-info params, got: %s", out) + } +} + +func TestEntityGetExecute_OK(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, lingoTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/lingo/v1/entities/ent-1", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "entity": map[string]interface{}{ + "id": "ent-1", + "main_keys": []interface{}{map[string]interface{}{"key": "飞书"}}, + "aliases": []interface{}{map[string]interface{}{"key": "Lark"}}, + "description": "企业协作平台", + }, + }, + }, + }) + err := runLingoShortcut(t, LingoEntityGet, f, stdout, []string{ + "+get", + "--entity-id", "ent-1", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeEnvelope(t, stdout) + entity, _ := data["entity"].(map[string]interface{}) + if entity == nil { + t.Fatal("missing entity in data") + } + if entity["id"] != "ent-1" { + t.Fatalf("entity id = %v, want ent-1", entity["id"]) + } +} diff --git a/shortcuts/lingo/lingo_entity_match.go b/shortcuts/lingo/lingo_entity_match.go new file mode 100644 index 0000000000..5280055959 --- /dev/null +++ b/shortcuts/lingo/lingo_entity_match.go @@ -0,0 +1,68 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package lingo + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +// LingoEntityMatch exactly matches a word against dictionary entries. +var LingoEntityMatch = common.Shortcut{ + Service: "lingo", + Command: "+match", + Description: "Exact-match a word against dictionary entries (use to check if a term is already collected)", + Risk: "read", + Scopes: []string{"baike:entity:readonly"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "word", Desc: "exact word to match (required)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validate.RejectControlChars(runtime.Str("word"), "word") + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{"word": runtime.Str("word")} + return common.NewDryRunAPI(). + POST("/open-apis/lingo/v1/entities/match"). + Body(body). + Desc("Exact-match dictionary entry") + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + body := map[string]interface{}{"word": runtime.Str("word")} + data, err := runtime.DoAPIJSON("POST", "/open-apis/lingo/v1/entities/match", larkcore.QueryParams{}, body) + if err != nil { + return err + } + + results, _ := data["results"].([]interface{}) + runtime.OutFormat(data, nil, func(w io.Writer) { + if len(results) == 0 { + fmt.Fprintf(w, "No match for %q\n", runtime.Str("word")) + return + } + fmt.Fprintf(w, "Matched %d entity(ies):\n", len(results)) + for _, r := range results { + rm, ok := r.(map[string]interface{}) + if !ok { + continue + } + eid, _ := rm["entity_id"].(string) + ttype, _ := rm["type"].(string) + if ttype != "" { + fmt.Fprintf(w, " [%s] type=%s\n", eid, ttype) + } else { + fmt.Fprintf(w, " [%s]\n", eid) + } + } + }) + return nil + }, +} diff --git a/shortcuts/lingo/lingo_entity_match_test.go b/shortcuts/lingo/lingo_entity_match_test.go new file mode 100644 index 0000000000..2176a953ea --- /dev/null +++ b/shortcuts/lingo/lingo_entity_match_test.go @@ -0,0 +1,114 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package lingo + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestEntityMatchValidate_MissingWord(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, lingoTestConfig(t)) + err := runLingoShortcut(t, LingoEntityMatch, f, stdout, []string{"+match"}) + if err == nil { + t.Fatal("expected error for missing --word") + } + if !strings.Contains(err.Error(), "word") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestEntityMatchValidate_ControlCharsInWord(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, lingoTestConfig(t)) + err := runLingoShortcut(t, LingoEntityMatch, f, stdout, []string{ + "+match", + "--word", "KYC\t", + }) + if err == nil { + t.Fatal("expected error for control chars in --word") + } +} + +func TestEntityMatchDryRun(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, lingoTestConfig(t)) + err := runLingoShortcut(t, LingoEntityMatch, f, stdout, []string{ + "+match", + "--word", "KYC", + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "/open-apis/lingo/v1/entities/match") { + t.Fatalf("dry-run output missing API path, got: %s", out) + } + if !strings.Contains(out, "KYC") { + t.Fatalf("dry-run output missing word, got: %s", out) + } +} + +func TestEntityMatchExecute_NotFound(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, lingoTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/lingo/v1/entities/match", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "results": []interface{}{}, + }, + }, + }) + err := runLingoShortcut(t, LingoEntityMatch, f, stdout, []string{ + "+match", + "--word", "nope", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeEnvelope(t, stdout) + results, _ := data["results"].([]interface{}) + if len(results) != 0 { + t.Fatalf("results = %v, want empty", results) + } +} + +func TestEntityMatchExecute_Found(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, lingoTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/lingo/v1/entities/match", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "results": []interface{}{ + map[string]interface{}{"entity_id": "ent-42", "type": 1}, + }, + }, + }, + }) + err := runLingoShortcut(t, LingoEntityMatch, f, stdout, []string{ + "+match", + "--word", "KYC", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeEnvelope(t, stdout) + results, _ := data["results"].([]interface{}) + if len(results) != 1 { + t.Fatalf("results count = %d, want 1", len(results)) + } +} diff --git a/shortcuts/lingo/lingo_entity_search.go b/shortcuts/lingo/lingo_entity_search.go new file mode 100644 index 0000000000..a9f0f72ea3 --- /dev/null +++ b/shortcuts/lingo/lingo_entity_search.go @@ -0,0 +1,122 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package lingo + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +// LingoEntitySearch fuzzy-searches dictionary entries. +var LingoEntitySearch = common.Shortcut{ + Service: "lingo", + Command: "+search", + Description: "Fuzzy search dictionary entries by query string", + Risk: "read", + Scopes: []string{"baike:entity:readonly"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "query", Desc: "search keyword (required)", Required: true}, + {Name: "repo-id", Desc: "dictionary repo ID; empty = shared company dictionary"}, + {Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"}, + {Name: "page-token", Desc: "pagination token for next page"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if err := validate.RejectControlChars(runtime.Str("query"), "query"); err != nil { + return err + } + if v := runtime.Str("repo-id"); v != "" { + if err := validate.RejectControlChars(v, "repo-id"); err != nil { + return err + } + } + if v := runtime.Str("page-token"); v != "" { + if err := validate.RejectControlChars(v, "page-token"); err != nil { + return err + } + } + size := runtime.Int("page-size") + if size < 1 || size > 100 { + return common.FlagErrorf("--page-size must be between 1 and 100") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := buildSearchBody(runtime) + return common.NewDryRunAPI(). + POST("/open-apis/lingo/v1/entities/search"). + Body(body). + Desc("Fuzzy search dictionary entries") + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + body := buildSearchBody(runtime) + data, err := runtime.DoAPIJSON("POST", "/open-apis/lingo/v1/entities/search", larkcore.QueryParams{}, body) + if err != nil { + return err + } + + entities, _ := data["entities"].([]interface{}) + runtime.OutFormat(data, nil, func(w io.Writer) { + fmt.Fprintf(w, "Found %d entity(ies)\n", len(entities)) + for _, e := range entities { + em, ok := e.(map[string]interface{}) + if !ok { + continue + } + id, _ := em["id"].(string) + mainKey := mainKeyText(em) + desc, _ := em["description"].(string) + fmt.Fprintf(w, " [%s] %s\n", id, mainKey) + if desc != "" { + fmt.Fprintf(w, " %s\n", truncate(desc, 120)) + } + } + }) + return nil + }, +} + +// buildSearchBody assembles the search request body from flags. +func buildSearchBody(runtime *common.RuntimeContext) map[string]interface{} { + body := map[string]interface{}{ + "query": runtime.Str("query"), + "page_size": runtime.Int("page-size"), + } + if v := runtime.Str("repo-id"); v != "" { + body["repo_id"] = v + } + if v := runtime.Str("page-token"); v != "" { + body["page_token"] = v + } + return body +} + +// mainKeyText extracts the main key text from an entity response object. +func mainKeyText(entity map[string]interface{}) string { + mk, ok := entity["main_keys"].([]interface{}) + if !ok || len(mk) == 0 { + return "" + } + first, ok := mk[0].(map[string]interface{}) + if !ok { + return "" + } + key, _ := first["key"].(string) + return key +} + +// truncate clips a string to n runes, appending "…" if clipped. +func truncate(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) + "…" +} diff --git a/shortcuts/lingo/lingo_entity_search_test.go b/shortcuts/lingo/lingo_entity_search_test.go new file mode 100644 index 0000000000..0ca5b274b6 --- /dev/null +++ b/shortcuts/lingo/lingo_entity_search_test.go @@ -0,0 +1,159 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package lingo + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestEntitySearchValidate_MissingQuery(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, lingoTestConfig(t)) + err := runLingoShortcut(t, LingoEntitySearch, f, stdout, []string{"+search"}) + if err == nil { + t.Fatal("expected error for missing --query") + } + if !strings.Contains(err.Error(), "query") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestEntitySearchValidate_PageSizeOutOfRange(t *testing.T) { + t.Parallel() + cases := []string{"0", "101", "-5"} + for _, size := range cases { + f, stdout, _, _ := cmdutil.TestFactory(t, lingoTestConfig(t)) + err := runLingoShortcut(t, LingoEntitySearch, f, stdout, []string{ + "+search", + "--query", "KYC", + "--page-size", size, + }) + if err == nil { + t.Fatalf("page-size=%s: expected error", size) + } + if !strings.Contains(err.Error(), "page-size") { + t.Fatalf("page-size=%s: unexpected error: %v", size, err) + } + } +} + +func TestEntitySearchValidate_ControlCharsInQuery(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, lingoTestConfig(t)) + err := runLingoShortcut(t, LingoEntitySearch, f, stdout, []string{ + "+search", + "--query", "KYC\t01", + }) + if err == nil { + t.Fatal("expected error for control chars in --query") + } +} + +func TestEntitySearchDryRun(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, lingoTestConfig(t)) + err := runLingoShortcut(t, LingoEntitySearch, f, stdout, []string{ + "+search", + "--query", "飞书", + "--page-size", "30", + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "/open-apis/lingo/v1/entities/search") { + t.Fatalf("dry-run output missing API path, got: %s", out) + } + if !strings.Contains(out, "飞书") { + t.Fatalf("dry-run output missing query, got: %s", out) + } +} + +func TestEntitySearchExecute_NoMatches(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, lingoTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/lingo/v1/entities/search", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "entities": []interface{}{}, + }, + }, + }) + err := runLingoShortcut(t, LingoEntitySearch, f, stdout, []string{ + "+search", + "--query", "nonexistent", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeEnvelope(t, stdout) + entities, _ := data["entities"].([]interface{}) + if len(entities) != 0 { + t.Fatalf("entities = %v, want empty", entities) + } +} + +func TestEntitySearchExecute_WithMatches(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, lingoTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/lingo/v1/entities/search", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "entities": []interface{}{ + map[string]interface{}{ + "id": "ent-1", + "main_keys": []interface{}{map[string]interface{}{"key": "飞书"}}, + "description": "企业协作平台", + }, + }, + }, + }, + }) + err := runLingoShortcut(t, LingoEntitySearch, f, stdout, []string{ + "+search", + "--query", "飞书", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeEnvelope(t, stdout) + entities, _ := data["entities"].([]interface{}) + if len(entities) != 1 { + t.Fatalf("entities count = %d, want 1", len(entities)) + } +} + +func TestEntitySearchExecute_APIError(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, lingoTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/lingo/v1/entities/search", + Status: 500, + Body: map[string]interface{}{ + "code": 999, + "msg": "internal error", + }, + }) + err := runLingoShortcut(t, LingoEntitySearch, f, stdout, []string{ + "+search", + "--query", "x", + }) + if err == nil { + t.Fatal("expected error for API failure") + } +} diff --git a/shortcuts/lingo/lingo_entity_update.go b/shortcuts/lingo/lingo_entity_update.go new file mode 100644 index 0000000000..1c02a80698 --- /dev/null +++ b/shortcuts/lingo/lingo_entity_update.go @@ -0,0 +1,118 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package lingo + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +// LingoEntityUpdate replaces a dictionary entry (PUT — full-body overwrite). +// Fields not provided are CLEARED on the remote side; call +get first if you +// only want to patch a subset. +var LingoEntityUpdate = common.Shortcut{ + Service: "lingo", + Command: "+update", + Description: "Update a dictionary entry (PUT — full-body overwrite; missing fields are cleared)", + Risk: "write", + Scopes: []string{"baike:entity"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "entity-id", Desc: "dictionary entity ID (required)", Required: true}, + {Name: "main-key", Desc: "main key (required)", Required: true}, + {Name: "aliases", Desc: "comma-separated alias list (empty = clear aliases)"}, + {Name: "description", Desc: "entry description text (empty = clear)", Input: []string{common.File, common.Stdin}}, + {Name: "allow-highlight", Type: "bool", Default: "true", Desc: "whether the entry is highlighted in documents"}, + {Name: "allow-search", Type: "bool", Default: "true", Desc: "whether the entry participates in search"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if err := validate.RejectControlChars(runtime.Str("entity-id"), "entity-id"); err != nil { + return err + } + mainKey := strings.TrimSpace(runtime.Str("main-key")) + if mainKey == "" { + return common.FlagErrorf("--main-key cannot be empty") + } + if err := validate.RejectControlChars(mainKey, "main-key"); err != nil { + return err + } + if v := runtime.Str("aliases"); v != "" { + if err := validate.RejectControlChars(v, "aliases"); err != nil { + return err + } + } + if v := runtime.Str("description"); v != "" { + if err := validate.RejectControlChars(v, "description"); err != nil { + return err + } + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := buildUpdateBody(runtime) + return common.NewDryRunAPI(). + PUT("/open-apis/lingo/v1/entities/:entity_id"). + Set("entity_id", runtime.Str("entity-id")). + Body(body). + Desc("Update dictionary entry (full-body overwrite)") + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + body := buildUpdateBody(runtime) + path := fmt.Sprintf("/open-apis/lingo/v1/entities/%s", runtime.Str("entity-id")) + data, err := runtime.DoAPIJSON("PUT", path, larkcore.QueryParams{}, body) + if err != nil { + return err + } + + runtime.OutFormat(data, nil, func(w io.Writer) { + entity, _ := data["entity"].(map[string]interface{}) + if entity == nil { + fmt.Fprintln(w, "Updated (no entity echoed)") + return + } + id, _ := entity["id"].(string) + fmt.Fprintf(w, "Updated entity [%s] %s\n", id, mainKeyText(entity)) + }) + return nil + }, +} + +// buildUpdateBody assembles the update request body from flags. +// Note: PUT is a full-body overwrite, so the body always carries main_keys +// even when only --description is being changed. +func buildUpdateBody(runtime *common.RuntimeContext) map[string]interface{} { + display := map[string]interface{}{ + "allow_highlight": runtime.Bool("allow-highlight"), + "allow_search": runtime.Bool("allow-search"), + } + + mainKey := map[string]interface{}{ + "key": runtime.Str("main-key"), + "display_status": display, + } + + body := map[string]interface{}{ + "main_keys": []map[string]interface{}{mainKey}, + } + + if aliasStr := runtime.Str("aliases"); aliasStr != "" { + aliases := splitAliases(aliasStr, display) + if len(aliases) > 0 { + body["aliases"] = aliases + } + } + + if desc := runtime.Str("description"); desc != "" { + body["description"] = desc + } + + return body +} diff --git a/shortcuts/lingo/lingo_entity_update_test.go b/shortcuts/lingo/lingo_entity_update_test.go new file mode 100644 index 0000000000..e7b4a7db30 --- /dev/null +++ b/shortcuts/lingo/lingo_entity_update_test.go @@ -0,0 +1,100 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package lingo + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestEntityUpdateValidate_MissingEntityID(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, lingoTestConfig(t)) + err := runLingoShortcut(t, LingoEntityUpdate, f, stdout, []string{ + "+update", + "--main-key", "KYC", + }) + if err == nil { + t.Fatal("expected error for missing --entity-id") + } + if !strings.Contains(err.Error(), "entity-id") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestEntityUpdateValidate_MissingMainKey(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, lingoTestConfig(t)) + err := runLingoShortcut(t, LingoEntityUpdate, f, stdout, []string{ + "+update", + "--entity-id", "ent-1", + }) + if err == nil { + t.Fatal("expected error for missing --main-key") + } + if !strings.Contains(err.Error(), "main-key") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestEntityUpdateDryRun(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, lingoTestConfig(t)) + err := runLingoShortcut(t, LingoEntityUpdate, f, stdout, []string{ + "+update", + "--entity-id", "ent-1", + "--main-key", "KYC", + "--description", "updated desc", + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "/open-apis/lingo/v1/entities/ent-1") { + t.Fatalf("dry-run output missing resolved path, got: %s", out) + } + if !strings.Contains(out, "PUT") { + t.Fatalf("dry-run should be PUT, got: %s", out) + } + if !strings.Contains(out, "updated desc") { + t.Fatalf("dry-run output missing description, got: %s", out) + } +} + +func TestEntityUpdateExecute_OK(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, lingoTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/lingo/v1/entities/ent-1", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "entity": map[string]interface{}{ + "id": "ent-1", + "main_keys": []interface{}{map[string]interface{}{"key": "KYC"}}, + }, + }, + }, + }) + err := runLingoShortcut(t, LingoEntityUpdate, f, stdout, []string{ + "+update", + "--entity-id", "ent-1", + "--main-key", "KYC", + "--description", "new", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeEnvelope(t, stdout) + entity, _ := data["entity"].(map[string]interface{}) + if entity == nil || entity["id"] != "ent-1" { + t.Fatalf("unexpected entity in data: %#v", data) + } +} diff --git a/shortcuts/lingo/shortcuts.go b/shortcuts/lingo/shortcuts.go new file mode 100644 index 0000000000..20ac40380f --- /dev/null +++ b/shortcuts/lingo/shortcuts.go @@ -0,0 +1,20 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package lingo + +import ( + "github.com/larksuite/cli/shortcuts/common" +) + +// Shortcuts returns all lingo (Feishu dictionary / Baike) shortcuts. +func Shortcuts() []common.Shortcut { + return []common.Shortcut{ + LingoEntitySearch, + LingoEntityMatch, + LingoEntityGet, + LingoEntityCreate, + LingoEntityUpdate, + LingoEntityDelete, + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 120f0415c6..30d4559bf0 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -19,6 +19,7 @@ import ( "github.com/larksuite/cli/shortcuts/drive" "github.com/larksuite/cli/shortcuts/event" "github.com/larksuite/cli/shortcuts/im" + "github.com/larksuite/cli/shortcuts/lingo" "github.com/larksuite/cli/shortcuts/mail" "github.com/larksuite/cli/shortcuts/markdown" "github.com/larksuite/cli/shortcuts/minutes" @@ -51,6 +52,7 @@ func init() { allShortcuts = append(allShortcuts, whiteboard.Shortcuts()...) allShortcuts = append(allShortcuts, wiki.Shortcuts()...) allShortcuts = append(allShortcuts, okr.Shortcuts()...) + allShortcuts = append(allShortcuts, lingo.Shortcuts()...) } // AllShortcuts returns a copy of all registered shortcuts (for dump-shortcuts). diff --git a/skills/lark-lingo/SKILL.md b/skills/lark-lingo/SKILL.md new file mode 100644 index 0000000000..307d0e4345 --- /dev/null +++ b/skills/lark-lingo/SKILL.md @@ -0,0 +1,87 @@ +--- +name: lark-lingo +version: 1.0.0 +description: "飞书词典(Lingo / 百科):管理企业词条。支持模糊搜索、精准匹配、获取详情、创建、修改、删除词条。当用户需要查询飞书词典、查词条、新建词条、维护企业术语/缩写/黑话词库时使用。" +metadata: + requires: + bins: ["lark-cli"] + cliHelp: "lark-cli lingo --help" +--- + +# lingo (v1) — 飞书词典 + +**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理。** + +## 快速决策 + +- 用户问**"X 是什么意思"** → 先 [`+match`](references/lark-lingo-match.md)(精准匹配),未命中再 [`+search`](references/lark-lingo-search.md)(模糊搜索);都没结果就直接告诉用户"词典里没收录",**不要编造解释**。 +- 用户要**判断"这个词是否已收录"** → [`+match`](references/lark-lingo-match.md) +- 用户要**列出候选 / 关键词召回** → [`+search`](references/lark-lingo-search.md) +- 用户要**看某条词条的完整释义** → [`+get`](references/lark-lingo-get.md) +- 用户要**新增企业术语 / 黑话** → [`+create`](references/lark-lingo-create.md)(必须 `--as bot`) +- 用户要**改词条释义** → [`+update`](references/lark-lingo-update.md)(PUT 整体覆盖;先 `+get` 取当前值再合并;必须 `--as bot`) +- 用户要**删词条** → [`+delete`](references/lark-lingo-delete.md)(不可逆;需 `--yes`;必须 `--as bot`) + +## 核心边界 + +- **写操作(create/update/delete)只接受 tenant_access_token**:API 端点拒收 user token,会返 `99991668 "user access token not support"`。所以这三个 shortcut **必须 `--as bot`**,没有 user 路径可走。读操作(search/match/get)user/bot 都行。 +- **bot scope 不在 `auth login` 申请**:bot 走 tenant_access_token,scope 是在飞书开发者后台 app 配置页 → 权限管理勾选 → 创建版本 → 管理员审批。`lark-cli auth login --scope` 申请的是 user scope,对 bot 调用无效(`auth status` 显示的也是 user scope,不是 bot 的)。 +- **`+update` 是 PUT 整体覆盖,不是 PATCH**:未传字段会被远端清空。改之前先 `+get` 拿当前值,合并后再 `+update`。 +- **审核机制**:API 创建/修改的词条**默认进入审核队列**,管理员审批通过后才公开可见。需要免审写入时,应用须开通 `baike:entity:exempt_review`(**仅自建应用**可申请)。 +- **词典库 (repo_id)**:可选参数。不传时操作**全公司共享词典**;传入 `repo_id` 则操作指定的私有词典库。 + +## Shortcuts(推荐优先使用) + +Shortcut 是对常用操作的高级封装(`lark-cli lingo + [flags]`)。有 Shortcut 的操作优先使用。 + +| Shortcut | 说明 | 写入风险 | +|----------|------|---------| +| [`+search`](references/lark-lingo-search.md) | 模糊搜索词条 | 只读 | +| [`+match`](references/lark-lingo-match.md) | 精准匹配词条(判断是否已收录) | 只读 | +| [`+get`](references/lark-lingo-get.md) | 通过 entity_id 获取词条详情 | 只读 | +| [`+create`](references/lark-lingo-create.md) | 创建词条(默认进入审核队列) | 写入 | +| [`+update`](references/lark-lingo-update.md) | 修改词条(PUT 整体覆盖) | 写入 | +| [`+delete`](references/lark-lingo-delete.md) | 删除词条(不可逆) | high-risk-write | + +## 典型流程:词条幂等收录 + +"这个词没收录就新建,已收录就跳过 / 取详情": + +```bash +# Step 1: 精准匹配看是否已存在 +lark-cli lingo +match --word "KYC" +# └─ 命中 → 用返回的 results[0].entity_id 走 Step 2a +# └─ 未命中 → 走 Step 2b + +# Step 2a: 已存在 → 取详情 +lark-cli lingo +get --entity-id "" + +# Step 2b: 不存在 → 创建(与用户确认释义后;必须 bot) +lark-cli lingo +create --as bot \ + --main-key "KYC" \ + --aliases "Know Your Customer" \ + --description "..." +``` + +## 权限 + +| 操作 | 所需 scope | 申请方式 | +|------|-----------|---------| +| `+search` / `+match` / `+get` | `baike:entity:readonly` | `lark-cli auth login --scope "..."`(user)或开发者后台勾选(bot) | +| `+create` / `+update` / `+delete` | `baike:entity` | **只能在开发者后台勾选 + 管理员审批**(bot 路径) | +| 免审写入(不进队列) | `baike:entity:exempt_review` | 同上,**仅自建应用** | + +> 缺 scope 时返回 `99991672`,hint 会指向开发者后台 console_url;缺别的 token 类型支持时返回 `99991668`。 + +## 安全规则 + +- **写操作(+create / +update / +delete)**:调用前必须向用户确认词面、释义、操作意图。 +- **+update 整体覆盖**:未传字段会被清空。先 `+get` 取当前值,合并后再 `+update`。 +- **+delete 不可逆**:必须二次确认 `entity_id` 对应的词条名称后再执行;shortcut 默认要求 `--yes`。 +- **创建后告知用户**「已提交审核,需管理员通过后生效」(除非应用已开通 `baike:entity:exempt_review`)。 + +## 参考 + +- [lark-shared](../lark-shared/SKILL.md) — 认证和全局参数 +- [Lingo 概述](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/lingo-v1/overview) +- [词条 API 总览](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/lingo-v1/entity) diff --git a/skills/lark-lingo/references/lark-lingo-create.md b/skills/lark-lingo/references/lark-lingo-create.md new file mode 100644 index 0000000000..ed24a84104 --- /dev/null +++ b/skills/lark-lingo/references/lark-lingo-create.md @@ -0,0 +1,107 @@ +# lingo +create + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +创建一条新的词条。**必须 `--as bot`**。 + +## 命令 + +```bash +# 最小请求(只主词) +lark-cli lingo +create --as bot \ + --main-key "KYC" + +# 常规:主词 + 别名(逗号分隔) + 释义 +lark-cli lingo +create --as bot \ + --main-key "飞书" \ + --aliases "Lark,FeiShu,飞书办公" \ + --description "企业协作平台" + +# 从文件读释义 +lark-cli lingo +create --as bot \ + --main-key "KYC" \ + --description @./desc.txt + +# 从 stdin 读释义 +printf "Know Your Customer …" | \ + lark-cli lingo +create --as bot \ + --main-key "KYC" \ + --description - + +# 写到私有词典库 +lark-cli lingo +create --as bot \ + --main-key "KYC" \ + --description "…" \ + --repo-id "" + +# 关闭搜索参与 / 关闭高亮 +lark-cli lingo +create --as bot \ + --main-key "KYC" \ + --description "…" \ + --allow-highlight=false \ + --allow-search=false + +# 预览底层请求 +lark-cli lingo +create --as bot \ + --main-key "KYC" \ + --description "…" \ + --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--main-key` | 是 | 主词(词条显示的主关键词) | +| `--aliases` | 否 | 别名列表,**逗号分隔**;空格会被自动 trim | +| `--description` | 否 | 释义文本;支持 `@file` 和 `-`(stdin) | +| `--repo-id` | 否 | 词典库 ID;省略时写入全公司共享词典 | +| `--allow-highlight` | 否 | 是否在文档中高亮,默认 `true` | +| `--allow-search` | 否 | 是否参与搜索,默认 `true` | + +## 关键约束 + +- **必须 `--as bot`**:API 端点拒收 user token,user 调用会返 `99991668 "user access token not support"`。 +- **主词只能 1 个**:API schema 限制 `main_keys` 数组最多 1 个元素;本 shortcut 只暴露单 `--main-key`。多主词需求请用原生 `lark-cli api POST /open-apis/lingo/v1/entities --data '{...}'`。 +- **创建会进审核队列**:除非应用已开通 `baike:entity:exempt_review`,词条要等管理员审批通过才对外可见。创建成功后应告知用户「已提交审核」。 +- **幂等性**:本 shortcut 不做存在性检查。"没收录就新建"的流程必须自己先调 [`+match`](lark-lingo-match.md) 判断。 + +## 返回值 + +```json +{ + "ok": true, + "identity": "bot", + "data": { + "entity": { + "id": "enterprise_xxxx", + "main_keys": [{"key": "KYC"}], + "aliases": [{"key": "Know Your Customer"}], + "description": "…" + } + } +} +``` + +## 错误排查 + +| 错误码 | 含义 | 修复 | +|--------|------|------| +| `99991668` `user access token not support` | 用了 user 身份 | 加 `--as bot` | +| `99991672` `Permission denied`(bot) | 应用缺 `baike:entity` scope | 飞书开发者后台 → 权限管理勾选 → 管理员审批 | + +## 权限 + +| 操作 | 所需 scope | +|------|-----------| +| 创建(走审核) | `baike:entity` | +| 免审创建 | `baike:entity` + `baike:entity:exempt_review`(仅自建应用) | + +> bot scope 在飞书开发者后台 app 配置页勾选 + 管理员审批,**不是**通过 `lark-cli auth login --scope` 申请。 + +## 参考 + +- [lark-lingo](../SKILL.md) — Lingo 域总览 +- [lark-lingo +match](lark-lingo-match.md) — 创建前先判断是否已收录 +- [lark-lingo +update](lark-lingo-update.md) — 已收录时走更新 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-lingo/references/lark-lingo-delete.md b/skills/lark-lingo/references/lark-lingo-delete.md new file mode 100644 index 0000000000..320a790a98 --- /dev/null +++ b/skills/lark-lingo/references/lark-lingo-delete.md @@ -0,0 +1,70 @@ +# lingo +delete + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +通过 `entity_id` 删除词条。**必须 `--as bot`**。**不可逆**。 + +## 命令 + +```bash +# 删除(必须同时带 --yes 才能实际执行) +lark-cli lingo +delete --as bot \ + --entity-id "enterprise_xxxx" \ + --yes + +# 预览底层请求(不执行,可省 --yes) +lark-cli lingo +delete --as bot \ + --entity-id "enterprise_xxxx" \ + --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--entity-id` | 是 | 要删除的词条 ID | +| `--yes` | 是(执行时) | 确认高危写入;不传会被框架拦下 | + +## 关键约束 + +- ⚠️ **不可逆**:删除后无法恢复。调用前**必须**用 [`+get`](lark-lingo-get.md) 二次确认 `entity_id` 对应的词条名称和释义是否真的要删。 +- **必须 `--as bot`**:user token 会返 `99991668`。 +- **框架级高危确认**:本 shortcut 的 risk = `high-risk-write`;不加 `--yes` 时框架会直接拒绝执行,只有 `--dry-run` 能跳过。 +- **不支持批量**:一次只能删一条。批量场景自己在外层循环 + 节流。 + +## 返回值 + +```json +{ + "ok": true, + "identity": "bot", + "data": { + "deleted": true, + "entity_id": "enterprise_xxxx" + } +} +``` + +## 安全流程 + +```bash +# Step 1: 用 +get 确认目标词条是你想删的那条 +lark-cli lingo +get --entity-id "enterprise_xxxx" + +# Step 2: 向用户复述主词 + 释义片段,得到明确确认 + +# Step 3: 执行 +lark-cli lingo +delete --as bot --entity-id "enterprise_xxxx" --yes +``` + +## 权限 + +| 身份 | 所需 scope | +|------|-----------| +| bot | `baike:entity` | + +## 参考 + +- [lark-lingo](../SKILL.md) — Lingo 域总览 +- [lark-lingo +get](lark-lingo-get.md) — 删前必做:确认目标 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-lingo/references/lark-lingo-get.md b/skills/lark-lingo/references/lark-lingo-get.md new file mode 100644 index 0000000000..3cf669db89 --- /dev/null +++ b/skills/lark-lingo/references/lark-lingo-get.md @@ -0,0 +1,64 @@ +# lingo +get + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +通过 `entity_id` 获取单条词条的**完整详情**(主词、别名、释义、富文本、关联元数据、外部系统信息)。 + +## 命令 + +```bash +# 最常用:按 entity_id 取详情 +lark-cli lingo +get --entity-id "enterprise_xxxx" + +# 按外部系统(provider + outer_id)查找对应词条 +lark-cli lingo +get --entity-id "" --provider "myhr" --outer-id "EMP-001" + +# 预览底层请求 +lark-cli lingo +get --entity-id "enterprise_xxxx" --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--entity-id` | 是 | 词条 ID(通常来自 `+match` / `+search` 返回的 `entity_id`) | +| `--provider` | 否 | 外部系统名;配合 `--outer-id` 使用,用于按外部系统 ID 反查已绑定的词条 | +| `--outer-id` | 否 | 外部系统 ID;必须与 `--provider` 同时使用 | + +## 关键约束 + +- 拿不到 `entity_id` 时,**先用 [`+match`](lark-lingo-match.md) 或 [`+search`](lark-lingo-search.md) 查到 id 再调**。不要编造 id。 +- `--provider` / `--outer-id` 是成对使用的,配合 `outer_info` 绑定场景(外部数据源和词条做关联);只填其一会被服务端拒绝。 + +## 返回值 + +```json +{ + "ok": true, + "identity": "user", + "data": { + "entity": { + "id": "enterprise_xxxx", + "main_keys": [{"key": "KYC"}], + "aliases": [{"key": "Know Your Customer"}], + "description": "了解你的交易 …", + "rich_text": "

", + "related_meta": {}, + "outer_info": {} + } + } +} +``` + +## 权限 + +| 身份 | 所需 scope | +|------|-----------| +| user / bot | `baike:entity:readonly` | + +## 参考 + +- [lark-lingo](../SKILL.md) — Lingo 域总览 +- [lark-lingo +match](lark-lingo-match.md) — 精准匹配得到 entity_id +- [lark-lingo +update](lark-lingo-update.md) — 改词条前先 `+get` 取当前值 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-lingo/references/lark-lingo-match.md b/skills/lark-lingo/references/lark-lingo-match.md new file mode 100644 index 0000000000..b1a72172ae --- /dev/null +++ b/skills/lark-lingo/references/lark-lingo-match.md @@ -0,0 +1,81 @@ +# lingo +match + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +**精准匹配**一个词与词典中的主词 / 别名。用于判断"这个词是否已收录",是创建词条前的幂等检查首选。 + +## 命令 + +```bash +# 判断是否已收录 +lark-cli lingo +match --word "KYC" + +# 预览底层请求 +lark-cli lingo +match --word "KYC" --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--word` | 是 | 要精确匹配的词面(主词或别名) | + +## 关键约束 + +- 是**完全等值匹配**:`KYC` 不会匹配上 `KYCM` 或 `KYC-V2`。要找候选请用 [`+search`](lark-lingo-search.md)。 +- 命中可能有**多条**(同一个词面对应多个不同的 `entity_id`,如多个团队各自收录的版本)。 + +## 返回值 + +命中: + +```json +{ + "ok": true, + "identity": "user", + "data": { + "results": [ + {"entity_id": "enterprise_xxxx", "type": 0} + ] + } +} +``` + +未命中: + +```json +{ + "ok": true, + "identity": "user", + "data": { + "results": [] + } +} +``` + +## 典型用法:幂等收录 + +```bash +# Step 1: match 判断 +lark-cli lingo +match --word "KYC" + +# Step 2a: 命中 → 拿 entity_id 取详情 +lark-cli lingo +get --entity-id "" + +# Step 2b: 未命中 → 跟用户确认释义后创建 +lark-cli lingo +create --as bot --main-key "KYC" --description "..." +``` + +## 权限 + +| 身份 | 所需 scope | +|------|-----------| +| user / bot | `baike:entity:readonly` | + +## 参考 + +- [lark-lingo](../SKILL.md) — Lingo 域总览 +- [lark-lingo +search](lark-lingo-search.md) — 模糊搜索 +- [lark-lingo +get](lark-lingo-get.md) — 获取详情 +- [lark-lingo +create](lark-lingo-create.md) — 创建词条 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-lingo/references/lark-lingo-search.md b/skills/lark-lingo/references/lark-lingo-search.md new file mode 100644 index 0000000000..fd4c946142 --- /dev/null +++ b/skills/lark-lingo/references/lark-lingo-search.md @@ -0,0 +1,70 @@ +# lingo +search + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +在词典中**模糊搜索**词条。返回按相关度排序的候选列表。 + +## 命令 + +```bash +# 基本模糊搜索 +lark-cli lingo +search --query "AML" + +# 调大分页 +lark-cli lingo +search --query "飞书" --page-size 50 + +# 指定私有词典库搜索 +lark-cli lingo +search --query "KYC" --repo-id "" + +# 获取下一页 +lark-cli lingo +search --query "AML" --page-token "" + +# 预览底层请求 +lark-cli lingo +search --query "AML" --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--query` | 是 | 搜索关键词(支持部分匹配) | +| `--repo-id` | 否 | 词典库 ID;省略时搜索全公司共享词典 | +| `--page-size` | 否 | 每页条数,取值 1–100,默认 20 | +| `--page-token` | 否 | 上一页返回的 `page_token`,用于翻页 | + +## 关键约束 + +- 是**模糊搜索**,不要求关键词完全等于主词或别名;如果要"完全相等判断",改用 [`+match`](lark-lingo-match.md)。 +- 没命中时 `data.entities = []`,**不要编造**词条,直接告诉用户"词典里没收录"。 + +## 返回值 + +```json +{ + "ok": true, + "identity": "user", + "data": { + "entities": [ + { + "id": "enterprise_xxxx", + "main_keys": [{"key": "AML"}], + "description": "反洗钱 Anti-Money Laundering …" + } + ], + "page_token": "...", + "has_more": false + } +} +``` + +## 权限 + +| 身份 | 所需 scope | +|------|-----------| +| user / bot | `baike:entity:readonly` | + +## 参考 + +- [lark-lingo](../SKILL.md) — Lingo 域总览 +- [lark-lingo +match](lark-lingo-match.md) — 精准匹配 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-lingo/references/lark-lingo-update.md b/skills/lark-lingo/references/lark-lingo-update.md new file mode 100644 index 0000000000..19e0ec5034 --- /dev/null +++ b/skills/lark-lingo/references/lark-lingo-update.md @@ -0,0 +1,88 @@ +# lingo +update + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +修改一条已有词条。**必须 `--as bot`**。**PUT 整体覆盖,不是 PATCH**。 + +## 命令 + +```bash +# 必须同时传主词 + 你要保留的字段 +lark-cli lingo +update --as bot \ + --entity-id "enterprise_xxxx" \ + --main-key "KYC" \ + --aliases "Know Your Customer" \ + --description "更新后的释义" + +# 只改 description:主词仍必须带 +lark-cli lingo +update --as bot \ + --entity-id "enterprise_xxxx" \ + --main-key "KYC" \ + --description "新释义" +# ⚠️ 这样会清空原有 aliases!先 +get 取全量再合并。 + +# 安全模式:先 +get,手工合并,再 +update +ENTITY_ID="enterprise_xxxx" +lark-cli lingo +get --entity-id "$ENTITY_ID" # 看当前 main_key / aliases / description +lark-cli lingo +update --as bot \ + --entity-id "$ENTITY_ID" \ + --main-key "<从上面 copy>" \ + --aliases "<从上面 copy,改动的地方在这里合进去>" \ + --description "<改动后的完整释义>" + +# 预览底层请求 +lark-cli lingo +update --as bot \ + --entity-id "enterprise_xxxx" \ + --main-key "KYC" \ + --description "…" \ + --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--entity-id` | 是 | 目标词条 ID | +| `--main-key` | 是 | 主词(PUT 整体覆盖要求必传) | +| `--aliases` | 否 | 别名列表,逗号分隔;**不传会被清空** | +| `--description` | 否 | 释义文本;**不传会被清空**;支持 `@file` 和 `-`(stdin) | +| `--allow-highlight` | 否 | 是否在文档中高亮,默认 `true` | +| `--allow-search` | 否 | 是否参与搜索,默认 `true` | + +## 关键约束 + +- ⚠️ **PUT 整体覆盖**:未传的可选字段会被远端清空。任何更新前请**先 [`+get`](lark-lingo-get.md) 取当前值,再合并后调本 shortcut**。 +- **必须 `--as bot`**:API 端点拒收 user token,user 调用返 `99991668`。 +- **修改走审核**:除非应用已开通 `baike:entity:exempt_review`,更新要等管理员审批通过才生效。 +- **不支持局部字段**:本 shortcut 映射到 `PUT /open-apis/lingo/v1/entities/:entity_id`;飞书 API 没有 PATCH 端点。 + +## 返回值 + +```json +{ + "ok": true, + "identity": "bot", + "data": { + "entity": { + "id": "enterprise_xxxx", + "main_keys": [{"key": "KYC"}], + "aliases": [{"key": "Know Your Customer"}], + "description": "更新后的释义" + } + } +} +``` + +## 权限 + +| 操作 | 所需 scope | +|------|-----------| +| 更新(走审核) | `baike:entity` | +| 免审更新 | `baike:entity` + `baike:entity:exempt_review`(仅自建应用) | + +## 参考 + +- [lark-lingo](../SKILL.md) — Lingo 域总览 +- [lark-lingo +get](lark-lingo-get.md) — 改之前取当前值 +- [lark-lingo +create](lark-lingo-create.md) — 新建词条 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/tests/cli_e2e/lingo/lingo_dryrun_test.go b/tests/cli_e2e/lingo/lingo_dryrun_test.go new file mode 100644 index 0000000000..dda099ac0c --- /dev/null +++ b/tests/cli_e2e/lingo/lingo_dryrun_test.go @@ -0,0 +1,154 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package lingo + +import ( + "context" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Dry-run E2E tests (no real API calls, no secrets needed) --- + +func setDryRunConfigEnv(t *testing.T) { + t.Helper() + t.Setenv("LARKSUITE_CLI_APP_ID", "cli_dryrun_test") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "dryrun_secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") +} + +func TestLingo_SearchDryRun(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "lingo", "+search", + "--query", "KYC", + "--page-size", "30", + "--dry-run", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + assert.True(t, strings.Contains(out, "/open-apis/lingo/v1/entities/search"), "dry-run should contain API path, got: %s", out) + assert.True(t, strings.Contains(out, "POST"), "dry-run should be POST, got: %s", out) + assert.True(t, strings.Contains(out, "KYC"), "dry-run should contain query, got: %s", out) +} + +func TestLingo_MatchDryRun(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "lingo", "+match", + "--word", "KYC", + "--dry-run", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + assert.True(t, strings.Contains(out, "/open-apis/lingo/v1/entities/match"), "dry-run should contain API path, got: %s", out) + assert.True(t, strings.Contains(out, "KYC"), "dry-run should contain word, got: %s", out) +} + +func TestLingo_GetDryRun(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "lingo", "+get", + "--entity-id", "ent-1", + "--dry-run", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + assert.True(t, strings.Contains(out, "/open-apis/lingo/v1/entities/ent-1"), "dry-run should contain resolved path, got: %s", out) + assert.True(t, strings.Contains(out, "GET"), "dry-run should be GET, got: %s", out) +} + +func TestLingo_CreateDryRun(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "lingo", "+create", + "--main-key", "KYC", + "--aliases", "Know Your Customer", + "--description", "AML monitoring concept", + "--dry-run", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + assert.True(t, strings.Contains(out, "/open-apis/lingo/v1/entities"), "dry-run should contain API path, got: %s", out) + assert.True(t, strings.Contains(out, "POST"), "dry-run should be POST, got: %s", out) + assert.True(t, strings.Contains(out, "main_keys"), "dry-run body should contain main_keys, got: %s", out) + assert.True(t, strings.Contains(out, "Know Your Customer"), "dry-run should contain alias, got: %s", out) + assert.True(t, strings.Contains(out, "AML monitoring concept"), "dry-run should contain description, got: %s", out) +} + +func TestLingo_UpdateDryRun(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "lingo", "+update", + "--entity-id", "ent-1", + "--main-key", "KYC", + "--description", "updated", + "--dry-run", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + assert.True(t, strings.Contains(out, "/open-apis/lingo/v1/entities/ent-1"), "dry-run should contain resolved path, got: %s", out) + assert.True(t, strings.Contains(out, "PUT"), "dry-run should be PUT, got: %s", out) + assert.True(t, strings.Contains(out, "updated"), "dry-run should contain description, got: %s", out) +} + +func TestLingo_DeleteDryRun(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "lingo", "+delete", + "--entity-id", "ent-1", + "--dry-run", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + assert.True(t, strings.Contains(out, "/open-apis/lingo/v1/entities/ent-1"), "dry-run should contain resolved path, got: %s", out) + assert.True(t, strings.Contains(out, "DELETE"), "dry-run should be DELETE, got: %s", out) +}