Compare commits

..

2 Commits

Author SHA1 Message Date
wangweiming
21b81f7ae4 fix: update ppe request headers
Change-Id: I7ea680f3f553646b41b0591b701eb14057f6c11d
2026-06-09 21:19:40 +08:00
wangweiming
5eaeab2bb3 feat: add PPE headers and log IDs
Inject PPE request headers into base CLI headers, log OpenAPI response log IDs, and point Feishu endpoints at the pre environment for this branch.

Change-Id: Id8a5328796ab8cdfa25147daace976e2fa242017
2026-06-08 21:41:57 +08:00
439 changed files with 3928 additions and 31964 deletions

30
.github/CODEOWNERS vendored
View File

@@ -1,30 +0,0 @@
/internal/ @liangshuo-1
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
/skills/ @liangshuo-1
/skills/lark-approval/
/skills/lark-apps/
/skills/lark-attendance/
/skills/lark-base/
/skills/lark-calendar/
/skills/lark-contact/
/skills/lark-doc/
/skills/lark-drive/
/skills/lark-event/
/skills/lark-im/
/skills/lark-mail/
/skills/lark-markdown/
/skills/lark-minutes/
/skills/lark-okr/
/skills/lark-openapi-explorer/
/skills/lark-shared/
/skills/lark-sheets/
/skills/lark-skill-maker/
/skills/lark-slides/
/skills/lark-task/
/skills/lark-vc/
/skills/lark-vc-agent/
/skills/lark-whiteboard/
/skills/lark-wiki/
/skills/lark-workflow-meeting-summary/
/skills/lark-workflow-standup-report/

2
.gitignore vendored
View File

@@ -35,8 +35,6 @@ tests/mail/reports/
# Generated / test artifacts # Generated / test artifacts
.hammer/ .hammer/
.lark-slides/ .lark-slides/
/notes/
/minutes/
internal/registry/meta_data.json internal/registry/meta_data.json
cmd/api/download.bin cmd/api/download.bin
app.log app.log

View File

@@ -73,20 +73,20 @@ linters:
- forbidigo - forbidigo
# errs-typed-only enforced on paths already migrated to errs.NewXxxError. # errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# Add a path when its migration is complete. # Add a path when its migration is complete.
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/) - path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
text: errs-typed-only text: errs-typed-only
linters: linters:
- forbidigo - forbidigo
# errs-no-bare-wrap enforced on paths fully migrated to typed final # errs-no-bare-wrap enforced on paths fully migrated to typed final
# errors. Scoped separately from errs-typed-only because cmd/auth/, # errors. Scoped separately from errs-typed-only because cmd/auth/,
# cmd/config/ still have residual fmt.Errorf and must not be caught. # cmd/config/ still have residual fmt.Errorf and must not be caught.
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/) - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go)
text: errs-no-bare-wrap text: errs-no-bare-wrap
linters: linters:
- forbidigo - forbidigo
# errs-no-legacy-helper enforced on domains whose shared validation/save # errs-no-legacy-helper enforced on domains whose shared validation/save
# helpers have migrated to typed final errors. # helpers have migrated to typed final errors.
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/) - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
text: errs-no-legacy-helper text: errs-no-legacy-helper
linters: linters:
- forbidigo - forbidigo

View File

@@ -17,7 +17,6 @@ builds:
goarch: goarch:
- amd64 - amd64
- arm64 - arm64
- riscv64
archives: archives:
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}" - name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"

View File

