diff --git a/.golangci.yml b/.golangci.yml index f9a51eae8..7548f7545 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -65,20 +65,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/calendar/helpers\.go|shortcuts/drive/) + - path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go|shortcuts/drive/|shortcuts/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/drive/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go) + - path-except: (shortcuts/drive/|shortcuts/im/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go) text: errs-no-bare-wrap linters: - forbidigo # errs-no-legacy-helper is drive-only: the shared helpers it bans are # still used by other domains until their later migration phase. - - path-except: (shortcuts/drive/) + - path-except: (shortcuts/drive/|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 b995b27f2..9dbfc783a 100644 --- a/lint/errscontract/rule_no_legacy_envelope_literal.go +++ b/lint/errscontract/rule_no_legacy_envelope_literal.go @@ -17,6 +17,7 @@ import ( // appending their path prefix here. var migratedEnvelopePaths = []string{ "shortcuts/drive/", + "shortcuts/im/", } // legacyOutputImportPath is the import path of the package that declares the 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 7b023e6e9..a80281cf0 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -475,6 +475,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, diff --git a/shortcuts/im/convert_lib/card_test.go b/shortcuts/im/convert_lib/card_test.go index 49f63644b..26800b50c 100644 --- a/shortcuts/im/convert_lib/card_test.go +++ b/shortcuts/im/convert_lib/card_test.go @@ -247,15 +247,15 @@ func TestConvertAtWithMentions(t *testing.T) { mentions := []interface{}{ map[string]interface{}{ "key": "@_user_1", - "id": "ou_6b64bef911a5a3ea763df8ffd9258f59", - "name": "燕忠毅", + "id": "ou_xxxx", + "name": "张三", }, } attachment := cardObj{ "at_users": cardObj{ "cde8a6c8": cardObj{ - "user_id": "754700000001", - "content": "燕忠毅", + "user_id": "1234567890", + "content": "张三", "mention_key": "@_user_1", }, }, @@ -267,7 +267,7 @@ func TestConvertAtWithMentions(t *testing.T) { attachment: attachment, mentionsByKey: buildMentionsByKey(mentions), } - if got := concise.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@燕忠毅(ou_6b64bef911a5a3ea763df8ffd9258f59)" { + if got := concise.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@张三(ou_xxxx)" { t.Fatalf("convertAt(concise with mentions) = %q", got) } @@ -277,7 +277,7 @@ func TestConvertAtWithMentions(t *testing.T) { attachment: attachment, mentionsByKey: buildMentionsByKey(mentions), } - if got := detailed.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@燕忠毅(open_id:ou_6b64bef911a5a3ea763df8ffd9258f59)" { + if got := detailed.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@张三(open_id:ou_xxxx)" { t.Fatalf("convertAt(detailed with mentions) = %q", got) } @@ -300,14 +300,14 @@ func TestConvertAtWithMentions(t *testing.T) { attachment: cardObj{ "at_users": cardObj{ "cde8a6c8": cardObj{ - "user_id": "754700000001", - "content": "燕忠毅", + "user_id": "1234567890", + "content": "张三", "mention_key": "@_user_1", }, }, }, } - if got := nilMentions.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@燕忠毅(user_id:754700000001)" { + if got := nilMentions.convertAt(cardObj{"userID": "cde8a6c8"}); got != "@张三(user_id:1234567890)" { t.Fatalf("convertAt(fallback nil mentionsByKey) = %q", got) } } 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 f75119219..4f3442906 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 } @@ -175,12 +175,12 @@ func sanitizeURLForDisplay(rawURL string) string { // 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) { 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).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 +188,17 @@ 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).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)) @@ -233,7 +233,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 } @@ -341,7 +341,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 +349,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 +370,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 +386,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 +405,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 +419,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 +428,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 +438,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 +452,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 @@ -621,7 +619,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 } @@ -1049,14 +1047,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 "", imInputStatError(err) } defer f.Close() @@ -1073,27 +1071,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 "", imInputStatError(err) } defer f.Close() @@ -1114,15 +1110,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 +1136,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 +1166,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 +1227,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 +1238,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 +1266,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 +1284,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 +1295,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 +1353,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 +1383,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 diff --git a/shortcuts/im/helpers_network_test.go b/shortcuts/im/helpers_network_test.go index a068c5b74..569c7b452 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" @@ -716,7 +718,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 +756,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 +786,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 +811,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 +840,12 @@ 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 !strings.Contains(err.Error(), "cannot read file") { + t.Fatalf("error should explain the unreadable file, got %v", err) } } @@ -920,7 +938,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..fbfe78365 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,22 +61,22 @@ 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 { @@ -88,11 +89,11 @@ 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") } } } @@ -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..c352a35d5 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 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 } @@ -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..d5b559f06 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,14 +68,14 @@ 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 { @@ -83,7 +84,7 @@ var ImChatSearch = common.Shortcut{ } } 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..45316648d 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" @@ -45,18 +45,18 @@ var ImChatUpdate = common.Shortcut{ // 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..95063d670 --- /dev/null +++ b/shortcuts/im/im_errors.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "errors" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" +) + +// 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) +} + +// imSaveError maps a FileIO.Save error to a typed error. Path validation +// failures are validation errors (exit code 2); mkdir / write failures are +// internal file-I/O errors (exit code 5). +func imSaveError(err error) error { + if err == nil { + return nil + } + var me *fileio.MkdirError + switch { + case errors.Is(err, fileio.ErrPathValidation): + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err) + case errors.As(err, &me): + return errs.NewInternalError(errs.SubtypeFileIO, "cannot create parent directory: %s", err).WithCause(err) + default: + return errs.NewInternalError(errs.SubtypeFileIO, "cannot create file: %s", err).WithCause(err) + } +} + +// imInputStatError maps a FileIO Stat/Open error for an input file to a typed +// validation error: the path came from a user flag (--image / --file / …), so a +// path-validation failure or an unreadable path is bad input (exit code 2), not +// a network or internal error. +func imInputStatError(err error) error { + if err == nil { + return nil + } + if errors.Is(err, fileio.ErrPathValidation) { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).WithCause(err) + } + return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read file: %s", err).WithCause(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..e5ba9f45f --- /dev/null +++ b/shortcuts/im/im_errors_test.go @@ -0,0 +1,151 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "errors" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" +) + +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 TestIMSaveError_PathValidationIsValidation(t *testing.T) { + err := imSaveError(fileio.ErrPathValidation) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("path-validation save error must be *errs.ValidationError, got %T", err) + } + if ve.Param != "--output" { + t.Errorf("Param = %q, want %q", ve.Param, "--output") + } +} + +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") + } +} + +func TestIMSaveError_MkdirIsInternalFileIO(t *testing.T) { + err := imSaveError(&fileio.MkdirError{Err: errors.New("mkdir denied")}) + var ie *errs.InternalError + if !errors.As(err, &ie) { + t.Fatalf("mkdir failure must be *errs.InternalError, got %T", err) + } + if ie.Subtype != errs.SubtypeFileIO { + t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeFileIO) + } +} + +func TestIMSaveError_WriteIsInternalFileIO(t *testing.T) { + err := imSaveError(errors.New("disk full")) + var ie *errs.InternalError + if !errors.As(err, &ie) { + t.Fatalf("write failure must be *errs.InternalError, got %T", err) + } + if ie.Subtype != errs.SubtypeFileIO { + t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeFileIO) + } +} + +func TestIMSaveError_Nil(t *testing.T) { + if imSaveError(nil) != nil { + t.Errorf("nil in -> nil out") + } +} + +func TestIMInputStatError_PathValidation(t *testing.T) { + err := imInputStatError(fileio.ErrPathValidation) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("path-validation input error must be *errs.ValidationError, got %T", err) + } + if !errors.Is(err, fileio.ErrPathValidation) { + t.Errorf("cause must preserve ErrPathValidation") + } +} + +func TestIMInputStatError_ReadFailure(t *testing.T) { + raw := errors.New("permission denied") + err := imInputStatError(raw) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("read failure must be *errs.ValidationError, got %T", err) + } + if !errors.Is(err, raw) { + t.Errorf("cause must be chained for errors.Is") + } +} + +func TestIMInputStatError_Nil(t *testing.T) { + if imInputStatError(nil) != nil { + t.Errorf("nil in -> nil out") + } +} diff --git a/shortcuts/im/im_flag_cancel.go b/shortcuts/im/im_flag_cancel.go index 4539d1ad0..77da14dc5 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,7 +63,7 @@ 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", @@ -203,20 +203,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..5e239afbd 100644 --- a/shortcuts/im/im_flag_test.go +++ b/shortcuts/im/im_flag_test.go @@ -15,8 +15,8 @@ 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" "github.com/spf13/cobra" ) @@ -593,18 +593,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) } } 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..b053f4900 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, imSaveError(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 == "" { @@ -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 af0e9621b..948e34c88 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" @@ -269,7 +270,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") } } @@ -279,7 +280,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 @@ -288,7 +289,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 @@ -298,7 +299,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 { @@ -307,7 +308,7 @@ 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 != "" { @@ -358,7 +359,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 @@ -421,7 +422,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 } @@ -447,7 +448,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 } @@ -460,7 +461,7 @@ func batchMGetMessages(runtime *common.RuntimeContext, messageIds []string) ([]i func batchQueryChatContexts(runtime *common.RuntimeContext, chatIds []string) map[string]map[string]interface{} { chatContexts := map[string]map[string]interface{}{} for _, batch := range chunkStrings(chatIds, messagesSearchChatBatchSize) { - chatRes, chatErr := runtime.DoAPIJSON( + chatRes, chatErr := runtime.DoAPIJSONTyped( http.MethodPost, "/open-apis/im/v1/chats/batch_query", larkcore.QueryParams{"user_id_type": []string{"open_id"}}, map[string]interface{}{"chat_ids": batch}, diff --git a/shortcuts/im/im_messages_send.go b/shortcuts/im/im_messages_send.go index 680672744..6f9459d44 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" ) @@ -130,13 +130,13 @@ var ImMessagesSend = common.Shortcut{ } 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..c7b21e20a 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" @@ -79,10 +80,10 @@ 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) return err @@ -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