Skip to content

Commit 0d4fab3

Browse files
committed
feat: emit typed error envelopes for minutes and vc commands
Failures from the minutes and video-conference commands now surface as structured, typed errors carrying a stable category and subtype — spanning input validation, missing permissions, network and file-I/O failures, and remote API errors — so callers can branch on the error kind instead of parsing free-form text. Batch commands report partial failures explicitly, emitting per-item results with a non-zero exit instead of masking them.
1 parent 24ce3ec commit 0d4fab3

25 files changed

Lines changed: 1410 additions & 276 deletions

.golangci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,20 +65,20 @@ linters:
6565
- forbidigo
6666
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
6767
# Add a path when its migration is complete.
68-
- 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/calendar/helpers\.go|shortcuts/drive/)
68+
- 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/calendar/helpers\.go|shortcuts/drive/|shortcuts/minutes/|shortcuts/vc/)
6969
text: errs-typed-only
7070
linters:
7171
- forbidigo
7272
# errs-no-bare-wrap enforced on paths fully migrated to typed final
7373
# errors. Scoped separately from errs-typed-only because cmd/auth/,
7474
# cmd/config/ still have residual fmt.Errorf and must not be caught.
75-
- path-except: (shortcuts/drive/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
75+
- path-except: (shortcuts/drive/|shortcuts/minutes/|shortcuts/vc/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
7676
text: errs-no-bare-wrap
7777
linters:
7878
- forbidigo
7979
# errs-no-legacy-helper is drive-only: the shared helpers it bans are
8080
# still used by other domains until their later migration phase.
81-
- path-except: (shortcuts/drive/)
81+
- path-except: (shortcuts/drive/|shortcuts/minutes/|shortcuts/vc/)
8282
text: errs-no-legacy-helper
8383
linters:
8484
- forbidigo
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package errclass
5+
6+
import "github.com/larksuite/cli/errs"
7+
8+
// minutesCodeMeta holds minutes-service Lark code → CodeMeta mappings.
9+
// Only codes whose meaning is verifiable from repo evidence are registered;
10+
// ambiguous codes fall back to CategoryAPI via BuildAPIError. Command-specific
11+
// messages and hints are layered on top via per-command enrichment.
12+
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
13+
var minutesCodeMeta = map[int]CodeMeta{
14+
2091001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // speaker not found in transcript
15+
2091005: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // caller lacks edit/read permission for the minute
16+
}
17+
18+
func init() { mergeCodeMeta(minutesCodeMeta, "minutes") }

internal/errclass/codemeta_vc.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package errclass
5+
6+
import "github.com/larksuite/cli/errs"
7+
8+
// vcCodeMeta holds vc-service Lark code → CodeMeta mappings.
9+
// Only codes whose meaning is verifiable from repo evidence are registered;
10+
// ambiguous codes (e.g. 124002 "recording still generating", which has no
11+
// precise taxonomy fit) fall back to CategoryAPI via BuildAPIError and rely on
12+
// per-command enrichment for a retry hint.
13+
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
14+
var vcCodeMeta = map[int]CodeMeta{
15+
121004: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // meeting has no minute file
16+
121005: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // caller is not a participant / lacks view permission
17+
}
18+
19+
func init() { mergeCodeMeta(vcCodeMeta, "vc") }

lint/errscontract/rule_no_legacy_envelope_literal.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import (
1717
// appending their path prefix here.
1818
var migratedEnvelopePaths = []string{
1919
"shortcuts/drive/",
20+
"shortcuts/minutes/",
21+
"shortcuts/vc/",
2022
}
2123

2224
// legacyOutputImportPath is the import path of the package that declares the

shortcuts/minutes/minutes_download.go

Lines changed: 48 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"strings"
1818
"time"
1919

20+
"github.com/larksuite/cli/errs"
2021
"github.com/larksuite/cli/extension/fileio"
2122
"github.com/larksuite/cli/internal/output"
2223
"github.com/larksuite/cli/internal/validate"
@@ -54,21 +55,21 @@ var MinutesDownload = common.Shortcut{
5455
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
5556
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
5657
if len(tokens) == 0 {
57-
return output.ErrValidation("--minute-tokens is required")
58+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-tokens is required").WithParam("--minute-tokens")
5859
}
5960
if len(tokens) > maxBatchSize {
60-
return output.ErrValidation("--minute-tokens: too many tokens (%d), maximum is %d", len(tokens), maxBatchSize)
61+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-tokens: too many tokens (%d), maximum is %d", len(tokens), maxBatchSize).WithParam("--minute-tokens")
6162
}
6263
for _, token := range tokens {
6364
if !validMinuteToken.MatchString(token) {
64-
return output.ErrValidation("invalid minute token %q: must contain only lowercase alphanumeric characters (e.g. obcnq3b9jl72l83w4f149w9c)", token)
65+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid minute token %q: must contain only lowercase alphanumeric characters (e.g. obcnq3b9jl72l83w4f149w9c)", token).WithParam("--minute-tokens")
6566
}
6667
}
6768
// Cheap checks first, then path-safety resolution.
6869
out := runtime.Str("output")
6970
outDir := runtime.Str("output-dir")
7071
if out != "" && outDir != "" {
71-
return output.ErrValidation("--output and --output-dir cannot both be set")
72+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output and --output-dir cannot both be set").WithParam("--output")
7273
}
7374
if out != "" {
7475
if err := common.ValidateSafePath(runtime.FileIO(), out); err != nil {
@@ -112,15 +113,15 @@ var MinutesDownload = common.Shortcut{
112113
explicitOutputPath = ""
113114
case statErr == nil && !fi.IsDir():
114115
if !single {
115-
return output.ErrValidation("--output %q is a file; batch mode expects a directory (use --output-dir)", explicitOutputPath)
116+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output %q is a file; batch mode expects a directory (use --output-dir)", explicitOutputPath).WithParam("--output")
116117
}
117118
case errors.Is(statErr, fs.ErrNotExist):
118119
if !single {
119120
explicitOutputDir = explicitOutputPath
120121
explicitOutputPath = ""
121122
}
122123
default:
123-
return output.Errorf(output.ExitAPI, "io_error", "cannot access --output %q: %s", explicitOutputPath, statErr)
124+
return errs.NewInternalError(errs.SubtypeFileIO, "cannot access --output %q: %s", explicitOutputPath, statErr).WithCause(statErr)
124125
}
125126
}
126127

@@ -137,6 +138,7 @@ var MinutesDownload = common.Shortcut{
137138
SizeBytes int64 `json:"size_bytes,omitempty"`
138139
DownloadURL string `json:"download_url,omitempty"`
139140
Error string `json:"error,omitempty"`
141+
err error // raw typed error for single-mode passthrough
140142
}
141143

142144
results := make([]result, len(tokens))
@@ -151,18 +153,18 @@ var MinutesDownload = common.Shortcut{
151153
// download URLs originate from the trusted Lark API, not user input.
152154
baseClient, err := runtime.Factory.HttpClient()
153155
if err != nil {
154-
return output.ErrNetwork("failed to get HTTP client: %s", err)
156+
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to get HTTP client: %s", err).WithCause(err)
155157
}
156158
clonedClient := *baseClient
157159
clonedClient.Timeout = disableClientTimeout
158160
clonedClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
159161
if len(via) >= maxDownloadRedirects {
160-
return fmt.Errorf("too many redirects")
162+
return fmt.Errorf("too many redirects") //nolint:forbidigo // returned to net/http CheckRedirect, not a CLI terminal error
161163
}
162164
if len(via) > 0 {
163165
prev := via[len(via)-1]
164166
if strings.EqualFold(prev.URL.Scheme, "https") && strings.EqualFold(req.URL.Scheme, "http") {
165-
return fmt.Errorf("redirect from https to http is not allowed")
167+
return fmt.Errorf("redirect from https to http is not allowed") //nolint:forbidigo // returned to net/http CheckRedirect, not a CLI terminal error
166168
}
167169
}
168170
return validate.ValidateDownloadSourceURL(req.Context(), req.URL.String())
@@ -193,7 +195,7 @@ var MinutesDownload = common.Shortcut{
193195

194196
downloadURL, err := fetchDownloadURL(ctx, runtime, token)
195197
if err != nil {
196-
results[i] = result{MinuteToken: token, Error: err.Error()}
198+
results[i] = result{MinuteToken: token, Error: err.Error(), err: err}
197199
continue
198200
}
199201

@@ -220,7 +222,7 @@ var MinutesDownload = common.Shortcut{
220222

221223
dl, err := downloadMediaFile(ctx, dlClient, downloadURL, token, opts)
222224
if err != nil {
223-
results[i] = result{MinuteToken: token, Error: err.Error()}
225+
results[i] = result{MinuteToken: token, Error: err.Error(), err: err}
224226
continue
225227
}
226228
results[i] = result{
@@ -235,7 +237,10 @@ var MinutesDownload = common.Shortcut{
235237
if single {
236238
r := results[0]
237239
if r.Error != "" {
238-
return output.ErrAPI(0, r.Error, nil)
240+
if r.err != nil {
241+
return r.err // typed error from fetchDownloadURL/downloadMediaFile, exit code preserved
242+
}
243+
return runtime.OutPartialFailure(map[string]interface{}{"downloads": results}, &output.Meta{Count: len(results)})
239244
}
240245
if urlOnly {
241246
runtime.Out(map[string]interface{}{
@@ -262,25 +267,27 @@ var MinutesDownload = common.Shortcut{
262267
}
263268
fmt.Fprintf(errOut, "[minutes +download] done: %d total, %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
264269

265-
runtime.OutFormat(map[string]interface{}{"downloads": results}, &output.Meta{Count: len(results)}, nil)
270+
outData := map[string]interface{}{"downloads": results}
271+
meta := &output.Meta{Count: len(results)}
266272
if successCount == 0 && len(results) > 0 {
267-
return output.ErrAPI(0, fmt.Sprintf("all %d downloads failed", len(results)), nil)
273+
return runtime.OutPartialFailure(outData, meta)
268274
}
275+
runtime.OutFormat(outData, meta, nil)
269276
return nil
270277
},
271278
}
272279

273280
// fetchDownloadURL retrieves the pre-signed download URL for a minute token.
274281
func fetchDownloadURL(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) (string, error) {
275-
data, err := runtime.DoAPIJSON(http.MethodGet,
282+
data, err := runtime.CallAPITyped(http.MethodGet,
276283
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/media", validate.EncodePathSegment(minuteToken)),
277284
nil, nil)
278285
if err != nil {
279286
return "", err
280287
}
281288
downloadURL := common.GetString(data, "download_url")
282289
if downloadURL == "" {
283-
return "", output.Errorf(output.ExitAPI, "api_error", "API returned empty download_url for %s", minuteToken)
290+
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned empty download_url for %s", minuteToken)
284291
}
285292
return downloadURL, nil
286293
}
@@ -302,26 +309,26 @@ type downloadOpts struct {
302309
// Filename resolution: opts.outputPath > Content-Disposition filename > Content-Type ext > <token>.media.
303310
func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, minuteToken string, opts downloadOpts) (*downloadResult, error) {
304311
if err := validate.ValidateDownloadSourceURL(ctx, downloadURL); err != nil {
305-
return nil, output.ErrValidation("blocked download URL: %s", err)
312+
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "blocked download URL: %s", err).WithCause(err)
306313
}
307314

308315
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
309316
if err != nil {
310-
return nil, output.ErrNetwork("invalid download URL: %s", err)
317+
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "invalid download URL: %s", err).WithCause(err)
311318
}
312319

313320
resp, err := client.Do(req)
314321
if err != nil {
315-
return nil, output.ErrNetwork("download failed: %s", err)
322+
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: %s", err).WithCause(err)
316323
}
317324
defer resp.Body.Close()
318325

319326
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
320327
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
321328
if len(body) > 0 {
322-
return nil, output.ErrNetwork("download failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
329+
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
323330
}
324-
return nil, output.ErrNetwork("download failed: HTTP %d", resp.StatusCode)
331+
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d", resp.StatusCode)
325332
}
326333

327334
// resolve output path
@@ -340,7 +347,7 @@ func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, mi
340347

341348
if !opts.overwrite {
342349
if _, statErr := opts.fio.Stat(outputPath); statErr == nil {
343-
return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
350+
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s (use --overwrite to replace)", outputPath)
344351
}
345352
}
346353

@@ -349,7 +356,7 @@ func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, mi
349356
ContentLength: resp.ContentLength,
350357
}, resp.Body)
351358
if err != nil {
352-
return nil, common.WrapSaveErrorByCategory(err, "io")
359+
return nil, minutesSaveError(err)
353360
}
354361
resolvedPath, err := opts.fio.ResolvePath(outputPath)
355362
if err != nil || resolvedPath == "" {
@@ -417,3 +424,21 @@ func extFromContentType(contentType string) string {
417424
}
418425
return ""
419426
}
427+
428+
// minutesSaveError maps a FileIO.Save error to a typed error: path
429+
// validation failures are validation errors; mkdir/write failures are
430+
// internal file-I/O errors.
431+
func minutesSaveError(err error) error {
432+
if err == nil {
433+
return nil
434+
}
435+
var me *fileio.MkdirError
436+
switch {
437+
case errors.Is(err, fileio.ErrPathValidation):
438+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithCause(err)
439+
case errors.As(err, &me):
440+
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create parent directory: %s", err).WithCause(err)
441+
default:
442+
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create file: %s", err).WithCause(err)
443+
}
444+
}

0 commit comments

Comments
 (0)