@@ -11,7 +11,7 @@
```bash ```bash
make build # Build (runs fetch_meta first) make build # Build (runs fetch_meta first)
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64) make unit-test # Required before PR (runs with -race)
make test # Full: vet + unit + integration make test # Full: vet + unit + integration
``` ```
@@ -75,31 +75,7 @@ The one rule to internalize: **every error message you write will be parsed by a
### Structured errors in commands ### Structured errors in commands
Command-facing failures must be typed `errs.*` errors — never the legacy `output.Err*` helpers and never a final bare `fmt.Errorf`. AI agents parse the stderr envelope's `type` / `subtype` / `param` / `hint` fields to decide their next action; the full taxonomy lives in `errs/ERROR_CONTRACT.md`. `RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
Picking a constructor:
| Failure | Constructor |
|---------|-------------|
| User flag/arg fails validation | `errs.NewValidationError(errs.SubtypeInvalidArgument, ...).WithParam("--flag")` |
| Valid request, wrong system state | `errs.NewValidationError(errs.SubtypeFailedPrecondition, ...).WithHint(...)` |
| Lark API returned `code != 0` | `runtime.CallAPITyped` (shortcuts) / `errclass.BuildAPIError` (raw responses) — never hand-build |
| Network / transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTransport, ...)` |
| Local file I/O failure | `errs.NewInternalError(errs.SubtypeFileIO, ...)` — validate the path first (`validate.SafeInputPath` / `SafeOutputPath`) and use `vfs.*` |
| Unclassified lower-layer error as final | `errs.NewInternalError(errs.SubtypeUnknown, ...).WithCause(err)` |
| Lower layer already returned a typed error | pass it through unchanged — re-wrapping downgrades its classification |
Signatures that are easy to guess wrong:
- `runtime.CallAPITyped(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error)` — it performs the HTTP request itself and classifies `code != 0` into a typed error; just return the error it gives you.
- Typed pass-through check: `if _, ok := errs.ProblemOf(err); ok { return err }``ProblemOf` returns `(*errs.Problem, bool)`, not a nilable pointer.
- `.WithParam` exists only on `*errs.ValidationError`. `InternalError` / `NetworkError` have no param field — file or endpoint context goes in the message or `.WithHint(...)`.
`forbidigo` + `lint/errscontract` reject the legacy `output.Err*` helpers, bare final `fmt.Errorf` / `errors.New`, and legacy envelope literals on migrated paths. Beyond what lint catches, three authoring conventions apply:
- Preserve the underlying error with `.WithCause(err)` so `errors.Is` / `errors.Unwrap` keep working.
- `param` names only the user input that actually failed. Recovery guidance goes in `.WithHint(...)`; machine-readable recovery fields (`missing_scopes`, `log_id`) carry server/system ground truth only — never caller-side guesses.
- Error-path tests assert typed metadata via `errs.ProblemOf` (`category` / `subtype` / `param`) and cause preservation, not message substrings alone.
### stdout is data, stderr is everything else ### stdout is data, stderr is everything else

View File

@@ -2,129 +2,6 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [v1.0.53] - 2026-06-12
### Features
- **auth**: Revoke user tokens server-side on `auth logout` (#1434)
- **auth**: Add `--json` flag support to auth subcommands (#1431)
- **token**: Mint TAT via unified OAuth v3 Token Endpoint (#1408)
- **note**: Split note into a dedicated domain with `+detail` and `+transcript` flows (#1345, #1417, #1435)
- **im**: Unify sort flags into `--sort` field and `--order` direction (#1302)
### Bug Fixes
- **apps**: Read release error_logs from `data.error_logs` in `+release-get` (#1436)
### Documentation
- **skills**: Optimize whiteboard skill (#1371)
- **skills**: Optimize okr skill (#1368)
## [v1.0.52] - 2026-06-11
### Features
- **events**: Per-resource subscription identity + Match hook (#1185)
- **apps**: Emit typed error envelopes across the apps domain (#1288)
- **wiki**: Emit typed error envelopes across the wiki domain (#1350)
- **im**: Add `--chat-modes` filter to chat search (#1317)
- **apps**: Exclude `.git` directory from `+html-publish` package (#1396)
- **build**: Support riscv64 prebuilt binaries in release and install pipeline
### Bug Fixes
- **apps**: Support git credential dry-run (#1390)
- **whiteboard**: Fix parsing empty whiteboard content (#1391)
- **build**: Make `-race` flag arch-conditional to support riscv64
### Documentation
- **im**: Document `chat.user_setting` batch_query/batch_update (#1339)
- **im**: Document `chat.managers` and `chat.moderation` API resources (#1294)
- **skills**: Optimize lark-drive skill routing (#1284)
- **skills**: Expand cite user guidance and fix typos (#1394)
## [v1.0.51] - 2026-06-10
### Features
- **apps**: Support multi dev modes (#1175)
- **im**: Complete audio/post rendering and add opt-in `--download-resources` (#1245)
- **base**: Configure initial base table schema (#1377)
- **vc**: Add recording event support (#1369)
- **minutes**: Replace words for transcript (#1372)
- **markdown**: Emit typed error envelopes across the markdown domain (#1347)
- **sheets**: Emit typed error envelopes across the sheets domain (#1348)
- **slides**: Emit typed error envelopes across the slides domain (#1349)
### Documentation
- **skills**: Warn about `@file` absolute path restriction in lark-doc skills (#1375)
- **skills**: Remove unsupported ⚠️ from callout emoji list (#1374)
## [v1.0.50] - 2026-06-09
### Features
- **doc**: Emit typed error envelopes across the doc domain (#1346)
- **event**: Emit typed error envelopes across the event domain (#1289)
- **contact**: Emit typed error envelopes across the contact domain (#1287)
- **sheets**: Guard `+csv-put --csv` against a path passed without `@` (#1337)
- **cli**: Adjust agent timeout hint output conditions (#1328)
### Bug Fixes
- **drive**: Add `@file`/stdin support to `+add-comment --content` (#1343)
- **slides**: Build create URL locally instead of drive metas call (#1329)
- **cli**: Clarify `--block-id` supports comma-separated batch delete in help text (#1336)
### Documentation
- **doc**: Replace append with `block_insert_after` in skeleton workflow guidance (#1340)
- **doc**: Document `<folder-manager>` resource block (#1168)
- **drive**: Add drive comment location guidance (#1258)
## [v1.0.49] - 2026-06-08
### Features
- **events**: Add whiteboard event domain with per-board subscription (#1265)
- **im**: Support feed group (#1102)
- **im**: Add feed shortcut create, list, and remove shortcuts (#1273)
- **im**: Format feed group error handling (#1308)
- **im**: Return typed error envelopes across the im domain (#1230)
- **base**: Emit typed error envelopes across the base domain (#1248)
- **calendar**: Emit typed error envelopes across the calendar domain (#1232)
- **task**: Emit typed error envelopes across the task domain (#1231)
- **okr,whiteboard**: Emit typed error envelopes across both domains (#1236)
- **minutes,vc**: Emit typed error envelopes across both domains (#1234)
- **markdown**: Harden create upload failures (#1325)
- **drive**: Harden inspect shortcut failures (#1324)
- **slides**: Add IconPark lookup for Lark slides (#1123)
- **doc**: Remove docs v1 API (#1291)
- **cli**: Add `skills` command to read embedded skill content (#1318)
- **cli**: Fetch official skills index (#1301)
- **shared**: Document relative-path-only file arguments (#1319)
- **scopes**: Clear `recommend.allow` scope auto-approve overrides (#1272)
- **shortcuts**: Check shortcut example commands against the live CLI tree (#1244)
### Bug Fixes
- **events**: Keep bounded event consume runs alive after stdin EOF (#1285)
- **drive**: Use docs secure label read scope (#1281)
### Documentation
- **approval**: Restructure skill with intent table and scope boundaries (#1307)
- **skills**: Tighten drive and markdown guardrails (#1326)
- **skills**: Optimize calendar, vc, and minutes skill guidance (#1269)
- **markdown**: Add markdown domain template (#1293)
- **markdown**: Improve lark-markdown skill guidance (#1279)
- **doc**: Improve lark-doc skill guidance (#1283)
- **wiki**: Optimize skill guidance and routing boundaries (#1275)
- **slides**: Tighten routing/boundary and reconcile in-slide whiteboard (#1169)
## [v1.0.48] - 2026-06-04 ## [v1.0.48] - 2026-06-04
### Features ### Features
@@ -1149,11 +1026,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese). - Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases. - CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51
[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50
[v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48 [v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47 [v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46 [v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46

View File

@@ -8,13 +8,6 @@ DATE := $(shell date +%Y-%m-%d)
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE) LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
PREFIX ?= /usr/local PREFIX ?= /usr/local
# The repository's Go 1.23 CI toolchain does not support -race on riscv64.
# Prefer GOARCH passed to make (for example, `make GOARCH=riscv64 unit-test`)
# over `go env GOARCH`, because command-line make variables are not visible to
# $(shell ...).
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
.PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks .PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
all: test all: test
@@ -41,7 +34,7 @@ fmt-check:
# ./extension/... keeps the public plugin SDK in the default test matrix. # ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta unit-test: fetch_meta
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \ go test -race -gcflags="all=-N -l" -count=1 \
./cmd/... ./internal/... ./shortcuts/... ./extension/... ./cmd/... ./internal/... ./shortcuts/... ./extension/...
# examples-build keeps the shipped plugin-SDK examples compilable. If this # examples-build keeps the shipped plugin-SDK examples compilable. If this

View File

@@ -41,7 +41,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances | | ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. | | 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) | | 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
| 🔗 Apps | Create Spark/Miaoda apps, publish HTML/static sites, run cloud generation, and manage access scope | | 🔗 Apps | Develop, deploy HTML, web pages and applications |
## Installation & Quick Start ## Installation & Quick Start

View File

@@ -41,7 +41,7 @@
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 | | ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| 🎯 OKR | 查询、创建、更新 OKR管理目标、关键结果、对齐、指标和进展记录 | | 🎯 OKR | 查询、创建、更新 OKR管理目标、关键结果、对齐、指标和进展记录 |
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) | | 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
| 🔗 应用 | 创建妙搭Spark/Miaoda应用、发布 HTML/静态站点、云端生成迭代、管理可用范围 | | 🔗 应用 | 开发、部署 HTML、Web 页面和应用 |
## 安装与快速开始 ## 安装与快速开始

View File

@@ -91,29 +91,6 @@ func TestAuthCheckCmd_FlagParsing(t *testing.T) {
} }
} }
func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *CheckOptions
cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthLogoutCmd_FlagParsing(t *testing.T) { func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil) f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -132,27 +109,6 @@ func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
} }
} }
func TestAuthLogoutCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *LogoutOptions
cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthListCmd_FlagParsing(t *testing.T) { func TestAuthListCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil) f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -170,27 +126,6 @@ func TestAuthListCmd_FlagParsing(t *testing.T) {
} }
} }
func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *ListOptions
cmd := NewCmdAuthList(f, func(opts *ListOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Error("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthStatusCmd_FlagParsing(t *testing.T) { func TestAuthStatusCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -210,29 +145,6 @@ func TestAuthStatusCmd_FlagParsing(t *testing.T) {
} }
} }
func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *StatusOptions
cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Error("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthStatusCmd_VerifyFlag(t *testing.T) { func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -355,32 +267,6 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
} }
} }
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *ScopesOptions
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--format", "pretty", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
if gotOpts.Format != "json" {
t.Errorf("expected format json, got %s", gotOpts.Format)
}
}
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) { func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,

View File

@@ -19,7 +19,6 @@ import (
type CheckOptions struct { type CheckOptions struct {
Factory *cmdutil.Factory Factory *cmdutil.Factory
Scope string Scope string
JSON bool
} }
// NewCmdAuthCheck creates the auth check subcommand. // NewCmdAuthCheck creates the auth check subcommand.
@@ -38,7 +37,6 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
} }
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)") cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmd.MarkFlagRequired("scope") cmd.MarkFlagRequired("scope")
cmdutil.SetRisk(cmd, "read") cmdutil.SetRisk(cmd, "read")

View File

@@ -18,7 +18,6 @@ import (
// ListOptions holds all inputs for auth list. // ListOptions holds all inputs for auth list.
type ListOptions struct { type ListOptions struct {
Factory *cmdutil.Factory Factory *cmdutil.Factory
JSON bool
} }
// NewCmdAuthList creates the auth list subcommand. // NewCmdAuthList creates the auth list subcommand.
@@ -35,7 +34,6 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
return authListRun(opts) return authListRun(opts)
}, },
} }
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read") cmdutil.SetRisk(cmd, "read")
return cmd return cmd
@@ -46,14 +44,6 @@ func authListRun(opts *ListOptions) error {
multi, _ := core.LoadMultiAppConfig() multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 { if multi == nil || len(multi.Apps) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"users": []map[string]interface{}{},
"reason": "not_configured",
})
return nil
}
// auth list is a read-only probe; the "configured but no users" // auth list is a read-only probe; the "configured but no users"
// branch below already returns exit 0 with a stderr hint, so we // branch below already returns exit 0 with a stderr hint, so we
// keep the same contract here. We still want the hint to be // keep the same contract here. We still want the hint to be
@@ -71,14 +61,6 @@ func authListRun(opts *ListOptions) error {
app := multi.CurrentAppConfig(f.Invocation.Profile) app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil || len(app.Users) == 0 { if app == nil || len(app.Users) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"users": []map[string]interface{}{},
"reason": "not_logged_in",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.") fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
return nil return nil
} }

View File

@@ -4,7 +4,6 @@
package auth package auth
import ( import (
"encoding/json"
"strings" "strings"
"testing" "testing"
@@ -35,33 +34,6 @@ func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
} }
} }
func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
users, ok := payload["users"].([]any)
if !ok || len(users) != 0 {
t.Errorf("stdout.users = %v, want empty array", payload["users"])
}
if payload["reason"] != "not_configured" {
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the // TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
// reason this hint exists workspace-aware in the first place: an AI agent // reason this hint exists workspace-aware in the first place: an AI agent
// in OpenClaw / Hermes that probes auth list before binding gets routed to // in OpenClaw / Hermes that probes auth list before binding gets routed to
@@ -85,48 +57,3 @@ func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T)
t.Errorf("agent hint must not mention config init: %s", out) t.Errorf("agent hint must not mention config init: %s", out)
} }
} }
func TestAuthListRun_JSONMode_NoLoggedInUsers_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
users, ok := payload["users"].([]any)
if !ok || len(users) != 0 {
t.Errorf("stdout.users = %v, want empty array", payload["users"])
}
if payload["reason"] != "not_logged_in" {
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthListRun_DefaultMode_NoLoggedInUsers_KeepsTextOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f}); err != nil {
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
}
if stdout.Len() != 0 {
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
}
if !strings.Contains(stderr.String(), "No logged-in users") {
t.Errorf("stderr = %q, want no-users hint", stderr.String())
}
}

View File

@@ -296,11 +296,10 @@ func authLoginRun(opts *LoginOptions) error {
} }
// Step 2: Show user code and verification URL. // Step 2: Show user code and verification URL.
// JSON mode embeds AgentTimeoutHint as a structured field so agents that // Both branches surface AgentTimeoutHint, but on different channels:
// capture stdout into a JSON parser see it without stream-mixing surprises. // JSON mode embeds it as a structured field (so an agent that captures
// Text mode prints the hint to stderr only when running under a non-TTY // stdout into a JSON parser sees it without stream-mixing surprises),
// (i.e. piped / agent harness), since humans reading a terminal don't need // text mode prints to stderr (alongside the URL prompt).
// the agent-oriented instructions.
if opts.JSON { if opts.JSON {
data := map[string]interface{}{ data := map[string]interface{}{
"event": "device_authorization", "event": "device_authorization",
@@ -318,9 +317,7 @@ func authLoginRun(opts *LoginOptions) error {
} else { } else {
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL) fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete) fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
if f.IOStreams != nil && !f.IOStreams.IsTerminal { fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
} }
// Step 3: Poll for token // Step 3: Poll for token
@@ -407,11 +404,10 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err) fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
} }
} }
// Skip the stderr hint in JSON mode (the --no-wait call that issued // Skip the stderr hint in JSON mode the --no-wait call that issued the
// the device_code already surfaced it as a JSON field), and also skip it // device_code already returned the hint as a JSON field, and writing
// when running on an interactive terminal — the agent-oriented // text to stderr would pollute consumers that combine streams via 2>&1.
// instructions only matter for piped / harness environments. if !opts.JSON {
if !opts.JSON && f.IOStreams != nil && !f.IOStreams.IsTerminal {
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint) fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
} }
log(msg.WaitingAuth) log(msg.WaitingAuth)

View File

@@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
// (not backed by from_meta service specs). Descriptions are now centralized in // (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json. // service_descriptions.json.
func getShortcutOnlyDomainNames() []string { func getShortcutOnlyDomainNames() []string {
return []string{"base", "contact", "docs", "markdown", "apps", "note"} return []string{"base", "contact", "docs", "markdown", "apps"}
} }

View File

@@ -9,7 +9,6 @@ import (
"errors" "errors"
"io" "io"
"net/http" "net/http"
"slices"
"sort" "sort"
"strings" "strings"
"testing" "testing"
@@ -215,12 +214,6 @@ func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
} }
} }
func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) {
if !slices.Contains(getShortcutOnlyDomainNames(), "note") {
t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read")
}
}
func TestCollectScopesForDomains(t *testing.T) { func TestCollectScopesForDomains(t *testing.T) {
projects := registry.ListFromMetaProjects() projects := registry.ListFromMetaProjects()
if len(projects) == 0 { if len(projects) == 0 {

View File

@@ -18,7 +18,6 @@ import (
// LogoutOptions holds all inputs for auth logout. // LogoutOptions holds all inputs for auth logout.
type LogoutOptions struct { type LogoutOptions struct {
Factory *cmdutil.Factory Factory *cmdutil.Factory
JSON bool
} }
// NewCmdAuthLogout creates the auth logout subcommand. // NewCmdAuthLogout creates the auth logout subcommand.
@@ -35,7 +34,6 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
return authLogoutRun(opts) return authLogoutRun(opts)
}, },
} }
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "write") cmdutil.SetRisk(cmd, "write")
return cmd return cmd
@@ -46,65 +44,25 @@ func authLogoutRun(opts *LogoutOptions) error {
multi, _ := core.LoadMultiAppConfig() multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 { if multi == nil || len(multi.Apps) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": false,
"reason": "not_configured",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.") fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.")
return nil return nil
} }
app := multi.CurrentAppConfig(f.Invocation.Profile) app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil || len(app.Users) == 0 { if app == nil || len(app.Users) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": false,
"reason": "not_logged_in",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.") fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
return nil return nil
} }
httpClient, httpErr := f.HttpClient()
appSecret, secretErr := core.ResolveSecretInput(app.AppSecret, f.Keychain)
for _, user := range app.Users { for _, user := range app.Users {
if httpErr == nil && secretErr == nil {
if token := larkauth.GetStoredToken(app.AppId, user.UserOpenId); token != nil {
revokeToken := token.RefreshToken
tokenTypeHint := "refresh_token"
if revokeToken == "" {
revokeToken = token.AccessToken
tokenTypeHint = "access_token"
}
if revokeToken != "" {
_ = larkauth.RevokeToken(httpClient, app.AppId, appSecret, app.Brand, revokeToken, tokenTypeHint)
}
}
}
if err := larkauth.RemoveStoredToken(app.AppId, user.UserOpenId); err != nil { if err := larkauth.RemoveStoredToken(app.AppId, user.UserOpenId); err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "Warning: failed to remove token for %s: %v\n", user.UserOpenId, err) fmt.Fprintf(f.IOStreams.ErrOut, "Warning: failed to remove token for %s: %v\n", user.UserOpenId, err)
} }
} }
app.Users = []core.AppUser{} app.Users = []core.AppUser{}
if err := core.SaveMultiAppConfig(multi); err != nil { if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
} }
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": true,
})
return nil
}
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out") output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
return nil return nil
} }

View File

@@ -1,356 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"net/url"
"strings"
"testing"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/zalando/go-keyring"
)
func writeLogoutConfig(t *testing.T, users []core.AppUser) {
t.Helper()
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "test-app",
Apps: []core.AppConfig{
{
AppId: "test-app",
AppSecret: core.PlainSecret("test-secret"),
Brand: core.BrandFeishu,
Users: users,
},
},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
}
func TestAuthLogoutRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != false {
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
}
if payload["reason"] != "not_configured" {
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_JSONMode_NotLoggedIn_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != false {
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
}
if payload["reason"] != "not_logged_in" {
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "test-app",
UserOpenId: "ou_user",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != true {
t.Errorf("stdout.loggedOut = %v, want true", payload["loggedOut"])
}
if _, hasReason := payload["reason"]; hasReason {
t.Errorf("stdout.reason must be absent on success, got %v", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "test-app",
UserOpenId: "ou_user",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if stdout.Len() != 0 {
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
}
if !strings.Contains(stderr.String(), "Logged out") {
t.Errorf("stderr = %q, want success text", stderr.String())
}
}
func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
RefreshToken: "user-refresh-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Body: map[string]interface{}{"code": 0},
BodyFilter: func(body []byte) bool {
values, err := url.ParseQuery(string(body))
if err != nil {
return false
}
return values.Get("client_id") == "cli_test" &&
values.Get("client_secret") == "secret" &&
values.Get("token") == "user-refresh-token" &&
values.Get("token_type_hint") == "refresh_token"
},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if got := stderr.String(); !strings.Contains(got, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", got)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}
func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Body: map[string]interface{}{"code": 0},
BodyFilter: func(body []byte) bool {
values, err := url.ParseQuery(string(body))
if err != nil {
return false
}
return values.Get("client_id") == "cli_test" &&
values.Get("client_secret") == "secret" &&
values.Get("token") == "user-access-token" &&
values.Get("token_type_hint") == "access_token"
},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if got := stderr.String(); !strings.Contains(got, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", got)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}
func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
RefreshToken: "user-refresh-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Status: 500,
Body: map[string]interface{}{"error": "server_error"},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
gotErr := stderr.String()
if strings.Contains(gotErr, "failed to revoke token for ou_user") {
t.Fatalf("stderr = %q, want no revoke warning", gotErr)
}
if !strings.Contains(gotErr, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", gotErr)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}

View File

@@ -19,7 +19,6 @@ type ScopesOptions struct {
Factory *cmdutil.Factory Factory *cmdutil.Factory
Ctx context.Context Ctx context.Context
Format string Format string
JSON bool
} }
// NewCmdAuthScopes creates the auth scopes subcommand. // NewCmdAuthScopes creates the auth scopes subcommand.
@@ -31,9 +30,6 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
Short: "Query scopes enabled for the app", Short: "Query scopes enabled for the app",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context() opts.Ctx = cmd.Context()
if opts.JSON {
opts.Format = "json"
}
if runF != nil { if runF != nil {
return runF(opts) return runF(opts)
} }
@@ -42,7 +38,6 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
} }
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty") cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read") cmdutil.SetRisk(cmd, "read")
return cmd return cmd

View File

@@ -17,7 +17,6 @@ import (
type StatusOptions struct { type StatusOptions struct {
Factory *cmdutil.Factory Factory *cmdutil.Factory
Verify bool Verify bool
JSON bool
} }
// NewCmdAuthStatus creates the auth status subcommand. // NewCmdAuthStatus creates the auth status subcommand.
@@ -36,7 +35,6 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
} }
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)") cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read") cmdutil.SetRisk(cmd, "read")
return cmd return cmd

View File

@@ -33,16 +33,15 @@ const probeTimeout = 3 * time.Second
// //
// 1. A TAT request using the just-saved credentials. credential.FetchTAT // 1. A TAT request using the just-saved credentials. credential.FetchTAT
// returns a typed errs.* error (via the shared classifyTATResponseCode) // returns a typed errs.* error (via the shared classifyTATResponseCode)
// only when the unified Token Endpoint deterministically rejected the // only when the server deterministically rejected the credentials — a
// credentials — an OAuth2 invalid_client / unauthorized_client classified as // non-zero TAT body code, classified as CategoryConfig / SubtypeInvalidClient
// CategoryConfig / SubtypeInvalidClient, or whatever codemeta maps. That // (10003 / 10014) or whatever codemeta maps. That typed error is propagated
// typed error is propagated so the root dispatcher renders the canonical // so the root dispatcher renders the canonical envelope and `config init`
// envelope and `config init` exits non-zero — identical to how every other // exits non-zero — identical to how every other token-resolving command
// token-resolving command reports the same bad credentials. Ambiguous // reports the same bad credentials. Ambiguous failures (transport errors,
// failures (transport errors, transient 5xx/server_error, JSON parse errors, // HTTP non-200, JSON parse errors, timeouts) come back as raw untyped
// timeouts) come back as raw untyped errors and are swallowed (return nil), // errors and are swallowed (return nil), so valid configurations are never
// so valid configurations are never disturbed by upstream noise. // disturbed by upstream noise. errs.IsTyped is the discriminator.
// errs.IsTyped is the discriminator.
// //
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of // 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
// that call (success, server error, timeout, parse failure) is always // that call (success, server error, timeout, parse failure) is always

View File

@@ -31,10 +31,10 @@ type fakeRT struct {
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) { func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
switch { switch {
case strings.HasSuffix(req.URL.Path, "/oauth/v3/token"): case strings.HasSuffix(req.URL.Path, "/auth/v3/tenant_access_token/internal"):
f.tatCalls++ f.tatCalls++
if f.tatHandler == nil { if f.tatHandler == nil {
return jsonResp(200, `{"code":0,"access_token":"t-ok","token_type":"Bearer"}`), nil return jsonResp(200, `{"code":0,"tenant_access_token":"t-ok"}`), nil
} }
return f.tatHandler(req) return f.tatHandler(req)
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"): case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
@@ -84,15 +84,14 @@ func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.B
} }
// assertConfigRejection asserts runProbe propagated a deterministic credential // assertConfigRejection asserts runProbe propagated a deterministic credential
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient). This // rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient) with
// is the same typed error every other token-resolving command returns for the // the expected upstream code. This is the same typed error every other
// same bad credentials, and nothing is written to stderr (the root dispatcher // token-resolving command returns for the same bad credentials, and nothing is
// renders the envelope). The numeric code is not asserted: the unified v3 Token // written to stderr (the root dispatcher renders the envelope).
// Endpoint reports invalid_client via the OAuth2 error string, not a Lark code. func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCode int) {
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer) {
t.Helper() t.Helper()
if err == nil { if err == nil {
t.Fatal("expected *errs.ConfigError, got nil") t.Fatalf("expected *errs.ConfigError (code %d), got nil", wantCode)
} }
var cfgErr *errs.ConfigError var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) { if !errors.As(err, &cfgErr) {
@@ -104,6 +103,9 @@ func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer) {
if cfgErr.Subtype != errs.SubtypeInvalidClient { if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient) t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
} }
if cfgErr.Code != wantCode {
t.Errorf("Code = %d, want %d", cfgErr.Code, wantCode)
}
if errBuf.Len() != 0 { if errBuf.Len() != 0 {
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String()) t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
} }
@@ -121,13 +123,11 @@ func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
} }
} }
// invalid_client (bad / non-existent app_id or wrong secret) → the v3 Token // 10003 (bad / non-existent app_id) → ConfigError/InvalidClient, propagated.
// Endpoint returns HTTP 400 with the OAuth2 error → ConfigError/InvalidClient, func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
// propagated. The probe endpoint must not be called when TAT fails.
func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
rt := &fakeRT{ rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) { tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(400, `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`), nil return jsonResp(200, `{"code":10003,"msg":"invalid param"}`), nil
}, },
} }
f, errBuf := fakeFactory(t, rt) f, errBuf := fakeFactory(t, rt)
@@ -137,27 +137,28 @@ func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
if rt.probeCalls != 0 { if rt.probeCalls != 0 {
t.Error("probe endpoint must not be called when TAT fails") t.Error("probe endpoint must not be called when TAT fails")
} }
assertConfigRejection(t, err, errBuf) assertConfigRejection(t, err, errBuf, 10003)
} }
// unauthorized_client is treated as the same credential rejection, propagated. // 10014 (real app_id + wrong secret) → ConfigError/InvalidClient via codemeta —
func TestRunProbe_TATUnauthorizedClient_ReturnsConfigError(t *testing.T) { // the most common real-world rejection, propagated.
func TestRunProbe_TATCode10014_ReturnsConfigError(t *testing.T) {
rt := &fakeRT{ rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) { tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(401, `{"error":"unauthorized_client","error_description":"client not authorized"}`), nil return jsonResp(200, `{"code":10014,"msg":"app secret invalid"}`), nil
}, },
} }
f, errBuf := fakeFactory(t, rt) f, errBuf := fakeFactory(t, rt)
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf) assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf, 10014)
} }
// Any other deterministic client-side OAuth error (e.g. invalid_scope) falls // Any non-zero body code is a deterministic rejection and propagates (typed).
// back to *errs.APIError via BuildAPIError — still typed, so the probe surfaces // An unrecognized code falls back to *errs.APIError via BuildAPIError — still
// it rather than swallowing — but is not a credential (ConfigError) rejection. // typed, so the probe still surfaces it rather than swallowing.
func TestRunProbe_TATOtherClientError_Propagates(t *testing.T) { func TestRunProbe_TATUnknownBodyCode_Propagates(t *testing.T) {
rt := &fakeRT{ rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) { tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(400, `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`), nil return jsonResp(200, `{"code":99999,"msg":"future-unknown"}`), nil
}, },
} }
f, errBuf := fakeFactory(t, rt) f, errBuf := fakeFactory(t, rt)

View File

@@ -12,7 +12,6 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event"
@@ -39,8 +38,7 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
logger, err := bus.SetupBusLogger(eventsDir) logger, err := bus.SetupBusLogger(eventsDir)
if err != nil { if err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, return err
"set up bus logger: %s", err).WithCause(err)
} }
tr := transport.New() tr := transport.New()
@@ -60,14 +58,7 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
} }
}() }()
if err := b.Run(ctx); err != nil { return b.Run(ctx)
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeUnknown,
"event bus daemon exited: %s", err).WithCause(err)
}
return nil
}, },
} }

View File

@@ -1,45 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// The hidden `event _bus` daemon command must exit with a typed file_io error
// when its log directory cannot be created (the error is only visible in the
// forked process's captured stderr / bus.log).
func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
// Block the events/ root with a regular file so MkdirAll fails.
if err := os.WriteFile(filepath.Join(dir, "events"), []byte("x"), 0600); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
})
cmd := NewCmdBus(f)
cmd.SetArgs([]string{})
err := cmd.Execute()
if err == nil {
t.Fatal("expected logger setup error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeFileIO {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryInternal, errs.SubtypeFileIO)
}
}

View File

@@ -16,7 +16,6 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/appmeta" "github.com/larksuite/cli/internal/appmeta"
"github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
@@ -102,10 +101,11 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
if o.jqExpr != "" { if o.jqExpr != "" {
if err := output.ValidateJqExpression(o.jqExpr); err != nil { if err := output.ValidateJqExpression(o.jqExpr); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err). return output.ErrWithHint(
WithParam("--jq"). output.ExitValidation, "validation",
WithCause(err). err.Error(),
WithHint("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey) fmt.Sprintf("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey),
)
} }
} }
@@ -261,12 +261,12 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
if len(missing) == 0 { if len(missing) == 0 {
return nil return nil
} }
return errs.NewPermissionError(errs.SubtypeMissingScope, return output.ErrWithHint(
"missing required scopes for EventKey %s (as %s): %s", output.ExitAuth, "auth",
pf.eventKey, pf.identity, strings.Join(missing, ", ")). fmt.Sprintf("missing required scopes for EventKey %s (as %s): %s",
WithIdentity(string(pf.identity)). pf.eventKey, pf.identity, strings.Join(missing, ", ")),
WithMissingScopes(missing...). scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand),
WithHint("%s", scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand)) )
} }
// scopeRemediationHint returns an identity-appropriate fix for missing scopes. // scopeRemediationHint returns an identity-appropriate fix for missing scopes.
@@ -301,27 +301,23 @@ func preflightEventTypes(pf *preflightCtx) error {
if len(missing) == 0 { if len(missing) == 0 {
return nil return nil
} }
return errs.NewValidationError(errs.SubtypeFailedPrecondition, return output.ErrWithHint(
"EventKey %s requires event types not subscribed in console: %s", output.ExitValidation, "validation",
pf.keyDef.Key, strings.Join(missing, ", ")). fmt.Sprintf("EventKey %s requires event types not subscribed in console: %s",
WithHint("subscribe these events and publish a new app version at: %s", pf.keyDef.Key, strings.Join(missing, ", ")),
consoleEventSubscriptionURL(pf.brand, pf.appID)) fmt.Sprintf("subscribe these events and publish a new app version at: %s",
consoleEventSubscriptionURL(pf.brand, pf.appID)),
)
} }
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name). // sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
func sanitizeOutputDir(dir string) (string, error) { func sanitizeOutputDir(dir string) (string, error) {
if strings.HasPrefix(dir, "~") { if strings.HasPrefix(dir, "~") {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
"%s; use a relative path like ./output instead", errOutputDirTilde).
WithParam("--output-dir").
WithCause(errOutputDirTilde)
} }
safe, err := validate.SafeOutputPath(dir) safe, err := validate.SafeOutputPath(dir)
if err != nil { if err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
"%s %q: %s", errOutputDirUnsafe, dir, err).
WithParam("--output-dir").
WithCause(errOutputDirUnsafe)
} }
return safe, nil return safe, nil
} }
@@ -333,21 +329,18 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
} }
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID)) result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
if err != nil { if err != nil {
if _, ok := errs.ProblemOf(err); ok { return "", output.ErrAuth("resolve tenant access token: %s", err)
return "", err
}
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"resolve tenant access token: %s", err).WithCause(err)
} }
if result == nil || result.Token == "" { if result == nil || result.Token == "" {
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing, return "", output.ErrWithHint(
"no tenant access token available for app %s", appID). output.ExitAuth, "auth",
WithHint("Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.") fmt.Sprintf("no tenant access token available for app %s", appID),
"Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.",
)
} }
return result.Token, nil return result.Token, nil
} }
// Sentinels for errors.Is checks; call sites wrap them as typed ValidationError causes.
var ( var (
errInvalidParamFormat = errors.New("invalid --param format") errInvalidParamFormat = errors.New("invalid --param format")
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion") errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
@@ -359,10 +352,7 @@ func parseParams(raw []string) (map[string]string, error) {
for _, kv := range raw { for _, kv := range raw {
k, v, ok := strings.Cut(kv, "=") k, v, ok := strings.Cut(kv, "=")
if !ok || k == "" { if !ok || k == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
"%s %q: expected key=value", errInvalidParamFormat, kv).
WithParam("--param").
WithCause(errInvalidParamFormat)
} }
m[k] = v m[k] = v
} }

View File

@@ -4,14 +4,9 @@
package event package event
import ( import (
"context"
"errors" "errors"
"strings" "strings"
"testing" "testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/credential"
) )
func TestParseParams(t *testing.T) { func TestParseParams(t *testing.T) {
@@ -78,7 +73,6 @@ func TestParseParams(t *testing.T) {
if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) { if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) {
t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho) t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho)
} }
assertInvalidArgumentParam(t, err, "--param")
return return
} }
if err != nil { if err != nil {
@@ -96,77 +90,6 @@ func TestParseParams(t *testing.T) {
} }
} }
// emptyTokenResolver resolves to a result that carries no token.
type emptyTokenResolver struct{}
func (emptyTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return &credential.TokenResult{}, nil
}
// failingTokenResolver fails outright with an untyped error.
type failingTokenResolver struct{}
func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return nil, errors.New("backend unavailable")
}
func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
}
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {
_, err := resolveTenantToken(context.Background(), factoryWithResolver(emptyTokenResolver{}), "cli_x")
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
}
var malformed *credential.MalformedTokenResultError
if !errors.As(err, &malformed) {
t.Error("empty-token failure should preserve the credential-layer cause")
}
}
func TestResolveTenantToken_ResolverFailure(t *testing.T) {
_, err := resolveTenantToken(context.Background(), factoryWithResolver(failingTokenResolver{}), "cli_x")
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
}
if errors.Unwrap(err) == nil {
t.Error("resolver failure should preserve its cause")
}
}
// assertInvalidArgumentParam verifies err is a typed validation error with
// subtype invalid_argument naming the given flag in its param field.
func assertInvalidArgumentParam(t *testing.T, err error, param string) {
t.Helper()
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != param {
t.Errorf("param = %q, want %q", ve.Param, param)
}
}
func TestSanitizeOutputDir(t *testing.T) { func TestSanitizeOutputDir(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
@@ -207,7 +130,6 @@ func TestSanitizeOutputDir(t *testing.T) {
if !errors.Is(err, tc.wantSentry) { if !errors.Is(err, tc.wantSentry) {
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error()) t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
} }
assertInvalidArgumentParam(t, err, "--output-dir")
return return
} }
if err != nil { if err != nil {

View File

@@ -143,79 +143,6 @@ func TestWriteStatusText_CoversAllStates(t *testing.T) {
} }
} }
func TestWriteStatusText_ShowsSubColumn(t *testing.T) {
var buf bytes.Buffer
writeStatusText(&buf, []appStatus{
{
AppID: "cli_RUNNINGXXXXXXXXX",
State: stateRunning,
PID: 1234,
UptimeSec: 60,
Active: 2,
Consumers: []protocol.ConsumerInfo{
{PID: 1001, EventKey: "mail.x", SubscriptionID: "mail.x:alice", Received: 5, Dropped: 0},
{PID: 1002, EventKey: "mail.x", SubscriptionID: "mail.x:bob", Received: 3, Dropped: 0},
},
},
})
out := buf.String()
if !strings.Contains(out, "SUB") {
t.Errorf("missing SUB column header: %s", out)
}
if !strings.Contains(out, "alice") {
t.Errorf("missing alice suffix in SUB column: %s", out)
}
if !strings.Contains(out, "bob") {
t.Errorf("missing bob suffix in SUB column: %s", out)
}
}
func TestWriteStatusText_LegacySubscriptionID_RendersDash(t *testing.T) {
var buf bytes.Buffer
writeStatusText(&buf, []appStatus{
{
AppID: "cli_RUNNINGXXXXXXXXX",
State: stateRunning,
PID: 1234,
UptimeSec: 60,
Active: 1,
Consumers: []protocol.ConsumerInfo{
{PID: 1001, EventKey: "im.x", SubscriptionID: "", Received: 5},
},
},
})
out := buf.String()
if !strings.Contains(out, "SUB") {
t.Errorf("missing SUB header: %s", out)
}
if !strings.Contains(out, "-") {
t.Errorf("missing dash placeholder for empty SubscriptionID: %s", out)
}
}
func TestWriteStatusText_EventKeyEqualSubscriptionID_RendersDash(t *testing.T) {
var buf bytes.Buffer
writeStatusText(&buf, []appStatus{
{
AppID: "cli_RUNNINGXXXXXXXXX",
State: stateRunning,
PID: 1234,
UptimeSec: 60,
Active: 1,
Consumers: []protocol.ConsumerInfo{
{PID: 1001, EventKey: "im.x", SubscriptionID: "im.x", Received: 5},
},
},
})
out := buf.String()
if !strings.Contains(out, "SUB") {
t.Errorf("missing SUB header: %s", out)
}
if !strings.Contains(out, "-") {
t.Errorf("missing dash placeholder when SubscriptionID==EventKey: %s", out)
}
}
func TestWriteStatusJSON_OrphanHint(t *testing.T) { func TestWriteStatusJSON_OrphanHint(t *testing.T) {
var buf bytes.Buffer var buf bytes.Buffer
if err := writeStatusJSON(&buf, []appStatus{ if err := writeStatusJSON(&buf, []appStatus{

View File

@@ -8,10 +8,10 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/appmeta" "github.com/larksuite/cli/internal/appmeta"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event" eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
) )
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx { func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
@@ -89,17 +89,19 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") { if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") {
t.Errorf("error should name the missing event type, got: %v", err) t.Errorf("error should name the missing event type, got: %v", err)
} }
p, ok := errs.ProblemOf(err) var exit *output.ExitError
if !ok { if !errors.As(err, &exit) {
t.Fatalf("expected typed errs error, got %T: %v", err, err) t.Fatalf("expected output.ExitError, got %T: %v", err, err)
} }
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition { if exit.Code != output.ExitValidation {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, t.Errorf("ExitCode = %d, want ExitValidation (%d)", exit.Code, output.ExitValidation)
errs.CategoryValidation, errs.SubtypeFailedPrecondition) }
if exit.Detail == nil {
t.Fatal("expected Detail with hint")
} }
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event" wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
if !strings.Contains(p.Hint, wantURL) { if !strings.Contains(exit.Detail.Hint, wantURL) {
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, p.Hint) t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, exit.Detail.Hint)
} }
} }
@@ -143,19 +145,17 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
if !strings.Contains(err.Error(), "im:message.group_at_msg") { if !strings.Contains(err.Error(), "im:message.group_at_msg") {
t.Errorf("error should name missing scope, got: %v", err) t.Errorf("error should name missing scope, got: %v", err)
} }
var permErr *errs.PermissionError var exit *output.ExitError
if !errors.As(err, &permErr) { if !errors.As(err, &exit) {
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err) t.Fatalf("expected output.ExitError, got %T: %v", err, err)
} }
if permErr.Category != errs.CategoryAuthorization || permErr.Subtype != errs.SubtypeMissingScope { if exit.Code != output.ExitAuth {
t.Errorf("problem = %s/%s, want %s/%s", permErr.Category, permErr.Subtype, t.Errorf("ExitCode = %d, want ExitAuth (%d)", exit.Code, output.ExitAuth)
errs.CategoryAuthorization, errs.SubtypeMissingScope)
} }
wantMissing := []string{"im:message.group_at_msg"} if exit.Detail == nil {
if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != wantMissing[0] { t.Fatal("expected Detail with hint, got nil Detail")
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, wantMissing)
} }
hint := permErr.Hint hint := exit.Detail.Hint
wantSubstrings := []string{ wantSubstrings := []string{
"https://open.feishu.cn/app/cli_x/auth?q=", "https://open.feishu.cn/app/cli_x/auth?q=",
"im:message.group_at_msg", "im:message.group_at_msg",

View File

@@ -6,8 +6,8 @@ package event
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
) )
@@ -26,11 +26,7 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
As: r.accessIdentity, As: r.accessIdentity,
}) })
if err != nil { if err != nil {
if _, ok := errs.ProblemOf(err); ok { return nil, err
return nil, err
}
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport,
"api %s %s: %s", method, path, err).WithCause(err)
} }
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing. // Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
ct := resp.Header.Get("Content-Type") ct := resp.Header.Get("Content-Type")
@@ -40,20 +36,11 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
if len(body) > maxBodyEcho { if len(body) > maxBodyEcho {
body = body[:maxBodyEcho] + "…(truncated)" body = body[:maxBodyEcho] + "…(truncated)"
} }
if resp.StatusCode >= 500 { return nil, fmt.Errorf("api %s %s returned %d: %s", method, path, resp.StatusCode, body)
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer,
"api %s %s returned %d: %s", method, path, resp.StatusCode, body).WithRetryable()
}
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"api %s %s returned %d: %s", method, path, resp.StatusCode, body)
} }
result, err := client.ParseJSONResponse(resp) result, err := client.ParseJSONResponse(resp)
if err != nil { if err != nil {
if _, ok := errs.ProblemOf(err); ok { return nil, err
return nil, err
}
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"api %s %s: %s", method, path, err).WithCause(err)
} }
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil { if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
return json.RawMessage(resp.RawBody), apiErr return json.RawMessage(resp.RawBody), apiErr

View File

@@ -1,147 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
// staticTokenResolver always returns a fixed token without any HTTP calls.
type staticTokenResolver struct{}
func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return &credential.TokenResult{Token: "test-token"}, nil
}
// stubRoundTripper intercepts every outgoing request with a canned response.
type stubRoundTripper struct {
respond func(*http.Request) (*http.Response, error)
}
func (s stubRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return s.respond(r) }
func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
sdk := lark.NewClient("test-app", "test-secret",
lark.WithEnableTokenCache(false),
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHttpClient(&http.Client{Transport: rt}),
)
return &consumeRuntime{
client: &client.APIClient{
SDK: sdk,
ErrOut: io.Discard,
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
},
accessIdentity: core.AsBot,
}
}
func stubResponse(status int, contentType, body string) func(*http.Request) (*http.Response, error) {
return func(r *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: status,
Header: http.Header{"Content-Type": []string{contentType}},
Body: io.NopCloser(strings.NewReader(body)),
Request: r,
}, nil
}
}
func requireCallAPIProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype) {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != category || p.Subtype != subtype {
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, category, subtype)
}
}
func TestConsumeRuntimeCallAPI_NonJSONHTTPError(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusNotFound, "text/plain", "gone")})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
if !strings.Contains(err.Error(), "returned 404") {
t.Errorf("error should echo the HTTP status, got: %v", err)
}
}
func TestConsumeRuntimeCallAPI_NonJSONHTTPErrorTruncatesLongBody(t *testing.T) {
long := strings.Repeat("x", 300)
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusBadGateway, "text/html", long)})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
requireCallAPIProblem(t, err, errs.CategoryNetwork, errs.SubtypeNetworkServer)
p, _ := errs.ProblemOf(err)
if !p.Retryable {
t.Fatal("5xx non-JSON response should be marked retryable")
}
if !strings.Contains(err.Error(), "…(truncated)") {
t.Errorf("long body should be truncated in the message, got: %v", err)
}
}
func TestConsumeRuntimeCallAPI_UnparsableJSONBody(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json", "{not json")})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
}
func TestConsumeRuntimeCallAPI_TransportFailure(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: func(*http.Request) (*http.Response, error) {
return nil, errors.New("connection refused")
}})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryNetwork {
t.Fatalf("category = %s, want %s", p.Category, errs.CategoryNetwork)
}
}
func TestConsumeRuntimeCallAPI_EnvelopeErrorIsTyped(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
`{"code":99991663,"msg":"app not found"}`)})
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if _, ok := errs.ProblemOf(err); !ok {
t.Fatalf("envelope error should be typed via BuildAPIError, got %T: %v", err, err)
}
}
func TestConsumeRuntimeCallAPI_Success(t *testing.T) {
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
`{"code":0,"data":{"ok":true}}`)})
raw, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(string(raw), `"code":0`) {
t.Errorf("raw body should pass through, got: %s", raw)
}
}

View File

@@ -11,7 +11,6 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
eventlib "github.com/larksuite/cli/internal/event" eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas" "github.com/larksuite/cli/internal/event/schemas"
@@ -40,14 +39,12 @@ func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string,
if len(def.Schema.FieldOverrides) > 0 { if len(def.Schema.FieldOverrides) > 0 {
var parsed map[string]interface{} var parsed map[string]interface{}
if err := json.Unmarshal(base, &parsed); err != nil { if err := json.Unmarshal(base, &parsed); err != nil {
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown, return nil, nil, err
"parse base schema for field overrides: %s", err).WithCause(err)
} }
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides) orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
out, err := json.Marshal(parsed) out, err := json.Marshal(parsed)
if err != nil { if err != nil {
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown, return nil, nil, err
"serialize schema with field overrides: %s", err).WithCause(err)
} }
return out, orphans, nil return out, orphans, nil
} }
@@ -76,7 +73,7 @@ func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
copy(buf, s.Raw) copy(buf, s.Raw)
return buf, nil return buf, nil
} }
return nil, errs.NewInternalError(errs.SubtypeUnknown, "schemaSpec has neither Type nor Raw") return nil, fmt.Errorf("schemaSpec has neither Type nor Raw")
} }
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command { func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
@@ -134,16 +131,12 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
if len(def.Params) > 0 { if len(def.Params) > 0 {
fmt.Fprintf(out, "\nParameters:\n") fmt.Fprintf(out, "\nParameters:\n")
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tSUB-KEY\tDEFAULT\tDESCRIPTION\n") fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tDEFAULT\tDESCRIPTION\n")
for _, p := range def.Params { for _, p := range def.Params {
required := "no" required := "no"
if p.Required { if p.Required {
required = "yes" required = "yes"
} }
subKey := "no"
if p.SubscriptionKey {
subKey = "yes"
}
defaultVal := p.Default defaultVal := p.Default
if defaultVal == "" { if defaultVal == "" {
defaultVal = "-" defaultVal = "-"
@@ -152,7 +145,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
if desc == "" { if desc == "" {
desc = "-" desc = "-"
} }
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, subKey, defaultVal, desc) fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, defaultVal, desc)
} }
w.Flush() w.Flush()
@@ -172,7 +165,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
resolved, _, err := resolveSchemaJSON(def) resolved, _, err := resolveSchemaJSON(def)
if err != nil { if err != nil {
return err return output.Errorf(output.ExitInternal, "internal", "resolve schema: %v", err)
} }
if resolved != nil { if resolved != nil {
fmt.Fprintf(out, "\nOutput Schema:\n") fmt.Fprintf(out, "\nOutput Schema:\n")

View File

@@ -10,7 +10,6 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event" eventlib "github.com/larksuite/cli/internal/event"
@@ -96,79 +95,6 @@ func TestRunSchema_JSONOutput(t *testing.T) {
} }
} }
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
const syntheticKey = "test.evt_sub"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
eventlib.RegisterKey(eventlib.KeyDefinition{
Key: syntheticKey,
EventType: syntheticKey,
Params: []eventlib.ParamDef{
{Name: "mailbox", SubscriptionKey: true, Description: "subscription id source"},
{Name: "folders", Description: "filter only"},
},
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
})
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, syntheticKey, false); err != nil {
t.Fatalf("runSchema: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "SUB-KEY") {
t.Errorf("missing SUB-KEY column header in:\n%s", out)
}
// Find the mailbox row and verify "yes" is present
var mailboxRow string
for _, ln := range strings.Split(out, "\n") {
if strings.Contains(ln, "mailbox") && !strings.Contains(ln, "NAME") {
mailboxRow = ln
break
}
}
if !strings.Contains(mailboxRow, "yes") {
t.Errorf("mailbox row missing yes SUB-KEY marker: %q", mailboxRow)
}
// Find the folders row and verify "no" is present
var foldersRow string
for _, ln := range strings.Split(out, "\n") {
if strings.Contains(ln, "folders") && !strings.Contains(ln, "NAME") {
foldersRow = ln
break
}
}
if !strings.Contains(foldersRow, "no") {
t.Errorf("folders row missing no SUB-KEY marker: %q", foldersRow)
}
}
func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
const syntheticKey = "test.evt_json"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
eventlib.RegisterKey(eventlib.KeyDefinition{
Key: syntheticKey,
EventType: syntheticKey,
Params: []eventlib.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
})
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, syntheticKey, true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
if !strings.Contains(stdout.String(), `"subscription_key"`) {
t.Errorf("JSON output missing subscription_key field: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `true`) {
t.Errorf("JSON output missing subscription_key: true value: %s", stdout.String())
}
}
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) { func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
const syntheticKey = "t.custom.overlay" const syntheticKey = "t.custom.overlay"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) }) t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
@@ -203,38 +129,3 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
t.Errorf("overlay format = %v, want open_id", got) t.Errorf("overlay format = %v, want open_id", got)
} }
} }
func TestRenderSpec_EmptySpecIsTypedInternalError(t *testing.T) {
_, err := renderSpec(&eventlib.SchemaSpec{})
if err == nil {
t.Fatal("expected error for spec with neither Type nor Raw")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
}
}
func TestResolveSchemaJSON_InvalidBaseWithOverridesIsTypedInternalError(t *testing.T) {
def := &eventlib.KeyDefinition{
Key: "synthetic.invalid.base",
Schema: eventlib.SchemaDef{
Custom: &eventlib.SchemaSpec{Raw: json.RawMessage("{not json")},
FieldOverrides: map[string]schemas.FieldMeta{"x": {}},
},
}
_, _, err := resolveSchemaJSON(def)
if err == nil {
t.Fatal("expected error for unparsable base schema")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
}
}

View File

@@ -7,7 +7,6 @@ import (
"fmt" "fmt"
"io" "io"
"sort" "sort"
"strings"
"sync" "sync"
"time" "time"
@@ -243,17 +242,12 @@ func writeStatusText(out io.Writer, statuses []appStatus) {
s.PID, (time.Duration(s.UptimeSec) * time.Second).String()) s.PID, (time.Duration(s.UptimeSec) * time.Second).String())
fmt.Fprintf(out, " Active consumers: %d\n", s.Active) fmt.Fprintf(out, " Active consumers: %d\n", s.Active)
if len(s.Consumers) > 0 { if len(s.Consumers) > 0 {
headers := []string{"CONSUMER", "EVENT KEY", "SUB", "RECEIVED", "DROPPED"} headers := []string{"CONSUMER", "EVENT KEY", "RECEIVED", "DROPPED"}
rows := make([][]string, 0, len(s.Consumers)) rows := make([][]string, 0, len(s.Consumers))
for _, c := range s.Consumers { for _, c := range s.Consumers {
subDisplay := "-"
if c.SubscriptionID != "" && c.SubscriptionID != c.EventKey {
subDisplay = strings.TrimPrefix(c.SubscriptionID, c.EventKey+":")
}
rows = append(rows, []string{ rows = append(rows, []string{
fmt.Sprintf("pid=%d", c.PID), fmt.Sprintf("pid=%d", c.PID),
c.EventKey, c.EventKey,
subDisplay,
fmt.Sprintf("%d", c.Received), fmt.Sprintf("%d", c.Received),
fmt.Sprintf("%d", c.Dropped), fmt.Sprintf("%d", c.Dropped),
}) })

View File

@@ -8,8 +8,8 @@ import (
"sort" "sort"
"strings" "strings"
"github.com/larksuite/cli/errs"
eventlib "github.com/larksuite/cli/internal/event" eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/suggest" "github.com/larksuite/cli/internal/suggest"
) )
@@ -64,6 +64,9 @@ func unknownEventKeyErr(key string) error {
if guesses := suggestEventKeys(key); len(guesses) > 0 { if guesses := suggestEventKeys(key); len(guesses) > 0 {
msg += " — did you mean " + formatSuggestions(guesses) + "?" msg += " — did you mean " + formatSuggestions(guesses) + "?"
} }
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg). return output.ErrWithHint(
WithHint("Run 'lark-cli event list' to see available keys.") output.ExitValidation, "validation",
msg,
"Run 'lark-cli event list' to see available keys.",
)
} }

View File

@@ -80,7 +80,6 @@ const (
SubtypeSDKError Subtype = "sdk_error" // lark SDK Do() returned an unexpected error SubtypeSDKError Subtype = "sdk_error" // lark SDK Do() returned an unexpected error
SubtypeInvalidResponse Subtype = "invalid_response" // SDK response body not parsable as JSON SubtypeInvalidResponse Subtype = "invalid_response" // SDK response body not parsable as JSON
SubtypeFileIO Subtype = "file_io" // local file I/O failure (mkdir / write / read) SubtypeFileIO Subtype = "file_io" // local file I/O failure (mkdir / write / read)
SubtypeExternalTool Subtype = "external_tool" // an external tool the CLI shells out to (git, npx) failed at runtime; the tool output is in the message
SubtypeStorage Subtype = "storage" // local persistence failure (e.g. config file save) SubtypeStorage Subtype = "storage" // local persistence failure (e.g. config file save)
// Generic untyped error lifted to InternalError uses SubtypeUnknown. // Generic untyped error lifted to InternalError uses SubtypeUnknown.
) )

View File

@@ -5,19 +5,18 @@ package minutes
import ( import (
"context" "context"
"fmt"
"time" "time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event"
) )
const cleanupTimeout = 5 * time.Second const cleanupTimeout = 5 * time.Second
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) { func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) { return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
if rt == nil { if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown, return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
"runtime API client is required for pre-consume subscription")
} }
body := map[string]string{"event_type": eventType} body := map[string]string{"event_type": eventType}
@@ -25,13 +24,10 @@ func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) fu
return nil, err return nil, err
} }
return func() error { return func() {
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
defer cancel() defer cancel()
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil { _, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
return err
}
return nil
}, nil }, nil
} }
} }

View File

@@ -1,35 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
)
// isLarkCode must match the API code on typed errs.* errors — the consume
// runtime classifies OAPI failures via errclass.BuildAPIError, so the
// not-found retry in fillVCNoteGeneratedDetails depends on this reading
// Problem.Code rather than the legacy envelope shape.
func TestIsLarkCode_MatchesTypedAPIErrorCode(t *testing.T) {
typedNotFound := errs.NewAPIError(errs.SubtypeNotFound, "note not ready").
WithCode(vcNoteDetailNotFoundCode)
if !isLarkCode(typedNotFound, vcNoteDetailNotFoundCode) {
t.Fatal("typed API error carrying the not-found code must match (retry path)")
}
if isLarkCode(typedNotFound, 99999) {
t.Error("a different expected code must not match")
}
otherTyped := errs.NewAPIError(errs.SubtypeServerError, "boom").WithCode(500)
if isLarkCode(otherTyped, vcNoteDetailNotFoundCode) {
t.Error("typed error with another code must not match")
}
if isLarkCode(errors.New("plain failure"), vcNoteDetailNotFoundCode) {
t.Error("untyped error must not match")
}
}

View File

@@ -6,11 +6,12 @@ package vc
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"time" "time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/validate"
) )
@@ -147,8 +148,9 @@ func fillVCNoteGeneratedDetails(ctx context.Context, rt event.APIClient, out *VC
} }
func isLarkCode(err error, code int) bool { func isLarkCode(err error, code int) bool {
if p, ok := errs.ProblemOf(err); ok { var exitErr *output.ExitError
return p.Code == code if errors.As(err, &exitErr) && exitErr.Detail != nil {
return exitErr.Detail.Code == code
} }
return false return false
} }

View File

@@ -5,19 +5,18 @@ package vc
import ( import (
"context" "context"
"fmt"
"time" "time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event"
) )
const cleanupTimeout = 5 * time.Second const cleanupTimeout = 5 * time.Second
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) { func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) { return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
if rt == nil { if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown, return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
"runtime API client is required for pre-consume subscription")
} }
body := map[string]string{"event_type": eventType} body := map[string]string{"event_type": eventType}
@@ -25,13 +24,10 @@ func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) fu
return nil, err return nil, err
} }
return func() error { return func() {
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
defer cancel() defer cancel()
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil { _, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
return err
}
return nil
}, nil }, nil
} }
} }

View File

@@ -1,84 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/larksuite/cli/internal/event"
)
// VCRecordingEndedOutput is the flattened shape for vc.recording.recording_ended_v1.
type VCRecordingEndedOutput struct {
Type string `json:"type" desc:"Event type; always vc.recording.recording_ended_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
EventTime string `json:"event_time,omitempty" desc:"Time when the recording ended and uploaded successfully, in RFC3339 / ISO 8601 with the current system timezone"`
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
}
type recordingEndedEnvelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event recordingEndedEvent `json:"event"`
}
type recordingEndedEvent struct {
UniqueKey string `json:"unique_key"`
Source string `json:"source"`
}
func processVCRecordingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
envelope, ok := parseRecordingEndedEnvelope(raw)
if !ok {
return raw.Payload, nil
}
if !isRecordingEndedBeanEvent(envelope) {
return nil, nil
}
out := &VCRecordingEndedOutput{
Type: recordingEndedEventType(envelope, raw),
EventID: envelope.Header.EventID,
EventTime: recordingEndedEventTime(envelope.Header.CreateTime),
UniqueKey: envelope.Event.UniqueKey,
Source: envelope.Event.Source,
}
return json.Marshal(out)
}
func parseRecordingEndedEnvelope(raw *event.RawEvent) (*recordingEndedEnvelope, bool) {
var envelope recordingEndedEnvelope
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return nil, false
}
return &envelope, true
}
func isRecordingEndedBeanEvent(envelope *recordingEndedEnvelope) bool {
return envelope != nil && envelope.Event.Source == "recording_bean"
}
func recordingEndedEventType(envelope *recordingEndedEnvelope, raw *event.RawEvent) string {
if envelope != nil && envelope.Header.EventType != "" {
return envelope.Header.EventType
}
return raw.EventType
}
func recordingEndedEventTime(raw string) string {
if raw == "" {
return ""
}
millis, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return ""
}
return time.UnixMilli(millis).Local().Format(time.RFC3339)
}

