mirror of
https://github.com/larksuite/cli.git
synced 2026-07-10 02:54:04 +08:00
Compare commits
17 Commits
v1.0.66
...
fix/endpoi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2f4f56520 | ||
|
|
519a600b62 | ||
|
|
d87d9b458a | ||
|
|
1173179b10 | ||
|
|
74d8458635 | ||
|
|
80fadf1801 | ||
|
|
c04da4723a | ||
|
|
a09388d035 | ||
|
|
cdd9d3409b | ||
|
|
06f6b0b18c | ||
|
|
9413e7cd8b | ||
|
|
047d729f72 | ||
|
|
1a9f637866 | ||
|
|
34c4ba5581 | ||
|
|
9a6ba41684 | ||
|
|
f495cbb166 | ||
|
|
6f95c5eb22 |
54
.github/workflows/ci.yml
vendored
54
.github/workflows/ci.yml
vendored
@@ -263,13 +263,19 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Resolve CLI E2E domains
|
||||
id: e2e_domains
|
||||
run: node scripts/e2e_domains.js
|
||||
- name: Build lark-cli
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: make build
|
||||
- name: Run dry-run E2E tests
|
||||
env:
|
||||
@@ -277,7 +283,28 @@ jobs:
|
||||
LARKSUITE_CLI_APP_ID: dry-run
|
||||
LARKSUITE_CLI_APP_SECRET: dry-run
|
||||
LARKSUITE_CLI_BRAND: feishu
|
||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
||||
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||
E2E_DRY_ROOT_PACKAGE: ${{ steps.e2e_domains.outputs.dry_root_package }}
|
||||
E2E_DRY_PACKAGES: ${{ steps.e2e_domains.outputs.dry_packages }}
|
||||
run: |
|
||||
if [ "$E2E_MODE" = "skip" ]; then
|
||||
echo "No dry-run CLI E2E needed: $E2E_REASON"
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "$E2E_DRY_ROOT_PACKAGE" ] && [ -z "$E2E_DRY_PACKAGES" ]; then
|
||||
echo "::error::No dry-run CLI E2E packages resolved for mode $E2E_MODE"
|
||||
exit 1
|
||||
fi
|
||||
echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
||||
if [ -n "$E2E_DRY_ROOT_PACKAGE" ]; then
|
||||
echo "Dry-run CLI E2E root package: $E2E_DRY_ROOT_PACKAGE"
|
||||
go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"
|
||||
fi
|
||||
if [ -n "$E2E_DRY_PACKAGES" ]; then
|
||||
echo "Dry-run CLI E2E packages: $E2E_DRY_PACKAGES"
|
||||
go test -v -count=1 -timeout=5m $E2E_DRY_PACKAGES -run 'DryRun|Regression'
|
||||
fi
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
@@ -292,15 +319,22 @@ jobs:
|
||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Resolve CLI E2E domains
|
||||
id: e2e_domains
|
||||
run: node scripts/e2e_domains.js
|
||||
- name: Build lark-cli
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: make build
|
||||
- name: Configure bot credentials
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: |
|
||||
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
|
||||
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
|
||||
@@ -310,16 +344,24 @@ jobs:
|
||||
- name: Run CLI E2E tests
|
||||
env:
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
|
||||
run: |
|
||||
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
|
||||
if [ "$E2E_MODE" = "skip" ]; then
|
||||
echo "No live CLI E2E needed: $E2E_REASON"
|
||||
exit 0
|
||||
fi
|
||||
packages="$E2E_LIVE_PACKAGES"
|
||||
if [ -z "$packages" ]; then
|
||||
echo "No CLI E2E packages to test after exclusions."
|
||||
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
|
||||
exit 1
|
||||
fi
|
||||
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
||||
echo "Live CLI E2E packages: $packages"
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
- name: Publish CLI E2E test report
|
||||
if: ${{ !cancelled() }}
|
||||
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
with:
|
||||
name: CLI E2E Tests
|
||||
|
||||
24
CHANGELOG.md
24
CHANGELOG.md
@@ -2,6 +2,29 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.67] - 2026-07-08
|
||||
|
||||
### Features
|
||||
|
||||
- **mail**: add message modify and trash shortcuts (#1567)
|
||||
- support whiteboard file inputs in docs XML (#1784)
|
||||
- **vc**: refine meeting-events output and reaction forwarding (#1674)
|
||||
- **affordance**: usage guidance for shortcuts and per-command skills (#1793)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- accept opaque wiki node tokens (#1789)
|
||||
- **apps**: make db --environment optional, auto-select branch server-side (#1735)
|
||||
- preserve original filename in multipart file upload (#1767)
|
||||
|
||||
### Documentation
|
||||
|
||||
- restore one-time authorization guidance in lark-apps skill (#1794)
|
||||
|
||||
### Misc
|
||||
|
||||
- e2e: harden CLI E2E retry, cleanup, and domain selection (#1709)
|
||||
|
||||
## [v1.0.66] - 2026-07-07
|
||||
|
||||
### Features
|
||||
@@ -1398,6 +1421,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.67]: https://github.com/larksuite/cli/releases/tag/v1.0.67
|
||||
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
|
||||
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
|
||||
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
|
||||
|
||||
2
Makefile
2
Makefile
@@ -51,7 +51,7 @@ script-test:
|
||||
bash scripts/resolve-changed-from.test.sh
|
||||
bash scripts/ci-workflow.test.sh
|
||||
bash scripts/semantic-review-workflow.test.sh
|
||||
$(NODE) --test scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
|
||||
@@ -10,18 +10,33 @@ step. Maintain these files alongside `skills/` and `shortcuts/`.
|
||||
A small, fixed markdown subset; each file describes one domain:
|
||||
|
||||
# <domain> optional `> skill: <name>` applies to every command below
|
||||
## <command> the command as typed, minus `lark-cli <domain>`
|
||||
## <command> the command as typed, minus `lark-cli <domain>`; a
|
||||
+-prefixed heading (## +create) targets that shortcut
|
||||
<lead paragraph> when to use this command
|
||||
### Avoid when when not to use it / which command to use instead
|
||||
### Prerequisites what you must have first (e.g. an id, and where it comes from)
|
||||
### Tips gotchas and constraints
|
||||
### Examples **description** lines, each followed by a fenced command
|
||||
### Skills bullet skill names, or name/relpath references
|
||||
(lark-contact/references/x.md), to read for usage;
|
||||
merged with the domain `> skill:` default (deduped,
|
||||
domain first)
|
||||
### <other heading> a custom section; flows through verbatim
|
||||
|
||||
Reference another command with `[[command]]` — it renders as `command` in help.
|
||||
Under `Avoid when` it means "use that one instead"; under `Prerequisites`
|
||||
("… from [[command]]") it means "get the input there first".
|
||||
|
||||
Both service-API commands (`## messages get`) and `+`-prefixed shortcuts
|
||||
(`## +create`) take entries. A `### Skills` entry is a skill name (validated
|
||||
against `<name>/SKILL.md`) or a `name/relpath` reference into that skill
|
||||
(validated against the path); help drops any that don't resolve, so a typo shows
|
||||
nothing. Point a command at its own reference (e.g. `+search-user` →
|
||||
`lark-contact/references/lark-contact-search-user.md`) rather than re-listing the
|
||||
domain skill, which the `> skill:` default already covers. When a shortcut also
|
||||
sets a hand-authored `Tips` list in Go, the overlay's `### Tips` win — they
|
||||
replace the Go tips (not merged), so keep tips in one place.
|
||||
|
||||
## Example
|
||||
|
||||
## messages get
|
||||
@@ -47,3 +62,5 @@ Under `Avoid when` it means "use that one instead"; under `Prerequisites`
|
||||
anything the schema and flags already show; the agent infers the rest.
|
||||
- Command-form headings resolve to method ids via the registry, so plural resource
|
||||
names (`messages`) map to the singular method id (`message`) automatically.
|
||||
`+`-prefixed shortcut headings are matched verbatim (no plural/space folding),
|
||||
so the heading must equal the shortcut command exactly (`## +history-revert`).
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
# contact
|
||||
> skill: lark-contact
|
||||
|
||||
## +search-user
|
||||
The primary user lookup for user identity: search by keyword or email, resolve known ids with --user-ids, or get yourself with --user-ids me — it does by-id reads too, so as a user you rarely need `+get-user`. Each match returns an open_id and p2p_chat_id to chain into follow-ups.
|
||||
|
||||
### Skills
|
||||
- lark-contact/references/lark-contact-search-user.md
|
||||
|
||||
### Avoid when
|
||||
- Running as a bot — this shortcut is user-only; use [[+get-user]] instead (it supports bot identity)
|
||||
- You only need users' personal status for ids you already hold → use [[user_profiles batch_query]]
|
||||
|
||||
### Examples
|
||||
|
||||
**Find a user by name**
|
||||
```bash
|
||||
lark-cli contact +search-user --query "alice" --as user
|
||||
```
|
||||
|
||||
**Fetch known users by open_id (me = yourself)**
|
||||
```bash
|
||||
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
|
||||
```
|
||||
|
||||
## +get-user
|
||||
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.
|
||||
|
||||
### Skills
|
||||
- lark-contact/references/lark-contact-get-user.md
|
||||
|
||||
### Avoid when
|
||||
- You don't have the user's id yet, or want to match by name/keyword → use [[+search-user]]
|
||||
- Running as a user — [[+search-user]] --user-ids covers by-id reads and more in one tool
|
||||
|
||||
### Tips
|
||||
- Self lookup (omit --user-id) needs user identity; a bot must pass --user-id
|
||||
- --user-id-type must match the id you pass (default open_id)
|
||||
|
||||
## user_profiles batch_query
|
||||
Bulk-fetch personal status and signature for user ids you already have.
|
||||
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -1069,3 +1073,157 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
||||
t.Errorf("expected method GET, got %s", gotOpts.Method)
|
||||
}
|
||||
}
|
||||
|
||||
// parseMultipartFilenames drives one api --file upload through the mock
|
||||
// transport and returns a map of field name -> part filename parsed from the
|
||||
// captured multipart body, plus the map of text form fields. It fails the test
|
||||
// if the captured request is not multipart/form-data.
|
||||
func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) (map[string]string, map[string]string) {
|
||||
t.Helper()
|
||||
ct := stub.CapturedHeaders.Get("Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(ct)
|
||||
if err != nil {
|
||||
t.Fatalf("parse Content-Type %q: %v", ct, err)
|
||||
}
|
||||
if !strings.HasPrefix(mediaType, "multipart/") {
|
||||
t.Fatalf("Content-Type = %q, want multipart/*", mediaType)
|
||||
}
|
||||
filenames := map[string]string{}
|
||||
fields := map[string]string{}
|
||||
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if fn := part.FileName(); fn != "" {
|
||||
filenames[part.FormName()] = fn
|
||||
} else {
|
||||
buf := &bytes.Buffer{}
|
||||
_, _ = buf.ReadFrom(part)
|
||||
fields[part.FormName()] = buf.String()
|
||||
}
|
||||
}
|
||||
return filenames, fields
|
||||
}
|
||||
|
||||
func TestApiCmd_FileUpload_PreservesFilename(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
URL: "/open-apis/approval/v4/files/upload",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "invoice.pdf"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
filenames, _ := parseMultipartFilenames(t, stub)
|
||||
if got := filenames["file"]; got != "invoice.pdf" {
|
||||
t.Fatalf("part filename for field %q = %q, want %q", "file", got, "invoice.pdf")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileUpload_FieldPrefixKeepsBasename(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "sub", "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
URL: "/open-apis/approval/v4/files/upload",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "upload=sub/invoice.pdf"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
filenames, _ := parseMultipartFilenames(t, stub)
|
||||
if _, ok := filenames["upload"]; !ok {
|
||||
t.Fatalf("expected field name %q from field=path form, got fields %v", "upload", filenames)
|
||||
}
|
||||
if got := filenames["upload"]; got != "invoice.pdf" {
|
||||
t.Fatalf("part filename for field %q = %q, want %q (basename only)", "upload", got, "invoice.pdf")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileUpload_WithDataFields(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
URL: "/open-apis/approval/v4/files/upload",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot",
|
||||
"--file", "invoice.pdf", "--data", `{"type":"attachment"}`})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
filenames, fields := parseMultipartFilenames(t, stub)
|
||||
if got := filenames["file"]; got != "invoice.pdf" {
|
||||
t.Fatalf("part filename = %q, want %q", got, "invoice.pdf")
|
||||
}
|
||||
if got := fields["type"]; got != "attachment" {
|
||||
t.Fatalf("text field type = %q, want %q", got, "attachment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FileUpload_StdinFallsBackToUnknown(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.IOStreams.In = bytes.NewReader([]byte("stdin-bytes"))
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
URL: "/open-apis/approval/v4/files/upload",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "-"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
filenames, _ := parseMultipartFilenames(t, stub)
|
||||
if got := filenames["file"]; got != "unknown-file" {
|
||||
t.Fatalf("stdin part filename = %q, want %q (no stable local name, fallback)", got, "unknown-file")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -916,25 +916,6 @@ func TestReadDotenv_ValueWithEquals(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBrand(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"", "feishu"},
|
||||
{"feishu", "feishu"},
|
||||
{"lark", "lark"},
|
||||
{"LARK", "lark"},
|
||||
{" lark ", "lark"},
|
||||
{"Lark", "lark"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := normalizeBrand(tt.input); got != tt.want {
|
||||
t.Errorf("normalizeBrand(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenClawConfigPath_Overrides(t *testing.T) {
|
||||
t.Run("OPENCLAW_CONFIG_PATH wins", func(t *testing.T) {
|
||||
custom := filepath.Join(t.TempDir(), "custom.json")
|
||||
|
||||
@@ -205,7 +205,7 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
return &core.AppConfig{
|
||||
AppId: selected.AppID,
|
||||
AppSecret: stored,
|
||||
Brand: core.LarkBrand(normalizeBrand(selected.Brand)),
|
||||
Brand: core.ParseBrand(selected.Brand),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
return &core.AppConfig{
|
||||
AppId: appID,
|
||||
AppSecret: stored,
|
||||
Brand: core.LarkBrand(normalizeBrand(b.envMap["FEISHU_DOMAIN"])),
|
||||
Brand: core.ParseBrand(b.envMap["FEISHU_DOMAIN"]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -326,7 +326,7 @@ func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
return &core.AppConfig{
|
||||
AppId: appID,
|
||||
AppSecret: stored,
|
||||
Brand: core.LarkBrand(normalizeBrand(b.cfg.Accounts.App.Tenant)),
|
||||
Brand: core.ParseBrand(b.cfg.Accounts.App.Tenant),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -350,16 +350,6 @@ func sourceDisplayName(source string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeBrand applies .strip().lower() and defaults to "feishu".
|
||||
// Aligns with Hermes gateway/platforms/feishu.py:1119 behavior.
|
||||
func normalizeBrand(raw string) string {
|
||||
s := strings.TrimSpace(strings.ToLower(raw))
|
||||
if s == "" {
|
||||
return "feishu"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// resolveHermesEnvPath returns the path to Hermes's .env file.
|
||||
// Respects HERMES_HOME override; defaults to ~/.hermes/.env.
|
||||
//
|
||||
|
||||
@@ -146,6 +146,20 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
|
||||
}, nil
|
||||
}
|
||||
|
||||
// retryBrand decides whether app-registration polling must be retried on a
|
||||
// different brand. When the secret was not issued and the tenant's real brand
|
||||
// differs from the brand we polled, it returns the tenant's actual brand and true.
|
||||
func retryBrand(polled core.LarkBrand, tenantBrand string) (core.LarkBrand, bool) {
|
||||
if tenantBrand == "" {
|
||||
return polled, false
|
||||
}
|
||||
actual := core.ParseBrand(tenantBrand)
|
||||
if actual == polled {
|
||||
return polled, false
|
||||
}
|
||||
return actual, true
|
||||
}
|
||||
|
||||
// runCreateAppFlow runs the "create new app" flow via OpenClaw device flow.
|
||||
// If brandOverride is non-empty, skip the interactive brand selection.
|
||||
func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, msg *initMsg) (*configInitResult, error) {
|
||||
@@ -208,18 +222,19 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScanNonTTY)
|
||||
}
|
||||
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
result, err := larkauth.PollAppRegistration(ctx, httpClient, larkBrand, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Step 4: Handle Lark brand special case
|
||||
// If tenant_brand=lark and no client_secret, retry with lark brand endpoint
|
||||
if result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
|
||||
// fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant)
|
||||
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "lark endpoint retry failed: %v", err).WithCause(err)
|
||||
// Step 4: Retry on the tenant's actual brand if the secret was not issued on
|
||||
// the polled brand (e.g. configured feishu but the tenant is a lark tenant).
|
||||
if result.ClientSecret == "" && result.UserInfo != nil {
|
||||
if rb, ok := retryBrand(larkBrand, result.UserInfo.TenantBrand); ok {
|
||||
result, err = larkauth.PollAppRegistration(ctx, httpClient, rb, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "brand endpoint retry failed: %v", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,12 +242,12 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret")
|
||||
}
|
||||
|
||||
// Determine final brand from response
|
||||
// Determine the final brand from the tenant's reported brand, normalized the
|
||||
// same way as the retry decision (core.ParseBrand) so the saved brand always
|
||||
// matches the endpoint the credentials were issued on.
|
||||
finalBrand := larkBrand
|
||||
if result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
|
||||
finalBrand = core.BrandLark
|
||||
} else if result.UserInfo != nil && result.UserInfo.TenantBrand == "feishu" {
|
||||
finalBrand = core.BrandFeishu
|
||||
if result.UserInfo != nil && result.UserInfo.TenantBrand != "" {
|
||||
finalBrand = core.ParseBrand(result.UserInfo.TenantBrand)
|
||||
}
|
||||
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
|
||||
31
cmd/config/init_interactive_test.go
Normal file
31
cmd/config/init_interactive_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestRetryBrand(t *testing.T) {
|
||||
cases := []struct {
|
||||
polled core.LarkBrand
|
||||
tenant string
|
||||
wantBrand core.LarkBrand
|
||||
wantRetry bool
|
||||
}{
|
||||
{core.BrandFeishu, "lark", core.BrandLark, true},
|
||||
{core.BrandFeishu, "feishu", core.BrandFeishu, false},
|
||||
{core.BrandFeishu, "", core.BrandFeishu, false},
|
||||
{core.BrandLark, "feishu", core.BrandFeishu, true},
|
||||
{core.BrandLark, "lark", core.BrandLark, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
gotB, gotR := retryBrand(c.polled, c.tenant)
|
||||
if gotR != c.wantRetry || gotB != c.wantBrand {
|
||||
t.Errorf("retryBrand(%q,%q) = (%q,%v), want (%q,%v)", c.polled, c.tenant, gotB, gotR, c.wantBrand, c.wantRetry)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -679,7 +679,11 @@ func installTipsHelpFunc(root *cobra.Command) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
if service.PrepareMethodHelp(cmd) {
|
||||
if service.PrepareMethodHelp(cmd, embeddedSkillContent) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
if service.PrepareShortcutHelp(cmd, embeddedSkillContent) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -14,11 +14,13 @@ import (
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
@@ -103,6 +105,11 @@ func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
|
||||
}
|
||||
|
||||
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||
t.Helper()
|
||||
return buildStrictModeIntegrationRootCmdWithCatalog(t, f, nil)
|
||||
}
|
||||
|
||||
func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Factory, catalog *apicatalog.Catalog) *cobra.Command {
|
||||
t.Helper()
|
||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||
rootCmd.SilenceErrors = true
|
||||
@@ -113,7 +120,11 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
|
||||
}
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
if catalog != nil {
|
||||
service.RegisterServiceCommandsFromCatalog(context.Background(), rootCmd, f, *catalog)
|
||||
} else {
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
}
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
@@ -121,6 +132,29 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
func strictModeFixtureCatalog() apicatalog.Catalog {
|
||||
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{
|
||||
{
|
||||
Name: "fixture",
|
||||
ServicePath: "/open-apis/fixture/v1",
|
||||
Resources: map[string]meta.Resource{
|
||||
"things": {
|
||||
Methods: map[string]meta.Method{
|
||||
"create": {
|
||||
Path: "things",
|
||||
HTTPMethod: "POST",
|
||||
AccessTokens: []meta.Token{meta.TokenTenant},
|
||||
RequestBody: map[string]meta.Field{
|
||||
"name": {Type: "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
@@ -355,10 +389,11 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
|
||||
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
catalog := strictModeFixtureCatalog()
|
||||
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
|
||||
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--dry-run",
|
||||
})
|
||||
|
||||
if code != output.ExitValidation {
|
||||
|
||||
@@ -71,11 +71,18 @@ func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
}
|
||||
|
||||
// domainHelpBase returns the description to seed domain help with — the
|
||||
// hand-authored Long when present, else the Short — captured once into an
|
||||
// annotation so re-rendering reuses the pristine text instead of the
|
||||
// already-augmented Long.
|
||||
// hand-authored Long when present, else the Short.
|
||||
func domainHelpBase(cmd *cobra.Command) string {
|
||||
if base, ok := cmd.Annotations[domainBaseAnnotation]; ok {
|
||||
return captureHelpBase(cmd, domainBaseAnnotation)
|
||||
}
|
||||
|
||||
// captureHelpBase records a command's pristine lead text once — its
|
||||
// hand-authored Long, or Short when Long is empty — into the given annotation,
|
||||
// so lazy re-renders compose onto the original text instead of onto an
|
||||
// already-augmented Long. This is what lets a shortcut's PostMount-authored
|
||||
// Long survive: it becomes the base the affordance block is appended below.
|
||||
func captureHelpBase(cmd *cobra.Command, key string) string {
|
||||
if base, ok := cmd.Annotations[key]; ok {
|
||||
return base
|
||||
}
|
||||
base := cmd.Long
|
||||
@@ -85,7 +92,7 @@ func domainHelpBase(cmd *cobra.Command) string {
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[domainBaseAnnotation] = base
|
||||
cmd.Annotations[key] = base
|
||||
return base
|
||||
}
|
||||
|
||||
@@ -101,12 +108,12 @@ func methodLong(description, schemaPath, paramsOnly string) string {
|
||||
}
|
||||
|
||||
// Annotation keys PrepareMethodHelp reads to rebuild a method command's Long.
|
||||
// The affordance overlay coordinates live in cmdmeta (shared with shortcuts).
|
||||
const (
|
||||
affordanceServiceAnnotation = "affordance-service"
|
||||
affordanceMethodAnnotation = "affordance-method"
|
||||
schemaPathAnnotation = "method-schema-path"
|
||||
paramsOnlyAnnotation = "method-params-only"
|
||||
domainBaseAnnotation = "affordance-domain-base"
|
||||
schemaPathAnnotation = "method-schema-path"
|
||||
paramsOnlyAnnotation = "method-params-only"
|
||||
domainBaseAnnotation = "affordance-domain-base"
|
||||
shortcutBaseAnnotation = "affordance-shortcut-base"
|
||||
)
|
||||
|
||||
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
|
||||
@@ -115,10 +122,7 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
if service != "" && methodID != "" {
|
||||
cmd.Annotations[affordanceServiceAnnotation] = service
|
||||
cmd.Annotations[affordanceMethodAnnotation] = methodID
|
||||
}
|
||||
cmdmeta.SetAffordanceRef(cmd, service, methodID)
|
||||
cmd.Annotations[schemaPathAnnotation] = schemaPath
|
||||
if paramsOnly != "" {
|
||||
cmd.Annotations[paramsOnlyAnnotation] = paramsOnly
|
||||
@@ -128,8 +132,11 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params
|
||||
// PrepareMethodHelp rebuilds a generated method command's Long with the agent
|
||||
// guidance at the TOP (Risk, then the affordance block, then the schema
|
||||
// pointer), returning false for non-method commands. The overlay is parsed
|
||||
// here — only when help is rendered.
|
||||
func PrepareMethodHelp(cmd *cobra.Command) bool {
|
||||
// here — only when help is rendered. skillFS (nil-safe) gates the related-skill
|
||||
// pointers: each is emitted only when it resolves in the skill tree (see
|
||||
// affordance.SkillStatPath), so a typo or a build without embedded skills never
|
||||
// prints a `skills read` that cannot be opened.
|
||||
func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
ann := cmd.Annotations
|
||||
if ann == nil {
|
||||
return false
|
||||
@@ -141,22 +148,15 @@ func PrepareMethodHelp(cmd *cobra.Command) bool {
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(cmd.Short)
|
||||
if level, ok := cmdutil.GetRisk(cmd); ok {
|
||||
// --yes asserts the USER confirmed; the agent must not self-approve.
|
||||
if level == cmdutil.RiskHighRiskWrite {
|
||||
fmt.Fprintf(&b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "\n\nRisk: %s", level)
|
||||
}
|
||||
}
|
||||
writeRisk(&b, cmd)
|
||||
|
||||
var skills []string
|
||||
if raw, ok := affordanceRaw(cmd); ok {
|
||||
if block := renderAffordance(meta.Method{Affordance: raw}); block != "" {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(block)
|
||||
}
|
||||
if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok {
|
||||
if block := renderAffordanceValue(a); block != "" {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(block)
|
||||
}
|
||||
skills = a.Skills
|
||||
}
|
||||
}
|
||||
@@ -164,17 +164,95 @@ func PrepareMethodHelp(cmd *cobra.Command) bool {
|
||||
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
|
||||
b.WriteString(ann[paramsOnlyAnnotation])
|
||||
|
||||
if len(skills) > 0 {
|
||||
b.WriteString("\n\nWorkflow skill (end-to-end usage):")
|
||||
for _, s := range skills {
|
||||
fmt.Fprintf(&b, "\n lark-cli skills read %s", s)
|
||||
}
|
||||
}
|
||||
writeRelatedSkills(&b, skills, skillFS)
|
||||
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
}
|
||||
|
||||
// PrepareShortcutHelp composes a +-prefixed shortcut's Long from its affordance
|
||||
// overlay — the same top layout as method help (description, Risk, guidance
|
||||
// block, related skills) minus the schema pointer, which shortcuts have none
|
||||
// of. Returns false when the command is not a shortcut or carries no overlay
|
||||
// entry, so shortcuts without guidance keep the default help plus the bottom
|
||||
// risk/tips append.
|
||||
//
|
||||
// The lead is the command's pristine base (captureHelpBase): a shortcut that
|
||||
// set a hand-authored Long in PostMount (e.g. the docs shortcuts' "agents MUST
|
||||
// read the skill" directive) keeps it — the affordance block is appended below,
|
||||
// never clobbering it.
|
||||
//
|
||||
// Tips precedence (intentional, not a bug): the overlay's ### Tips win. The
|
||||
// shortcut's declarative Tips (the Go Tips field) are only a fallback used when
|
||||
// the overlay declares none; when the overlay has tips, the Go tips are dropped
|
||||
// (replaced, not merged) so tips never render twice. Authoring a ### Tips block
|
||||
// therefore silently retires that shortcut's Go Tips — consolidate into one.
|
||||
func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceShortcut {
|
||||
return false
|
||||
}
|
||||
raw, ok := affordanceRaw(cmd)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
a, ok := (meta.Method{Affordance: raw}).ParsedAffordance()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if len(a.Tips) == 0 {
|
||||
a.Tips = cmdutil.GetTips(cmd)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(captureHelpBase(cmd, shortcutBaseAnnotation))
|
||||
writeRisk(&b, cmd)
|
||||
if block := renderAffordanceValue(a); block != "" {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(block)
|
||||
}
|
||||
writeRelatedSkills(&b, a.Skills, skillFS)
|
||||
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
}
|
||||
|
||||
// writeRisk appends the "Risk: <level>" line, warning agents not to self-approve
|
||||
// high-risk-write commands. A no-op when the command has no risk annotation.
|
||||
func writeRisk(b *strings.Builder, cmd *cobra.Command) {
|
||||
level, ok := cmdutil.GetRisk(cmd)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// --yes asserts the USER confirmed; the agent must not self-approve.
|
||||
if level == cmdutil.RiskHighRiskWrite {
|
||||
fmt.Fprintf(b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
|
||||
} else {
|
||||
fmt.Fprintf(b, "\n\nRisk: %s", level)
|
||||
}
|
||||
}
|
||||
|
||||
// writeRelatedSkills appends the "Related skills" block for the entries that
|
||||
// exist in skillFS. Nothing is written when skillFS is nil or no entry resolves,
|
||||
// so help never prints a `skills read` pointer that cannot be opened.
|
||||
func writeRelatedSkills(b *strings.Builder, skills []string, skillFS fs.FS) {
|
||||
if skillFS == nil || len(skills) == 0 {
|
||||
return
|
||||
}
|
||||
var avail []string
|
||||
for _, s := range skills {
|
||||
if _, err := fs.Stat(skillFS, affordance.SkillStatPath(s)); err == nil {
|
||||
avail = append(avail, s)
|
||||
}
|
||||
}
|
||||
if len(avail) == 0 {
|
||||
return
|
||||
}
|
||||
b.WriteString("\n\nRelated skills (read for end-to-end usage):")
|
||||
for _, s := range avail {
|
||||
fmt.Fprintf(b, "\n lark-cli skills read %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// affordanceLookup is the overlay source; a package var so tests can inject.
|
||||
var affordanceLookup = affordance.For
|
||||
|
||||
@@ -189,12 +267,8 @@ func RenderAffordanceForCmd(cmd *cobra.Command) string {
|
||||
}
|
||||
|
||||
func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) {
|
||||
if cmd.Annotations == nil {
|
||||
return nil, false
|
||||
}
|
||||
service := cmd.Annotations[affordanceServiceAnnotation]
|
||||
methodID := cmd.Annotations[affordanceMethodAnnotation]
|
||||
if service == "" || methodID == "" {
|
||||
service, methodID, ok := cmdmeta.AffordanceRef(cmd)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return affordanceLookup(service, methodID)
|
||||
@@ -207,7 +281,13 @@ func renderAffordance(m meta.Method) string {
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return renderAffordanceValue(a)
|
||||
}
|
||||
|
||||
// renderAffordanceValue renders an already-parsed affordance. Split from
|
||||
// renderAffordance so callers can render a value they have adjusted first (e.g.
|
||||
// a shortcut folding its declarative tips into an overlay that has none).
|
||||
func renderAffordanceValue(a meta.Affordance) string {
|
||||
var sections []string
|
||||
bullets := func(title string, items []string) {
|
||||
var nonEmpty []string
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -70,8 +71,8 @@ func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
|
||||
t.Errorf("affordance must not be baked into Long (lazy):\n%s", cmd.Long)
|
||||
}
|
||||
// The lookup ref is recorded so the help path can resolve it later.
|
||||
if cmd.Annotations[affordanceServiceAnnotation] != "im" || cmd.Annotations[affordanceMethodAnnotation] != "messages.create" {
|
||||
t.Errorf("affordance ref annotations = %v, want im/messages.create", cmd.Annotations)
|
||||
if svc, method, ok := cmdmeta.AffordanceRef(cmd); !ok || svc != "im" || method != "messages.create" {
|
||||
t.Errorf("affordance ref = %q/%q (ok=%v), want im/messages.create", svc, method, ok)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +120,7 @@ func TestPrepareMethodHelp(t *testing.T) {
|
||||
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
|
||||
|
||||
if !PrepareMethodHelp(cmd) {
|
||||
if !PrepareMethodHelp(cmd, nil) {
|
||||
t.Fatal("PrepareMethodHelp returned false for a service-method command")
|
||||
}
|
||||
long := cmd.Long
|
||||
@@ -136,11 +137,133 @@ func TestPrepareMethodHelp(t *testing.T) {
|
||||
}
|
||||
|
||||
// A non-service command (no schema-path annotation) is left untouched.
|
||||
if PrepareMethodHelp(&cobra.Command{Use: "plain"}) {
|
||||
if PrepareMethodHelp(&cobra.Command{Use: "plain"}, nil) {
|
||||
t.Error("PrepareMethodHelp should return false for a non-service command")
|
||||
}
|
||||
}
|
||||
|
||||
// PrepareShortcutHelp composes a shortcut's Long from its overlay with the same
|
||||
// top layout as method help (no schema pointer), folding declarative tips when
|
||||
// the overlay declares none, and leaves shortcuts without an overlay entry (and
|
||||
// non-shortcut commands) for the default help path.
|
||||
func TestPrepareShortcutHelp(t *testing.T) {
|
||||
orig := affordanceLookup
|
||||
t.Cleanup(func() { affordanceLookup = orig })
|
||||
affordanceLookup = func(service, methodID string) (json.RawMessage, bool) {
|
||||
if service == "calendar" && methodID == "+create" {
|
||||
return json.RawMessage(`{"use_when":["高层创建日程"],"skills":["lark-calendar"]}`), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
sc := &cobra.Command{Use: "+create", Short: "Create an event"}
|
||||
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
|
||||
cmdmeta.SetAffordanceRef(sc, "calendar", "+create")
|
||||
cmdutil.SetRisk(sc, "write")
|
||||
cmdutil.SetTips(sc, []string{"start/end 收 ISO 8601"})
|
||||
|
||||
if !PrepareShortcutHelp(sc, nil) {
|
||||
t.Fatal("PrepareShortcutHelp returned false for a shortcut with an overlay")
|
||||
}
|
||||
for _, want := range []string{"Create an event", "Risk: write", "When to use:", "高层创建日程", "Tips:", "start/end 收 ISO 8601"} {
|
||||
if !strings.Contains(sc.Long, want) {
|
||||
t.Errorf("shortcut Long missing %q:\n%s", want, sc.Long)
|
||||
}
|
||||
}
|
||||
if strings.Contains(sc.Long, "Full parameter schema:") {
|
||||
t.Errorf("shortcut Long must not carry a schema pointer:\n%s", sc.Long)
|
||||
}
|
||||
|
||||
// No overlay entry -> leave it for the default help path.
|
||||
bare := &cobra.Command{Use: "+bare", Short: "x"}
|
||||
cmdmeta.SetSource(bare, cmdmeta.SourceShortcut, false)
|
||||
cmdmeta.SetAffordanceRef(bare, "calendar", "+bare")
|
||||
if PrepareShortcutHelp(bare, nil) {
|
||||
t.Error("PrepareShortcutHelp should return false when the shortcut has no overlay")
|
||||
}
|
||||
|
||||
// Non-shortcut source is ignored even with a ref.
|
||||
notSc := &cobra.Command{Use: "create", Short: "x"}
|
||||
cmdmeta.SetAffordanceRef(notSc, "calendar", "+create")
|
||||
if PrepareShortcutHelp(notSc, nil) {
|
||||
t.Error("PrepareShortcutHelp should return false for a non-shortcut command")
|
||||
}
|
||||
}
|
||||
|
||||
// Related-skill pointers are gated on existence: a skill that resolves in the
|
||||
// skill FS renders, a typo is dropped (never print an unopenable `skills read`),
|
||||
// and a nil skill FS suppresses the whole block.
|
||||
func TestRelatedSkillsStatGating(t *testing.T) {
|
||||
orig := affordanceLookup
|
||||
t.Cleanup(func() { affordanceLookup = orig })
|
||||
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
|
||||
return json.RawMessage(`{"use_when":["x"],"skills":["lark-real","lark-typo","lark-real/references/deep.md","lark-real/references/missing.md"]}`), true
|
||||
}
|
||||
skillFS := fstest.MapFS{
|
||||
"lark-real/SKILL.md": {Data: []byte("# real")},
|
||||
"lark-real/references/deep.md": {Data: []byte("# deep")},
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "d"}
|
||||
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
|
||||
if !PrepareMethodHelp(cmd, skillFS) {
|
||||
t.Fatal("PrepareMethodHelp returned false")
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "skills read lark-real\n") {
|
||||
t.Errorf("existing bare-name skill should render on its own line; got:\n%s", cmd.Long)
|
||||
}
|
||||
if strings.Contains(cmd.Long, "lark-typo") {
|
||||
t.Errorf("nonexistent skill must be dropped, not printed as an unopenable pointer; got:\n%s", cmd.Long)
|
||||
}
|
||||
// A name/relpath reference to an existing file renders; a missing one drops.
|
||||
if !strings.Contains(cmd.Long, "skills read lark-real/references/deep.md") {
|
||||
t.Errorf("existing reference entry should render; got:\n%s", cmd.Long)
|
||||
}
|
||||
if strings.Contains(cmd.Long, "references/missing.md") {
|
||||
t.Errorf("nonexistent reference must be dropped; got:\n%s", cmd.Long)
|
||||
}
|
||||
|
||||
// nil skill FS: the whole Related-skills block is suppressed.
|
||||
bare := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
|
||||
PrepareMethodHelp(bare, nil)
|
||||
if strings.Contains(bare.Long, "Related skills") {
|
||||
t.Errorf("nil skillFS should suppress the skills block; got:\n%s", bare.Long)
|
||||
}
|
||||
}
|
||||
|
||||
// A shortcut that set a hand-authored Long (as the docs shortcuts do in
|
||||
// PostMount) keeps it as the lead: the affordance block is appended below, not
|
||||
// clobbered, and re-rendering does not double-append.
|
||||
func TestPrepareShortcutHelp_PreservesPostMountLong(t *testing.T) {
|
||||
orig := affordanceLookup
|
||||
t.Cleanup(func() { affordanceLookup = orig })
|
||||
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
|
||||
return json.RawMessage(`{"use_when":["高层创建日程"]}`), true
|
||||
}
|
||||
|
||||
const authored = "Custom docs help. AI agents MUST read the skill first."
|
||||
sc := &cobra.Command{Use: "+create", Short: "Create", Long: authored}
|
||||
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
|
||||
cmdmeta.SetAffordanceRef(sc, "calendar", "+create")
|
||||
|
||||
if !PrepareShortcutHelp(sc, nil) {
|
||||
t.Fatal("PrepareShortcutHelp returned false for a shortcut with an overlay")
|
||||
}
|
||||
if !strings.HasPrefix(sc.Long, authored) {
|
||||
t.Errorf("hand-authored Long must lead, not be clobbered; got:\n%s", sc.Long)
|
||||
}
|
||||
if !strings.Contains(sc.Long, "When to use:") {
|
||||
t.Errorf("affordance block should be appended below the base; got:\n%s", sc.Long)
|
||||
}
|
||||
// Re-render must reuse the captured base, not append the block twice.
|
||||
PrepareShortcutHelp(sc, nil)
|
||||
if n := strings.Count(sc.Long, "When to use:"); n != 1 {
|
||||
t.Errorf("affordance appended %d times across re-renders, want 1:\n%s", n, sc.Long)
|
||||
}
|
||||
}
|
||||
|
||||
// domainCmd wires a domain-tagged command with a subcommand under a root, the
|
||||
// shape PrepareDomainHelp expects.
|
||||
func domainCmd(short, long string) *cobra.Command {
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -1132,6 +1136,63 @@ func TestDetectFileFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// parseMultipartFilenames drives one service-method --file upload through the
|
||||
// mock transport and returns a map of field name -> part filename parsed from
|
||||
// the captured multipart body. Mirrors cmd/api's helper of the same name
|
||||
// (inlined here rather than shared, since the two live in different packages)
|
||||
// to give BuildFormdata's shared local-file fix a second real entry-point
|
||||
// covering it.
|
||||
func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) map[string]string {
|
||||
t.Helper()
|
||||
ct := stub.CapturedHeaders.Get("Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(ct)
|
||||
if err != nil {
|
||||
t.Fatalf("parse Content-Type %q: %v", ct, err)
|
||||
}
|
||||
if !strings.HasPrefix(mediaType, "multipart/") {
|
||||
t.Fatalf("Content-Type = %q, want multipart/*", mediaType)
|
||||
}
|
||||
filenames := map[string]string{}
|
||||
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if fn := part.FileName(); fn != "" {
|
||||
filenames[part.FormName()] = fn
|
||||
}
|
||||
}
|
||||
return filenames
|
||||
}
|
||||
|
||||
func TestServiceMethod_FileUpload_PreservesFilename(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, testConfig)
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("fake-image"), 0600); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
URL: "/open-apis/im/v1/images",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"image_key": "img_xxx"}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), imImageMethod(), "create", "images", nil)
|
||||
cmd.SetArgs([]string{"--file", "photo.jpg", "--data", `{"image_type":"message"}`, "--as", "bot"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
filenames := parseMultipartFilenames(t, stub)
|
||||
if got := filenames["image"]; got != "photo.jpg" {
|
||||
t.Fatalf("part filename for field %q = %q, want %q", "image", got, "photo.jpg")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/selfupdate"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
@@ -125,6 +126,10 @@ func updateRun(opts *UpdateOptions) error {
|
||||
io := opts.Factory.IOStreams
|
||||
cur := currentVersion()
|
||||
updater := newUpdater()
|
||||
updater.Brand = core.BrandFeishu
|
||||
if cfg, err := opts.Factory.Config(); err == nil && cfg != nil {
|
||||
updater.Brand = cfg.Brand
|
||||
}
|
||||
|
||||
if !opts.Check {
|
||||
updater.CleanupStaleFiles()
|
||||
|
||||
6
extension/credential/env/env.go
vendored
6
extension/credential/env/env.go
vendored
@@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
@@ -41,10 +42,7 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
|
||||
Reason: envvars.CliAppID + " is set but no app secret or access token is available",
|
||||
}
|
||||
}
|
||||
brand := credential.Brand(os.Getenv(envvars.CliBrand))
|
||||
if brand == "" {
|
||||
brand = credential.BrandFeishu
|
||||
}
|
||||
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
|
||||
acct := &credential.Account{AppID: appID, AppSecret: appSecret, Brand: brand}
|
||||
|
||||
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
@@ -58,10 +59,7 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
|
||||
}
|
||||
}
|
||||
|
||||
brand := credential.Brand(os.Getenv(envvars.CliBrand))
|
||||
if brand == "" {
|
||||
brand = credential.BrandFeishu
|
||||
}
|
||||
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
|
||||
|
||||
acct := &credential.Account{
|
||||
AppID: appID,
|
||||
|
||||
2
go.mod
2
go.mod
@@ -10,7 +10,7 @@ require (
|
||||
github.com/gofrs/flock v0.8.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/itchyny/gojq v0.12.17
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.7.2
|
||||
github.com/sergi/go-diff v1.4.0
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/smartystreets/goconvey v1.8.1
|
||||
|
||||
4
go.sum
4
go.sum
@@ -79,8 +79,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.7.2 h1:SCIcXHRmtpQbiaZgDTDi1NYNCzrusi7ePJBR9uKoduE=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.7.2/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
|
||||
@@ -83,10 +83,9 @@ func commandFormResolver(service string) func(string) string {
|
||||
}
|
||||
}
|
||||
return func(h string) string {
|
||||
h = strings.TrimSpace(h)
|
||||
if id, ok := byForm[h]; ok {
|
||||
if id, ok := byForm[strings.TrimSpace(h)]; ok {
|
||||
return id
|
||||
}
|
||||
return strings.ReplaceAll(h, " ", ".")
|
||||
return headingToKey(h) // one home for the shortcut/method key convention
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
)
|
||||
|
||||
// fixtureMD is a minimal affordance source: two methods, each with a lead
|
||||
@@ -84,3 +86,38 @@ func TestParseDomainMD_ParagraphNotDropped(t *testing.T) {
|
||||
t.Errorf("custom-section paragraph not flowed through: %+v", a.Extensions)
|
||||
}
|
||||
}
|
||||
|
||||
// The ### Skills section merges with the domain `> skill:` default: domain
|
||||
// first, then per-command entries, de-duplicated. A command with no ### Skills
|
||||
// still inherits the domain default.
|
||||
func TestParseDomainMD_SkillsMerge(t *testing.T) {
|
||||
md := "# d\n> skill: lark-d\n\n" +
|
||||
"## foo\ndoes foo.\n\n### Skills\n- lark-workflow\n- lark-d\n\n" + // lark-d duplicates the domain default
|
||||
"## bar\ndoes bar.\n"
|
||||
got := parseDomainMD([]byte(md), nil)
|
||||
|
||||
if a := got["foo"]; len(a.Skills) != 2 || a.Skills[0] != "lark-d" || a.Skills[1] != "lark-workflow" {
|
||||
t.Errorf("foo skills = %v, want [lark-d lark-workflow] (domain first, deduped)", a.Skills)
|
||||
}
|
||||
if a := got["bar"]; len(a.Skills) != 1 || a.Skills[0] != "lark-d" {
|
||||
t.Errorf("bar skills = %v, want [lark-d] (domain default inherited)", a.Skills)
|
||||
}
|
||||
}
|
||||
|
||||
// A +-prefixed shortcut heading keys verbatim (no space->dot folding), so it
|
||||
// matches the shortcut command as mounted.
|
||||
func TestParseDomainMD_ShortcutHeadingVerbatim(t *testing.T) {
|
||||
md := "# d\n\n## +create\ncreate via shortcut.\n"
|
||||
got := parseDomainMD([]byte(md), nil)
|
||||
if _, ok := got["+create"]; !ok {
|
||||
t.Errorf("shortcut heading should key as %q; got keys %v", "+create", keysOf(got))
|
||||
}
|
||||
}
|
||||
|
||||
func keysOf(m map[string]meta.Affordance) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
// ### Prerequisites -> prerequisites (a "…来自 [[x]]" link is a sequence edge)
|
||||
// ### Tips -> tips
|
||||
// ### Examples -> examples: **description** + a ```fenced``` command
|
||||
// ### Skills -> skills: bullet skill names, added to the domain default
|
||||
// ### <other> -> extensions[] (custom section, flows through verbatim)
|
||||
// [[cmd]] -> a command reference, rendered as `cmd`
|
||||
//
|
||||
@@ -34,16 +35,56 @@ var standardSection = map[string]string{
|
||||
"Prerequisites": "prerequisites",
|
||||
"Tips": "tips",
|
||||
"Examples": "examples",
|
||||
"Skills": "skills",
|
||||
}
|
||||
|
||||
// mergeSkills returns the domain-default skill followed by a command's own skill
|
||||
// entries, de-duplicated in author order and empties dropped. Backticks (left by
|
||||
// the shared bullet parse) are stripped so each entry is a bare skill name.
|
||||
func mergeSkills(domain string, extra []string) []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
add := func(s string) {
|
||||
s = strings.Trim(strings.TrimSpace(s), "`")
|
||||
if s == "" || seen[s] {
|
||||
return
|
||||
}
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
add(domain)
|
||||
for _, s := range extra {
|
||||
add(s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func linkToBacktick(s string) string { return mdLink.ReplaceAllString(s, "`$1`") }
|
||||
|
||||
// SkillStatPath maps a `### Skills` entry to the path (relative to the skill
|
||||
// tree) whose existence gates it: a bare skill name resolves to its SKILL.md,
|
||||
// while an entry containing a slash is a name/relative-path reference (e.g.
|
||||
// "lark-contact/references/lark-contact-search-user.md") and resolves to that
|
||||
// path directly. Both render as `lark-cli skills read <entry>` — the slash form
|
||||
// skills read already accepts — so a per-command entry can point at that
|
||||
// command's own reference file, not just re-point the domain skill.
|
||||
func SkillStatPath(entry string) string {
|
||||
if strings.Contains(entry, "/") {
|
||||
return entry
|
||||
}
|
||||
return entry + "/SKILL.md"
|
||||
}
|
||||
|
||||
// headingToKey maps a command heading ("instances get") to its affordance key
|
||||
// ("instances.get"). The space→dot rule holds where the command form matches
|
||||
// the method id; domains whose resource names differ (e.g. plural "messages"
|
||||
// vs id segment "message") need the registry's authoritative resource↔id table.
|
||||
func headingToKey(h string) string {
|
||||
return strings.ReplaceAll(strings.TrimSpace(h), " ", ".")
|
||||
h = strings.TrimSpace(h)
|
||||
if strings.HasPrefix(h, "+") { // shortcut command: key is the command verbatim
|
||||
return h
|
||||
}
|
||||
return strings.ReplaceAll(h, " ", ".")
|
||||
}
|
||||
|
||||
type mdSection struct {
|
||||
@@ -82,6 +123,7 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
|
||||
if len(useWhen) > 0 {
|
||||
a.UseWhen = useWhen
|
||||
}
|
||||
var perCmdSkills []string
|
||||
for _, s := range secs {
|
||||
switch standardSection[s.label] {
|
||||
case "avoid_when":
|
||||
@@ -92,12 +134,14 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
|
||||
a.Tips = s.items
|
||||
case "examples":
|
||||
a.Examples = s.cases
|
||||
case "skills":
|
||||
perCmdSkills = s.items
|
||||
default:
|
||||
a.Extensions = append(a.Extensions, meta.AffordanceSection{Label: s.label, Items: s.items})
|
||||
}
|
||||
}
|
||||
if skill != "" {
|
||||
a.Skills = []string{skill}
|
||||
if s := mergeSkills(skill, perCmdSkills); len(s) > 0 {
|
||||
a.Skills = s
|
||||
}
|
||||
out[curKey] = a
|
||||
}
|
||||
@@ -157,7 +201,7 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
|
||||
inFence, fence = true, nil
|
||||
} else {
|
||||
inFence = false
|
||||
sec.cases = append(sec.cases, meta.AffordanceCase{Description: pending, Command: strings.Join(fence, "\n")})
|
||||
sec.cases = append(sec.cases, meta.AffordanceCase{Description: linkToBacktick(pending), Command: strings.Join(fence, "\n")})
|
||||
pending = ""
|
||||
}
|
||||
continue
|
||||
|
||||
@@ -39,6 +39,13 @@ type AppRegUserInfo struct {
|
||||
TenantBrand string // "feishu" or "lark"
|
||||
}
|
||||
|
||||
// appRegistrationEndpoint returns the accounts-domain registration endpoint for
|
||||
// the given brand. Both the begin and poll actions target the same brand so the
|
||||
// device_code issuer and poller domains always match.
|
||||
func appRegistrationEndpoint(brand core.LarkBrand) string {
|
||||
return core.ResolveEndpoints(brand).Accounts + PathAppRegistration
|
||||
}
|
||||
|
||||
// RequestAppRegistration initiates the app registration device flow.
|
||||
func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOut io.Writer) (*AppRegistrationResponse, error) {
|
||||
if errOut == nil {
|
||||
@@ -46,8 +53,7 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOu
|
||||
}
|
||||
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
regEp := core.ResolveEndpoints(core.BrandFeishu) // registration begin always uses feishu
|
||||
endpoint := regEp.Accounts + PathAppRegistration
|
||||
endpoint := appRegistrationEndpoint(brand)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("action", "begin")
|
||||
@@ -129,8 +135,7 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor
|
||||
const maxPollInterval = 60
|
||||
const maxPollAttempts = 200
|
||||
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
endpoint := ep.Accounts + PathAppRegistration
|
||||
endpoint := appRegistrationEndpoint(brand)
|
||||
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
currentInterval := interval
|
||||
attempts := 0
|
||||
|
||||
@@ -6,6 +6,7 @@ package auth
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
@@ -31,3 +32,18 @@ func Test_BuildVerificationURL(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppRegistrationEndpoint(t *testing.T) {
|
||||
cases := []struct {
|
||||
brand core.LarkBrand
|
||||
want string
|
||||
}{
|
||||
{core.BrandFeishu, "https://accounts.feishu.cn" + PathAppRegistration},
|
||||
{core.BrandLark, "https://accounts.larksuite.com" + PathAppRegistration},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := appRegistrationEndpoint(c.brand); got != c.want {
|
||||
t.Errorf("brand %q: endpoint = %q, want %q", c.brand, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package cmdmeta is the single source of truth for command metadata that the
|
||||
// policy engine and the hook selector both consume. It wraps the existing
|
||||
// cmdutil annotations (risk_level, supportedIdentities) and adds the
|
||||
// "domain" axis that the hook selector and Rule path globs need.
|
||||
// policy engine, the hook selector, and help rendering consume. It wraps the
|
||||
// existing cmdutil annotations (risk_level, supportedIdentities) and adds the
|
||||
// "domain" axis that the hook selector and Rule path globs need, plus the
|
||||
// affordance ref (service, method id) that lets service-method and shortcut
|
||||
// help share one usage-guidance lookup path.
|
||||
//
|
||||
// Three axes:
|
||||
//
|
||||
@@ -51,6 +53,12 @@ const (
|
||||
|
||||
sourceAnnotationKey = "cmdmeta.source"
|
||||
generatedAnnotationKey = "cmdmeta.generated"
|
||||
|
||||
// affordance{Service,Method}Key locate the command's usage-guidance overlay
|
||||
// entry (see internal/affordance). Both service-method commands and
|
||||
// +-prefixed shortcuts set these so help rendering shares one lookup path.
|
||||
affordanceServiceKey = "cmdmeta.affordance.service"
|
||||
affordanceMethodKey = "cmdmeta.affordance.method"
|
||||
)
|
||||
|
||||
// Meta groups the three command-level metadata axes consumed by the policy
|
||||
@@ -125,6 +133,35 @@ func SetSource(cmd *cobra.Command, source Source, generated bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetAffordanceRef records which affordance overlay entry (service, method id)
|
||||
// a command maps to, so help rendering can look up its usage guidance. Stored
|
||||
// on the command itself (no inheritance): each method / shortcut owns its ref.
|
||||
// A no-op if either coordinate is empty.
|
||||
func SetAffordanceRef(cmd *cobra.Command, service, method string) {
|
||||
if service == "" || method == "" {
|
||||
return
|
||||
}
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[affordanceServiceKey] = service
|
||||
cmd.Annotations[affordanceMethodKey] = method
|
||||
}
|
||||
|
||||
// AffordanceRef returns the command's own affordance overlay coordinates.
|
||||
// ok is false when the command carries no ref.
|
||||
func AffordanceRef(cmd *cobra.Command) (service, method string, ok bool) {
|
||||
if cmd.Annotations == nil {
|
||||
return "", "", false
|
||||
}
|
||||
service = cmd.Annotations[affordanceServiceKey]
|
||||
method = cmd.Annotations[affordanceMethodKey]
|
||||
if service == "" || method == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return service, method, true
|
||||
}
|
||||
|
||||
// Domain returns the nearest-ancestor domain for the command. Empty string
|
||||
// when no ancestor has the annotation -- this is the "unknown" state the
|
||||
// policy engine must treat as ALLOW.
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -128,7 +129,7 @@ func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin boo
|
||||
WithParam("--file").
|
||||
WithCause(err)
|
||||
}
|
||||
fd.AddFile(fieldName, bytes.NewReader(data))
|
||||
fd.AddFileWithName(fieldName, filepath.Base(filePath), bytes.NewReader(data))
|
||||
}
|
||||
|
||||
// Add top-level JSON keys as text form fields.
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
|
||||
package core
|
||||
|
||||
import "strings"
|
||||
|
||||
// LarkBrand represents the Lark platform brand.
|
||||
// "feishu" targets China-mainland, "lark" targets international.
|
||||
// Any other string is treated as a custom base URL.
|
||||
// Any unrecognized value is normalized to BrandFeishu (the default brand).
|
||||
type LarkBrand string
|
||||
|
||||
const (
|
||||
@@ -14,9 +16,10 @@ const (
|
||||
)
|
||||
|
||||
// ParseBrand normalizes a brand string to a LarkBrand constant.
|
||||
// Unrecognized values default to BrandFeishu.
|
||||
// Matching is case-insensitive and whitespace-tolerant; any value other than
|
||||
// "lark" (after trim + lowercase) normalizes to BrandFeishu.
|
||||
func ParseBrand(value string) LarkBrand {
|
||||
if value == "lark" {
|
||||
if strings.ToLower(strings.TrimSpace(value)) == "lark" {
|
||||
return BrandLark
|
||||
}
|
||||
return BrandFeishu
|
||||
|
||||
@@ -57,3 +57,23 @@ func TestResolveOpenBaseURL(t *testing.T) {
|
||||
t.Errorf("ResolveOpenBaseURL(lark) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBrand(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want LarkBrand
|
||||
}{
|
||||
{"", BrandFeishu},
|
||||
{"feishu", BrandFeishu},
|
||||
{"lark", BrandLark},
|
||||
{"LARK", BrandLark},
|
||||
{" lark ", BrandLark},
|
||||
{"Lark", BrandLark},
|
||||
{"xyz", BrandFeishu},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ParseBrand(c.in); got != c.want {
|
||||
t.Errorf("ParseBrand(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ func TestBuildAPIError_ExitCodeMatrix(t *testing.T) {
|
||||
{"230027 user_not_authorized", 230027, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 3, "PermissionError"},
|
||||
{"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.SubtypePermissionDenied, 3, "PermissionError"},
|
||||
{"1470400 task_invalid_params", 1470400, errs.CategoryAPI, errs.SubtypeInvalidParameters, 1, "APIError"},
|
||||
{"1062507 drive_parent_sibling_limit", 1062507, errs.CategoryAPI, errs.SubtypeQuotaExceeded, 1, "APIError"},
|
||||
{"99991400 rate_limit", 99991400, errs.CategoryAPI, errs.SubtypeRateLimit, 1, "APIError"},
|
||||
{"99991661 token_missing", 99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, 3, "AuthenticationError"},
|
||||
{"21000 challenge_required", 21000, errs.CategoryPolicy, errs.Subtype("challenge_required"), 6, "SecurityPolicyError"},
|
||||
|
||||
@@ -17,6 +17,7 @@ var driveCodeMeta = map[int]CodeMeta{
|
||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1061101: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file quota exceeded
|
||||
1062507: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // parent folder child count limit exceeded
|
||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
||||
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
||||
|
||||
@@ -8,8 +8,11 @@ import "encoding/json"
|
||||
// Affordance is the typed usage guidance overlaid on a method. It is the single
|
||||
// model the envelope renderer and the command help both parse, so the
|
||||
// vocabulary is defined once; the JSON tags double as the envelope wire shape.
|
||||
// Skills entries are skill names (or name/path) rendered as runnable
|
||||
// `lark-cli skills read <entry>` pointers.
|
||||
// Skills entries are either a bare skill name (e.g. "lark-doc") or a
|
||||
// name/relative-path reference (e.g. "lark-contact/references/x.md"); both
|
||||
// render as runnable `lark-cli skills read <entry>` pointers. Help validates
|
||||
// each against the embedded skill tree (a name → its SKILL.md, a reference →
|
||||
// that path) and drops any that do not resolve.
|
||||
type Affordance struct {
|
||||
UseWhen []string `json:"use_when,omitempty"`
|
||||
AvoidWhen []string `json:"avoid_when,omitempty"`
|
||||
|
||||
@@ -75,13 +75,7 @@ func remoteMetaURL(version string) string {
|
||||
if testMetaURL != "" {
|
||||
return testMetaURL
|
||||
}
|
||||
var base string
|
||||
switch configuredBrand {
|
||||
case core.BrandLark:
|
||||
base = "https://open.larksuite.com/api/tools/open/api_definition"
|
||||
default:
|
||||
base = "https://open.feishu.cn/api/tools/open/api_definition"
|
||||
}
|
||||
base := core.ResolveEndpoints(configuredBrand).Open + "/api/tools/open/api_definition"
|
||||
q := "protocol=meta&client_version=" + url.QueryEscape(build.Version)
|
||||
if version != "" {
|
||||
q += "&data_version=" + url.QueryEscape(version)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
@@ -49,7 +50,10 @@ const (
|
||||
|
||||
var (
|
||||
skillsIndexFetchTimeout = 10 * time.Second
|
||||
officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json"
|
||||
// officialSkillsIndexURL, when non-empty, overrides the brand-derived skills
|
||||
// index URL. It is empty in production (the URL is resolved from the brand
|
||||
// via core.ResolveEndpoints) and set by tests to a local server.
|
||||
officialSkillsIndexURL = ""
|
||||
)
|
||||
|
||||
// DetectResult holds installation detection results.
|
||||
@@ -101,6 +105,10 @@ func (r *NpmResult) CombinedOutput() string {
|
||||
// Override DetectOverride / NpmInstallOverride / SkillsCommandOverride / VerifyOverride
|
||||
// / RestoreAvailableOverride for testing.
|
||||
type Updater struct {
|
||||
// Brand selects the endpoint set for skills index/source resolution.
|
||||
// Zero value resolves to the feishu default via core.ResolveEndpoints.
|
||||
Brand core.LarkBrand
|
||||
|
||||
DetectOverride func() DetectResult
|
||||
NpmInstallOverride func(version string) *NpmResult
|
||||
PnpmInstallOverride func(version string) *NpmResult
|
||||
@@ -129,6 +137,21 @@ type Updater struct {
|
||||
// New creates an Updater with default (real) behavior.
|
||||
func New() *Updater { return &Updater{} }
|
||||
|
||||
// skillsIndexURL returns the well-known skills index URL for the Updater's
|
||||
// brand. A non-empty officialSkillsIndexURL (tests only) takes precedence.
|
||||
func (u *Updater) skillsIndexURL() string {
|
||||
if officialSkillsIndexURL != "" {
|
||||
return officialSkillsIndexURL
|
||||
}
|
||||
return core.ResolveEndpoints(u.Brand).Open + "/.well-known/skills/index.json"
|
||||
}
|
||||
|
||||
// skillsSource returns the skills repository source host for the Updater's
|
||||
// brand, used as the `npx skills add <source>` argument.
|
||||
func (u *Updater) skillsSource() string {
|
||||
return core.ResolveEndpoints(u.Brand).Open
|
||||
}
|
||||
|
||||
// DetectInstallMethod determines how the CLI was installed and whether the
|
||||
// owning package manager is available for auto-update.
|
||||
func (u *Updater) DetectInstallMethod() DetectResult {
|
||||
@@ -258,7 +281,7 @@ func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), skillsIndexFetchTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, officialSkillsIndexURL, nil)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.skillsIndexURL(), nil)
|
||||
if err != nil {
|
||||
r.Err = err
|
||||
return r
|
||||
@@ -297,7 +320,7 @@ func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
||||
}
|
||||
|
||||
func (u *Updater) ListOfficialSkills() *NpmResult {
|
||||
r := u.runSkillsListOfficial("https://open.feishu.cn")
|
||||
r := u.runSkillsListOfficial(u.skillsSource())
|
||||
if r.Err != nil {
|
||||
r = u.runSkillsListOfficial("larksuite/cli")
|
||||
}
|
||||
@@ -313,7 +336,7 @@ func (u *Updater) ListGlobalSkillsJSON() *NpmResult {
|
||||
}
|
||||
|
||||
func (u *Updater) InstallSkill(nameList []string) *NpmResult {
|
||||
r := u.runSkillsInstall("https://open.feishu.cn", nameList)
|
||||
r := u.runSkillsInstall(u.skillsSource(), nameList)
|
||||
if r.Err != nil {
|
||||
r = u.runSkillsInstall("larksuite/cli", nameList)
|
||||
}
|
||||
@@ -321,7 +344,7 @@ func (u *Updater) InstallSkill(nameList []string) *NpmResult {
|
||||
}
|
||||
|
||||
func (u *Updater) InstallAllSkills() *NpmResult {
|
||||
r := u.runSkillsAdd("https://open.feishu.cn")
|
||||
r := u.runSkillsAdd(u.skillsSource())
|
||||
if r.Err != nil {
|
||||
r = u.runSkillsAdd("larksuite/cli")
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -515,3 +516,23 @@ func TestDetectInstallMethod_Caches(t *testing.T) {
|
||||
t.Errorf("expected cached pnpm result to be returned, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsBrandHosts(t *testing.T) {
|
||||
cases := []struct {
|
||||
brand core.LarkBrand
|
||||
wantIndex string
|
||||
wantSource string
|
||||
}{
|
||||
{core.BrandFeishu, "https://open.feishu.cn/.well-known/skills/index.json", "https://open.feishu.cn"},
|
||||
{core.BrandLark, "https://open.larksuite.com/.well-known/skills/index.json", "https://open.larksuite.com"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
u := &Updater{Brand: c.brand}
|
||||
if got := u.skillsIndexURL(); got != c.wantIndex {
|
||||
t.Errorf("brand %q: skillsIndexURL = %q, want %q", c.brand, got, c.wantIndex)
|
||||
}
|
||||
if got := u.skillsSource(); got != c.wantSource {
|
||||
t.Errorf("brand %q: skillsSource = %q, want %q", c.brand, got, c.wantSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -23,7 +24,6 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
|
||||
cacheTTL = 24 * time.Hour
|
||||
fetchTimeout = 15 * time.Second
|
||||
stateFile = "update-state.json"
|
||||
@@ -202,8 +202,25 @@ type npmLatestResponse struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// resolveRegistryURL builds the npm registry metadata URL for @larksuite/cli.
|
||||
// It honors npm_config_registry when set to a valid https registry (npm
|
||||
// ecosystem convention), otherwise falls back to the public npmjs default.
|
||||
// This request carries no Lark credentials — it is package-distribution only.
|
||||
func resolveRegistryURL() string {
|
||||
const pkgPath = "/@larksuite/cli/latest"
|
||||
const defaultBase = "https://registry.npmjs.org"
|
||||
raw := strings.TrimSpace(os.Getenv("npm_config_registry"))
|
||||
if raw != "" {
|
||||
if u, err := url.Parse(raw); err == nil && u.Scheme == "https" && u.Host != "" {
|
||||
base := strings.TrimRight(u.Scheme+"://"+u.Host+u.Path, "/")
|
||||
return base + pkgPath
|
||||
}
|
||||
}
|
||||
return defaultBase + pkgPath
|
||||
}
|
||||
|
||||
func fetchLatestVersion() (string, error) {
|
||||
resp, err := httpClient().Get(registryURL)
|
||||
resp, err := httpClient().Get(resolveRegistryURL())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -275,3 +275,23 @@ func TestIsCIEnv(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRegistryURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, env, want string
|
||||
}{
|
||||
{"unset", "", "https://registry.npmjs.org/@larksuite/cli/latest"},
|
||||
{"custom https", "https://corp.example.com/repository/npm/", "https://corp.example.com/repository/npm/@larksuite/cli/latest"},
|
||||
{"trailing slashes", "https://corp.example.com///", "https://corp.example.com/@larksuite/cli/latest"},
|
||||
{"non-https ignored", "http://internal.example.com/", "https://registry.npmjs.org/@larksuite/cli/latest"},
|
||||
{"garbage ignored", "not a url", "https://registry.npmjs.org/@larksuite/cli/latest"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Setenv("npm_config_registry", c.env)
|
||||
if got := resolveRegistryURL(); got != c.want {
|
||||
t.Errorf("resolveRegistryURL() = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,21 @@ lint/
|
||||
├── rule_subtype_classifier.go
|
||||
├── rule_typed_error_completeness.go
|
||||
└── *_test.go
|
||||
└── domaincontract/ # endpoint domain contract: no hardcoded resolver hosts
|
||||
├── scan.go # ScanRepo(root) ([]lintapi.Violation, error) ← public entry
|
||||
└── scan_test.go
|
||||
```
|
||||
|
||||
## Endpoint domain contract (`domaincontract`)
|
||||
|
||||
Every outbound Lark domain must be resolved through `core.ResolveEndpoints`.
|
||||
The `domaincontract` domain rejects string literals containing a resolver-owned
|
||||
host FQDN (`{open,accounts,mcp,applink}.{feishu.cn,larksuite.com}`) in any
|
||||
production `.go` file. The only allowlisted files are the resolver definition
|
||||
(`internal/core/types.go`) and this rule's own host list
|
||||
(`lint/domaincontract/scan.go`). Comments and `_test.go` files are not scanned.
|
||||
To add or change an outbound endpoint, edit the resolver — never hardcode a host.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
|
||||
97
lint/domaincontract/scan.go
Normal file
97
lint/domaincontract/scan.go
Normal file
@@ -0,0 +1,97 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package domaincontract guards against hardcoded resolver-owned host FQDNs.
|
||||
// Every outbound Lark domain must be resolved via core.ResolveEndpoints; the
|
||||
// only place these host strings may appear as literals is the resolver itself.
|
||||
package domaincontract
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/lint/lintapi"
|
||||
)
|
||||
|
||||
// forbiddenHosts are the resolver-owned FQDNs. They may only appear as string
|
||||
// literals in the allowlisted resolver source.
|
||||
var forbiddenHosts = []string{
|
||||
"open.feishu.cn", "accounts.feishu.cn", "mcp.feishu.cn", "applink.feishu.cn",
|
||||
"open.larksuite.com", "accounts.larksuite.com", "mcp.larksuite.com", "applink.larksuite.com",
|
||||
}
|
||||
|
||||
// allowlist maps repo-relative paths permitted to contain the literals: the
|
||||
// resolver definition itself, and this rule's own forbidden-host list.
|
||||
var allowlist = map[string]bool{
|
||||
filepath.FromSlash("internal/core/types.go"): true,
|
||||
filepath.FromSlash("lint/domaincontract/scan.go"): true,
|
||||
}
|
||||
|
||||
func skipDir(name string) bool {
|
||||
switch name {
|
||||
case "vendor", "testdata", "node_modules", ".git", ".claude":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ScanRepo walks production .go files under root and flags string literals
|
||||
// containing a forbidden resolver host outside the allowlist. Comments and
|
||||
// _test.go files are not scanned.
|
||||
func ScanRepo(root string) ([]lintapi.Violation, error) {
|
||||
var out []lintapi.Violation
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
if skipDir(d.Name()) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
rel, relErr := filepath.Rel(root, path)
|
||||
if relErr == nil && allowlist[rel] {
|
||||
return nil
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
file, perr := parser.ParseFile(fset, path, nil, 0)
|
||||
if perr != nil {
|
||||
return nil // unparseable file: not our concern
|
||||
}
|
||||
display := path
|
||||
if relErr == nil {
|
||||
display = rel
|
||||
}
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
lit, ok := n.(*ast.BasicLit)
|
||||
if !ok || lit.Kind != token.STRING {
|
||||
return true
|
||||
}
|
||||
for _, host := range forbiddenHosts {
|
||||
if strings.Contains(lit.Value, host) {
|
||||
pos := fset.Position(lit.Pos())
|
||||
out = append(out, lintapi.Violation{
|
||||
Rule: "no-hardcoded-endpoint",
|
||||
Action: lintapi.ActionReject,
|
||||
File: display,
|
||||
Line: pos.Line,
|
||||
Message: "hardcoded resolver host " + host + " — outbound domains must come from core.ResolveEndpoints",
|
||||
Suggestion: "use core.ResolveEndpoints(brand) instead of a literal host",
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
44
lint/domaincontract/scan_test.go
Normal file
44
lint/domaincontract/scan_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package domaincontract
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeFile(t *testing.T, root, rel, content string) {
|
||||
t.Helper()
|
||||
p := filepath.Join(root, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanRepo(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Negative: allowlisted resolver source may hold the literals.
|
||||
writeFile(t, root, "internal/core/types.go", "package core\n\nvar x = \"https://open.feishu.cn\"\n")
|
||||
// Negative: non-resolver hosts + a comment reference must not trip the guard.
|
||||
writeFile(t, root, "shortcuts/x/display.go", "package x\n\n// see https://open.feishu.cn/document/foo\nvar h = \"https://www.feishu.cn\"\nvar e = \"https://example.feishu.cn\"\nvar r = \"https://registry.npmjs.org/pkg\"\n")
|
||||
// Negative: _test.go files may assert literals.
|
||||
writeFile(t, root, "internal/y/y_test.go", "package y\n\nvar w = \"https://open.larksuite.com\"\n")
|
||||
// Positive: production literal outside the allowlist.
|
||||
writeFile(t, root, "internal/z/z.go", "package z\n\nvar bad = \"https://accounts.larksuite.com/oauth\"\n")
|
||||
|
||||
vs, err := ScanRepo(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(vs) != 1 {
|
||||
t.Fatalf("got %d violations, want 1: %+v", len(vs), vs)
|
||||
}
|
||||
if filepath.Base(vs[0].File) != "z.go" {
|
||||
t.Errorf("violation in %q, want z.go", vs[0].File)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/lint/domaincontract"
|
||||
"github.com/larksuite/cli/lint/errscontract"
|
||||
"github.com/larksuite/cli/lint/lintapi"
|
||||
)
|
||||
@@ -43,6 +44,9 @@ type scanner struct {
|
||||
|
||||
var scanners = []scanner{
|
||||
{name: "errscontract", fn: errscontract.ScanRepoWithOptions},
|
||||
{name: "domaincontract", fn: func(root string, _ errscontract.ScanOptions) ([]lintapi.Violation, error) {
|
||||
return domaincontract.ScanRepo(root)
|
||||
}},
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.66",
|
||||
"version": "1.0.67",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -215,6 +215,73 @@ if ! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$dry_run_section" ||
|
||||
! grep -Fq "id: e2e_domains" <<<"$dry_run_section" ||
|
||||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should resolve changed-file CLI E2E domains before running tests"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "steps.e2e_domains.outputs.dry_packages" <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should use resolved dry_packages instead of always running the full suite"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$dry_run_section" ||
|
||||
! grep -Fq 'echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should pass dynamic domain output through env before shell use"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "E2E_DRY_ROOT_PACKAGE: \${{ steps.e2e_domains.outputs.dry_root_package }}" <<<"$dry_run_section" ||
|
||||
! grep -Fq 'go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"' <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should run the root CLI E2E harness package without the DryRun/Regression filter"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "No dry-run CLI E2E needed" <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should explicitly skip when domain mode is skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
|
||||
! grep -Fq "id: e2e_domains" <<<"$section" ||
|
||||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
|
||||
echo "e2e-live should resolve changed-file CLI E2E domains before credentials and tests"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "steps.e2e_domains.outputs.live_packages" <<<"$section"; then
|
||||
echo "e2e-live should use resolved live_packages instead of always running the full suite"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$section" ||
|
||||
! grep -Fq 'echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$section"; then
|
||||
echo "e2e-live should pass dynamic domain output through env before shell use"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ - name: Build lark-cli/ { in_step = 1 }
|
||||
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
|
||||
END { exit found ? 0 : 1 }
|
||||
' <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should skip building lark-cli when domain mode is skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ - name: Build lark-cli/ { in_step = 1 }
|
||||
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
|
||||
END { exit found ? 0 : 1 }
|
||||
' <<<"$section"; then
|
||||
echo "e2e-live should skip building lark-cli when domain mode is skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "permissions:" <<<"$section" ||
|
||||
! grep -Fq "contents: read" <<<"$section" ||
|
||||
! grep -Fq "checks: write" <<<"$section"; then
|
||||
@@ -237,13 +304,23 @@ if ! grep -Fq "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_A
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ - name: Configure bot credentials/ { in_step = 1 }
|
||||
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||
in_step && /^ - name:/ && !/Configure bot credentials/ { in_step = 0 }
|
||||
END { exit found ? 0 : 1 }
|
||||
' <<<"$section"; then
|
||||
echo "e2e-live should only configure bot credentials when domain mode is not skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then
|
||||
echo "e2e-live build, configure, test, and report steps should not be gated by a skip-state output"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then
|
||||
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled"
|
||||
if ! grep -Fq "if: \${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}" <<<"$section"; then
|
||||
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled or domain mode is skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
54
scripts/domain-map.js
Normal file
54
scripts/domain-map.js
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const DOMAIN_MAP_PATH = path.join(__dirname, "domain-map.json");
|
||||
const domainMap = JSON.parse(fs.readFileSync(DOMAIN_MAP_PATH, "utf8"));
|
||||
|
||||
function normalizeRepoPath(input) {
|
||||
return String(input || "").trim().replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase();
|
||||
}
|
||||
|
||||
const pathMappingsBySpecificity = (domainMap.pathMappings || [])
|
||||
.map((entry) => ({ ...entry, prefix: normalizeRepoPath(entry.prefix) }))
|
||||
.sort((a, b) => b.prefix.length - a.prefix.length);
|
||||
|
||||
function findPathMapping(filePath) {
|
||||
const normalized = normalizeRepoPath(filePath);
|
||||
return pathMappingsBySpecificity.find((entry) => normalized.startsWith(entry.prefix));
|
||||
}
|
||||
|
||||
function labelDomainsForPath(filePath) {
|
||||
const mapping = findPathMapping(filePath);
|
||||
return mapping ? [...(mapping.labelDomains || [])] : [];
|
||||
}
|
||||
|
||||
function e2eDomainsForPath(filePath) {
|
||||
const mapping = findPathMapping(filePath);
|
||||
return mapping ? [...(mapping.e2eDomains || [])] : [];
|
||||
}
|
||||
|
||||
function matchesFullFallback(filePath) {
|
||||
const normalized = normalizeRepoPath(filePath);
|
||||
return (domainMap.fullFallbackPrefixes || []).some((prefix) => normalized.startsWith(prefix));
|
||||
}
|
||||
|
||||
function isSkippablePath(filePath) {
|
||||
const normalized = normalizeRepoPath(filePath);
|
||||
const basename = path.posix.basename(normalized);
|
||||
return (domainMap.skipPrefixes || []).some((prefix) => normalized.startsWith(prefix))
|
||||
|| (domainMap.skipSuffixes || []).some((suffix) => normalized.endsWith(suffix))
|
||||
|| (domainMap.skipFilenames || []).includes(basename);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
domainMap,
|
||||
e2eDomainsForPath,
|
||||
findPathMapping,
|
||||
isSkippablePath,
|
||||
labelDomainsForPath,
|
||||
matchesFullFallback,
|
||||
normalizeRepoPath,
|
||||
};
|
||||
71
scripts/domain-map.json
Normal file
71
scripts/domain-map.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"pathMappings": [
|
||||
{ "prefix": "shortcuts/im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
|
||||
{ "prefix": "shortcuts/vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
|
||||
{ "prefix": "shortcuts/calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
|
||||
{ "prefix": "shortcuts/doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
|
||||
{ "prefix": "shortcuts/sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
|
||||
{ "prefix": "shortcuts/drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
|
||||
{ "prefix": "shortcuts/wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
|
||||
{ "prefix": "shortcuts/base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
|
||||
{ "prefix": "shortcuts/mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
|
||||
{ "prefix": "shortcuts/task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
|
||||
{ "prefix": "shortcuts/contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
|
||||
{ "prefix": "shortcuts/apps/", "labelDomains": [], "e2eDomains": ["apps"] },
|
||||
{ "prefix": "shortcuts/markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
|
||||
{ "prefix": "shortcuts/minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
|
||||
{ "prefix": "shortcuts/okr/", "labelDomains": [], "e2eDomains": ["okr"] },
|
||||
{ "prefix": "shortcuts/slides/", "labelDomains": [], "e2eDomains": ["slides"] },
|
||||
{ "prefix": "shortcuts/note/", "labelDomains": [], "e2eDomains": ["note"] },
|
||||
{ "prefix": "shortcuts/event/", "labelDomains": [], "e2eDomains": ["event"] },
|
||||
|
||||
{ "prefix": "skills/lark-im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
|
||||
{ "prefix": "skills/lark-vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
|
||||
{ "prefix": "skills/lark-doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
|
||||
{ "prefix": "skills/lark-wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
|
||||
{ "prefix": "skills/lark-drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
|
||||
{ "prefix": "skills/lark-sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
|
||||
{ "prefix": "skills/lark-base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
|
||||
{ "prefix": "skills/lark-mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
|
||||
{ "prefix": "skills/lark-calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
|
||||
{ "prefix": "skills/lark-task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
|
||||
{ "prefix": "skills/lark-contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
|
||||
{ "prefix": "skills/lark-apps/", "labelDomains": [], "e2eDomains": ["apps"] },
|
||||
{ "prefix": "skills/lark-markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
|
||||
{ "prefix": "skills/lark-minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
|
||||
{ "prefix": "skills/lark-okr/", "labelDomains": [], "e2eDomains": ["okr"] },
|
||||
{ "prefix": "skills/lark-slides/", "labelDomains": [], "e2eDomains": ["slides"] },
|
||||
{ "prefix": "skills/lark-note/", "labelDomains": [], "e2eDomains": ["note"] },
|
||||
{ "prefix": "skills/lark-event/", "labelDomains": [], "e2eDomains": ["event"] }
|
||||
],
|
||||
"fullFallbackPrefixes": [
|
||||
"shortcuts/common/",
|
||||
"cmd/",
|
||||
"internal/",
|
||||
"pkg/",
|
||||
"extension/",
|
||||
"registry/",
|
||||
"go.mod",
|
||||
"go.sum",
|
||||
"Makefile",
|
||||
".github/workflows/",
|
||||
"scripts/"
|
||||
],
|
||||
"skipPrefixes": [
|
||||
"docs/",
|
||||
".changeset/"
|
||||
],
|
||||
"skipSuffixes": [
|
||||
".md",
|
||||
".mdx",
|
||||
".txt",
|
||||
".rst"
|
||||
],
|
||||
"skipFilenames": [
|
||||
"readme.md",
|
||||
"readme.zh.md",
|
||||
"changelog.md",
|
||||
"license",
|
||||
"cla.md"
|
||||
]
|
||||
}
|
||||
224
scripts/e2e_domains.js
Normal file
224
scripts/e2e_domains.js
Normal file
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env node
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const {
|
||||
e2eDomainsForPath,
|
||||
findPathMapping,
|
||||
isSkippablePath,
|
||||
matchesFullFallback,
|
||||
normalizeRepoPath,
|
||||
} = require("./domain-map");
|
||||
|
||||
const ROOT = process.env.E2E_DOMAINS_ROOT || path.join(__dirname, "..");
|
||||
process.chdir(ROOT);
|
||||
|
||||
function execLines(command, args) {
|
||||
return execFileSync(command, args, { encoding: "utf8" })
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function modulePath() {
|
||||
return execLines("go", ["list", "-m"])[0];
|
||||
}
|
||||
|
||||
function rootPackage(moduleName) {
|
||||
return `${moduleName}/tests/cli_e2e`;
|
||||
}
|
||||
|
||||
function allLivePackages(moduleName) {
|
||||
return execLines("go", ["list", "./tests/cli_e2e/..."])
|
||||
.filter((pkg) => pkg !== rootPackage(moduleName))
|
||||
.filter((pkg) => !pkg.endsWith("/demo"));
|
||||
}
|
||||
|
||||
function allDryPackages(moduleName) {
|
||||
return allLivePackages(moduleName);
|
||||
}
|
||||
|
||||
const domainExistsCache = new Map();
|
||||
|
||||
function domainExists(domain) {
|
||||
if (domainExistsCache.has(domain)) {
|
||||
return domainExistsCache.get(domain);
|
||||
}
|
||||
let exists = false;
|
||||
try {
|
||||
execFileSync("go", ["list", `./tests/cli_e2e/${domain}`], { stdio: "ignore" });
|
||||
exists = true;
|
||||
} catch {
|
||||
exists = false;
|
||||
}
|
||||
domainExistsCache.set(domain, exists);
|
||||
return exists;
|
||||
}
|
||||
|
||||
function readChangedFiles() {
|
||||
const changedFilesPath = process.env.E2E_DOMAIN_CHANGED_FILES;
|
||||
if (changedFilesPath) {
|
||||
return fs.readFileSync(changedFilesPath, "utf8")
|
||||
.split(/\r?\n/)
|
||||
.map(normalizeRepoPath)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (process.env.GITHUB_EVENT_NAME !== "pull_request") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseRef = process.env.GITHUB_BASE_REF || "main";
|
||||
try {
|
||||
execFileSync("git", ["rev-parse", "--verify", `origin/${baseRef}`], { stdio: "ignore" });
|
||||
return execLines("git", ["diff", "--name-only", `origin/${baseRef}...HEAD`]).map(normalizeRepoPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function addDomain(domains, domain) {
|
||||
if (domain && domainExists(domain)) {
|
||||
domains.add(domain);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function classifyPath(filePath, domains) {
|
||||
const normalized = normalizeRepoPath(filePath);
|
||||
if (!normalized) return { matched: false };
|
||||
|
||||
const e2eMatch = normalized.match(/^tests\/cli_e2e\/([^/]+)\//);
|
||||
if (e2eMatch) {
|
||||
const domain = e2eMatch[1];
|
||||
if (domain === "demo") return { matched: false };
|
||||
if (domainExists(domain)) {
|
||||
addDomain(domains, domain);
|
||||
return { matched: true };
|
||||
}
|
||||
if (isSkippablePath(normalized)) return { matched: false };
|
||||
return { fullReason: `unknown CLI E2E domain path: ${normalized}` };
|
||||
}
|
||||
|
||||
if (normalized.startsWith("tests/cli_e2e/")) {
|
||||
return { fullReason: `shared CLI E2E harness changed: ${normalized}` };
|
||||
}
|
||||
|
||||
if (matchesFullFallback(normalized)) {
|
||||
return { fullReason: `shared/runtime path changed: ${normalized}` };
|
||||
}
|
||||
|
||||
const mappedDomains = e2eDomainsForPath(normalized);
|
||||
if (mappedDomains.length > 0) {
|
||||
const missingDomains = [];
|
||||
for (const domain of mappedDomains) {
|
||||
if (!addDomain(domains, domain)) missingDomains.push(domain);
|
||||
}
|
||||
if (missingDomains.length > 0) {
|
||||
return { fullReason: `mapped CLI E2E domain has no package: ${missingDomains.join(",")} (${normalized})` };
|
||||
}
|
||||
return { matched: true };
|
||||
}
|
||||
|
||||
if (findPathMapping(normalized)) {
|
||||
return { fullReason: `mapped path has no CLI E2E package: ${normalized}` };
|
||||
}
|
||||
|
||||
if (normalized.match(/^shortcuts\/[^/]+\//) || normalized.match(/^skills\/lark-[^/]+\//)) {
|
||||
return { fullReason: `unmapped CLI E2E domain path: ${normalized}` };
|
||||
}
|
||||
|
||||
if (isSkippablePath(normalized)) return { matched: false };
|
||||
|
||||
return { fullReason: `unclassified path changed: ${normalized}` };
|
||||
}
|
||||
|
||||
function resolveDomains(changedFiles) {
|
||||
const moduleName = modulePath();
|
||||
const rootDryPackage = rootPackage(moduleName);
|
||||
if (changedFiles === null) {
|
||||
return {
|
||||
mode: "full",
|
||||
reason: "non-pull_request run or unavailable diff",
|
||||
domains: ["all"],
|
||||
dryRootPackage: rootDryPackage,
|
||||
dryPackages: allDryPackages(moduleName),
|
||||
livePackages: allLivePackages(moduleName),
|
||||
};
|
||||
}
|
||||
|
||||
const domains = new Set();
|
||||
let matchedRelevant = false;
|
||||
let fullReason = "";
|
||||
|
||||
for (const file of changedFiles) {
|
||||
const result = classifyPath(file, domains);
|
||||
if (result.matched) matchedRelevant = true;
|
||||
if (result.fullReason && !fullReason) fullReason = result.fullReason;
|
||||
}
|
||||
|
||||
if (fullReason) {
|
||||
return {
|
||||
mode: "full",
|
||||
reason: fullReason,
|
||||
domains: ["all"],
|
||||
dryRootPackage: rootDryPackage,
|
||||
dryPackages: allDryPackages(moduleName),
|
||||
livePackages: allLivePackages(moduleName),
|
||||
};
|
||||
}
|
||||
|
||||
if (matchedRelevant && domains.size > 0) {
|
||||
const sortedDomains = [...domains].sort();
|
||||
const packages = sortedDomains.map((domain) => `${moduleName}/tests/cli_e2e/${domain}`);
|
||||
return {
|
||||
mode: "subset",
|
||||
reason: "business domain changes",
|
||||
domains: sortedDomains,
|
||||
dryRootPackage: rootDryPackage,
|
||||
dryPackages: packages,
|
||||
livePackages: packages,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "skip",
|
||||
reason: "docs-only or no live CLI E2E impact",
|
||||
domains: [],
|
||||
dryRootPackage: "",
|
||||
dryPackages: [],
|
||||
livePackages: [],
|
||||
};
|
||||
}
|
||||
|
||||
function emit(resolved) {
|
||||
const values = {
|
||||
mode: resolved.mode,
|
||||
reason: resolved.reason,
|
||||
domains: resolved.domains.join(","),
|
||||
dry_root_package: resolved.dryRootPackage,
|
||||
dry_packages: resolved.dryPackages.join(" "),
|
||||
live_packages: resolved.livePackages.join(" "),
|
||||
};
|
||||
|
||||
const lines = Object.entries(values).map(([key, value]) => `${key}=${value}`);
|
||||
console.log(lines.join("\n"));
|
||||
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
emit(resolveDomains(readChangedFiles()));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
classifyPath,
|
||||
readChangedFiles,
|
||||
resolveDomains,
|
||||
};
|
||||
94
scripts/e2e_domains.test.js
Normal file
94
scripts/e2e_domains.test.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const test = require("node:test");
|
||||
|
||||
const scriptPath = path.join(__dirname, "e2e_domains.js");
|
||||
|
||||
function parseOutput(raw) {
|
||||
const result = {};
|
||||
for (const line of raw.trim().split(/\r?\n/)) {
|
||||
const idx = line.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
result[line.slice(0, idx)] = line.slice(idx + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function runDomains(files) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-domains-"));
|
||||
const file = path.join(dir, "changed.txt");
|
||||
fs.writeFileSync(file, `${files.join("\n")}\n`);
|
||||
try {
|
||||
return parseOutput(execFileSync(process.execPath, [scriptPath], {
|
||||
cwd: path.join(__dirname, ".."),
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, E2E_DOMAIN_CHANGED_FILES: file },
|
||||
}));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("maps shortcut changes to one business domain package", () => {
|
||||
const output = runDomains(["shortcuts/im/messages/send.go"]);
|
||||
assert.equal(output.mode, "subset");
|
||||
assert.equal(output.domains, "im");
|
||||
assert.match(output.dry_root_package, /github\.com\/larksuite\/cli\/tests\/cli_e2e$/);
|
||||
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/im/);
|
||||
assert.doesNotMatch(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
|
||||
});
|
||||
|
||||
test("maps doc shortcuts to docs package", () => {
|
||||
const output = runDomains(["shortcuts/doc/update.go"]);
|
||||
assert.equal(output.mode, "subset");
|
||||
assert.equal(output.domains, "docs");
|
||||
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/docs/);
|
||||
});
|
||||
|
||||
test("maps direct e2e domain package changes", () => {
|
||||
const output = runDomains(["tests/cli_e2e/drive/helpers.go"]);
|
||||
assert.equal(output.mode, "subset");
|
||||
assert.equal(output.domains, "drive");
|
||||
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
|
||||
});
|
||||
|
||||
test("falls back to full for shared e2e harness changes", () => {
|
||||
const output = runDomains(["tests/cli_e2e/core.go"]);
|
||||
assert.equal(output.mode, "full");
|
||||
assert.equal(output.domains, "all");
|
||||
assert.match(output.reason, /shared CLI E2E harness changed/);
|
||||
});
|
||||
|
||||
test("falls back to full for runtime changes", () => {
|
||||
const output = runDomains(["cmd/root.go"]);
|
||||
assert.equal(output.mode, "full");
|
||||
assert.equal(output.domains, "all");
|
||||
assert.match(output.reason, /shared\/runtime path changed/);
|
||||
});
|
||||
|
||||
test("skips docs-only changes", () => {
|
||||
const output = runDomains(["docs/usage.md", "README.md"]);
|
||||
assert.equal(output.mode, "skip");
|
||||
assert.equal(output.domains, "");
|
||||
assert.equal(output.dry_root_package, "");
|
||||
assert.equal(output.live_packages, "");
|
||||
});
|
||||
|
||||
test("uses shared map for skill domain changes", () => {
|
||||
const output = runDomains(["skills/lark-sheets/SKILL.md"]);
|
||||
assert.equal(output.mode, "subset");
|
||||
assert.equal(output.domains, "sheets");
|
||||
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/sheets/);
|
||||
});
|
||||
|
||||
test("falls back to full when a mapped path has no e2e package", () => {
|
||||
const output = runDomains(["shortcuts/whiteboard/export.go"]);
|
||||
assert.equal(output.mode, "full");
|
||||
assert.match(output.reason, /unmapped CLI E2E domain path/);
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { labelDomainsForPath } = require("../domain-map");
|
||||
|
||||
// ============================================================================
|
||||
// Constants & Configuration
|
||||
@@ -35,33 +36,6 @@ const CORE_PREFIXES = ["internal/auth/", "internal/engine/", "internal/config/",
|
||||
const HEAD_BUSINESS_DOMAINS = new Set(["im", "contact", "ccm", "base", "docx"]);
|
||||
const LOW_RISK_TYPES = new Set(["docs", "ci", "test", "chore"]);
|
||||
|
||||
// CODEOWNERS-based path to domain label mapping
|
||||
// Maps shortcuts and skills paths to business domain labels
|
||||
const PATH_TO_DOMAIN_MAP = {
|
||||
// shortcuts
|
||||
"shortcuts/im/": "im",
|
||||
"shortcuts/vc/": "vc",
|
||||
"shortcuts/calendar/": "calendar",
|
||||
"shortcuts/doc/": "ccm",
|
||||
"shortcuts/sheets/": "ccm",
|
||||
"shortcuts/drive/": "ccm",
|
||||
"shortcuts/wiki/": "ccm",
|
||||
"shortcuts/base/": "base",
|
||||
"shortcuts/mail/": "mail",
|
||||
"shortcuts/task/": "task",
|
||||
"shortcuts/contact/": "contact",
|
||||
// skills
|
||||
"skills/lark-im/": "im",
|
||||
"skills/lark-vc/": "vc",
|
||||
"skills/lark-doc/": "ccm",
|
||||
"skills/lark-wiki/": "ccm",
|
||||
"skills/lark-base/": "base",
|
||||
"skills/lark-mail/": "mail",
|
||||
"skills/lark-calendar/": "calendar",
|
||||
"skills/lark-task/": "task",
|
||||
"skills/lark-contact/": "contact",
|
||||
};
|
||||
|
||||
const SENSITIVE_PATTERN = /(^|\/)(auth|permission|permissions|security)(\/|_|\.|$)/;
|
||||
|
||||
const CLASS_STANDARDS = {
|
||||
@@ -285,13 +259,7 @@ function skillDomainForPath(filePath) {
|
||||
|
||||
// Get business domain label based on CODEOWNERS path mapping
|
||||
function getBusinessDomain(filePath) {
|
||||
const normalized = normalizePath(filePath);
|
||||
for (const [prefix, domain] of Object.entries(PATH_TO_DOMAIN_MAP)) {
|
||||
if (normalized.startsWith(prefix)) {
|
||||
return domain;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
return labelDomainsForPath(filePath)[0] || "";
|
||||
}
|
||||
|
||||
async function detectNewShortcutDomain(files) {
|
||||
|
||||
@@ -8,7 +8,17 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
script="$repo_root/scripts/resolve-changed-from.sh"
|
||||
|
||||
tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
cleanup_tmp() {
|
||||
local attempt
|
||||
for attempt in 1 2 3; do
|
||||
rm -rf "$tmp" && return 0
|
||||
sleep 1
|
||||
done
|
||||
rm -rf "$tmp"
|
||||
}
|
||||
|
||||
trap cleanup_tmp EXIT
|
||||
mkdir -p "$tmp"
|
||||
|
||||
git_init() {
|
||||
|
||||
@@ -40,7 +40,7 @@ var AppsDBAuditList = common.Shortcut{
|
||||
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -145,7 +145,10 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
|
||||
existing := map[string]bool{}
|
||||
token := ""
|
||||
for {
|
||||
params := map[string]interface{}{"env": env, "page_size": 100}
|
||||
params := map[string]interface{}{"page_size": 100}
|
||||
if env != "" {
|
||||
params["env"] = env
|
||||
}
|
||||
if token != "" {
|
||||
params["page_token"] = token
|
||||
}
|
||||
@@ -168,7 +171,11 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
|
||||
|
||||
// fetchAuditEnabledTables 拉审计状态,返回当前已开启审计的表名集合(status 命令同源接口)。
|
||||
func fetchAuditEnabledTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
|
||||
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), map[string]interface{}{"env": env}, nil)
|
||||
statusParams := map[string]interface{}{}
|
||||
if env != "" {
|
||||
statusParams["env"] = env
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), statusParams, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -208,11 +215,10 @@ func auditListTables(rctx *common.RuntimeContext) []string {
|
||||
|
||||
// buildAuditListParams 组装 audit_list 查询参数:env / tables(逗号拼接) / page_size 及可选 since/until/page_token。
|
||||
func buildAuditListParams(rctx *common.RuntimeContext, tables []string) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
params := dbEnvParams(rctx, map[string]interface{}{
|
||||
"tables": strings.Join(tables, ","),
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
})
|
||||
addStr := func(flag, key string) {
|
||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||
params[key] = v
|
||||
|
||||
@@ -35,7 +35,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "table to enable audit for", Required: true},
|
||||
{Name: "retention", Default: "7d", Enum: auditRetentions, Desc: "how long to keep audit logs"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -47,7 +47,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
||||
return common.NewDryRunAPI().
|
||||
POST(appAuditSetPath(appID)).
|
||||
Desc("Enable table audit").
|
||||
Params(map[string]interface{}{"env": dbEnv(rctx)}).
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": true, "retention": rctx.Str("retention")})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -60,7 +60,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
||||
stop := rctx.StartSpinner("Enabling audit logging for " + table)
|
||||
defer stop()
|
||||
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
||||
map[string]interface{}{"env": dbEnv(rctx)},
|
||||
dbEnvParams(rctx, map[string]interface{}{}),
|
||||
map[string]interface{}{"table": table, "enabled": true, "retention": retention})
|
||||
stop()
|
||||
if err != nil {
|
||||
@@ -96,7 +96,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "table to disable audit for", Required: true},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -108,7 +108,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
||||
return common.NewDryRunAPI().
|
||||
POST(appAuditSetPath(appID)).
|
||||
Desc("Disable table audit").
|
||||
Params(map[string]interface{}{"env": dbEnv(rctx)}).
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": false})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -118,7 +118,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
||||
}
|
||||
table := strings.TrimSpace(rctx.Str("table"))
|
||||
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
||||
map[string]interface{}{"env": dbEnv(rctx)},
|
||||
dbEnvParams(rctx, map[string]interface{}{}),
|
||||
map[string]interface{}{"table": table, "enabled": false})
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbAuditSetHint)
|
||||
|
||||
@@ -30,7 +30,7 @@ var AppsDBAuditStatus = common.Shortcut{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "show status for a single table (default: all configured tables)"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -75,7 +75,7 @@ var AppsDBAuditStatus = common.Shortcut{
|
||||
|
||||
// buildAuditStatusParams 组装 audit_status 查询参数:env 及可选 table(单表查询)。
|
||||
func buildAuditStatusParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{"env": dbEnv(rctx)}
|
||||
params := dbEnvParams(rctx, map[string]interface{}{})
|
||||
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
|
||||
params["table"] = t
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ var AppsDBChangelogList = common.Shortcut{
|
||||
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -77,10 +77,9 @@ var AppsDBChangelogList = common.Shortcut{
|
||||
|
||||
// buildChangelogParams 组装 changelog_list 查询参数:env / page_size 及可选 table/change_id/since/until/page_token。
|
||||
func buildChangelogParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
params := dbEnvParams(rctx, map[string]interface{}{
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
})
|
||||
addStr := func(flag, key string) {
|
||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||
params[key] = v
|
||||
|
||||
@@ -47,7 +47,7 @@ var AppsDBDataExport = common.Shortcut{
|
||||
{Name: "table", Desc: "source table", Required: true},
|
||||
{Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default: <table>.csv)"},
|
||||
{Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "source db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "source db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -75,10 +75,10 @@ var AppsDBDataExport = common.Shortcut{
|
||||
return common.NewDryRunAPI().
|
||||
GET(appDataExportPath(appID)).
|
||||
Desc("Export Miaoda app table data (raw bytes)").
|
||||
Params(map[string]interface{}{
|
||||
"env": dbEnv(rctx), "table": strings.TrimSpace(rctx.Str("table")),
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{
|
||||
"table": strings.TrimSpace(rctx.Str("table")),
|
||||
"format": format, "limit": rctx.Int("limit"),
|
||||
})
|
||||
}))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
@@ -95,15 +95,18 @@ var AppsDBDataExport = common.Shortcut{
|
||||
// total 查询失败不阻断导出——回退到按导出文件内容数行。
|
||||
total, totalErr := queryExportTotal(rctx, appID, dbEnv(rctx), table)
|
||||
|
||||
exportQuery := larkcore.QueryParams{
|
||||
"table": []string{table},
|
||||
"format": []string{format},
|
||||
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
|
||||
}
|
||||
if env := dbEnv(rctx); env != "" {
|
||||
exportQuery["env"] = []string{env}
|
||||
}
|
||||
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: appDataExportPath(appID),
|
||||
QueryParams: larkcore.QueryParams{
|
||||
"env": []string{dbEnv(rctx)},
|
||||
"table": []string{table},
|
||||
"format": []string{format},
|
||||
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
|
||||
},
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: appDataExportPath(appID),
|
||||
QueryParams: exportQuery,
|
||||
})
|
||||
if err != nil {
|
||||
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint)
|
||||
@@ -157,8 +160,11 @@ var AppsDBDataExport = common.Shortcut{
|
||||
// queryExportTotal 调 GetAppTableRecordList(page_size=1)取 total(符合条件的记录总数)。
|
||||
// 该接口与 +db-data-export 同为 spark:app:read scope,避免导出命令被迫升级到写权限。
|
||||
func queryExportTotal(rctx *common.RuntimeContext, appID, env, table string) (int, error) {
|
||||
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table),
|
||||
map[string]interface{}{"env": env, "page_size": 1}, nil)
|
||||
params := map[string]interface{}{"page_size": 1}
|
||||
if env != "" {
|
||||
params["env"] = env
|
||||
}
|
||||
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table), params, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ var AppsDBDataImport = common.Shortcut{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "file", Desc: "local data file (.csv/.json), relative to cwd", Required: true},
|
||||
{Name: "table", Desc: "target table (default: file name without extension)"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -76,7 +76,7 @@ var AppsDBDataImport = common.Shortcut{
|
||||
return common.NewDryRunAPI().
|
||||
POST(appDataImportPath(appID)).
|
||||
Desc("Import data file into Miaoda app table (multipart upload)").
|
||||
Params(map[string]interface{}{"env": dbEnv(rctx), "table": importTableName(rctx)}).
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{"table": importTableName(rctx)})).
|
||||
Body(map[string]interface{}{"file_name": fileName, "file": "<contents of --file>"})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -100,10 +100,14 @@ var AppsDBDataImport = common.Shortcut{
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddFile("file", bytes.NewReader(content))
|
||||
|
||||
importQuery := larkcore.QueryParams{"table": []string{table}}
|
||||
if env := dbEnv(rctx); env != "" {
|
||||
importQuery["env"] = []string{env}
|
||||
}
|
||||
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: appDataImportPath(appID),
|
||||
QueryParams: larkcore.QueryParams{"env": []string{dbEnv(rctx)}, "table": []string{table}},
|
||||
QueryParams: importQuery,
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
|
||||
@@ -121,6 +121,31 @@ func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_DryRunOmitsEnvWhenUnset 验证不传 --environment 时 dry-run 的 query
|
||||
// 不带 env 键(交服务端按应用形态自动选分支),但仍携带 table。
|
||||
func TestAppsDBDataImport_DryRunOmitsEnvWhenUnset(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
p := env.API[0].Params
|
||||
if _, ok := p["env"]; ok {
|
||||
t.Fatalf("no --environment → env key must be omitted, got params=%v", p)
|
||||
}
|
||||
if p["table"] != "orders" {
|
||||
t.Fatalf("table should still default to file basename, got params=%v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_Success 验证成功导入后输出含 table、rows 与回显的 file 名。
|
||||
func TestAppsDBDataImport_Success(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
|
||||
@@ -97,6 +97,16 @@ var AppsDBEnvMigrate = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 先 dry_run 预览拿待发布变更数(对齐 miaoda-cli 的 diff-then-apply):服务端在未经
|
||||
// dry_run 预热时直接 apply,虽发布成功却把 changes_applied 回填成 0(展示「Migrated (0 changes)」)。
|
||||
// 这一步既预热服务端计数、又作为 apply 仍回 0 时的兜底数。dry_run 报错(如无待发布变更)不阻断,
|
||||
// 交由下面真实 apply 统一报同样的业务错。
|
||||
pending := 0
|
||||
var previewFrom, previewTo string
|
||||
if preview, perr := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": true}); perr == nil {
|
||||
pending = len(projectMigrationChanges(preview["changes"]))
|
||||
previewFrom, previewTo = common.GetString(preview, "from"), common.GetString(preview, "to")
|
||||
}
|
||||
stop := rctx.StartSpinner("Applying migration (dev → online)")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false})
|
||||
@@ -104,6 +114,12 @@ var AppsDBEnvMigrate = common.Shortcut{
|
||||
return withAppsHint(err, dbEnvMigrateHint)
|
||||
}
|
||||
from, to := common.GetString(submit, "from"), common.GetString(submit, "to")
|
||||
if from == "" {
|
||||
from = previewFrom
|
||||
}
|
||||
if to == "" {
|
||||
to = previewTo
|
||||
}
|
||||
taskID := common.GetString(submit, "task_id")
|
||||
applied := intFromAny(submit["changes_applied"])
|
||||
if applied == 0 {
|
||||
@@ -131,6 +147,10 @@ var AppsDBEnvMigrate = common.Shortcut{
|
||||
applied = n
|
||||
}
|
||||
}
|
||||
// 服务端把发布成功的变更数回 0 时,用发布前 dry_run 预览的 pending 数兜底,避免误显示「(0 changes)」。
|
||||
if applied == 0 && pending > 0 {
|
||||
applied = pending
|
||||
}
|
||||
stop() // clear spinner before printing the result
|
||||
out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
|
||||
@@ -105,8 +105,10 @@ func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) {
|
||||
// 异步:submit 返 task_id,status 立刻 applied → CLI 对外统一 migrated。
|
||||
func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
// Reusable:Execute 现在会先打一次 dry_run 预览拿待发布数、再打 apply(对齐 miaoda-cli 的
|
||||
// diff-then-apply,兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Method: "POST", URL: dbEnvMigrateURL, Reusable: true,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -126,8 +128,10 @@ func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
|
||||
// TestAppsDBEnvMigrate_PollFailedSurfacesError 验证轮询到 failed 时返回 API/server_error 类型错误,携带服务端 message 与恢复 hint。
|
||||
func TestAppsDBEnvMigrate_PollFailedSurfacesError(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
// Reusable:Execute 现在会先打一次 dry_run 预览拿待发布数、再打 apply(对齐 miaoda-cli 的
|
||||
// diff-then-apply,兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Method: "POST", URL: dbEnvMigrateURL, Reusable: true,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -319,6 +323,31 @@ func TestAppsDBQuotaGet_WithQuotaPretty(t *testing.T) {
|
||||
}
|
||||
|
||||
// 配额未对接(storage_quota_bytes=0)→ json 删 quota/usage_percent,仅留已用量与 tables/views。
|
||||
// TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset 验证不传 --environment 时 quota-get 的 dry-run
|
||||
// query 不带 env 键(交服务端按应用形态自动选分支)。
|
||||
func TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBQuotaGet,
|
||||
[]string{"+db-quota-get", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != dbQuotaURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if _, ok := a.Params["env"]; ok {
|
||||
t.Fatalf("no --environment → env key must be omitted, got params=%v", a.Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsDBQuotaGet_NoQuotaOmitsFields(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -66,7 +66,7 @@ var AppsDBExecute = common.Shortcut{
|
||||
{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
|
||||
Input: []string{common.Stdin}},
|
||||
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -291,10 +291,9 @@ func parseErrorSentinel(data string) (int, string) {
|
||||
//
|
||||
// CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。
|
||||
func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
return dbEnvParams(rctx, map[string]interface{}{
|
||||
"transactional": false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// resolveExecuteSQL 返回要执行的 SQL,在用时(DryRun/Execute)现读,使 --file 的内容
|
||||
|
||||
@@ -29,7 +29,7 @@ var AppsDBQuotaGet = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -41,14 +41,14 @@ var AppsDBQuotaGet = common.Shortcut{
|
||||
return common.NewDryRunAPI().
|
||||
GET(appDbQuotaPath(appID)).
|
||||
Desc("Get Miaoda app database storage usage").
|
||||
Params(map[string]interface{}{"env": dbEnv(rctx)})
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{}))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), map[string]interface{}{"env": dbEnv(rctx)}, nil)
|
||||
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), dbEnvParams(rctx, map[string]interface{}{}), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
|
||||
@@ -32,19 +32,23 @@ var AppsDBRecoveryDiff = common.Shortcut{
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
|
||||
},
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectLegacyEnvFlag(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return normalizeTimeFlags(rctx, "target")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Preview PITR recovery").
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": true})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -81,19 +85,23 @@ var AppsDBRecoveryApply = common.Shortcut{
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
|
||||
},
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectLegacyEnvFlag(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return normalizeTimeFlags(rctx, "target")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Apply PITR recovery").
|
||||
Params(dbEnvParams(rctx, map[string]interface{}{})).
|
||||
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": false})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -104,7 +112,7 @@ var AppsDBRecoveryApply = common.Shortcut{
|
||||
target := rctx.Str("target")
|
||||
stop := rctx.StartSpinner("Restoring database (target: " + target + ")")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": false})
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": false})
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbRecoveryHint)
|
||||
}
|
||||
@@ -119,7 +127,7 @@ var AppsDBRecoveryApply = common.Shortcut{
|
||||
}
|
||||
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 2*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil)
|
||||
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), dbEnvParams(rctx, map[string]interface{}{}), nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "status")) {
|
||||
@@ -157,7 +165,7 @@ var AppsDBRecoveryApply = common.Shortcut{
|
||||
func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[string]interface{}, error) {
|
||||
stop := rctx.StartSpinner("Previewing recovery impact (target: " + target + ")")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": true})
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": true})
|
||||
if err != nil {
|
||||
return nil, withAppsHint(err, dbRecoveryHint)
|
||||
}
|
||||
@@ -167,7 +175,7 @@ func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[
|
||||
}
|
||||
return pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), map[string]interface{}{"preview_request_id": prid}, nil)
|
||||
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), dbEnvParams(rctx, map[string]interface{}{"preview_request_id": prid}), nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "preview_status")) {
|
||||
@@ -195,13 +203,13 @@ type recoveryChange struct {
|
||||
// recoveryDiffOutput 组装 diff 输出:target / tables_affected / changes[] / estimated_seconds。
|
||||
func recoveryDiffOutput(target string, preview map[string]interface{}) map[string]interface{} {
|
||||
arr, _ := preview["changes"].([]interface{})
|
||||
changes := make([]recoveryChange, 0, len(arr))
|
||||
raw := make([]recoveryChange, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
changes = append(changes, recoveryChange{
|
||||
raw = append(raw, recoveryChange{
|
||||
Table: common.GetString(m, "table"),
|
||||
Inserted: m["inserted"],
|
||||
Deleted: m["deleted"],
|
||||
@@ -209,16 +217,33 @@ func recoveryDiffOutput(target string, preview map[string]interface{}) map[strin
|
||||
DroppedAt: common.GetString(m, "dropped_at"),
|
||||
})
|
||||
}
|
||||
tablesAffected := intFromAny(preview["tables_affected"])
|
||||
if tablesAffected == 0 {
|
||||
tablesAffected = len(changes)
|
||||
// 服务端可能对同一张表既下发 schema 动作(drop/restore/alter)、又下发纯数据行变更。
|
||||
// schema 动作已涵盖数据结果(如 drop 隐含删光行),丢弃该表的冗余数据行那条,避免同表
|
||||
// 两行 + tables_affected 翻倍。
|
||||
hasSchema := map[string]bool{}
|
||||
for _, c := range raw {
|
||||
if c.Action != "" {
|
||||
hasSchema[c.Table] = true
|
||||
}
|
||||
}
|
||||
changes := make([]recoveryChange, 0, len(raw))
|
||||
for _, c := range raw {
|
||||
if c.Action == "" && hasSchema[c.Table] {
|
||||
continue
|
||||
}
|
||||
changes = append(changes, c)
|
||||
}
|
||||
// tables_affected 按去重后的不同表数计(而非变更条数)。
|
||||
seen := map[string]bool{}
|
||||
for _, c := range changes {
|
||||
seen[c.Table] = true
|
||||
}
|
||||
est := intFromAny(preview["estimated_seconds"])
|
||||
if est == 0 {
|
||||
est = 30 // PRD 兜底
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"target": target, "tables_affected": tablesAffected,
|
||||
"target": target, "tables_affected": len(seen),
|
||||
"changes": changes, "estimated_seconds": est,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ var AppsDBTableGet = common.Shortcut{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "app id", Required: true},
|
||||
{Name: "table", Desc: "table name", Required: true},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -80,7 +80,7 @@ var AppsDBTableGet = common.Shortcut{
|
||||
// CLI 检测 rctx.Format == "pretty" 时给 server 带 format=ddl,要求返 CREATE 语句文本;
|
||||
// 其他 format(含默认 json)不传该参数,让 server 返默认结构化字段。
|
||||
func buildDBTableGetParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{"env": dbEnv(rctx)}
|
||||
params := dbEnvParams(rctx, map[string]interface{}{})
|
||||
if rctx.Format == "pretty" {
|
||||
params["format"] = "ddl"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -42,7 +43,7 @@ var AppsDBTableList = common.Shortcut{
|
||||
{Name: "app-id", Desc: "app id", Required: true},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -110,10 +111,9 @@ func projectTableListItems(raw interface{}) []dbTableListItem {
|
||||
}
|
||||
|
||||
func buildDBTableListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
params := dbEnvParams(rctx, map[string]interface{}{
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
})
|
||||
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
|
||||
params["page_token"] = token
|
||||
}
|
||||
@@ -286,6 +286,17 @@ func numericAsFloat(raw interface{}) (float64, bool) {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
case string:
|
||||
// 服务端有些数值字段(如 recovery diff 的 inserted/deleted 行数)以字符串下发。
|
||||
s := strings.TrimSpace(v)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
case nil:
|
||||
return 0, false
|
||||
}
|
||||
|
||||
@@ -236,7 +236,11 @@ func TestNumericAsFloat_AllTypes(t *testing.T) {
|
||||
{"json.Number valid", json.Number("13.5"), 13.5, true},
|
||||
{"json.Number invalid", json.Number("abc"), 0, false},
|
||||
{"nil", nil, 0, false},
|
||||
{"unsupported string", "x", 0, false},
|
||||
{"non-numeric string", "x", 0, false},
|
||||
{"numeric string", "13.5", 13.5, true},
|
||||
{"numeric string int", "2", 2, true},
|
||||
{"numeric string padded", " 13.5 ", 13.5, true},
|
||||
{"empty string", "", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
|
||||
@@ -34,6 +34,16 @@ func dbEnv(rctx *common.RuntimeContext) string {
|
||||
return rctx.Str("environment")
|
||||
}
|
||||
|
||||
// dbEnvParams 把 env 并入 params:仅当显式指定了环境(非空)才带 env 键;未指定(空)时
|
||||
// 省略该键,由服务端按应用多环境状态自动选分支(多环境→dev,单环境→online)。与家族对
|
||||
// 空可选参数的 omit-empty 约定一致——不发空串,wire 上真正不带 env。原样返回同一个 map 便于链式。
|
||||
func dbEnvParams(rctx *common.RuntimeContext, params map[string]interface{}) map[string]interface{} {
|
||||
if env := dbEnv(rctx); env != "" {
|
||||
params["env"] = env
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// rejectLegacyEnvFlag 在 Validate 阶段拦截已移除的 --env:显式传了就报清晰的 validation 错,指向 --environment。
|
||||
func rejectLegacyEnvFlag(rctx *common.RuntimeContext) error {
|
||||
if rctx.Changed("env") {
|
||||
|
||||
73
shortcuts/base/record_json_shorthand_test.go
Normal file
73
shortcuts/base/record_json_shorthand_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func mountBaseShortcutFlags(t *testing.T, s common.Shortcut, name string) *cobra.Command {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "test"}
|
||||
s.Mount(parent, &cmdutil.Factory{})
|
||||
cmd, _, err := parent.Find([]string{name})
|
||||
if err != nil {
|
||||
t.Fatalf("Find(%s) error = %v", name, err)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// record-list 获得 --json 简写
|
||||
func TestRecordListRegistersJSONShorthand(t *testing.T) {
|
||||
cmd := mountBaseShortcutFlags(t, BaseRecordList, "+record-list")
|
||||
fl := cmd.Flags().Lookup("json")
|
||||
if fl == nil {
|
||||
t.Fatal("+record-list missing --json shorthand")
|
||||
}
|
||||
if fl.Usage != "shorthand for --format json" {
|
||||
t.Errorf("usage = %q, want shorthand", fl.Usage)
|
||||
}
|
||||
if def := cmd.Flags().Lookup("format").DefValue; def != "markdown" {
|
||||
t.Errorf("format default = %q, want markdown (unchanged)", def)
|
||||
}
|
||||
}
|
||||
|
||||
// record-search / record-get 的 --json 保持请求体语义,不被覆盖(回归锚点)
|
||||
func TestRecordSearchGetKeepRequestBodyJSON(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
cmdName string
|
||||
}{
|
||||
{"record-search", BaseRecordSearch, "+record-search"},
|
||||
{"record-get", BaseRecordGet, "+record-get"},
|
||||
} {
|
||||
cmd := mountBaseShortcutFlags(t, tc.shortcut, tc.cmdName)
|
||||
fl := cmd.Flags().Lookup("json")
|
||||
if fl == nil {
|
||||
t.Fatalf("%s: --json (request body) missing", tc.name)
|
||||
}
|
||||
if strings.Contains(fl.Usage, "shorthand") {
|
||||
t.Fatalf("%s: request-body --json overwritten by shorthand: %q", tc.name, fl.Usage)
|
||||
}
|
||||
if fl.Value.Type() != "string" {
|
||||
t.Fatalf("%s: --json type = %q, want string", tc.name, fl.Value.Type())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enum 已接入:help 描述携带枚举后缀(框架对带 Enum 的 flag 自动追加 " (markdown|json)")
|
||||
func TestRecordReadFormatFlagCarriesEnum(t *testing.T) {
|
||||
cmd := mountBaseShortcutFlags(t, BaseRecordList, "+record-list")
|
||||
usage := cmd.Flags().Lookup("format").Usage
|
||||
if !strings.Contains(usage, "(markdown|json)") {
|
||||
t.Fatalf("format usage missing enum suffix: %q", usage)
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,7 @@ func recordReadFormatFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: "format",
|
||||
Default: "markdown",
|
||||
Enum: []string{"markdown", "json"},
|
||||
Desc: "output format: markdown (default) | json",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -889,6 +889,7 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
|
||||
}
|
||||
}
|
||||
cmdmeta.SetSource(cmd, cmdmeta.SourceShortcut, false)
|
||||
cmdmeta.SetAffordanceRef(cmd, shortcut.Service, shortcut.Command)
|
||||
cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes)
|
||||
registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut)
|
||||
cmdutil.SetTips(cmd, shortcut.Tips)
|
||||
@@ -1026,6 +1027,7 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
|
||||
}
|
||||
rctx.larkSDK = sdk
|
||||
|
||||
applyJSONShorthand(cmd, s)
|
||||
rctx.Format = rctx.Str("format")
|
||||
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
|
||||
return rctx, nil
|
||||
@@ -1171,6 +1173,75 @@ func registerShortcutFlags(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut)
|
||||
registerShortcutFlagsWithContext(context.Background(), cmd, f, s)
|
||||
}
|
||||
|
||||
// shortcutDeclaresJSONFlag reports whether the shortcut itself declares a flag
|
||||
// named "json" in its Flags list (custom semantics, e.g. event +subscribe's
|
||||
// pretty-print switch or base +record-search's request-body payload).
|
||||
// Framework-injected flags never appear in s.Flags, so this cleanly separates
|
||||
// "self-declared json" from "injected shorthand".
|
||||
func shortcutDeclaresJSONFlag(s *Shortcut) bool {
|
||||
for _, fl := range s.Flags {
|
||||
if fl.Name == "json" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// shortcutFormatSupportsJSON reports whether the command's format flag accepts
|
||||
// "json": a self-declared format supports it only when its Enum lists "json";
|
||||
// a framework-injected default format (no format entry in s.Flags) always does.
|
||||
func shortcutFormatSupportsJSON(s *Shortcut) bool {
|
||||
for _, fl := range s.Flags {
|
||||
if fl.Name == "format" {
|
||||
return slices.Contains(fl.Enum, "json")
|
||||
}
|
||||
}
|
||||
return true // framework-injected: json (default) | pretty | table | ndjson | csv
|
||||
}
|
||||
|
||||
// ensureJSONShorthand registers --json as a shorthand for --format json when:
|
||||
// 1. the command has a format flag (self-declared or framework-injected), AND
|
||||
// 2. that format supports "json" (see shortcutFormatSupportsJSON), AND
|
||||
// 3. no flag named "json" is registered yet — pflag panics on duplicate
|
||||
// registration, and commands that declare their own --json (event
|
||||
// +subscribe, base +record-search/-get) keep their custom semantics.
|
||||
func ensureJSONShorthand(cmd *cobra.Command, s *Shortcut) {
|
||||
// A shortcut that declares its own "json" flag defines custom semantics
|
||||
// (e.g. pretty-print switch, request-body payload) — never a shorthand.
|
||||
if shortcutDeclaresJSONFlag(s) {
|
||||
return
|
||||
}
|
||||
if cmd.Flags().Lookup("format") == nil {
|
||||
return
|
||||
}
|
||||
if !shortcutFormatSupportsJSON(s) {
|
||||
return
|
||||
}
|
||||
// Safety net: pflag panics on duplicate registration.
|
||||
if cmd.Flags().Lookup("json") != nil {
|
||||
return
|
||||
}
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
}
|
||||
|
||||
// applyJSONShorthand folds the injected --json shorthand into the format flag
|
||||
// itself, before rctx.Format caches it — so both the cached value (OutFormat,
|
||||
// ValidateJqFlags, dry-run) and later runtime.Str("format") reads observe
|
||||
// "json". An explicitly passed --format always wins over the shorthand (the
|
||||
// shorthand only fills in when the user did not choose a format). Shortcuts
|
||||
// that declare their own "json" flag keep its custom semantics untouched.
|
||||
func applyJSONShorthand(cmd *cobra.Command, s *Shortcut) {
|
||||
if shortcutDeclaresJSONFlag(s) {
|
||||
return
|
||||
}
|
||||
if cmd.Flags().Lookup("json") == nil || cmd.Flags().Changed("format") {
|
||||
return
|
||||
}
|
||||
if set, _ := cmd.Flags().GetBool("json"); set {
|
||||
_ = cmd.Flags().Set("format", "json")
|
||||
}
|
||||
}
|
||||
|
||||
func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) {
|
||||
for _, fl := range s.Flags {
|
||||
desc := fl.Desc
|
||||
@@ -1234,10 +1305,8 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
if cmd.Flags().Lookup("json") == nil {
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
}
|
||||
}
|
||||
ensureJSONShorthand(cmd, s)
|
||||
if s.Risk == "high-risk-write" {
|
||||
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
|
||||
}
|
||||
|
||||
200
shortcuts/common/runner_json_shorthand_test.go
Normal file
200
shortcuts/common/runner_json_shorthand_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
const jsonShorthandUsage = "shorthand for --format json"
|
||||
|
||||
func mountTestShortcut(t *testing.T, s Shortcut) *cobra.Command {
|
||||
t.Helper()
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
s.Mount(parent, f)
|
||||
cmd, _, err := parent.Find([]string{s.Command})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// 自定义 format 且 Enum 含 json → 注册简写(本次修复的核心行为)
|
||||
func TestJSONShorthand_CustomFormatWithJSONEnum_Registered(t *testing.T) {
|
||||
cmd := mountTestShortcut(t, Shortcut{
|
||||
Service: "mail", Command: "+fake-triage", Description: "x",
|
||||
Flags: []Flag{{Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, Desc: "fmt"}},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
})
|
||||
fl := cmd.Flags().Lookup("json")
|
||||
if fl == nil {
|
||||
t.Fatal("--json not registered for custom-format shortcut whose Enum contains json")
|
||||
}
|
||||
if fl.Usage != jsonShorthandUsage {
|
||||
t.Errorf("usage = %q, want %q", fl.Usage, jsonShorthandUsage)
|
||||
}
|
||||
// 默认输出格式不被改变
|
||||
if def := cmd.Flags().Lookup("format").DefValue; def != "table" {
|
||||
t.Errorf("format default = %q, want table", def)
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义 format 但 Enum 不含 json → 不注册
|
||||
func TestJSONShorthand_CustomFormatWithoutJSONEnum_NotRegistered(t *testing.T) {
|
||||
cmd := mountTestShortcut(t, Shortcut{
|
||||
Service: "x", Command: "+no-json", Description: "x",
|
||||
Flags: []Flag{{Name: "format", Default: "csv", Enum: []string{"csv", "table"}, Desc: "fmt"}},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
})
|
||||
if cmd.Flags().Lookup("json") != nil {
|
||||
t.Fatal("--json must NOT be registered when format Enum lacks json")
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义 format 但无 Enum(现状 triage 形态)→ 不注册(Enum 是判定依据)
|
||||
func TestJSONShorthand_CustomFormatNoEnum_NotRegistered(t *testing.T) {
|
||||
cmd := mountTestShortcut(t, Shortcut{
|
||||
Service: "x", Command: "+legacy", Description: "x",
|
||||
Flags: []Flag{{Name: "format", Default: "table", Desc: "fmt"}},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
})
|
||||
if cmd.Flags().Lookup("json") != nil {
|
||||
t.Fatal("--json must NOT be registered when format has no Enum metadata")
|
||||
}
|
||||
}
|
||||
|
||||
// 自声明 json flag(subscribe 的 pretty / record-search 的请求体)→ 不覆盖、不 panic、语义保留
|
||||
func TestJSONShorthand_SelfDeclaredJSON_Preserved(t *testing.T) {
|
||||
cmd := mountTestShortcut(t, Shortcut{
|
||||
Service: "event", Command: "+fake-subscribe", Description: "x",
|
||||
Flags: []Flag{
|
||||
{Name: "json", Type: "bool", Desc: "pretty-print JSON instead of NDJSON"},
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
})
|
||||
fl := cmd.Flags().Lookup("json")
|
||||
if fl == nil {
|
||||
t.Fatal("self-declared --json missing")
|
||||
}
|
||||
if fl.Usage != "pretty-print JSON instead of NDJSON" {
|
||||
t.Errorf("self-declared --json usage overwritten: %q", fl.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
// parseMounted mounts the shortcut and parses args against the command's FlagSet
|
||||
// (registration side effects included), without executing RunE.
|
||||
func parseMounted(t *testing.T, s Shortcut, args []string) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd := mountTestShortcut(t, s)
|
||||
if err := cmd.ParseFlags(args); err != nil {
|
||||
t.Fatalf("ParseFlags(%v) error = %v", args, err)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func customFormatShortcut() Shortcut {
|
||||
return Shortcut{
|
||||
Service: "mail", Command: "+fake-triage", Description: "x",
|
||||
Flags: []Flag{{Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, Desc: "fmt"}},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
}
|
||||
|
||||
// --json 单独使用 → format 归一化为 json
|
||||
func TestApplyJSONShorthand_JSONAlone_SetsFormatJSON(t *testing.T) {
|
||||
s := customFormatShortcut()
|
||||
cmd := parseMounted(t, s, []string{"--json"})
|
||||
applyJSONShorthand(cmd, &s)
|
||||
if got := cmd.Flags().Lookup("format").Value.String(); got != "json" {
|
||||
t.Fatalf("format = %q, want json", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 显式 --format 优先于 --json 简写:--format table --json → table
|
||||
func TestApplyJSONShorthand_ExplicitFormatWins(t *testing.T) {
|
||||
s := customFormatShortcut()
|
||||
cmd := parseMounted(t, s, []string{"--format", "table", "--json"})
|
||||
applyJSONShorthand(cmd, &s)
|
||||
if got := cmd.Flags().Lookup("format").Value.String(); got != "table" {
|
||||
t.Fatalf("format = %q, want table (explicit --format must win)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --format json --json → json(一致,无冲突)
|
||||
func TestApplyJSONShorthand_ExplicitJSONFormatConsistent(t *testing.T) {
|
||||
s := customFormatShortcut()
|
||||
cmd := parseMounted(t, s, []string{"--format", "json", "--json"})
|
||||
applyJSONShorthand(cmd, &s)
|
||||
if got := cmd.Flags().Lookup("format").Value.String(); got != "json" {
|
||||
t.Fatalf("format = %q, want json", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 均不传 → 默认值不变
|
||||
func TestApplyJSONShorthand_NoFlags_DefaultUntouched(t *testing.T) {
|
||||
s := customFormatShortcut()
|
||||
cmd := parseMounted(t, s, nil)
|
||||
applyJSONShorthand(cmd, &s)
|
||||
if got := cmd.Flags().Lookup("format").Value.String(); got != "table" {
|
||||
t.Fatalf("format = %q, want table (default untouched)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 自声明 string 型 --json(record-search 形态:format+json 双声明)→ 归一化跳过
|
||||
func TestApplyJSONShorthand_SelfDeclaredStringJSON_Skipped(t *testing.T) {
|
||||
s := Shortcut{
|
||||
Service: "base", Command: "+fake-record-search", Description: "x",
|
||||
Flags: []Flag{
|
||||
{Name: "format", Default: "markdown", Enum: []string{"markdown", "json"}, Desc: "fmt"},
|
||||
{Name: "json", Desc: "request body JSON object"},
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
cmd := parseMounted(t, s, []string{"--json", `{"keyword":"Alice"}`})
|
||||
applyJSONShorthand(cmd, &s)
|
||||
if got := cmd.Flags().Lookup("format").Value.String(); got != "markdown" {
|
||||
t.Fatalf("format = %q, want markdown (self-declared json must not normalize)", got)
|
||||
}
|
||||
if got := cmd.Flags().Lookup("json").Value.String(); got != `{"keyword":"Alice"}` {
|
||||
t.Fatalf("request-body --json corrupted: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 自声明 bool 型 --json(subscribe 形态:无自定义 format,框架注入 format)→ 归一化跳过
|
||||
func TestApplyJSONShorthand_SelfDeclaredBoolJSON_Skipped(t *testing.T) {
|
||||
s := Shortcut{
|
||||
Service: "event", Command: "+fake-subscribe", Description: "x",
|
||||
Flags: []Flag{
|
||||
{Name: "json", Type: "bool", Desc: "pretty-print JSON instead of NDJSON"},
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
cmd := parseMounted(t, s, []string{"--json"})
|
||||
applyJSONShorthand(cmd, &s)
|
||||
// 注入的 format 默认即 json;这里断言的是 Changed 状态未被归一化污染
|
||||
if cmd.Flags().Changed("format") {
|
||||
t.Fatal("normalization must not touch format for shortcuts declaring their own --json")
|
||||
}
|
||||
}
|
||||
|
||||
// 无自定义 format(普通命令)→ 注入默认 format + 简写(现状回归)
|
||||
func TestJSONShorthand_DefaultInjectedFormat_StillRegistered(t *testing.T) {
|
||||
cmd := mountTestShortcut(t, Shortcut{
|
||||
Service: "im", Command: "+plain", Description: "x",
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
})
|
||||
fl := cmd.Flags().Lookup("json")
|
||||
if fl == nil {
|
||||
t.Fatal("--json missing on default-format shortcut (regression)")
|
||||
}
|
||||
if fl.Usage != jsonShorthandUsage {
|
||||
t.Errorf("usage = %q, want %q", fl.Usage, jsonShorthandUsage)
|
||||
}
|
||||
}
|
||||
@@ -150,12 +150,10 @@ var ContactSearchUser = common.Shortcut{
|
||||
{Name: "queries", Desc: "comma-separated keywords searched in parallel; output is a flat users[] with matched_query plus a queries[] sidecar"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Keyword search: lark-cli contact +search-user --query 'alice'",
|
||||
"Look up by ID (or 'me' for self): lark-cli contact +search-user --user-ids 'ou_xxx,me'",
|
||||
"Filter-only enumeration — users you've chatted with: lark-cli contact +search-user --has-chatted",
|
||||
"Refine same-name hits: lark-cli contact +search-user --query '张三' --has-chatted --exclude-external-users",
|
||||
"Multi-name fanout: lark-cli contact +search-user --queries 'alice,bob,张三'",
|
||||
"open_id is the stable identifier for follow-up commands; on has_more=true add filters or tighten --query — there is no auto-pagination.",
|
||||
"on has_more=true add filters or tighten --query — there is no auto-pagination.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateSearchUser(runtime)
|
||||
|
||||
@@ -27,12 +27,17 @@ const (
|
||||
html5BlockDataAttr = "data"
|
||||
html5BlockReferenceRoot = "doc-fetch-resources"
|
||||
html5BlockReferenceMaxRaw = 1024
|
||||
|
||||
whiteboardTag = "whiteboard"
|
||||
whiteboardTypeAttr = "type"
|
||||
whiteboardPathAttr = "path"
|
||||
)
|
||||
|
||||
var (
|
||||
html5BlockStartTagPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>`)
|
||||
html5BlockElementPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>(.*?)</html5-block>`)
|
||||
html5BlockSafeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
whiteboardElementPattern = regexp.MustCompile(`(?is)<whiteboard\b[^>]*(?:/>|>.*?</whiteboard>)`)
|
||||
)
|
||||
|
||||
type html5BlockReferenceEntry struct {
|
||||
@@ -58,6 +63,11 @@ type html5BlockStartTag struct {
|
||||
SelfClosing bool
|
||||
}
|
||||
|
||||
type whiteboardStartTag struct {
|
||||
Attrs []html5BlockAttr
|
||||
SelfClosing bool
|
||||
}
|
||||
|
||||
func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
body := buildCreateBody(runtime)
|
||||
if runtime.Str("content") == "" && !runtime.Changed("reference-map") {
|
||||
@@ -115,7 +125,11 @@ func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteIn
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
|
||||
content, html5RefMap, err := prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), input.Content, html5RefMap)
|
||||
content, err := prepareWhiteboardWriteContent(runtime, runtime.Str("doc-format"), input.Content)
|
||||
if err != nil {
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
content, html5RefMap, err = prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), content, html5RefMap)
|
||||
if err != nil {
|
||||
return docsV2WriteInput{}, err
|
||||
}
|
||||
@@ -232,6 +246,248 @@ func prepareHTML5BlockWriteContent(runtime *common.RuntimeContext, format string
|
||||
return out, compactReferenceMap(refMap), nil
|
||||
}
|
||||
|
||||
func prepareWhiteboardWriteContent(runtime *common.RuntimeContext, format string, content string) (string, error) {
|
||||
if !strings.Contains(content, "<whiteboard") {
|
||||
return content, nil
|
||||
}
|
||||
|
||||
rewrite := func(segment string) (string, error) {
|
||||
return rewriteWhiteboardFileRefs(runtime, segment)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(format) != "markdown" {
|
||||
return rewrite(content)
|
||||
}
|
||||
|
||||
var rewriteErrs []error
|
||||
out := applyOutsideCodeFences(content, func(segment string) string {
|
||||
outSegment, rewriteErr := rewrite(segment)
|
||||
if rewriteErr != nil {
|
||||
rewriteErrs = append(rewriteErrs, rewriteErr)
|
||||
return segment
|
||||
}
|
||||
return outSegment
|
||||
})
|
||||
if len(rewriteErrs) > 0 {
|
||||
return "", aggregateWhiteboardRewriteErrors(rewriteErrs)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func rewriteWhiteboardFileRefs(runtime *common.RuntimeContext, content string) (string, error) {
|
||||
var rewriteErrs []error
|
||||
out := whiteboardElementPattern.ReplaceAllStringFunc(content, func(raw string) string {
|
||||
rewritten, err := rewriteWhiteboardFileRef(runtime, raw)
|
||||
if err != nil {
|
||||
rewriteErrs = append(rewriteErrs, err)
|
||||
return raw
|
||||
}
|
||||
return rewritten
|
||||
})
|
||||
if len(rewriteErrs) > 0 {
|
||||
return "", aggregateWhiteboardRewriteErrors(rewriteErrs)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func rewriteWhiteboardFileRef(runtime *common.RuntimeContext, raw string) (string, error) {
|
||||
startRaw, body, _, ok := splitWhiteboardElement(raw)
|
||||
if !ok {
|
||||
return raw, nil
|
||||
}
|
||||
tag, err := parseWhiteboardStartTag(startRaw)
|
||||
if err != nil {
|
||||
return "", common.ValidationErrorf("invalid whiteboard tag: %v", err).WithParam("whiteboard")
|
||||
}
|
||||
|
||||
pathValue, hasPath := tag.attr(whiteboardPathAttr)
|
||||
bodyPath, hasBodyPath := whiteboardBodyPathRef(body)
|
||||
if !hasPath && !hasBodyPath {
|
||||
return raw, nil
|
||||
}
|
||||
if hasPath && strings.TrimSpace(body) != "" {
|
||||
return "", common.ValidationErrorf("whiteboard cannot contain both path and inline content").WithParam("whiteboard")
|
||||
}
|
||||
if hasPath && hasBodyPath {
|
||||
return "", common.ValidationErrorf("whiteboard cannot contain both path and @file body").WithParam("whiteboard")
|
||||
}
|
||||
|
||||
typRaw, ok := tag.attr(whiteboardTypeAttr)
|
||||
if !ok || strings.TrimSpace(typRaw) == "" {
|
||||
return "", common.ValidationErrorf("whiteboard file input requires type=\"svg\", type=\"mermaid\", or type=\"plantuml\"").WithParam("type")
|
||||
}
|
||||
typ, ok := canonicalWhiteboardFileType(typRaw)
|
||||
if !ok {
|
||||
return "", common.ValidationErrorf("whiteboard file input only supports type=\"svg\", type=\"mermaid\", or type=\"plantuml\", got %q", typRaw).WithParam("type")
|
||||
}
|
||||
|
||||
if hasBodyPath {
|
||||
pathValue = bodyPath
|
||||
}
|
||||
data, err := readWhiteboardPath(runtime, pathValue, typ)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tag.setAttr(whiteboardTypeAttr, typ)
|
||||
tag.removeAttrs(whiteboardPathAttr)
|
||||
return tag.render(false) + whiteboardContentForType(typ, data) + "</" + whiteboardTag + ">", nil
|
||||
}
|
||||
|
||||
func splitWhiteboardElement(raw string) (startTag string, body string, selfClosing bool, ok bool) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
selfClosing = strings.HasSuffix(trimmed, "/>")
|
||||
if selfClosing {
|
||||
return raw, "", true, true
|
||||
}
|
||||
startEnd := strings.Index(raw, ">")
|
||||
if startEnd < 0 {
|
||||
return "", "", false, false
|
||||
}
|
||||
endStart := strings.LastIndex(strings.ToLower(raw), "</whiteboard>")
|
||||
if endStart < 0 || endStart < startEnd {
|
||||
return "", "", false, false
|
||||
}
|
||||
return raw[:startEnd+1], raw[startEnd+1 : endStart], false, true
|
||||
}
|
||||
|
||||
func whiteboardBodyPathRef(body string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(body)
|
||||
if !strings.HasPrefix(trimmed, "@") || strings.HasPrefix(trimmed, "@@") {
|
||||
return "", false
|
||||
}
|
||||
if strings.ContainsAny(trimmed, "\r\n") {
|
||||
return "", false
|
||||
}
|
||||
return trimmed, true
|
||||
}
|
||||
|
||||
func canonicalWhiteboardFileType(raw string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "svg":
|
||||
return "svg", true
|
||||
case "mermaid":
|
||||
return "mermaid", true
|
||||
case "plantuml":
|
||||
return "plantuml", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func readWhiteboardPath(runtime *common.RuntimeContext, pathValue string, typ string) (string, error) {
|
||||
pathRaw := strings.TrimSpace(pathValue)
|
||||
if !strings.HasPrefix(pathRaw, "@") {
|
||||
return "", common.ValidationErrorf("whiteboard %s path %q must start with @, for example @diagram.%s", typ, pathValue, exampleWhiteboardExt(typ)).WithParam("path")
|
||||
}
|
||||
relPath := strings.TrimSpace(strings.TrimPrefix(pathRaw, "@"))
|
||||
if relPath == "" {
|
||||
return "", common.ValidationErrorf("whiteboard %s path cannot be empty after @", typ).WithParam("path")
|
||||
}
|
||||
clean := filepath.Clean(relPath)
|
||||
if filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return "", common.ValidationErrorf("whiteboard %s path %q must be a relative path within the current working directory", typ, pathValue).WithParam("path")
|
||||
}
|
||||
if !whiteboardExtAllowed(typ, strings.ToLower(filepath.Ext(clean))) {
|
||||
return "", common.ValidationErrorf("whiteboard %s path %q must point to a %s file", typ, pathValue, whiteboardExtList(typ)).WithParam("path")
|
||||
}
|
||||
data, err := cmdutil.ReadInputFile(runtime.FileIO(), clean)
|
||||
if err != nil {
|
||||
return "", common.ValidationErrorf("whiteboard %s path %q cannot be read from the current working directory; check that the file exists relative to where lark-cli is running: %v", typ, clean, err).
|
||||
WithParam("path").
|
||||
WithParams(errs.InvalidParam{Name: clean, Reason: fmt.Sprintf("whiteboard %s path cannot be read", typ)}).
|
||||
WithCause(err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func whiteboardExtAllowed(typ string, ext string) bool {
|
||||
for _, allowed := range whiteboardAllowedExts(typ) {
|
||||
if ext == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func whiteboardAllowedExts(typ string) []string {
|
||||
switch typ {
|
||||
case "svg":
|
||||
return []string{".svg"}
|
||||
case "mermaid":
|
||||
return []string{".mermaid", ".mmd"}
|
||||
case "plantuml":
|
||||
return []string{".plantuml", ".puml", ".pu", ".uml"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func whiteboardExtList(typ string) string {
|
||||
return strings.Join(whiteboardAllowedExts(typ), ", ")
|
||||
}
|
||||
|
||||
func exampleWhiteboardExt(typ string) string {
|
||||
exts := whiteboardAllowedExts(typ)
|
||||
if len(exts) == 0 {
|
||||
return "txt"
|
||||
}
|
||||
return strings.TrimPrefix(exts[0], ".")
|
||||
}
|
||||
|
||||
func whiteboardContentForType(typ string, data string) string {
|
||||
if typ == "svg" {
|
||||
return data
|
||||
}
|
||||
return escapeXMLText(data)
|
||||
}
|
||||
|
||||
func aggregateWhiteboardRewriteErrors(rewriteErrs []error) error {
|
||||
flatErrs := flattenWhiteboardRewriteErrors(rewriteErrs)
|
||||
messages := make([]string, 0, len(flatErrs))
|
||||
params := make([]errs.InvalidParam, 0, len(flatErrs))
|
||||
for _, err := range flatErrs {
|
||||
messages = append(messages, err.Error())
|
||||
params = append(params, whiteboardInvalidParamsFromError(err)...)
|
||||
}
|
||||
validationErr := common.ValidationErrorf("whiteboard file input failed: %s", strings.Join(messages, "; ")).
|
||||
WithParam("whiteboard").
|
||||
WithCause(errors.Join(flatErrs...))
|
||||
if len(params) > 0 {
|
||||
validationErr.WithParams(params...)
|
||||
}
|
||||
return validationErr
|
||||
}
|
||||
|
||||
func flattenWhiteboardRewriteErrors(rewriteErrs []error) []error {
|
||||
flatErrs := make([]error, 0, len(rewriteErrs))
|
||||
for _, err := range rewriteErrs {
|
||||
var validationErr *errs.ValidationError
|
||||
if errors.As(err, &validationErr) && validationErr.Param == "whiteboard" && validationErr.Cause != nil {
|
||||
if joined, ok := validationErr.Cause.(interface{ Unwrap() []error }); ok {
|
||||
flatErrs = append(flatErrs, flattenWhiteboardRewriteErrors(joined.Unwrap())...)
|
||||
continue
|
||||
}
|
||||
}
|
||||
flatErrs = append(flatErrs, err)
|
||||
}
|
||||
return flatErrs
|
||||
}
|
||||
|
||||
func whiteboardInvalidParamsFromError(err error) []errs.InvalidParam {
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
return nil
|
||||
}
|
||||
if len(validationErr.Params) > 0 {
|
||||
return validationErr.Params
|
||||
}
|
||||
if validationErr.Param != "" {
|
||||
return []errs.InvalidParam{{Name: validationErr.Param, Reason: validationErr.Message}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateHTML5BlockWriteElementBodies(format string, content string) error {
|
||||
validateSegment := func(segment string) error {
|
||||
matches := html5BlockElementPattern.FindAllStringSubmatchIndex(segment, -1)
|
||||
@@ -621,6 +877,34 @@ func parseHTML5BlockStartTag(raw string) (html5BlockStartTag, error) {
|
||||
return html5BlockStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
|
||||
}
|
||||
|
||||
func parseWhiteboardStartTag(raw string) (whiteboardStartTag, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
selfClosing := strings.HasSuffix(trimmed, "/>")
|
||||
decoder := xml.NewDecoder(strings.NewReader(raw))
|
||||
for {
|
||||
tok, err := decoder.Token()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return whiteboardStartTag{}, err
|
||||
}
|
||||
start, ok := tok.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if start.Name.Local != whiteboardTag {
|
||||
return whiteboardStartTag{}, fmt.Errorf("expected <%s>, got <%s>", whiteboardTag, start.Name.Local) //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
|
||||
}
|
||||
attrs := make([]html5BlockAttr, 0, len(start.Attr))
|
||||
for _, attr := range start.Attr {
|
||||
attrs = append(attrs, html5BlockAttr{Name: attr.Name.Local, Value: attr.Value})
|
||||
}
|
||||
return whiteboardStartTag{Attrs: attrs, SelfClosing: selfClosing}, nil
|
||||
}
|
||||
return whiteboardStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
|
||||
}
|
||||
|
||||
func (t html5BlockStartTag) attr(name string) (string, bool) {
|
||||
for _, attr := range t.Attrs {
|
||||
if attr.Name == name {
|
||||
@@ -630,6 +914,15 @@ func (t html5BlockStartTag) attr(name string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (t whiteboardStartTag) attr(name string) (string, bool) {
|
||||
for _, attr := range t.Attrs {
|
||||
if attr.Name == name {
|
||||
return attr.Value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (t html5BlockStartTag) hasAttr(name string) bool {
|
||||
_, ok := t.attr(name)
|
||||
return ok
|
||||
@@ -650,6 +943,31 @@ func (t *html5BlockStartTag) removeAttrs(names ...string) {
|
||||
t.Attrs = attrs
|
||||
}
|
||||
|
||||
func (t *whiteboardStartTag) removeAttrs(names ...string) {
|
||||
remove := make(map[string]struct{}, len(names))
|
||||
for _, name := range names {
|
||||
remove[name] = struct{}{}
|
||||
}
|
||||
attrs := t.Attrs[:0]
|
||||
for _, attr := range t.Attrs {
|
||||
if _, ok := remove[attr.Name]; ok {
|
||||
continue
|
||||
}
|
||||
attrs = append(attrs, attr)
|
||||
}
|
||||
t.Attrs = attrs
|
||||
}
|
||||
|
||||
func (t *whiteboardStartTag) setAttr(name string, value string) {
|
||||
for i, attr := range t.Attrs {
|
||||
if attr.Name == name {
|
||||
t.Attrs[i].Value = value
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Attrs = append(t.Attrs, html5BlockAttr{Name: name, Value: value})
|
||||
}
|
||||
|
||||
func (t html5BlockStartTag) render(selfClosing bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('<')
|
||||
@@ -674,6 +992,25 @@ func (t html5BlockStartTag) render(selfClosing bool) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (t whiteboardStartTag) render(selfClosing bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('<')
|
||||
b.WriteString(whiteboardTag)
|
||||
for _, attr := range t.Attrs {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(attr.Name)
|
||||
b.WriteString(`="`)
|
||||
b.WriteString(escapeXMLAttr(attr.Value))
|
||||
b.WriteByte('"')
|
||||
}
|
||||
if selfClosing {
|
||||
b.WriteString("/>")
|
||||
} else {
|
||||
b.WriteByte('>')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func escapeXMLAttr(value string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
@@ -694,3 +1031,18 @@ func escapeXMLAttr(value string) string {
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func escapeXMLText(value string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
switch r {
|
||||
case '&':
|
||||
b.WriteString("&")
|
||||
case '<':
|
||||
b.WriteString("<")
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@ package doc
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -116,6 +118,61 @@ func TestDocsCreateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2WhiteboardFileInputs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
files := map[string]string{
|
||||
"diagram.svg": `<svg viewBox="0 0 10 10"><text>A</text></svg>`,
|
||||
"flow.mmd": "flowchart TD\nA --> B",
|
||||
"sequence.puml": "@startuml\nAlice -> Bob: hi\n@enduml",
|
||||
}
|
||||
for name, content := range files {
|
||||
if err := os.WriteFile(name, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(%s) error: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_new_doc",
|
||||
"revision_id": float64(1),
|
||||
},
|
||||
})
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", strings.Join([]string{
|
||||
`<whiteboard type="svg" path="@diagram.svg"></whiteboard>`,
|
||||
`<whiteboard type="mermaid">@flow.mmd</whiteboard>`,
|
||||
`<whiteboard type="plantUML" path="@sequence.puml"/>`,
|
||||
}, "\n"),
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeRequestBody(t, stub.CapturedBody)
|
||||
got := body["content"].(string)
|
||||
for _, want := range []string{
|
||||
`<whiteboard type="svg"><svg viewBox="0 0 10 10"><text>A</text></svg></whiteboard>`,
|
||||
"<whiteboard type=\"mermaid\">flowchart TD\nA --> B</whiteboard>",
|
||||
"<whiteboard type=\"plantuml\">@startuml\nAlice -> Bob: hi\n@enduml</whiteboard>",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("content missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, `path="@`) {
|
||||
t.Fatalf("content still contains whiteboard path attr: %s", got)
|
||||
}
|
||||
if _, ok := body["reference_map"]; ok {
|
||||
t.Fatalf("whiteboard file input must not create reference_map: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func findDocsTestFlag(flags []common.Flag, name string) common.Flag {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == name {
|
||||
@@ -407,6 +464,119 @@ func TestDocsCreateV2HTML5BlockPathReadFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2WhiteboardFileInputReportsAllMissingPaths(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", strings.Join([]string{
|
||||
`<whiteboard type="svg" path="@missing.svg"></whiteboard>`,
|
||||
`<whiteboard type="mermaid">@missing.mmd</whiteboard>`,
|
||||
`<whiteboard type="plantuml" path="@missing.puml"></whiteboard>`,
|
||||
}, "\n"),
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected aggregated whiteboard path error")
|
||||
}
|
||||
assertWhiteboardFileInputValidation(t, err, []string{
|
||||
"missing.svg",
|
||||
"missing.mmd",
|
||||
"missing.puml",
|
||||
}, []string{
|
||||
`whiteboard svg path "missing.svg" cannot be read`,
|
||||
`whiteboard mermaid path "missing.mmd" cannot be read`,
|
||||
`whiteboard plantuml path "missing.puml" cannot be read`,
|
||||
})
|
||||
}
|
||||
|
||||
func TestDocsCreateV2WhiteboardFileInputMarkdownReportsMissingPathsAcrossFences(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--doc-format", "markdown",
|
||||
"--content", strings.Join([]string{
|
||||
`<whiteboard type="svg" path="@before.svg"></whiteboard>`,
|
||||
"```",
|
||||
`<whiteboard type="svg" path="@inside.svg"></whiteboard>`,
|
||||
"```",
|
||||
`<whiteboard type="plantuml" path="@after.puml"></whiteboard>`,
|
||||
}, "\n"),
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected aggregated whiteboard path error")
|
||||
}
|
||||
assertWhiteboardFileInputValidation(t, err, []string{
|
||||
"before.svg",
|
||||
"after.puml",
|
||||
}, []string{
|
||||
`whiteboard svg path "before.svg" cannot be read`,
|
||||
`whiteboard plantuml path "after.puml" cannot be read`,
|
||||
})
|
||||
if strings.Contains(err.Error(), "inside.svg") {
|
||||
t.Fatalf("error should ignore fenced whiteboard path, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertWhiteboardFileInputValidation(t *testing.T, err error, wantParams []string, wantMessages []string) {
|
||||
t.Helper()
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("category/subtype = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "whiteboard" {
|
||||
t.Fatalf("param = %q, want whiteboard", validationErr.Param)
|
||||
}
|
||||
if validationErr.Cause == nil {
|
||||
t.Fatal("expected aggregated error to preserve cause")
|
||||
}
|
||||
var childValidationErr *errs.ValidationError
|
||||
if !errors.As(validationErr.Cause, &childValidationErr) || childValidationErr.Cause == nil {
|
||||
t.Fatalf("expected child validation cause to preserve file read cause, got %#v", validationErr.Cause)
|
||||
}
|
||||
|
||||
gotParams := make(map[string]string, len(validationErr.Params))
|
||||
for _, param := range validationErr.Params {
|
||||
gotParams[param.Name] = param.Reason
|
||||
}
|
||||
if len(gotParams) != len(wantParams) {
|
||||
t.Fatalf("params = %#v, want names %v", validationErr.Params, wantParams)
|
||||
}
|
||||
for _, param := range wantParams {
|
||||
reason, ok := gotParams[param]
|
||||
if !ok {
|
||||
t.Fatalf("params = %#v, want name %q", validationErr.Params, param)
|
||||
}
|
||||
if reason == "" {
|
||||
t.Fatalf("param %q missing reason: %#v", param, validationErr.Params)
|
||||
}
|
||||
}
|
||||
for _, want := range wantMessages {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error missing %q:\n%v", want, err)
|
||||
}
|
||||
if !strings.Contains(validationErr.Cause.Error(), want) {
|
||||
t.Fatalf("cause missing %q:\n%v", want, validationErr.Cause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2HTML5BlockRejectsInlineContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
@@ -623,6 +623,10 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
|
||||
case problem.Subtype == errs.SubtypeRateLimit || problem.Code == 99991400:
|
||||
decision.Class = "rate_limited"
|
||||
decision.Terminal = true
|
||||
case problem.Code == 1062507:
|
||||
decision.Class = "parent_sibling_limit"
|
||||
decision.Terminal = true
|
||||
decision.Hint = "The destination parent folder has reached its child-count limit. Clean up that folder, choose another --folder-token, or split the upload across subfolders before retrying."
|
||||
case problem.Subtype == errs.SubtypeQuotaExceeded || problem.Code == 1061043:
|
||||
decision.Class = "file_size_limit"
|
||||
case problem.Code == 1062009:
|
||||
|
||||
@@ -1334,6 +1334,75 @@ func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushAbortsAfterCreateFolderParentSiblingLimit(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll(filepath.Join("local", "a"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll a: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join("local", "b"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll b: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/create_folder",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1062507,
|
||||
"msg": "parent node out of sibling num.",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Fatalf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
if got := summary["aborted"]; got != true {
|
||||
t.Fatalf("summary.aborted = %v, want true", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item["rel_path"] != "a" || item["phase"] != "create_folder" || item["error_class"] != "parent_sibling_limit" {
|
||||
t.Fatalf("unexpected failed item: %#v", item)
|
||||
}
|
||||
if item["code"] != float64(1062507) || item["subtype"] != "quota_exceeded" || item["retryable"] != false {
|
||||
t.Fatalf("unexpected failure metadata: %#v", item)
|
||||
}
|
||||
if got, _ := item["hint"].(string); !strings.Contains(got, "--folder-token") || !strings.Contains(got, "child-count limit") {
|
||||
t.Fatalf("hint should explain the destination folder child-count limit, got item=%#v", item)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item["rel_path"] == "b" {
|
||||
t.Fatalf("parent sibling limit must abort before b, got items=%#v", items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushDetectsLocalFileChangedBeforeUpload(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
larkevent "github.com/larksuite/oapi-sdk-go/v3/event"
|
||||
"github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
|
||||
@@ -247,10 +246,7 @@ var EventSubscribe = common.Shortcut{
|
||||
}
|
||||
|
||||
// --- WebSocket ---
|
||||
domain := lark.FeishuBaseUrl
|
||||
if runtime.Config.Brand == core.BrandLark {
|
||||
domain = lark.LarkBaseUrl
|
||||
}
|
||||
domain := core.ResolveEndpoints(runtime.Config.Brand).Open
|
||||
|
||||
info(fmt.Sprintf("%sConnecting to Lark event WebSocket...%s", output.Cyan, output.Reset))
|
||||
if eventTypeFilter != nil {
|
||||
|
||||
24
shortcuts/event/subscribe_test.go
Normal file
24
shortcuts/event/subscribe_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
)
|
||||
|
||||
// TestWSDomainMatchesResolver guards the invariant that lets the event
|
||||
// WebSocket domain be resolved via core.ResolveEndpoints instead of the SDK
|
||||
// base-URL constants: the resolver's Open host must equal the SDK's per-brand
|
||||
// base URL. If the SDK constants ever drift from the resolver, this fails.
|
||||
func TestWSDomainMatchesResolver(t *testing.T) {
|
||||
if got, want := core.ResolveEndpoints(core.BrandFeishu).Open, lark.FeishuBaseUrl; got != want {
|
||||
t.Errorf("feishu WS domain = %q, want SDK %q", got, want)
|
||||
}
|
||||
if got, want := core.ResolveEndpoints(core.BrandLark).Open, lark.LarkBaseUrl; got != want {
|
||||
t.Errorf("lark WS domain = %q, want SDK %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -51,9 +51,8 @@ func hintSendDraft(runtime *common.RuntimeContext, mailboxID, draftID string) {
|
||||
// original message as read after a reply/reply-all/forward operation.
|
||||
func hintMarkAsRead(runtime *common.RuntimeContext, mailboxID, originalMessageID string) {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"tip: mark original as read? lark-cli mail user_mailbox.messages batch_modify_message"+
|
||||
` --params '{"user_mailbox_id":"%s"}' --data '{"message_ids":["%s"],"remove_label_ids":["UNREAD"]}'`+"\n",
|
||||
sanitizeForTerminal(mailboxID), sanitizeForTerminal(originalMessageID))
|
||||
"tip: mark original as read? lark-cli mail +message-modify --mailbox '%s' --message-ids '%s' --remove-label-ids UNREAD\n",
|
||||
shellQuoteForHint(mailboxID), shellQuoteForHint(originalMessageID))
|
||||
}
|
||||
|
||||
// hintReadReceiptRequest prints a stderr tip when a message that the caller
|
||||
|
||||
@@ -465,14 +465,19 @@ func TestPrintWatchOutputSchema(t *testing.T) {
|
||||
// TestHintMarkAsRead verifies hint mark as read.
|
||||
func TestHintMarkAsRead(t *testing.T) {
|
||||
rt, _, stderr := newOutputRuntime(t)
|
||||
// Inject ANSI escape + message ID to verify sanitization
|
||||
hintMarkAsRead(rt, "me", "msg-\x1b[31m123")
|
||||
hintMarkAsRead(rt, "mail box;$(whoami)", "msg-\x1b[31m123 'quoted'\nnext")
|
||||
out := stderr.String()
|
||||
if strings.Contains(out, "\x1b[") {
|
||||
t.Errorf("hintMarkAsRead should sanitize ANSI escapes, got: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "msg-123") {
|
||||
t.Errorf("hintMarkAsRead should contain sanitized message ID, got: %q", out)
|
||||
if strings.Contains(out, "\nnext") {
|
||||
t.Errorf("hintMarkAsRead should strip embedded newlines, got: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "--mailbox 'mail box;$(whoami)'") {
|
||||
t.Errorf("hintMarkAsRead should quote mailbox for shell copy/paste, got: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "--message-ids 'msg-123 '\\''quoted'\\''next'") {
|
||||
t.Errorf("hintMarkAsRead should quote message ID for shell copy/paste, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
112
shortcuts/mail/mail_json_shorthand_test.go
Normal file
112
shortcuts/mail/mail_json_shorthand_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// help 必须列出 --json 简写
|
||||
func TestMailTriageHelpListsJSONShorthand(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
if err := runMountedMailShortcutWithCobraOutput(t, MailTriage, []string{"+triage", "-h"}, f, stdout); err != nil {
|
||||
t.Fatalf("help returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "shorthand for --format json") {
|
||||
t.Fatalf("triage help missing --json shorthand\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailWatchHelpListsJSONShorthand(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
if err := runMountedMailShortcutWithCobraOutput(t, MailWatch, []string{"+watch", "-h"}, f, stdout); err != nil {
|
||||
t.Fatalf("help returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "shorthand for --format json") {
|
||||
t.Fatalf("watch help missing --json shorthand\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 行为验证:--json 走 JSON 输出路径,不输出 table read hint
|
||||
func TestMailTriageJSONShorthandDoesNotEmitReadHint(t *testing.T) {
|
||||
f, stdout, stderr, reg := mailShortcutTestFactory(t)
|
||||
registerTriageReadHintStubs(reg)
|
||||
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--json", "--max", "1"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("triage --json returned error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
if strings.Contains(stderr.String(), "tip: read full content:") {
|
||||
t.Fatalf("--json must follow the JSON path, got table hint\nstderr=%s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"messages"`) {
|
||||
t.Fatalf("--json stdout missing JSON payload\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 等价性验证:--json 与 --format json 的 dry-run 输出一致
|
||||
func TestMailTriageJSONShorthandDryRunEquivalence(t *testing.T) {
|
||||
f1, stdout1, _, _ := mailShortcutTestFactory(t)
|
||||
if err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--json", "--max", "1", "--dry-run"}, f1, stdout1); err != nil {
|
||||
t.Fatalf("--json --dry-run error: %v", err)
|
||||
}
|
||||
f2, stdout2, _, _ := mailShortcutTestFactory(t)
|
||||
if err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--format", "json", "--max", "1", "--dry-run"}, f2, stdout2); err != nil {
|
||||
t.Fatalf("--format json --dry-run error: %v", err)
|
||||
}
|
||||
if stdout1.String() != stdout2.String() {
|
||||
t.Fatalf("dry-run outputs differ:\n--json:\n%s\n--format json:\n%s", stdout1.String(), stdout2.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 优先级验证:显式 --format table 优先,--json 让位 → 仍走 table 路径
|
||||
func TestMailTriageExplicitTableWinsOverJSONShorthand(t *testing.T) {
|
||||
f, stdout, stderr, reg := mailShortcutTestFactory(t)
|
||||
registerTriageReadHintStubs(reg)
|
||||
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--format", "table", "--json", "--max", "1"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("triage returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "tip: read full content:") {
|
||||
t.Fatalf("explicit --format table must win over --json (expected table hint)\nstderr=%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 错误验证:Enum 硬校验
|
||||
func TestMailTriageEnumRejectsUnknownFormat(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--format", "bogus", "--max", "1", "--dry-run"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for --format bogus")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error = %T, want typed errs problem carrier", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryValidation)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if ve.Param != "--format" {
|
||||
t.Fatalf("param = %q, want --format", ve.Param)
|
||||
}
|
||||
if !strings.Contains(problem.Message, `invalid value "bogus" for --format`) {
|
||||
t.Fatalf("message = %q, want enum validation message", problem.Message)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "table, json, data") {
|
||||
t.Fatalf("message = %q, want allowed values list", problem.Message)
|
||||
}
|
||||
}
|
||||
482
shortcuts/mail/mail_message_manage_test.go
Normal file
482
shortcuts/mail/mail_message_manage_test.go
Normal file
@@ -0,0 +1,482 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func messageManageID(suffix string) string {
|
||||
return "msg_abcdefghijklmnop_" + suffix
|
||||
}
|
||||
|
||||
func stubMessageManagePost(reg *httpmock.Registry, endpoint string, body map[string]interface{}) *httpmock.Stub {
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/messages/" + endpoint,
|
||||
Body: body,
|
||||
}
|
||||
reg.Register(stub)
|
||||
return stub
|
||||
}
|
||||
|
||||
func decodeMessageManageSummary(t *testing.T, data map[string]interface{}) ([]interface{}, []interface{}) {
|
||||
t.Helper()
|
||||
success, ok := data["success_message_ids"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("success_message_ids = %#v, want array", data["success_message_ids"])
|
||||
}
|
||||
failed, ok := data["failed_message_ids"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("failed_message_ids = %#v, want array", data["failed_message_ids"])
|
||||
}
|
||||
return success, failed
|
||||
}
|
||||
|
||||
func requireMessageManageValidationParam(t *testing.T, err error, param string) *errs.ValidationError {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for %s, got nil", param)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError for %s, got %T", param, err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed Problem for %s, got %T", param, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
||||
}
|
||||
if validationErr.Param != param {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, param)
|
||||
}
|
||||
return validationErr
|
||||
}
|
||||
|
||||
func requireMessageManageFailedPrecondition(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected failed precondition error, got nil")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed Problem, got %T", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("problem = %s/%s, want validation/failed_precondition", problem.Category, problem.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageManage_NormalizeMessageIDs(t *testing.T) {
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
got, err := normalizeMessageManageIDs([]string{id1, id2, id1})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeMessageManageIDs returned error: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
|
||||
t.Fatalf("ids = %v, want [%s %s]", got, id1, id2)
|
||||
}
|
||||
got, err = normalizeMessageManageIDs([]string{id1 + "," + id2, id1})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeMessageManageIDs CSV/repeated returned error: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
|
||||
t.Fatalf("CSV/repeated ids = %v, want [%s %s]", got, id1, id2)
|
||||
}
|
||||
|
||||
cases := [][]string{
|
||||
{""},
|
||||
{" id_with_leading_space_12345"},
|
||||
{"msg_abcdefghijklmnop_1,msg_abcdefghijklmnop_2 "},
|
||||
{"1234567890123456"},
|
||||
{"short"},
|
||||
{"msg_abcdefghijklmnop!"},
|
||||
{"msg_abcdefghijklmnop\t"},
|
||||
{"msg_abcdefghijklmnop_1\nmsg_abcdefghijklmnop_2"},
|
||||
{"msg_abcdefghijklmnop_1", "msg_abcdefghijklmnop_2 "},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
_, err := normalizeMessageManageIDs(tc)
|
||||
requireMessageManageValidationParam(t, err, "--message-ids")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_Metadata(t *testing.T) {
|
||||
if MailMessageModify.Command != "+message-modify" {
|
||||
t.Fatalf("Command = %q", MailMessageModify.Command)
|
||||
}
|
||||
if MailMessageModify.Risk != "write" {
|
||||
t.Errorf("Risk = %q, want write", MailMessageModify.Risk)
|
||||
}
|
||||
if len(MailMessageModify.AuthTypes) != 1 || MailMessageModify.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v, want [user]", MailMessageModify.AuthTypes)
|
||||
}
|
||||
requiredScopes := map[string]bool{
|
||||
"mail:user_mailbox.message:modify": true,
|
||||
}
|
||||
for _, scope := range MailMessageModify.Scopes {
|
||||
delete(requiredScopes, scope)
|
||||
}
|
||||
if len(requiredScopes) != 0 {
|
||||
t.Errorf("Scopes missing %v", requiredScopes)
|
||||
}
|
||||
if len(MailMessageModify.ConditionalScopes) != 1 || MailMessageModify.ConditionalScopes[0] != "mail:user_mailbox.folder:read" {
|
||||
t.Errorf("ConditionalScopes = %v, want [mail:user_mailbox.folder:read]", MailMessageModify.ConditionalScopes)
|
||||
}
|
||||
flags := map[string]common.Flag{}
|
||||
for _, fl := range MailMessageModify.Flags {
|
||||
flags[fl.Name] = fl
|
||||
}
|
||||
for _, name := range []string{"mailbox", "message-ids", "add-label-ids", "remove-label-ids", "add-folder"} {
|
||||
if _, ok := flags[name]; !ok {
|
||||
t.Fatalf("missing --%s flag", name)
|
||||
}
|
||||
}
|
||||
if flags["message-ids"].Type != "string_array" || !flags["message-ids"].Required {
|
||||
t.Errorf("--message-ids = %#v, want required string_array", flags["message-ids"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTrash_Metadata(t *testing.T) {
|
||||
if MailMessageTrash.Command != "+message-trash" {
|
||||
t.Fatalf("Command = %q", MailMessageTrash.Command)
|
||||
}
|
||||
if MailMessageTrash.Risk != "high-risk-write" {
|
||||
t.Errorf("Risk = %q, want high-risk-write", MailMessageTrash.Risk)
|
||||
}
|
||||
if len(MailMessageTrash.AuthTypes) != 1 || MailMessageTrash.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v, want [user]", MailMessageTrash.AuthTypes)
|
||||
}
|
||||
if len(MailMessageTrash.Scopes) != 1 || MailMessageTrash.Scopes[0] != "mail:user_mailbox.message:modify" {
|
||||
t.Errorf("Scopes = %v, want [mail:user_mailbox.message:modify]", MailMessageTrash.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_LabelOnlyDoesNotRequireFolderReadScope(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
token := auth.GetStoredToken("test-app", "ou_testuser")
|
||||
if token == nil {
|
||||
t.Fatal("expected test token")
|
||||
}
|
||||
token.Scope = strings.ReplaceAll(token.Scope, " mail:user_mailbox.folder:read", "")
|
||||
if err := auth.SetStoredToken(token); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
id := messageManageID("1")
|
||||
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--remove-label-ids", "UNREAD",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
removeLabels := body["remove_label_ids"].([]interface{})
|
||||
if len(removeLabels) != 1 || removeLabels[0] != "UNREAD" {
|
||||
t.Fatalf("remove_label_ids = %#v, want [UNREAD]", removeLabels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_ReadReceiptRequestLabelIsSystemLabel(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--remove-label-ids", "read_receipt_request",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
removeLabels := body["remove_label_ids"].([]interface{})
|
||||
if len(removeLabels) != 1 || removeLabels[0] != "READ_RECEIPT_REQUEST" {
|
||||
t.Fatalf("remove_label_ids = %#v, want [READ_RECEIPT_REQUEST]", removeLabels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_LabelFolderNormalizationAndValidationAPIs(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/labels/customA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"label_id": "customA"}}})
|
||||
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/folders/folderA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"folder_id": "folderA"}}})
|
||||
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--add-label-ids", "unread,customA",
|
||||
"--remove-label-ids", "FLAGGED",
|
||||
"--add-folder", "folderA",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
if got := body["add_folder"]; got != "folderA" {
|
||||
t.Errorf("add_folder = %v, want folderA", got)
|
||||
}
|
||||
addLabels := body["add_label_ids"].([]interface{})
|
||||
if addLabels[0] != "UNREAD" || addLabels[1] != "customA" {
|
||||
t.Errorf("add_label_ids = %#v, want [UNREAD customA]", addLabels)
|
||||
}
|
||||
removeLabels := body["remove_label_ids"].([]interface{})
|
||||
if removeLabels[0] != "FLAGGED" {
|
||||
t.Errorf("remove_label_ids = %#v, want [FLAGGED]", removeLabels)
|
||||
}
|
||||
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||
if len(success) != 1 || success[0] != id || len(failed) != 0 {
|
||||
t.Errorf("summary success=%v failed=%v", success, failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_RejectsLabelIntersectionAndTrashFolder(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--add-label-ids", "unread",
|
||||
"--remove-label-ids", "UNREAD",
|
||||
}, f, stdout)
|
||||
requireMessageManageValidationParam(t, err, "--add-label-ids")
|
||||
if !strings.Contains(err.Error(), "label cannot be both added and removed") {
|
||||
t.Fatalf("error = %v, want label intersection validation", err)
|
||||
}
|
||||
|
||||
err = runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--add-folder", "trash",
|
||||
}, f, stdout)
|
||||
requireMessageManageValidationParam(t, err, "--add-folder")
|
||||
if !strings.Contains(err.Error(), "use +message-trash") {
|
||||
t.Fatalf("error = %v, want TRASH validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_EmptyOperationDoesNotCallPost(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id1 + "," + id2 + "," + id1,
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||
if len(success) != 2 || success[0] != id1 || success[1] != id2 || len(failed) != 0 {
|
||||
t.Fatalf("summary success=%v failed=%v", success, failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_BatchesAndAggregatesPartialFailure(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
ids := make([]string, 41)
|
||||
for i := range ids {
|
||||
ids[i] = messageManageID(fmt.Sprintf("%02d", i))
|
||||
}
|
||||
first := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
second := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
|
||||
third := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", strings.Join(ids, ","),
|
||||
"--add-folder", "archive",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
for idx, stub := range []*httpmock.Stub{first, second, third} {
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("batch %d body unmarshal: %v", idx+1, err)
|
||||
}
|
||||
messageIDs := body["message_ids"].([]interface{})
|
||||
want := []int{20, 20, 1}[idx]
|
||||
if len(messageIDs) != want {
|
||||
t.Fatalf("batch %d size = %d, want %d", idx+1, len(messageIDs), want)
|
||||
}
|
||||
if body["add_folder"] != "ARCHIVED" {
|
||||
t.Fatalf("batch %d add_folder = %v, want ARCHIVED", idx+1, body["add_folder"])
|
||||
}
|
||||
}
|
||||
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||
if len(success) != 21 || len(failed) != 20 {
|
||||
t.Fatalf("success=%d failed=%d, want 21/20", len(success), len(failed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_AllBatchesFailReturnsError(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--add-folder", "archive",
|
||||
}, f, stdout)
|
||||
requireMessageManageFailedPrecondition(t, err)
|
||||
}
|
||||
|
||||
func TestMessageModify_DryRunShowsPlanWithoutValidationGET(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id1 + "," + id2,
|
||||
"--add-label-ids", "customA",
|
||||
"--add-folder", "folderA",
|
||||
"--dry-run",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run failed: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
`/user_mailboxes/me/messages/batch_modify`,
|
||||
`validation_api_plan`,
|
||||
`/user_mailboxes/me/labels/customA`,
|
||||
`/user_mailboxes/me/folders/folderA`,
|
||||
`will_validate`,
|
||||
`batch_size`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q; got %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTrash_RequiresYesAndBatches(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
err := runMountedMailShortcut(t, MailMessageTrash, []string{
|
||||
"+message-trash",
|
||||
"--message-ids", id1 + "," + id2,
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation error, got nil")
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitConfirmationRequired {
|
||||
t.Fatalf("exit code = %d, want %d", code, output.ExitConfirmationRequired)
|
||||
}
|
||||
|
||||
post := stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
err = runMountedMailShortcut(t, MailMessageTrash, []string{
|
||||
"+message-trash",
|
||||
"--message-ids", id1 + "," + id2,
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err with --yes: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
if got := len(body["message_ids"].([]interface{})); got != 2 {
|
||||
t.Fatalf("message_ids len = %d, want 2", got)
|
||||
}
|
||||
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||
if len(success) != 2 || len(failed) != 0 {
|
||||
t.Fatalf("summary success=%v failed=%v", success, failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTrash_AllBatchesFailReturnsError(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 1230001, "msg": "bad request"})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageTrash, []string{
|
||||
"+message-trash",
|
||||
"--message-ids", id,
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
requireMessageManageFailedPrecondition(t, err)
|
||||
}
|
||||
|
||||
func TestMessageManage_RejectsWhitespaceBeforeAPI(t *testing.T) {
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
cases := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "trash newline in repeated flag",
|
||||
shortcut: MailMessageTrash,
|
||||
args: []string{"+message-trash", "--message-ids", id1 + "\n" + id2, "--yes"},
|
||||
},
|
||||
{
|
||||
name: "trash tab in csv flag",
|
||||
shortcut: MailMessageTrash,
|
||||
args: []string{"+message-trash", "--message-ids", id1 + ",\t" + id2, "--yes"},
|
||||
},
|
||||
{
|
||||
name: "modify space in repeated flag",
|
||||
shortcut: MailMessageModify,
|
||||
args: []string{"+message-modify", "--message-ids", id1, "--message-ids", id2 + " ", "--add-folder", "archive"},
|
||||
},
|
||||
{
|
||||
name: "modify space in csv flag",
|
||||
shortcut: MailMessageModify,
|
||||
args: []string{"+message-modify", "--message-ids", id1 + ", " + id2, "--add-folder", "archive"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, tc.shortcut, tc.args, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d; err=%v", code, output.ExitValidation, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "must not contain whitespace or control characters") {
|
||||
t.Fatalf("error = %v, want whitespace/control validation", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
141
shortcuts/mail/mail_message_modify.go
Normal file
141
shortcuts/mail/mail_message_modify.go
Normal file
@@ -0,0 +1,141 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type messageModifyInput struct {
|
||||
MessageIDs []string
|
||||
AddLabelIDs []string
|
||||
RemoveLabelIDs []string
|
||||
AddFolder string
|
||||
CustomLabelIDs []string
|
||||
CustomFolderID string
|
||||
ValidationAPIPlans []validationAPIPlan
|
||||
}
|
||||
|
||||
// MailMessageModify is the `+message-modify` shortcut: apply labels, unread
|
||||
// state labels, or a folder move to existing messages in batches of 20.
|
||||
var MailMessageModify = common.Shortcut{
|
||||
Service: "mail",
|
||||
Command: "+message-modify",
|
||||
Description: "Modify existing mail messages by adding/removing label IDs or moving them to a folder. Batches message IDs in groups of 20 and keeps output compact.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"mail:user_mailbox.message:modify"},
|
||||
ConditionalScopes: []string{
|
||||
"mail:user_mailbox.folder:read",
|
||||
},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
|
||||
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to modify; comma-separated or repeat the flag."},
|
||||
{Name: "add-label-ids", Type: "string_slice", Desc: "Label IDs to add. System labels unread/important/other/flagged are normalized to upper case."},
|
||||
{Name: "remove-label-ids", Type: "string_slice", Desc: "Label IDs to remove. System labels unread/important/other/flagged are normalized to upper case."},
|
||||
{Name: "add-folder", Desc: "Folder ID to move messages to. System folders inbox/sent/spam/archive/archived are normalized; TRASH is rejected, use +message-trash."},
|
||||
},
|
||||
Validate: validateMessageModify,
|
||||
DryRun: dryRunMessageModify,
|
||||
Execute: executeMessageModify,
|
||||
}
|
||||
|
||||
func validateMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
_, err := buildMessageModifyInput(rt)
|
||||
return err
|
||||
}
|
||||
|
||||
func dryRunMessageModify(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveMailboxID(rt)
|
||||
input, _ := buildMessageModifyInput(rt)
|
||||
api := common.NewDryRunAPI().
|
||||
Desc("Modify messages sequentially in batches of 20; dry-run does not call label/folder validation APIs").
|
||||
Set("batch_size", mailMessageManageBatchSize).
|
||||
Set("batches", chunkMessageManageIDs(input.MessageIDs)).
|
||||
Set("validation_api_plan", input.ValidationAPIPlans)
|
||||
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
|
||||
api = api.POST(mailboxPath(mailboxID, "messages", "batch_modify")).
|
||||
Body(messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
func executeMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
mailboxID := resolveMailboxID(rt)
|
||||
input, err := buildMessageModifyInput(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateCustomMessageManageLabels(rt, mailboxID, input.CustomLabelIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateCustomMessageManageFolder(rt, mailboxID, input.CustomFolderID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(input.AddLabelIDs) == 0 && len(input.RemoveLabelIDs) == 0 && input.AddFolder == "" {
|
||||
emitMessageManageSummary(rt, messageManageSummary{
|
||||
SuccessMessageIDs: input.MessageIDs,
|
||||
FailedMessageIDs: []messageManageFailure{},
|
||||
}, true)
|
||||
return nil
|
||||
}
|
||||
|
||||
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
|
||||
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
|
||||
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_modify"), nil,
|
||||
messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
|
||||
if err != nil {
|
||||
for _, id := range batch {
|
||||
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
|
||||
}
|
||||
continue
|
||||
}
|
||||
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
|
||||
}
|
||||
emitMessageManageSummary(rt, summary, false)
|
||||
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
|
||||
return mailFailedPreconditionError("all message modify batches failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildMessageModifyInput(rt *common.RuntimeContext) (messageModifyInput, error) {
|
||||
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||
if err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
addLabels, customAddLabels, err := normalizeMessageManageLabels(rt.StrSlice("add-label-ids"), "--add-label-ids")
|
||||
if err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
removeLabels, customRemoveLabels, err := normalizeMessageManageLabels(rt.StrSlice("remove-label-ids"), "--remove-label-ids")
|
||||
if err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
if err := validateLabelIntersection(addLabels, removeLabels); err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
folder, customFolder, err := normalizeMessageManageFolder(rt.Str("add-folder"))
|
||||
if err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
customLabels := append(customAddLabels, customRemoveLabels...)
|
||||
customFolderID := ""
|
||||
if customFolder {
|
||||
customFolderID = folder
|
||||
}
|
||||
return messageModifyInput{
|
||||
MessageIDs: messageIDs,
|
||||
AddLabelIDs: addLabels,
|
||||
RemoveLabelIDs: removeLabels,
|
||||
AddFolder: folder,
|
||||
CustomLabelIDs: customLabels,
|
||||
CustomFolderID: customFolderID,
|
||||
ValidationAPIPlans: messageManageValidationPlan(resolveMailboxID(rt), customLabels, customFolderID),
|
||||
}, nil
|
||||
}
|
||||
75
shortcuts/mail/mail_message_trash.go
Normal file
75
shortcuts/mail/mail_message_trash.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// MailMessageTrash is the `+message-trash` shortcut: soft-delete existing
|
||||
// messages in batches of 20 via batch_trash. Risk is high-risk-write, so the
|
||||
// runner requires --yes before Execute.
|
||||
var MailMessageTrash = common.Shortcut{
|
||||
Service: "mail",
|
||||
Command: "+message-trash",
|
||||
Description: "Soft-delete existing mail messages. Batches message IDs in groups of 20 and calls batch_trash sequentially. Requires --yes.",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"mail:user_mailbox.message:modify"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
|
||||
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to soft-delete; comma-separated or repeat the flag."},
|
||||
},
|
||||
Validate: validateMessageTrash,
|
||||
DryRun: dryRunMessageTrash,
|
||||
Execute: executeMessageTrash,
|
||||
}
|
||||
|
||||
func validateMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
_, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||
return err
|
||||
}
|
||||
|
||||
func dryRunMessageTrash(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveMailboxID(rt)
|
||||
messageIDs, _ := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||
api := common.NewDryRunAPI().
|
||||
Desc("Soft-delete messages sequentially in batches of 20").
|
||||
Set("batch_size", mailMessageManageBatchSize).
|
||||
Set("batches", chunkMessageManageIDs(messageIDs))
|
||||
for _, batch := range chunkMessageManageIDs(messageIDs) {
|
||||
api = api.POST(mailboxPath(mailboxID, "messages", "batch_trash")).
|
||||
Body(map[string]interface{}{"message_ids": batch})
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
func executeMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
mailboxID := resolveMailboxID(rt)
|
||||
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
|
||||
for _, batch := range chunkMessageManageIDs(messageIDs) {
|
||||
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_trash"), nil,
|
||||
map[string]interface{}{"message_ids": batch})
|
||||
if err != nil {
|
||||
for _, id := range batch {
|
||||
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
|
||||
}
|
||||
continue
|
||||
}
|
||||
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
|
||||
}
|
||||
emitMessageManageSummary(rt, summary, false)
|
||||
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
|
||||
return mailFailedPreconditionError("all message trash batches failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func mailShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *by
|
||||
RefreshToken: "test-refresh-token",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
|
||||
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
|
||||
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly",
|
||||
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly mail:user_mailbox.folder:read",
|
||||
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
|
||||
}
|
||||
if err := auth.SetStoredToken(token); err != nil {
|
||||
|
||||
@@ -55,7 +55,7 @@ var MailTriage = common.Shortcut{
|
||||
Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "format", Default: "table", Desc: "output format: table | json | data (json/data output object with pagination fields)"},
|
||||
{Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, Desc: "output format: table | json | data (json/data output object with pagination fields)"},
|
||||
{Name: "max", Type: "int", Default: "20", Desc: "maximum number of messages to fetch (1-400; auto-paginates internally)"},
|
||||
{Name: "page-size", Type: "int", Desc: "alias for --max"},
|
||||
{Name: "page-token", Desc: "pagination token from a previous response to fetch the next page"},
|
||||
|
||||
@@ -99,7 +99,7 @@ var MailWatch = common.Shortcut{
|
||||
Scopes: []string{"mail:event", "mail:user_mailbox.event.mail_address:read", "mail:user_mailbox:readonly", "mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "format", Default: "data", Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
|
||||
{Name: "format", Default: "data", Enum: []string{"json", "data"}, Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
|
||||
{Name: "msg-format", Default: "metadata", Desc: "message payload mode: metadata(headers + meta, for triage/notification) | minimal(IDs and state only, no headers, for tracking read/folder changes) | plain_text_full(all metadata fields + full plain-text body) | event(raw WebSocket event, no API call, for debug) | full(full message including HTML body and attachments)"},
|
||||
{Name: "output-dir", Desc: "Write each message as a JSON file (always full payload, regardless of --msg-format)"},
|
||||
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
|
||||
|
||||
283
shortcuts/mail/message_manage_helpers.go
Normal file
283
shortcuts/mail/message_manage_helpers.go
Normal file
@@ -0,0 +1,283 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const mailMessageManageBatchSize = 20
|
||||
|
||||
var messageManageSystemLabels = map[string]string{
|
||||
"UNREAD": "UNREAD",
|
||||
"IMPORTANT": "IMPORTANT",
|
||||
"OTHER": "OTHER",
|
||||
"FLAGGED": "FLAGGED",
|
||||
"READ_RECEIPT_REQUEST": "READ_RECEIPT_REQUEST",
|
||||
}
|
||||
|
||||
var messageManageSystemFolders = map[string]string{
|
||||
"INBOX": "INBOX",
|
||||
"SENT": "SENT",
|
||||
"SPAM": "SPAM",
|
||||
"ARCHIVE": "ARCHIVED",
|
||||
"ARCHIVED": "ARCHIVED",
|
||||
}
|
||||
|
||||
type messageManageSummary struct {
|
||||
SuccessMessageIDs []string `json:"success_message_ids"`
|
||||
FailedMessageIDs []messageManageFailure `json:"failed_message_ids"`
|
||||
}
|
||||
|
||||
type messageManageFailure struct {
|
||||
MessageID string `json:"message_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type validationAPIPlan struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
WillValidate bool `json:"will_validate"`
|
||||
}
|
||||
|
||||
func normalizeMessageManageIDs(raw []string) ([]string, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
|
||||
}
|
||||
parts, err := splitMessageManageIDTokens(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]string, 0, len(parts))
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
|
||||
}
|
||||
id := strings.TrimSpace(part)
|
||||
if id == "" {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
|
||||
}
|
||||
if id != part {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain leading or trailing whitespace", i+1, part)
|
||||
}
|
||||
if err := validateMessageManageID(id, i); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func splitMessageManageIDTokens(raw []string) ([]string, error) {
|
||||
parts := make([]string, 0, len(raw))
|
||||
for i, token := range raw {
|
||||
for _, r := range token {
|
||||
if unicode.IsSpace(r) || unicode.IsControl(r) {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", i+1, token)
|
||||
}
|
||||
}
|
||||
parts = append(parts, strings.Split(token, ",")...)
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func validateMessageManageID(id string, index int) error {
|
||||
if len(id) < 16 {
|
||||
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): length must be at least 16 characters", index+1, id)
|
||||
}
|
||||
if strings.Trim(id, "0123456789") == "" {
|
||||
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): numeric primary IDs are not supported; pass the Open API message_id from mail output", index+1, id)
|
||||
}
|
||||
for _, r := range id {
|
||||
if unicode.IsSpace(r) || unicode.IsControl(r) {
|
||||
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", index+1, id)
|
||||
}
|
||||
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
continue
|
||||
}
|
||||
switch r {
|
||||
case '+', '/', '=', '_', '-':
|
||||
continue
|
||||
default:
|
||||
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): contains characters outside the Open API message_id character set", index+1, id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeMessageManageLabels(raw []string, flagName string) ([]string, []string, error) {
|
||||
labels := make([]string, 0, len(raw))
|
||||
custom := make([]string, 0, len(raw))
|
||||
seen := make(map[string]struct{}, len(raw))
|
||||
for i, part := range raw {
|
||||
id := strings.TrimSpace(part)
|
||||
if id == "" {
|
||||
return nil, nil, mailValidationParamError(flagName, "%s entry %d is empty; remove extra commas or provide valid label IDs", flagName, i+1)
|
||||
}
|
||||
if id != part {
|
||||
return nil, nil, mailValidationParamError(flagName, "%s entry %d (%q): must not contain leading or trailing whitespace", flagName, i+1, part)
|
||||
}
|
||||
normalized := id
|
||||
if system, ok := messageManageSystemLabels[strings.ToUpper(id)]; ok {
|
||||
normalized = system
|
||||
} else {
|
||||
custom = append(custom, id)
|
||||
}
|
||||
if _, ok := seen[normalized]; ok {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = struct{}{}
|
||||
labels = append(labels, normalized)
|
||||
}
|
||||
if len(labels) > 20 {
|
||||
return nil, nil, mailValidationParamError(flagName, "%s accepts at most 20 label IDs (got %d)", flagName, len(labels))
|
||||
}
|
||||
return labels, custom, nil
|
||||
}
|
||||
|
||||
func validateLabelIntersection(add, remove []string) error {
|
||||
removeSet := make(map[string]struct{}, len(remove))
|
||||
for _, id := range remove {
|
||||
removeSet[id] = struct{}{}
|
||||
}
|
||||
for _, id := range add {
|
||||
if _, ok := removeSet[id]; ok {
|
||||
return mailValidationParamError("--add-label-ids", "label cannot be both added and removed: %s", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeMessageManageFolder(raw string) (string, bool, error) {
|
||||
if raw == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
folder := strings.TrimSpace(raw)
|
||||
if folder == "" {
|
||||
return "", false, mailValidationParamError("--add-folder", "--add-folder must not be empty")
|
||||
}
|
||||
if folder != raw {
|
||||
return "", false, mailValidationParamError("--add-folder", "--add-folder %q must not contain leading or trailing whitespace", raw)
|
||||
}
|
||||
if strings.EqualFold(folder, "TRASH") {
|
||||
return "", false, mailValidationParamError("--add-folder", "TRASH is not supported by +message-modify; use +message-trash")
|
||||
}
|
||||
if system, ok := messageManageSystemFolders[strings.ToUpper(folder)]; ok {
|
||||
return system, false, nil
|
||||
}
|
||||
return folder, true, nil
|
||||
}
|
||||
|
||||
func chunkMessageManageIDs(ids []string) [][]string {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
chunks := make([][]string, 0, (len(ids)+mailMessageManageBatchSize-1)/mailMessageManageBatchSize)
|
||||
for start := 0; start < len(ids); start += mailMessageManageBatchSize {
|
||||
end := start + mailMessageManageBatchSize
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
chunks = append(chunks, ids[start:end])
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
func validateCustomMessageManageLabels(rt *common.RuntimeContext, mailboxID string, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := validateLabelReadScope(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, id := range ids {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "labels", id), nil, nil); err != nil {
|
||||
return mailDecorateProblemMessage(err, "label not found: %s", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCustomMessageManageFolder(rt *common.RuntimeContext, mailboxID, id string) error {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
if err := validateFolderReadScope(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "folders", id), nil, nil); err != nil {
|
||||
return mailDecorateProblemMessage(err, "folder not found: %s", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func messageManageBody(ids, addLabels, removeLabels []string, addFolder string) map[string]interface{} {
|
||||
body := map[string]interface{}{"message_ids": ids}
|
||||
if len(addLabels) > 0 {
|
||||
body["add_label_ids"] = addLabels
|
||||
}
|
||||
if len(removeLabels) > 0 {
|
||||
body["remove_label_ids"] = removeLabels
|
||||
}
|
||||
if addFolder != "" {
|
||||
body["add_folder"] = addFolder
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func messageManageValidationPlan(mailboxID string, customLabels []string, customFolder string) []validationAPIPlan {
|
||||
plans := make([]validationAPIPlan, 0, len(customLabels)+1)
|
||||
seenLabels := map[string]struct{}{}
|
||||
for _, id := range customLabels {
|
||||
if _, ok := seenLabels[id]; ok {
|
||||
continue
|
||||
}
|
||||
seenLabels[id] = struct{}{}
|
||||
plans = append(plans, validationAPIPlan{
|
||||
Method: "GET",
|
||||
Path: mailboxPath(mailboxID, "labels", id),
|
||||
WillValidate: true,
|
||||
})
|
||||
}
|
||||
if customFolder != "" {
|
||||
plans = append(plans, validationAPIPlan{
|
||||
Method: "GET",
|
||||
Path: mailboxPath(mailboxID, "folders", customFolder),
|
||||
WillValidate: true,
|
||||
})
|
||||
}
|
||||
return plans
|
||||
}
|
||||
|
||||
func emitMessageManageSummary(rt *common.RuntimeContext, summary messageManageSummary, noAPICalls bool) {
|
||||
rt.OutFormat(summary, &output.Meta{Count: len(summary.SuccessMessageIDs)}, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "success_message_ids: %d\n", len(summary.SuccessMessageIDs))
|
||||
fmt.Fprintf(w, "failed_message_ids: %d\n", len(summary.FailedMessageIDs))
|
||||
if noAPICalls {
|
||||
fmt.Fprintln(w, "No changes requested; no API calls were made.")
|
||||
}
|
||||
for _, item := range summary.FailedMessageIDs {
|
||||
fmt.Fprintf(w, "- %s: %s\n", item.MessageID, item.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -10,6 +10,8 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
MailMessage,
|
||||
MailMessages,
|
||||
MailMessageModify,
|
||||
MailMessageTrash,
|
||||
MailThread,
|
||||
MailTriage,
|
||||
MailWatch,
|
||||
|
||||
@@ -5,6 +5,7 @@ package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -25,6 +27,9 @@ const (
|
||||
minVCMeetingEventsPageSize = 20
|
||||
maxVCMeetingEventsPageSize = 100
|
||||
maxVCMeetingEventsPages = 200
|
||||
leaveReasonUserLeft = 1
|
||||
leaveReasonMeetingEnded = 2
|
||||
leaveReasonKicked = 3
|
||||
)
|
||||
|
||||
var meetingDisplayLocation = time.FixedZone("UTC+8", 8*60*60)
|
||||
@@ -41,11 +46,11 @@ func toUnixSeconds(input string, hint ...string) (string, error) {
|
||||
return ts, nil
|
||||
}
|
||||
|
||||
// VCMeetingEvents lists bot meeting events for a meeting.
|
||||
// VCMeetingEvents lists meeting events for a meeting.
|
||||
var VCMeetingEvents = common.Shortcut{
|
||||
Service: "vc",
|
||||
Command: "+meeting-events",
|
||||
Description: "List bot meeting events by meeting ID",
|
||||
Description: "List meeting events by meeting ID",
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:meeting.meetingevent:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -99,20 +104,28 @@ var VCMeetingEvents = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
events = compactMeetingEvents(events)
|
||||
outData := map[string]interface{}{
|
||||
"events": events,
|
||||
"has_more": data["has_more"],
|
||||
"page_token": data["page_token"],
|
||||
identity, identityWarning := meetingEventsCurrentIdentity(runtime)
|
||||
outData := buildMeetingEventsOutput(data, events, identity, identityWarning)
|
||||
metadata := map[string]interface{}{
|
||||
"row_type": "metadata",
|
||||
"meeting": outData.Meeting,
|
||||
"identity": outData.Identity,
|
||||
"has_more": outData.HasMore,
|
||||
"page_token": outData.PageToken,
|
||||
}
|
||||
if len(outData.Warnings) > 0 {
|
||||
metadata["warnings"] = outData.Warnings
|
||||
}
|
||||
ndjsonData := meetingEventsEventRows(outData.Events, metadata)
|
||||
|
||||
timeline := buildMeetingEventTimeline(events)
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(events)}, func(w io.Writer) {
|
||||
if len(timeline.entries) == 0 {
|
||||
fmt.Fprintln(w, "No meeting events.")
|
||||
return
|
||||
}
|
||||
io.WriteString(w, renderMeetingEventsPretty(timeline))
|
||||
})
|
||||
if runtime.Format == "ndjson" {
|
||||
runtime.OutFormat(ndjsonData, &output.Meta{Count: len(events)}, func(w io.Writer) {})
|
||||
} else {
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(events)}, func(w io.Writer) {
|
||||
renderMeetingEventsCompactPretty(w, outData, timeline)
|
||||
})
|
||||
}
|
||||
if runtime.Format == "pretty" && pageToken != "" {
|
||||
fmt.Fprintf(runtime.IO().Out, "\npage_token: %s\n", pageToken)
|
||||
if hasMore {
|
||||
@@ -123,6 +136,400 @@ var VCMeetingEvents = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
type meetingEventsOutput struct {
|
||||
Meeting meetingEventsMeeting `json:"meeting"`
|
||||
Identity meetingEventsIdentity `json:"identity"`
|
||||
Events []meetingEventsEvent `json:"events"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
PageToken string `json:"page_token,omitempty"`
|
||||
}
|
||||
|
||||
type meetingEventsMeeting struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Topic string `json:"topic,omitempty"`
|
||||
MeetingNo string `json:"meeting_no,omitempty"`
|
||||
StartTime string `json:"start_time,omitempty"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type meetingEventsIdentity struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ParticipantType string `json:"participant_type,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
type meetingEventsEvent struct {
|
||||
EventID string `json:"event_id,omitempty"`
|
||||
EventType string `json:"event_type,omitempty"`
|
||||
EventTime string `json:"event_time,omitempty"`
|
||||
Actors []meetingEventsIdentity `json:"actors,omitempty"`
|
||||
Payload map[string]interface{} `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type meetingEventsEndSignal struct {
|
||||
Ended bool
|
||||
EndTime time.Time
|
||||
HasEndTime bool
|
||||
}
|
||||
|
||||
func buildMeetingEventsOutput(data map[string]interface{}, events []interface{}, identity meetingEventsIdentity, warnings ...string) meetingEventsOutput {
|
||||
output := meetingEventsOutput{
|
||||
Meeting: meetingEventsMeetingFromPayload(nil),
|
||||
Identity: identity,
|
||||
HasMore: common.GetBool(data, "has_more"),
|
||||
PageToken: common.GetString(data, "page_token"),
|
||||
}
|
||||
for _, warning := range warnings {
|
||||
if warning = strings.TrimSpace(warning); warning != "" {
|
||||
output.Warnings = append(output.Warnings, warning)
|
||||
}
|
||||
}
|
||||
for _, raw := range events {
|
||||
event, _ := raw.(map[string]interface{})
|
||||
if event == nil {
|
||||
continue
|
||||
}
|
||||
payload := common.GetMap(event, "payload")
|
||||
if meeting := common.GetMap(payload, "meeting"); meeting != nil {
|
||||
output.Meeting = meetingEventsMeetingFromPayload(meeting)
|
||||
}
|
||||
output.Events = append(output.Events, meetingEventsEventFromPayload(event, output.Identity))
|
||||
}
|
||||
applyMeetingEventsEndSignal(&output.Meeting, meetingEventsEndSignalFromEvents(events))
|
||||
return output
|
||||
}
|
||||
|
||||
func meetingEventsCurrentIdentity(runtime *common.RuntimeContext) (meetingEventsIdentity, string) {
|
||||
if runtime.As() == core.AsBot {
|
||||
botInfo, err := runtime.BotInfo()
|
||||
if err != nil {
|
||||
return meetingEventsBotIdentity(nil), fmt.Sprintf("identity unavailable: %v", err)
|
||||
}
|
||||
return meetingEventsBotIdentity(botInfo), ""
|
||||
}
|
||||
userOpenID := strings.TrimSpace(runtime.UserOpenId())
|
||||
identity := meetingEventsIdentity{
|
||||
ID: userOpenID,
|
||||
Name: strings.TrimSpace(runtime.Config.UserName),
|
||||
ParticipantType: "human",
|
||||
}
|
||||
identity.Label = identityLabel(identity)
|
||||
if userOpenID == "" {
|
||||
return identity, "identity unavailable: current user open_id is unavailable"
|
||||
}
|
||||
return identity, ""
|
||||
}
|
||||
|
||||
func meetingEventsBotIdentity(botInfo *common.BotInfo) meetingEventsIdentity {
|
||||
if botInfo == nil {
|
||||
return meetingEventsIdentity{ParticipantType: "bot", Label: "bot"}
|
||||
}
|
||||
identity := meetingEventsIdentity{
|
||||
ID: botInfo.OpenID,
|
||||
Name: botInfo.AppName,
|
||||
ParticipantType: "bot",
|
||||
}
|
||||
identity.Label = identityLabel(identity)
|
||||
return identity
|
||||
}
|
||||
|
||||
func meetingEventsMeetingFromPayload(meeting map[string]interface{}) meetingEventsMeeting {
|
||||
out := meetingEventsMeeting{
|
||||
ID: common.GetString(meeting, "id"),
|
||||
Topic: common.GetString(meeting, "topic"),
|
||||
MeetingNo: common.GetString(meeting, "meeting_no"),
|
||||
StartTime: meetingEventsTimeString(common.GetString(meeting, "start_time")),
|
||||
EndTime: meetingEventsTimeString(common.GetString(meeting, "end_time")),
|
||||
Status: "unknown",
|
||||
}
|
||||
start, hasStart := parseFlexibleTime(out.StartTime)
|
||||
end, hasEnd := parseFlexibleTime(out.EndTime)
|
||||
if hasStart && !hasEnd {
|
||||
out.Status = "ongoing"
|
||||
}
|
||||
if hasStart && hasEnd {
|
||||
if end.After(start) {
|
||||
out.Status = "ended"
|
||||
} else {
|
||||
out.Status = "ongoing"
|
||||
out.EndTime = ""
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func applyMeetingEventsEndSignal(meeting *meetingEventsMeeting, signal meetingEventsEndSignal) {
|
||||
if meeting == nil || !signal.Ended {
|
||||
return
|
||||
}
|
||||
meeting.Status = "ended"
|
||||
if signal.HasEndTime {
|
||||
meeting.EndTime = signal.EndTime.UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
|
||||
func meetingEventsEndSignalFromEvents(events []interface{}) meetingEventsEndSignal {
|
||||
var signal meetingEventsEndSignal
|
||||
for _, raw := range events {
|
||||
event, _ := raw.(map[string]interface{})
|
||||
if event == nil || meetingEventType(event) != "participant_left" {
|
||||
continue
|
||||
}
|
||||
payload := common.GetMap(event, "payload")
|
||||
if payload == nil {
|
||||
continue
|
||||
}
|
||||
fallbackTime, fallbackOK := parseFlexibleTime(common.GetString(event, "event_time"))
|
||||
for _, rawItem := range common.GetSlice(payload, "participant_left_items") {
|
||||
item, _ := rawItem.(map[string]interface{})
|
||||
if item == nil || int(common.GetFloat(item, "leave_reason")) != leaveReasonMeetingEnded {
|
||||
continue
|
||||
}
|
||||
signal.Ended = true
|
||||
endTime, ok := parseFlexibleTime(common.GetString(item, "leave_time"))
|
||||
if !ok {
|
||||
endTime, ok = fallbackTime, fallbackOK
|
||||
}
|
||||
if ok && (!signal.HasEndTime || endTime.After(signal.EndTime)) {
|
||||
signal.EndTime = endTime
|
||||
signal.HasEndTime = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return signal
|
||||
}
|
||||
|
||||
func meetingEventsEventFromPayload(event map[string]interface{}, selfIdentity meetingEventsIdentity) meetingEventsEvent {
|
||||
payload := common.GetMap(event, "payload")
|
||||
out := meetingEventsEvent{
|
||||
EventID: common.GetString(event, "event_id"),
|
||||
EventType: meetingEventType(event),
|
||||
EventTime: meetingEventsTimeString(common.GetString(event, "event_time")),
|
||||
Payload: payload,
|
||||
}
|
||||
out.Actors = eventActors(out.EventType, payload, selfIdentity)
|
||||
return out
|
||||
}
|
||||
|
||||
func eventActors(eventType string, payload map[string]interface{}, selfIdentity meetingEventsIdentity) []meetingEventsIdentity {
|
||||
var actors []meetingEventsIdentity
|
||||
addFromItems := func(key, participantKey string) {
|
||||
for _, raw := range common.GetSlice(payload, key) {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
if participant := common.GetMap(item, participantKey); participant != nil {
|
||||
actors = append(actors, meetingEventsIdentityFromParticipant(participant, selfIdentity))
|
||||
}
|
||||
}
|
||||
}
|
||||
switch eventType {
|
||||
case "participant_joined":
|
||||
addFromItems("participant_joined_items", "participant")
|
||||
case "participant_left":
|
||||
addFromItems("participant_left_items", "participant")
|
||||
case "transcript_received":
|
||||
addFromItems("transcript_received_items", "speaker")
|
||||
case "chat_received":
|
||||
addFromItems("chat_received_items", "operator")
|
||||
case "magic_share_started":
|
||||
addFromItems("magic_share_started_items", "operator")
|
||||
case "magic_share_ended":
|
||||
addFromItems("magic_share_ended_items", "operator")
|
||||
}
|
||||
return actors
|
||||
}
|
||||
|
||||
func meetingEventsIdentityFromParticipant(participant map[string]interface{}, selfIdentity meetingEventsIdentity) meetingEventsIdentity {
|
||||
identity := meetingEventsIdentity{
|
||||
ID: common.GetString(participant, "id"),
|
||||
Name: common.GetString(participant, "user_name"),
|
||||
ParticipantType: meetingEventsParticipantType(participant),
|
||||
Role: meetingEventsParticipantRole(participant),
|
||||
}
|
||||
if identity.ID != "" && selfIdentity.ID != "" && identity.ID == selfIdentity.ID {
|
||||
if selfIdentity.ParticipantType == "bot" && (identity.ParticipantType == "" || identity.ParticipantType == "human") {
|
||||
identity.ParticipantType = "bot"
|
||||
}
|
||||
if selfIdentity.ParticipantType == "bot" && (identity.Role == "" || identity.Role == "participant") {
|
||||
identity.Role = "bot"
|
||||
}
|
||||
}
|
||||
if identity.ParticipantType == "" {
|
||||
identity.ParticipantType = "human"
|
||||
}
|
||||
if identity.Role == "" {
|
||||
identity.Role = "participant"
|
||||
}
|
||||
identity.Label = identityLabel(identity)
|
||||
return identity
|
||||
}
|
||||
|
||||
func meetingEventsParticipantType(participant map[string]interface{}) string {
|
||||
if raw := meetingEventsParticipantTypeFromParticipantType(fieldValueString(participant, "participant_type")); raw != "" {
|
||||
return raw
|
||||
}
|
||||
return meetingEventsParticipantTypeFromUserType(fieldValueString(participant, "user_type"))
|
||||
}
|
||||
|
||||
func meetingEventsParticipantTypeFromParticipantType(raw string) string {
|
||||
raw = strings.ToLower(strings.TrimSpace(raw))
|
||||
switch raw {
|
||||
case "1", "user", "human":
|
||||
return "human"
|
||||
case "2", "bot", "app":
|
||||
return "bot"
|
||||
case "":
|
||||
return ""
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func meetingEventsParticipantRole(participant map[string]interface{}) string {
|
||||
if raw := meetingEventsRoleFromParticipantRole(fieldValueString(participant, "role")); raw != "" {
|
||||
return raw
|
||||
}
|
||||
return meetingEventsRoleFromEventUserRole(fieldValueString(participant, "user_role"))
|
||||
}
|
||||
|
||||
func meetingEventsParticipantTypeFromUserType(raw string) string {
|
||||
raw = strings.ToLower(strings.TrimSpace(raw))
|
||||
switch raw {
|
||||
case "1", "user", "human":
|
||||
return "human"
|
||||
case "2", "10", "bot", "app":
|
||||
return "bot"
|
||||
case "":
|
||||
return ""
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func meetingEventsRoleFromParticipantRole(raw string) string {
|
||||
raw = strings.ToLower(strings.TrimSpace(raw))
|
||||
switch raw {
|
||||
case "1", "host":
|
||||
return "host"
|
||||
case "2", "co_host", "cohost":
|
||||
return "co_host"
|
||||
case "3", "participant", "attendee":
|
||||
return "participant"
|
||||
case "4", "bot", "app":
|
||||
return "bot"
|
||||
case "":
|
||||
return ""
|
||||
default:
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
func meetingEventsRoleFromEventUserRole(raw string) string {
|
||||
raw = strings.ToLower(strings.TrimSpace(raw))
|
||||
switch raw {
|
||||
case "1", "participant", "attendee":
|
||||
return "participant"
|
||||
case "2", "host":
|
||||
return "host"
|
||||
case "4", "bot", "app":
|
||||
return "bot"
|
||||
case "", "0":
|
||||
return ""
|
||||
default:
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
func fieldValueString(values map[string]interface{}, key string) string {
|
||||
if values == nil {
|
||||
return ""
|
||||
}
|
||||
switch value := values[key].(type) {
|
||||
case string:
|
||||
return value
|
||||
case int:
|
||||
return strconv.Itoa(value)
|
||||
case int64:
|
||||
return strconv.FormatInt(value, 10)
|
||||
case float64:
|
||||
return strconv.FormatInt(int64(value), 10)
|
||||
case json.Number:
|
||||
return value.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func identityLabel(identity meetingEventsIdentity) string {
|
||||
name := identity.Name
|
||||
if name == "" {
|
||||
name = identity.ID
|
||||
}
|
||||
if name == "" {
|
||||
name = "unknown"
|
||||
}
|
||||
var tags []string
|
||||
if identity.ParticipantType != "" {
|
||||
tags = append(tags, identity.ParticipantType)
|
||||
}
|
||||
if identity.Role != "" && identity.Role != identity.ParticipantType {
|
||||
tags = append(tags, identity.Role)
|
||||
}
|
||||
if len(tags) == 0 {
|
||||
return name
|
||||
}
|
||||
return fmt.Sprintf("%s [%s]", name, strings.Join(tags, ","))
|
||||
}
|
||||
|
||||
func meetingEventsTimeString(raw string) string {
|
||||
if parsed, ok := parseFlexibleTime(raw); ok {
|
||||
return parsed.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return strings.TrimSpace(raw)
|
||||
}
|
||||
|
||||
func meetingEventsEventRows(events []meetingEventsEvent, metadata map[string]interface{}) []interface{} {
|
||||
rows := make([]interface{}, 0, len(events)+1)
|
||||
for _, event := range events {
|
||||
row := meetingEventsEventRow(event)
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if metadata != nil {
|
||||
rows = append(rows, metadata)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func meetingEventsEventRow(event meetingEventsEvent) map[string]interface{} {
|
||||
raw, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"row_type": "event"}
|
||||
}
|
||||
var row map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &row); err != nil {
|
||||
return map[string]interface{}{"row_type": "event"}
|
||||
}
|
||||
row["row_type"] = "event"
|
||||
return row
|
||||
}
|
||||
|
||||
func renderMeetingEventsCompactPretty(w io.Writer, data meetingEventsOutput, timeline meetingTimeline) {
|
||||
if data.Identity.Label != "" {
|
||||
fmt.Fprintf(w, "当前身份:%s\n", escapePrettyText(data.Identity.Label))
|
||||
}
|
||||
if len(timeline.entries) == 0 {
|
||||
fmt.Fprintln(w, "No meeting events.")
|
||||
return
|
||||
}
|
||||
io.WriteString(w, renderMeetingEventsPretty(timeline))
|
||||
}
|
||||
|
||||
func meetingEventsPageSize(runtime *common.RuntimeContext) (int, error) {
|
||||
if runtime.Bool("page-all") {
|
||||
return maxVCMeetingEventsPageSize, nil
|
||||
@@ -323,7 +730,6 @@ type meetingTimelineEntry struct {
|
||||
when time.Time
|
||||
hasWhen bool
|
||||
sequence int
|
||||
group int
|
||||
subject string
|
||||
description string
|
||||
details []string
|
||||
@@ -332,7 +738,6 @@ type meetingTimelineEntry struct {
|
||||
func buildMeetingEventTimeline(events []interface{}) meetingTimeline {
|
||||
timeline := meetingTimeline{}
|
||||
var sequence int
|
||||
var group int
|
||||
for _, raw := range events {
|
||||
event, _ := raw.(map[string]interface{})
|
||||
if event == nil {
|
||||
@@ -345,11 +750,11 @@ func buildMeetingEventTimeline(events []interface{}) meetingTimeline {
|
||||
if timeline.topic == "" || !timeline.hasStart || !timeline.hasEnd {
|
||||
populateMeetingHeader(&timeline, common.GetMap(payload, "meeting"))
|
||||
}
|
||||
for _, entry := range buildTimelineEntriesForEvent(event, &sequence, group) {
|
||||
for _, entry := range buildTimelineEntriesForEvent(event, &sequence) {
|
||||
timeline.entries = append(timeline.entries, entry)
|
||||
}
|
||||
group++
|
||||
}
|
||||
applyMeetingTimelineEndSignal(&timeline, meetingEventsEndSignalFromEvents(events))
|
||||
sort.SliceStable(timeline.entries, func(i, j int) bool {
|
||||
left := timeline.entries[i]
|
||||
right := timeline.entries[j]
|
||||
@@ -370,6 +775,24 @@ func buildMeetingEventTimeline(events []interface{}) meetingTimeline {
|
||||
return timeline
|
||||
}
|
||||
|
||||
func applyMeetingTimelineEndSignal(timeline *meetingTimeline, signal meetingEventsEndSignal) {
|
||||
if timeline == nil || !signal.Ended {
|
||||
return
|
||||
}
|
||||
if signal.HasEndTime {
|
||||
if !timeline.hasStart || signal.EndTime.After(timeline.startTime) {
|
||||
timeline.endTime = signal.EndTime
|
||||
timeline.hasEnd = true
|
||||
return
|
||||
}
|
||||
timeline.hasEnd = false
|
||||
return
|
||||
}
|
||||
if timeline.hasStart && timeline.hasEnd && !timeline.endTime.After(timeline.startTime) {
|
||||
timeline.hasEnd = false
|
||||
}
|
||||
}
|
||||
|
||||
func populateMeetingHeader(timeline *meetingTimeline, meeting map[string]interface{}) {
|
||||
if timeline == nil || meeting == nil {
|
||||
return
|
||||
@@ -391,7 +814,7 @@ func populateMeetingHeader(timeline *meetingTimeline, meeting map[string]interfa
|
||||
}
|
||||
}
|
||||
|
||||
func buildTimelineEntriesForEvent(event map[string]interface{}, sequence *int, group int) []meetingTimelineEntry {
|
||||
func buildTimelineEntriesForEvent(event map[string]interface{}, sequence *int) []meetingTimelineEntry {
|
||||
payload := common.GetMap(event, "payload")
|
||||
if payload == nil {
|
||||
return nil
|
||||
@@ -400,26 +823,26 @@ func buildTimelineEntriesForEvent(event map[string]interface{}, sequence *int, g
|
||||
eventTime, eventTimeOK := parseFlexibleTime(common.GetString(event, "event_time"))
|
||||
switch eventType {
|
||||
case "participant_joined":
|
||||
return participantJoinedEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
return participantJoinedEntries(payload, eventTime, eventTimeOK, sequence)
|
||||
case "participant_left":
|
||||
return participantLeftEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
return participantLeftEntries(payload, eventTime, eventTimeOK, sequence)
|
||||
case "transcript_received":
|
||||
return transcriptEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
return transcriptEntries(payload, eventTime, eventTimeOK, sequence)
|
||||
case "chat_received":
|
||||
return chatEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
return chatEntries(payload, eventTime, eventTimeOK, sequence)
|
||||
case "magic_share_started":
|
||||
return magicShareStartedEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
return magicShareStartedEntries(payload, eventTime, eventTimeOK, sequence)
|
||||
case "magic_share_ended":
|
||||
return magicShareEndedEntries(payload, eventTime, eventTimeOK, sequence, group)
|
||||
return magicShareEndedEntries(payload, eventTime, eventTimeOK, sequence)
|
||||
default:
|
||||
return []meetingTimelineEntry{newTimelineEntry(eventTime, eventTimeOK, sequence, group, meetingEventUserDisplayName(nil), meetingEventSummary(event), nil)}
|
||||
return []meetingTimelineEntry{newTimelineEntry(eventTime, eventTimeOK, sequence, meetingEventUserDisplayName(nil), meetingEventSummary(event), nil)}
|
||||
}
|
||||
}
|
||||
|
||||
func participantJoinedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
func participantJoinedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "participant_joined_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "加入了会议", nil)}
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "加入了会议", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
@@ -432,15 +855,15 @@ func participantJoinedEntries(payload map[string]interface{}, fallbackTime time.
|
||||
if subject == "" {
|
||||
subject = "未知参会人"
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, "加入了会议", nil))
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, "加入了会议", nil))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func participantLeftEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
func participantLeftEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "participant_left_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "离开了会议", nil)}
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "离开了会议", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
@@ -453,15 +876,15 @@ func participantLeftEntries(payload map[string]interface{}, fallbackTime time.Ti
|
||||
if subject == "" {
|
||||
subject = "未知参会人"
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, leaveAction(item), nil))
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, leaveAction(item), nil))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func transcriptEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
func transcriptEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "transcript_received_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "产生了转写", nil)}
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "产生了转写", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
@@ -479,15 +902,15 @@ func transcriptEntries(payload map[string]interface{}, fallbackTime time.Time, f
|
||||
if text != "" {
|
||||
description = text
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, nil))
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, description, nil))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func chatEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
func chatEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "chat_received_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "发送了消息", nil)}
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "发送了消息", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
@@ -507,15 +930,15 @@ func chatEntries(payload map[string]interface{}, fallbackTime time.Time, fallbac
|
||||
} else {
|
||||
description = fmt.Sprintf("[%s] %s", typeLabel, description)
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, nil))
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, description, nil))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func magicShareStartedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
func magicShareStartedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "magic_share_started_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "开始共享内容", nil)}
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "开始共享内容", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
@@ -538,15 +961,15 @@ func magicShareStartedEntries(payload map[string]interface{}, fallbackTime time.
|
||||
if url != "" {
|
||||
details = append(details, "URL: "+url)
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, description, details))
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, description, details))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func magicShareEndedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int, group int) []meetingTimelineEntry {
|
||||
func magicShareEndedEntries(payload map[string]interface{}, fallbackTime time.Time, fallbackOK bool, sequence *int) []meetingTimelineEntry {
|
||||
items := common.GetSlice(payload, "magic_share_ended_items")
|
||||
if len(items) == 0 {
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, group, "", "结束共享", nil)}
|
||||
return []meetingTimelineEntry{newTimelineEntry(fallbackTime, fallbackOK, sequence, "", "结束共享", nil)}
|
||||
}
|
||||
entries := make([]meetingTimelineEntry, 0, len(items))
|
||||
for _, raw := range items {
|
||||
@@ -559,17 +982,16 @@ func magicShareEndedEntries(payload map[string]interface{}, fallbackTime time.Ti
|
||||
if subject == "" {
|
||||
subject = "未知用户"
|
||||
}
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, group, subject, "结束共享", nil))
|
||||
entries = append(entries, newTimelineEntry(when, ok, sequence, subject, "结束共享", nil))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func newTimelineEntry(when time.Time, hasWhen bool, sequence *int, group int, subject, description string, details []string) meetingTimelineEntry {
|
||||
func newTimelineEntry(when time.Time, hasWhen bool, sequence *int, subject, description string, details []string) meetingTimelineEntry {
|
||||
entry := meetingTimelineEntry{
|
||||
when: when,
|
||||
hasWhen: hasWhen,
|
||||
sequence: *sequence,
|
||||
group: group,
|
||||
subject: subject,
|
||||
description: description,
|
||||
details: details,
|
||||
@@ -713,9 +1135,9 @@ func needsColon(description string) bool {
|
||||
|
||||
func leaveAction(item map[string]interface{}) string {
|
||||
switch int(common.GetFloat(item, "leave_reason")) {
|
||||
case 2:
|
||||
case leaveReasonMeetingEnded:
|
||||
return "因会议结束离开了会议"
|
||||
case 3:
|
||||
case leaveReasonKicked:
|
||||
return "被移出了会议"
|
||||
default:
|
||||
return "离开了会议"
|
||||
|
||||
@@ -5,6 +5,7 @@ package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
@@ -54,6 +55,33 @@ func meetingEventsStub(events []interface{}, hasMore bool, pageToken string) *ht
|
||||
}
|
||||
}
|
||||
|
||||
func botInfoStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "bot_001",
|
||||
"app_name": "Demo Bot",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func botInfoErrorStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991663,
|
||||
"msg": "bot info unavailable",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func participantJoinedEvent() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"event_id": "event-1",
|
||||
@@ -73,6 +101,8 @@ func participantJoinedEvent() map[string]interface{} {
|
||||
"participant": map[string]interface{}{
|
||||
"id": "bot_001",
|
||||
"user_name": "Demo Bot",
|
||||
"user_type": 2,
|
||||
"user_role": 4,
|
||||
},
|
||||
"join_time": "2026-04-17T08:00:00Z",
|
||||
},
|
||||
@@ -90,6 +120,36 @@ func participantJoinedEventOngoing() map[string]interface{} {
|
||||
return event
|
||||
}
|
||||
|
||||
func participantLeftEventWithReason(leaveReason int) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"event_id": "event-left",
|
||||
"event_type": "participant_left",
|
||||
"event_time": "2026-04-17T07:18:50Z",
|
||||
"payload": map[string]interface{}{
|
||||
"activity_event_type": "participant_left",
|
||||
"meeting": map[string]interface{}{
|
||||
"id": "7628568141510692381",
|
||||
"topic": "项目例会",
|
||||
"meeting_no": "724939760",
|
||||
"start_time": "1776410100",
|
||||
"end_time": "1776410100",
|
||||
},
|
||||
"participant_left_items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"participant": map[string]interface{}{
|
||||
"id": "bot_001",
|
||||
"user_name": "Demo Bot",
|
||||
"user_type": 2,
|
||||
"user_role": 4,
|
||||
},
|
||||
"leave_time": "1776410330000",
|
||||
"leave_reason": leaveReason,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func chatReceivedEvent() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"event_id": "event-2",
|
||||
@@ -112,7 +172,7 @@ func chatReceivedEvent() map[string]interface{} {
|
||||
"chat_received_items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"content": "hello",
|
||||
"message_type": 3,
|
||||
"message_type": 1,
|
||||
"operator": map[string]interface{}{
|
||||
"id": "u1",
|
||||
"user_name": "Alice",
|
||||
@@ -140,7 +200,7 @@ func multiChatReceivedEvent() map[string]interface{} {
|
||||
"chat_received_items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"content": "第一条\n第二行",
|
||||
"message_type": 3,
|
||||
"message_type": 1,
|
||||
"send_time": "1776408061000",
|
||||
"operator": map[string]interface{}{
|
||||
"id": "u1",
|
||||
@@ -149,6 +209,44 @@ func multiChatReceivedEvent() map[string]interface{} {
|
||||
},
|
||||
map[string]interface{}{
|
||||
"content": "第二条",
|
||||
"message_type": 1,
|
||||
"send_time": "1776408062000",
|
||||
"operator": map[string]interface{}{
|
||||
"id": "u1",
|
||||
"user_name": "Alice",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mixedChatAndReactionEvent() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"event_id": "event-reaction",
|
||||
"event_type": "chat_received",
|
||||
"event_time": "2026-04-17T08:05:00Z",
|
||||
"payload": map[string]interface{}{
|
||||
"activity_event_type": "chat_received",
|
||||
"meeting": map[string]interface{}{
|
||||
"id": "7628568141510692381",
|
||||
"topic": "项目例会",
|
||||
"meeting_no": "724939760",
|
||||
"start_time": "1776407700",
|
||||
"end_time": "1776411300",
|
||||
},
|
||||
"chat_received_items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"content": "hello",
|
||||
"message_type": 1,
|
||||
"send_time": "1776408061000",
|
||||
"operator": map[string]interface{}{
|
||||
"id": "u1",
|
||||
"user_name": "Alice",
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"content": "OK",
|
||||
"message_type": 3,
|
||||
"send_time": "1776408062000",
|
||||
"operator": map[string]interface{}{
|
||||
@@ -414,7 +512,7 @@ func TestMeetingEvents_DryRun(t *testing.T) {
|
||||
"--start", "1710000000",
|
||||
"--end", "1710003600",
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -442,7 +540,7 @@ func TestMeetingEvents_DryRun_PageAllUsesMaxLimit(t *testing.T) {
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--page-all",
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -457,24 +555,39 @@ func TestMeetingEvents_ExecuteJSON_PageAll(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "pt_2"))
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "json",
|
||||
"--page-all",
|
||||
"--as", "user",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &envelope); err != nil {
|
||||
t.Fatalf("unmarshal stdout: %v: %s", err, stdout.String())
|
||||
}
|
||||
events := common.GetSlice(common.GetMap(envelope, "data"), "events")
|
||||
if got := len(events); got != 2 {
|
||||
t.Fatalf("events len = %d, want 2: %s", got, stdout.String())
|
||||
}
|
||||
for _, raw := range events {
|
||||
event, _ := raw.(map[string]interface{})
|
||||
if _, ok := event["summary"]; ok {
|
||||
t.Fatalf("event should not expose summary: %s", stdout.String())
|
||||
}
|
||||
if _, ok := event["raw"]; ok {
|
||||
t.Fatalf("event should not expose raw: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
out := strings.ReplaceAll(stdout.String(), " ", "")
|
||||
out = strings.ReplaceAll(out, "\n", "")
|
||||
if count := strings.Count(out, `"event_type":"participant_joined"`); count != 2 {
|
||||
t.Fatalf("expected 2 aggregated events, got %d: %s", count, stdout.String())
|
||||
}
|
||||
if !strings.Contains(out, `"has_more":false`) {
|
||||
t.Fatalf("expected final has_more=false: %s", stdout.String())
|
||||
}
|
||||
@@ -483,6 +596,80 @@ func TestMeetingEvents_ExecuteJSON_PageAll(t *testing.T) {
|
||||
func TestMeetingEvents_ExecuteJSON(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "1710000000000000000"))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
out := strings.ReplaceAll(stdout.String(), " ", "")
|
||||
out = strings.ReplaceAll(out, "\n", "")
|
||||
for _, want := range []string{
|
||||
`"identity":{"id":"bot_001","name":"DemoBot","participant_type":"bot","label":"DemoBot[bot]"}`,
|
||||
`"role":"bot"`,
|
||||
`"event_type":"participant_joined"`,
|
||||
`"actors":[`,
|
||||
`"start_time":"2026-04-17T06:35:00Z"`,
|
||||
`"has_more":true`,
|
||||
`"page_token":"1710000000000000000"`,
|
||||
`"events":[`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("json output missing %q: %s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{
|
||||
`"current_participants":`,
|
||||
`"is_self":`,
|
||||
`"summary":`,
|
||||
`"raw":`,
|
||||
} {
|
||||
if strings.Contains(out, unwanted) {
|
||||
t.Fatalf("json output should not contain %q: %s", unwanted, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecuteJSON_BotIdentityErrorDoesNotBlockEvents(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
|
||||
reg.Register(botInfoErrorStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
out := strings.ReplaceAll(stdout.String(), " ", "")
|
||||
out = strings.ReplaceAll(out, "\n", "")
|
||||
for _, want := range []string{
|
||||
`"event_type":"participant_joined"`,
|
||||
`"identity":{"participant_type":"bot","label":"bot"}`,
|
||||
`"warnings":[`,
|
||||
`identityunavailable`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("json output missing %q: %s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecuteJSON_UserIdentitySkipsBotInfo(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
@@ -498,26 +685,205 @@ func TestMeetingEvents_ExecuteJSON(t *testing.T) {
|
||||
out := strings.ReplaceAll(stdout.String(), " ", "")
|
||||
out = strings.ReplaceAll(out, "\n", "")
|
||||
for _, want := range []string{
|
||||
`"identity":{"id":"ou_testuser","participant_type":"human","label":"ou_testuser[human]"}`,
|
||||
`"event_type":"participant_joined"`,
|
||||
`"has_more":true`,
|
||||
`"page_token":"1710000000000000000"`,
|
||||
`"events":[`,
|
||||
`"has_more":false`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("json output missing %q: %s", want, stdout.String())
|
||||
t.Fatalf("user json output missing %q: %s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecuteJSON_OngoingMeetingOmitsEndTime(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing()}, false, ""))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &envelope); err != nil {
|
||||
t.Fatalf("invalid json output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data := common.GetMap(envelope, "data")
|
||||
meeting := common.GetMap(data, "meeting")
|
||||
if got := common.GetString(meeting, "status"); got != "ongoing" {
|
||||
t.Fatalf("meeting status = %q, want ongoing: %s", got, stdout.String())
|
||||
}
|
||||
if _, ok := meeting["end_time"]; ok {
|
||||
t.Fatalf("ongoing meeting should not expose dirty top-level end_time: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeetingEventsOutput_MeetingEndedLeaveReasonOverridesDirtyMeetingEndTime(t *testing.T) {
|
||||
out := buildMeetingEventsOutput(map[string]interface{}{}, []interface{}{
|
||||
participantLeftEventWithReason(leaveReasonMeetingEnded),
|
||||
}, meetingEventsIdentity{})
|
||||
|
||||
if got := out.Meeting.Status; got != "ended" {
|
||||
t.Fatalf("meeting status = %q, want ended", got)
|
||||
}
|
||||
if got := out.Meeting.EndTime; got != "2026-04-17T07:18:50Z" {
|
||||
t.Fatalf("meeting end_time = %q, want leave time", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeetingEventsOutput_NormalLeaveReasonDoesNotEndMeeting(t *testing.T) {
|
||||
out := buildMeetingEventsOutput(map[string]interface{}{}, []interface{}{
|
||||
participantLeftEventWithReason(leaveReasonUserLeft),
|
||||
}, meetingEventsIdentity{})
|
||||
|
||||
if got := out.Meeting.Status; got != "ongoing" {
|
||||
t.Fatalf("meeting status = %q, want ongoing", got)
|
||||
}
|
||||
if got := out.Meeting.EndTime; got != "" {
|
||||
t.Fatalf("meeting end_time = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMeetingEventsPretty_MeetingEndedLeaveReasonOverridesDirtyMeetingEndTime(t *testing.T) {
|
||||
timeline := buildMeetingEventTimeline([]interface{}{
|
||||
participantLeftEventWithReason(leaveReasonMeetingEnded),
|
||||
})
|
||||
got := renderMeetingEventsPretty(timeline)
|
||||
|
||||
if strings.Contains(got, "进行中") {
|
||||
t.Fatalf("pretty output should not show ongoing for meeting-ended leave reason: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "会议时间:2026-04-17 15:15:00 - 2026-04-17 15:18:50") {
|
||||
t.Fatalf("pretty output missing derived meeting end window: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeetingEventsOutput_UsesLatestMeetingSnapshot(t *testing.T) {
|
||||
out := buildMeetingEventsOutput(map[string]interface{}{}, []interface{}{
|
||||
participantJoinedEventOngoing(),
|
||||
participantJoinedEvent(),
|
||||
}, meetingEventsIdentity{})
|
||||
|
||||
if got := out.Meeting.Status; got != "ended" {
|
||||
t.Fatalf("meeting status = %q, want ended", got)
|
||||
}
|
||||
if got := out.Meeting.EndTime; got != "2026-04-17T07:35:00Z" {
|
||||
t.Fatalf("meeting end_time = %q, want latest ended snapshot", got)
|
||||
}
|
||||
if got := len(out.Events); got != 2 {
|
||||
t.Fatalf("events len = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeetingEventsOutput_EmptyEventsHasUnknownMeetingStatus(t *testing.T) {
|
||||
out := buildMeetingEventsOutput(map[string]interface{}{}, nil, meetingEventsIdentity{})
|
||||
|
||||
if got := out.Meeting.Status; got != "unknown" {
|
||||
t.Fatalf("meeting status = %q, want unknown", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventsMeetingFromPayload_StartOnlyIsOngoing(t *testing.T) {
|
||||
got := meetingEventsMeetingFromPayload(map[string]interface{}{
|
||||
"id": "m1",
|
||||
"start_time": "1776410100",
|
||||
})
|
||||
|
||||
if got.Status != "ongoing" {
|
||||
t.Fatalf("meeting status = %q, want ongoing", got.Status)
|
||||
}
|
||||
if got.StartTime != "2026-04-17T07:15:00Z" {
|
||||
t.Fatalf("meeting start_time = %q, want normalized RFC3339", got.StartTime)
|
||||
}
|
||||
if got.EndTime != "" {
|
||||
t.Fatalf("meeting end_time = %q, want empty", got.EndTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecuteNDJSONIncludesMetadataRow(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, true, "1710000000000000000"))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "ndjson",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("ndjson lines = %d, want 2: %s", len(lines), stdout.String())
|
||||
}
|
||||
if !strings.Contains(lines[0], `"row_type":"event"`) || !strings.Contains(lines[0], `"event_type":"participant_joined"`) {
|
||||
t.Fatalf("first ndjson row should be event: %s", lines[0])
|
||||
}
|
||||
for _, unwanted := range []string{
|
||||
`"summary":`,
|
||||
`"raw":`,
|
||||
} {
|
||||
if strings.Contains(lines[0], unwanted) {
|
||||
t.Fatalf("event ndjson row should not contain %q: %s", unwanted, lines[0])
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
`"row_type":"metadata"`,
|
||||
`"has_more":true`,
|
||||
`"page_token":"1710000000000000000"`,
|
||||
`"identity":`,
|
||||
} {
|
||||
if !strings.Contains(lines[1], want) {
|
||||
t.Fatalf("metadata ndjson row missing %q: %s", want, lines[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventsEventRows_OmitsEmptyEventFields(t *testing.T) {
|
||||
rows := meetingEventsEventRows([]meetingEventsEvent{
|
||||
{EventType: "unknown_event"},
|
||||
}, nil)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("rows len = %d, want 1", len(rows))
|
||||
}
|
||||
row, ok := rows[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("row type = %T, want map", rows[0])
|
||||
}
|
||||
for _, unwanted := range []string{"event_id", "event_time", "actors", "payload"} {
|
||||
if _, exists := row[unwanted]; exists {
|
||||
t.Fatalf("row should omit %q when empty: %#v", unwanted, row)
|
||||
}
|
||||
}
|
||||
if got := row["row_type"]; got != "event" {
|
||||
t.Fatalf("row_type = %v, want event", got)
|
||||
}
|
||||
if got := row["event_type"]; got != "unknown_event" {
|
||||
t.Fatalf("event_type = %v, want unknown_event", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecuteJSON_PrunesEmptySlices(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{chatReceivedEvent()}, false, ""))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "json",
|
||||
"--as", "user",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -536,20 +902,54 @@ func TestMeetingEvents_ExecuteJSON_PrunesEmptySlices(t *testing.T) {
|
||||
t.Fatalf("json output should not contain %q: %s", unwanted, out)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(out, `"message_type": 3`) {
|
||||
if !strings.Contains(out, `"message_type": 1`) {
|
||||
t.Fatalf("json output should keep numeric fields: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecuteJSON_PreservesReactionItems(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{mixedChatAndReactionEvent()}, false, ""))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
out := strings.ReplaceAll(stdout.String(), " ", "")
|
||||
out = strings.ReplaceAll(out, "\n", "")
|
||||
for _, want := range []string{
|
||||
`"event_type":"chat_received"`,
|
||||
`"chat_received_items":[`,
|
||||
`"content":"OK"`,
|
||||
`"message_type":3`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("json output missing %q: %s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, `"im_post"`) {
|
||||
t.Fatalf("json output should not include IM post payload: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_ExecutePretty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing(), multiChatReceivedEvent(), magicShareStartedEvent()}, true, "1710000000000000000"))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "pretty",
|
||||
"--as", "user",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -558,11 +958,12 @@ func TestMeetingEvents_ExecutePretty(t *testing.T) {
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"当前身份:Demo Bot [bot]",
|
||||
"会议主题:项目例会",
|
||||
"会议时间:2026-04-17 15:15:00(进行中)",
|
||||
"Demo Bot(bot_001) 加入了会议",
|
||||
"Alice(u1): [reaction] 第一条\\n第二行",
|
||||
"Alice(u1): [reaction] 第二条",
|
||||
"Alice(u1): [text] 第一条\\n第二行",
|
||||
"Alice(u1): [text] 第二条",
|
||||
"Bob(u2) 开始共享「共享文档」",
|
||||
"URL: https://example.com/doc",
|
||||
"page_token: 1710000000000000000",
|
||||
@@ -582,12 +983,13 @@ func TestMeetingEvents_ExecutePretty(t *testing.T) {
|
||||
func TestMeetingEvents_ExecutePretty_PrintsPageTokenWithoutHasMore(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub([]interface{}{participantJoinedEventOngoing()}, false, "pt_last"))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "pretty",
|
||||
"--as", "user",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -606,12 +1008,13 @@ func TestMeetingEvents_ExecutePretty_PrintsPageTokenWithoutHasMore(t *testing.T)
|
||||
func TestMeetingEvents_ExecuteEmpty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(meetingEventsStub(nil, false, ""))
|
||||
reg.Register(botInfoStub())
|
||||
|
||||
err := mountAndRun(t, VCMeetingEvents, []string{
|
||||
"+meeting-events",
|
||||
"--meeting-id", "7628568141510692381",
|
||||
"--format", "pretty",
|
||||
"--as", "user",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -850,9 +1253,9 @@ func TestLeaveAction(t *testing.T) {
|
||||
item map[string]interface{}
|
||||
want string
|
||||
}{
|
||||
{name: "meeting ended", item: map[string]interface{}{"leave_reason": 2}, want: "因会议结束离开了会议"},
|
||||
{name: "kicked", item: map[string]interface{}{"leave_reason": 3}, want: "被移出了会议"},
|
||||
{name: "default", item: map[string]interface{}{"leave_reason": 1}, want: "离开了会议"},
|
||||
{name: "meeting ended", item: map[string]interface{}{"leave_reason": leaveReasonMeetingEnded}, want: "因会议结束离开了会议"},
|
||||
{name: "kicked", item: map[string]interface{}{"leave_reason": leaveReasonKicked}, want: "被移出了会议"},
|
||||
{name: "default", item: map[string]interface{}{"leave_reason": leaveReasonUserLeft}, want: "离开了会议"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -884,6 +1287,70 @@ func TestMeetingEventUserWithID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventsIdentityFromParticipant_UsesContractFields(t *testing.T) {
|
||||
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
|
||||
"id": "u1",
|
||||
"user_name": "Alice",
|
||||
"user_type": 1,
|
||||
"user_role": 2,
|
||||
}, meetingEventsIdentity{})
|
||||
|
||||
if got.ParticipantType != "human" || got.Role != "host" {
|
||||
t.Fatalf("identity = %#v, want participant_type=human role=host", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventsIdentityFromParticipant_UserRoleParticipant(t *testing.T) {
|
||||
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
|
||||
"id": "u1",
|
||||
"user_name": "Alice",
|
||||
"user_type": 1,
|
||||
"user_role": 1,
|
||||
}, meetingEventsIdentity{})
|
||||
|
||||
if got.Role != "participant" {
|
||||
t.Fatalf("identity = %#v, want role=participant", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventsIdentityFromParticipant_UserTypeApp(t *testing.T) {
|
||||
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
|
||||
"id": "ou_app",
|
||||
"user_name": "Demo Bot",
|
||||
"user_type": 10,
|
||||
"user_role": 1,
|
||||
}, meetingEventsIdentity{})
|
||||
|
||||
if got.ParticipantType != "bot" {
|
||||
t.Fatalf("identity = %#v, want participant_type=bot", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventsIdentityFromParticipant_UnknownUserType(t *testing.T) {
|
||||
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
|
||||
"id": "u_unknown",
|
||||
"user_name": "Unknown",
|
||||
"user_type": 0,
|
||||
"user_role": 1,
|
||||
}, meetingEventsIdentity{})
|
||||
|
||||
if got.ParticipantType != "unknown" {
|
||||
t.Fatalf("identity = %#v, want participant_type=unknown", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventsIdentityFromParticipant_IgnoresGenericTypeField(t *testing.T) {
|
||||
got := meetingEventsIdentityFromParticipant(map[string]interface{}{
|
||||
"id": "u1",
|
||||
"user_name": "Alice",
|
||||
"type": "bot",
|
||||
}, meetingEventsIdentity{})
|
||||
|
||||
if got.ParticipantType != "human" {
|
||||
t.Fatalf("identity = %#v, generic type field should not drive participant_type", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventSummary(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -933,6 +1400,22 @@ func TestMeetingEventSummary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEventsEventFromPayloadUsesActivityEventTypeFallback(t *testing.T) {
|
||||
event := participantJoinedEvent()
|
||||
delete(event, "event_type")
|
||||
|
||||
got := meetingEventsEventFromPayload(event, meetingEventsIdentity{})
|
||||
if got.EventType != "participant_joined" {
|
||||
t.Fatalf("EventType = %q, want participant_joined", got.EventType)
|
||||
}
|
||||
if len(got.Actors) != 1 {
|
||||
t.Fatalf("actors len = %d, want 1: %#v", len(got.Actors), got.Actors)
|
||||
}
|
||||
if got.Actors[0].ID != "bot_001" {
|
||||
t.Fatalf("actor id = %q, want bot_001", got.Actors[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscapePrettyText(t *testing.T) {
|
||||
got := escapePrettyText("line1\nline2\t\r" + string(rune(0x07)))
|
||||
want := `line1\nline2\t\r\u0007`
|
||||
|
||||
@@ -199,6 +199,19 @@ func TestWikiNodeListNormalizesWikiURLParentNodeToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListAcceptsOpaqueParentNodeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const opaqueNodeToken = "Q6ZM_EXAMPLE_TOKEN"
|
||||
token, err := normalizeWikiNodeListParentToken(opaqueNodeToken)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeWikiNodeListParentToken() error = %v", err)
|
||||
}
|
||||
if token != opaqueNodeToken {
|
||||
t.Fatalf("token = %q, want %q", token, opaqueNodeToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListRejectsAmbiguousSpaceAndParentTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -224,11 +237,6 @@ func TestWikiNodeListRejectsAmbiguousSpaceAndParentTokens(t *testing.T) {
|
||||
input: "wik_placeholder/child",
|
||||
wantMsg: "raw wiki node token",
|
||||
},
|
||||
{
|
||||
name: "document token",
|
||||
input: "docx_placeholder_parent",
|
||||
wantMsg: "must be a wiki node token",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -351,10 +359,11 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
const parentNodeToken = "Q6ZM_EXAMPLE_TOKEN"
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes?page_size=50&parent_node_token=wik_parent",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes?page_size=50&parent_node_token=" + parentNodeToken,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
@@ -365,7 +374,7 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
|
||||
"node_token": "wik_child",
|
||||
"obj_token": "docx_child",
|
||||
"obj_type": "docx",
|
||||
"parent_node_token": "wik_parent",
|
||||
"parent_node_token": parentNodeToken,
|
||||
"node_type": "origin",
|
||||
"title": "Child Doc",
|
||||
"has_child": false,
|
||||
@@ -378,7 +387,7 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "7211568716812369922", "--parent-node-token", "wik_parent", "--as", "bot",
|
||||
"+node-list", "--space-id", "7211568716812369922", "--parent-node-token", parentNodeToken, "--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
@@ -400,8 +409,8 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
|
||||
if len(envelope.Data.Nodes) != 1 {
|
||||
t.Fatalf("len(nodes) = %d, want 1", len(envelope.Data.Nodes))
|
||||
}
|
||||
if envelope.Data.Nodes[0]["parent_node_token"] != "wik_parent" {
|
||||
t.Fatalf("nodes[0].parent_node_token = %v, want %q", envelope.Data.Nodes[0]["parent_node_token"], "wik_parent")
|
||||
if envelope.Data.Nodes[0]["parent_node_token"] != parentNodeToken {
|
||||
t.Fatalf("nodes[0].parent_node_token = %v, want %q", envelope.Data.Nodes[0]["parent_node_token"], parentNodeToken)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,8 +69,8 @@ var WikiNodeGet = common.Shortcut{
|
||||
{Name: "space-id", Desc: "optional: assert the resolved node lives in this space"},
|
||||
},
|
||||
Tips: []string{
|
||||
"--node-token accepts a raw token (wikcnXXX, docxXXX, ...) or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>.",
|
||||
"For raw obj_tokens (not starting with wik), pass --obj-type so the API knows how to resolve them; URL inputs infer it from the path.",
|
||||
"--node-token accepts a raw wiki node_token, obj_token, or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>.",
|
||||
"For raw obj_tokens, pass --obj-type so the API knows how to resolve them; URL inputs infer it from the path.",
|
||||
"Pair with +move / +node-copy / +delete-space to confirm space_id, obj_type, and parent before mutating.",
|
||||
"--token is the deprecated original name and still works for backward compatibility; new scripts should use --node-token.",
|
||||
},
|
||||
@@ -235,29 +235,10 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
|
||||
).WithParam("--node-token")
|
||||
} else {
|
||||
spec.Token = tokenInput
|
||||
if looksLikeWikiNodeToken(spec.Token) {
|
||||
if spec.ObjType == "" {
|
||||
spec.SourceKind = "raw-node"
|
||||
// node_tokens take no obj_type; reject a conflicting flag rather
|
||||
// than silently passing it (the API would just ignore it, but the
|
||||
// mismatch signals caller confusion).
|
||||
if spec.ObjType != "" {
|
||||
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--obj-type is only valid for obj_tokens; %q looks like a node_token",
|
||||
spec.Token,
|
||||
).WithParam("--obj-type")
|
||||
}
|
||||
} else {
|
||||
spec.SourceKind = "raw-obj"
|
||||
// A raw obj_token needs an explicit obj_type: get_node would
|
||||
// otherwise default to "doc" and fail confusingly for docx /
|
||||
// sheet / bitable / ... Fail fast with the same upfront contract
|
||||
// as +node-delete instead of deferring to an opaque API error.
|
||||
if spec.ObjType == "" {
|
||||
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--obj-type is required for a raw obj_token %q (one of: %s); or pass a typed Lark URL (e.g. /docx/<token>) so it can be inferred",
|
||||
spec.Token, strings.Join(wikiNodeGetObjTypeEnum, ", "),
|
||||
).WithParam("--obj-type")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,18 +251,6 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// looksLikeWikiNodeToken returns true when the token has the `wik` prefix used
|
||||
// for node_tokens. Lark wiki tokens are case-insensitive in practice; callers
|
||||
// pass `wikcn`/`wikus`/`Wik...` interchangeably, so normalize for the check.
|
||||
//
|
||||
// This is a heuristic based on the current Lark token-naming convention, not a
|
||||
// guaranteed invariant: if Lark ever introduces a non-node token type that
|
||||
// also starts with `wik`, it would be misclassified. Worst case is a
|
||||
// confusing API error (no data risk); revisit if the token scheme changes.
|
||||
func looksLikeWikiNodeToken(token string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(token), "wik")
|
||||
}
|
||||
|
||||
// tokenAndObjTypeFromWikiURL extracts the token and inferred obj_type from a
|
||||
// Lark URL path. The wiki path returns an empty obj_type because node_tokens
|
||||
// don't need one.
|
||||
|
||||
@@ -31,6 +31,22 @@ func TestParseWikiNodeGetSpecRawNodeToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecOpaqueRawNodeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const opaqueNodeToken = "Sm78_EXAMPLE_TOKEN"
|
||||
spec, err := parseWikiNodeGetSpec(opaqueNodeToken, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||
}
|
||||
if spec.Token != opaqueNodeToken || spec.ObjType != "" || spec.SourceKind != "raw-node" {
|
||||
t.Fatalf("spec = %+v, want raw-node %s with no obj_type", spec, opaqueNodeToken)
|
||||
}
|
||||
if got := spec.RequestParams(); !reflect.DeepEqual(got, map[string]interface{}{"token": opaqueNodeToken}) {
|
||||
t.Fatalf("RequestParams() = %v, want {token: %s}", got, opaqueNodeToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRawObjTokenWithExplicitObjType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -43,23 +59,30 @@ func TestParseWikiNodeGetSpecRawObjTokenWithExplicitObjType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRejectsRawObjTokenWithoutObjType(t *testing.T) {
|
||||
func TestParseWikiNodeGetSpecRawTokenWithoutObjTypeDefaultsToNodeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Mirrors +node-delete: a raw obj_token with no --obj-type must fail
|
||||
// upfront instead of defaulting to "doc" and hitting an opaque API error.
|
||||
_, err := parseWikiNodeGetSpec("bascnXYZ", "", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "--obj-type is required for a raw obj_token") {
|
||||
t.Fatalf("expected raw obj_token obj-type-required error, got %v", err)
|
||||
spec, err := parseWikiNodeGetSpec("bascnXYZ", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||
}
|
||||
if spec.Token != "bascnXYZ" || spec.ObjType != "" || spec.SourceKind != "raw-node" {
|
||||
t.Fatalf("spec = %+v, want raw-node bascnXYZ with no obj_type", spec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWikiNodeGetSpecRejectsObjTypeOnNodeToken(t *testing.T) {
|
||||
func TestParseWikiNodeGetSpecRawTokenWithObjTypeUsesObjTokenLookup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseWikiNodeGetSpec("wikcnABC", "docx", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "only valid for obj_tokens") {
|
||||
t.Fatalf("expected node_token + obj_type rejection, got %v", err)
|
||||
spec, err := parseWikiNodeGetSpec("wikcnABC", "docx", "")
|
||||
if err != nil {
|
||||
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
|
||||
}
|
||||
if spec.Token != "wikcnABC" || spec.ObjType != "docx" || spec.SourceKind != "raw-obj" {
|
||||
t.Fatalf("spec = %+v, want raw-obj wikcnABC with obj_type docx", spec)
|
||||
}
|
||||
if got := spec.RequestParams(); !reflect.DeepEqual(got, map[string]interface{}{"token": "wikcnABC", "obj_type": "docx"}) {
|
||||
t.Fatalf("RequestParams() = %v, want {token: wikcnABC, obj_type: docx}", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -207,11 +207,6 @@ func normalizeWikiNodeListParentToken(parentNodeToken string) (string, error) {
|
||||
"--parent-node-token must be a raw wiki node token, not a partial URL or path",
|
||||
).WithParam("--parent-node-token")
|
||||
}
|
||||
if !looksLikeWikiNodeToken(parentNodeToken) {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--parent-node-token must be a wiki node token; do not pass a docx/sheet/base/file token",
|
||||
).WithParam("--parent-node-token").WithHint("Run `lark-cli wiki +node-get --node-token <url-or-token>` to resolve a document URL or obj_token to the wiki `node_token` first.")
|
||||
}
|
||||
if err := validateOptionalResourceName(parentNodeToken, "--parent-node-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
1. `+triage --from spam@x.com` → 列出 N 条结果
|
||||
2. 展示:"将删除 N 封邮件(发件人 spam@x.com,主题:…),确认?"
|
||||
3. 用户确认后 → `*.batch_trash`
|
||||
3. 用户确认后 → `+message-trash --message-ids ... --yes`
|
||||
|
||||
## 身份选择:优先使用 user 身份
|
||||
|
||||
@@ -82,12 +82,13 @@
|
||||
1. **确认身份** — 首次操作邮箱前先调用 `lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"me"}'` 获取当前用户的真实邮箱地址(`primary_email_address`),不要通过系统用户名猜测。后续判断"发件人是否为用户本人"时以此地址为准。
|
||||
2. **浏览** — `+triage` 查看收件箱摘要,获取 `message_id` / `thread_id`
|
||||
3. **阅读** — `+message` 读单封邮件,`+thread` 读整个会话
|
||||
4. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
5. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
6. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
|
||||
7. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
||||
8. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
||||
9. **已读回执** —
|
||||
4. **整理** — 标签、已读/未读状态和移动文件夹优先用 `+message-modify`;软删除优先用 `+message-trash`
|
||||
5. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
6. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
7. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
|
||||
8. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
||||
9. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
||||
10. **已读回执** —
|
||||
- **请求回执(写信侧)**:`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。
|
||||
- **响应回执(拉信侧)**:拉信看到 `label_ids` 含 `READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。
|
||||
|
||||
@@ -417,7 +418,7 @@ lark-cli mail +message --message-id <id>
|
||||
|
||||
## 原生 API 调用规则
|
||||
|
||||
没有 Shortcut 覆盖的操作才使用原生 API。调用步骤以本节为准(API Resources 章节的 resource/method 列表可辅助查阅)。
|
||||
没有 Shortcut 覆盖的操作才使用原生 API。标签、已读状态、移动文件夹优先使用 `+message-modify`;软删除优先使用 `+message-trash`。调用步骤以本节为准(API Resources 章节的 resource/method 列表可辅助查阅)。
|
||||
|
||||
### Step 1 — 用 `-h` 确定要调用的 API(必须,不可跳过)
|
||||
|
||||
|
||||
@@ -12,6 +12,16 @@ metadata:
|
||||
|
||||
妙搭应用属于用户资产。默认用 `--as user`;认证、scope、exit-10、高风险确认、`_notice` 等通用处理只读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不要在本 skill 里复制。妙搭应用有三条开发路径:**本地全栈**(拉源码本地写)/ **HTML 托管**(发布静态产物)/ **云端会话**(妙搭 AI 生成)。
|
||||
|
||||
## 身份与一次性授权
|
||||
|
||||
妙搭应用是用户的个人资产,统一 `--as user`(见开头)。**首次操作前先一次性把本域 scope 全拿到**,避免每条命令首次跑都触发新一轮授权,或未授权直接打到 openapi 导致服务端报错:
|
||||
|
||||
```bash
|
||||
lark-cli auth login --domain apps
|
||||
```
|
||||
|
||||
因缺权限失败(`error.subtype == "missing_scope"`)时的通用处理见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),同样按 `--domain apps` 授权。
|
||||
|
||||
## 意图路由
|
||||
|
||||
按具体操作查命令(开发路径先用下方「选择开发路径」判定表定好再进来取命令):
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- 必填:`--app-id`,以及 `--sql` / `--file` 二选一(互斥)。
|
||||
- `--sql`:内联 SQL 文本;传 `-` 时从 stdin 读。绝对路径文件经 stdin 传入:`--sql - < <absolute-path>`(shell 解析路径,CLI 仅接收内容)。
|
||||
- `--file`:`.sql` 文件路径,需为工作目录内的相对路径(如 `--file ./migration.sql`);绝对路径、或经 `..`/符号链接越出工作目录的路径会被拒绝。文件不在工作目录内时,改用 `--sql - < <文件路径>` 经 stdin 传入。
|
||||
- `--environment` 枚举:`dev` / `online`,**默认 `dev`**;操作线上库、或**未开启多环境的应用(其数据库在 `online`,没有 dev 分支)**时显式 `--environment online`。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。
|
||||
- `--environment` 枚举:`dev` / `online`,**不传则由服务端按应用是否开启多环境自动选择(多环境→`dev`,未开启多环境→`online`)**;要固定环境就显式传 `--environment dev|online`。**未开启多环境的应用显式传 `--environment dev` 会报错(无 dev 分支)——这类应用不传 `--environment`(走 `online`)或显式 `--environment online`**。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。
|
||||
- risk 是 `high-risk-write`(SQL 可含 DML/DDL):任何执行都需 `--yes`,否则返回 `confirmation_required` / exit 10。`--dry-run` 预览不需要 `--yes`。
|
||||
- **不会自动为你包事务,事务边界需自己在 SQL 里控制**:多语句默认逐条独立提交,中间某条失败时前序语句已生效、不会回滚;若需要「要么全部成功、要么全部回滚」的原子性,请在 SQL 内显式写 `BEGIN … COMMIT`(详见下「Agent 规则」)。
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
## 约定(先读)
|
||||
|
||||
- **环境 `--environment dev|online`(所有 db 命令统一默认 `dev`)**:看表、看结构、数据导入导出、变更追溯、审计、配额都按环境区分,写操作建议先在 `dev` 验。**注意:只有开启了多环境(`+db-env-create`)的应用才有 `dev` 分支;未开启多环境的应用其数据库在 `online`——对这类应用必须显式 `--environment online`,否则默认的 `dev` 分支不存在、会报错**。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。`+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义、`+db-recovery-*` 作用于当前库,二者**没有** `--environment`。
|
||||
- **环境 `--environment dev|online`(可省略)**:看表、看结构、数据导入导出、变更追溯、审计、配额都按环境区分。省略 `--environment` 时 CLI 不带该参数、由服务端按应用形态自动选分支——多环境应用走 `dev`、未开多环境的走 `online`;要固定环境就显式传。唯一会报错的组合:对未开多环境的应用显式传 `--environment dev`(无 `dev` 分支)。写操作建议先在 `dev` 验(仅多环境应用有 `dev`)。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。`+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义,**没有** `--environment`。
|
||||
- **本地文件 / `--output` 用工作目录内相对路径**:导入 `--file ./orders.csv`、导出 `--output ./out.csv`;绝对路径、或经 `..`/符号链接越出工作目录的 `--output` 会被拒(validation / exit 2)。路径在别处先 `cd` 过去或改成相对路径。
|
||||
- **高危操作必须带 `--yes`**:`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply` 缺省会被确认关卡拦下;动手前先用对应的预览命令或 `--dry-run` 看清影响。
|
||||
- **时间参数按口语自然传**(`--since`/`--until`/`--target`),格式见末尾。
|
||||
@@ -154,7 +154,7 @@ lark-cli apps +db-quota-get --app-id app_xxx --environment dev
|
||||
|
||||
## Agent 规则
|
||||
|
||||
- 用户说「本地 / 开发库 / 调试库」优先 `--environment dev`,线上排查用 `--environment online`;数据面写操作(导入 / 审计开关)默认先在 `dev` 验再动 `online`。
|
||||
- 用户说「本地 / 开发库 / 调试库」优先 `--environment dev`,线上排查用 `--environment online`;数据面写操作(导入 / 审计开关)建议先在 `dev` 验再动 `online`。**注意省略 `--environment` 时写操作会落到服务端选中的分支——单环境应用即 `online`(生产)**:不确定应用是否多环境时,写操作显式传 `--environment`;显式 `dev` 在单环境应用上会安全报错(无 dev 分支),正好当「是否多环境」的探针用。
|
||||
- 看表用 `+db-table-list`,看结构用 `+db-table-get`(要建表语句加 `--format pretty`);`+db-env-create` 仅用于存量单库拆多环境,新建的 full_stack 应用一般不需要。
|
||||
- 四个高危命令(`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply`)动手前先看清影响再带 `--yes`:发布 / 恢复先跑对应预览 `+db-env-diff` / `+db-recovery-diff`,导入无预览命令、可先 `--dry-run` 看请求或先在 `--environment dev` 验;不要静默追加 `--yes`,遇 confirmation_required(exit 10)按 lark-shared 协议向用户确认不可逆风险后再补 `--yes` 重试。
|
||||
- 导入 / 导出的本地路径用工作目录内相对路径;超大表导出会被行数 / 体积上限拒,改用 `+db-execute` 分批。
|
||||
|
||||
@@ -44,6 +44,8 @@ SubAgent 插入 SVG。
|
||||
</whiteboard>
|
||||
```
|
||||
|
||||
如果 Mermaid 已在本地文件中,可写成 `<whiteboard type="mermaid" path="@diagram.mmd"></whiteboard>`;CLI 会在写入前读取文件并展开为内联内容。
|
||||
|
||||
### 步骤 2B: SubAgent 使用 SVG 插入图表
|
||||
|
||||
主 Agent 启动 SubAgent,让它用 `docs +create` / `docs +update` 插入:
|
||||
@@ -56,6 +58,8 @@ SubAgent 插入 SVG。
|
||||
</whiteboard>
|
||||
```
|
||||
|
||||
如果 SVG 已在本地文件中,可写成 `<whiteboard type="svg" path="@diagram.svg"></whiteboard>`;PlantUML 文件同理使用 `<whiteboard type="plantuml" path="@sequence.puml"></whiteboard>`。
|
||||
|
||||
Sub Agent 需要携带以下的最小上下文,以及后续的 [SVG 设计 Workflow] 章节指南:
|
||||
|
||||
- doc token、插入位置(标题 / block_id / command)
|
||||
|
||||
@@ -41,7 +41,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
文档中可嵌入外部资源块(属于容器标签的特殊形式),需要额外语法创建:
|
||||
|
||||
- `<img>` — `<img href="https://..."/>` 上传网络图片
|
||||
- `<whiteboard>` — 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整自包含 SVG</whiteboard>`;复杂图使用 `<whiteboard type="blank"></whiteboard>` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入;
|
||||
- `<whiteboard>` — 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整自包含 SVG</whiteboard>`;也可用本地文件简写 `<whiteboard type="svg" path="@diagram.svg"></whiteboard>`、`<whiteboard type="mermaid" path="@flow.mmd"></whiteboard>`、`<whiteboard type="plantuml" path="@sequence.puml"></whiteboard>`,CLI 会写入前展开为内联内容;复杂图使用 `<whiteboard type="blank"></whiteboard>` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入;
|
||||
- `<sheet>` — `<sheet type="blank"></sheet>` 空白;`<sheet sheet-id="SID" token="TOKEN"></sheet>` 复制已有
|
||||
- `<task>` — `<task task-id="GUID"></task>`,必传 task-id(任务 guid)
|
||||
- `<chat_card>` — `<chat_card chat-id="CHAT_ID"></chat_card>`,必传 chat-id
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-drive
|
||||
version: 1.0.0
|
||||
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用;doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责:文档内容编辑(走 lark-doc)、表格/Base 表内数据操作(走 lark-sheets/lark-base)、知识空间节点/成员管理(走 lark-wiki)、原生 Markdown 文件读写/patch/diff(走 lark-markdown)。"
|
||||
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token、判断链接类型/真实 token/标题,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用;doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责:文档内容编辑(走 lark-doc)、表格/Base 表内数据操作(走 lark-sheets/lark-base)、知识空间节点/成员管理(走 lark-wiki)、原生 Markdown 文件读写/patch/diff(走 lark-markdown)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -21,8 +21,10 @@ metadata:
|
||||
## 快速决策
|
||||
|
||||
- 用户要**复制文档 / 创建副本 / 另存为副本**时,使用 `lark-cli drive files copy`。先用 `lark-cli schema drive.files.copy --format json` 确认参数;如果来源是 wiki URL/token,先用 `lark-cli drive +inspect` 获取底层 `token` 和 `type`,不要把 wiki token 直接当 `file_token`。`params.file_token` 传源文档 token,`data.folder_token` 传目标文件夹 token,`data.name` 传副本名称,`data.type` 传源文件类型(如 `docx` / `sheet` / `bitable` / `slides`)。示例:`lark-cli drive files copy --params '{"file_token":"<DOC_TOKEN>"}' --data '{"folder_token":"<FOLDER_TOKEN>","name":"<COPY_NAME>","type":"docx"}'`。如返回 `confirmation_required`,按 `lark-shared` 高风险审批协议向用户确认后,在原命令末尾追加 `--yes` 重试。
|
||||
- 用户要**识别飞书 / doubao 云空间 URL 的类型和 token**时,可以先按 URL 路径形态做轻量判断;当路径已明确指向 docx / sheet / bitable / slides / file / folder 等资源时,可直接提取对应 token/type。传入 wiki URL、需要识别标题或 canonical URL、URL/token 有歧义,或后续操作依赖底层真实资源时,再使用 `lark-cli drive +inspect --url '<url>'` 进行识别;具体用法、失败处理和边界见 [`references/lark-drive-inspect.md`](references/lark-drive-inspect.md)。
|
||||
- 高风险写操作(删除、公开权限修改、owner 转移、版本删除/回滚、批量移动/覆盖/同步)必须同时满足三个条件才执行:目标已解析为该操作可直接使用的执行对象,执行细节已明确到可直接调用命令(例如删除的 file-token/type、公开权限修改的共享范围、owner 转移的目标 owner、版本删除/回滚的 version id、移动/覆盖/同步的目标位置和冲突策略),且用户在本轮明确确认执行这些具体目标和执行细节。用户只说“删除没用的文件”“开放/共享给大家”“改成开放”“覆盖/移动这些”只表示目标状态;先只读发现并列出候选、权限档位或执行方案,停止等待用户确认。
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow-knowledge-organize.md`](references/lark-drive-workflow-knowledge-organize.md)。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
|
||||
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
|
||||
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`,owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag,避免手写嵌套 JSON。
|
||||
- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `need_relation=true` 返回评论位置,其他类型暂不支持,具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解。
|
||||
- 用户给出 doubao.com 的云空间资源 URL/token,或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时,仍按资源类型、URL 路径和 token 路由到本 skill;不要因为域名不是飞书而回退到 WebFetch。
|
||||
@@ -162,7 +164,7 @@ lark-cli drive <resource> <method> [flags] # 调用 API
|
||||
|
||||
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
>
|
||||
> **高频原生命令:** 读取 Drive 文件夹清单时使用 `drive files list`,必须按 [`references/lark-drive-files-list.md`](references/lark-drive-files-list.md) 的模板通过 `--params` 传 `folder_token` / `page_token`,并手动处理分页;不要把 `--page-all` 输出直接交给 JSON 解析脚本。
|
||||
> **高频原生命令:** 读取 Drive 文件夹清单时使用 `drive files list`,使用前先读 [`references/lark-drive-files-list.md`](references/lark-drive-files-list.md),按模板通过 `--params` 传参并手动处理分页;不要把 `--page-all` 输出直接交给 JSON 解析脚本。
|
||||
|
||||
### files
|
||||
|
||||
@@ -204,10 +206,12 @@ lark-cli drive <resource> <method> [flags] # 调用 API
|
||||
### file.statistics
|
||||
|
||||
- `get` — 获取文件统计信息
|
||||
- 获取 docx / 文件统计信息时,建议优先使用 typed flags:`lark-cli drive file.statistics get --file-token <token> --file-type <type> --format json`;`--params` JSON 也支持,适合批量拼装或 raw 参数场景。
|
||||
|
||||
### file.view_records
|
||||
|
||||
- `list` — 获取文档的访问者记录
|
||||
- 查看 docx 最近访问记录、返回 open_id、最多 N 条时,建议优先使用 typed flags:`lark-cli drive file.view_records list --file-token <docx_token> --file-type docx --page-size <N> --viewer-id-type open_id --format json`;`--params` JSON 也支持,适合批量拼装、分页续跑或 raw 参数场景。
|
||||
|
||||
### file.comment.reply.reactions
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user