diff --git a/.golangci.yml b/.golangci.yml index 1e2c5ebd6..175ee254a 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -73,20 +73,20 @@ linters: - forbidigo # errs-typed-only enforced on paths already migrated to errs.NewXxxError. # 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/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/) + - 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/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/) text: errs-typed-only linters: - forbidigo # errs-no-bare-wrap enforced on paths fully migrated to typed final # errors. Scoped separately from errs-typed-only because cmd/auth/, # cmd/config/ still have residual fmt.Errorf and must not be caught. - - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go) + - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/|shortcuts/common/mcp_client\.go) text: errs-no-bare-wrap linters: - forbidigo # errs-no-legacy-helper enforced on domains whose shared validation/save # helpers have migrated to typed final errors. - - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/) + - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/) text: errs-no-legacy-helper linters: - forbidigo diff --git a/cmd/root_integration_test.go b/cmd/root_integration_test.go index 104ae4e8a..2948cf028 100644 --- a/cmd/root_integration_test.go +++ b/cmd/root_integration_test.go @@ -377,9 +377,9 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) { OK: false, Identity: "bot", Error: &output.ErrDetail{ - Type: "api_error", + Type: "api", Code: 230002, - Message: "HTTP 400: Bot/User can NOT be out of the chat.", + Message: "Bot/User can NOT be out of the chat.", }, }) } diff --git a/lint/errscontract/rule_no_legacy_envelope_literal.go b/lint/errscontract/rule_no_legacy_envelope_literal.go index b6a1ec3cf..37cada331 100644 --- a/lint/errscontract/rule_no_legacy_envelope_literal.go +++ b/lint/errscontract/rule_no_legacy_envelope_literal.go @@ -23,6 +23,7 @@ var migratedEnvelopePaths = []string{ "shortcuts/okr/", "shortcuts/task/", "shortcuts/whiteboard/", + "shortcuts/im/", } // legacyOutputImportPath is the import path of the package that declares the diff --git a/lint/errscontract/rules_test.go b/lint/errscontract/rules_test.go index 5baa80489..2e222ab3b 100644 --- a/lint/errscontract/rules_test.go +++ b/lint/errscontract/rules_test.go @@ -691,7 +691,7 @@ func boom() error { return &output.ExitError{Code: 1} } ` - v := CheckNoLegacyEnvelopeLiteral("shortcuts/im/foo.go", src) + v := CheckNoLegacyEnvelopeLiteral("shortcuts/contact/foo.go", src) if len(v) != 0 { t.Errorf("non-migrated path should pass, got: %+v", v) } @@ -900,14 +900,14 @@ func boom(runtime *common.RuntimeContext) error { } func TestCheckNoLegacyRuntimeAPICall_IgnoresNonMigratedPath(t *testing.T) { - src := `package im + src := `package contact func boom(runtime *common.RuntimeContext) error { _, err := runtime.CallAPI("POST", "/x", nil, nil) return err } ` - v := CheckNoLegacyRuntimeAPICall("shortcuts/im/im_send.go", src) + v := CheckNoLegacyRuntimeAPICall("shortcuts/contact/contact_get.go", src) if len(v) != 0 { t.Errorf("non-migrated path must not fire, got: %+v", v) } @@ -998,7 +998,7 @@ func boom() { } func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) { - src := `package im + src := `package contact import "github.com/larksuite/cli/shortcuts/common" @@ -1006,7 +1006,7 @@ func boom() { common.FlagErrorf("legacy allowed until domain migrates") } ` - v := CheckNoLegacyCommonHelperCall("shortcuts/im/im_send.go", src) + v := CheckNoLegacyCommonHelperCall("shortcuts/contact/contact_get.go", src) if len(v) != 0 { t.Errorf("non-migrated path must pass, got: %+v", v) } diff --git a/shortcuts/common/call_api_typed_test.go b/shortcuts/common/call_api_typed_test.go index 40925e029..d05144487 100644 --- a/shortcuts/common/call_api_typed_test.go +++ b/shortcuts/common/call_api_typed_test.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/cobra" "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" @@ -198,3 +199,58 @@ func TestCallAPITyped_NonObjectJSON(t *testing.T) { t.Errorf("subtype = %q, want %q", intErr.Subtype, errs.SubtypeInvalidResponse) } } + +// TestDoAPIJSONTyped_Success returns the data object on code 0, confirming the +// typed DoAPIJSON replacement preserves the success contract of DoAPIJSON. +func TestDoAPIJSONTyped_Success(t *testing.T) { + rt, reg := newCallAPITypedRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/x/z", + Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{"id": "z1"}}, + }) + + data, err := rt.DoAPIJSONTyped("GET", "/open-apis/x/z", nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if data["id"] != "z1" { + t.Errorf("data[id] = %v, want z1", data["id"]) + } +} + +func TestDoAPIJSONTyped_RawClientErrorBecomesTypedInternal(t *testing.T) { + rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+x"}, &core.CliConfig{}, nil, core.AsUser) + rt.apiClientFunc = func() (*client.APIClient, error) { + return nil, errors.New("raw client construction error") + } + + _, err := rt.DoAPIJSONTyped("GET", "/open-apis/x/z", nil, nil) + var internalErr *errs.InternalError + if !errors.As(err, &internalErr) { + t.Fatalf("expected raw client errors to be lifted to typed internal errors, got %T: %v", err, err) + } + if internalErr.Subtype != errs.SubtypeUnknown { + t.Errorf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeUnknown) + } +} + +// TestDoAPIJSONTyped_NonZeroCode classifies a non-zero API code into a typed +// errs.* error (carrying log_id), never a legacy output.ExitError envelope. +func TestDoAPIJSONTyped_NonZeroCode(t *testing.T) { + rt, reg := newCallAPITypedRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/x/z", + Body: map[string]interface{}{"code": float64(1061044), "msg": "boom", "log_id": "lz"}, + }) + + _, err := rt.DoAPIJSONTyped("POST", "/open-apis/x/z", nil, map[string]any{}) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected a typed errs.* error, got %T: %v", err, err) + } + if p.LogID != "lz" { + t.Errorf("LogID = %q, want lz", p.LogID) + } +} diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index db9141286..59be2040f 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -492,6 +492,28 @@ func (ctx *RuntimeContext) DoAPIJSONWithLogID(method, apiPath string, query lark return ctx.doAPIJSON(method, apiPath, query, body, true) } +// DoAPIJSONTyped is the typed-only replacement for DoAPIJSON: it issues the same +// larkcore.ApiReq request (identical method / path / query / body model) but +// classifies failures into typed errs.* errors via ClassifyAPIResponse instead +// of emitting a legacy output.ExitError "api_error" envelope. A transport / auth +// error from the client boundary is already typed and passes through unchanged; +// a non-zero API code is classified with subtype / code / log_id. +func (ctx *RuntimeContext) DoAPIJSONTyped(method, apiPath string, query larkcore.QueryParams, body any) (map[string]any, error) { + req := &larkcore.ApiReq{ + HttpMethod: method, + ApiPath: apiPath, + QueryParams: query, + } + if body != nil { + req.Body = body + } + resp, err := ctx.DoAPI(req) + if err != nil { + return nil, typedOrInternal(err) + } + return ctx.ClassifyAPIResponse(resp) +} + func (ctx *RuntimeContext) doAPIJSON(method, apiPath string, query larkcore.QueryParams, body any, includeLogID bool) (map[string]any, error) { req := &larkcore.ApiReq{ HttpMethod: method, @@ -682,6 +704,9 @@ func WrapSaveErrorTyped(err error) error { if err == nil { return nil } + if _, ok := errs.ProblemOf(err); ok { + return err + } var me *fileio.MkdirError switch { case errors.Is(err, fileio.ErrPathValidation): diff --git a/shortcuts/common/validate_ids.go b/shortcuts/common/validate_ids.go index 69914789d..efc6b210e 100644 --- a/shortcuts/common/validate_ids.go +++ b/shortcuts/common/validate_ids.go @@ -9,18 +9,6 @@ import ( "github.com/larksuite/cli/internal/output" ) -// ValidateChatID checks if a chat ID has valid format (oc_ prefix). -// Also extracts token from URL if provided. -// -// Deprecated: use ValidateChatIDTyped for typed error envelopes. -func ValidateChatID(input string) (string, error) { - chatID, msg := normalizeChatID(input) - if msg != "" { - return "", output.ErrValidation("%s", msg) - } - return chatID, nil -} - // ValidateChatIDTyped checks if a chat ID has valid format (oc_ prefix). // Also extracts token from URL if provided. param names the flag being // validated (e.g. "--chat-ids") and is recorded on the typed error. diff --git a/shortcuts/common/validate_test.go b/shortcuts/common/validate_test.go index 4a58e9dd4..c05226caa 100644 --- a/shortcuts/common/validate_test.go +++ b/shortcuts/common/validate_test.go @@ -194,6 +194,21 @@ func TestWrapSaveErrorTyped_ClassifiesPathAndFileIO(t *testing.T) { } } +func TestWrapSaveErrorTyped_PreservesTypedWriteCause(t *testing.T) { + typed := errs.NewNetworkError(errs.SubtypeNetworkServer, "HTTP 500: chunk failed"). + WithCode(500) + err := WrapSaveErrorTyped(&fileio.WriteError{Err: typed}) + + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed problem, got %T: %v", err, err) + } + if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkServer || p.Code != 500 { + t.Fatalf("problem = category %q subtype %q code %d, want network/%s/500", + p.Category, p.Subtype, p.Code, errs.SubtypeNetworkServer) + } +} + func TestAtLeastOne(t *testing.T) { tests := []struct { name string diff --git a/shortcuts/im/convert_lib/helpers.go b/shortcuts/im/convert_lib/helpers.go index 75368f45f..a9cec1d74 100644 --- a/shortcuts/im/convert_lib/helpers.go +++ b/shortcuts/im/convert_lib/helpers.go @@ -162,7 +162,7 @@ func batchResolveByBasicContact(runtime *common.RuntimeContext, missingIDs []str } batch := missingIDs[i:end] - data, err := runtime.DoAPIJSON(http.MethodPost, + data, err := runtime.DoAPIJSONTyped(http.MethodPost, "/open-apis/contact/v3/users/basic_batch", larkcore.QueryParams{"user_id_type": []string{"open_id"}}, map[string]interface{}{"user_ids": batch}, @@ -198,7 +198,7 @@ func batchResolveUsers(runtime *common.RuntimeContext, missingIDs []string, name } apiURL := "/open-apis/contact/v3/users/batch?" + strings.Join(parts, "&") - data, err := runtime.DoAPIJSON(http.MethodGet, apiURL, nil, nil) + data, err := runtime.DoAPIJSONTyped(http.MethodGet, apiURL, nil, nil) if err != nil { break } diff --git a/shortcuts/im/convert_lib/merge.go b/shortcuts/im/convert_lib/merge.go index aece5f73b..08c1808ae 100644 --- a/shortcuts/im/convert_lib/merge.go +++ b/shortcuts/im/convert_lib/merge.go @@ -200,20 +200,20 @@ func batchResolveMergeForwardSenders(runtime *common.RuntimeContext, prefetch ma // container via a single API call. Returns a flat list of raw message items // with upper_message_id for tree reconstruction. // -// Uses DoAPIJSON so the response envelope's code/msg are checked and surfaced +// Uses DoAPIJSONTyped so the response envelope's code/msg are checked and surfaced // — earlier this used the low-level DoAPI and reported every non-zero code // as a generic "empty data" error, hiding the real failure (e.g. a server // "code: 2200 Internal Error" with its log_id would show up as just "empty // data" in the output). func fetchMergeForwardSubMessages(messageID string, runtime *common.RuntimeContext) ([]map[string]interface{}, error) { - data, err := runtime.DoAPIJSON(http.MethodGet, mergeForwardMessagesPath(messageID), larkcore.QueryParams{ + data, err := runtime.DoAPIJSONTyped(http.MethodGet, mergeForwardMessagesPath(messageID), larkcore.QueryParams{ "user_id_type": []string{"open_id"}, "card_msg_content_type": []string{"raw_card_content"}, }, nil) if err != nil { return nil, err } - // DoAPIJSON returns the envelope's `data` field; when the server's JSON + // DoAPIJSONTyped returns the envelope's `data` field; when the server's JSON // has `code: 0` but omits `data` entirely, that field comes back as nil. // Reading from a nil map in Go is safe (returns the zero value, never // panics), but guarding explicitly makes the "successful empty diff --git a/shortcuts/im/convert_lib/reactions.go b/shortcuts/im/convert_lib/reactions.go index 3c39d1fc1..1f61170b2 100644 --- a/shortcuts/im/convert_lib/reactions.go +++ b/shortcuts/im/convert_lib/reactions.go @@ -156,7 +156,7 @@ func fetchReactionsBatch(runtime *common.RuntimeContext, batchIDs []string, idIn queries = append(queries, map[string]interface{}{"message_id": id}) } - data, err := runtime.DoAPIJSON(http.MethodPost, + data, err := runtime.DoAPIJSONTyped(http.MethodPost, "/open-apis/im/v1/messages/reactions/batch_query", nil, map[string]interface{}{"queries": queries}, diff --git a/shortcuts/im/convert_lib/thread.go b/shortcuts/im/convert_lib/thread.go index 2f6e80353..1447406d9 100644 --- a/shortcuts/im/convert_lib/thread.go +++ b/shortcuts/im/convert_lib/thread.go @@ -243,7 +243,7 @@ func ExpandThreadReplies(runtime *common.RuntimeContext, messages []map[string]i // Returns the raw message items, whether more replies exist beyond the limit, // and a non-nil error when the API call fails. func fetchThreadReplies(runtime *common.RuntimeContext, threadID string, limit int) ([]map[string]interface{}, bool, error) { - data, err := runtime.DoAPIJSON(http.MethodGet, "/open-apis/im/v1/messages", larkcore.QueryParams{ + data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", larkcore.QueryParams{ "container_id_type": []string{"thread"}, "container_id": []string{threadID}, "sort_type": []string{"ByCreateTimeAsc"}, @@ -251,7 +251,7 @@ func fetchThreadReplies(runtime *common.RuntimeContext, threadID string, limit i "card_msg_content_type": []string{"raw_card_content"}, }, nil) if err != nil { - return nil, false, fmt.Errorf("fetch thread replies for %s: %w", threadID, err) + return nil, false, fmt.Errorf("fetch thread replies for %s: %w", threadID, err) //nolint:forbidigo // best-effort internal thread fetch; never surfaced as a final shortcut error (ExpandThreadReplies is void) } hasMore, _ := data["has_more"].(bool) rawItems, _ := data["items"].([]interface{}) diff --git a/shortcuts/im/helpers.go b/shortcuts/im/helpers.go index ee0f54bb3..fd70ba8a0 100644 --- a/shortcuts/im/helpers.go +++ b/shortcuts/im/helpers.go @@ -19,10 +19,10 @@ import ( "strconv" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/credential" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" @@ -37,11 +37,11 @@ var messageIDRe = regexp.MustCompile(`^om_`) func flagMessageID(rt *common.RuntimeContext) (string, error) { id := strings.TrimSpace(rt.Str("message-id")) if id == "" { - return "", output.ErrValidation("--message-id is required") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-id is required").WithParam("--message-id") } if strings.HasPrefix(id, "omt_") { - return "", output.ErrValidation( - "invalid message ID %q: omt_ prefix is a thread ID, not a message ID; flag operations require om_ message IDs", id) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, + "invalid message ID %q: omt_ prefix is a thread ID, not a message ID; flag operations require om_ message IDs", id).WithParam("--message-id") } return validateMessageID(id) } @@ -65,10 +65,10 @@ func buildMGetURL(ids []string) string { func validateMessageID(input string) (string, error) { input = strings.TrimSpace(input) if input == "" { - return "", output.ErrValidation("message ID cannot be empty") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "message ID cannot be empty").WithParam("--message-id") } if !strings.HasPrefix(input, "om_") { - return "", output.ErrValidation("invalid message ID %q: must start with om_", input) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid message ID %q: must start with om_", input).WithParam("--message-id") } return input, nil } @@ -173,14 +173,16 @@ func sanitizeURLForDisplay(rawURL string) string { // startURLDownload performs URL validation, creates an HTTP client, and sends a // GET request. It returns the response (with Body still open) and the file // extension inferred from the URL. The caller must close resp.Body. -func startURLDownload(ctx context.Context, runtime *common.RuntimeContext, rawURL string) (*http.Response, string, error) { +func startURLDownload(ctx context.Context, runtime *common.RuntimeContext, rawURL, param string) (*http.Response, string, error) { if err := validate.ValidateDownloadSourceURL(ctx, rawURL); err != nil { - return nil, "", fmt.Errorf("blocked URL: %w", err) + return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "blocked URL: %v", err). + WithParam(param). + WithCause(err) } httpClient, err := runtime.Factory.HttpClient() if err != nil { - return nil, "", fmt.Errorf("http client: %w", err) + return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "http client: %v", err).WithCause(err) } httpClient = validate.NewDownloadHTTPClient(httpClient, validate.DownloadHTTPClientOptions{ AllowHTTP: true, @@ -188,17 +190,19 @@ func startURLDownload(ctx context.Context, runtime *common.RuntimeContext, rawUR req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { - return nil, "", fmt.Errorf("invalid URL: %w", err) + return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid URL: %v", err). + WithParam(param). + WithCause(err) } resp, err := httpClient.Do(req) if err != nil { - return nil, "", fmt.Errorf("download failed: %w", err) + return nil, "", wrapIMNetworkErr(err, "download failed") } if resp.StatusCode != http.StatusOK { resp.Body.Close() - return nil, "", fmt.Errorf("download failed: HTTP %d", resp.StatusCode) + return nil, "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d", resp.StatusCode) } ext := filepath.Ext(fileNameFromURL(rawURL)) @@ -208,8 +212,8 @@ func startURLDownload(ctx context.Context, runtime *common.RuntimeContext, rawUR // downloadURLToReader returns a size-limited io.ReadCloser for the URL content // and the file extension inferred from the URL. The caller must close the // returned ReadCloser. No temp file is created and the content is not buffered. -func downloadURLToReader(ctx context.Context, runtime *common.RuntimeContext, rawURL string, maxSize int64) (io.ReadCloser, string, error) { - resp, ext, err := startURLDownload(ctx, runtime, rawURL) //nolint:bodyclose // resp.Body is closed by the returned limitedReadCloser +func downloadURLToReader(ctx context.Context, runtime *common.RuntimeContext, rawURL string, maxSize int64, param string) (io.ReadCloser, string, error) { + resp, ext, err := startURLDownload(ctx, runtime, rawURL, param) //nolint:bodyclose // resp.Body is closed by the returned limitedReadCloser if err != nil { return nil, "", err } @@ -233,7 +237,7 @@ func (l *limitedReadCloser) Read(p []byte) (int, error) { n, err := l.r.Read(p) l.n += int64(n) if l.n > l.max { - return n, fmt.Errorf("download exceeds size limit (max %s)", common.FormatSize(l.max)) + return n, fmt.Errorf("download exceeds size limit (max %s)", common.FormatSize(l.max)) //nolint:forbidigo // io.Reader.Read contract returns a plain error; classified by the download caller } return n, err } @@ -314,7 +318,7 @@ func resolveURLMedia(ctx context.Context, runtime *common.RuntimeContext, s medi fmt.Fprintf(runtime.IO().ErrOut, "downloading %s: %s\n", s.flagName, sanitizeURLForDisplay(s.value)) if s.kind == mediaKindImage { - rc, _, err := downloadURLToReader(ctx, runtime, s.value, s.maxSize) + rc, _, err := downloadURLToReader(ctx, runtime, s.value, s.maxSize, s.flagName) if err != nil { return "", err } @@ -324,7 +328,7 @@ func resolveURLMedia(ctx context.Context, runtime *common.RuntimeContext, s medi } // File-kind: buffer in memory for possible duration parsing. - mb, err := newMediaBuffer(ctx, runtime, s.value, s.maxSize) + mb, err := newMediaBuffer(ctx, runtime, s.value, s.maxSize, s.flagName) if err != nil { return "", err } @@ -341,7 +345,7 @@ func resolveLocalMedia(ctx context.Context, runtime *common.RuntimeContext, s me fmt.Fprintf(runtime.IO().ErrOut, "uploading %s: %s\n", s.mediaType, filepath.Base(s.value)) if s.kind == mediaKindImage { - return uploadImageToIM(ctx, runtime, s.value, "message") + return uploadImageToIM(ctx, runtime, s.value, "message", s.flagName) } ft := detectIMFileType(s.value) @@ -349,7 +353,7 @@ func resolveLocalMedia(ctx context.Context, runtime *common.RuntimeContext, s me if s.withDuration { dur = parseMediaDuration(runtime, s.value, ft) } - return uploadFileToIM(ctx, runtime, s.value, ft, dur) + return uploadFileToIM(ctx, runtime, s.value, ft, dur, s.flagName) } // resolveVideoContent handles the video case which needs both a file_key and @@ -370,7 +374,7 @@ func resolveVideoContent(ctx context.Context, runtime *common.RuntimeContext, vi } coverKey, err := resolveOneMedia(ctx, runtime, coverSpec) if err != nil { - return "", "", fmt.Errorf("cover image upload failed: %w", err) + return "", "", wrapIMNetworkErr(err, "cover image upload failed") } jsonBytes, _ := json.Marshal(map[string]string{"file_key": fKey, "image_key": coverKey}) @@ -386,13 +390,13 @@ func mediaFallbackOrError(originalValue, mediaType string, uploadErr error) (str jsonBytes, _ := json.Marshal(map[string]string{"text": fallbackText}) return "text", string(jsonBytes), nil } - return "", "", fmt.Errorf("%s upload failed: %w", mediaType, uploadErr) + return "", "", wrapIMNetworkErr(uploadErr, "%s upload failed", mediaType) } // resolveP2PChatID resolves user open_id to P2P chat_id. func resolveP2PChatID(runtime *common.RuntimeContext, openID string) (string, error) { if runtime.IsBot() { - return "", output.Errorf(output.ExitValidation, "validation", "--user-id requires user identity (--as user); use --chat-id when calling with bot identity") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id requires user identity (--as user); use --chat-id when calling with bot identity").WithParam("--user-id") } apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ HttpMethod: http.MethodPost, @@ -405,11 +409,10 @@ func resolveP2PChatID(runtime *common.RuntimeContext, openID string) (string, er if err != nil { return "", err } - var result map[string]interface{} - if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { - return "", fmt.Errorf("failed to parse chat_p2p response: %w", err) + data, err := runtime.ClassifyAPIResponse(apiResp) + if err != nil { + return "", err } - data, _ := result["data"].(map[string]interface{}) chats, _ := data["p2p_chats"].([]interface{}) for _, item := range chats { @@ -420,7 +423,7 @@ func resolveP2PChatID(runtime *common.RuntimeContext, openID string) (string, er } } - return "", output.Errorf(output.ExitAPI, "not_found", "P2P chat not found for this user") + return "", errs.NewAPIError(errs.SubtypeNotFound, "P2P chat not found for this user") } // resolveThreadID normalizes a message ID to its thread ID when possible. @@ -429,7 +432,7 @@ func resolveThreadID(runtime *common.RuntimeContext, id string) (string, error) return id, nil } if !messageIDRe.MatchString(id) { - return "", output.Errorf(output.ExitValidation, "validation", "invalid thread ID format: must start with om_ or omt_") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid thread ID format: must start with om_ or omt_").WithParam("--thread") } apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ @@ -439,11 +442,10 @@ func resolveThreadID(runtime *common.RuntimeContext, id string) (string, error) if err != nil { return "", err } - var result map[string]interface{} - if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { - return "", fmt.Errorf("failed to parse message response: %w", err) + data, err := runtime.ClassifyAPIResponse(apiResp) + if err != nil { + return "", err } - data, _ := result["data"].(map[string]interface{}) items, _ := data["items"].([]interface{}) for _, item := range items { @@ -454,7 +456,7 @@ func resolveThreadID(runtime *common.RuntimeContext, id string) (string, error) } } - return "", output.Errorf(output.ExitAPI, "not_found", "thread ID not found for this message") + return "", errs.NewAPIError(errs.SubtypeNotFound, "thread ID not found for this message") } // parseOggOpusDuration parses the duration in milliseconds from an OGG/Opus @@ -612,8 +614,8 @@ type mediaBuffer struct { } // newMediaBuffer downloads URL content into memory via downloadURLToReader. -func newMediaBuffer(ctx context.Context, runtime *common.RuntimeContext, rawURL string, maxSize int64) (*mediaBuffer, error) { - rc, ext, err := downloadURLToReader(ctx, runtime, rawURL, maxSize) +func newMediaBuffer(ctx context.Context, runtime *common.RuntimeContext, rawURL string, maxSize int64, param string) (*mediaBuffer, error) { + rc, ext, err := downloadURLToReader(ctx, runtime, rawURL, maxSize, param) if err != nil { return nil, err } @@ -621,7 +623,7 @@ func newMediaBuffer(ctx context.Context, runtime *common.RuntimeContext, rawURL data, err := io.ReadAll(rc) if err != nil { - return nil, fmt.Errorf("download failed: %w", err) + return nil, wrapIMNetworkErr(err, "download failed") } return newMediaBufferFromBytes(data, ext, rawURL), nil } @@ -927,7 +929,7 @@ func resolveMarkdownImageURLs(ctx context.Context, runtime *common.RuntimeContex } imgURL := sub[1] - rc, _, err := downloadURLToReader(ctx, runtime, imgURL, maxImageUploadSize) + rc, _, err := downloadURLToReader(ctx, runtime, imgURL, maxImageUploadSize, "--markdown") if err != nil { fmt.Fprintf(runtime.IO().ErrOut, "warning: failed to download image %s: %v\n", sanitizeURLForDisplay(imgURL), err) return "" @@ -1049,14 +1051,14 @@ func detectIMFileType(filePath string) string { const maxImageUploadSize = 5 * 1024 * 1024 // 5MB — Lark API limit for images const maxFileUploadSize = 100 * 1024 * 1024 // 100MB — Lark API limit for files -func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePath, imageType string) (string, error) { +func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePath, imageType, param string) (string, error) { if info, err := runtime.FileIO().Stat(filePath); err == nil && info.Size() > maxImageUploadSize { - return "", fmt.Errorf("image size %s exceeds limit (max 5MB)", common.FormatSize(info.Size())) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "image size %s exceeds limit (max 5MB)", common.FormatSize(info.Size())).WithParam(param) } f, err := runtime.FileIO().Open(filePath) if err != nil { - return "", err + return "", withIMValidationParam(common.WrapInputStatErrorTyped(err), param) } defer f.Close() @@ -1073,27 +1075,25 @@ func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePa return "", err } - var result map[string]interface{} - if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { - return "", fmt.Errorf("parse error: %w", err) + data, err := runtime.ClassifyAPIResponse(apiResp) + if err != nil { + return "", err } - - data, _ := result["data"].(map[string]interface{}) imageKey, _ := data["image_key"].(string) if imageKey == "" { - return "", fmt.Errorf("image_key not found in response (code: %v, msg: %v)", result["code"], result["msg"]) + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "image_key missing from a successful upload response") } return imageKey, nil } -func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePath, fileType, duration string) (string, error) { +func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePath, fileType, duration, param string) (string, error) { if info, err := runtime.FileIO().Stat(filePath); err == nil && info.Size() > maxFileUploadSize { - return "", fmt.Errorf("file size %s exceeds limit (max 100MB)", common.FormatSize(info.Size())) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "file size %s exceeds limit (max 100MB)", common.FormatSize(info.Size())).WithParam(param) } f, err := runtime.FileIO().Open(filePath) if err != nil { - return "", err + return "", withIMValidationParam(common.WrapInputStatErrorTyped(err), param) } defer f.Close() @@ -1114,15 +1114,13 @@ func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePat return "", err } - var result map[string]interface{} - if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { - return "", fmt.Errorf("parse error: %w", err) + data, err := runtime.ClassifyAPIResponse(apiResp) + if err != nil { + return "", err } - - data, _ := result["data"].(map[string]interface{}) fileKey, _ := data["file_key"].(string) if fileKey == "" { - return "", fmt.Errorf("file_key not found in response (code: %v, msg: %v)", result["code"], result["msg"]) + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "file_key missing from a successful upload response") } return fileKey, nil } @@ -1142,15 +1140,13 @@ func uploadImageFromReader(ctx context.Context, runtime *common.RuntimeContext, return "", err } - var result map[string]interface{} - if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { - return "", fmt.Errorf("parse error: %w", err) + data, err := runtime.ClassifyAPIResponse(apiResp) + if err != nil { + return "", err } - - data, _ := result["data"].(map[string]interface{}) imageKey, _ := data["image_key"].(string) if imageKey == "" { - return "", fmt.Errorf("image_key not found in response (code: %v, msg: %v)", result["code"], result["msg"]) + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "image_key missing from a successful upload response") } return imageKey, nil } @@ -1174,15 +1170,13 @@ func uploadFileFromReader(ctx context.Context, runtime *common.RuntimeContext, r return "", err } - var result map[string]interface{} - if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { - return "", fmt.Errorf("parse error: %w", err) + data, err := runtime.ClassifyAPIResponse(apiResp) + if err != nil { + return "", err } - - data, _ := result["data"].(map[string]interface{}) fileKey, _ := data["file_key"].(string) if fileKey == "" { - return "", fmt.Errorf("file_key not found in response (code: %v, msg: %v)", result["code"], result["msg"]) + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "file_key missing from a successful upload response") } return fileKey, nil } @@ -1237,9 +1231,9 @@ func checkFlagRequiredScopes(ctx context.Context, rt *common.RuntimeContext, req } result, err := rt.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(rt.As(), rt.Config.AppID)) if err != nil { - return output.ErrWithHint(output.ExitAuth, "auth", - fmt.Sprintf("cannot verify required scope(s): %v", err), - flagScopeLoginHint(required)) + return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "cannot verify required scope(s): %v", err). + WithHint("%s", flagScopeLoginHint(required)). + WithCause(err) } if result == nil || result.Scopes == "" { fmt.Fprintf(rt.IO().ErrOut, @@ -1248,9 +1242,9 @@ func checkFlagRequiredScopes(ctx context.Context, rt *common.RuntimeContext, req return nil } if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 { - return output.ErrWithHint(output.ExitAuth, "missing_scope", - fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")), - flagScopeLoginHint(missing)) + return errs.NewPermissionError(errs.SubtypeMissingScope, "missing required scope(s): %s", strings.Join(missing, ", ")). + WithMissingScopes(missing...). + WithHint("%s", flagScopeLoginHint(missing)) } return nil } @@ -1276,11 +1270,11 @@ func parseItemID(id string) (ItemType, FlagType, error) { case strings.HasPrefix(id, "om_"): return ItemTypeDefault, FlagTypeMessage, nil case id == "": - return 0, 0, output.ErrValidation("--message-id cannot be empty") + return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-id cannot be empty").WithParam("--message-id") default: - return 0, 0, output.ErrValidation( + return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot infer item type from id %q: expected om_ (message) prefix; "+ - "pass --item-type and --flag-type explicitly if you are using a different id format", id) + "pass --item-type and --flag-type explicitly if you are using a different id format", id).WithParam("--message-id") } } @@ -1294,7 +1288,7 @@ func parseItemType(s string) (ItemType, error) { case "msg_thread": return ItemTypeMsgThread, nil } - return 0, output.ErrValidation("invalid --item-type %q: expected one of default|thread|msg_thread", s) + return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --item-type %q: expected one of default|thread|msg_thread", s).WithParam("--item-type") } // parseFlagType converts a user-facing string to the server enum. @@ -1305,7 +1299,7 @@ func parseFlagType(s string) (FlagType, error) { case "feed": return FlagTypeFeed, nil } - return 0, output.ErrValidation("invalid --flag-type %q: expected one of message|feed", s) + return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --flag-type %q: expected one of message|feed", s).WithParam("--flag-type") } // isValidCombo checks if the (ItemType, FlagType) pair is accepted by the server. @@ -1363,24 +1357,24 @@ func newFlagItem(itemID string, it ItemType, ft FlagType) flagItem { // getMessageChatID queries the message API to get the chat_id. // Used by flag-create to determine the chat type for feed-layer flags. func getMessageChatID(rt *common.RuntimeContext, messageID string) (string, error) { - data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/messages/"+validate.EncodePathSegment(messageID), nil, nil) + data, err := rt.DoAPIJSONTyped("GET", "/open-apis/im/v1/messages/"+validate.EncodePathSegment(messageID), nil, nil) if err != nil { return "", err } items, ok := data["items"].([]any) if !ok || len(items) == 0 { - return "", output.ErrValidation("message not found or unexpected API response format") + return "", errs.NewAPIError(errs.SubtypeNotFound, "message not found") } msg, ok := items[0].(map[string]any) if !ok { - return "", output.ErrValidation("unexpected message format in API response") + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "unexpected message format in API response") } chatID, ok := msg["chat_id"].(string) if !ok { - return "", output.ErrValidation("message response missing chat_id field") + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "message response missing chat_id field") } return chatID, nil } @@ -1393,12 +1387,12 @@ func getMessageChatID(rt *common.RuntimeContext, messageID string) (string, erro // Returns an error if the chat query fails, since guessing the wrong item_type // can cause silent failures in flag operations. func resolveThreadFeedItemType(rt *common.RuntimeContext, chatID string) (ItemType, error) { - data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/chats/"+validate.EncodePathSegment(chatID), nil, nil) + data, err := rt.DoAPIJSONTyped("GET", "/open-apis/im/v1/chats/"+validate.EncodePathSegment(chatID), nil, nil) if err != nil { - return ItemTypeDefault, fmt.Errorf("failed to query chat_mode for chat %s: %w", chatID, err) + return ItemTypeDefault, wrapIMNetworkErr(err, "failed to query chat_mode for chat %s", chatID) } - // DoAPIJSON returns envelope.Data, so chat_mode is at the top level + // DoAPIJSONTyped returns envelope.Data, so chat_mode is at the top level chatMode, _ := data["chat_mode"].(string) if chatMode == "topic" { return ItemTypeThread, nil @@ -1433,7 +1427,7 @@ type shortcutItem struct { func collectChatIDs(rt *common.RuntimeContext) ([]string, error) { raw := rt.StrSlice("chat-id") if len(raw) == 0 { - return nil, output.ErrValidation("--chat-id is required (oc_xxx); repeat the flag or pass comma-separated values") + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-id is required (oc_xxx); repeat the flag or pass comma-separated values").WithParam("--chat-id") } seen := make(map[string]struct{}, len(raw)) @@ -1444,8 +1438,8 @@ func collectChatIDs(rt *common.RuntimeContext) ([]string, error) { continue } if !strings.HasPrefix(v, "oc_") { - return nil, output.ErrValidation( - "invalid --chat-id %q: must be an open_chat_id starting with oc_", v) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, + "invalid --chat-id %q: must be an open_chat_id starting with oc_", v).WithParam("--chat-id") } if _, ok := seen[v]; ok { continue @@ -1454,12 +1448,12 @@ func collectChatIDs(rt *common.RuntimeContext) ([]string, error) { out = append(out, v) } if len(out) == 0 { - return nil, output.ErrValidation("--chat-id is required (oc_xxx)") + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-id is required (oc_xxx)").WithParam("--chat-id") } if len(out) > feedShortcutBatchLimit { - return nil, output.ErrValidation( + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "too many --chat-id values (%d); the server accepts up to %d per request", - len(out), feedShortcutBatchLimit) + len(out), feedShortcutBatchLimit).WithParam("--chat-id") } return out, nil } @@ -1511,6 +1505,11 @@ func shortcutTypeFromValue(v any) ShortcutType { return ShortcutType(int(n)) case int: return ShortcutType(n) + case json.Number: + i, err := n.Int64() + if err == nil { + return ShortcutType(i) + } } return ShortcutTypeUnknown } @@ -1520,7 +1519,7 @@ func shortcutTypeFromValue(v any) ShortcutType { // chat_id. Shared by feed-shortcut detail enrichment and message-search chat // context lookup, which apply their own per-chunk error policies. func queryChatBatch(rt *common.RuntimeContext, batch []string, dst map[string]map[string]any) error { - res, err := rt.DoAPIJSON(http.MethodPost, "/open-apis/im/v1/chats/batch_query", + res, err := rt.DoAPIJSONTyped(http.MethodPost, "/open-apis/im/v1/chats/batch_query", larkcore.QueryParams{"user_id_type": []string{"open_id"}}, map[string]any{"chat_ids": batch}) if err != nil { @@ -1633,6 +1632,11 @@ func annotateFailedShortcuts(data map[string]any) { m["reason_label"] = shortcutFailedReasonString(int(r)) case int: m["reason_label"] = shortcutFailedReasonString(r) + case json.Number: + i, err := r.Int64() + if err == nil { + m["reason_label"] = shortcutFailedReasonString(int(i)) + } } } } diff --git a/shortcuts/im/helpers_network_test.go b/shortcuts/im/helpers_network_test.go index a068c5b74..9d75fcf62 100644 --- a/shortcuts/im/helpers_network_test.go +++ b/shortcuts/im/helpers_network_test.go @@ -8,6 +8,7 @@ import ( "context" "crypto/md5" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -22,6 +23,7 @@ import ( 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/extension/fileio" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" @@ -445,8 +447,15 @@ func TestDownloadIMResourceToPathRetryContextCanceled(t *testing.T) { cmdutil.TestChdir(t, t.TempDir()) target := "out.bin" _, _, err := downloadIMResourceToPath(ctx, runtime, "om_cancel", "file_cancel", "file", target, true) - if err != context.Canceled { - t.Fatalf("downloadIMResourceToPath() error = %v, want context.Canceled", err) + if !errors.Is(err, context.Canceled) { + t.Fatalf("downloadIMResourceToPath() error = %v, want errors.Is(context.Canceled)", err) + } + var ne *errs.NetworkError + if !errors.As(err, &ne) { + t.Fatalf("downloadIMResourceToPath() error = %T, want *errs.NetworkError", err) + } + if ne.Subtype != errs.SubtypeNetworkTransport { + t.Fatalf("network subtype = %q, want %q", ne.Subtype, errs.SubtypeNetworkTransport) } // First attempt is made, then retry checks ctx.Err() and returns if attempts != 1 { @@ -600,6 +609,14 @@ func TestDownloadIMResourceToPathRangeChunkFailureCleansOutput(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "HTTP 500: chunk failed") { t.Fatalf("downloadIMResourceToPath() error = %v", err) } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("downloadIMResourceToPath() error = %T, want typed problem", err) + } + if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkServer || p.Code != http.StatusInternalServerError { + t.Fatalf("network problem = subtype %q code %d, want subtype %q code %d", + p.Subtype, p.Code, errs.SubtypeNetworkServer, http.StatusInternalServerError) + } if _, statErr := os.Stat(target); !os.IsNotExist(statErr) { t.Fatalf("output file exists after failed download, stat error = %v", statErr) } @@ -716,7 +733,7 @@ func TestUploadImageToIMSuccess(t *testing.T) { t.Fatalf("WriteFile() error = %v", err) } - got, err := uploadImageToIM(context.Background(), runtime, path, "message") + got, err := uploadImageToIM(context.Background(), runtime, path, "message", "--image") if err != nil { t.Fatalf("uploadImageToIM() error = %v", err) } @@ -754,7 +771,7 @@ func TestUploadFileToIMSuccess(t *testing.T) { t.Fatalf("WriteFile() error = %v", err) } - got, err := uploadFileToIM(context.Background(), runtime, path, "stream", "1200") + got, err := uploadFileToIM(context.Background(), runtime, path, "stream", "1200", "--file") if err != nil { t.Fatalf("uploadFileToIM() error = %v", err) } @@ -784,10 +801,14 @@ func TestUploadImageToIMSizeLimit(t *testing.T) { rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { return nil, fmt.Errorf("unexpected") })) - _, err = uploadImageToIM(context.Background(), rt, path, "message") + _, err = uploadImageToIM(context.Background(), rt, path, "message", "--image") if err == nil || !strings.Contains(err.Error(), "exceeds limit") { t.Fatalf("uploadImageToIM() error = %v", err) } + var ve *errs.ValidationError + if !errors.As(err, &ve) || ve.Param != "--image" { + t.Fatalf("uploadImageToIM() size error must carry Param=--image, got %T %+v", err, err) + } } func TestUploadFileToIMSizeLimit(t *testing.T) { @@ -805,13 +826,21 @@ func TestUploadFileToIMSizeLimit(t *testing.T) { rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { return nil, fmt.Errorf("unexpected") })) - _, err = uploadFileToIM(context.Background(), rt, path, "stream", "") + _, err = uploadFileToIM(context.Background(), rt, path, "stream", "", "--file") if err == nil || !strings.Contains(err.Error(), "exceeds limit") { t.Fatalf("uploadFileToIM() error = %v", err) } + var ve *errs.ValidationError + if !errors.As(err, &ve) || ve.Param != "--file" { + t.Fatalf("uploadFileToIM() size error must carry Param=--file, got %T %+v", err, err) + } } -func TestResolveMediaContentWrapsUploadError(t *testing.T) { +// TestResolveMediaContentMissingLocalFileIsValidation pins that a missing local +// media path is a typed validation error (bad --image input), not a network or +// internal error: the file never opened, so there is no transport failure to +// classify as network. +func TestResolveMediaContentMissingLocalFileIsValidation(t *testing.T) { runtime := &common.RuntimeContext{ Factory: &cmdutil.Factory{ FileIOProvider: fileio.GetProvider(), @@ -826,8 +855,49 @@ func TestResolveMediaContentWrapsUploadError(t *testing.T) { missing := "missing.png" _, _, err := resolveMediaContent(context.Background(), runtime, "", missing, "", "", "", "") - if err == nil || !strings.Contains(err.Error(), "image upload failed") { - t.Fatalf("resolveMediaContent() error = %v", err) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("missing local media file must be a validation error, got %T: %v", err, err) + } + if ve.Param != "--image" { + t.Fatalf("missing local media file Param = %q, want --image", ve.Param) + } + if !strings.Contains(err.Error(), "cannot read file") { + t.Fatalf("error should explain the unreadable file, got %v", err) + } +} + +func TestUploadFileToIMMissingLocalFileCarriesParam(t *testing.T) { + runtime := &common.RuntimeContext{ + Factory: &cmdutil.Factory{ + FileIOProvider: fileio.GetProvider(), + IOStreams: &cmdutil.IOStreams{ + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + }, + }, + } + + cmdutil.TestChdir(t, t.TempDir()) + + _, err := uploadFileToIM(context.Background(), runtime, "missing.bin", "stream", "", "--file") + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("missing local file must be a validation error, got %T: %v", err, err) + } + if ve.Param != "--file" { + t.Fatalf("missing local file Param = %q, want --file", ve.Param) + } +} + +func TestStartURLDownloadBlockedURLCarriesParam(t *testing.T) { + _, _, err := startURLDownload(context.Background(), nil, "http://127.0.0.1/image.png", "--image") + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("blocked URL must be a validation error, got %T: %v", err, err) + } + if ve.Param != "--image" { + t.Fatalf("blocked URL Param = %q, want --image", ve.Param) } } @@ -920,7 +990,7 @@ func TestUploadFileToIMPreservesLocalFileName(t *testing.T) { t.Fatalf("WriteFile() error = %v", err) } - if _, err := uploadFileToIM(context.Background(), runtime, "./"+localName, "pdf", ""); err != nil { + if _, err := uploadFileToIM(context.Background(), runtime, "./"+localName, "pdf", "", "--file"); err != nil { t.Fatalf("uploadFileToIM() error = %v", err) } if !strings.Contains(gotBody, `name="file_name"`) || !strings.Contains(gotBody, localName) { diff --git a/shortcuts/im/im_chat_create.go b/shortcuts/im/im_chat_create.go index c2d88e5f0..0f8a35431 100644 --- a/shortcuts/im/im_chat_create.go +++ b/shortcuts/im/im_chat_create.go @@ -10,6 +10,7 @@ import ( "net/http" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" @@ -52,7 +53,7 @@ var ImChatCreate = common.Shortcut{ }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if runtime.Bool("set-bot-manager") && !runtime.IsBot() { - return output.ErrValidation("--set-bot-manager is only supported with bot identity (--as bot)") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--set-bot-manager is only supported with bot identity (--as bot)").WithParam("--set-bot-manager") } name := runtime.Str("name") @@ -60,25 +61,25 @@ var ImChatCreate = common.Shortcut{ // Public groups must have a name with at least 2 characters. if chatType == "public" && len([]rune(name)) < 2 { - return output.ErrValidation("--name is required for public groups and must be at least 2 characters") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name is required for public groups and must be at least 2 characters").WithParam("--name") } // Group name length must not exceed 60 characters. if len([]rune(name)) > 60 { - return output.ErrValidation("--name exceeds the maximum of 60 characters (got %d)", len([]rune(name))) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name exceeds the maximum of 60 characters (got %d)", len([]rune(name))).WithParam("--name") } // Description length must not exceed 100 characters. if desc := runtime.Str("description"); len([]rune(desc)) > 100 { - return output.ErrValidation("--description exceeds the maximum of 100 characters (got %d)", len([]rune(desc))) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--description exceeds the maximum of 100 characters (got %d)", len([]rune(desc))).WithParam("--description") } // Validate users. if users := runtime.Str("users"); users != "" { ids := common.SplitCSV(users) if len(ids) > 50 { - return output.ErrValidation("--users exceeds the maximum of 50 (got %d)", len(ids)) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--users exceeds the maximum of 50 (got %d)", len(ids)).WithParam("--users") } for _, id := range ids { - if _, err := common.ValidateUserID(id); err != nil { + if _, err := common.ValidateUserIDTyped("--users", id); err != nil { return err } } @@ -88,18 +89,18 @@ var ImChatCreate = common.Shortcut{ if bots := runtime.Str("bots"); bots != "" { ids := common.SplitCSV(bots) if len(ids) > 5 { - return output.ErrValidation("--bots exceeds the maximum of 5 (got %d)", len(ids)) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--bots exceeds the maximum of 5 (got %d)", len(ids)).WithParam("--bots") } for _, id := range ids { if !strings.HasPrefix(id, "cli_") { - return output.ErrValidation("invalid bot id %q: expected app ID (cli_xxx)", id) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid bot id %q: expected app ID (cli_xxx)", id).WithParam("--bots") } } } // Validate owner. if owner := runtime.Str("owner"); owner != "" { - if _, err := common.ValidateUserID(owner); err != nil { + if _, err := common.ValidateUserIDTyped("--owner", owner); err != nil { return err } } @@ -112,7 +113,7 @@ var ImChatCreate = common.Shortcut{ if runtime.Bool("set-bot-manager") { qp["set_bot_manager"] = []string{"true"} } - resData, err := runtime.DoAPIJSON(http.MethodPost, "/open-apis/im/v1/chats", qp, body) + resData, err := runtime.DoAPIJSONTyped(http.MethodPost, "/open-apis/im/v1/chats", qp, body) if err != nil { return err } @@ -127,7 +128,7 @@ var ImChatCreate = common.Shortcut{ // Try to fetch the group share link without blocking on failure. if chatID, ok := resData["chat_id"].(string); ok && chatID != "" { - linkData, err := runtime.DoAPIJSON(http.MethodPost, + linkData, err := runtime.DoAPIJSONTyped(http.MethodPost, fmt.Sprintf("/open-apis/im/v1/chats/%s/link", validate.EncodePathSegment(chatID)), nil, nil) if err == nil { diff --git a/shortcuts/im/im_chat_list.go b/shortcuts/im/im_chat_list.go index 028f741f0..4a61ed69a 100644 --- a/shortcuts/im/im_chat_list.go +++ b/shortcuts/im/im_chat_list.go @@ -9,6 +9,7 @@ import ( "io" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -71,15 +72,15 @@ var ImChatList = common.Shortcut{ // enum, and the bot + single-p2p rejection (mixed types degrade in Execute). Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if n := runtime.Int("page-size"); n < 1 || n > 100 { - return output.ErrValidation("--page-size must be an integer between 1 and 100") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 100").WithParam("--page-size") } parts, err := normalizeTypes(runtime.StrSlice("types")) if err != nil { return err } if len(parts) == 1 && parts[0] == "p2p" && runtime.IsBot() { - return output.ErrValidation( - `--types=p2p (single chats) is only supported with user identity (--as user). To protect user privacy, bot identity cannot list p2p chats. Use --as user, or include "group" in --types.`) + return errs.NewValidationError(errs.SubtypeInvalidArgument, + `--types=p2p (single chats) is only supported with user identity (--as user). To protect user privacy, bot identity cannot list p2p chats. Use --as user, or include "group" in --types.`).WithParam("--types") } return nil }, @@ -95,7 +96,7 @@ var ImChatList = common.Shortcut{ writeBotStripP2pWarning(runtime.IO().ErrOut) } params := buildChatListParams(runtime, effective) - resData, err := runtime.CallAPI("GET", imChatListPath, params, nil) + resData, err := runtime.CallAPITyped("GET", imChatListPath, params, nil) if err != nil { return err } @@ -211,10 +212,10 @@ func normalizeTypes(raw []string) ([]string, error) { for _, p := range raw { p = strings.TrimSpace(strings.ToLower(p)) if p == "" { - return nil, output.ErrValidation("--types must contain at least one of p2p, group") + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--types must contain at least one of p2p, group").WithParam("--types") } if p != "p2p" && p != "group" { - return nil, output.ErrValidation("--types contains invalid value %q: expected one of p2p, group", p) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--types contains invalid value %q: expected one of p2p, group", p).WithParam("--types") } if _, dup := seen[p]; dup { continue diff --git a/shortcuts/im/im_chat_messages_list.go b/shortcuts/im/im_chat_messages_list.go index a32e2d74b..58a323735 100644 --- a/shortcuts/im/im_chat_messages_list.go +++ b/shortcuts/im/im_chat_messages_list.go @@ -10,6 +10,7 @@ import ( "net/http" "strconv" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib" @@ -66,15 +67,15 @@ var ImChatMessageList = common.Shortcut{ // Under bot identity, --user-id is not supported; require --chat-id only. if runtime.IsBot() { if runtime.Str("user-id") != "" { - return common.FlagErrorf("--user-id requires user identity (--as user); use --chat-id when calling with bot identity") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id requires user identity (--as user); use --chat-id when calling with bot identity").WithParam("--user-id") } if runtime.Str("chat-id") == "" { - return common.FlagErrorf("specify --chat-id (bot identity does not support --user-id)") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --chat-id (bot identity does not support --user-id)").WithParam("--chat-id") } } else { - if err := common.ExactlyOne(runtime, "chat-id", "user-id"); err != nil { + if err := common.ExactlyOneTyped(runtime, "chat-id", "user-id"); err != nil { if runtime.Str("chat-id") == "" && runtime.Str("user-id") == "" { - return common.FlagErrorf("specify at least one of --chat-id or --user-id") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --chat-id or --user-id") } return err } @@ -82,12 +83,12 @@ var ImChatMessageList = common.Shortcut{ // Validate ID formats if chatFlag := runtime.Str("chat-id"); chatFlag != "" { - if _, err := common.ValidateChatID(chatFlag); err != nil { + if _, err := common.ValidateChatIDTyped("--chat-id", chatFlag); err != nil { return err } } if userFlag := runtime.Str("user-id"); userFlag != "" { - if _, err := common.ValidateUserID(userFlag); err != nil { + if _, err := common.ValidateUserIDTyped("--user-id", userFlag); err != nil { return err } } @@ -109,7 +110,7 @@ var ImChatMessageList = common.Shortcut{ return err } - data, err := runtime.DoAPIJSON(http.MethodGet, "/open-apis/im/v1/messages", params, nil) + data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil) if err != nil { return err } @@ -205,14 +206,14 @@ func buildChatMessageListRequest(runtime *common.RuntimeContext, chatId string) if startFlag := runtime.Str("start"); startFlag != "" { startTime, err := common.ParseTime(startFlag) if err != nil { - return nil, output.ErrValidation("--start: %v", err) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start") } params["start_time"] = []string{startTime} } if endFlag := runtime.Str("end"); endFlag != "" { endTime, err := common.ParseTime(endFlag, "end") if err != nil { - return nil, output.ErrValidation("--end: %v", err) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end") } params["end_time"] = []string{endTime} } @@ -236,7 +237,7 @@ func resolveChatIDForMessagesList(runtime *common.RuntimeContext, dryRun bool) ( return "", err } if chatId == "" { - return "", output.Errorf(output.ExitAPI, "not_found", "P2P chat not found for this user") + return "", errs.NewAPIError(errs.SubtypeNotFound, "P2P chat not found for this user") } return chatId, nil } diff --git a/shortcuts/im/im_chat_search.go b/shortcuts/im/im_chat_search.go index bcb56afd1..0fb49ffc3 100644 --- a/shortcuts/im/im_chat_search.go +++ b/shortcuts/im/im_chat_search.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/util" "github.com/larksuite/cli/shortcuts/common" @@ -53,10 +54,10 @@ var ImChatSearch = common.Shortcut{ query := runtime.Str("query") memberIDs := runtime.Str("member-ids") if query == "" && memberIDs == "" { - return output.ErrValidation("--query and --member-ids cannot both be empty; provide at least one (e.g. --query \"team-name\" or --member-ids \"ou_xxx\")") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query and --member-ids cannot both be empty; provide at least one (e.g. --query \"team-name\" or --member-ids \"ou_xxx\")") } if query != "" && len([]rune(query)) > 64 { - return output.ErrValidation("--query exceeds the maximum of 64 characters (got %d)", len([]rune(query))) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query exceeds the maximum of 64 characters (got %d)", len([]rune(query))).WithParam("--query") } if st := runtime.Str("search-types"); st != "" { allowed := map[string]struct{}{ @@ -67,23 +68,23 @@ var ImChatSearch = common.Shortcut{ } for _, item := range common.SplitCSV(st) { if _, ok := allowed[item]; !ok { - return output.ErrValidation("invalid --search-types value %q: expected one of private, external, public_joined, public_not_joined", item) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --search-types value %q: expected one of private, external, public_joined, public_not_joined", item).WithParam("--search-types") } } } if mi := runtime.Str("member-ids"); mi != "" { ids := common.SplitCSV(mi) if len(ids) > 50 { - return output.ErrValidation("--member-ids exceeds the maximum of 50 (got %d)", len(ids)) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-ids exceeds the maximum of 50 (got %d)", len(ids)).WithParam("--member-ids") } for _, id := range ids { - if _, err := common.ValidateUserID(id); err != nil { + if _, err := common.ValidateUserIDTyped("--member-ids", id); err != nil { return err } } } if n := runtime.Int("page-size"); n < 1 || n > 100 { - return output.ErrValidation("--page-size must be an integer between 1 and 100") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 100").WithParam("--page-size") } return nil }, @@ -94,7 +95,7 @@ var ImChatSearch = common.Shortcut{ Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { body := buildSearchChatBody(runtime) params := buildSearchChatParams(runtime) - resData, err := runtime.CallAPI("POST", "/open-apis/im/v2/chats/search", params, body) + resData, err := runtime.CallAPITyped("POST", "/open-apis/im/v2/chats/search", params, body) if err != nil { return err } diff --git a/shortcuts/im/im_chat_update.go b/shortcuts/im/im_chat_update.go index 76427e2db..0e7411fb9 100644 --- a/shortcuts/im/im_chat_update.go +++ b/shortcuts/im/im_chat_update.go @@ -9,7 +9,7 @@ import ( "io" "net/http" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" @@ -38,25 +38,25 @@ var ImChatUpdate = common.Shortcut{ }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { chat := runtime.Str("chat-id") - if _, err := common.ValidateChatID(chat); err != nil { + if _, err := common.ValidateChatIDTyped("--chat-id", chat); err != nil { return err } // Validate --name length. name := runtime.Str("name") if name != "" && len([]rune(name)) > 60 { - return output.ErrValidation("--name exceeds the maximum of 60 characters (got %d)", len([]rune(name))) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name exceeds the maximum of 60 characters (got %d)", len([]rune(name))).WithParam("--name") } // Validate --description length. if desc := runtime.Str("description"); desc != "" && len([]rune(desc)) > 100 { - return output.ErrValidation("--description exceeds the maximum of 100 characters (got %d)", len([]rune(desc))) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--description exceeds the maximum of 100 characters (got %d)", len([]rune(desc))).WithParam("--description") } // At least one field must be provided for update. body := buildUpdateChatBody(runtime) if len(body) == 0 { - return output.ErrValidation("at least one field must be specified to update") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "at least one field must be specified to update") } return nil @@ -65,7 +65,7 @@ var ImChatUpdate = common.Shortcut{ chatID := runtime.Str("chat-id") body := buildUpdateChatBody(runtime) - _, err := runtime.DoAPIJSON(http.MethodPut, + _, err := runtime.DoAPIJSONTyped(http.MethodPut, fmt.Sprintf("/open-apis/im/v1/chats/%s", validate.EncodePathSegment(chatID)), larkcore.QueryParams{"user_id_type": []string{"open_id"}}, body, diff --git a/shortcuts/im/im_errors.go b/shortcuts/im/im_errors.go new file mode 100644 index 000000000..7d20ca59c --- /dev/null +++ b/shortcuts/im/im_errors.go @@ -0,0 +1,63 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "errors" + "strings" + + "github.com/larksuite/cli/errs" +) + +// wrapIMNetworkErr returns err unchanged when it is already a typed errs.* +// error (preserving its subtype / code / log_id from the runtime boundary), +// and only wraps a raw, unclassified error as a transport-level network error. +func wrapIMNetworkErr(err error, format string, args ...any) error { + if _, ok := errs.ProblemOf(err); ok { + return err + } + return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err) +} + +func imContextError(err error) error { + if err == nil { + return nil + } + subtype := errs.SubtypeNetworkTransport + if errors.Is(err, context.DeadlineExceeded) { + subtype = errs.SubtypeNetworkTimeout + } + return errs.NewNetworkError(subtype, "%s", err.Error()).WithCause(err) +} + +func withIMValidationParam(err error, param string) error { + if err == nil || param == "" { + return err + } + var ve *errs.ValidationError + if errors.As(err, &ve) && ve.Param == "" { + ve.WithParam(param) + } + return err +} + +// appendIMRecoveryHint attaches a recovery hint to err. A typed error keeps its +// classification (category/subtype/code/log_id); only the hint is appended to +// p.Hint (newline-joined when a hint already exists), and err is returned +// unchanged. An unclassified error falls back to a typed internal error. +func appendIMRecoveryHint(err error, hint string) error { + if err == nil { + return nil + } + if p, ok := errs.ProblemOf(err); ok { + if strings.TrimSpace(p.Hint) != "" { + p.Hint = p.Hint + "\n" + hint + } else { + p.Hint = hint + } + return err + } + return errs.NewInternalError(errs.SubtypeSDKError, "%s", err.Error()).WithHint(hint).WithCause(err) +} diff --git a/shortcuts/im/im_errors_test.go b/shortcuts/im/im_errors_test.go new file mode 100644 index 000000000..6bc0bd5fa --- /dev/null +++ b/shortcuts/im/im_errors_test.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "errors" + "testing" + + "github.com/larksuite/cli/errs" +) + +func TestWrapIMNetworkErr_PassthroughTyped(t *testing.T) { + typed := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad input") + got := wrapIMNetworkErr(typed, "download failed") + if got != error(typed) { + t.Fatalf("typed error must be passed through unchanged, got %v", got) + } +} + +func TestWrapIMNetworkErr_WrapsRaw(t *testing.T) { + raw := errors.New("dial tcp: i/o timeout") + got := wrapIMNetworkErr(raw, "download failed: %s", "x") + var ne *errs.NetworkError + if !errors.As(got, &ne) { + t.Fatalf("raw error must become *errs.NetworkError, got %T", got) + } + if ne.Subtype != errs.SubtypeNetworkTransport { + t.Errorf("subtype = %q, want %q", ne.Subtype, errs.SubtypeNetworkTransport) + } + if !errors.Is(got, raw) { + t.Errorf("cause must be chained for errors.Is") + } +} + +func TestAppendIMRecoveryHint_TypedPreservedHintAppended(t *testing.T) { + typed := errs.NewAPIError(errs.SubtypeNotFound, "message not found") + got := appendIMRecoveryHint(typed, "specify --item-type explicitly") + if got != error(typed) { + t.Fatalf("typed error must be returned unchanged, got %T", got) + } + var ae *errs.APIError + if !errors.As(got, &ae) { + t.Fatalf("typed classification must be preserved, got %T", got) + } + if ae.Subtype != errs.SubtypeNotFound { + t.Errorf("subtype = %q, want %q", ae.Subtype, errs.SubtypeNotFound) + } + p, ok := errs.ProblemOf(got) + if !ok || p.Hint != "specify --item-type explicitly" { + t.Errorf("hint = %q (ok=%v), want %q", p.Hint, ok, "specify --item-type explicitly") + } +} + +func TestAppendIMRecoveryHint_RawBecomesInternal(t *testing.T) { + got := appendIMRecoveryHint(errors.New("boom"), "specify --item-type explicitly") + var ie *errs.InternalError + if !errors.As(got, &ie) { + t.Fatalf("raw error must become *errs.InternalError, got %T", got) + } + if ie.Hint != "specify --item-type explicitly" { + t.Errorf("hint = %q, want %q", ie.Hint, "specify --item-type explicitly") + } +} + +func TestAppendIMRecoveryHint_Nil(t *testing.T) { + if appendIMRecoveryHint(nil, "hint") != nil { + t.Errorf("nil in -> nil out") + } +} + +func TestAppendIMRecoveryHint_AppendsExistingHint(t *testing.T) { + typed := errs.NewAPIError(errs.SubtypeNotFound, "message not found").WithHint("first") + got := appendIMRecoveryHint(typed, "second") + p, ok := errs.ProblemOf(got) + if !ok { + t.Fatalf("expected typed problem, got %T", got) + } + if p.Hint != "first\nsecond" { + t.Errorf("hint = %q, want %q", p.Hint, "first\nsecond") + } +} diff --git a/shortcuts/im/im_feed_shortcut_create.go b/shortcuts/im/im_feed_shortcut_create.go index 3ff4a95ea..b39a194fe 100644 --- a/shortcuts/im/im_feed_shortcut_create.go +++ b/shortcuts/im/im_feed_shortcut_create.go @@ -6,7 +6,7 @@ package im import ( "context" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -67,7 +67,7 @@ var ImFeedShortcutCreate = common.Shortcut{ return err } items := buildShortcutItems(ids) - data, err := runtime.DoAPIJSON("POST", "/open-apis/im/v2/feed_shortcuts", nil, + data, err := runtime.DoAPIJSONTyped("POST", "/open-apis/im/v2/feed_shortcuts", nil, map[string]any{ "shortcuts": items, "is_header": isHeader, @@ -88,7 +88,7 @@ func resolveIsHeader(rt *common.RuntimeContext) (bool, error) { head := rt.Bool("head") tail := rt.Bool("tail") if head && tail { - return false, output.ErrValidation("--head and --tail are mutually exclusive") + return false, errs.NewValidationError(errs.SubtypeInvalidArgument, "--head and --tail are mutually exclusive") } if tail { return false, nil diff --git a/shortcuts/im/im_feed_shortcut_list.go b/shortcuts/im/im_feed_shortcut_list.go index 43c981689..75194c873 100644 --- a/shortcuts/im/im_feed_shortcut_list.go +++ b/shortcuts/im/im_feed_shortcut_list.go @@ -48,7 +48,7 @@ var ImFeedShortcutList = common.Shortcut{ return d }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - data, err := runtime.DoAPIJSON("GET", "/open-apis/im/v2/feed_shortcuts", + data, err := runtime.DoAPIJSONTyped("GET", "/open-apis/im/v2/feed_shortcuts", feedShortcutListQuery(runtime.Str("page-token")), nil) if err != nil { return err diff --git a/shortcuts/im/im_feed_shortcut_remove.go b/shortcuts/im/im_feed_shortcut_remove.go index 21534fcd9..e00788170 100644 --- a/shortcuts/im/im_feed_shortcut_remove.go +++ b/shortcuts/im/im_feed_shortcut_remove.go @@ -47,7 +47,7 @@ var ImFeedShortcutRemove = common.Shortcut{ return err } items := buildShortcutItems(ids) - data, err := runtime.DoAPIJSON("POST", "/open-apis/im/v2/feed_shortcuts/remove", nil, + data, err := runtime.DoAPIJSONTyped("POST", "/open-apis/im/v2/feed_shortcuts/remove", nil, map[string]any{"shortcuts": items}) if err != nil { return err diff --git a/shortcuts/im/im_flag_cancel.go b/shortcuts/im/im_flag_cancel.go index 4539d1ad0..6e3b07762 100644 --- a/shortcuts/im/im_flag_cancel.go +++ b/shortcuts/im/im_flag_cancel.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -63,11 +63,9 @@ var ImFlagCancel = common.Shortcut{ "item_type": itemType, "flag_type": flagType, } - data, err := runtime.DoAPIJSON("POST", "/open-apis/im/v1/flags/cancel", nil, + data, err := runtime.DoAPIJSONTyped("POST", "/open-apis/im/v1/flags/cancel", nil, map[string]any{"flag_items": []flagItem{item}}) if err != nil { - fmt.Fprintf(runtime.IO().ErrOut, "warning: cancel failed for %s/%s: %v\n", - itemType, flagType, err) result["status"] = "failed" result["error"] = err.Error() lastErr = err @@ -78,8 +76,12 @@ var ImFlagCancel = common.Shortcut{ results = append(results, result) } - runtime.Out(map[string]any{"results": results}, nil) - return lastErr + payload := map[string]any{"results": results} + if lastErr != nil { + return runtime.OutPartialFailure(payload, nil) + } + runtime.Out(payload, nil) + return nil }, } @@ -203,20 +205,20 @@ func buildSingleCancelItem(id, itOverride, ftOverride string) (flagItem, error) // Provide more specific hints for common mistakes if itOverride != "" && ftOverride == "" { if itemType == ItemTypeThread || itemType == ItemTypeMsgThread { - return flagItem{}, output.ErrValidation( + return flagItem{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid combination: --item-type=%s requires --flag-type=feed (feed-layer flags are the only valid type for threads)", - itOverride) + itOverride).WithParam("--item-type") } - return flagItem{}, output.ErrValidation( + return flagItem{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid combination: --item-type=%s with inferred --flag-type=%s; specify --flag-type explicitly to override", - itOverride, flagTypeString(flagType)) + itOverride, flagTypeString(flagType)).WithParam("--item-type") } if itOverride == "" && ftOverride != "" { - return flagItem{}, output.ErrValidation( + return flagItem{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid combination: --flag-type=%s with inferred --item-type=%s; specify --item-type explicitly to override", - ftOverride, itemTypeString(itemType)) + ftOverride, itemTypeString(itemType)).WithParam("--flag-type") } - return flagItem{}, output.ErrValidation( + return flagItem{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --item-type/--flag-type combination: supported pairs are default+message, thread+feed, and msg_thread+feed") } return newFlagItem(id, itemType, flagType), nil diff --git a/shortcuts/im/im_flag_create.go b/shortcuts/im/im_flag_create.go index 9ed2cb399..52e90ee1d 100644 --- a/shortcuts/im/im_flag_create.go +++ b/shortcuts/im/im_flag_create.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -50,12 +50,14 @@ var ImFlagCreate = common.Shortcut{ } // Combo validation already done in Validate, but double-check as a safety net. if !isValidCombo(parseItemTypeFromRaw(item.ItemType), parseFlagTypeFromRaw(item.FlagType)) { - return output.ErrValidation( + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid (item_type=%s, flag_type=%s) combination; the server only accepts "+ "(default, message), (thread, feed), or (msg_thread, feed)", - item.ItemType, item.FlagType) + item.ItemType, item.FlagType).WithParams( + errs.InvalidParam{Name: "--item-type", Reason: "unsupported with the given --flag-type"}, + errs.InvalidParam{Name: "--flag-type", Reason: "unsupported with the given --item-type"}) } - data, err := runtime.DoAPIJSON("POST", "/open-apis/im/v1/flags", nil, + data, err := runtime.DoAPIJSONTyped("POST", "/open-apis/im/v1/flags", nil, map[string]any{"flag_items": []flagItem{item}}) if err != nil { return err @@ -138,18 +140,16 @@ func buildCreateItem(rt *common.RuntimeContext) (flagItem, error) { chatID, err := getMessageChatID(rt, id) if err != nil { - return flagItem{}, output.ErrValidation( - "failed to query message for feed-layer flag: %v; if you know the chat type, specify --item-type explicitly", err) + return flagItem{}, appendIMRecoveryHint(err, "specify --item-type explicitly") } if chatID == "" { - return flagItem{}, output.ErrValidation( + return flagItem{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "message does not belong to a chat; feed-layer flags are only for messages in chats") } feedIT, err := resolveThreadFeedItemType(rt, chatID) if err != nil { - return flagItem{}, output.ErrValidation( - "failed to determine chat type: %v; if you know the chat type, specify --item-type explicitly", err) + return flagItem{}, appendIMRecoveryHint(err, "specify --item-type explicitly") } return newFlagItem(id, feedIT, FlagTypeFeed), nil } @@ -186,18 +186,24 @@ func parseExplicitFlagCombo(itOverride, ftOverride string) (explicitFlagCombo, e if combo.ItemTypeSet && !combo.FlagTypeSet { switch combo.ItemType { case ItemTypeThread, ItemTypeMsgThread: - return explicitFlagCombo{}, output.ErrValidation( - "--item-type=%s requires --flag-type=feed; message-layer flags always use item-type=default", itOverride) + return explicitFlagCombo{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--item-type=%s requires --flag-type=feed; message-layer flags always use item-type=default", itOverride).WithParams( + errs.InvalidParam{Name: "--item-type", Reason: "requires --flag-type=feed"}, + errs.InvalidParam{Name: "--flag-type", Reason: "must be feed for this --item-type"}) case ItemTypeDefault: - return explicitFlagCombo{}, output.ErrValidation( - "--item-type=default requires --flag-type=message; or omit both to use default behavior") + return explicitFlagCombo{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--item-type=default requires --flag-type=message; or omit both to use default behavior").WithParams( + errs.InvalidParam{Name: "--item-type", Reason: "default requires --flag-type=message"}, + errs.InvalidParam{Name: "--flag-type", Reason: "must be message for --item-type=default"}) } } if combo.ItemTypeSet && combo.FlagTypeSet && !isValidCombo(combo.ItemType, combo.FlagType) { - return explicitFlagCombo{}, output.ErrValidation( + return explicitFlagCombo{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --item-type=%s --flag-type=%s combination; supported pairs are default+message, thread+feed, and msg_thread+feed", - itOverride, ftOverride) + itOverride, ftOverride).WithParams( + errs.InvalidParam{Name: "--item-type", Reason: "unsupported pairing"}, + errs.InvalidParam{Name: "--flag-type", Reason: "unsupported pairing"}) } return combo, nil diff --git a/shortcuts/im/im_flag_list.go b/shortcuts/im/im_flag_list.go index 6599bd024..d4761e124 100644 --- a/shortcuts/im/im_flag_list.go +++ b/shortcuts/im/im_flag_list.go @@ -9,7 +9,7 @@ import ( "fmt" "strconv" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" ) @@ -56,7 +56,7 @@ var ImFlagList = common.Shortcut{ return executeListAllPages(runtime) } - data, err := runtime.DoAPIJSON("GET", "/open-apis/im/v1/flags", listQuery(runtime), nil) + data, err := runtime.DoAPIJSONTyped("GET", "/open-apis/im/v1/flags", listQuery(runtime), nil) if err != nil { return err } @@ -72,10 +72,10 @@ var ImFlagList = common.Shortcut{ func validateListOptions(rt *common.RuntimeContext) error { if n := rt.Int("page-size"); n < 1 || n > 50 { - return output.ErrValidation("--page-size must be an integer between 1 and 50") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 50").WithParam("--page-size") } if n := rt.Int("page-limit"); n < 1 || n > 1000 { - return output.ErrValidation("--page-limit must be an integer between 1 and 1000") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be an integer between 1 and 1000").WithParam("--page-limit") } return nil } @@ -159,7 +159,7 @@ func enrichFeedThreadItems(rt *common.RuntimeContext, data map[string]any) error end = len(ids) } batch := ids[i:end] - got, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/messages/mget", + got, err := rt.DoAPIJSONTyped("GET", "/open-apis/im/v1/messages/mget", larkcore.QueryParams{"message_ids": batch}, nil) if err != nil { return err @@ -244,7 +244,7 @@ func executeListAllPages(rt *common.RuntimeContext) error { if page > 0 { token = lastPageToken } - data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/flags", + data, err := rt.DoAPIJSONTyped("GET", "/open-apis/im/v1/flags", larkcore.QueryParams{ "page_size": []string{strconv.Itoa(rt.Int("page-size"))}, "page_token": []string{token}, diff --git a/shortcuts/im/im_flag_test.go b/shortcuts/im/im_flag_test.go index dbf3ad832..8a1a31e6b 100644 --- a/shortcuts/im/im_flag_test.go +++ b/shortcuts/im/im_flag_test.go @@ -15,6 +15,7 @@ import ( "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" @@ -593,18 +594,18 @@ func TestCheckFlagRequiredScopesReportsTokenResolutionError(t *testing.T) { setRuntimeTokenError(t, rt, errors.New("token cache unavailable")) err := checkFlagRequiredScopes(context.Background(), rt, flagMessageReadScopes) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) { - t.Fatalf("checkFlagRequiredScopes() error = %T %v, want ExitError", err, err) + var authErr *errs.AuthenticationError + if !errors.As(err, &authErr) { + t.Fatalf("checkFlagRequiredScopes() error = %T %v, want *errs.AuthenticationError", err, err) } - if exitErr.Code != output.ExitAuth || exitErr.Detail.Type != "auth" { - t.Fatalf("checkFlagRequiredScopes() detail = %+v code=%d, want auth exit", exitErr.Detail, exitErr.Code) + if authErr.Subtype != errs.SubtypeTokenMissing { + t.Fatalf("checkFlagRequiredScopes() subtype = %q, want %q", authErr.Subtype, errs.SubtypeTokenMissing) } - if !strings.Contains(exitErr.Detail.Message, "cannot verify required scope") { - t.Fatalf("message = %q, want scope verification context", exitErr.Detail.Message) + if !strings.Contains(authErr.Message, "cannot verify required scope") { + t.Fatalf("message = %q, want scope verification context", authErr.Message) } - if !strings.Contains(exitErr.Detail.Hint, strings.Join(flagMessageReadScopes, " ")) { - t.Fatalf("hint = %q, want required scopes", exitErr.Detail.Hint) + if !strings.Contains(authErr.Hint, strings.Join(flagMessageReadScopes, " ")) { + t.Fatalf("hint = %q, want required scopes", authErr.Hint) } } @@ -1337,6 +1338,10 @@ func TestFlagCancelExecuteSummarizesPartialFailure(t *testing.T) { if err == nil { t.Fatalf("Execute() expected partial failure error, got nil") } + var partialErr *output.PartialFailureError + if !errors.As(err, &partialErr) { + t.Fatalf("Execute() error = %T, want *output.PartialFailureError", err) + } out := rt.Factory.IOStreams.Out.(*bytes.Buffer).String() for _, want := range []string{`"results"`, `"item_type": "default"`, `"flag_type": "message"`, `"status": "ok"`, `"item_type": "msg_thread"`, `"flag_type": "feed"`, `"status": "failed"`, "feed failed"} { @@ -1346,6 +1351,7 @@ func TestFlagCancelExecuteSummarizesPartialFailure(t *testing.T) { } var envelope struct { + OK bool `json:"ok"` Data struct { Results []map[string]any `json:"results"` } `json:"data"` @@ -1356,6 +1362,12 @@ func TestFlagCancelExecuteSummarizesPartialFailure(t *testing.T) { if len(envelope.Data.Results) != 2 { t.Fatalf("results len = %d, want 2", len(envelope.Data.Results)) } + if envelope.OK { + t.Fatalf("stdout ok = true, want false for partial failure") + } + if errOut := rt.Factory.IOStreams.ErrOut.(*bytes.Buffer).String(); errOut != "" { + t.Fatalf("stderr = %q, want empty for partial failure result envelope", errOut) + } } func TestBuildCancelItems_OnlyItemTypeOverride(t *testing.T) { diff --git a/shortcuts/im/im_messages_mget.go b/shortcuts/im/im_messages_mget.go index 866d63595..a814faeef 100644 --- a/shortcuts/im/im_messages_mget.go +++ b/shortcuts/im/im_messages_mget.go @@ -9,6 +9,7 @@ import ( "io" "net/http" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib" @@ -42,10 +43,10 @@ var ImMessagesMGet = common.Shortcut{ Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { ids := common.SplitCSV(runtime.Str("message-ids")) if len(ids) == 0 { - return output.ErrValidation("--message-ids is required (comma-separated om_xxx)") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-ids is required (comma-separated om_xxx)").WithParam("--message-ids") } if len(ids) > maxMGetMessageIDs { - return output.ErrValidation("--message-ids supports at most %d IDs per request (got %d)", maxMGetMessageIDs, len(ids)) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-ids supports at most %d IDs per request (got %d)", maxMGetMessageIDs, len(ids)).WithParam("--message-ids") } for _, id := range ids { if _, err := validateMessageID(id); err != nil { @@ -58,7 +59,7 @@ var ImMessagesMGet = common.Shortcut{ ids := common.SplitCSV(runtime.Str("message-ids")) mgetURL := buildMGetURL(ids) - data, err := runtime.DoAPIJSON(http.MethodGet, mgetURL, nil, nil) + data, err := runtime.DoAPIJSONTyped(http.MethodGet, mgetURL, nil, nil) if err != nil { return err } diff --git a/shortcuts/im/im_messages_reply.go b/shortcuts/im/im_messages_reply.go index 31795dbd2..471bbec76 100644 --- a/shortcuts/im/im_messages_reply.go +++ b/shortcuts/im/im_messages_reply.go @@ -9,7 +9,7 @@ import ( "fmt" "net/http" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -102,20 +102,20 @@ var ImMessagesReply = common.Shortcut{ } if messageId == "" { - return output.ErrValidation("--message-id is required (om_xxx)") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-id is required (om_xxx)").WithParam("--message-id") } if _, err := validateMessageID(messageId); err != nil { return err } if msg := validateContentFlags(text, markdown, content, imageKey, fileKey, videoKey, videoCoverKey, audioKey); msg != "" { - return output.ErrValidation(msg) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg) } if content != "" && !json.Valid([]byte(content)) { - return output.ErrValidation("--content is not valid JSON: %s\nexample: --content '{\"text\":\"hello\"}' or --text 'hello'", content) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '{\"text\":\"hello\"}' or --text 'hello'", content).WithParam("--content") } if msg := validateExplicitMsgType(runtime.Cmd, msgType, text, markdown, imageKey, fileKey, videoKey, audioKey); msg != "" { - return output.ErrValidation(msg) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).WithParam("--msg-type") } return nil @@ -167,7 +167,7 @@ var ImMessagesReply = common.Shortcut{ data["uuid"] = idempotencyKey } - resData, err := runtime.DoAPIJSON(http.MethodPost, + resData, err := runtime.DoAPIJSONTyped(http.MethodPost, fmt.Sprintf("/open-apis/im/v1/messages/%s/reply", validate.EncodePathSegment(messageId)), nil, data) if err != nil { diff --git a/shortcuts/im/im_messages_resources_download.go b/shortcuts/im/im_messages_resources_download.go index abb3c3a54..cd65c8a5f 100644 --- a/shortcuts/im/im_messages_resources_download.go +++ b/shortcuts/im/im_messages_resources_download.go @@ -14,9 +14,9 @@ import ( "strings" "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/client" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" ) @@ -48,16 +48,16 @@ var ImMessagesResourcesDownload = common.Shortcut{ }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if messageId := runtime.Str("message-id"); messageId == "" { - return output.ErrValidation("--message-id is required (om_xxx)") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-id is required (om_xxx)").WithParam("--message-id") } else if _, err := validateMessageID(messageId); err != nil { return err } relPath, err := normalizeDownloadOutputPath(runtime.Str("file-key"), runtime.Str("output")) if err != nil { - return output.ErrValidation("%s", err) + return err } if _, err := runtime.ResolveSavePath(relPath); err != nil { - return output.ErrValidation("unsafe output path: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err) } return nil }, @@ -67,10 +67,10 @@ var ImMessagesResourcesDownload = common.Shortcut{ fileType := runtime.Str("type") relPath, err := normalizeDownloadOutputPath(fileKey, runtime.Str("output")) if err != nil { - return output.ErrValidation("invalid output path: %s", err) + return err } if _, err := runtime.ResolveSavePath(relPath); err != nil { - return output.ErrValidation("unsafe output path: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err) } userSpecifiedOutput := runtime.Str("output") != "" @@ -87,23 +87,23 @@ var ImMessagesResourcesDownload = common.Shortcut{ func normalizeDownloadOutputPath(fileKey, outputPath string) (string, error) { fileKey = strings.TrimSpace(fileKey) if fileKey == "" { - return "", fmt.Errorf("file-key cannot be empty") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "file-key cannot be empty").WithParam("--file-key") } if strings.ContainsAny(fileKey, "/\\") { - return "", fmt.Errorf("file-key cannot contain path separators") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "file-key cannot contain path separators").WithParam("--file-key") } if outputPath == "" { return fileKey, nil } outputPath = filepath.Clean(strings.TrimSpace(outputPath)) if outputPath == "." { - return "", fmt.Errorf("path cannot be empty") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "path cannot be empty").WithParam("--output") } if filepath.IsAbs(outputPath) { - return "", fmt.Errorf("absolute paths are not allowed") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "absolute paths are not allowed").WithParam("--output") } if outputPath == ".." || strings.HasPrefix(outputPath, ".."+string(filepath.Separator)) { - return "", fmt.Errorf("path cannot escape the current working directory") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "path cannot escape the current working directory").WithParam("--output") } return outputPath, nil } @@ -192,7 +192,7 @@ func (r *rangeChunkReader) Read(p []byte) (int, error) { return 0, closeErr } } - return 0, output.ErrNetwork("chunk overflow: delivered %d, expected %d", r.delivered, r.totalSize) + return 0, errs.NewNetworkError(errs.SubtypeNetworkTransport, "chunk overflow: delivered %d, expected %d", r.delivered, r.totalSize) } switch err { @@ -222,7 +222,7 @@ func (r *rangeChunkReader) Read(p []byte) (int, error) { if r.delivered == r.totalSize { return 0, io.EOF } - return 0, output.ErrNetwork("file size mismatch: expected %d, got %d", r.totalSize, r.delivered) + return 0, errs.NewNetworkError(errs.SubtypeNetworkTransport, "file size mismatch: expected %d, got %d", r.totalSize, r.delivered) } end := min(r.nextOffset+normalChunkSize-1, r.totalSize-1) @@ -238,7 +238,7 @@ func (r *rangeChunkReader) Read(p []byte) (int, error) { } if resp.StatusCode != http.StatusPartialContent { resp.Body.Close() - return 0, output.ErrNetwork("unexpected status code: %d", resp.StatusCode) + return 0, errs.NewNetworkError(errs.SubtypeNetworkTransport, "unexpected status code: %d", resp.StatusCode) } r.current = resp.Body @@ -270,7 +270,7 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex return "", 0, err } if downloadResp == nil { - return "", 0, output.ErrNetwork("download failed: empty response") + return "", 0, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: empty response") } if downloadResp.StatusCode >= 400 { @@ -289,7 +289,7 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex totalSize, err := parseTotalSize(downloadResp.Header.Get("Content-Range")) if err != nil { downloadResp.Body.Close() - return "", 0, output.ErrNetwork("invalid Content-Range header on range response: %s", err) + return "", 0, errs.NewNetworkError(errs.SubtypeNetworkTransport, "invalid Content-Range header on range response: %s", err) } body = newRangeChunkReader(ctx, runtime, messageID, fileKey, fileType, downloadResp.Body, totalSize) sizeBytes = totalSize @@ -300,7 +300,7 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex default: downloadResp.Body.Close() - return "", 0, output.ErrNetwork("unexpected status code: %d", downloadResp.StatusCode) + return "", 0, errs.NewNetworkError(errs.SubtypeNetworkTransport, "unexpected status code: %d", downloadResp.StatusCode) } defer body.Close() @@ -309,10 +309,10 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex ContentLength: sizeBytes, }, body) if err != nil { - return "", 0, common.WrapSaveErrorByCategory(err, "api_error") + return "", 0, common.WrapSaveErrorTyped(err) } if sizeBytes >= 0 && result.Size() != sizeBytes { - return "", 0, output.ErrNetwork("file size mismatch: expected %d, got %d", sizeBytes, result.Size()) + return "", 0, errs.NewNetworkError(errs.SubtypeNetworkTransport, "file size mismatch: expected %d, got %d", sizeBytes, result.Size()) } savedPath, resolveErr := runtime.ResolveSavePath(finalPath) if resolveErr != nil || savedPath == "" { @@ -404,7 +404,7 @@ func doIMResourceDownloadRequest(ctx context.Context, runtime *common.RuntimeCon return resp, nil } if ctx.Err() != nil { - return nil, ctx.Err() + return nil, imContextError(ctx.Err()) } lastErr = err if attempt == imDownloadRequestRetries { @@ -415,7 +415,7 @@ func doIMResourceDownloadRequest(ctx context.Context, runtime *common.RuntimeCon if lastErr != nil { return nil, lastErr } - return nil, output.ErrNetwork("download request failed") + return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download request failed") } func sleepIMDownloadRetry(ctx context.Context, attempt int) { @@ -431,37 +431,37 @@ func sleepIMDownloadRetry(ctx context.Context, attempt int) { func downloadResponseError(resp *http.Response) error { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) if len(body) > 0 { - return output.ErrNetwork("download failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + return errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } - return output.ErrNetwork("download failed: HTTP %d", resp.StatusCode) + return errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d", resp.StatusCode) } func parseTotalSize(contentRange string) (int64, error) { contentRange = strings.TrimSpace(contentRange) if contentRange == "" { - return 0, fmt.Errorf("content-range is empty") + return 0, fmt.Errorf("content-range is empty") //nolint:forbidigo // intermediate Content-Range parse; caller wraps it as a typed network error } if !strings.HasPrefix(contentRange, "bytes ") { - return 0, fmt.Errorf("unsupported content-range: %q", contentRange) + return 0, fmt.Errorf("unsupported content-range: %q", contentRange) //nolint:forbidigo // intermediate Content-Range parse; caller wraps it as a typed network error } parts := strings.SplitN(strings.TrimPrefix(contentRange, "bytes "), "/", 2) if len(parts) != 2 || parts[1] == "" { - return 0, fmt.Errorf("unsupported content-range: %q", contentRange) + return 0, fmt.Errorf("unsupported content-range: %q", contentRange) //nolint:forbidigo // intermediate Content-Range parse; caller wraps it as a typed network error } if parts[0] == "*" { - return 0, fmt.Errorf("unsupported content-range: %q", contentRange) + return 0, fmt.Errorf("unsupported content-range: %q", contentRange) //nolint:forbidigo // intermediate Content-Range parse; caller wraps it as a typed network error } if parts[1] == "*" { - return 0, fmt.Errorf("unknown total size in content-range: %q", contentRange) + return 0, fmt.Errorf("unknown total size in content-range: %q", contentRange) //nolint:forbidigo // intermediate Content-Range parse; caller wraps it as a typed network error } totalSize, err := strconv.ParseInt(parts[1], 10, 64) if err != nil { - return 0, fmt.Errorf("parse total size: %w", err) + return 0, fmt.Errorf("parse total size: %w", err) //nolint:forbidigo // intermediate Content-Range parse; caller wraps it as a typed network error } if totalSize <= 0 { - return 0, fmt.Errorf("invalid total size: %d", totalSize) + return 0, fmt.Errorf("invalid total size: %d", totalSize) //nolint:forbidigo // intermediate Content-Range parse; caller wraps it as a typed network error } return totalSize, nil } diff --git a/shortcuts/im/im_messages_search.go b/shortcuts/im/im_messages_search.go index 14266a5c8..cfdc1b5f8 100644 --- a/shortcuts/im/im_messages_search.go +++ b/shortcuts/im/im_messages_search.go @@ -10,6 +10,7 @@ import ( "net/http" "strconv" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib" @@ -268,7 +269,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch if runtime.Cmd != nil && runtime.Cmd.Flags().Changed("page-limit") { pageLimit := runtime.Int("page-limit") if pageLimit < 1 || pageLimit > messagesSearchMaxPageLimit { - return nil, output.ErrValidation("--page-limit must be an integer between 1 and 40") + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be an integer between 1 and 40").WithParam("--page-limit") } } @@ -278,7 +279,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch if startFlag != "" { ts, err := common.ParseTime(startFlag) if err != nil { - return nil, output.ErrValidation("--start: %v", err) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start") } startTs = ts start := startFlag @@ -287,7 +288,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch if endFlag != "" { ts, err := common.ParseTime(endFlag, "end") if err != nil { - return nil, output.ErrValidation("--end: %v", err) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end") } endTs = ts end := endFlag @@ -297,7 +298,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch sv, _ := strconv.ParseInt(startTs, 10, 64) ev, _ := strconv.ParseInt(endTs, 10, 64) if sv > ev { - return nil, output.ErrValidation("--start cannot be later than --end") + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start cannot be later than --end") } } if len(timeRange) > 0 { @@ -306,12 +307,12 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch if senderTypeFlag != "" && excludeSenderTypeFlag != "" { if senderTypeFlag == excludeSenderTypeFlag { - return nil, output.ErrValidation("--sender-type and --exclude-sender-type cannot be the same value") + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--sender-type and --exclude-sender-type cannot be the same value") } } if chatFlag != "" { for _, chatID := range common.SplitCSV(chatFlag) { - if _, err := common.ValidateChatID(chatID); err != nil { + if _, err := common.ValidateChatIDTyped("--chat-id", chatID); err != nil { return nil, err } } @@ -319,7 +320,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch } if senderFlag != "" { for _, userID := range common.SplitCSV(senderFlag) { - if _, err := common.ValidateUserID(userID); err != nil { + if _, err := common.ValidateUserIDTyped("--sender", userID); err != nil { return nil, err } } @@ -343,7 +344,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch if atChatterIdsFlag != "" { ids := common.SplitCSV(atChatterIdsFlag) for _, id := range ids { - if _, err := common.ValidateUserID(id); err != nil { + if _, err := common.ValidateUserIDTyped("--at-chatter-ids", id); err != nil { return nil, err } } @@ -357,7 +358,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch pageSize := runtime.Int("page-size") if pageSize < 1 { - return nil, output.ErrValidation("--page-size must be an integer between 1 and 50") + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 50").WithParam("--page-size") } if pageSize > messagesSearchMaxPageSize { pageSize = messagesSearchMaxPageSize @@ -420,7 +421,7 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest) params["page_token"] = []string{pageToken} } - searchData, err := runtime.DoAPIJSON(http.MethodPost, "/open-apis/im/v1/messages/search", params, req.body) + searchData, err := runtime.DoAPIJSONTyped(http.MethodPost, "/open-apis/im/v1/messages/search", params, req.body) if err != nil { return nil, false, "", false, pageLimit, err } @@ -446,7 +447,7 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest) func batchMGetMessages(runtime *common.RuntimeContext, messageIds []string) ([]interface{}, error) { var items []interface{} for _, batch := range chunkStrings(messageIds, messagesSearchMGetBatchSize) { - mgetData, err := runtime.DoAPIJSON(http.MethodGet, buildMGetURL(batch), nil, nil) + mgetData, err := runtime.DoAPIJSONTyped(http.MethodGet, buildMGetURL(batch), nil, nil) if err != nil { return nil, err } diff --git a/shortcuts/im/im_messages_send.go b/shortcuts/im/im_messages_send.go index 680672744..403aa29c6 100644 --- a/shortcuts/im/im_messages_send.go +++ b/shortcuts/im/im_messages_send.go @@ -10,8 +10,8 @@ import ( "os" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" ) @@ -113,30 +113,30 @@ var ImMessagesSend = common.Shortcut{ } } - if err := common.ExactlyOne(runtime, "chat-id", "user-id"); err != nil { + if err := common.ExactlyOneTyped(runtime, "chat-id", "user-id"); err != nil { return err } // Validate ID formats if chatFlag != "" { - if _, err := common.ValidateChatID(chatFlag); err != nil { + if _, err := common.ValidateChatIDTyped("--chat-id", chatFlag); err != nil { return err } } if userFlag != "" { - if _, err := common.ValidateUserID(userFlag); err != nil { + if _, err := common.ValidateUserIDTyped("--user-id", userFlag); err != nil { return err } } if msg := validateContentFlags(text, markdown, content, imageKey, fileKey, videoKey, videoCoverKey, audioKey); msg != "" { - return common.FlagErrorf(msg) + return errs.NewValidationError(errs.SubtypeInvalidArgument, msg) } if content != "" && !json.Valid([]byte(content)) { - return common.FlagErrorf("--content is not valid JSON: %s\nexample: --content '{\"text\":\"hello\"}' or --text 'hello'", content) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '{\"text\":\"hello\"}' or --text 'hello'", content).WithParam("--content") } if msg := validateExplicitMsgType(runtime.Cmd, msgType, text, markdown, imageKey, fileKey, videoKey, audioKey); msg != "" { - return common.FlagErrorf(msg) + return errs.NewValidationError(errs.SubtypeInvalidArgument, msg).WithParam("--msg-type") } return nil @@ -193,7 +193,7 @@ var ImMessagesSend = common.Shortcut{ data["uuid"] = idempotencyKey } - resData, err := runtime.DoAPIJSON(http.MethodPost, "/open-apis/im/v1/messages", + resData, err := runtime.DoAPIJSONTyped(http.MethodPost, "/open-apis/im/v1/messages", larkcore.QueryParams{"receive_id_type": []string{receiveIdType}}, data) if err != nil { return err @@ -220,7 +220,7 @@ func validateMediaFlagPath(fio fileio.FileIO, flagName, value string) error { return nil } if _, err := fio.Stat(value); err != nil && !os.IsNotExist(err) { - return output.ErrValidation("%s: %v", flagName, err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %v", flagName, err).WithParam(flagName) } return nil } diff --git a/shortcuts/im/im_threads_messages_list.go b/shortcuts/im/im_threads_messages_list.go index 5a2c11cba..0f527ace2 100644 --- a/shortcuts/im/im_threads_messages_list.go +++ b/shortcuts/im/im_threads_messages_list.go @@ -11,6 +11,7 @@ import ( "strconv" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib" @@ -46,7 +47,7 @@ var ImThreadsMessagesList = common.Shortcut{ sortType = "ByCreateTimeDesc" } - pageSize, _ := common.ValidatePageSize(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize) + pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize) d := common.NewDryRunAPI() containerID := threadFlag @@ -79,12 +80,12 @@ var ImThreadsMessagesList = common.Shortcut{ Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { threadId := runtime.Str("thread") if threadId == "" { - return output.ErrValidation("--thread is required (om_xxx or omt_xxx)") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--thread is required (om_xxx or omt_xxx)").WithParam("--thread") } if !strings.HasPrefix(threadId, "om_") && !strings.HasPrefix(threadId, "omt_") { - return output.ErrValidation("invalid --thread %q: must start with om_ or omt_", threadId) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --thread %q: must start with om_ or omt_", threadId).WithParam("--thread") } - _, err := common.ValidatePageSize(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize) + _, err := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize) return err }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { @@ -100,7 +101,7 @@ var ImThreadsMessagesList = common.Shortcut{ sortType = "ByCreateTimeDesc" } - pageSize, _ := common.ValidatePageSize(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize) + pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize) params := map[string][]string{ "container_id_type": []string{"thread"}, @@ -113,7 +114,7 @@ var ImThreadsMessagesList = common.Shortcut{ params["page_token"] = []string{pageToken} } - data, err := runtime.DoAPIJSON(http.MethodGet, "/open-apis/im/v1/messages", params, nil) + data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil) if err != nil { return err } diff --git a/shortcuts/im/mute_filter.go b/shortcuts/im/mute_filter.go index da5d08b03..5bccd10fd 100644 --- a/shortcuts/im/mute_filter.go +++ b/shortcuts/im/mute_filter.go @@ -16,7 +16,7 @@ package im import ( "fmt" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -240,14 +240,14 @@ func FetchMuteStatus(runtime *common.RuntimeContext, chatIDs []string) (map[stri return map[string]bool{}, nil, nil } if len(chatIDs) > MaxMuteStatusBatchSize { - return nil, nil, output.ErrValidation( + return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "batch_get_mute_status accepts at most %d chat_ids per call (got %d)", MaxMuteStatusBatchSize, len(chatIDs)) } body := BuildBatchGetMuteStatusBody(chatIDs) - resp, err := runtime.CallAPI("POST", BatchGetMuteStatusPath, nil, body) + resp, err := runtime.CallAPITyped("POST", BatchGetMuteStatusPath, nil, body) if err != nil { - return nil, nil, fmt.Errorf("fetch mute status: %w", err) + return nil, nil, wrapIMNetworkErr(err, "fetch mute status") } muted, unknown := ParseBatchGetMuteStatusResponse(chatIDs, resp) return muted, unknown, nil