View File

@@ -1,84 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/larksuite/cli/internal/event"
)
// VCRecordingStartedOutput is the flattened shape for vc.recording.recording_started_v1.
type VCRecordingStartedOutput struct {
Type string `json:"type" desc:"Event type; always vc.recording.recording_started_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
EventTime string `json:"event_time,omitempty" desc:"Recording start time in RFC3339 / ISO 8601 with the current system timezone"`
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
}
type recordingStartedEnvelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event recordingStartedEvent `json:"event"`
}
type recordingStartedEvent struct {
UniqueKey string `json:"unique_key"`
Source string `json:"source"`
}
func processVCRecordingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
envelope, ok := parseRecordingStartedEnvelope(raw)
if !ok {
return raw.Payload, nil
}
if !isRecordingStartedBeanEvent(envelope) {
return nil, nil
}
out := &VCRecordingStartedOutput{
Type: recordingStartedEventType(envelope, raw),
EventID: envelope.Header.EventID,
EventTime: recordingStartedEventTime(envelope.Header.CreateTime),
UniqueKey: envelope.Event.UniqueKey,
Source: envelope.Event.Source,
}
return json.Marshal(out)
}
func parseRecordingStartedEnvelope(raw *event.RawEvent) (*recordingStartedEnvelope, bool) {
var envelope recordingStartedEnvelope
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return nil, false
}
return &envelope, true
}
func isRecordingStartedBeanEvent(envelope *recordingStartedEnvelope) bool {
return envelope != nil && envelope.Event.Source == "recording_bean"
}
func recordingStartedEventType(envelope *recordingStartedEnvelope, raw *event.RawEvent) string {
if envelope != nil && envelope.Header.EventType != "" {
return envelope.Header.EventType
}
return raw.EventType
}
func recordingStartedEventTime(raw string) string {
if raw == "" {
return ""
}
millis, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return ""
}
return time.UnixMilli(millis).Local().Format(time.RFC3339)
}

View File

@@ -1,468 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestVCKeys_RecordingEventsRegistered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
eventType string
}{
{eventTypeRecordingStarted},
{eventTypeRecordingTranscriptGenerated},
{eventTypeRecordingEnded},
} {
t.Run(tc.eventType, func(t *testing.T) {
def, ok := event.Lookup(tc.eventType)
if !ok {
t.Fatalf("%s should be registered via Keys()", tc.eventType)
}
if def.Schema.Custom == nil {
t.Error("Processed key must set Schema.Custom")
}
if def.Schema.Native != nil {
t.Error("Processed key must not set Schema.Native")
}
if def.Process == nil {
t.Error("Process must not be nil for processed key")
}
if def.PreConsume == nil {
t.Error("PreConsume must not be nil for processed key")
}
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:recording:read" {
t.Errorf("Scopes = %v", def.Scopes)
}
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v", def.AuthTypes)
}
if len(def.RequiredConsoleEvents) != 1 || def.RequiredConsoleEvents[0] != tc.eventType {
t.Errorf("RequiredConsoleEvents = %v", def.RequiredConsoleEvents)
}
if !strings.Contains(def.Description, "recording_bean") {
t.Errorf("Description should document recording_bean source, got %q", def.Description)
}
if !strings.Contains(def.Description, "connected to Feishu software") {
t.Errorf("Description should document Feishu software connection requirement, got %q", def.Description)
}
if strings.Contains(def.Description, "future") || strings.Contains(def.Description, "software_recording") {
t.Errorf("Description should not mention future sources, got %q", def.Description)
}
if tc.eventType == eventTypeRecordingEnded && (strings.Contains(def.Description, "object_type") || strings.Contains(def.Description, "object_id")) {
t.Errorf("ended Description should not document object metadata, got %q", def.Description)
}
wantSchemaType := reflect.TypeOf(VCRecordingStartedOutput{})
switch tc.eventType {
case eventTypeRecordingTranscriptGenerated:
wantSchemaType = reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{})
case eventTypeRecordingEnded:
wantSchemaType = reflect.TypeOf(VCRecordingEndedOutput{})
}
if def.Schema.Custom.Type != wantSchemaType {
t.Errorf("Custom schema Type = %v, want %v", def.Schema.Custom.Type, wantSchemaType)
}
})
}
}
func TestProcessVCRecordingStarted(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
out := runRecordingProcess[VCRecordingStartedOutput](t, eventTypeRecordingStarted, processVCRecordingStarted, `{
"schema": "2.0",
"header": {
"event_id": "ev_rec_start_001",
"event_type": "vc.recording.recording_started_v1",
"create_time": "1761782400000"
},
"event": {
"unique_key": "recording_001",
"source": "recording_bean"
}
}`)
if out.Type != eventTypeRecordingStarted {
t.Errorf("Type = %q", out.Type)
}
if out.EventID != "ev_rec_start_001" || out.EventTime != recordingTestEventTime(1761782400000) {
t.Errorf("EventID/EventTime = %q/%q", out.EventID, out.EventTime)
}
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
}
}
func TestProcessVCRecordingTranscriptGenerated(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
got := runRecordingProcessRaw(t, eventTypeRecordingTranscriptGenerated, processVCRecordingTranscriptGenerated, `{
"schema": "2.0",
"header": {
"event_id": "ev_rec_transcript_001",
"event_type": "vc.recording.recording_transcript_generated_v1",
"create_time": "1761782400100"
},
"event": {
"unique_key": "recording_001",
"source": "recording_bean",
"transcript_items": [
{
"speaker": {
"id": {
"open_id": "ou_0f8bf7acdf2ae69553ecbdbfbbd10a53",
"union_id": "on_bc03f16d781bff4178a5d11e48eb1867",
"user_id": null
},
"user_type": 100,
"user_role": 1,
"user_name": "Alice"
},
"text": "hello world",
"language": "en_us",
"start_time_ms": "1761782399000",
"end_time_ms": "1761782400000",
"sentence_id": "987654321"
},
{
"speaker": {
"user_name": "Bob"
},
"text": "second sentence",
"language": "en_us",
"start_time_ms": "1761782401000",
"end_time_ms": "1761782402000",
"sentence_id": "987654322"
}
]
}
}`)
if got == nil {
t.Fatal("Process output is nil")
}
var out VCRecordingTranscriptGeneratedOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
}
if out.Type != eventTypeRecordingTranscriptGenerated {
t.Errorf("Type = %q", out.Type)
}
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
}
if out.EventTime != recordingTestEventTime(1761782400100) {
t.Errorf("EventTime = %q", out.EventTime)
}
if len(out.TranscriptItems) != 2 {
t.Fatalf("TranscriptItems len = %d, want 2", len(out.TranscriptItems))
}
item := out.TranscriptItems[0]
if item.SpeakerName != "Alice" || item.Text != "hello world" {
t.Errorf("Transcript speaker/text = %q/%q", item.SpeakerName, item.Text)
}
if item.StartTime != recordingTestEventTime(1761782399000) || item.EndTime != recordingTestEventTime(1761782400000) {
t.Errorf("Transcript timing = %q/%q", item.StartTime, item.EndTime)
}
if item.SentenceID != "987654321" {
t.Errorf("SentenceID = %q, want 987654321", item.SentenceID)
}
if out.TranscriptItems[1].SpeakerName != "Bob" || out.TranscriptItems[1].SentenceID != "987654322" {
t.Errorf("second transcript item = %+v", out.TranscriptItems[1])
}
itemJSON, err := json.Marshal(item)
if err != nil {
t.Fatalf("marshal transcript item: %v", err)
}
var itemFields map[string]any
if err := json.Unmarshal(itemJSON, &itemFields); err != nil {
t.Fatalf("unmarshal transcript item JSON: %v", err)
}
wantItemFields := map[string]bool{
"speaker_name": true,
"text": true,
"start_time": true,
"end_time": true,
"sentence_id": true,
}
for gotField := range itemFields {
if !wantItemFields[gotField] {
t.Errorf("Transcript item should not contain field %q, got %s", gotField, string(itemJSON))
}
}
for wantField := range wantItemFields {
if _, ok := itemFields[wantField]; !ok {
t.Errorf("Transcript item missing field %q, got %s", wantField, string(itemJSON))
}
}
for _, unexpected := range []string{
`"seq_id"`,
`"speaker"`,
`"user_open_id"`,
`"user_type"`,
`"user_role"`,
`"language"`,
`"start_time_ms"`,
`"end_time_ms"`,
`"sequence_id"`,
`"transcript_item"`,
} {
if strings.Contains(string(got), unexpected) {
t.Errorf("Transcript output should not contain %s, got %s", unexpected, string(got))
}
}
if !strings.Contains(string(got), `"sentence_id":"987654321"`) {
t.Errorf("Transcript output should contain sentence_id, got %s", string(got))
}
}
func TestProcessVCRecordingEnded(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
out := runRecordingProcess[VCRecordingEndedOutput](t, eventTypeRecordingEnded, processVCRecordingEnded, `{
"schema": "2.0",
"header": {
"event_id": "ev_rec_end_001",
"event_type": "vc.recording.recording_ended_v1",
"create_time": "1761782400200"
},
"event": {
"unique_key": "recording_001",
"source": "recording_bean",
"object_type": "minutes",
"object_id": "minute_token_001"
}
}`)
if out.Type != eventTypeRecordingEnded {
t.Errorf("Type = %q", out.Type)
}
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
}
if out.EventTime != recordingTestEventTime(1761782400200) {
t.Errorf("EventTime = %q", out.EventTime)
}
}
func TestProcessVCRecordingEnded_DropsObjectMetadata(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
got := runRecordingProcessRaw(t, eventTypeRecordingEnded, processVCRecordingEnded, `{
"schema": "2.0",
"header": {
"event_id": "ev_rec_end_001",
"event_type": "vc.recording.recording_ended_v1",
"create_time": "1761782400200"
},
"event": {
"unique_key": "recording_001",
"source": "recording_bean",
"object_type": "minutes",
"object_id": "minute_token_001"
}
}`)
if strings.Contains(string(got), "object_type") || strings.Contains(string(got), "object_id") {
t.Fatalf("ended output should drop object metadata, got %s", string(got))
}
}
func TestProcessVCRecording_DropsTimestampField(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
got := runRecordingProcessRaw(t, eventTypeRecordingStarted, processVCRecordingStarted, `{
"schema": "2.0",
"header": {
"event_id": "ev_rec_start_001",
"event_type": "vc.recording.recording_started_v1",
"create_time": "1761782400000"
},
"event": {
"unique_key": "recording_001",
"source": "recording_bean"
}
}`)
if strings.Contains(string(got), `"timestamp"`) {
t.Fatalf("recording output should use event_time instead of timestamp, got %s", string(got))
}
if !strings.Contains(string(got), `"event_time":"`+recordingTestEventTime(1761782400000)+`"`) {
t.Fatalf("recording output should include ISO 8601 event_time, got %s", string(got))
}
}
func TestProcessVCRecording_NonRecordingBeanFiltered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
payload string
}{
{
name: "started",
eventType: eventTypeRecordingStarted,
process: processVCRecordingStarted,
payload: `{
"schema": "2.0",
"header": {"event_id": "ev_rec_start_001", "event_type": "vc.recording.recording_started_v1"},
"event": {"unique_key": "recording_001", "source": "software_recording"}
}`,
},
{
name: "transcript",
eventType: eventTypeRecordingTranscriptGenerated,
process: processVCRecordingTranscriptGenerated,
payload: `{
"schema": "2.0",
"header": {"event_id": "ev_rec_transcript_001", "event_type": "vc.recording.recording_transcript_generated_v1"},
"event": {"unique_key": "recording_001", "source": "software_recording", "transcript_items": []}
}`,
},
{
name: "ended",
eventType: eventTypeRecordingEnded,
process: processVCRecordingEnded,
payload: `{
"schema": "2.0",
"header": {"event_id": "ev_rec_end_001", "event_type": "vc.recording.recording_ended_v1"},
"event": {"unique_key": "recording_001", "source": "software_recording"}
}`,
},
} {
t.Run(tc.name, func(t *testing.T) {
got := runRecordingProcessRaw(t, tc.eventType, tc.process, tc.payload)
if got != nil {
t.Fatalf("non-recording_bean event should be filtered, got %s", string(got))
}
})
}
}
func TestProcessVCRecording_MalformedPayloadPassthrough(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
}{
{name: "started", eventType: eventTypeRecordingStarted, process: processVCRecordingStarted},
{name: "transcript", eventType: eventTypeRecordingTranscriptGenerated, process: processVCRecordingTranscriptGenerated},
{name: "ended", eventType: eventTypeRecordingEnded, process: processVCRecordingEnded},
} {
t.Run(tc.name, func(t *testing.T) {
raw := &event.RawEvent{
EventType: tc.eventType,
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := tc.process(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
})
}
}
func TestVCRecording_PreConsumeSubscriptionLifecycle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
eventType string
}{
{eventTypeRecordingStarted},
{eventTypeRecordingTranscriptGenerated},
{eventTypeRecordingEnded},
} {
t.Run(tc.eventType, func(t *testing.T) {
def, ok := event.Lookup(tc.eventType)
if !ok {
t.Fatalf("%s should be registered via Keys()", tc.eventType)
}
type call struct {
method string
path string
body any
}
var calls []call
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
calls = append(calls, call{method: method, path: path, body: body})
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
},
}
cleanup, err := def.PreConsume(context.Background(), rt, nil)
if err != nil {
t.Fatalf("PreConsume error: %v", err)
}
if cleanup == nil {
t.Fatal("cleanup must not be nil")
}
if len(calls) != 1 {
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
}
if calls[0].method != "POST" || calls[0].path != pathRecordingSubscribe {
t.Fatalf("subscribe call = %+v", calls[0])
}
assertSubscriptionRequest(t, calls[0].body, tc.eventType)
cleanup()
if len(calls) != 2 {
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
}
if calls[1].method != "POST" || calls[1].path != pathRecordingUnsubscribe {
t.Fatalf("unsubscribe call = %+v", calls[1])
}
assertSubscriptionRequest(t, calls[1].body, tc.eventType)
})
}
}
func runRecordingProcess[T any](t *testing.T, eventType string, process event.ProcessFunc, payload string) T {
t.Helper()
got := runRecordingProcessRaw(t, eventType, process, payload)
if got == nil {
t.Fatal("Process output is nil")
}
var out T
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
}
return out
}
func runRecordingProcessRaw(t *testing.T, eventType string, process event.ProcessFunc, payload string) json.RawMessage {
t.Helper()
raw := &event.RawEvent{
EventType: eventType,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := process(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
return got
}
func recordingTestEventTime(millis int64) string {
return time.UnixMilli(millis).Local().Format(time.RFC3339)
}

View File

@@ -1,163 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/larksuite/cli/internal/event"
)
// VCRecordingTranscriptItemOutput is one flattened transcript item for recording events.
type VCRecordingTranscriptItemOutput struct {
SpeakerName string `json:"speaker_name,omitempty" desc:"Speaker display name"`
Text string `json:"text,omitempty" desc:"Transcript text"`
StartTime string `json:"start_time,omitempty" desc:"Transcript item start time in RFC3339 / ISO 8601 with the current system timezone"`
EndTime string `json:"end_time,omitempty" desc:"Transcript item end time in RFC3339 / ISO 8601 with the current system timezone"`
SentenceID string `json:"sentence_id,omitempty" desc:"Transcript sentence ID"`
}
// VCRecordingTranscriptGeneratedOutput is the flattened shape for vc.recording.recording_transcript_generated_v1.
type VCRecordingTranscriptGeneratedOutput struct {
Type string `json:"type" desc:"Event type; always vc.recording.recording_transcript_generated_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
EventTime string `json:"event_time,omitempty" desc:"Time when this batch of transcript items was generated, in RFC3339 / ISO 8601 with the current system timezone"`
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
TranscriptItems []VCRecordingTranscriptItemOutput `json:"transcript_items,omitempty" desc:"Generated transcript items"`
}
type recordingTranscriptGeneratedEnvelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event recordingTranscriptGeneratedEvent `json:"event"`
}
type recordingTranscriptGeneratedEvent struct {
UniqueKey string `json:"unique_key"`
Source string `json:"source"`
TranscriptItems []recordingTranscriptGeneratedItemIn `json:"transcript_items"`
}
type recordingTranscriptGeneratedItemIn struct {
Speaker *recordingTranscriptGeneratedSpeakerIn `json:"speaker"`
Text string `json:"text"`
StartTimeMs recordingTranscriptGeneratedString `json:"start_time_ms"`
EndTimeMs recordingTranscriptGeneratedString `json:"end_time_ms"`
SentenceID string `json:"sentence_id"`
}
type recordingTranscriptGeneratedSpeakerIn struct {
UserName string `json:"user_name"`
}
type recordingTranscriptGeneratedString string
func processVCRecordingTranscriptGenerated(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
envelope, ok := parseRecordingTranscriptGeneratedEnvelope(raw)
if !ok {
return raw.Payload, nil
}
if !isRecordingTranscriptGeneratedBeanEvent(envelope) {
return nil, nil
}
out := &VCRecordingTranscriptGeneratedOutput{
Type: recordingTranscriptGeneratedEventType(envelope, raw),
EventID: envelope.Header.EventID,
EventTime: recordingTranscriptGeneratedEventTime(envelope.Header.CreateTime),
UniqueKey: envelope.Event.UniqueKey,
Source: envelope.Event.Source,
TranscriptItems: recordingTranscriptItems(envelope.Event.TranscriptItems),
}
return json.Marshal(out)
}
func parseRecordingTranscriptGeneratedEnvelope(raw *event.RawEvent) (*recordingTranscriptGeneratedEnvelope, bool) {
var envelope recordingTranscriptGeneratedEnvelope
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return nil, false
}
return &envelope, true
}
func isRecordingTranscriptGeneratedBeanEvent(envelope *recordingTranscriptGeneratedEnvelope) bool {
return envelope != nil && envelope.Event.Source == "recording_bean"
}
func recordingTranscriptGeneratedEventType(envelope *recordingTranscriptGeneratedEnvelope, raw *event.RawEvent) string {
if envelope != nil && envelope.Header.EventType != "" {
return envelope.Header.EventType
}
return raw.EventType
}
func recordingTranscriptGeneratedEventTime(raw string) string {
return recordingTranscriptGeneratedMillisToLocalRFC3339(raw)
}
func recordingTranscriptGeneratedMillisToLocalRFC3339(raw string) string {
if raw == "" {
return ""
}
millis, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return ""
}
return time.UnixMilli(millis).Local().Format(time.RFC3339)
}
func recordingTranscriptItems(items []recordingTranscriptGeneratedItemIn) []VCRecordingTranscriptItemOutput {
if len(items) == 0 {
return nil
}
out := make([]VCRecordingTranscriptItemOutput, 0, len(items))
for _, item := range items {
out = append(out, recordingTranscriptItem(item))
}
return out
}
func recordingTranscriptItem(item recordingTranscriptGeneratedItemIn) VCRecordingTranscriptItemOutput {
return VCRecordingTranscriptItemOutput{
SpeakerName: recordingSpeakerName(item.Speaker),
Text: item.Text,
StartTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.StartTimeMs.String()),
EndTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.EndTimeMs.String()),
SentenceID: item.SentenceID,
}
}
func recordingSpeakerName(speaker *recordingTranscriptGeneratedSpeakerIn) string {
if speaker == nil {
return ""
}
return speaker.UserName
}
func (s *recordingTranscriptGeneratedString) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
return nil
}
var str string
if err := json.Unmarshal(data, &str); err == nil {
*s = recordingTranscriptGeneratedString(str)
return nil
}
var num json.Number
if err := json.Unmarshal(data, &num); err != nil {
return err
}
*s = recordingTranscriptGeneratedString(num.String())
return nil
}
func (s recordingTranscriptGeneratedString) String() string {
return string(s)
}

View File

@@ -11,18 +11,13 @@ import (
) )
const ( const (
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1" eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
eventTypeNoteGenerated = "vc.note.generated_v1" eventTypeNoteGenerated = "vc.note.generated_v1"
eventTypeRecordingStarted = "vc.recording.recording_started_v1"
eventTypeRecordingTranscriptGenerated = "vc.recording.recording_transcript_generated_v1"
eventTypeRecordingEnded = "vc.recording.recording_ended_v1"
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription" pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription" pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription" pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription"
pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription" pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription"
pathRecordingSubscribe = "/open-apis/vc/v1/recordings/subscription"
pathRecordingUnsubscribe = "/open-apis/vc/v1/recordings/unsubscription"
pathNoteDetailFmt = "/open-apis/vc/v1/notes/%s" pathNoteDetailFmt = "/open-apis/vc/v1/notes/%s"
) )
@@ -62,53 +57,5 @@ func Keys() []event.KeyDefinition {
}, },
RequiredConsoleEvents: []string{eventTypeNoteGenerated}, RequiredConsoleEvents: []string{eventTypeNoteGenerated},
}, },
{
Key: eventTypeRecordingStarted,
DisplayName: "Recording started",
Description: "Triggered when a recording_bean recording starts; only generated when connected to Feishu software.",
EventType: eventTypeRecordingStarted,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingStartedOutput{})},
},
Process: processVCRecordingStarted,
PreConsume: subscriptionPreConsume(eventTypeRecordingStarted, pathRecordingSubscribe, pathRecordingUnsubscribe),
Scopes: []string{"vc:recording:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeRecordingStarted},
},
{
Key: eventTypeRecordingTranscriptGenerated,
DisplayName: "Recording transcript generated",
Description: "Triggered when recording_bean transcript items are generated; only generated when connected to Feishu software.",
EventType: eventTypeRecordingTranscriptGenerated,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{})},
},
Process: processVCRecordingTranscriptGenerated,
PreConsume: subscriptionPreConsume(eventTypeRecordingTranscriptGenerated, pathRecordingSubscribe, pathRecordingUnsubscribe),
Scopes: []string{"vc:recording:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeRecordingTranscriptGenerated},
},
{
Key: eventTypeRecordingEnded,
DisplayName: "Recording ended",
Description: "Triggered when a recording_bean recording ends and uploads successfully; only generated when connected to Feishu software.",
EventType: eventTypeRecordingEnded,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingEndedOutput{})},
},
Process: processVCRecordingEnded,
PreConsume: subscriptionPreConsume(eventTypeRecordingEnded, pathRecordingSubscribe, pathRecordingUnsubscribe),
Scopes: []string{"vc:recording:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeRecordingEnded},
},
} }
} }

View File

@@ -8,7 +8,6 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/validate"
) )
@@ -22,18 +21,14 @@ const cleanupTimeout = 5 * time.Second
// //
// board.whiteboard.updated_v1 is subscribed per-whiteboard (by whiteboard_id), // board.whiteboard.updated_v1 is subscribed per-whiteboard (by whiteboard_id),
// so the path contains a :whiteboard_id placeholder that must be supplied via params. // so the path contains a :whiteboard_id placeholder that must be supplied via params.
func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func() error, error) { func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func() error, error) { return func(ctx context.Context, rt event.APIClient, params map[string]string) (func(), error) {
if rt == nil { if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown, return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
"runtime API client is required for pre-consume subscription")
} }
whiteboardID := params["whiteboard_id"] whiteboardID := params["whiteboard_id"]
if whiteboardID == "" { if whiteboardID == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, return nil, fmt.Errorf("param whiteboard_id is required for %s", eventType)
"param whiteboard_id is required for %s", eventType).
WithParam("--param").
WithHint("pass it as --param whiteboard_id=<id>; run `lark-cli event schema %s` for details", eventType)
} }
encoded := validate.EncodePathSegment(whiteboardID) encoded := validate.EncodePathSegment(whiteboardID)
subscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/subscribe", encoded) subscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/subscribe", encoded)
@@ -44,13 +39,10 @@ func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, ev
return nil, err return nil, err
} }
return func() error { return func() {
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
defer cancel() defer cancel()
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil { _, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
return err
}
return nil
}, nil }, nil
} }
} }

View File

@@ -11,7 +11,6 @@ import (
"sync" "sync"
"testing" "testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event"
) )
@@ -59,16 +58,6 @@ func TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID(t *testing.T) {
if !strings.Contains(err.Error(), "whiteboard_id") { if !strings.Contains(err.Error(), "whiteboard_id") {
t.Fatalf("error should mention whiteboard_id, got: %v", err) t.Fatalf("error should mention whiteboard_id, got: %v", err)
} }
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--param")
}
if ve.Hint == "" {
t.Error("missing whiteboard_id should carry a hint")
}
} }
// TestWhiteboardSubscriptionPreConsume_NilRuntime verifies that PreConsume // TestWhiteboardSubscriptionPreConsume_NilRuntime verifies that PreConsume
@@ -81,9 +70,6 @@ func TestWhiteboardSubscriptionPreConsume_NilRuntime(t *testing.T) {
if err == nil { if err == nil {
t.Fatalf("expected error when runtime client is nil") t.Fatalf("expected error when runtime client is nil")
} }
if p, ok := errs.ProblemOf(err); !ok || p.Category != errs.CategoryInternal {
t.Errorf("nil-runtime invariant should be a typed internal error, got %T: %v", err, err)
}
} }
// TestWhiteboardSubscriptionPreConsume_SubscribeError verifies that a // TestWhiteboardSubscriptionPreConsume_SubscribeError verifies that a

View File

@@ -47,7 +47,6 @@ type DeviceFlowResult struct {
// OAuthEndpoints contains the OAuth endpoint URLs. // OAuthEndpoints contains the OAuth endpoint URLs.
type OAuthEndpoints struct { type OAuthEndpoints struct {
DeviceAuthorization string DeviceAuthorization string
Revoke string
Token string Token string
} }
@@ -56,7 +55,6 @@ func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints {
ep := core.ResolveEndpoints(brand) ep := core.ResolveEndpoints(brand)
return OAuthEndpoints{ return OAuthEndpoints{
DeviceAuthorization: ep.Accounts + PathDeviceAuthorization, DeviceAuthorization: ep.Accounts + PathDeviceAuthorization,
Revoke: ep.Accounts + PathOAuthRevoke,
Token: ep.Open + PathOAuthTokenV2, Token: ep.Open + PathOAuthTokenV2,
} }
} }

View File

@@ -31,9 +31,6 @@ func TestResolveOAuthEndpoints_Feishu(t *testing.T) {
if ep.DeviceAuthorization != "https://accounts.feishu.cn/oauth/v1/device_authorization" { if ep.DeviceAuthorization != "https://accounts.feishu.cn/oauth/v1/device_authorization" {
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization) t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
} }
if ep.Revoke != "https://accounts.feishu.cn/oauth/v1/revoke" {
t.Errorf("Revoke = %q", ep.Revoke)
}
if ep.Token != "https://open.feishu.cn/open-apis/authen/v2/oauth/token" { if ep.Token != "https://open.feishu.cn/open-apis/authen/v2/oauth/token" {
t.Errorf("Token = %q", ep.Token) t.Errorf("Token = %q", ep.Token)
} }
@@ -45,9 +42,6 @@ func TestResolveOAuthEndpoints_Lark(t *testing.T) {
if ep.DeviceAuthorization != "https://accounts.larksuite.com/oauth/v1/device_authorization" { if ep.DeviceAuthorization != "https://accounts.larksuite.com/oauth/v1/device_authorization" {
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization) t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
} }
if ep.Revoke != "https://accounts.larksuite.com/oauth/v1/revoke" {
t.Errorf("Revoke = %q", ep.Revoke)
}
if ep.Token != "https://open.larksuite.com/open-apis/authen/v2/oauth/token" { if ep.Token != "https://open.larksuite.com/open-apis/authen/v2/oauth/token" {
t.Errorf("Token = %q", ep.Token) t.Errorf("Token = %q", ep.Token)
} }

View File

@@ -7,8 +7,6 @@ package auth
const ( const (
// PathDeviceAuthorization is the endpoint for device authorization. // PathDeviceAuthorization is the endpoint for device authorization.
PathDeviceAuthorization = "/oauth/v1/device_authorization" PathDeviceAuthorization = "/oauth/v1/device_authorization"
// PathOAuthRevoke is the endpoint for revoking an OAuth token.
PathOAuthRevoke = "/oauth/v1/revoke"
// PathAppRegistration is the endpoint for application registration. // PathAppRegistration is the endpoint for application registration.
PathAppRegistration = "/oauth/v1/app/registration" PathAppRegistration = "/oauth/v1/app/registration"
// PathOAuthTokenV2 is the endpoint for requesting an OAuth token (v2). // PathOAuthTokenV2 is the endpoint for requesting an OAuth token (v2).

View File

@@ -1,131 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
// RevokeToken revokes a previously issued OAuth token.
func RevokeToken(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, token, tokenTypeHint string) error {
endpoints := ResolveOAuthEndpoints(brand)
form := url.Values{}
form.Set("client_id", appId)
form.Set("client_secret", appSecret)
form.Set("token", token)
if tokenTypeHint != "" {
form.Set("token_type_hint", tokenTypeHint)
}
req, err := http.NewRequest(http.MethodPost, endpoints.Revoke, strings.NewReader(form.Encode()))
if err != nil {
return errs.NewInternalError(errs.SubtypeUnknown, "token revoke request creation failed: %v", err).WithCause(err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "token revoke transport error: %v", err).WithCause(err)
}
defer resp.Body.Close()
logHTTPResponse(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "token revoke read error: %v", err).WithCause(err)
}
if resp.StatusCode >= 400 {
return revokeHTTPStatusError(resp.StatusCode, body)
}
if len(body) == 0 {
return nil
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return nil
}
if code := getInt(data, "code", 0); code != 0 {
msg := getStr(data, "msg")
if msg == "" {
msg = getStr(data, "message")
}
if msg == "" {
msg = "unknown error"
}
return errs.NewAPIError(errs.SubtypeUnknown, "token revoke failed [%d]: %s", code, msg).
WithCode(code).
WithCause(errors.New(msg))
}
if errStr := getStr(data, "error"); errStr != "" {
msg := getStr(data, "error_description")
if msg == "" {
msg = errStr
}
return errs.NewAPIError(errs.SubtypeUnknown, "token revoke failed: %s", msg).
WithCause(errors.New(msg))
}
return nil
}
func revokeHTTPStatusError(status int, body []byte) error {
msg := formatOAuthErrorBody(body)
cause := errors.New(strings.TrimSpace(string(body)))
if strings.TrimSpace(string(body)) == "" {
cause = errors.New(msg)
}
if status >= http.StatusInternalServerError {
return errs.NewNetworkError(errs.SubtypeNetworkServer, "token revoke failed: HTTP %d: %s", status, msg).
WithCode(status).
WithRetryable().
WithCause(cause)
}
subtype := errs.SubtypeUnknown
if status == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "token revoke failed: HTTP %d: %s", status, msg).
WithCode(status).
WithCause(cause)
}
func formatOAuthErrorBody(body []byte) string {
trimmed := strings.TrimSpace(string(body))
if trimmed == "" {
return "empty response"
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return trimmed
}
if msg := getStr(data, "error_description"); msg != "" {
return msg
}
if msg := getStr(data, "msg"); msg != "" {
return msg
}
if msg := getStr(data, "message"); msg != "" {
return msg
}
if msg := getStr(data, "error"); msg != "" {
return msg
}
return trimmed
}

View File

@@ -1,207 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"net/http"
"net/url"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
)
type revokeRoundTripFunc func(*http.Request) (*http.Response, error)
func (fn revokeRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
type errReadCloser struct {
err error
}
func (r errReadCloser) Read(_ []byte) (int, error) {
return 0, r.err
}
func (r errReadCloser) Close() error {
return nil
}
func TestRevokeToken_PostsExpectedForm(t *testing.T) {
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
stub := &httpmock.Stub{
Method: "POST",
URL: PathOAuthRevoke,
Body: map[string]interface{}{"code": 0},
BodyFilter: func(body []byte) bool {
values, err := url.ParseQuery(string(body))
if err != nil {
return false
}
return values.Get("client_id") == "cli_a" &&
values.Get("client_secret") == "secret_b" &&
values.Get("token") == "user-access-token" &&
values.Get("token_type_hint") == "access_token"
},
}
reg.Register(stub)
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err != nil {
t.Fatalf("RevokeToken() error = %v", err)
}
if got := stub.CapturedHeaders.Get("Content-Type"); got != "application/x-www-form-urlencoded" {
t.Fatalf("Content-Type = %q", got)
}
}
func TestRevokeToken_DoFailureReturnsTypedNetworkError(t *testing.T) {
sentinel := errors.New("transport down")
httpClient := &http.Client{
Transport: revokeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, sentinel
}),
}
err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("problem = %#v, want network/transport", p)
}
if !errors.Is(err, sentinel) {
t.Fatalf("expected cause %v to be preserved, got %v", sentinel, err)
}
}
func TestRevokeToken_ReportsHTTPError(t *testing.T) {
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
reg.Register(&httpmock.Stub{
Method: "POST",
URL: PathOAuthRevoke,
Status: 400,
Body: map[string]interface{}{"error": "invalid_token"},
})
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Category != errs.CategoryAPI || p.Code != 400 {
t.Fatalf("problem = %#v, want api error with HTTP 400", p)
}
if !strings.Contains(err.Error(), "invalid_token") {
t.Fatalf("expected invalid_token error, got %v", err)
}
}
func TestRevokeToken_ReportsOAuthCodeErrorAsTypedAPIError(t *testing.T) {
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
reg.Register(&httpmock.Stub{
Method: "POST",
URL: PathOAuthRevoke,
Body: map[string]interface{}{
"code": 12345,
"msg": "invalid revoke state",
},
})
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Category != errs.CategoryAPI || p.Code != 12345 {
t.Fatalf("problem = %#v, want api error with code 12345", p)
}
if !strings.Contains(err.Error(), "invalid revoke state") {
t.Fatalf("expected oauth error message, got %v", err)
}
}
func TestRevokeToken_ReportsOAuthErrorFieldAsTypedAPIError(t *testing.T) {
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
reg.Register(&httpmock.Stub{
Method: "POST",
URL: PathOAuthRevoke,
Body: map[string]interface{}{
"error": "invalid_token",
"error_description": "token already expired",
},
})
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Category != errs.CategoryAPI {
t.Fatalf("problem = %#v, want api error", p)
}
if !strings.Contains(err.Error(), "token already expired") {
t.Fatalf("expected oauth error_description, got %v", err)
}
}
func TestRevokeToken_ReadFailureReturnsTypedInternalError(t *testing.T) {
sentinel := errors.New("read failed")
httpClient := &http.Client{
Transport: revokeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: errReadCloser{err: sentinel},
Header: make(http.Header),
}, nil
}),
}
err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("problem = %#v, want internal/invalid_response", p)
}
if !errors.Is(err, sentinel) {
t.Fatalf("expected cause %v to be preserved, got %v", sentinel, err)
}
if !strings.Contains(err.Error(), "token revoke read error") {
t.Fatalf("expected read error message, got %v", err)
}
if _, ok := err.(*errs.InternalError); !ok {
t.Fatalf("expected *errs.InternalError, got %T", err)
}
}

View File

@@ -153,9 +153,79 @@ func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as c
if err != nil { if err != nil {
return nil, WrapDoAPIError(err) return nil, WrapDoAPIError(err)
} }
c.logAPIResponse(req, resp)
return resp, nil return resp, nil
} }
func (c *APIClient) logAPIResponse(req *larkcore.ApiReq, resp *larkcore.ApiResp) {
if resp == nil {
return
}
logID := strings.TrimSpace(resp.LogId())
if logID == "" {
return
}
method, path := apiReqLogFields(req, "")
fmt.Fprintf(c.errOut(), "[lark-cli] api-response: method=%s path=%s status=%d log_id=%s\n", method, path, resp.StatusCode, logID)
}
func (c *APIClient) logStreamResponse(req *larkcore.ApiReq, requestURL string, resp *http.Response) {
if resp == nil {
return
}
logID := streamLogID(resp.Header)
if logID == "" {
return
}
method, path := apiReqLogFields(req, requestURL)
fmt.Fprintf(c.errOut(), "[lark-cli] api-response: method=%s path=%s status=%d log_id=%s\n", method, path, resp.StatusCode, logID)
}
func (c *APIClient) errOut() io.Writer {
if c != nil && c.ErrOut != nil {
return c.ErrOut
}
return io.Discard
}
func apiReqLogFields(req *larkcore.ApiReq, fallbackURL string) (string, string) {
method := ""
path := ""
if req != nil {
method = req.HttpMethod
path = req.ApiPath
}
method = strings.ToUpper(strings.TrimSpace(method))
if method == "" {
method = "UNKNOWN"
}
path = requestLogPath(path)
if path == "missing" {
path = requestLogPath(fallbackURL)
}
return method, path
}
func requestLogPath(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return "missing"
}
if u, err := url.Parse(raw); err == nil && u.IsAbs() {
if u.EscapedPath() != "" {
return u.EscapedPath()
}
return "/"
}
if i := strings.Index(raw, "?"); i >= 0 {
raw = raw[:i]
}
if raw == "" {
return "missing"
}
return raw
}
// DoStream executes a streaming HTTP request against the Lark OpenAPI endpoint. // DoStream executes a streaming HTTP request against the Lark OpenAPI endpoint.
// Unlike DoSDKRequest (which buffers the full body via the SDK), DoStream returns // Unlike DoSDKRequest (which buffers the full body via the SDK), DoStream returns
// a live *http.Response whose Body is an io.Reader for streaming consumption. // a live *http.Response whose Body is an io.Reader for streaming consumption.
@@ -224,6 +294,7 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
return nil, errs.NewNetworkError(classifyNetworkSubtype(err), "stream request failed: %s", err).WithCause(err) return nil, errs.NewNetworkError(classifyNetworkSubtype(err), "stream request failed: %s", err).WithCause(err)
} }
resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel} resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel}
c.logStreamResponse(req, requestURL, resp)
// Handle HTTP errors internally // Handle HTTP errors internally
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {

View File

@@ -464,6 +464,48 @@ func TestDoStream_TransportFailureSplitsSubtype(t *testing.T) {
} }
} }
func TestDoStream_LogsLogIDToErrOut(t *testing.T) {
errBuf := &bytes.Buffer{}
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{
"Content-Type": []string{"application/octet-stream"},
larkcore.HttpHeaderKeyLogId: []string{"stream-log-123"},
},
Body: io.NopCloser(strings.NewReader("ok")),
}, nil
})
ac := &APIClient{
HTTP: &http.Client{Transport: rt},
ErrOut: errBuf,
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
resp, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/drive/v1/medias/file_token/download",
}, core.AsBot)
if err != nil {
t.Fatalf("DoStream() error = %v", err)
}
defer resp.Body.Close()
got := errBuf.String()
for _, want := range []string{
"[lark-cli] api-response:",
"method=GET",
"path=/open-apis/drive/v1/medias/file_token/download",
"status=200",
"log_id=stream-log-123",
} {
if !strings.Contains(got, want) {
t.Fatalf("log missing %q; got:\n%s", want, got)
}
}
}
// failingTokenResolver always returns TokenUnavailableError, exercising the // failingTokenResolver always returns TokenUnavailableError, exercising the
// auth/credential failure path through resolveAccessToken. // auth/credential failure path through resolveAccessToken.
type failingTokenResolver struct{} type failingTokenResolver struct{}
@@ -618,6 +660,41 @@ func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) {
} }
} }
func TestDoSDKRequest_LogsLogIDToErrOut(t *testing.T) {
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{
"Content-Type": []string{"application/json"},
larkcore.HttpHeaderKeyLogId: []string{"sdk-log-123"},
},
Body: io.NopCloser(strings.NewReader(`{"code":0,"msg":"ok","data":{}}`)),
}, nil
})
ac, errBuf := newTestAPIClient(t, rt)
_, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/contact/v3/users/me",
}, core.AsBot)
if err != nil {
t.Fatalf("DoSDKRequest() error = %v", err)
}
got := errBuf.String()
for _, want := range []string{
"[lark-cli] api-response:",
"method=GET",
"path=/open-apis/contact/v3/users/me",
"status=200",
"log_id=sdk-log-123",
} {
if !strings.Contains(got, want) {
t.Fatalf("log missing %q; got:\n%s", want, got)
}
}
}
// TestCallAPI_ParseJSONFailureWrapsAsAPI pins the typed-envelope contract for // TestCallAPI_ParseJSONFailureWrapsAsAPI pins the typed-envelope contract for
// malformed JSON response bodies: WrapJSONResponseParseError emits // malformed JSON response bodies: WrapJSONResponseParseError emits
// *errs.InternalError{Subtype: invalid_response} with the rawAPIJSONHint // *errs.InternalError{Subtype: invalid_response} with the rawAPIJSONHint

View File

@@ -28,8 +28,14 @@ const (
HeaderShortcut = "X-Cli-Shortcut" HeaderShortcut = "X-Cli-Shortcut"
HeaderExecutionId = "X-Cli-Execution-Id" HeaderExecutionId = "X-Cli-Execution-Id"
HeaderAgentTrace = "X-Agent-Trace" HeaderAgentTrace = "X-Agent-Trace"
HeaderTTEnv = "X-Tt-Env"
HeaderUsePPE = "X-Use-Ppe"
HeaderRPCAppID = "Rpc-Persist-Cli-Req-App-Id"
SourceValue = "lark-cli" SourceValue = "lark-cli"
TTEnvValue = "ppe_doubao_office_local"
UsePPEValue = "1"
RPCAppID = "497858"
HeaderUserAgent = "User-Agent" HeaderUserAgent = "User-Agent"
@@ -75,6 +81,9 @@ func BaseSecurityHeaders() http.Header {
h.Set(HeaderVersion, build.Version) h.Set(HeaderVersion, build.Version)
h.Set(HeaderBuild, DetectBuildKind()) h.Set(HeaderBuild, DetectBuildKind())
h.Set(HeaderUserAgent, UserAgentValue()) h.Set(HeaderUserAgent, UserAgentValue())
h.Set(HeaderTTEnv, TTEnvValue)
h.Set(HeaderUsePPE, UsePPEValue)
h.Set(HeaderRPCAppID, RPCAppID)
if v := AgentTraceValue(); v != "" { if v := AgentTraceValue(); v != "" {
h.Set(HeaderAgentTrace, v) h.Set(HeaderAgentTrace, v)
} }

View File

@@ -256,13 +256,26 @@ func TestBaseSecurityHeaders_IncludesBuildHeader(t *testing.T) {
func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) { func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
h := BaseSecurityHeaders() h := BaseSecurityHeaders()
for _, key := range []string{HeaderSource, HeaderVersion, HeaderBuild, HeaderUserAgent} { for _, key := range []string{HeaderSource, HeaderVersion, HeaderBuild, HeaderUserAgent, HeaderTTEnv, HeaderUsePPE, HeaderRPCAppID} {
if h.Get(key) == "" { if h.Get(key) == "" {
t.Errorf("BaseSecurityHeaders missing %s", key) t.Errorf("BaseSecurityHeaders missing %s", key)
} }
} }
} }
func TestBaseSecurityHeaders_IncludesPersistentRequestHeaders(t *testing.T) {
h := BaseSecurityHeaders()
if got := h.Get(HeaderTTEnv); got != TTEnvValue {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderTTEnv, got, TTEnvValue)
}
if got := h.Get(HeaderUsePPE); got != UsePPEValue {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderUsePPE, got, UsePPEValue)
}
if got := h.Get(HeaderRPCAppID); got != RPCAppID {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderRPCAppID, got, RPCAppID)
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// AgentTraceValue / HeaderAgentTrace // AgentTraceValue / HeaderAgentTrace
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -22,12 +22,6 @@ func ParseBrand(value string) LarkBrand {
return BrandFeishu return BrandFeishu
} }
// OAuthTokenV3Path is the unified OAuth 2.0 Token Endpoint path on the accounts
// domain. It serves every grant type (client_credentials for TAT,
// authorization_code / device_code / refresh_token for UAT) and replaces the
// legacy per-token endpoints (e.g. /open-apis/auth/v3/tenant_access_token/internal).
const OAuthTokenV3Path = "/oauth/v3/token"
// Endpoints holds resolved endpoint URLs for different Lark services. // Endpoints holds resolved endpoint URLs for different Lark services.
type Endpoints struct { type Endpoints struct {
Open string // e.g. "https://open.feishu.cn" Open string // e.g. "https://open.feishu.cn"

View File

@@ -42,11 +42,6 @@ func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) {
if ep.Open != "https://open.feishu.cn" { if ep.Open != "https://open.feishu.cn" {
t.Errorf("Open = %q, want feishu.cn for empty brand", ep.Open) t.Errorf("Open = %q, want feishu.cn for empty brand", ep.Open)
} }
// The unified OAuth v3 Token Endpoint mints TAT on the accounts domain;
// pin the default-brand host so a stray non-production domain revert is caught.
if ep.Accounts != "https://accounts.feishu.cn" {
t.Errorf("Accounts = %q, want accounts.feishu.cn for empty brand", ep.Accounts)
}
} }
func TestResolveOpenBaseURL(t *testing.T) { func TestResolveOpenBaseURL(t *testing.T) {

View File

@@ -19,44 +19,33 @@ import (
extcred "github.com/larksuite/cli/extension/credential" extcred "github.com/larksuite/cli/extension/credential"
) )
// classifyTATResponseCode wraps a deterministic (non-transient) failure from the // classifyTATResponseCode wraps a non-zero TAT endpoint response code into the
// unified Token Endpoint into the canonical typed errs.* error. The v3 endpoint // canonical typed error. The TAT mint endpoint reports invalid credentials
// reports failures using the OAuth 2.0 model — an `error` string plus an // with two distinct codes:
// optional numeric `code` — instead of the legacy `{code, msg}` shape.
// //
// invalid_client / unauthorized_client mean the configured app_id/app_secret // - 10003: bad app_id format or non-existent app_id ("invalid param")
// cannot mint a token; from the user's perspective that is the same actionable // - 10014: invalid app_secret ("app secret invalid")
// CategoryConfig/InvalidClient failure the legacy 10003/10014 codes produced. //
// Every other deterministic error falls through to BuildAPIError, which still // Both surface as CategoryConfig/InvalidClient from the user's perspective —
// yields a typed error so probe callers (errs.IsTyped) surface it rather than // the configured credentials cannot mint a tenant access token. 10014 is
// swallowing it. Transient/server-side failures (5xx / server_error) are // globally mapped in codemeta (TAT-mint-specific variant of OAuth 99991543).
// filtered out by FetchTAT before this is called, so they stay untyped. // 10003 is NOT globally mapped because in other Lark endpoints it carries
func classifyTATResponseCode(code int, oauthErr, errDesc, brand, appID string) error { // unrelated semantics (e.g. task API uses 10003 for permission denied), so
msg := errDesc // the override stays local to this TAT call site instead of leaking into the
if msg == "" { // shared codemeta table.
msg = oauthErr func classifyTATResponseCode(code int, msg, brand, appID string) error {
} if code == 10003 {
switch oauthErr {
case "invalid_client", "unauthorized_client":
return errs.NewConfigError(errs.SubtypeInvalidClient, "%s", msg). return errs.NewConfigError(errs.SubtypeInvalidClient, "%s", msg).
WithCode(code). WithCode(code).
WithHint("%s", errclass.ConfigHint(errs.SubtypeInvalidClient)) WithHint("%s", errclass.ConfigHint(errs.SubtypeInvalidClient))
} }
if err := errclass.BuildAPIError(map[string]any{ return errclass.BuildAPIError(map[string]any{
"code": code, "code": code,
"msg": msg, "msg": msg,
}, errclass.ClassifyContext{ }, errclass.ClassifyContext{
Brand: brand, Brand: brand,
AppID: appID, AppID: appID,
}); err != nil { })
return err
}
// BuildAPIError returns nil for code 0 (Feishu's success convention), but this
// function is only reached once FetchTAT has ruled out success — a non-credential
// OAuth error (e.g. invalid_scope) can arrive with code 0 and is still a
// deterministic rejection. Back it with a typed APIError so callers never receive
// the ("", nil) "empty token, no error" pair.
return errs.NewAPIError(errs.SubtypeUnknown, "%s", msg).WithCode(code)
} }
// DefaultAccountProvider resolves account from config.json via keychain. // DefaultAccountProvider resolves account from config.json via keychain.
@@ -157,8 +146,8 @@ func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, er
return &TokenResult{Token: token, Scopes: scopes}, nil return &TokenResult{Token: token, Scopes: scopes}, nil
} }
// resolveTAT resolves a tenant access token. The result is cached after the first // resolveTAT resolves a tenant access token. Result is cached after first call.
// call via sync.Once — only the context from the first call is used. // NOTE: Uses sync.Once — only the context from the first call is used.
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) { func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) {
p.tatOnce.Do(func() { p.tatOnce.Do(func() {
p.tatResult, p.tatErr = p.doResolveTAT(ctx) p.tatResult, p.tatErr = p.doResolveTAT(ctx)

View File

@@ -19,16 +19,18 @@ func TestDefaultAccountProvider_Implements(t *testing.T) {
var _ DefaultAccountResolver = &DefaultAccountProvider{} var _ DefaultAccountResolver = &DefaultAccountProvider{}
} }
// TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient pins that the // TestClassifyTATResponseCode_10003_MapsToInvalidClient pins that the TAT
// unified Token Endpoint's OAuth2 invalid_client error surfaces as // endpoint's "invalid param" code surfaces as CategoryConfig/InvalidClient.
// CategoryConfig/InvalidClient — the configured app_id/app_secret cannot mint a // Reason: a bad or non-existent app_id triggers 10003 on the TAT mint endpoint,
// tenant access token, the same actionable failure the legacy 10003/10014 codes // which from the user's perspective is the same actionable failure as 10014
// produced. The numeric code is intentionally not asserted: the v3 endpoint may // ("app secret invalid") — both mean the configured credentials cannot mint a
// return invalid_client with no Lark code (code defaults to 0). // tenant access token. The global codemeta intentionally does not map 10003
func TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient(t *testing.T) { // because in other Lark endpoints 10003 carries unrelated semantics (e.g. task
err := classifyTATResponseCode(0, "invalid_client", "client authentication failed", "feishu", "cli_app_x") // API uses it for permission denied), so the override is local to this site.
func TestClassifyTATResponseCode_10003_MapsToInvalidClient(t *testing.T) {
err := classifyTATResponseCode(10003, "invalid param", "feishu", "cli_app_x")
if err == nil { if err == nil {
t.Fatal("expected non-nil error for invalid_client") t.Fatal("expected non-nil error for code=10003")
} }
var cfgErr *errs.ConfigError var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) { if !errors.As(err, &cfgErr) {
@@ -40,16 +42,22 @@ func TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient(t *testing.T)
if cfgErr.Subtype != errs.SubtypeInvalidClient { if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient) t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
} }
if cfgErr.Code != 10003 {
t.Errorf("Code = %d, want 10003", cfgErr.Code)
}
if cfgErr.Hint == "" { if cfgErr.Hint == "" {
t.Error("Hint must be non-empty so the user gets a recovery action") t.Error("Hint must be non-empty so the user gets a recovery action")
} }
} }
// TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient pins that // TestClassifyTATResponseCode_10014_RoutesViaCodeMeta pins that 10014 still
// unauthorized_client is treated as the same credential failure as // goes through the global BuildAPIError path (codemeta entry) so the override
// invalid_client. // for 10003 does not regress the existing mapping.
func TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient(t *testing.T) { func TestClassifyTATResponseCode_10014_RoutesViaCodeMeta(t *testing.T) {
err := classifyTATResponseCode(0, "unauthorized_client", "client not authorized", "feishu", "cli_app_x") err := classifyTATResponseCode(10014, "app secret invalid", "feishu", "cli_app_x")
if err == nil {
t.Fatal("expected non-nil error for code=10014")
}
var cfgErr *errs.ConfigError var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) { if !errors.As(err, &cfgErr) {
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err) t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
@@ -57,38 +65,21 @@ func TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient(t *testi
if cfgErr.Subtype != errs.SubtypeInvalidClient { if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient) t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
} }
} if cfgErr.Code != 10014 {
t.Errorf("Code = %d, want 10014", cfgErr.Code)
// TestClassifyTATResponseCode_OtherErrorFallsThrough pins that OAuth errors
// outside the credential set fall through to the generic BuildAPIError fallback
// — still typed, but not a ConfigError. The mapping is narrow and intentional.
func TestClassifyTATResponseCode_OtherErrorFallsThrough(t *testing.T) {
err := classifyTATResponseCode(20068, "invalid_scope", "unauthorized scope", "feishu", "cli_app_x")
if err == nil {
t.Fatal("expected non-nil error for invalid_scope")
}
var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) {
t.Fatalf("invalid_scope must not be classified as ConfigError, got %T", err)
} }
} }
// TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped pins the code-0 // TestClassifyTATResponseCode_UnknownCodeFallsThrough pins that codes outside
// backstop: a non-credential OAuth error (e.g. invalid_scope) that arrives with no // the credential set fall through to the generic BuildAPIError fallback
// numeric code (code 0) must still produce a non-nil typed error. BuildAPIError // (CategoryAPI/SubtypeUnknown) — the override is narrow and intentional.
// returns nil for code 0 (Feishu's success convention); without the backstop, func TestClassifyTATResponseCode_UnknownCodeFallsThrough(t *testing.T) {
// FetchTAT would surface this deterministic rejection as ("", nil) — an empty token err := classifyTATResponseCode(99999999, "some unknown failure", "feishu", "cli_app_x")
// with no error.
func TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped(t *testing.T) {
err := classifyTATResponseCode(0, "invalid_scope", "the requested scope is not granted", "feishu", "cli_app_x")
if err == nil { if err == nil {
t.Fatal("expected non-nil error for code-0 invalid_scope (must not be swallowed as success)") t.Fatal("expected non-nil error for unmapped code")
}
if !errs.IsTyped(err) {
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
} }
var cfgErr *errs.ConfigError var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) { if errors.As(err, &cfgErr) {
t.Fatalf("code-0 invalid_scope must not be a ConfigError, got %T", err) t.Fatalf("unmapped code must not be classified as ConfigError, got %T", err)
} }
} }

View File

@@ -4,47 +4,46 @@
package credential package credential
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/url"
"strings"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
) )
// FetchTAT performs a single HTTP POST to mint a tenant access token via the // FetchTAT performs a single HTTP POST to mint a tenant access token with the
// unified OAuth 2.0 Token Endpoint ({accounts}/oauth/v3/token) using the // given credentials. It does not read configuration or keychain, so callers
// client_credentials grant with client_secret_post authentication. It does not // that already hold plaintext credentials (e.g. the post-`config init` probe)
// read configuration or keychain, so callers that already hold plaintext // can validate them without a second keychain round-trip.
// credentials (e.g. the post-`config init` probe) can validate them without a
// second keychain round-trip.
// //
// A deterministic client-side rejection (e.g. invalid_client) returns the // A non-zero TAT response code means the server inspected the payload and
// canonical typed error from classifyTATResponseCode — the SAME classification // rejected the credentials; FetchTAT returns the canonical typed error from
// doResolveTAT (and thus every token-resolving command) produces, so callers // classifyTATResponseCode — the SAME classification doResolveTAT (and thus
// see one consistent envelope. Transport failures, unreadable/unparseable // every token-resolving command) produces, so callers see one consistent
// bodies, and transient server-side failures (5xx / server_error) are returned // envelope (CategoryConfig / SubtypeInvalidClient for 10003 / 10014, etc.).
// raw (untyped), leaving them ambiguous; a caller can use errs.IsTyped to tell a // Transport, HTTP-status and JSON-parse failures are returned raw (untyped),
// deterministic credential rejection apart from upstream/transport noise. // leaving them ambiguous; a caller can use errs.IsTyped to tell a deterministic
// credential rejection apart from upstream/transport noise.
// //
// The caller owns the context timeout. // The caller owns the context timeout.
func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID, appSecret string) (string, error) { func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID, appSecret string) (string, error) {
ep := core.ResolveEndpoints(brand) ep := core.ResolveEndpoints(brand)
endpoint := ep.Accounts + core.OAuthTokenV3Path url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
form := url.Values{} body, err := json.Marshal(map[string]string{
form.Set("grant_type", "client_credentials") "app_id": appID,
form.Set("client_id", appID) "app_secret": appSecret,
form.Set("client_secret", appSecret) })
if err != nil {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) return "", fmt.Errorf("failed to marshal TAT request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil { if err != nil {
return "", err return "", err
} }
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req) resp, err := httpClient.Do(req)
if err != nil { if err != nil {
@@ -52,51 +51,20 @@ func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode != http.StatusOK {
if err != nil { return "", fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
return "", fmt.Errorf("failed to read TAT response: %w", err)
} }
var result struct { var result struct {
Code int `json:"code"` Code int `json:"code"`
AccessToken string `json:"access_token"` Msg string `json:"msg"`
Error string `json:"error"` TenantAccessToken string `json:"tenant_access_token"`
ErrorDescription string `json:"error_description"`
Msg string `json:"msg"`
} }
if err := json.Unmarshal(body, &result); err != nil { if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
// An unparseable body is ambiguous (covers non-JSON error pages and return "", fmt.Errorf("failed to parse TAT response: %w", err)
// truncated payloads); stay untyped so probe callers treat it as noise.
return "", fmt.Errorf("failed to parse TAT response (HTTP %d): %w", resp.StatusCode, err)
} }
if result.Code != 0 {
if result.Code == 0 && result.AccessToken != "" { return "", classifyTATResponseCode(result.Code, result.Msg, string(brand), appID)
return result.AccessToken, nil
} }
return result.TenantAccessToken, nil
// Transient/server-side failures stay untyped so probe callers stay silent and
// retryers can back off; only deterministic client rejections are typed. Covers
// 5xx, HTTP 429 rate-limit, and the OAuth transient error strings (server_error,
// temporarily_unavailable, slow_down) — matching the legacy "non-2xx is noise"
// behavior so a rate-limited probe is not surfaced as a hard credential error.
if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests ||
result.Error == "server_error" || result.Error == "temporarily_unavailable" ||
result.Error == "slow_down" {
return "", fmt.Errorf("TAT endpoint transient failure (HTTP %d, code=%d, error=%q): %s",
resp.StatusCode, result.Code, result.Error, result.ErrorDescription)
}
// A 2xx with neither token nor error is a malformed success — ambiguous, untyped.
if result.Code == 0 && result.Error == "" {
return "", fmt.Errorf("TAT response missing access_token (HTTP %d)", resp.StatusCode)
}
// Prefer the OAuth error_description; fall back to the legacy Lark `msg` so a
// gateway-level {code, msg} response (carrying no OAuth fields) still yields a
// non-empty typed message instead of a bare "API error: [code]".
desc := result.ErrorDescription
if desc == "" {
desc = result.Msg
}
return "", classifyTATResponseCode(result.Code, result.Error, desc, string(brand), appID)
} }

View File

@@ -44,7 +44,7 @@ func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error)
func TestFetchTAT_Success(t *testing.T) { func TestFetchTAT_Success(t *testing.T) {
rt := &stubRoundTripper{ rt := &stubRoundTripper{
respCode: 200, respCode: 200,
respBody: `{"code":0,"access_token":"t-abc","token_type":"Bearer","expires_in":7200}`, respBody: `{"code":0,"tenant_access_token":"t-abc","msg":"ok"}`,
} }
hc := &http.Client{Transport: rt} hc := &http.Client{Transport: rt}
@@ -55,33 +55,24 @@ func TestFetchTAT_Success(t *testing.T) {
if token != "t-abc" { if token != "t-abc" {
t.Errorf("token = %q, want t-abc", token) t.Errorf("token = %q, want t-abc", token)
} }
if rt.gotReq.URL.String() != "https://accounts.feishu.cn/oauth/v3/token" { if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" {
t.Errorf("url = %s", rt.gotReq.URL.String()) t.Errorf("url = %s", rt.gotReq.URL.String())
} }
if ct := rt.gotReq.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" { if !strings.Contains(rt.gotBody, `"app_id":"cli_app"`) || !strings.Contains(rt.gotBody, `"app_secret":"secret_x"`) {
t.Errorf("Content-Type = %q, want application/x-www-form-urlencoded", ct) t.Errorf("request body missing credentials: %s", rt.gotBody)
}
// client_secret_post: grant_type + client_id + client_secret in the form body.
for _, want := range []string{"grant_type=client_credentials", "client_id=cli_app", "client_secret=secret_x"} {
if !strings.Contains(rt.gotBody, want) {
t.Errorf("request body missing %q: %s", want, rt.gotBody)
}
} }
} }
// invalid_client (wrong app_id/app_secret on the client_credentials grant) is a // 10003 (bad / non-existent app_id, "invalid param") is classified locally by
// deterministic client-side rejection that FetchTAT routes to
// classifyTATResponseCode as CategoryConfig / SubtypeInvalidClient — the same // classifyTATResponseCode as CategoryConfig / SubtypeInvalidClient — the same
// typed error doResolveTAT (and thus every token-resolving command) returns. // typed error doResolveTAT (and thus every token-resolving command) returns.
// The v3 endpoint reports it as HTTP 400 with the OAuth2 error body (wrong func TestFetchTAT_Code10003_ConfigInvalidClient(t *testing.T) {
// secret → code 20002, unknown app → code 20048). rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10003,"msg":"invalid param"}`}
func TestFetchTAT_InvalidClient_ConfigInvalidClient(t *testing.T) {
rt := &stubRoundTripper{respCode: 400, respBody: `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`}
hc := &http.Client{Transport: rt} hc := &http.Client{Transport: rt}
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil { if err == nil {
t.Fatal("expected error for invalid_client") t.Fatal("expected error for code 10003")
} }
if token != "" { if token != "" {
t.Errorf("token = %q, want empty", token) t.Errorf("token = %q, want empty", token)
@@ -96,115 +87,52 @@ func TestFetchTAT_InvalidClient_ConfigInvalidClient(t *testing.T) {
if cfgErr.Subtype != errs.SubtypeInvalidClient { if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient) t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
} }
if cfgErr.Code != 10003 {
t.Errorf("Code = %d, want 10003", cfgErr.Code)
}
} }
// Any other deterministic client-side OAuth error (e.g. invalid_scope) still // 10014 ("app secret invalid") — the most common real-world rejection (real
// yields a typed error (errs.IsTyped) via BuildAPIError — so a probe caller // app_id + wrong secret) — is globally mapped in codemeta to
// surfaces it rather than silently swallowing it — but is NOT classified as a // CategoryConfig / SubtypeInvalidClient via BuildAPIError.
// credential (invalid_client) problem. func TestFetchTAT_Code10014_ConfigInvalidClient(t *testing.T) {
func TestFetchTAT_OtherClientError_Typed(t *testing.T) { rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10014,"msg":"app secret invalid"}`}
rt := &stubRoundTripper{respCode: 400, respBody: `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`}
hc := &http.Client{Transport: rt} hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") _, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected error for invalid_scope")
}
if !errs.IsTyped(err) {
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
}
var cfgErr *errs.ConfigError var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) { if !errors.As(err, &cfgErr) {
t.Errorf("invalid_scope must not be classified as ConfigError/InvalidClient, got %T", err) t.Fatalf("error not *errs.ConfigError: %T %v", err, err)
}
if cfgErr.Subtype != errs.SubtypeInvalidClient || cfgErr.Code != 10014 {
t.Errorf("got Subtype=%q Code=%d, want invalid_client/10014", cfgErr.Subtype, cfgErr.Code)
} }
} }
// A deterministic OAuth error that arrives WITHOUT a numeric code (code defaults to // Any non-zero body code is a deterministic server-side rejection, so it
// 0) must still surface as a non-nil typed error — never the ("", nil) success pair. // always yields a typed error (errs.IsTyped). An unrecognized code falls back
// Guards the code-0 backstop in classifyTATResponseCode: BuildAPIError returns nil // to CategoryAPI / SubtypeUnknown via BuildAPIError — still typed, so a probe
// for code 0, which would otherwise swallow this rejection into an empty-token success. // caller still surfaces it rather than silently swallowing.
func TestFetchTAT_OtherClientError_CodeZero_Typed(t *testing.T) { func TestFetchTAT_UnknownBodyCode_Typed(t *testing.T) {
rt := &stubRoundTripper{respCode: 400, respBody: `{"error":"invalid_scope","error_description":"the requested scope is not granted"}`} rt := &stubRoundTripper{respCode: 200, respBody: `{"code":99999,"msg":"future-unknown"}`}
hc := &http.Client{Transport: rt}
tok, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected non-nil error for code-0 invalid_scope (must not return empty token + nil error)")
}
if tok != "" {
t.Errorf("token = %q, want empty", tok)
}
if !errs.IsTyped(err) {
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
}
}
// A gateway-style {code, msg} error (no OAuth error / error_description fields)
// must still surface its msg on the typed error, not degrade to a generic
// "API error: [code]". Guards the legacy-msg fallback in FetchTAT.
func TestFetchTAT_LarkStyleMsg_FallsBackOnTypedError(t *testing.T) {
rt := &stubRoundTripper{respCode: 400, respBody: `{"code":99999,"msg":"app ticket invalid"}`}
hc := &http.Client{Transport: rt} hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") _, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil { if err == nil {
t.Fatal("expected error for {code, msg} response") t.Fatal("expected error for code 99999")
} }
if !errs.IsTyped(err) { if !errs.IsTyped(err) {
t.Fatalf("expected a typed errs.* error, got %T %v", err, err) t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
} }
if !strings.Contains(err.Error(), "app ticket invalid") { var apiErr *errs.APIError
t.Errorf("typed error must carry the Lark msg, got: %v", err) if !errors.As(err, &apiErr) {
t.Errorf("unknown code should fall back to *errs.APIError, got %T", err)
} }
} }
// Transient server-side failures (5xx / server_error) are NOT deterministic // Non-2xx HTTP is ambiguous (not a payload-level credential rejection) — it
// credential rejections — they must stay UNTYPED so a probe caller treats them // must stay UNTYPED so a probe caller treats it as upstream noise and stays
// as upstream noise and stays silent (and retryers can back off). // silent.
func TestFetchTAT_ServerError_Untyped(t *testing.T) {
rt := &stubRoundTripper{respCode: 500, respBody: `{"code":20050,"error":"server_error","error_description":"please retry"}`}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected error for server_error")
}
if errs.IsTyped(err) {
t.Errorf("server_error must be UNTYPED (transient), got typed %T %v", err, err)
}
}
// Rate-limiting is transient, not a deterministic credential rejection — an HTTP
// 429 (even with a parseable OAuth body) and the OAuth slow_down error must both
// stay UNTYPED so a rate-limited probe stays silent and retryers can back off.
func TestFetchTAT_RateLimit_Untyped(t *testing.T) {
cases := []struct {
name string
code int
body string
}{
{"http 429", 429, `{"code":99991400,"error":"too_many_requests","error_description":"rate limit exceeded"}`},
{"oauth slow_down", 200, `{"error":"slow_down","error_description":"polling too fast"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rt := &stubRoundTripper{respCode: tc.code, respBody: tc.body}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected error for rate-limit")
}
if errs.IsTyped(err) {
t.Errorf("rate-limit must be UNTYPED (transient), got typed %T %v", err, err)
}
})
}
}
// Non-2xx HTTP with a non-JSON body is ambiguous (not a structured OAuth
// rejection) — it must stay UNTYPED so a probe caller treats it as upstream
// noise and stays silent.
func TestFetchTAT_HTTPNon200_Untyped(t *testing.T) { func TestFetchTAT_HTTPNon200_Untyped(t *testing.T) {
for _, code := range []int{401, 403, 500, 503} { for _, code := range []int{401, 403, 500, 503} {
rt := &stubRoundTripper{respCode: code, respBody: `whatever`} rt := &stubRoundTripper{respCode: code, respBody: `whatever`}
@@ -254,12 +182,12 @@ func TestFetchTAT_BrandRouting(t *testing.T) {
brand core.LarkBrand brand core.LarkBrand
wantURL string wantURL string
}{ }{
{core.BrandFeishu, "https://accounts.feishu.cn/oauth/v3/token"}, {core.BrandFeishu, "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"},
{core.BrandLark, "https://accounts.larksuite.com/oauth/v3/token"}, {core.BrandLark, "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal"},
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(string(tc.brand), func(t *testing.T) { t.Run(string(tc.brand), func(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"access_token":"t","token_type":"Bearer"}`} rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"tenant_access_token":"t"}`}
hc := &http.Client{Transport: rt} hc := &http.Client{Transport: rt}
if _, err := FetchTAT(context.Background(), hc, tc.brand, "a", "b"); err != nil { if _, err := FetchTAT(context.Background(), hc, tc.brand, "a", "b"); err != nil {
t.Fatal(err) t.Fatal(err)

View File

@@ -65,7 +65,7 @@ var codeMeta = map[int]CodeMeta{
// CategoryConfig // CategoryConfig
99991543: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // RFC 6749 §5.2 — app_id / app_secret incorrect (Open API) 99991543: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // RFC 6749 §5.2 — app_id / app_secret incorrect (Open API)
10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // legacy TAT endpoint — "app secret invalid" (pre-v3 variant of 99991543; CLI now reports invalid_client) 10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // TAT endpoint — "app secret invalid" (TAT-mint variant of 99991543)
// CategoryPolicy // CategoryPolicy
21000: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeChallengeRequired}, 21000: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeChallengeRequired},

View File

@@ -262,23 +262,19 @@ func (b *Bus) handleConn(conn net.Conn) {
// handleHello registers a consume connection with the hub; reader carries bytes already pulled off conn. // handleHello registers a consume connection with the hub; reader carries bytes already pulled off conn.
func (b *Bus) handleHello(conn net.Conn, reader *bufio.Reader, hello *protocol.Hello) { func (b *Bus) handleHello(conn net.Conn, reader *bufio.Reader, hello *protocol.Hello) {
subID := hello.SubscriptionID bc := NewConn(conn, reader, hello.EventKey, hello.EventTypes, hello.PID)
if subID == "" {
subID = hello.EventKey
}
bc := NewConn(conn, reader, hello.EventKey, hello.EventTypes, hello.PID, subID)
bc.SetLogger(b.logger) bc.SetLogger(b.logger)
// Register + isFirst under one lock; blocks on any in-progress cleanup lock for the same EventKey. // Register + isFirst under one lock; blocks on any in-progress cleanup lock for the same EventKey.
firstForKey := b.hub.RegisterAndIsFirst(bc) firstForKey := b.hub.RegisterAndIsFirst(bc)
bc.SetCheckLastForKey(func(scope string) bool { bc.SetCheckLastForKey(func(eventKey string) bool {
return b.hub.AcquireCleanupLock(scope) return b.hub.AcquireCleanupLock(eventKey)
}) })
bc.SetOnClose(func(c *Conn) { bc.SetOnClose(func(c *Conn) {
b.hub.UnregisterAndIsLast(c) b.hub.UnregisterAndIsLast(c)
// Release is idempotent and must fire on every disconnect path so waiters don't block forever. // Release is idempotent and must fire on every disconnect path so waiters don't block forever.
b.hub.ReleaseCleanupLock(c.SubscriptionID()) b.hub.ReleaseCleanupLock(c.EventKey())
b.mu.Lock() b.mu.Lock()
delete(b.conns, c) delete(b.conns, c)
remaining := len(b.conns) remaining := len(b.conns)

View File

@@ -33,7 +33,7 @@ func TestRunShutdownWithMultipleConns(t *testing.T) {
server, client := net.Pipe() server, client := net.Pipe()
pipes = append(pipes, server, client) pipes = append(pipes, server, client)
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 1000+i, "") bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 1000+i)
bc.SetLogger(logger) bc.SetLogger(logger)
hub.RegisterAndIsFirst(bc) hub.RegisterAndIsFirst(bc)

View File

@@ -29,10 +29,9 @@ type Conn struct {
writeMu sync.Mutex // serialises all net.Conn writes (Encode+SetWriteDeadline is a 2-call sequence) writeMu sync.Mutex // serialises all net.Conn writes (Encode+SetWriteDeadline is a 2-call sequence)
eventKey string eventKey string
eventTypes []string eventTypes []string
subID string
pid int pid int
onClose func(*Conn) onClose func(*Conn)
checkLastForKey func(scope string) bool checkLastForKey func(eventKey string) bool
logger *log.Logger logger *log.Logger
closed chan struct{} closed chan struct{}
closeOnce sync.Once closeOnce sync.Once
@@ -42,7 +41,7 @@ type Conn struct {
} }
// NewConn creates a Conn; pass a reader with pre-buffered bytes (handoff from Bus.handleConn) or nil for a fresh one. // NewConn creates a Conn; pass a reader with pre-buffered bytes (handoff from Bus.handleConn) or nil for a fresh one.
func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes []string, pid int, subID string) *Conn { func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes []string, pid int) *Conn {
if reader == nil { if reader == nil {
reader = bufio.NewReader(conn) reader = bufio.NewReader(conn)
} }
@@ -53,20 +52,10 @@ func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes []
eventKey: eventKey, eventKey: eventKey,
eventTypes: eventTypes, eventTypes: eventTypes,
pid: pid, pid: pid,
subID: subID,
closed: make(chan struct{}), closed: make(chan struct{}),
} }
} }
// SubscriptionID returns the subscription identity. Falls back to EventKey
// when the stored subID is empty (legacy clients / no-SubscriptionKey EventKeys).
func (c *Conn) SubscriptionID() string {
if c.subID == "" {
return c.eventKey
}
return c.subID
}
func (c *Conn) SetOnClose(fn func(*Conn)) { c.onClose = fn } func (c *Conn) SetOnClose(fn func(*Conn)) { c.onClose = fn }
// SetCheckLastForKey: returning true means "you are the last subscriber, run cleanup". // SetCheckLastForKey: returning true means "you are the last subscriber, run cleanup".
@@ -143,19 +132,13 @@ func (c *Conn) ReaderLoop() {
} }
func (c *Conn) handleControlMessage(msg interface{}) { func (c *Conn) handleControlMessage(msg interface{}) {
switch msg.(type) { switch m := msg.(type) {
case *protocol.Bye: case *protocol.Bye:
c.shutdown() c.shutdown()
case *protocol.PreShutdownCheck: case *protocol.PreShutdownCheck:
// Use the connection's own authoritative subscription identity rather
// than recomputing from the incoming message: a stale or mismatched
// PreShutdownCheck must not ask about the wrong scope (which would
// suppress or mistrigger per-subscription cleanup). Conn.SubscriptionID()
// already falls back to EventKey when its stored subID is empty.
scope := c.SubscriptionID()
lastForKey := true lastForKey := true
if c.checkLastForKey != nil { if c.checkLastForKey != nil {
lastForKey = c.checkLastForKey(scope) lastForKey = c.checkLastForKey(m.EventKey)
} }
ack := protocol.NewPreShutdownAck(lastForKey) ack := protocol.NewPreShutdownAck(lastForKey)
if err := c.writeFrame(ack); err != nil && c.logger != nil { if err := c.writeFrame(ack); err != nil && c.logger != nil {

View File

@@ -21,7 +21,7 @@ func TestConn_SenderWritesEvents(t *testing.T) {
defer server.Close() defer server.Close()
defer client.Close() defer client.Close()
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 12345, "") bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 12345)
go bc.SenderLoop() go bc.SenderLoop()
bc.SendCh() <- &protocol.Event{ bc.SendCh() <- &protocol.Event{
@@ -62,7 +62,7 @@ func TestConn_ConcurrentWritesSerialised(t *testing.T) {
defer client.Close() defer client.Close()
det := &serializingDetector{Conn: server} det := &serializingDetector{Conn: server}
bc := NewConn(det, nil, "im.msg", []string{"im.msg"}, 12345, "") bc := NewConn(det, nil, "im.msg", []string{"im.msg"}, 12345)
go func() { _, _ = io.Copy(io.Discard, client) }() go func() { _, _ = io.Copy(io.Discard, client) }()
@@ -106,7 +106,7 @@ func TestConn_TrySend_NonEvicting(t *testing.T) {
server, client := net.Pipe() server, client := net.Pipe()
defer server.Close() defer server.Close()
defer client.Close() defer client.Close()
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345, "") bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345)
for i := 0; i < sendChCap; i++ { for i := 0; i < sendChCap; i++ {
if !bc.TrySend(i) { if !bc.TrySend(i) {
@@ -126,7 +126,7 @@ func TestConn_ReaderDetectsEOF(t *testing.T) {
server, client := net.Pipe() server, client := net.Pipe()
defer server.Close() defer server.Close()
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345, "") bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345)
done := make(chan struct{}) done := make(chan struct{})
go func() { go func() {
@@ -142,23 +142,3 @@ func TestConn_ReaderDetectsEOF(t *testing.T) {
t.Fatal("ReaderLoop did not exit on EOF") t.Fatal("ReaderLoop did not exit on EOF")
} }
} }
func TestConn_SubscriptionID(t *testing.T) {
c1, c2 := net.Pipe()
defer c1.Close()
defer c2.Close()
conn := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 999, "mail.x:abc")
if got := conn.SubscriptionID(); got != "mail.x:abc" {
t.Errorf("SubscriptionID() = %q, want %q", got, "mail.x:abc")
}
}
func TestConn_SubscriptionID_EmptyFallsBackToEventKey(t *testing.T) {
c1, c2 := net.Pipe()
defer c1.Close()
defer c2.Close()
conn := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 999, "")
if got := conn.SubscriptionID(); got != "mail.x" {
t.Errorf("SubscriptionID() with empty input = %q, want fallback %q", got, "mail.x")
}
}

View File

@@ -63,134 +63,3 @@ func TestHandleHello_HelloAckWriteFailureUnregisters(t *testing.T) {
t.Errorf("b.conns after failed HelloAck = %d entries, want 0", remaining) t.Errorf("b.conns after failed HelloAck = %d entries, want 0", remaining)
} }
} }
// TestHandleHello_LegacyClient_FallsBackToEventKey: a Hello with empty
// subscription_id registers under EventKey (today's behavior preserved).
func TestHandleHello_LegacyClient_FallsBackToEventKey(t *testing.T) {
logger := log.New(io.Discard, "", 0)
hub := NewHub()
b := &Bus{
hub: hub,
logger: logger,
conns: make(map[*Conn]struct{}),
idleTimer: time.NewTimer(30 * time.Second),
shutdownCh: make(chan struct{}, 1),
}
server, client := net.Pipe()
defer server.Close()
defer client.Close()
// Legacy client: no subscription_id field (empty string).
hello := &protocol.Hello{
PID: 9999,
EventKey: "im.message",
EventTypes: []string{"im.message.receive_v1"},
SubscriptionID: "", // legacy: empty, should fallback to EventKey
}
br := bufio.NewReader(server)
done := make(chan struct{})
go func() {
b.handleHello(server, br, hello)
close(done)
}()
// Read the HelloAck from client side to let handleHello complete.
clientReader := bufio.NewReader(client)
ackLine, err := clientReader.ReadString('\n')
if err != nil {
t.Fatalf("failed to read HelloAck: %v", err)
}
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("handleHello did not return within 3s")
}
// Assertions: registered under EventKey (not a qualified subscription ID).
if got := hub.ConnCount(); got != 1 {
t.Errorf("hub.ConnCount = %d, want 1", got)
}
if got := hub.EventKeyCount("im.message"); got != 1 {
t.Errorf("hub.EventKeyCount(im.message) = %d, want 1", got)
}
if got := hub.SubCount("im.message"); got != 1 {
t.Errorf("hub.SubCount(im.message) = %d, want 1 (legacy fallback to EventKey)", got)
}
if got := hub.SubCount("im.message:something"); got != 0 {
t.Errorf("hub.SubCount(im.message:something) = %d, want 0 (should not exist)", got)
}
if ackLine == "" {
t.Fatal("HelloAck was empty")
}
}
// TestHandleHello_ModernClient_UsesSubscriptionID: a Hello with
// non-empty subscription_id registers under that ID, not EventKey.
func TestHandleHello_ModernClient_UsesSubscriptionID(t *testing.T) {
logger := log.New(io.Discard, "", 0)
hub := NewHub()
b := &Bus{
hub: hub,
logger: logger,
conns: make(map[*Conn]struct{}),
idleTimer: time.NewTimer(30 * time.Second),
shutdownCh: make(chan struct{}, 1),
}
server, client := net.Pipe()
defer server.Close()
defer client.Close()
// Modern client: subscription_id explicitly set.
subscriptionID := "mail.message:alice@example.com"
hello := &protocol.Hello{
PID: 8888,
EventKey: "mail.message",
EventTypes: []string{"mail.message.receive_v1"},
SubscriptionID: subscriptionID, // modern: per-resource subscription
}
br := bufio.NewReader(server)
done := make(chan struct{})
go func() {
b.handleHello(server, br, hello)
close(done)
}()
// Read the HelloAck from client side to let handleHello complete.
clientReader := bufio.NewReader(client)
ackLine, err := clientReader.ReadString('\n')
if err != nil {
t.Fatalf("failed to read HelloAck: %v", err)
}
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("handleHello did not return within 3s")
}
// Assertions: registered under the subscription_id, not bare EventKey.
if got := hub.ConnCount(); got != 1 {
t.Errorf("hub.ConnCount = %d, want 1", got)
}
if got := hub.EventKeyCount("mail.message"); got != 1 {
t.Errorf("hub.EventKeyCount(mail.message) = %d, want 1", got)
}
if got := hub.SubCount(subscriptionID); got != 1 {
t.Errorf("hub.SubCount(%q) = %d, want 1 (modern: uses SubscriptionID)", subscriptionID, got)
}
if got := hub.SubCount("mail.message"); got != 0 {
t.Errorf("hub.SubCount(mail.message) = %d, want 0 (modern: NOT registered under bare EventKey)", got)
}
if ackLine == "" {
t.Fatal("HelloAck was empty")
}
}

View File

@@ -16,9 +16,6 @@ import (
// Subscriber is the interface a connection must satisfy for Hub registration. // Subscriber is the interface a connection must satisfy for Hub registration.
type Subscriber interface { type Subscriber interface {
EventKey() string EventKey() string
// SubscriptionID identifies the per-resource subscription for dedup purposes.
// When no resource qualifier is needed it equals EventKey.
SubscriptionID() string
EventTypes() []string EventTypes() []string
SendCh() chan interface{} SendCh() chan interface{}
PID() int PID() int
@@ -37,11 +34,8 @@ type Subscriber interface {
type Hub struct { type Hub struct {
mu sync.RWMutex mu sync.RWMutex
subscribers map[Subscriber]struct{} subscribers map[Subscriber]struct{}
// subCounts is keyed by SubscriptionID (not EventKey) so that different keyCounts map[string]int
// per-resource subscriptions sharing the same EventKey are deduped independently. // cleanupInProgress[key] holds a channel closed on release; presence means a cleanup lock is held.
subCounts map[string]int
// cleanupInProgress[subscriptionID] holds a channel closed on release;
// presence means a cleanup lock is held for that subscription.
cleanupInProgress map[string]chan struct{} cleanupInProgress map[string]chan struct{}
logger atomic.Pointer[log.Logger] logger atomic.Pointer[log.Logger]
} }
@@ -49,7 +43,7 @@ type Hub struct {
func NewHub() *Hub { func NewHub() *Hub {
return &Hub{ return &Hub{
subscribers: make(map[Subscriber]struct{}), subscribers: make(map[Subscriber]struct{}),
subCounts: make(map[string]int), keyCounts: make(map[string]int),
cleanupInProgress: make(map[string]chan struct{}), cleanupInProgress: make(map[string]chan struct{}),
} }
} }
@@ -57,7 +51,7 @@ func NewHub() *Hub {
// SetLogger attaches a logger (nil tolerated). // SetLogger attaches a logger (nil tolerated).
func (h *Hub) SetLogger(l *log.Logger) { h.logger.Store(l) } func (h *Hub) SetLogger(l *log.Logger) { h.logger.Store(l) }
// UnregisterAndIsLast removes s and reports whether it was last for its SubscriptionID; stale unregisters are no-ops. // UnregisterAndIsLast removes s and reports whether it was last for its EventKey; stale unregisters are no-ops.
func (h *Hub) UnregisterAndIsLast(s Subscriber) bool { func (h *Hub) UnregisterAndIsLast(s Subscriber) bool {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
@@ -65,35 +59,34 @@ func (h *Hub) UnregisterAndIsLast(s Subscriber) bool {
return false return false
} }
delete(h.subscribers, s) delete(h.subscribers, s)
sid := s.SubscriptionID() h.keyCounts[s.EventKey()]--
h.subCounts[sid]-- isLast := h.keyCounts[s.EventKey()] == 0
isLast := h.subCounts[sid] == 0
if isLast { if isLast {
delete(h.subCounts, sid) delete(h.keyCounts, s.EventKey())
} }
return isLast return isLast
} }
// AcquireCleanupLock reserves cleanup rights iff exactly one subscriber exists for subscriptionID and no lock is held. // AcquireCleanupLock reserves cleanup rights iff exactly one subscriber exists for eventKey and no lock is held.
// Count==0 is rejected (would block future Register calls). On true return, caller MUST Release. // Count==0 is rejected (would block future Register calls). On true return, caller MUST Release.
func (h *Hub) AcquireCleanupLock(subscriptionID string) bool { func (h *Hub) AcquireCleanupLock(eventKey string) bool {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
if h.subCounts[subscriptionID] != 1 { if h.keyCounts[eventKey] != 1 {
return false return false
} }
if _, alreadyLocked := h.cleanupInProgress[subscriptionID]; alreadyLocked { if _, alreadyLocked := h.cleanupInProgress[eventKey]; alreadyLocked {
return false return false
} }
h.cleanupInProgress[subscriptionID] = make(chan struct{}) h.cleanupInProgress[eventKey] = make(chan struct{})
return true return true
} }
// ReleaseCleanupLock is idempotent; OnClose calls unconditionally. // ReleaseCleanupLock is idempotent; OnClose calls unconditionally.
func (h *Hub) ReleaseCleanupLock(subscriptionID string) { func (h *Hub) ReleaseCleanupLock(eventKey string) {
h.mu.Lock() h.mu.Lock()
ch := h.cleanupInProgress[subscriptionID] ch := h.cleanupInProgress[eventKey]
delete(h.cleanupInProgress, subscriptionID) delete(h.cleanupInProgress, eventKey)
h.mu.Unlock() h.mu.Unlock()
if ch != nil { if ch != nil {
close(ch) close(ch)
@@ -101,24 +94,23 @@ func (h *Hub) ReleaseCleanupLock(subscriptionID string) {
} }
// RegisterAndIsFirst adds s to the hub and reports whether it's the first // RegisterAndIsFirst adds s to the hub and reports whether it's the first
// subscriber for its SubscriptionID. If a cleanup is in progress for // subscriber for its EventKey. If a cleanup is in progress for
// s.SubscriptionID() (another conn holds the cleanup lock), this waits until // s.EventKey() (another conn holds the cleanup lock), this waits until
// cleanup releases before registering — closing the PreShutdownCheck × // cleanup releases before registering — closing the PreShutdownCheck ×
// Hello TOCTOU race. The wait releases h.mu before blocking on the // Hello TOCTOU race. The wait releases h.mu before blocking on the
// channel, so concurrent operations on other subscriptions aren't stalled. // channel, so concurrent operations on other keys aren't stalled.
func (h *Hub) RegisterAndIsFirst(s Subscriber) bool { func (h *Hub) RegisterAndIsFirst(s Subscriber) bool {
sid := s.SubscriptionID()
for { for {
h.mu.Lock() h.mu.Lock()
ch, locked := h.cleanupInProgress[sid] ch, locked := h.cleanupInProgress[s.EventKey()]
if locked { if locked {
h.mu.Unlock() h.mu.Unlock()
<-ch // wait for release, then re-check (defensive against races) <-ch // wait for release, then re-check (defensive against races)
continue continue
} }
isFirst := h.subCounts[sid] == 0 isFirst := h.keyCounts[s.EventKey()] == 0
h.subscribers[s] = struct{}{} h.subscribers[s] = struct{}{}
h.subCounts[sid]++ h.keyCounts[s.EventKey()]++
h.mu.Unlock() h.mu.Unlock()
return isFirst return isFirst
} }
@@ -184,25 +176,11 @@ func (h *Hub) ConnCount() int {
return len(h.subscribers) return len(h.subscribers)
} }
// EventKeyCount returns total subscribers for the given EventKey, aggregating // EventKeyCount returns the number of subscribers registered for eventKey.
// across all SubscriptionIDs. For per-subscription counts use SubCount.
func (h *Hub) EventKeyCount(eventKey string) int { func (h *Hub) EventKeyCount(eventKey string) int {
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()
count := 0 return h.keyCounts[eventKey]
for s := range h.subscribers {
if s.EventKey() == eventKey {
count++
}
}
return count
}
// SubCount returns the count of subscribers for the given SubscriptionID.
func (h *Hub) SubCount(subscriptionID string) int {
h.mu.RLock()
defer h.mu.RUnlock()
return h.subCounts[subscriptionID]
} }
// BroadcastSourceStatus fans out a source-level status change to every // BroadcastSourceStatus fans out a source-level status change to every
@@ -227,11 +205,10 @@ func (h *Hub) Consumers() []protocol.ConsumerInfo {
result := make([]protocol.ConsumerInfo, 0, len(h.subscribers)) result := make([]protocol.ConsumerInfo, 0, len(h.subscribers))
for s := range h.subscribers { for s := range h.subscribers {
result = append(result, protocol.ConsumerInfo{ result = append(result, protocol.ConsumerInfo{
PID: s.PID(), PID: s.PID(),
EventKey: s.EventKey(), EventKey: s.EventKey(),
SubscriptionID: s.SubscriptionID(), Received: s.Received(),
Received: s.Received(), Dropped: s.DroppedCount(),
Dropped: s.DroppedCount(),
}) })
} }
return result return result

View File

@@ -17,7 +17,7 @@ func TestHubDroppedCountIncrements(t *testing.T) {
server, client := testNetPipe(t) server, client := testNetPipe(t)
defer server.Close() defer server.Close()
defer client.Close() defer client.Close()
c := NewConn(server, nil, "k", []string{"t"}, 1, "") c := NewConn(server, nil, "k", []string{"t"}, 1)
c.sendCh = make(chan interface{}, 1) c.sendCh = make(chan interface{}, 1)
h.RegisterAndIsFirst(c) h.RegisterAndIsFirst(c)
@@ -35,7 +35,7 @@ func TestPublishAssignsIncrementalSeq(t *testing.T) {
server, client := testNetPipe(t) server, client := testNetPipe(t)
defer server.Close() defer server.Close()
defer client.Close() defer client.Close()
c := NewConn(server, nil, "k", []string{"t"}, 1, "") c := NewConn(server, nil, "k", []string{"t"}, 1)
c.sendCh = make(chan interface{}, 10) c.sendCh = make(chan interface{}, 10)
h.RegisterAndIsFirst(c) h.RegisterAndIsFirst(c)
@@ -60,7 +60,7 @@ func TestPublishPopulatesEventIDAndSourceTime(t *testing.T) {
server, client := testNetPipe(t) server, client := testNetPipe(t)
defer server.Close() defer server.Close()
defer client.Close() defer client.Close()
c := NewConn(server, nil, "k", []string{"t"}, 1, "") c := NewConn(server, nil, "k", []string{"t"}, 1)
c.sendCh = make(chan interface{}, 1) c.sendCh = make(chan interface{}, 1)
h.RegisterAndIsFirst(c) h.RegisterAndIsFirst(c)
@@ -87,7 +87,7 @@ func TestPublishSourceTimeTakesPrecedence(t *testing.T) {
server, client := testNetPipe(t) server, client := testNetPipe(t)
defer server.Close() defer server.Close()
defer client.Close() defer client.Close()
c := NewConn(server, nil, "k", []string{"t"}, 1, "") c := NewConn(server, nil, "k", []string{"t"}, 1)
c.sendCh = make(chan interface{}, 1) c.sendCh = make(chan interface{}, 1)
h.RegisterAndIsFirst(c) h.RegisterAndIsFirst(c)
@@ -111,7 +111,7 @@ func TestPublishSourceTimeFallback(t *testing.T) {
server, client := testNetPipe(t) server, client := testNetPipe(t)
defer server.Close() defer server.Close()
defer client.Close() defer client.Close()
c := NewConn(server, nil, "k", []string{"t"}, 1, "") c := NewConn(server, nil, "k", []string{"t"}, 1)
c.sendCh = make(chan interface{}, 1) c.sendCh = make(chan interface{}, 1)
h.RegisterAndIsFirst(c) h.RegisterAndIsFirst(c)

View File

@@ -111,7 +111,6 @@ type alwaysFailSubscriber struct {
} }
func (s *alwaysFailSubscriber) EventKey() string { return s.eventKey } func (s *alwaysFailSubscriber) EventKey() string { return s.eventKey }
func (s *alwaysFailSubscriber) SubscriptionID() string { return s.eventKey }
func (s *alwaysFailSubscriber) EventTypes() []string { return s.eventTypes } func (s *alwaysFailSubscriber) EventTypes() []string { return s.eventTypes }
func (s *alwaysFailSubscriber) SendCh() chan interface{} { return s.sendCh } func (s *alwaysFailSubscriber) SendCh() chan interface{} { return s.sendCh }
func (s *alwaysFailSubscriber) PID() int { return 0 } func (s *alwaysFailSubscriber) PID() int { return 0 }
@@ -154,7 +153,6 @@ func newRaceSubscriber(key string, types []string, capacity int) *raceSubscriber
} }
func (s *raceSubscriber) EventKey() string { return s.eventKey } func (s *raceSubscriber) EventKey() string { return s.eventKey }
func (s *raceSubscriber) SubscriptionID() string { return s.eventKey }
func (s *raceSubscriber) EventTypes() []string { return s.eventTypes } func (s *raceSubscriber) EventTypes() []string { return s.eventTypes }
func (s *raceSubscriber) SendCh() chan interface{} { return s.sendCh } func (s *raceSubscriber) SendCh() chan interface{} { return s.sendCh }
func (s *raceSubscriber) PID() int { return s.pid } func (s *raceSubscriber) PID() int { return s.pid }

View File

@@ -5,7 +5,6 @@ package bus
import ( import (
"encoding/json" "encoding/json"
"net"
"sync" "sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
@@ -236,10 +235,7 @@ func newTestConn(eventKey string, eventTypes []string) *testConn {
} }
} }
func (c *testConn) EventKey() string { return c.eventKey } func (c *testConn) EventKey() string { return c.eventKey }
// SubscriptionID falls back to EventKey for test mocks that don't set a separate subscription ID.
func (c *testConn) SubscriptionID() string { return c.eventKey }
func (c *testConn) EventTypes() []string { return c.eventTypes } func (c *testConn) EventTypes() []string { return c.eventTypes }
func (c *testConn) SendCh() chan interface{} { return c.sendCh } func (c *testConn) SendCh() chan interface{} { return c.sendCh }
func (c *testConn) PID() int { return c.pid } func (c *testConn) PID() int { return c.pid }
@@ -279,79 +275,3 @@ func (c *testConn) TrySend(msg interface{}) bool {
return false return false
} }
} }
func TestHub_SubscriptionID_Isolation(t *testing.T) {
h := NewHub()
c1, _ := net.Pipe()
c2, _ := net.Pipe()
defer c1.Close()
defer c2.Close()
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:bob")
if !h.RegisterAndIsFirst(s1) {
t.Error("s1 should be first for its subscription")
}
if !h.RegisterAndIsFirst(s2) {
t.Error("s2 should ALSO be first (different SubscriptionID)")
}
if !h.UnregisterAndIsLast(s1) {
t.Error("s1 should be last for mail.x:alice")
}
if !h.UnregisterAndIsLast(s2) {
t.Error("s2 should be last for mail.x:bob")
}
}
func TestHub_SameSubscriptionID_NotFirst(t *testing.T) {
h := NewHub()
c1, _ := net.Pipe()
c2, _ := net.Pipe()
defer c1.Close()
defer c2.Close()
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:alice")
if !h.RegisterAndIsFirst(s1) {
t.Error("s1 first")
}
if h.RegisterAndIsFirst(s2) {
t.Error("s2 same SubscriptionID should NOT be first")
}
}
func TestHub_EventKeyCount_AggregatesAcrossSubscriptions(t *testing.T) {
h := NewHub()
c1, _ := net.Pipe()
c2, _ := net.Pipe()
defer c1.Close()
defer c2.Close()
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:bob")
h.RegisterAndIsFirst(s1)
h.RegisterAndIsFirst(s2)
if got := h.EventKeyCount("mail.x"); got != 2 {
t.Errorf("EventKeyCount(mail.x) = %d, want 2 (aggregated across subscriptions)", got)
}
if got := h.SubCount("mail.x:alice"); got != 1 {
t.Errorf("SubCount(mail.x:alice) = %d, want 1", got)
}
if got := h.SubCount("mail.x:bob"); got != 1 {
t.Errorf("SubCount(mail.x:bob) = %d, want 1", got)
}
}
func TestHub_Consumers_PopulatesSubscriptionID(t *testing.T) {
h := NewHub()
c1, _ := net.Pipe()
defer c1.Close()
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
h.RegisterAndIsFirst(s1)
consumers := h.Consumers()
if len(consumers) != 1 {
t.Fatalf("got %d consumers, want 1", len(consumers))
}
if consumers[0].SubscriptionID != "mail.x:alice" {
t.Errorf("Consumers()[0].SubscriptionID = %q, want %q", consumers[0].SubscriptionID, "mail.x:alice")
}
}

View File

@@ -14,7 +14,6 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/transport" "github.com/larksuite/cli/internal/event/transport"
) )
@@ -45,9 +44,7 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
keyDef, ok := event.Lookup(opts.EventKey) keyDef, ok := event.Lookup(opts.EventKey)
if !ok { if !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, return fmt.Errorf("unknown EventKey: %s\nRun 'lark-cli event list' to see available keys", opts.EventKey)
"unknown EventKey: %s", opts.EventKey).
WithHint("run `lark-cli event list` to see available keys")
} }
if err := validateParams(keyDef, opts.Params); err != nil { if err := validateParams(keyDef, opts.Params); err != nil {
@@ -61,22 +58,6 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
} }
} }
// Normalize params (resolve aliases like "me" -> real email) before fingerprint
// compute, PreConsume, Match, Process. Must happen BEFORE doHello so the
// SubscriptionID we send to bus reflects canonical values.
if keyDef.NormalizeParams != nil {
if err := keyDef.NormalizeParams(ctx, opts.Runtime, opts.Params); err != nil {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeUnknown,
"normalize params for %s: %s", opts.EventKey, err).WithCause(err)
}
}
// Compute subscription identity from normalized params + SubscriptionKey flags.
subscriptionID := ComputeSubscriptionID(keyDef, opts.Params)
if opts.Timeout > 0 { if opts.Timeout > 0 {
var cancel context.CancelFunc var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, opts.Timeout) ctx, cancel = context.WithTimeout(ctx, opts.Timeout)
@@ -97,24 +78,19 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
} }
defer conn.Close() defer conn.Close()
ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType}, subscriptionID) ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType})
if err != nil { if err != nil {
return errs.NewInternalError(errs.SubtypeUnknown, return fmt.Errorf("handshake failed: %w", err)
"event bus handshake failed: %s", err).WithCause(err)
} }
var cleanup func() error var cleanup func()
if ack.FirstForKey && keyDef.PreConsume != nil { if ack.FirstForKey && keyDef.PreConsume != nil {
if !opts.Quiet { if !opts.Quiet {
fmt.Fprintf(errOut, "[event] running pre-consume setup...\n") fmt.Fprintf(errOut, "[event] running pre-consume setup...\n")
} }
cleanup, err = keyDef.PreConsume(ctx, opts.Runtime, opts.Params) cleanup, err = keyDef.PreConsume(ctx, opts.Runtime, opts.Params)
if err != nil { if err != nil {
if _, ok := errs.ProblemOf(err); ok { return fmt.Errorf("pre-consume failed: %w", err)
return err
}
return errs.NewInternalError(errs.SubtypeUnknown,
"pre-consume failed: %s", err).WithCause(err)
} }
} }
@@ -129,22 +105,14 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
if cleanup != nil { if cleanup != nil {
switch { switch {
case r != nil: case r != nil:
fmt.Fprintf(errOut, fmt.Fprintf(errOut, "WARN: panic recovered; running cleanup unconditionally (may affect other consumers of %s)\n", opts.EventKey)
"WARN: panic recovered; running cleanup unconditionally (may affect other consumers of %s)\n", cleanup()
opts.EventKey)
if cleanupErr := cleanup(); cleanupErr != nil {
fmt.Fprintf(errOut,
"WARN: cleanup also failed during panic recovery: %v\n", cleanupErr)
}
case lastForKey: case lastForKey:
if !opts.Quiet { if !opts.Quiet {
fmt.Fprintf(errOut, "[event] running cleanup...\n") fmt.Fprintf(errOut, "[event] running cleanup...\n")
} }
if cleanupErr := cleanup(); cleanupErr != nil { cleanup()
fmt.Fprintf(errOut, if !opts.Quiet {
"WARN: cleanup failed: %v (server-side subscribe is idempotent — residual record will be overwritten on next subscribe)\n",
cleanupErr)
} else if !opts.Quiet {
fmt.Fprintf(errOut, "[event] cleanup done.\n") fmt.Fprintf(errOut, "[event] cleanup done.\n")
} }
} }
@@ -168,7 +136,7 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
writeReadyMarker(errOut, opts) writeReadyMarker(errOut, opts)
return consumeLoop(ctx, conn, br, keyDef, opts, subscriptionID, &lastForKey, &emitted) return consumeLoop(ctx, conn, br, keyDef, opts, &lastForKey, &emitted)
} }
func truncateDuration(d time.Duration) time.Duration { func truncateDuration(d time.Duration) time.Duration {
@@ -184,10 +152,8 @@ func validateParams(def *event.KeyDefinition, params map[string]string) error {
for _, p := range def.Params { for _, p := range def.Params {
if p.Required { if p.Required {
if _, ok := params[p.Name]; !ok { if _, ok := params[p.Name]; !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, return fmt.Errorf("required param %q missing for EventKey %s. Run 'lark-cli event schema %s' for details",
"required param %q missing for EventKey %s", p.Name, def.Key). p.Name, def.Key, def.Key)
WithParam("--param").
WithHint("pass it as --param %s=<value>; run `lark-cli event schema %s` for details", p.Name, def.Key)
} }
} }
} }
@@ -203,15 +169,11 @@ func validateParams(def *event.KeyDefinition, params map[string]string) error {
continue continue
} }
if len(validNames) == 0 { if len(validNames) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, return fmt.Errorf("unknown param %q: EventKey %s accepts no params. Run 'lark-cli event schema %s' for details",
"unknown param %q: EventKey %s accepts no params", k, def.Key). k, def.Key, def.Key)
WithParam("--param").
WithHint("run `lark-cli event schema %s` for details", def.Key)
} }
return errs.NewValidationError(errs.SubtypeInvalidArgument, return fmt.Errorf("unknown param %q for EventKey %s. valid params: %s. Run 'lark-cli event schema %s' for details",
"unknown param %q for EventKey %s. valid params: %s", k, def.Key, strings.Join(validNames, ", ")). k, def.Key, strings.Join(validNames, ", "), def.Key)
WithParam("--param").
WithHint("run `lark-cli event schema %s` for details", def.Key)
} }
return nil return nil
} }

View File

@@ -1,101 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"net"
"strings"
"testing"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/protocol"
"github.com/larksuite/cli/internal/event/transport"
)
// fakeRT is a minimal event.APIClient mock.
type fakeRT struct {
err error
}
func (f *fakeRT) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
return nil, f.err
}
func TestNormalizeParams_ErrorIsWrappedWithEventKey(t *testing.T) {
// Drives the real Run() path: NormalizeParams fails before EnsureBus, so no
// bus is contacted, yet the production error-wrapping is exercised — if Run()
// ever stops wrapping, this test fails.
const key = "test.evt_normalize_fail"
event.RegisterKey(event.KeyDefinition{
Key: key,
EventType: key,
Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}},
NormalizeParams: func(_ context.Context, _ event.APIClient, _ map[string]string) error {
return errors.New("simulated normalize failure")
},
})
defer event.UnregisterKeyForTest(key)
err := Run(context.Background(), transport.New(), "app", "", "", Options{
EventKey: key,
Runtime: &fakeRT{},
Quiet: true,
})
if err == nil {
t.Fatal("expected Run to fail when NormalizeParams errors")
}
if !strings.Contains(err.Error(), "normalize params for "+key+":") {
t.Errorf("error not wrapped with EventKey prefix: %v", err)
}
if !strings.Contains(err.Error(), "simulated normalize failure") {
t.Errorf("underlying error not propagated: %v", err)
}
}
func TestDoHello_PassesSubscriptionIDToWire(t *testing.T) {
a, b := net.Pipe()
defer a.Close()
defer b.Close()
// Server-side: read Hello, decode, assert SubscriptionID, send ack
done := make(chan string, 1)
go func() {
br := bufio.NewReader(b)
line, err := protocol.ReadFrame(br)
if err != nil {
done <- "READ_ERR:" + err.Error()
return
}
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
if err != nil {
done <- "DECODE_ERR:" + err.Error()
return
}
if hello, ok := msg.(*protocol.Hello); ok {
done <- hello.SubscriptionID
// send ack so client can return
ack := protocol.NewHelloAck("v1", true)
_ = protocol.EncodeWithDeadline(b, ack, protocol.WriteTimeout)
} else {
done <- "WRONG_TYPE"
}
}()
ack, _, err := doHello(a, "mail.x", []string{"mail.x"}, "mail.x:alice")
if err != nil {
t.Fatalf("doHello error: %v", err)
}
if ack == nil {
t.Fatal("got nil ack")
}
got := <-done
if got != "mail.x:alice" {
t.Errorf("Hello.SubscriptionID on wire = %q, want %q", got, "mail.x:alice")
}
}

View File

@@ -1,41 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"sort"
"github.com/larksuite/cli/internal/event"
)
// ComputeSubscriptionID returns a stable identifier scoped to (EventKey, values
// of the ParamDefs marked SubscriptionKey); the framework uses it to dedup
// PreConsume/cleanup gates and key Hub counts per-subscription. No SubscriptionKey
// params -> returns def.Key verbatim (legacy one-dimensional behavior).
//
// Stability contract: same EventKey + same normalized param values -> same ID
// across CLI versions; changing the encoding requires a wire-format bump.
func ComputeSubscriptionID(def *event.KeyDefinition, params map[string]string) string {
type kv struct {
Name string `json:"name"`
Value string `json:"value"`
}
var subParams []kv
for _, p := range def.Params {
if !p.SubscriptionKey {
continue
}
subParams = append(subParams, kv{Name: p.Name, Value: params[p.Name]})
}
if len(subParams) == 0 {
return def.Key
}
sort.Slice(subParams, func(i, j int) bool { return subParams[i].Name < subParams[j].Name })
raw, _ := json.Marshal(subParams) // err impossible: kv has no unmarshalable fields
sum := sha256.Sum256(raw)
return def.Key + ":" + base64.RawURLEncoding.EncodeToString(sum[:12])
}

View File

@@ -1,126 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/event"
)
func TestComputeSubscriptionID(t *testing.T) {
makeDef := func(subKeyNames ...string) *event.KeyDefinition {
def := &event.KeyDefinition{Key: "test.evt"}
marked := make(map[string]bool, len(subKeyNames))
for _, n := range subKeyNames {
marked[n] = true
}
for _, n := range []string{"alpha", "beta", "gamma"} {
def.Params = append(def.Params, event.ParamDef{Name: n, SubscriptionKey: marked[n]})
}
return def
}
t.Run("no SubscriptionKey params returns EventKey verbatim", func(t *testing.T) {
def := makeDef()
got := ComputeSubscriptionID(def, map[string]string{"alpha": "x", "beta": "y"})
if got != "test.evt" {
t.Errorf("got %q, want %q", got, "test.evt")
}
})
t.Run("single SubscriptionKey param: non-sub params do not leak into ID", func(t *testing.T) {
def := makeDef("alpha")
id1 := ComputeSubscriptionID(def, map[string]string{"alpha": "value1", "beta": "ignored"})
id2 := ComputeSubscriptionID(def, map[string]string{"alpha": "value1", "beta": "different"})
if id1 != id2 {
t.Errorf("non-SubscriptionKey param change leaked into ID: %q vs %q", id1, id2)
}
})
t.Run("different SubscriptionKey value produces different ID", func(t *testing.T) {
def := makeDef("alpha")
id1 := ComputeSubscriptionID(def, map[string]string{"alpha": "v1"})
id2 := ComputeSubscriptionID(def, map[string]string{"alpha": "v2"})
if id1 == id2 {
t.Errorf("different values produced same ID: %q", id1)
}
})
}
func TestComputeSubscriptionID_Stability(t *testing.T) {
// Param order in the ParamDef list must not affect the result (sorted by name internally).
def1 := &event.KeyDefinition{
Key: "test.evt",
Params: []event.ParamDef{
{Name: "b", SubscriptionKey: true},
{Name: "a", SubscriptionKey: true},
},
}
def2 := &event.KeyDefinition{
Key: "test.evt",
Params: []event.ParamDef{
{Name: "a", SubscriptionKey: true},
{Name: "b", SubscriptionKey: true},
},
}
id1 := ComputeSubscriptionID(def1, map[string]string{"a": "1", "b": "2"})
id2 := ComputeSubscriptionID(def2, map[string]string{"a": "1", "b": "2"})
if id1 != id2 {
t.Errorf("order-sensitive: id1=%q id2=%q", id1, id2)
}
}
func TestComputeSubscriptionID_Format(t *testing.T) {
def := &event.KeyDefinition{
Key: "mail.user_mailbox.event.message_received_v1",
Params: []event.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
}
id := ComputeSubscriptionID(def, map[string]string{"mailbox": "liuxinyang@example.com"})
prefix := "mail.user_mailbox.event.message_received_v1:"
if !strings.HasPrefix(id, prefix) {
t.Fatalf("missing prefix: %q", id)
}
suffix := strings.TrimPrefix(id, prefix)
if len(suffix) != 16 {
t.Errorf("fingerprint length = %d, want 16", len(suffix))
}
for _, c := range suffix {
isValid := (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_'
if !isValid {
t.Errorf("non-base64URL char in fingerprint: %q", suffix)
break
}
}
}
func TestComputeSubscriptionID_UnicodeAndSpecialChars(t *testing.T) {
def := &event.KeyDefinition{
Key: "test.evt",
Params: []event.ParamDef{{Name: "value", SubscriptionKey: true}},
}
for _, val := range []string{"中文", "emoji🚀", "with spaces", "with:colons", "with\"quotes"} {
id := ComputeSubscriptionID(def, map[string]string{"value": val})
if !strings.HasPrefix(id, "test.evt:") || len(id) != len("test.evt:")+16 {
t.Errorf("ID malformed for value=%q: %q (len=%d)", val, id, len(id))
}
}
}
func TestComputeSubscriptionID_EmptyValue(t *testing.T) {
def := &event.KeyDefinition{
Key: "test.evt",
Params: []event.ParamDef{{Name: "x", SubscriptionKey: true}},
}
id1 := ComputeSubscriptionID(def, map[string]string{"x": ""})
id2 := ComputeSubscriptionID(def, map[string]string{}) // missing entirely
if id1 != id2 {
t.Errorf("empty value should be indistinguishable from missing: %q vs %q", id1, id2)
}
id3 := ComputeSubscriptionID(def, map[string]string{"x": "nonempty"})
if id1 == id3 {
t.Errorf("empty and nonempty produced same ID: %q", id1)
}
}

View File

@@ -18,8 +18,8 @@ const helloAckTimeout = 5 * time.Second // symmetric with bus-side hello read de
// doHello returns a bufio.Reader holding any bytes already pulled off conn so events // doHello returns a bufio.Reader holding any bytes already pulled off conn so events
// buffered with the ack in one TCP segment aren't dropped. // buffered with the ack in one TCP segment aren't dropped.
func doHello(conn net.Conn, eventKey string, eventTypes []string, subscriptionID string) (*protocol.HelloAck, *bufio.Reader, error) { func doHello(conn net.Conn, eventKey string, eventTypes []string) (*protocol.HelloAck, *bufio.Reader, error) {
hello := protocol.NewHello(os.Getpid(), eventKey, eventTypes, "v1", subscriptionID) hello := protocol.NewHello(os.Getpid(), eventKey, eventTypes, "v1")
if err := protocol.EncodeWithDeadline(conn, hello, protocol.WriteTimeout); err != nil { if err := protocol.EncodeWithDeadline(conn, hello, protocol.WriteTimeout); err != nil {
return nil, nil, err return nil, nil, err
} }

View File

@@ -27,7 +27,7 @@ func TestDoHello_ReadDeadline(t *testing.T) {
start := time.Now() start := time.Now()
done := make(chan error, 1) done := make(chan error, 1)
go func() { go func() {
_, _, err := doHello(client, "im.msg", []string{"im.msg"}, "") _, _, err := doHello(client, "im.msg", []string{"im.msg"})
done <- err done <- err
}() }()

View File

@@ -8,21 +8,17 @@ import (
"fmt" "fmt"
"github.com/itchyny/gojq" "github.com/itchyny/gojq"
"github.com/larksuite/cli/errs"
) )
// CompileJQ compiles once for hot-path reuse; exported so callers can preflight before side effects. // CompileJQ compiles once for hot-path reuse; exported so callers can preflight before side effects.
func CompileJQ(expr string) (*gojq.Code, error) { func CompileJQ(expr string) (*gojq.Code, error) {
query, err := gojq.Parse(expr) query, err := gojq.Parse(expr)
if err != nil { if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, return nil, fmt.Errorf("invalid jq expression: %w", err)
"invalid jq expression: %s", err).WithParam("--jq").WithCause(err)
} }
code, err := gojq.Compile(query) code, err := gojq.Compile(query)
if err != nil { if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, return nil, fmt.Errorf("jq compile error: %w", err)
"jq compile error: %s", err).WithParam("--jq").WithCause(err)
} }
return code, nil return code, nil
} }

View File

@@ -22,7 +22,7 @@ import (
) )
// consumeLoop reads events and dispatches to workers; cancels on terminal sink errors. // consumeLoop reads events and dispatches to workers; cancels on terminal sink errors.
func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *event.KeyDefinition, opts Options, subscriptionID string, lastForKey *bool, emitted *atomic.Int64) error { func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *event.KeyDefinition, opts Options, lastForKey *bool, emitted *atomic.Int64) error {
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
defer cancel() defer cancel()
@@ -185,7 +185,7 @@ func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *e
close(stopReader) close(stopReader)
<-readerDone <-readerDone
conn.SetReadDeadline(time.Time{}) conn.SetReadDeadline(time.Time{})
*lastForKey = checkLastForKey(conn, opts.EventKey, subscriptionID) *lastForKey = checkLastForKey(conn, opts.EventKey)
conn.Close() conn.Close()
case <-allDone: case <-allDone:
// bus-side close; can't query, assume last // bus-side close; can't query, assume last
@@ -199,19 +199,13 @@ func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *e
// processAndOutput returns (wrote, err); err non-nil only for sink.Write failures. // processAndOutput returns (wrote, err); err non-nil only for sink.Write failures.
func processAndOutput(ctx context.Context, keyDef *event.KeyDefinition, evt *protocol.Event, opts Options, sink Sink, jqCode *gojq.Code) (bool, error) { func processAndOutput(ctx context.Context, keyDef *event.KeyDefinition, evt *protocol.Event, opts Options, sink Sink, jqCode *gojq.Code) (bool, error) {
raw := &event.RawEvent{
EventType: evt.EventType,
Payload: evt.Payload,
}
// Synchronous Match filter runs before any work (Process / sink write).
if keyDef.Match != nil && !keyDef.Match(raw, opts.Params) {
return false, nil
}
var result json.RawMessage var result json.RawMessage
if keyDef.Process != nil { if keyDef.Process != nil {
raw := &event.RawEvent{
EventType: evt.EventType,
Payload: evt.Payload,
}
var err error var err error
result, err = keyDef.Process(ctx, opts.Runtime, raw, opts.Params) result, err = keyDef.Process(ctx, opts.Runtime, raw, opts.Params)
if err != nil { if err != nil {

View File

@@ -5,13 +5,10 @@ package consume
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"strings" "strings"
"sync" "sync"
"testing" "testing"
"github.com/larksuite/cli/errs"
) )
func TestCompileJQReportsErrorEarly(t *testing.T) { func TestCompileJQReportsErrorEarly(t *testing.T) {
@@ -23,16 +20,6 @@ func TestCompileJQReportsErrorEarly(t *testing.T) {
if !strings.Contains(msg, "compile") && !strings.Contains(msg, "parse") && !strings.Contains(msg, "invalid") { if !strings.Contains(msg, "compile") && !strings.Contains(msg, "parse") && !strings.Contains(msg, "invalid") {
t.Errorf("error should mention compile/parse/invalid, got: %v", err) t.Errorf("error should mention compile/parse/invalid, got: %v", err)
} }
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--jq" {
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--jq")
}
if errors.Unwrap(err) == nil {
t.Error("compile error should preserve its cause")
}
} }
func TestCompileJQReturnsUsableCode(t *testing.T) { func TestCompileJQReturnsUsableCode(t *testing.T) {

View File

@@ -89,7 +89,7 @@ func TestConsumeLoop_DeliversEventsAndExitsOnMaxEvents(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted) err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted)
if err != nil { if err != nil {
t.Fatalf("consumeLoop: %v", err) t.Fatalf("consumeLoop: %v", err)
} }
@@ -132,7 +132,7 @@ func TestConsumeLoop_SeqGapEmitsWarning(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted); err != nil { if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted); err != nil {
t.Fatalf("consumeLoop: %v", err) t.Fatalf("consumeLoop: %v", err)
} }
if got := emitted.Load(); got != 2 { if got := emitted.Load(); got != 2 {
@@ -169,7 +169,7 @@ func TestConsumeLoop_JQFilterAppliedPerEvent(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted); err != nil { if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted); err != nil {
t.Fatalf("consumeLoop: %v", err) t.Fatalf("consumeLoop: %v", err)
} }
if got := emitted.Load(); got != 1 { if got := emitted.Load(); got != 1 {
@@ -196,96 +196,12 @@ func TestConsumeLoop_CompileJQFailsEarly(t *testing.T) {
var lastForKey bool var lastForKey bool
var emitted atomic.Int64 var emitted atomic.Int64
err := consumeLoop(context.Background(), client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted) err := consumeLoop(context.Background(), client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted)
if err == nil { if err == nil {
t.Fatal("consumeLoop should fail immediately on bad jq expression") t.Fatal("consumeLoop should fail immediately on bad jq expression")
} }
} }
// captureSink is a minimal Sink for unit-testing processAndOutput directly.
type captureSink struct {
written []json.RawMessage
}
func (s *captureSink) Write(data json.RawMessage) error {
s.written = append(s.written, data)
return nil
}
func TestProcessAndOutput_Match_DropsEvent(t *testing.T) {
calledProcess := false
keyDef := &event.KeyDefinition{
Key: "test.evt",
Match: func(raw *event.RawEvent, params map[string]string) bool {
return false
},
Process: func(ctx context.Context, rt event.APIClient, raw *event.RawEvent, params map[string]string) (json.RawMessage, error) {
calledProcess = true
return json.RawMessage(`{}`), nil
},
}
sink := &captureSink{}
wrote, err := processAndOutput(context.Background(), keyDef,
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{"x":1}`)},
Options{}, sink, nil)
if err != nil {
t.Fatal(err)
}
if wrote {
t.Error("Match returned false but event was written")
}
if calledProcess {
t.Error("Process was called even though Match returned false")
}
if len(sink.written) != 0 {
t.Errorf("sink received %d events, want 0", len(sink.written))
}
}
func TestProcessAndOutput_Match_NilAcceptsAll(t *testing.T) {
keyDef := &event.KeyDefinition{Key: "test.evt"} // no Match, no Process
sink := &captureSink{}
wrote, err := processAndOutput(context.Background(), keyDef,
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{"x":1}`)},
Options{}, sink, nil)
if err != nil || !wrote {
t.Errorf("expected wrote=true err=nil; got wrote=%v err=%v", wrote, err)
}
if len(sink.written) != 1 {
t.Errorf("sink received %d events, want 1", len(sink.written))
}
}
func TestProcessAndOutput_Match_RunsBeforeProcess(t *testing.T) {
// Record the actual call sequence — a bare call-count check would still
// pass if Process ran before Match.
var order []string
keyDef := &event.KeyDefinition{
Key: "test.evt",
Match: func(raw *event.RawEvent, params map[string]string) bool {
order = append(order, "match")
return true
},
Process: func(ctx context.Context, rt event.APIClient, raw *event.RawEvent, params map[string]string) (json.RawMessage, error) {
order = append(order, "process")
return raw.Payload, nil
},
}
sink := &captureSink{}
wrote, err := processAndOutput(context.Background(), keyDef,
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{}`)},
Options{}, sink, nil)
if err != nil {
t.Fatal(err)
}
if !wrote {
t.Error("expected wrote=true")
}
if len(order) != 2 || order[0] != "match" || order[1] != "process" {
t.Errorf("call order = %v, want [match process]", order)
}
}
func TestIsTerminalSinkError(t *testing.T) { func TestIsTerminalSinkError(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
name string name string

View File

@@ -16,8 +16,8 @@ const preShutdownAckTimeout = 2 * time.Second
// checkLastForKey atomically reserves a cleanup lock; on any error defaults to true // checkLastForKey atomically reserves a cleanup lock; on any error defaults to true
// (cleanup-on-error is safer than leaking server state). Discards non-ack frames in flight. // (cleanup-on-error is safer than leaking server state). Discards non-ack frames in flight.
func checkLastForKey(conn net.Conn, eventKey string, subscriptionID string) bool { func checkLastForKey(conn net.Conn, eventKey string) bool {
msg := protocol.NewPreShutdownCheck(eventKey, subscriptionID) msg := protocol.NewPreShutdownCheck(eventKey)
if err := protocol.EncodeWithDeadline(conn, msg, protocol.WriteTimeout); err != nil { if err := protocol.EncodeWithDeadline(conn, msg, protocol.WriteTimeout); err != nil {
return true return true
} }

View File

@@ -4,8 +4,6 @@
package consume package consume
import ( import (
"bufio"
"bytes"
"encoding/json" "encoding/json"
"io" "io"
"net" "net"
@@ -40,7 +38,7 @@ func TestCheckLastForKey_IgnoresNonAckFrames(t *testing.T) {
} }
}() }()
got := checkLastForKey(client, "im.msg", "") got := checkLastForKey(client, "im.msg")
if got != false { if got != false {
t.Errorf("checkLastForKey = %v, want false", got) t.Errorf("checkLastForKey = %v, want false", got)
} }
@@ -64,7 +62,7 @@ func TestCheckLastForKey_ReturnsAckValue(t *testing.T) {
_ = protocol.Encode(server, ack) _ = protocol.Encode(server, ack)
}() }()
got := checkLastForKey(client, "im.msg", "") got := checkLastForKey(client, "im.msg")
if got != true { if got != true {
t.Errorf("checkLastForKey = %v, want true", got) t.Errorf("checkLastForKey = %v, want true", got)
} }
@@ -85,7 +83,7 @@ func TestCheckLastForKey_DefaultsToTrueOnTimeout(t *testing.T) {
}() }()
start := time.Now() start := time.Now()
got := checkLastForKey(client, "im.msg", "") got := checkLastForKey(client, "im.msg")
elapsed := time.Since(start) elapsed := time.Since(start)
if got != true { if got != true {
@@ -95,39 +93,3 @@ func TestCheckLastForKey_DefaultsToTrueOnTimeout(t *testing.T) {
t.Errorf("elapsed = %v, expected ~%v (timeout-bounded)", elapsed, preShutdownAckTimeout) t.Errorf("elapsed = %v, expected ~%v (timeout-bounded)", elapsed, preShutdownAckTimeout)
} }
} }
func TestCheckLastForKey_SendsSubscriptionID(t *testing.T) {
a, b := net.Pipe()
defer a.Close()
defer b.Close()
done := make(chan string, 1)
go func() {
br := bufio.NewReader(b)
line, err := protocol.ReadFrame(br)
if err != nil {
done <- "READ_ERR"
return
}
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
if err != nil {
done <- "DECODE_ERR"
return
}
check, ok := msg.(*protocol.PreShutdownCheck)
if !ok {
done <- "WRONG_TYPE"
return
}
done <- check.SubscriptionID
// Reply with ack so client returns
ack := protocol.NewPreShutdownAck(true)
_ = protocol.EncodeWithDeadline(b, ack, protocol.WriteTimeout)
}()
_ = checkLastForKey(a, "mail.x", "mail.x:alice")
got := <-done
if got != "mail.x:alice" {
t.Errorf("PreShutdownCheck.SubscriptionID on wire = %q, want %q", got, "mail.x:alice")
}
}

View File

@@ -13,7 +13,6 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/vfs" "github.com/larksuite/cli/internal/vfs"
) )
@@ -24,8 +23,7 @@ type Sink interface {
func newSink(opts Options) (Sink, error) { func newSink(opts Options) (Sink, error) {
if opts.OutputDir != "" { if opts.OutputDir != "" {
if err := vfs.MkdirAll(opts.OutputDir, 0755); err != nil { if err := vfs.MkdirAll(opts.OutputDir, 0755); err != nil {
return nil, errs.NewInternalError(errs.SubtypeFileIO, return nil, fmt.Errorf("create output dir: %w", err)
"create output dir: %s", err).WithCause(err)
} }
// PID disambiguates filenames across processes sharing a Dir. // PID disambiguates filenames across processes sharing a Dir.
return &DirSink{Dir: opts.OutputDir, pid: os.Getpid()}, nil return &DirSink{Dir: opts.OutputDir, pid: os.Getpid()}, nil

View File

@@ -16,7 +16,6 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/protocol" "github.com/larksuite/cli/internal/event/protocol"
@@ -52,9 +51,10 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
} else { } else {
fmt.Fprintf(errOut, "[event] remote connection check: online_instance_cnt=%d\n", count) fmt.Fprintf(errOut, "[event] remote connection check: online_instance_cnt=%d\n", count)
if count > 0 { if count > 0 {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, return nil, fmt.Errorf("another event bus is already connected to this app "+
"another event bus is already connected to this app (%d active connection(s) detected via API); only one bus should run globally to avoid duplicate event delivery", count). "(%d active connection(s) detected via API).\n"+
WithHint("use `lark-cli event status` to check, or `lark-cli event stop` on the other machine first") "Only one bus should run globally to avoid duplicate event delivery.\n"+
"Use 'lark-cli event status' to check, or 'lark-cli event stop' on the other machine first", count)
} }
} }
} else { } else {
@@ -65,10 +65,8 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
pid, forkErr := forkBus(tr, appID, profileName, domain) pid, forkErr := forkBus(tr, appID, profileName, domain)
if forkErr != nil && !errors.Is(forkErr, lockfile.ErrHeld) { if forkErr != nil && !errors.Is(forkErr, lockfile.ErrHeld) {
eventsRoot := filepath.Join(core.GetConfigDir(), "events") eventsRoot := filepath.Join(core.GetConfigDir(), "events")
return nil, errs.NewInternalError(errs.SubtypeUnknown, return nil, fmt.Errorf("failed to start event bus daemon: %w\n"+
"failed to start event bus daemon: %s", forkErr). "Check: disk space, permissions on %s, and 'lark-cli doctor'", forkErr, eventsRoot)
WithCause(forkErr).
WithHint("check disk space, permissions on %s, and `lark-cli doctor`", eventsRoot)
} }
if pid > 0 { if pid > 0 {
announceForkedBus(errOut, pid) announceForkedBus(errOut, pid)
@@ -90,9 +88,7 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
fmt.Fprintln(errOut, "[event] event bus exited unexpectedly.") fmt.Fprintln(errOut, "[event] event bus exited unexpectedly.")
fmt.Fprintln(errOut, "[event] please check app credentials (lark-cli config show) and retry.") fmt.Fprintln(errOut, "[event] please check app credentials (lark-cli config show) and retry.")
fmt.Fprintf(errOut, "[event] logs: %s\n", logPath) fmt.Fprintf(errOut, "[event] logs: %s\n", logPath)
return nil, errs.NewInternalError(errs.SubtypeUnknown, return nil, fmt.Errorf("failed to connect to event bus within %v (app=%s)", dialTimeout, appID)
"failed to connect to event bus within %v (app=%s)", dialTimeout, appID).
WithHint("check app credentials (`lark-cli config show`) and retry; bus logs: %s", logPath)
} }
// probeAndDialBus distinguishes a healthy bus from a mid-shutdown listener via StatusQuery first. // probeAndDialBus distinguishes a healthy bus from a mid-shutdown listener via StatusQuery first.

View File

@@ -1,99 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"context"
"encoding/json"
"errors"
"io"
"net"
"strconv"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
// failDialTransport refuses every dial so EnsureBus falls through to the
// remote-connection check without a local bus.
type failDialTransport struct{}
func (failDialTransport) Listen(string) (net.Listener, error) { return nil, errors.New("no listen") }
func (failDialTransport) Dial(string) (net.Conn, error) { return nil, errors.New("refused") }
func (failDialTransport) Address(string) string { return "guard-test-addr" }
func (failDialTransport) Cleanup(string) {}
// remoteBusyAPIClient reports active remote WebSocket connections.
type remoteBusyAPIClient struct{ count int }
func (c remoteBusyAPIClient) CallAPI(context.Context, string, string, interface{}) (json.RawMessage, error) {
return json.RawMessage(`{"code":0,"msg":"ok","data":{"online_instance_cnt":` +
strconv.Itoa(c.count) + `}}`), nil
}
func TestEnsureBus_RemoteBusAlreadyConnectedIsFailedPrecondition(t *testing.T) {
conn, err := EnsureBus(context.Background(), failDialTransport{},
"cli_guard_test", "", "", remoteBusyAPIClient{count: 2}, io.Discard)
if conn != nil {
t.Fatal("expected nil conn when a remote bus is already connected")
}
if err == nil {
t.Fatal("expected single-bus guard error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeFailedPrecondition)
}
if !strings.Contains(ve.Hint, "event stop") {
t.Errorf("hint should point at `event stop`, got: %q", ve.Hint)
}
}
func TestRun_UnknownEventKeyIsTypedValidation(t *testing.T) {
err := Run(context.Background(), failDialTransport{}, "cli_x", "", "", Options{
EventKey: "bogus.run.key",
ErrOut: io.Discard,
})
if err == nil {
t.Fatal("expected unknown EventKey error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(ve.Hint, "event list") {
t.Errorf("hint should point at `event list`, got: %q", ve.Hint)
}
}
func TestRun_InvalidJQFailsBeforeAnySideEffect(t *testing.T) {
event.RegisterKey(event.KeyDefinition{
Key: "consume.runtest.jq",
EventType: "consume.runtest.jq_v1",
Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{}`)}},
})
err := Run(context.Background(), failDialTransport{}, "cli_x", "", "", Options{
EventKey: "consume.runtest.jq",
JQExpr: "[invalid{{{",
ErrOut: io.Discard,
})
if err == nil {
t.Fatal("expected jq validation error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Param != "--jq" {
t.Errorf("param = %q, want %q", ve.Param, "--jq")
}
}

View File

@@ -1,64 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
func requireParamValidationError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected validation error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--param")
}
if ve.Hint == "" {
t.Error("param validation error should hint at `lark-cli event schema`")
}
}
func TestValidateParams_RequiredMissing(t *testing.T) {
def := &event.KeyDefinition{
Key: "x.test",
Params: []event.ParamDef{{Name: "chat_id", Required: true}},
}
requireParamValidationError(t, validateParams(def, map[string]string{}))
}
func TestValidateParams_UnknownParam(t *testing.T) {
def := &event.KeyDefinition{
Key: "x.test",
Params: []event.ParamDef{{Name: "chat_id"}},
}
requireParamValidationError(t, validateParams(def, map[string]string{"nope": "1"}))
}
func TestValidateParams_UnknownParamNoParamsAccepted(t *testing.T) {
def := &event.KeyDefinition{Key: "x.test"}
requireParamValidationError(t, validateParams(def, map[string]string{"nope": "1"}))
}
func TestValidateParams_DefaultAppliedAndValidPasses(t *testing.T) {
def := &event.KeyDefinition{
Key: "x.test",
Params: []event.ParamDef{{Name: "mode", Required: true, Default: "all"}},
}
params := map[string]string{}
if err := validateParams(def, params); err != nil {
t.Fatalf("default should satisfy required param, got: %v", err)
}
if params["mode"] != "all" {
t.Errorf("default not applied, params=%v", params)
}
}

View File

@@ -77,88 +77,3 @@ func TestDecodeUnknownType(t *testing.T) {
t.Error("expected error for unknown type") t.Error("expected error for unknown type")
} }
} }
func TestEncodeDecodeHello_WithSubscriptionID(t *testing.T) {
msg := &Hello{
Type: MsgTypeHello,
PID: 12345,
EventKey: "mail.user_mailbox.event.message_received_v1",
EventTypes: []string{"mail.user_mailbox.event.message_received_v1"},
Version: "v1",
SubscriptionID: "mail.user_mailbox.event.message_received_v1:a7Bx9Kp2Lm3Qv4Rs",
}
buf := &bytes.Buffer{}
if err := Encode(buf, msg); err != nil {
t.Fatal(err)
}
line := buf.Bytes()
if !bytes.Contains(line, []byte(`"subscription_id":"mail.user_mailbox.event.message_received_v1:a7Bx9Kp2Lm3Qv4Rs"`)) {
t.Errorf("subscription_id not serialized: %s", string(line))
}
decoded, err := Decode(bytes.TrimRight(line, "\n"))
if err != nil {
t.Fatal(err)
}
hello, ok := decoded.(*Hello)
if !ok {
t.Fatalf("expected *Hello, got %T", decoded)
}
if hello.SubscriptionID != msg.SubscriptionID {
t.Errorf("roundtrip subscription_id: got %q want %q", hello.SubscriptionID, msg.SubscriptionID)
}
}
func TestEncodeDecodeHello_EmptySubscriptionIDOmitted(t *testing.T) {
msg := &Hello{
Type: MsgTypeHello,
PID: 1,
EventKey: "k",
EventTypes: []string{"k"},
Version: "v1",
}
buf := &bytes.Buffer{}
if err := Encode(buf, msg); err != nil {
t.Fatal(err)
}
if bytes.Contains(buf.Bytes(), []byte("subscription_id")) {
t.Errorf("empty subscription_id should be omitted: %s", buf.String())
}
decoded, _ := Decode(bytes.TrimRight(buf.Bytes(), "\n"))
hello := decoded.(*Hello)
if hello.SubscriptionID != "" {
t.Errorf("got %q, want empty", hello.SubscriptionID)
}
}
func TestEncodeDecodePreShutdownCheck_WithSubscriptionID(t *testing.T) {
msg := &PreShutdownCheck{
Type: MsgTypePreShutdownCheck,
EventKey: "mail.x",
SubscriptionID: "mail.x:abc",
}
buf := &bytes.Buffer{}
if err := Encode(buf, msg); err != nil {
t.Fatal(err)
}
decoded, err := Decode(bytes.TrimRight(buf.Bytes(), "\n"))
if err != nil {
t.Fatal(err)
}
got := decoded.(*PreShutdownCheck)
if got.SubscriptionID != msg.SubscriptionID {
t.Errorf("roundtrip: got %q want %q", got.SubscriptionID, msg.SubscriptionID)
}
}
func TestStatusResponse_ConsumerInfo_SubscriptionID(t *testing.T) {
msg := NewStatusResponse(7, 120, 1, []ConsumerInfo{
{PID: 99, EventKey: "mail.x", SubscriptionID: "mail.x:abc", Received: 5, Dropped: 0},
})
buf := &bytes.Buffer{}
if err := Encode(buf, msg); err != nil {
t.Fatal(err)
}
if !bytes.Contains(buf.Bytes(), []byte(`"subscription_id":"mail.x:abc"`)) {
t.Errorf("ConsumerInfo.SubscriptionID missing from JSON: %s", buf.String())
}
}

View File

@@ -34,12 +34,11 @@ type SourceStatus struct {
} }
type Hello struct { type Hello struct {
Type string `json:"type"` Type string `json:"type"`
PID int `json:"pid"` PID int `json:"pid"`
EventKey string `json:"event_key"` EventKey string `json:"event_key"`
EventTypes []string `json:"event_types"` EventTypes []string `json:"event_types"`
Version string `json:"version"` Version string `json:"version"`
SubscriptionID string `json:"subscription_id,omitempty"` // empty = fallback to EventKey on bus side
} }
type HelloAck struct { type HelloAck struct {
@@ -62,11 +61,10 @@ type Bye struct {
Type string `json:"type"` Type string `json:"type"`
} }
// PreShutdownCheck atomically reserves the cleanup lock for (EventKey, SubscriptionID). // PreShutdownCheck atomically reserves the cleanup lock for EventKey.
type PreShutdownCheck struct { type PreShutdownCheck struct {
Type string `json:"type"` Type string `json:"type"`
EventKey string `json:"event_key"` EventKey string `json:"event_key"`
SubscriptionID string `json:"subscription_id,omitempty"` // empty = fallback to EventKey
} }
type PreShutdownAck struct { type PreShutdownAck struct {
@@ -79,11 +77,10 @@ type StatusQuery struct {
} }
type ConsumerInfo struct { type ConsumerInfo struct {
PID int `json:"pid"` PID int `json:"pid"`
EventKey string `json:"event_key"` EventKey string `json:"event_key"`
SubscriptionID string `json:"subscription_id,omitempty"` Received int64 `json:"received"`
Received int64 `json:"received"` Dropped int64 `json:"dropped"`
Dropped int64 `json:"dropped"`
} }
type StatusResponse struct { type StatusResponse struct {
@@ -98,14 +95,13 @@ type Shutdown struct {
Type string `json:"type"` Type string `json:"type"`
} }
func NewHello(pid int, eventKey string, eventTypes []string, version string, subscriptionID string) *Hello { func NewHello(pid int, eventKey string, eventTypes []string, version string) *Hello {
return &Hello{ return &Hello{
Type: MsgTypeHello, Type: MsgTypeHello,
PID: pid, PID: pid,
EventKey: eventKey, EventKey: eventKey,
EventTypes: eventTypes, EventTypes: eventTypes,
Version: version, Version: version,
SubscriptionID: subscriptionID,
} }
} }
@@ -128,8 +124,8 @@ func NewEvent(eventType, eventID, sourceTime string, seq uint64, payload json.Ra
} }
} }
func NewPreShutdownCheck(eventKey, subscriptionID string) *PreShutdownCheck { func NewPreShutdownCheck(eventKey string) *PreShutdownCheck {
return &PreShutdownCheck{Type: MsgTypePreShutdownCheck, EventKey: eventKey, SubscriptionID: subscriptionID} return &PreShutdownCheck{Type: MsgTypePreShutdownCheck, EventKey: eventKey}
} }
func NewPreShutdownAck(lastForKey bool) *PreShutdownAck { func NewPreShutdownAck(lastForKey bool) *PreShutdownAck {

View File

@@ -17,7 +17,7 @@ import (
// Every NewXxx helper must set the Type discriminator (Decode rejects messages without it). // Every NewXxx helper must set the Type discriminator (Decode rejects messages without it).
func TestConstructors_PinTypeField(t *testing.T) { func TestConstructors_PinTypeField(t *testing.T) {
if got := NewHello(1, "k", []string{"t"}, "v1", ""); got.Type != MsgTypeHello { if got := NewHello(1, "k", []string{"t"}, "v1"); got.Type != MsgTypeHello {
t.Errorf("NewHello.Type = %q, want %q", got.Type, MsgTypeHello) t.Errorf("NewHello.Type = %q, want %q", got.Type, MsgTypeHello)
} }
if got := NewHelloAck("v1", true); got.Type != MsgTypeHelloAck || !got.FirstForKey { if got := NewHelloAck("v1", true); got.Type != MsgTypeHelloAck || !got.FirstForKey {
@@ -26,7 +26,7 @@ func TestConstructors_PinTypeField(t *testing.T) {
if got := NewEvent("im.msg", "e1", "", 7, json.RawMessage(`{}`)); got.Type != MsgTypeEvent || got.Seq != 7 { if got := NewEvent("im.msg", "e1", "", 7, json.RawMessage(`{}`)); got.Type != MsgTypeEvent || got.Seq != 7 {
t.Errorf("NewEvent mismatch: %+v", got) t.Errorf("NewEvent mismatch: %+v", got)
} }
if got := NewPreShutdownCheck("k", ""); got.Type != MsgTypePreShutdownCheck || got.EventKey != "k" { if got := NewPreShutdownCheck("k"); got.Type != MsgTypePreShutdownCheck || got.EventKey != "k" {
t.Errorf("NewPreShutdownCheck mismatch: %+v", got) t.Errorf("NewPreShutdownCheck mismatch: %+v", got)
} }
if got := NewPreShutdownAck(true); got.Type != MsgTypePreShutdownAck || !got.LastForKey { if got := NewPreShutdownAck(true); got.Type != MsgTypePreShutdownAck || !got.LastForKey {
@@ -63,7 +63,7 @@ func TestEncode_DecodeRoundtripAllTypes(t *testing.T) {
} }
} }
roundtrip(t, NewHelloAck("v1", true), &HelloAck{}) roundtrip(t, NewHelloAck("v1", true), &HelloAck{})
roundtrip(t, NewPreShutdownCheck("im.msg", ""), &PreShutdownCheck{}) roundtrip(t, NewPreShutdownCheck("im.msg"), &PreShutdownCheck{})
roundtrip(t, NewPreShutdownAck(false), &PreShutdownAck{}) roundtrip(t, NewPreShutdownAck(false), &PreShutdownAck{})
roundtrip(t, NewStatusQuery(), &StatusQuery{}) roundtrip(t, NewStatusQuery(), &StatusQuery{})
roundtrip(t, NewStatusResponse(7, 120, 1, []ConsumerInfo{{PID: 99, EventKey: "k"}}), &StatusResponse{}) roundtrip(t, NewStatusResponse(7, 120, 1, []ConsumerInfo{{PID: 99, EventKey: "k"}}), &StatusResponse{})

View File

@@ -55,23 +55,6 @@ type ParamDef struct {
Default string `json:"default,omitempty"` Default string `json:"default,omitempty"`
Description string `json:"description"` Description string `json:"description"`
Values []ParamValue `json:"values,omitempty"` Values []ParamValue `json:"values,omitempty"`
// SubscriptionKey marks this param as part of the subscription identity.
// Two consumers of the same EventKey but different values for any
// SubscriptionKey-marked param are treated as DISTINCT subscriptions:
// PreConsume runs once per (EventKey, SubscriptionID), cleanup runs once per
// (EventKey, SubscriptionID).
//
// CONTRACT: only mark a param SubscriptionKey if the EventKey's server-side
// subscribe/unsubscribe API is itself scoped to that resource. Lark keys the
// subscription record by (app, user, event_type) and overwrites it rather
// than reference-counting, so for a non-per-resource API the cleanup of one
// resource's last consumer unsubscribes the shared record and silently cuts
// off every other resource sharing that event_type.
//
// Default false = the param is a filter / formatting / metadata param
// and does not affect subscription identity.
SubscriptionKey bool `json:"subscription_key,omitempty"`
} }
type ProcessFunc = func(ctx context.Context, rt APIClient, raw *RawEvent, params map[string]string) (json.RawMessage, error) type ProcessFunc = func(ctx context.Context, rt APIClient, raw *RawEvent, params map[string]string) (json.RawMessage, error)
@@ -100,44 +83,10 @@ type KeyDefinition struct {
Schema SchemaDef `json:"schema"` Schema SchemaDef `json:"schema"`
// NormalizeParams canonicalizes param values BEFORE fingerprint compute,
// PreConsume, Match, and Process. Mutates the params map in place.
// May call OAPI; runs once per consumer at startup.
//
// Use cases: resolve aliases ("me" -> real email, a name -> an ID),
// trim whitespace. On error, consume fails (no retry); caller gets the
// wrapped error.
//
// Default nil = no normalization, params pass through unchanged.
NormalizeParams func(ctx context.Context, rt APIClient, params map[string]string) error `json:"-"`
// Process required when Schema.Custom is Processed output; must be nil when Native is used. // Process required when Schema.Custom is Processed output; must be nil when Native is used.
//
// Convention: returning (nil, nil) signals "drop this event" — the
// consumer loop will skip writing it to sink and not advance the
// emitted counter. Useful for async filtering (e.g. fetch metadata,
// drop if folder doesn't match). For sync filters that don't need
// OAPI, use Match instead.
Process func(ctx context.Context, rt APIClient, raw *RawEvent, params map[string]string) (json.RawMessage, error) `json:"-"` Process func(ctx context.Context, rt APIClient, raw *RawEvent, params map[string]string) (json.RawMessage, error) `json:"-"`
// Match is a synchronous payload filter run on every received event PreConsume func(ctx context.Context, rt APIClient, params map[string]string) (cleanup func(), err error) `json:"-"`
// BEFORE Process. Return false to drop the event without further work.
//
// Signature deliberately omits ctx/rt to physically enforce "no OAPI
// calls in Match". For filters that need a metadata fetch first, use
// Process and return nil to drop.
//
// Default nil = accept all events.
Match func(raw *RawEvent, params map[string]string) bool `json:"-"`
// PreConsume runs once per (EventKey, SubscriptionID) when this consumer
// is first for that scope. Returns a cleanup function that the framework
// invokes when this consumer is the last for its scope.
//
// The cleanup's error return is honored: on nil the framework prints
// "[event] cleanup done."; on non-nil it prints a WARN with an
// idempotency note.
PreConsume func(ctx context.Context, rt APIClient, params map[string]string) (cleanup func() error, err error) `json:"-"`
Scopes []string `json:"scopes,omitempty"` Scopes []string `json:"scopes,omitempty"`

View File

@@ -35,12 +35,9 @@ const (
LarkErrAppNotInUse = 99991662 // app is disabled in this tenant LarkErrAppNotInUse = 99991662 // app is disabled in this tenant
LarkErrAppUnauthorized = 99991673 // app status unavailable; check installation LarkErrAppUnauthorized = 99991673 // app status unavailable; check installation
// "Wrong app credentials" code from the LEGACY TAT endpoint // TAT-endpoint variant of the "wrong app credentials" condition.
// (/open-apis/auth/v3/tenant_access_token/internal returns 10014, "app secret // /open-apis/auth/v3/tenant_access_token/internal returns code 10014
// invalid", instead of 99991543). Since the OAuth v3 migration the CLI mints // ("app secret invalid") instead of 99991543 when the secret is wrong.
// TAT via accounts/oauth/v3/token and reports this as the OAuth invalid_client
// error, so it no longer emits 10014 itself; the constant + codemeta mapping
// are retained as a defensive fallback should 10014 still arrive.
LarkErrTATInvalidSecret = 10014 LarkErrTATInvalidSecret = 10014
// Rate limit. // Rate limit.

View File

@@ -47,10 +47,6 @@
"en": { "title": "Minutes", "description": "Minutes content and metadata retrieval" }, "en": { "title": "Minutes", "description": "Minutes content and metadata retrieval" },
"zh": { "title": "妙记", "description": "妙记信息获取、内容查询" } "zh": { "title": "妙记", "description": "妙记信息获取、内容查询" }
}, },
"note": {
"en": { "title": "Note", "description": "Meeting note detail and unified transcript retrieval" },
"zh": { "title": "会议纪要", "description": "会议纪要详情与 unified 逐字稿查询" }
},
"sheets": { "sheets": {
"en": { "title": "Sheets", "description": "Spreadsheet operations" }, "en": { "title": "Sheets", "description": "Spreadsheet operations" },
"zh": { "title": "电子表格", "description": "电子表格操作" } "zh": { "title": "电子表格", "description": "电子表格操作" }

View File

@@ -15,28 +15,15 @@ import (
// legacy validation/save helpers are forbidden; callers must use the typed // legacy validation/save helpers are forbidden; callers must use the typed
// common replacements or construct an errs.* typed error directly. // common replacements or construct an errs.* typed error directly.
var migratedCommonHelperPaths = []string{ var migratedCommonHelperPaths = []string{
"cmd/event/",
"events/",
"internal/event/consume/",
"shortcuts/apps/",
"shortcuts/base/", "shortcuts/base/",
"shortcuts/calendar/", "shortcuts/calendar/",
"shortcuts/contact/",
"shortcuts/doc/",
"shortcuts/drive/", "shortcuts/drive/",
"shortcuts/event/",
"shortcuts/im/",
"shortcuts/mail/", "shortcuts/mail/",
"shortcuts/markdown/",
"shortcuts/minutes/", "shortcuts/minutes/",
"shortcuts/note/",
"shortcuts/okr/", "shortcuts/okr/",
"shortcuts/sheets/",
"shortcuts/slides/",
"shortcuts/task/", "shortcuts/task/",
"shortcuts/vc/", "shortcuts/vc/",
"shortcuts/whiteboard/", "shortcuts/whiteboard/",
"shortcuts/wiki/",
} }
const commonImportPath = "github.com/larksuite/cli/shortcuts/common" const commonImportPath = "github.com/larksuite/cli/shortcuts/common"

View File

@@ -16,28 +16,16 @@ import (
// call sites must return a typed errs.* error instead. Future domains opt in by // call sites must return a typed errs.* error instead. Future domains opt in by
// appending their path prefix here. // appending their path prefix here.
var migratedEnvelopePaths = []string{ var migratedEnvelopePaths = []string{
"cmd/event/",
"events/",
"internal/event/consume/",
"shortcuts/apps/",
"shortcuts/base/", "shortcuts/base/",
"shortcuts/calendar/", "shortcuts/calendar/",
"shortcuts/contact/",
"shortcuts/doc/",
"shortcuts/drive/", "shortcuts/drive/",
"shortcuts/event/",
"shortcuts/im/",
"shortcuts/mail/", "shortcuts/mail/",
"shortcuts/markdown/",
"shortcuts/minutes/", "shortcuts/minutes/",
"shortcuts/note/",
"shortcuts/okr/", "shortcuts/okr/",
"shortcuts/sheets/",
"shortcuts/slides/",
"shortcuts/task/", "shortcuts/task/",
"shortcuts/vc/", "shortcuts/vc/",
"shortcuts/whiteboard/", "shortcuts/whiteboard/",
"shortcuts/wiki/", "shortcuts/im/",
} }
// legacyOutputImportPath is the import path of the package that declares the // legacyOutputImportPath is the import path of the package that declares the

View File

@@ -27,11 +27,6 @@ import (
// is not matched. runtime.DoAPI / runtime.RawAPI are intentionally not listed: // is not matched. runtime.DoAPI / runtime.RawAPI are intentionally not listed:
// they return the raw response for the caller to classify and do not emit a // they return the raw response for the caller to classify and do not emit a
// legacy envelope themselves. // legacy envelope themselves.
//
// Files that do not import shortcuts/common are skipped: the legacy helpers
// are methods on common.RuntimeContext, so a same-named method on another
// receiver (for example the event domain's APIClient interface, whose
// implementation classifies into typed errs.* errors) is not a legacy call.
func CheckNoLegacyRuntimeAPICall(path, src string) []Violation { func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
if !isMigratedEnvelopePath(path) || strings.HasSuffix(path, "_test.go") { if !isMigratedEnvelopePath(path) || strings.HasSuffix(path, "_test.go") {
return nil return nil
@@ -41,9 +36,6 @@ func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
if err != nil { if err != nil {
return nil return nil
} }
if !importsPath(file, commonImportPath) {
return nil
}
var out []Violation var out []Violation
ast.Inspect(file, func(n ast.Node) bool { ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr) call, ok := n.(*ast.CallExpr)
@@ -79,16 +71,3 @@ func matchLegacyRuntimeAPIMethod(name string) (string, bool) {
} }
return "", false return "", false
} }
// importsPath reports whether the file imports the given package path.
func importsPath(file *ast.File, importPath string) bool {
for _, imp := range file.Imports {
if imp.Path == nil {
continue
}
if strings.Trim(imp.Path.Value, "`\"") == importPath {
return true
}
}
return false
}

View File

@@ -620,7 +620,6 @@ func boom() error {
func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnMigratedShortcutPaths(t *testing.T) { func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnMigratedShortcutPaths(t *testing.T) {
for _, path := range []string{ for _, path := range []string{
"shortcuts/markdown/markdown_fetch.go",
"shortcuts/okr/okr_image_upload.go", "shortcuts/okr/okr_image_upload.go",
"shortcuts/task/task_update.go", "shortcuts/task/task_update.go",
"shortcuts/whiteboard/whiteboard_update.go", "shortcuts/whiteboard/whiteboard_update.go",
@@ -692,7 +691,7 @@ func boom() error {
return &output.ExitError{Code: 1} return &output.ExitError{Code: 1}
} }
` `
v := CheckNoLegacyEnvelopeLiteral("shortcuts/unmigrated/foo.go", src) v := CheckNoLegacyEnvelopeLiteral("shortcuts/contact/foo.go", src)
if len(v) != 0 { if len(v) != 0 {
t.Errorf("non-migrated path should pass, got: %+v", v) t.Errorf("non-migrated path should pass, got: %+v", v)
} }
@@ -814,8 +813,6 @@ func boom() error {
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnDrivePath(t *testing.T) { func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnDrivePath(t *testing.T) {
src := `package drive src := `package drive
import "github.com/larksuite/cli/shortcuts/common"
func boom(runtime *common.RuntimeContext) error { func boom(runtime *common.RuntimeContext) error {
_, err := runtime.CallAPI("POST", "/x", nil, nil) _, err := runtime.CallAPI("POST", "/x", nil, nil)
return err return err
@@ -836,8 +833,6 @@ func boom(runtime *common.RuntimeContext) error {
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnTaskPath(t *testing.T) { func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnTaskPath(t *testing.T) {
src := `package task src := `package task
import "github.com/larksuite/cli/shortcuts/common"
func boom(runtime *common.RuntimeContext) error { func boom(runtime *common.RuntimeContext) error {
_, err := runtime.CallAPI("POST", "/x", nil, nil) _, err := runtime.CallAPI("POST", "/x", nil, nil)
return err return err
@@ -858,8 +853,6 @@ func boom(runtime *common.RuntimeContext) error {
func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) { func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) {
src := `package drive src := `package drive
import "github.com/larksuite/cli/shortcuts/common"
func boom(runtime *common.RuntimeContext) error { func boom(runtime *common.RuntimeContext) error {
_, err := runtime.DoAPIJSONWithLogID("POST", "/x", nil, nil) _, err := runtime.DoAPIJSONWithLogID("POST", "/x", nil, nil)
return err return err
@@ -914,7 +907,7 @@ func boom(runtime *common.RuntimeContext) error {
return err return err
} }
` `
v := CheckNoLegacyRuntimeAPICall("shortcuts/unmigrated/sample.go", src) v := CheckNoLegacyRuntimeAPICall("shortcuts/contact/contact_get.go", src)
if len(v) != 0 { if len(v) != 0 {
t.Errorf("non-migrated path must not fire, got: %+v", v) t.Errorf("non-migrated path must not fire, got: %+v", v)
} }
@@ -951,17 +944,11 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
"HandleApiResult", "HandleApiResult",
} }
paths := []string{ paths := []string{
"shortcuts/doc/docs_fetch_v2.go",
"shortcuts/drive/drive_search.go", "shortcuts/drive/drive_search.go",
"shortcuts/im/im_messages_send.go",
"shortcuts/mail/mail_send.go", "shortcuts/mail/mail_send.go",
"shortcuts/markdown/markdown_fetch.go",
"shortcuts/okr/okr_progress_create.go", "shortcuts/okr/okr_progress_create.go",
"shortcuts/sheets/helpers.go",
"shortcuts/slides/slides_create.go",
"shortcuts/task/task_update.go", "shortcuts/task/task_update.go",
"shortcuts/whiteboard/whiteboard_query.go", "shortcuts/whiteboard/whiteboard_query.go",
"shortcuts/wiki/wiki_node_get.go",
} }
for _, path := range paths { for _, path := range paths {
for _, helper := range helpers { for _, helper := range helpers {
@@ -989,18 +976,6 @@ common.` + helper + `()
} }
} }
func TestMigratedCommonHelperPaths_CoverMigratedEnvelopePaths(t *testing.T) {
commonPaths := make(map[string]struct{}, len(migratedCommonHelperPaths))
for _, path := range migratedCommonHelperPaths {
commonPaths[path] = struct{}{}
}
for _, path := range migratedEnvelopePaths {
if _, ok := commonPaths[path]; !ok {
t.Fatalf("migratedEnvelopePaths contains %q but migratedCommonHelperPaths does not", path)
}
}
}
func TestCheckNoLegacyCommonHelperCall_RejectsDangerousCharsOnCalendarPath(t *testing.T) { func TestCheckNoLegacyCommonHelperCall_RejectsDangerousCharsOnCalendarPath(t *testing.T) {
src := `package calendar src := `package calendar
@@ -1022,91 +997,6 @@ func boom() {
} }
} }
func TestCheckNoLegacyCommonHelperCall_CoversDocPathWithAliasAndFunctionValue(t *testing.T) {
src := `package migrated
import c "github.com/larksuite/cli/shortcuts/common"
func boom() {
f := c.FlagErrorf
_ = f
c.WrapInputStatError(nil)
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/doc/docs_fetch_v2.go", src)
if len(v) != 2 {
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on doc path, got %d: %+v", len(v), v)
}
}
func TestCheckNoLegacyCommonHelperCall_CoversSheetsPathWithAliasAndFunctionValue(t *testing.T) {
src := `package migrated
import c "github.com/larksuite/cli/shortcuts/common"
func boom() {
f := c.FlagErrorf
_ = f
c.WrapInputStatError(nil)
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/sheets/helpers.go", src)
if len(v) != 2 {
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on sheets path, got %d: %+v", len(v), v)
}
}
func TestCheckNoLegacyCommonHelperCall_CoversSlidesPathWithAliasAndFunctionValue(t *testing.T) {
src := `package migrated
import c "github.com/larksuite/cli/shortcuts/common"
func boom() {
f := c.FlagErrorf
_ = f
c.WrapInputStatError(nil)
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/slides/slides_create.go", src)
if len(v) != 2 {
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on slides path, got %d: %+v", len(v), v)
}
}
func TestCheckNoLegacyCommonHelperCall_CoversMarkdownPathWithAliasAndFunctionValue(t *testing.T) {
src := `package migrated
import c "github.com/larksuite/cli/shortcuts/common"
func boom() {
f := c.FlagErrorf
_ = f
c.WrapInputStatError(nil)
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/markdown/markdown_fetch.go", src)
if len(v) != 2 {
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on markdown path, got %d: %+v", len(v), v)
}
}
func TestCheckNoLegacyCommonHelperCall_CoversWikiPathWithAliasAndFunctionValue(t *testing.T) {
src := `package migrated
import c "github.com/larksuite/cli/shortcuts/common"
func boom() {
f := c.FlagErrorf
_ = f
c.WrapInputStatError(nil)
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/wiki/wiki_node_get.go", src)
if len(v) != 2 {
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on wiki path, got %d: %+v", len(v), v)
}
}
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) { func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
src := `package contact src := `package contact
@@ -1116,7 +1006,7 @@ func boom() {
common.FlagErrorf("legacy allowed until domain migrates") common.FlagErrorf("legacy allowed until domain migrates")
} }
` `
v := CheckNoLegacyCommonHelperCall("shortcuts/unmigrated/sample.go", src) v := CheckNoLegacyCommonHelperCall("shortcuts/contact/contact_get.go", src)
if len(v) != 0 { if len(v) != 0 {
t.Errorf("non-migrated path must pass, got: %+v", v) t.Errorf("non-migrated path must pass, got: %+v", v)
} }
@@ -1186,23 +1076,3 @@ func boom() error {
t.Fatalf("expected 1 violation for function-value reference, got %d: %+v", len(v), v) t.Fatalf("expected 1 violation for function-value reference, got %d: %+v", len(v), v)
} }
} }
func TestCheckNoLegacyRuntimeAPICall_SkipsNonCommonReceiver(t *testing.T) {
// The event domain's APIClient interface has a same-named CallAPI method
// whose implementation classifies into typed errs.* errors; without the
// shortcuts/common import the call cannot be the legacy RuntimeContext
// helper and must not fire.
src := `package vc
import "github.com/larksuite/cli/internal/event"
func boom(rt event.APIClient) error {
_, err := rt.CallAPI(nil, "POST", "/x", nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("events/vc/preconsume.go", src)
if len(v) != 0 {
t.Errorf("non-common CallAPI receiver must not fire, got: %+v", v)
}
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "@larksuite/cli", "name": "@larksuite/cli",
"version": "1.0.53", "version": "1.0.48",
"description": "The official CLI for Lark/Feishu open platform", "description": "The official CLI for Lark/Feishu open platform",
"bin": { "bin": {
"lark-cli": "scripts/run.js" "lark-cli": "scripts/run.js"
@@ -15,8 +15,7 @@
], ],
"cpu": [ "cpu": [
"x64", "x64",
"arm64", "arm64"
"riscv64"
], ],
"engines": { "engines": {
"node": ">=16" "node": ">=16"

View File

@@ -33,7 +33,6 @@ build_target darwin arm64
build_target linux amd64 build_target linux amd64
build_target darwin amd64 build_target darwin amd64
build_target linux arm64 build_target linux arm64
build_target linux riscv64
build_target windows amd64 build_target windows amd64
build_target windows arm64 build_target windows arm64
@@ -56,7 +55,6 @@ const platformMap = {
const archMap = { const archMap = {
x64: "amd64", x64: "amd64",
arm64: "arm64", arm64: "arm64",
riscv64: "riscv64",
}; };
const platform = platformMap[process.platform]; const platform = platformMap[process.platform];

View File

@@ -30,7 +30,6 @@ const PLATFORM_MAP = {
const ARCH_MAP = { const ARCH_MAP = {
x64: "amd64", x64: "amd64",
arm64: "arm64", arm64: "arm64",
riscv64: "riscv64",
}; };
const platform = PLATFORM_MAP[process.platform]; const platform = PLATFORM_MAP[process.platform];

Some files were not shown because too many files have changed in this diff Show More