Compare commits

...

14 Commits

Author SHA1 Message Date
zhaoyukun.yk
d4f97e4ea4 fix(im): remove unsupported feed group shortcuts
Remove the feed group list/query shortcut registration, implementation, tests, and skill references so the im domain no longer exposes unsupported feed group commands.

Keep the remaining im flag validation paths on typed error envelopes.
2026-06-06 18:07:49 +08:00
zhaoyukun.yk
78d7f54770 fix(im): report feed group failures as typed errors
Feed group list and query commands now classify API failures into typed
errors, consistent with the rest of the im domain. This restores the
main branch lint gate to green.
2026-06-06 17:31:20 +08:00
evandance
5788a6c384 feat(im): return typed error envelopes across the im domain (#1230) 2026-06-06 17:07:57 +08:00
zhumiaoxin
bd07859c90 feat(im): cli support feed group (#1102)
Add IM feed group support documentation for lark-cli, making the raw im feed.groups.* APIs discoverable and easier for agents to use correctly.
2026-06-06 14:25:31 +08:00
evandance
8c3cba17b2 feat(task): emit typed error envelopes across the task domain (#1231)
Task commands now return structured, typed errors instead of the legacy
exit-code envelope: every failure carries a stable category, subtype, and
recovery hint, so callers can branch on the error class instead of parsing
messages. Exit codes derive from the error category — input validation exits 2,
a permission denial exits 3, other API errors exit 1.

Batch operations (adding tasks to a tasklist, creating a tasklist with tasks)
now report partial failure honestly: the per-item successes and failures stay
on stdout and the command exits non-zero instead of masking failures as a
success.
2026-06-05 22:30:45 +08:00
evandance
6367aaa0f5 feat(okr,whiteboard): emit typed error envelopes across both domains (#1236)
The okr and whiteboard commands now report every failure as a typed error
envelope. Invalid flags, malformed input, output-file conflicts, and API or
transport failures alike carry a stable category, subtype, the offending flag
or Lark error code, and a meaningful exit code — so scripts and agents can
branch on the error shape instead of scraping message strings.
2026-06-05 20:00:04 +08:00
qinxiaoyun
37b17f3d37 feat(events): add whiteboard event domain with per-board subscription (#1265)
Wire the board.whiteboard.updated_v1 EventKey into the consume pipeline so that lark-cli event consume automatically calls the per-whiteboard subscribe / unsubscribe OAPIs instead of requiring callers to manage server-side subscriptions out-of-band.

Change-Id: I94323807e8dc649d3296f6922311d2acaf92284e
2026-06-05 17:09:17 +08:00
evandance
be5527ca4e feat(im): add feed shortcut create, list, and remove shortcuts (#1273)
Adds feed shortcut management to the im domain: pin chats to the user's feed sidebar, list pinned entries, and unpin them. Three new shortcuts wrap the im/v2/feed_shortcuts OpenAPI routes, which currently expose CHAT-type entries only and accept user identity only.
2026-06-05 16:42:48 +08:00
fangshuyu-768
a75420f72c docs: add markdown domain template (#1293) 2026-06-05 15:48:01 +08:00
evandance
f3949f04c4 feat(calendar): emit typed error envelopes across the calendar domain (#1232)
Calendar commands now return structured, typed error envelopes for every
failure mode — input validation, internal faults, and API responses —
instead of legacy generic errors. Callers and AI agents get consistent
exit codes and a machine-readable shape (type / subtype / code / hint),
and can tell bad input, an internal fault, and an API rejection apart.
Validation errors are attributed to the offending flag.

Server-supplied error details (e.g. why an event time was rejected) are
surfaced on the typed error's hint via a shared classifier improvement
that benefits every domain. Multi-step operations (create-with-attendees
rollback, multi-field update) preserve the real failure's classification
and report which steps completed.

The whole calendar domain is now lint-locked against reintroducing legacy
error constructors.
2026-06-05 13:06:50 +08:00
caojie0621
62364fc320 fix(drive): use docs secure label read scope (#1281) 2026-06-05 12:48:22 +08:00
fangshuyu-768
2f4e2c3019 docs: improve lark-markdown skill guidance (#1279) 2026-06-05 12:34:56 +08:00
evandance
3990151122 feat(base): emit typed error envelopes across the base domain (#1248) 2026-06-05 11:40:00 +08:00
MaxHuang22
fa929f02d6 feat: clear recommend.allow scope auto-approve overrides (#1272)
The recommend.allow list in scope_overrides.json special-cased a set of
calendar/contact/mail scopes into the auto-approve set on top of the
platform recommendations in scope_priorities.json. Remove all entries so
no scopes are special-cased anymore; auto-approve now reflects only the
platform recommend=true scopes (plus the recommend.deny removals).

Update registry tests to use a recommend=true scope (sheets:spreadsheet:read)
as the auto-approve sample and assert the override allow set is empty.

Change-Id: Ic555a2c664e2dbd742f79712253f2918dfabf7ce
2026-06-05 11:37:46 +08:00
165 changed files with 8603 additions and 2114 deletions

View File

@@ -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/calendar/helpers\.go|shortcuts/drive/|shortcuts/mail/)
- 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/drive/|shortcuts/mail/|shortcuts/calendar/helpers\.go|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 is scoped to migrated domains: the shared helpers
# it bans are still used by other domains until their later migration phase.
- path-except: (shortcuts/drive/|shortcuts/mail/)
# 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/|shortcuts/im/)
text: errs-no-legacy-helper
linters:
- forbidigo
@@ -116,16 +116,14 @@ linters:
[errs-typed-only] use errs.NewXxxError(...) builder
(see errs/types.go).
# ── legacy shared error helpers banned on migrated domains ──
# These helpers internally produce legacy output.Err* shapes, so they
# are invisible to the errs-typed-only ban above. Migrated domains use
# typed errs.* builders or domain-local file-I/O helpers instead; this
# prevents reintroduction while unmigrated domains continue to use the
# shared helpers until their later migration phase.
- pattern: (common\.FlagErrorf|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
# These helpers emit legacy output.Err* / bare error shapes or drop
# typed metadata such as Param/Cause. Migrated domains must use typed
# common replacements or local typed helpers instead.
- pattern: (common\.FlagErrorf|common\.RejectDangerousChars|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
msg: >-
[errs-no-legacy-helper] these shared helpers emit legacy output.Err*
shapes. Use typed errs.NewXxxError builders or a domain-local
file-I/O helper.
[errs-no-legacy-helper] these shared helpers emit legacy or
metadata-poor error shapes. Use typed common replacements, typed
errs.NewXxxError builders, or domain-local typed helpers.
# ── bare error wraps banned on fully-typed paths ──
- pattern: (fmt\.Errorf|errors\.New)\b
msg: >-

View File

@@ -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.",
},
})
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/larksuite/cli/events/im"
"github.com/larksuite/cli/events/minutes"
"github.com/larksuite/cli/events/vc"
"github.com/larksuite/cli/events/whiteboard"
"github.com/larksuite/cli/internal/event"
)
@@ -17,6 +18,7 @@ func init() {
im.Keys(),
minutes.Keys(),
vc.Keys(),
whiteboard.Keys(),
}
for _, keys := range all {
for _, k := range keys {

View File

@@ -0,0 +1,23 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
// BoardWhiteboardUpdatedV1Data is the flattened whiteboard updated source payload.
type BoardWhiteboardUpdatedV1Data struct {
// WhiteboardID is the id of the whiteboard whose content was updated.
WhiteboardID string `json:"whiteboard_id"`
// OperatorIDs lists the operators that produced this update batch.
OperatorIDs []OperatorID `json:"operator_ids"`
}
// OperatorID identifies an operator that produced the whiteboard update,
// expressed in the three Lark identity formats.
type OperatorID struct {
// OpenID is the operator's open_id within the current app.
OpenID string `json:"open_id"`
// UnionID is the operator's union_id across apps under the same ISV.
UnionID string `json:"union_id"`
// UserID is the operator's user_id within the tenant.
UserID string `json:"user_id"`
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"fmt"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/validate"
)
// cleanupTimeout bounds how long the unsubscribe call has to finish during
// PreConsume cleanup so a stuck OAPI cannot block process shutdown.
const cleanupTimeout = 5 * time.Second
// whiteboardSubscriptionPreConsume calls the whiteboard event subscribe OAPI
// and returns a cleanup that invokes the matching unsubscribe.
//
// board.whiteboard.updated_v1 is subscribed per-whiteboard (by whiteboard_id),
// so the path contains a :whiteboard_id placeholder that must be supplied via params.
func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func(), error) {
if rt == nil {
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
}
whiteboardID := params["whiteboard_id"]
if whiteboardID == "" {
return nil, fmt.Errorf("param whiteboard_id is required for %s", eventType)
}
encoded := validate.EncodePathSegment(whiteboardID)
subscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/subscribe", encoded)
unsubscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/unsubscribe", encoded)
body := map[string]string{"event_type": eventType}
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
return nil, err
}
return func() {
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
defer cancel()
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
}, nil
}
}

View File

@@ -0,0 +1,198 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"github.com/larksuite/cli/internal/event"
)
// recordedCall captures a single APIClient invocation for assertion.
type recordedCall struct {
method string
path string
body interface{}
}
// fakeAPIClient is a minimal event.APIClient stub that records calls and
// can be configured to fail when the request path matches errOnPath.
type fakeAPIClient struct {
mu sync.Mutex
calls []recordedCall
errOnPath string
}
// CallAPI records the invocation and optionally returns a simulated error
// when the path contains the configured errOnPath substring.
func (f *fakeAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, recordedCall{method: method, path: path, body: body})
if f.errOnPath != "" && strings.Contains(path, f.errOnPath) {
return nil, errors.New("simulated subscribe failure")
}
return json.RawMessage(`{}`), nil
}
// TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID verifies that the
// PreConsume hook fails fast with an actionable error when whiteboard_id
// is absent from the params map.
func TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{})
if err == nil {
t.Fatalf("expected error when whiteboard_id missing")
}
if cleanup != nil {
t.Fatalf("expected nil cleanup on error")
}
if !strings.Contains(err.Error(), "whiteboard_id") {
t.Fatalf("error should mention whiteboard_id, got: %v", err)
}
}
// TestWhiteboardSubscriptionPreConsume_NilRuntime verifies that PreConsume
// returns an error when the runtime APIClient dependency is missing.
func TestWhiteboardSubscriptionPreConsume_NilRuntime(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
_, err := pc(context.Background(), nil, map[string]string{"whiteboard_id": "wb1"})
if err == nil {
t.Fatalf("expected error when runtime client is nil")
}
}
// TestWhiteboardSubscriptionPreConsume_SubscribeError verifies that a
// failed subscribe call surfaces the error and skips registering a cleanup,
// so no spurious unsubscribe is invoked.
func TestWhiteboardSubscriptionPreConsume_SubscribeError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
rt := &fakeAPIClient{errOnPath: "/subscribe"}
cleanup, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb1"})
if err == nil {
t.Fatalf("expected error from subscribe call")
}
if cleanup != nil {
t.Fatalf("expected nil cleanup when subscribe fails")
}
// only the failed subscribe call should have been made; no unsubscribe.
if len(rt.calls) != 1 {
t.Fatalf("expected exactly 1 call (subscribe), got %d", len(rt.calls))
}
}
// TestWhiteboardSubscriptionPreConsume_SubscribeAndCleanup verifies the full
// happy-path: subscribe is called once with the correct method/path/body,
// and the returned cleanup invokes the matching unsubscribe.
func TestWhiteboardSubscriptionPreConsume_SubscribeAndCleanup(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
rt := &fakeAPIClient{}
cleanup, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb1"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cleanup == nil {
t.Fatalf("expected non-nil cleanup")
}
if len(rt.calls) != 1 {
t.Fatalf("expected 1 call after subscribe, got %d", len(rt.calls))
}
got := rt.calls[0]
if got.method != "POST" {
t.Errorf("subscribe method: got %q, want POST", got.method)
}
wantSubPath := "/open-apis/board/v1/whiteboards/wb1/subscribe"
if got.path != wantSubPath {
t.Errorf("subscribe path: got %q, want %q", got.path, wantSubPath)
}
body, _ := got.body.(map[string]string)
if body["event_type"] != eventTypeWhiteboardUpdated {
t.Errorf("subscribe body event_type: got %q, want %q", body["event_type"], eventTypeWhiteboardUpdated)
}
cleanup()
if len(rt.calls) != 2 {
t.Fatalf("expected 2 calls after cleanup, got %d", len(rt.calls))
}
got2 := rt.calls[1]
if got2.method != "POST" {
t.Errorf("unsubscribe method: got %q, want POST", got2.method)
}
wantUnsubPath := "/open-apis/board/v1/whiteboards/wb1/unsubscribe"
if got2.path != wantUnsubPath {
t.Errorf("unsubscribe path: got %q, want %q", got2.path, wantUnsubPath)
}
body2, _ := got2.body.(map[string]string)
if body2["event_type"] != eventTypeWhiteboardUpdated {
t.Errorf("unsubscribe body event_type: got %q, want %q", body2["event_type"], eventTypeWhiteboardUpdated)
}
}
// TestWhiteboardSubscriptionPreConsume_PathSegmentEncoded verifies that
// whiteboard_id values containing reserved URL characters are properly
// path-segment encoded so they cannot escape into adjacent path segments.
func TestWhiteboardSubscriptionPreConsume_PathSegmentEncoded(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
rt := &fakeAPIClient{}
// 含特殊字符的 whiteboard_id 应被 path-segment 编码,避免越界到其他 path 段。
_, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb/1?evil"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rt.calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(rt.calls))
}
if strings.Contains(rt.calls[0].path, "wb/1?evil") {
t.Errorf("whiteboard_id was not encoded; path: %s", rt.calls[0].path)
}
}
// TestWhiteboardUpdatedV1HasPreConsume ensures the registered EventKey for
// board.whiteboard.updated_v1 wires the PreConsume hook and declares the
// required whiteboard_id parameter.
func TestWhiteboardUpdatedV1HasPreConsume(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
keys := Keys()
for _, k := range keys {
if k.Key == eventTypeWhiteboardUpdated {
if k.PreConsume == nil {
t.Fatalf("EventKey %s should have PreConsume hook", eventTypeWhiteboardUpdated)
}
if len(k.Params) == 0 {
t.Fatalf("EventKey %s should declare whiteboard_id param", eventTypeWhiteboardUpdated)
}
var found bool
for _, p := range k.Params {
if p.Name == "whiteboard_id" && p.Required {
found = true
}
}
if !found {
t.Fatalf("EventKey %s must declare required whiteboard_id param", eventTypeWhiteboardUpdated)
}
return
}
}
t.Fatalf("EventKey %s not registered", eventTypeWhiteboardUpdated)
}
// 确保 event.APIClient 接口与本测试 mock 一致。
var _ event.APIClient = (*fakeAPIClient)(nil)

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package whiteboard registers Board-domain EventKeys.
package whiteboard
import (
"reflect"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
)
// eventTypeWhiteboardUpdated is the OAPI event type for whiteboard content updates.
const eventTypeWhiteboardUpdated = "board.whiteboard.updated_v1"
// Keys returns all Board-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeWhiteboardUpdated,
DisplayName: "Whiteboard updated",
Description: "Pushed when the whiteboard content is updated.",
EventType: eventTypeWhiteboardUpdated,
Params: []event.ParamDef{
{
Name: "whiteboard_id",
Type: event.ParamString,
Required: true,
Description: "Whiteboard id to subscribe; subscription is per-whiteboard.",
},
},
Schema: event.SchemaDef{
Native: &event.SchemaSpec{Type: reflect.TypeOf(BoardWhiteboardUpdatedV1Data{})},
FieldOverrides: map[string]schemas.FieldMeta{
"/event/whiteboard_id": {Kind: "whiteboard_id", Description: "whiteboard id to subscribe"},
"/event/operator_ids/*/open_id": {Kind: "open_id"},
"/event/operator_ids/*/union_id": {Kind: "union_id"},
"/event/operator_ids/*/user_id": {Kind: "user_id"},
},
},
PreConsume: whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated),
Scopes: []string{"board:whiteboard:node:read"},
AuthTypes: []string{"user", "bot"},
RequiredConsoleEvents: []string{eventTypeWhiteboardUpdated},
},
}
}

View File

@@ -92,6 +92,18 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
base.Troubleshooter = ts
}
}
// Upstream-provided field-level reasons (resp.error.details[].value). Lark
// returns these as free-text reason strings with no machine-readable field
// name (verified for code 190014:
// {"error":{"details":[{"value":"end_time should be later than start_time"}]}}),
// so they are lifted into Problem.Hint — the sanctioned free-text recovery
// prompt — rather than fabricated structured params. Lifted before the
// category switch so any classified arm inherits it; the CategoryAPI arm
// below prefers this server detail over the context-free APIHint default.
detailHint := liftErrorDetailValues(resp)
if detailHint != "" {
base.Hint = detailHint
}
switch meta.Category {
case errs.CategoryAuthorization:
@@ -129,7 +141,11 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
Action: action,
}
case errs.CategoryAPI:
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
// A server-supplied detail (lifted into base.Hint above) wins over the
// context-free APIHint default; only fall back to APIHint when absent.
if base.Hint == "" {
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
}
return &errs.APIError{Problem: base}
default:
// Fail closed: an unrecognized Category routes to InternalError
@@ -214,6 +230,10 @@ func stringFromAny(v any) string {
// per-subtype recovery hint before returning it, so the wire envelope
// emitted via BuildAPIError always carries a hint for known config subtypes.
func buildConfigError(p errs.Problem) *errs.ConfigError {
// Config categories have authoritative recovery guidance, so the curated
// ConfigHint deliberately overrides any server detail lifted into p.Hint
// (the opposite precedence from the CategoryAPI arm, where the lifted
// detail wins).
p.Hint = ConfigHint(p.Subtype)
return &errs.ConfigError{Problem: p}
}
@@ -258,6 +278,10 @@ func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContex
}
consoleURL := ConsoleURL(cc.Brand, cc.AppID, missing)
p.Message = CanonicalPermissionMessage(p.Subtype, cc.AppID, missing, p.Message)
// Permission categories have authoritative recovery guidance (scopes to
// grant, console URL), so the curated PermissionHint deliberately overrides
// any server detail lifted into p.Hint (the opposite precedence from the
// CategoryAPI arm, where the lifted detail wins).
p.Hint = PermissionHint(missing, identity, p.Subtype, consoleURL)
permErr := &errs.PermissionError{
Problem: p,
@@ -366,6 +390,32 @@ func PermissionHint(missing []string, identity string, subtype errs.Subtype, con
return "check the calling identity has the required scope"
}
// liftErrorDetailValues collects the non-empty resp.error.details[].value reason
// strings and joins them with "; ". Returns "" when the structure is absent or
// carries no non-empty value. The shape (verified for code 190014) is
// {"error":{"details":[{"value":"<reason>"}]}}.
func liftErrorDetailValues(resp map[string]any) string {
errBlock, ok := resp["error"].(map[string]any)
if !ok {
return ""
}
details, ok := errBlock["details"].([]any)
if !ok || len(details) == 0 {
return ""
}
var values []string
for _, d := range details {
m, ok := d.(map[string]any)
if !ok {
continue
}
if v, _ := m["value"].(string); v != "" {
values = append(values, v)
}
}
return strings.Join(values, "; ")
}
// extractMissingScopes walks resp["error"]["permission_violations"][].subject.
// Returns nil when the structure is absent.
func extractMissingScopes(resp map[string]any) []string {

View File

@@ -220,6 +220,111 @@ func TestBuildAPIError_TroubleshooterLiftedOnPermissionArm(t *testing.T) {
}
}
// TestBuildAPIError_DetailsLiftedToHintOnAPIArm pins that BuildAPIError lifts
// resp.error.details[].value into Problem.Hint when the response routes to the
// catch-all CategoryAPI arm. The real Lark shape (verified for code 190014) is
// {"error":{"details":[{"value":"end_time should be later than start_time"}]}}
// — only a human-readable reason string, no machine-readable field name. It is
// lifted into Hint (sanctioned free-text recovery prompt) rather than fabricated
// structured params.
func TestBuildAPIError_DetailsLiftedToHintOnAPIArm(t *testing.T) {
resp := map[string]any{
"code": 190014,
"msg": "invalid params",
"error": map[string]any{
"details": []any{
map[string]any{"value": "end_time should be later than start_time"},
},
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatal("ProblemOf returned !ok")
}
if !strings.Contains(p.Hint, "end_time should be later than start_time") {
t.Errorf("Hint = %q, want it to contain the server detail value", p.Hint)
}
}
// TestBuildAPIError_MultipleDetailsJoinedIntoHint pins that multiple non-empty
// detail values are joined with "; " into a single Hint, and empty values are
// skipped.
func TestBuildAPIError_MultipleDetailsJoinedIntoHint(t *testing.T) {
resp := map[string]any{
"code": 190014,
"msg": "invalid params",
"error": map[string]any{
"details": []any{
map[string]any{"value": "first reason"},
map[string]any{"value": ""},
map[string]any{"value": "second reason"},
},
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatal("ProblemOf returned !ok")
}
if p.Hint != "first reason; second reason" {
t.Errorf("Hint = %q, want %q", p.Hint, "first reason; second reason")
}
}
// TestBuildAPIError_DetailsSkipsNonMapEntries pins that malformed entries in
// the details array (not a JSON object) are skipped rather than panicking, and
// well-formed siblings still surface in the Hint.
func TestBuildAPIError_DetailsSkipsNonMapEntries(t *testing.T) {
resp := map[string]any{
"code": 190014,
"msg": "invalid params",
"error": map[string]any{
"details": []any{
"i am a bare string, not an object",
map[string]any{"value": "the real reason"},
42,
},
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatal("ProblemOf returned !ok")
}
if p.Hint != "the real reason" {
t.Errorf("Hint = %q, want %q", p.Hint, "the real reason")
}
}
// TestBuildAPIError_DetailsMalformedShapesNoHint pins that a missing error
// block, a non-array details field, and an empty details array all leave the
// Hint untouched (no lifted detail) instead of erroring.
func TestBuildAPIError_DetailsMalformedShapesNoHint(t *testing.T) {
cases := []struct {
name string
resp map[string]any
}{
{"no error block", map[string]any{"code": 190014, "msg": "invalid params"}},
{"details not array", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": "nope"}}},
{"empty details", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": []any{}}}},
{"detail values all empty", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": []any{map[string]any{"value": ""}}}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := errclass.BuildAPIError(tc.resp, errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatal("ProblemOf returned !ok")
}
// With no liftable detail, the Hint must not echo a server detail.
if strings.Contains(p.Hint, "nope") {
t.Errorf("Hint should not lift a non-array details field, got %q", p.Hint)
}
})
}
}
// TestBuildAPIError_TroubleshooterAbsent pins that Troubleshooter stays empty
// when the upstream response omits it — wire envelope must omit the field.
func TestBuildAPIError_TroubleshooterAbsent(t *testing.T) {

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// calendarCodeMeta holds calendar-service Lark code → CodeMeta mappings.
// Only codes whose meaning is verifiable from repo evidence are registered;
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var calendarCodeMeta = map[int]CodeMeta{
190014: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid params (carries a field-level detail lifted into Hint)
}
func init() { mergeCodeMeta(calendarCodeMeta, "calendar") }

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import (
"fmt"
"testing"
"github.com/larksuite/cli/errs"
)
// TestLookupCodeMeta_CalendarCodes pins each calendar-service code registered
// via the codemeta_calendar.go init() merge to its expected
// Category/Subtype/Retryable.
func TestLookupCodeMeta_CalendarCodes(t *testing.T) {
cases := []struct {
code int
wantCat errs.Category
wantSubtype errs.Subtype
wantRetry bool
}{
// 190014: calendar "invalid params" with a field-level detail
// (error.details[].value) lifted into Hint by BuildAPIError.
{190014, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
meta, ok := LookupCodeMeta(tc.code)
if !ok {
t.Fatalf("code %d not registered in codeMeta", tc.code)
}
if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry {
t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v",
tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry)
}
})
}
}

View File

@@ -231,14 +231,9 @@ func TestLoadAutoApproveSet(t *testing.T) {
t.Fatal("expected non-empty auto-approve set")
}
// From scope_overrides.json allow list
if !aaSet["calendar:calendar.event:create"] {
t.Error("expected calendar:calendar.event:create in auto-approve set (from allow list)")
}
// Verify allow list entries are present
// From scope_priorities.json recommend=="true"
if !aaSet["sheets:spreadsheet:read"] {
t.Error("expected sheets:spreadsheet:read in auto-approve set (from allow list)")
t.Error("expected sheets:spreadsheet:read in auto-approve set (recommend=true in priorities)")
}
t.Logf("Auto-approve set has %d scopes", len(aaSet))
@@ -257,16 +252,10 @@ func TestLoadPlatformAutoApproveSet(t *testing.T) {
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
allowSet := LoadOverrideAutoApproveAllow()
if len(allowSet) == 0 {
t.Fatal("expected non-empty override allow set")
}
// Known entries from scope_overrides.json
if !allowSet["calendar:calendar.event:create"] {
t.Error("expected calendar:calendar.event:create in allow set")
}
if !allowSet["mail:event"] {
t.Error("expected mail:event in allow set")
// recommend.allow in scope_overrides.json is intentionally empty:
// no scopes are special-cased into the auto-approve set anymore.
if len(allowSet) != 0 {
t.Errorf("expected empty override allow set, got %d entries", len(allowSet))
}
}
@@ -277,9 +266,9 @@ func TestLoadOverrideAutoApproveDeny(t *testing.T) {
}
func TestIsAutoApproveScope(t *testing.T) {
// Known auto-approve scope (in allow list)
if !IsAutoApproveScope("calendar:calendar.event:create") {
t.Error("expected calendar:calendar.event:create to be auto-approve")
// Known auto-approve scope (recommend=true in scope_priorities.json)
if !IsAutoApproveScope("sheets:spreadsheet:read") {
t.Error("expected sheets:spreadsheet:read to be auto-approve")
}
// Completely unknown scope
@@ -290,9 +279,8 @@ func TestIsAutoApproveScope(t *testing.T) {
func TestFilterAutoApproveScopes(t *testing.T) {
scopes := []string{
"calendar:calendar.event:create", // auto-approve (in allow list)
"zzz:unknown:scope", // not in auto-approve
"sheets:spreadsheet:read", // auto-approve (in allow list)
"sheets:spreadsheet:read", // auto-approve (recommend=true in priorities)
"zzz:unknown:scope", // not in auto-approve
}
result := FilterAutoApproveScopes(scopes)
@@ -300,10 +288,10 @@ func TestFilterAutoApproveScopes(t *testing.T) {
t.Fatal("expected at least 1 auto-approve scope in result")
}
// Check that calendar:calendar.event:create is included
// Check that sheets:spreadsheet:read is included
found := false
for _, s := range result {
if s == "calendar:calendar.event:create" {
if s == "sheets:spreadsheet:read" {
found = true
}
// Ensure unknown scopes are not included
@@ -312,7 +300,7 @@ func TestFilterAutoApproveScopes(t *testing.T) {
}
}
if !found {
t.Error("expected calendar:calendar.event:create in result")
t.Error("expected sheets:spreadsheet:read in result")
}
}

View File

@@ -12,25 +12,7 @@
"vc:meeting.meetingevent:read": 75
},
"recommend": {
"allow": [
"calendar:calendar.event:create",
"calendar:calendar.event:delete",
"calendar:calendar.event:read",
"calendar:calendar.event:update",
"calendar:calendar.free_busy:read",
"calendar:calendar:create",
"calendar:calendar:delete",
"calendar:calendar:read",
"calendar:calendar:update",
"contact:user.basic_profile:readonly",
"mail:event",
"mail:user_mailbox.mail_contact:read",
"mail:user_mailbox.mail_contact:write",
"mail:user_mailbox.message.address:read",
"mail:user_mailbox.message.body:read",
"mail:user_mailbox.message.subject:read",
"mail:user_mailbox.message:readonly"
],
"allow": [],
"deny": [
"im:chat",
"im:message.send_as_user"

View File

@@ -15,8 +15,13 @@ import (
// legacy validation/save helpers are forbidden; callers must use the typed
// common replacements or construct an errs.* typed error directly.
var migratedCommonHelperPaths = []string{
"shortcuts/base/",
"shortcuts/calendar/",
"shortcuts/drive/",
"shortcuts/mail/",
"shortcuts/okr/",
"shortcuts/task/",
"shortcuts/whiteboard/",
}
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"

View File

@@ -16,8 +16,14 @@ import (
// call sites must return a typed errs.* error instead. Future domains opt in by
// appending their path prefix here.
var migratedEnvelopePaths = []string{
"shortcuts/base/",
"shortcuts/calendar/",
"shortcuts/drive/",
"shortcuts/mail/",
"shortcuts/okr/",
"shortcuts/task/",
"shortcuts/whiteboard/",
"shortcuts/im/",
}
// legacyOutputImportPath is the import path of the package that declares the

View File

@@ -18,7 +18,7 @@ import (
// forbidigo's errs-typed-only ban does not see them because they are method
// calls, not output.Err* identifiers — this AST rule covers that gap.
//
// Migrated code must call a typed API wrapper (e.g. drive's driveCallAPI) or use
// Migrated code must call the domain's typed API wrapper or use
// runtime.DoAPI + errclass.BuildAPIError directly, so failures classify into
// typed errs.* errors.
//
@@ -53,7 +53,7 @@ func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
File: path,
Line: fset.Position(call.Pos()).Line,
Message: "runtime." + name + " emits a legacy output.ExitError api_error envelope and downgrades typed network/auth boundary errors; it is forbidden on migrated paths",
Suggestion: "call the domain's typed API wrapper (e.g. driveCallAPI) or runtime.DoAPI + errclass.BuildAPIError " +
Suggestion: "call the domain's typed API wrapper (for example driveCallAPI or callTaskAPITyped) or runtime.DoAPI + errclass.BuildAPIError " +
"so failures classify into typed errs.* errors",
})
}

View File

@@ -618,6 +618,35 @@ func boom() error {
}
}
func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnMigratedShortcutPaths(t *testing.T) {
for _, path := range []string{
"shortcuts/okr/okr_image_upload.go",
"shortcuts/task/task_update.go",
"shortcuts/whiteboard/whiteboard_update.go",
} {
t.Run(path, func(t *testing.T) {
src := `package migrated
import "github.com/larksuite/cli/internal/output"
func boom() error {
return &output.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral(path, src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "ExitError") {
t.Errorf("message should name the legacy type: %s", v[0].Message)
}
})
}
}
func TestCheckNoLegacyEnvelopeLiteral_RejectsErrDetailLiteralOnDrivePath(t *testing.T) {
src := `package drive
@@ -662,7 +691,7 @@ func boom() error {
return &output.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/calendar/foo.go", src)
v := CheckNoLegacyEnvelopeLiteral("shortcuts/contact/foo.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path should pass, got: %+v", v)
}
@@ -801,6 +830,26 @@ func boom(runtime *common.RuntimeContext) error {
}
}
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnTaskPath(t *testing.T) {
src := `package task
func boom(runtime *common.RuntimeContext) error {
_, err := runtime.CallAPI("POST", "/x", nil, nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/task/task_update.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "CallAPI") {
t.Errorf("message should name the legacy method: %s", v[0].Message)
}
}
func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) {
src := `package drive
@@ -851,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)
}
@@ -897,6 +946,9 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
paths := []string{
"shortcuts/drive/drive_search.go",
"shortcuts/mail/mail_send.go",
"shortcuts/okr/okr_progress_create.go",
"shortcuts/task/task_update.go",
"shortcuts/whiteboard/whiteboard_query.go",
}
for _, path := range paths {
for _, helper := range helpers {
@@ -924,8 +976,29 @@ common.` + helper + `()
}
}
func TestCheckNoLegacyCommonHelperCall_RejectsDangerousCharsOnCalendarPath(t *testing.T) {
src := `package calendar
import "github.com/larksuite/cli/shortcuts/common"
func boom() {
common.RejectDangerousChars("--summary", "x")
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/calendar/calendar_create.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Suggestion, "common.RejectDangerousCharsTyped") {
t.Errorf("suggestion should name typed replacement, got: %s", v[0].Suggestion)
}
}
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
src := `package im
src := `package contact
import "github.com/larksuite/cli/shortcuts/common"
@@ -933,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)
}

View File

@@ -31,7 +31,7 @@ var BaseAdvpermDisable = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
return nil
},
@@ -55,6 +55,6 @@ var BaseAdvpermDisable = common.Shortcut{
return err
}
return handleRoleResponse(runtime, apiResp.RawBody, "disable advanced permissions failed")
return handleRoleAPIResponse(runtime, apiResp, "disable advanced permissions failed")
},
}

View File

@@ -30,7 +30,7 @@ var BaseAdvpermEnable = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
return nil
},
@@ -54,6 +54,6 @@ var BaseAdvpermEnable = common.Shortcut{
return err
}
return handleRoleResponse(runtime, apiResp.RawBody, "enable advanced permissions failed")
return handleRoleAPIResponse(runtime, apiResp, "enable advanced permissions failed")
},
}

View File

@@ -196,9 +196,7 @@ func TestBaseAdvpermEnableExecuteAPIError(t *testing.T) {
},
})
args := []string{"+advperm-enable", "--base-token", "app_x"}
if err := runShortcut(t, BaseAdvpermEnable, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190001") {
t.Fatalf("err=%v", err)
}
assertProblemCode(t, runShortcut(t, BaseAdvpermEnable, args, factory, stdout), 190001, "bad request")
}
func TestBaseAdvpermDisableExecuteTransportError(t *testing.T) {
@@ -226,7 +224,5 @@ func TestBaseAdvpermDisableExecuteAPIError(t *testing.T) {
},
})
args := []string{"+advperm-disable", "--base-token", "app_x", "--yes"}
if err := runShortcut(t, BaseAdvpermDisable, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190002") {
t.Fatalf("err=%v", err)
}
assertProblemCode(t, runShortcut(t, BaseAdvpermDisable, args, factory, stdout), 190002, "permission denied")
}

View File

@@ -55,24 +55,24 @@ func dryRunBaseBlockDelete(_ context.Context, runtime *common.RuntimeContext) *c
func validateBaseBlockCreate(runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("name")) == "" {
return common.FlagErrorf("--name must not be blank")
return baseFlagErrorf("--name must not be blank")
}
if strings.TrimSpace(runtime.Str("type")) == "" {
return common.FlagErrorf("--type must not be blank")
return baseFlagErrorf("--type must not be blank")
}
return nil
}
func validateBaseBlockMove(runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("before-id")) != "" && strings.TrimSpace(runtime.Str("after-id")) != "" {
return common.FlagErrorf("--before-id and --after-id are mutually exclusive")
return baseFlagErrorf("--before-id and --after-id are mutually exclusive")
}
return nil
}
func validateBaseBlockRename(runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("name")) == "" {
return common.FlagErrorf("--name must not be blank")
return baseFlagErrorf("--name must not be blank")
}
return nil
}

View File

@@ -32,12 +32,12 @@ var BaseDataQuery = common.Shortcut{
dec := json.NewDecoder(bytes.NewReader([]byte(runtime.Str("dsl"))))
dec.UseNumber()
if err := dec.Decode(&dsl); err != nil {
return common.FlagErrorf("--dsl invalid JSON: %v", err)
return baseFlagErrorf("--dsl invalid JSON: %v", err)
}
_, hasDim := dsl["dimensions"]
_, hasMeas := dsl["measures"]
if !hasDim && !hasMeas {
return common.FlagErrorf("--dsl must contain at least one of 'dimensions' or 'measures'")
return baseFlagErrorf("--dsl must contain at least one of 'dimensions' or 'measures'")
}
return nil
},

View File

@@ -4,9 +4,13 @@
package base
import (
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/util"
)
@@ -24,76 +28,198 @@ func handleBaseAPIResult(result interface{}, err error, action string) (map[stri
// structured ErrAPI, with server-provided message/hint promoted to the top level.
func handleBaseAPIResultAny(result interface{}, err error, action string) (interface{}, error) {
if err != nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err)
return nil, baseAPIBoundaryError(err, action)
}
resultMap, _ := result.(map[string]interface{})
code, _ := util.ToFloat64(resultMap["code"])
resultMap, ok := result.(map[string]interface{})
if !ok || resultMap == nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: API returned a malformed response envelope", action)
}
if _, exists := resultMap["code"]; !exists {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: API response is missing code", action)
}
code, numeric := util.ToFloat64(resultMap["code"])
if !numeric {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: API response code is not numeric", action)
}
if code == 0 {
return resultMap["data"], nil
}
larkCode := int(code)
msg := extractDataErrorMessage(resultMap)
if strings.TrimSpace(msg) == "" {
msg, _ = resultMap["msg"].(string)
}
detail := extractErrorDetail(resultMap)
apiErr := output.ErrAPI(larkCode, msg, detail)
hint := extractErrorHint(resultMap)
if apiErr.Detail != nil && apiErr.Detail.Hint == "" && hint != "" {
apiErr.Detail.Hint = hint
}
if apiErr.Detail != nil {
apiErr.Detail.Detail = cleanEmptyBaseErrorDetail(detail)
}
return nil, apiErr
return nil, baseAPIErrorFromResult(resultMap, errclass.ClassifyContext{})
}
func cleanEmptyBaseErrorDetail(detail interface{}) interface{} {
detailMap, ok := detail.(map[string]interface{})
if !ok {
return nil
// baseFlagErrorf marks flag-usage failures; it shares baseValidationErrorf's
// typed envelope and exists so call sites read as flag rejections.
func baseFlagErrorf(format string, args ...any) error {
return baseValidationErrorf(format, args...)
}
func baseValidationErrorf(format string, args ...any) error {
msg := fmt.Sprintf(format, args...)
err := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg)
if params := flagParams(msg); len(params) > 0 {
err = err.WithParam(params[0].Name).WithParams(params...)
}
for key, value := range detailMap {
if value == nil {
delete(detailMap, key)
if cause := firstErrorArg(args); cause != nil {
err = err.WithCause(cause)
}
return err
}
func flagParams(msg string) []errs.InvalidParam {
reason := msg
seen := map[string]bool{}
params := []errs.InvalidParam{}
for start := strings.Index(msg, "--"); start >= 0; start = strings.Index(msg, "--") {
end := start + 2
for end < len(msg) {
ch := msg[end]
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' {
end++
continue
}
break
}
if end > start+2 {
name := msg[start:end]
if !seen[name] {
seen[name] = true
params = append(params, errs.InvalidParam{Name: name, Reason: reason})
}
}
msg = msg[end:]
}
if len(detailMap) == 0 {
return nil
}
return detailMap
return params
}
func extractErrorDetail(resultMap map[string]interface{}) interface{} {
if detail, ok := nonNilMapValue(resultMap, "error"); ok {
return detail
}
data, _ := resultMap["data"].(map[string]interface{})
if detail, ok := nonNilMapValue(data, "error"); ok {
return detail
func firstErrorArg(args []any) error {
for _, arg := range args {
if err, ok := arg.(error); ok {
return err
}
}
return nil
}
func nonNilMapValue(src map[string]interface{}, key string) (interface{}, bool) {
if src == nil {
return nil, false
// baseMissingFileIOError reports a broken runtime wiring: a command that needs
// local file access was constructed without a FileIO provider. The user cannot
// fix this by changing flags, so it classifies as internal, not validation.
func baseMissingFileIOError(format string, args ...any) error {
return errs.NewInternalError(errs.SubtypeFileIO, format, args...)
}
func baseInputStatError(err error) error {
if err == nil {
return nil
}
value, ok := src[key]
if !ok {
return nil, false
if errors.Is(err, fileio.ErrPathValidation) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).WithCause(err)
}
switch value.(type) {
case nil:
return nil, false
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read file: %s", err).WithCause(err)
}
func baseSaveError(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).WithCause(err)
case errors.As(err, &me):
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create parent directory: %s", err).WithCause(err)
default:
return value, true
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create file: %s", err).WithCause(err)
}
}
func baseAPIBoundaryError(err error, action string) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "%s: %s", action, err).WithCause(err)
}
func baseUploadAttachmentError(filePath string, err error) error {
if p, ok := errs.ProblemOf(err); ok {
p.Message = fmt.Sprintf("failed to upload attachment %s: %s", filePath, p.Message)
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "failed to upload attachment %s: %s", filePath, err).WithCause(err)
}
func baseAPIErrorFromResult(resultMap map[string]interface{}, cc errclass.ClassifyContext) error {
if resultMap == nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned a malformed response envelope")
}
if msg := extractDataErrorMessage(resultMap); msg != "" {
resultMap["msg"] = msg
}
hint := extractErrorHint(resultMap)
if logID := extractBaseErrorLogID(resultMap); logID != "" {
resultMap["log_id"] = logID
}
err := errclass.BuildAPIError(resultMap, cc)
if err == nil {
return nil
}
if p, ok := errs.ProblemOf(err); ok && hint != "" {
p.Hint = hint
}
return err
}
func enrichBaseAPIErrorFromBody(err error, body []byte, cc errclass.ClassifyContext) error {
if _, ok := errs.ProblemOf(err); !ok {
return err
}
result, parseErr := decodeBaseV3Response(body)
if parseErr != nil {
return err
}
enriched := baseAPIErrorFromResult(result, cc)
if enriched == nil {
return err
}
src, _ := errs.ProblemOf(enriched)
dst, _ := errs.ProblemOf(err)
if src != nil && dst != nil {
dst.Message = src.Message
dst.Hint = src.Hint
// A body without log_id must not erase a header-derived LogID
// already carried by err.
if src.LogID != "" {
dst.LogID = src.LogID
}
}
return err
}
func extractBaseErrorLogID(resultMap map[string]interface{}) string {
for _, key := range []string{"log_id", "logid"} {
if logID, _ := resultMap[key].(string); strings.TrimSpace(logID) != "" {
return strings.TrimSpace(logID)
}
}
if detail, ok := resultMap["error"].(map[string]interface{}); ok {
for _, key := range []string{"log_id", "logid"} {
if logID, _ := detail[key].(string); strings.TrimSpace(logID) != "" {
return strings.TrimSpace(logID)
}
}
}
data, _ := resultMap["data"].(map[string]interface{})
if detail, ok := data["error"].(map[string]interface{}); ok {
for _, key := range []string{"log_id", "logid"} {
if logID, _ := detail[key].(string); strings.TrimSpace(logID) != "" {
return strings.TrimSpace(logID)
}
}
}
return ""
}
func extractErrorHint(resultMap map[string]interface{}) string {
if detail, ok := resultMap["error"].(map[string]interface{}); ok {
if hint := consumeStringField(detail, "hint"); hint != "" {

View File

@@ -4,30 +4,15 @@
package base
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/errclass"
)
func TestErrorDetailHelpers(t *testing.T) {
if value, ok := nonNilMapValue(nil, "error"); ok || value != nil {
t.Fatalf("nil map should not return value")
}
if value, ok := nonNilMapValue(map[string]interface{}{"error": nil}, "error"); ok || value != nil {
t.Fatalf("nil entry should not return value")
}
detail := map[string]interface{}{"message": "boom", "hint": "retry later"}
if value, ok := nonNilMapValue(map[string]interface{}{"error": detail}, "error"); !ok || value == nil {
t.Fatalf("expected non-nil detail")
}
if got := extractErrorDetail(map[string]interface{}{"error": detail}); got == nil {
t.Fatalf("expected root detail")
}
if got := extractErrorDetail(map[string]interface{}{"data": map[string]interface{}{"error": detail}}); got == nil {
t.Fatalf("expected nested detail")
}
if got := extractErrorHint(map[string]interface{}{"data": map[string]interface{}{"error": detail}}); got != "retry later" {
t.Fatalf("hint=%q", got)
}
@@ -53,9 +38,12 @@ func TestHandleBaseAPIResultErrorPaths(t *testing.T) {
if _, err := handleBaseAPIResultAny(result, nil, "set filter"); err == nil || !strings.Contains(err.Error(), "invalid filter") {
t.Fatalf("err=%v", err)
} else {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Code != 190001 {
t.Fatalf("expected structured code 190001, got %v", err)
p, ok := errs.ProblemOf(err)
if !ok || p.Code != 190001 {
t.Fatalf("expected typed code 190001, got %T %v", err, err)
}
if p.Hint != "check field name" {
t.Fatalf("hint=%q", p.Hint)
}
}
if _, err := handleBaseAPIResult(result, nil, "set filter"); err == nil {
@@ -63,7 +51,7 @@ func TestHandleBaseAPIResultErrorPaths(t *testing.T) {
}
}
func TestHandleBaseAPIResultCleansBaseErrorDetail(t *testing.T) {
func TestHandleBaseAPIResultPromotesBaseErrorFields(t *testing.T) {
result := map[string]interface{}{
"code": 800010407,
"msg": "cell value invalid",
@@ -87,55 +75,27 @@ func TestHandleBaseAPIResultCleansBaseErrorDetail(t *testing.T) {
}
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T %v", err, err)
}
errDetail := exitErr.Detail
if errDetail.Code != 800010407 {
t.Fatalf("code=%d", errDetail.Code)
if p.Code != 800010407 {
t.Fatalf("code=%d", p.Code)
}
if errDetail.Hint != "Provide a number value." {
t.Fatalf("hint=%q", errDetail.Hint)
if p.Message != "The cell value does not match the expected input shape." {
t.Fatalf("message=%q", p.Message)
}
detail, _ := errDetail.Detail.(map[string]interface{})
if detail == nil {
t.Fatalf("expected cleaned detail, got %#v", errDetail.Detail)
if p.Hint != "Provide a number value." {
t.Fatalf("hint=%q", p.Hint)
}
if _, exists := detail["message"]; exists {
t.Fatalf("detail should not repeat message: %#v", detail)
}
if _, exists := detail["hint"]; exists {
t.Fatalf("detail should not repeat hint: %#v", detail)
}
if _, exists := detail["docs_url"]; exists {
t.Fatalf("detail should omit nil docs_url: %#v", detail)
}
if detail["level"] != "error" {
t.Fatalf("detail should preserve non-duplicate fields: %#v", detail)
}
if detail["extra_context"] != "future detail field" {
t.Fatalf("detail should pass through unknown non-nil fields: %#v", detail)
}
if detail["path"] != "Amount" || detail["value"] != "abc" {
t.Fatalf("cleaned detail mismatch: %#v", detail)
}
if detail["logid"] != "20260508160000000000000000000000" {
t.Fatalf("logid=%q", detail["logid"])
}
if retryable, ok := detail["retryable"].(bool); !ok || retryable {
t.Fatalf("retryable=%v", detail["retryable"])
}
table, _ := detail["table"].(map[string]interface{})
if table["id"] != "tbl_1" || table["name"] != "Orders" {
t.Fatalf("table=%#v", detail["table"])
if p.LogID != "20260508160000000000000000000000" {
t.Fatalf("logID=%q", p.LogID)
}
}
func TestHandleBaseAPIResultAlwaysRemovesMessageAndHintFromDetail(t *testing.T) {
func TestHandleBaseAPIResultClassifiesKnownPermissionCode(t *testing.T) {
result := map[string]interface{}{
"code": output.LarkErrTokenNoPermission,
"code": 99991676,
"msg": "permission denied",
"data": map[string]interface{}{
"error": map[string]interface{}{
@@ -146,15 +106,15 @@ func TestHandleBaseAPIResultAlwaysRemovesMessageAndHintFromDetail(t *testing.T)
}
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T %v", err, err)
}
if exitErr.Detail.Message != "Permission denied [99991676]" {
t.Fatalf("message=%q", exitErr.Detail.Message)
if p.Code != 99991676 {
t.Fatalf("code=%d", p.Code)
}
if exitErr.Detail.Detail != nil {
t.Fatalf("detail should be empty after removing message and hint: %#v", exitErr.Detail.Detail)
if p.Category != errs.CategoryAuthorization || p.Subtype != errs.SubtypeTokenScopeInsufficient {
t.Fatalf("category/subtype=%s/%s", p.Category, p.Subtype)
}
}
@@ -167,16 +127,91 @@ func TestAttachBaseResponseLogIDFromHeader(t *testing.T) {
attachBaseErrorLogID(result, "20260508170000000000000000000000")
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T %v", err, err)
}
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
if detail["logid"] != "20260508170000000000000000000000" {
t.Fatalf("logid=%q", detail["logid"])
if p.LogID != "20260508170000000000000000000000" {
t.Fatalf("logID=%q", p.LogID)
}
}
func TestHandleBaseAPIResultRejectsNonNumericCode(t *testing.T) {
for _, code := range []interface{}{"oops", map[string]interface{}{}, nil} {
result := map[string]interface{}{"code": code, "msg": "weird envelope"}
_, err := handleBaseAPIResultAny(result, nil, "list tables")
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("code=%#v: expected typed error, got %T %v", code, err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("code=%#v: category/subtype=%s/%s", code, p.Category, p.Subtype)
}
if !strings.Contains(p.Message, "list tables") {
t.Fatalf("code=%#v: message=%q", code, p.Message)
}
}
}
func TestEnrichBaseAPIErrorFromBodyLogIDMerge(t *testing.T) {
t.Run("body without log_id keeps header-derived LogID", func(t *testing.T) {
outer := errs.NewAPIError(errs.SubtypeUnknown, "outer failure").WithCode(190001).WithLogID("header-log-id")
err := enrichBaseAPIErrorFromBody(outer, []byte(`{"code":190001,"msg":"boom"}`), errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T %v", err, err)
}
if p.Message != "boom" {
t.Fatalf("message=%q", p.Message)
}
if p.LogID != "header-log-id" {
t.Fatalf("logID=%q, want header-log-id", p.LogID)
}
})
t.Run("body log_id overrides header-derived LogID", func(t *testing.T) {
outer := errs.NewAPIError(errs.SubtypeUnknown, "outer failure").WithCode(190001).WithLogID("header-log-id")
body := `{"code":190001,"msg":"boom","data":{"error":{"logid":"body-log-id"}}}`
err := enrichBaseAPIErrorFromBody(outer, []byte(body), errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T %v", err, err)
}
if p.LogID != "body-log-id" {
t.Fatalf("logID=%q, want body-log-id", p.LogID)
}
})
}
func TestBaseMissingFileIOErrorIsInternal(t *testing.T) {
p, ok := errs.ProblemOf(baseMissingFileIOError("file operations require a FileIO provider"))
if !ok {
t.Fatal("expected typed error")
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeFileIO {
t.Fatalf("category/subtype=%s/%s", p.Category, p.Subtype)
}
}
type assertErr struct{}
func (assertErr) Error() string { return "network timeout" }
func assertProblemCode(t *testing.T, err error, code int, messageParts ...string) {
t.Helper()
if err == nil {
t.Fatalf("expected error with code %d", code)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T %v", err, err)
}
if p.Code != code {
t.Fatalf("code=%d, want %d; err=%v", p.Code, code, err)
}
for _, part := range messageParts {
if !strings.Contains(p.Message, part) {
t.Fatalf("message=%q missing %q", p.Message, part)
}
}
}

View File

@@ -18,6 +18,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -513,6 +514,65 @@ func TestBaseBlockExecuteShortcuts(t *testing.T) {
}
}
func TestBaseBlockValidationReturnsTypedErrors(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
tests := []struct {
name string
shortcut common.Shortcut
args []string
params []string
}{
{
name: "create blank name",
shortcut: BaseBaseBlockCreate,
args: []string{"+base-block-create", "--base-token", "app_x", "--type", "docx", "--name", " "},
params: []string{"--name"},
},
{
name: "move conflicting sibling anchors",
shortcut: BaseBaseBlockMove,
args: []string{"+base-block-move", "--base-token", "app_x", "--block-id", "blk_doc", "--before-id", "blk_a", "--after-id", "blk_b"},
params: []string{"--before-id", "--after-id"},
},
{
name: "rename blank name",
shortcut: BaseBaseBlockRename,
args: []string{"+base-block-rename", "--base-token", "app_x", "--block-id", "blk_doc", "--name", " "},
params: []string{"--name"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := runShortcut(t, tt.shortcut, tt.args, factory, stdout)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T %v", err, err)
}
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("category/subtype=%s/%s", p.Category, p.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T %v", err, err)
}
if validationErr.Param != tt.params[0] {
t.Fatalf("param=%q, want %q", validationErr.Param, tt.params[0])
}
if len(validationErr.Params) != len(tt.params) {
t.Fatalf("params=%#v, want %v", validationErr.Params, tt.params)
}
for i, param := range tt.params {
if validationErr.Params[i].Name != param {
t.Fatalf("params=%#v, want %v", validationErr.Params, tt.params)
}
if validationErr.Params[i].Reason == "" {
t.Fatalf("params[%d] missing reason: %#v", i, validationErr.Params)
}
}
})
}
}
func TestBaseHistoryExecute(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -871,10 +931,10 @@ func TestBaseTableExecuteReadAndDelete(t *testing.T) {
t.Run("list-http-404", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables",
Status: 404,
Body: "404 page not found",
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables",
Status: 404,
RawBody: []byte("404 page not found"),
Headers: map[string][]string{
"Content-Type": {"text/plain"},
},
@@ -2093,6 +2153,9 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
if !strings.Contains(err.Error(), "exceeds 2GB limit") {
t.Fatalf("err=%v", err)
}
if !strings.Contains(err.Error(), filepath.Base(tmpFile.Name())) {
t.Fatalf("err=%v should name the offending file", err)
}
})
t.Run("upload attachment rejects deprecated name flag", func(t *testing.T) {
@@ -2262,6 +2325,23 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("download surfaces unsafe output path instead of directory hint", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
tmpDir := t.TempDir()
withBaseWorkingDir(t, tmpDir)
err := runShortcut(t, BaseRecordDownloadAttachment, []string{
"+record-download-attachment",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--record-id", "rec_x",
"--output", "../escape",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "unsafe output path") {
t.Fatalf("err=%v", err)
}
})
t.Run("download all disambiguates duplicate attachment names with file token", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -2458,21 +2538,37 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
"--record-id", "rec_x",
"--output", "downloads",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "download failed after 1 attachment(s) succeeded and 1 failed") {
if err == nil {
t.Fatalf("err=%v", err)
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured error, got %T %v", err, err)
var partialErr *output.PartialFailureError
if !errors.As(err, &partialErr) {
t.Fatalf("expected partial failure error, got %T %v", err, err)
}
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
downloaded, _ := detail["downloaded"].([]map[string]interface{})
failed, _ := detail["failed"].([]map[string]interface{})
if len(downloaded) != 1 || downloaded[0]["file_token"] != "box_a" || len(failed) != 1 || failed[0]["file_token"] != "box_b" {
t.Fatalf("detail=%#v", exitErr.Detail.Detail)
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("failed to decode partial failure output: %v\nraw=%s", err, stdout.String())
}
if detail["log_id"] != "202605270001" {
t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail)
if envelope["ok"] != false {
t.Fatalf("ok=%#v, want false; envelope=%#v", envelope["ok"], envelope)
}
data, _ := envelope["data"].(map[string]interface{})
if msg, _ := data["message"].(string); !strings.Contains(msg, "download failed after 1 attachment(s) succeeded and 1 failed") {
t.Fatalf("message=%q", msg)
}
downloaded, _ := data["downloaded"].([]interface{})
failed, _ := data["failed"].([]interface{})
if len(downloaded) != 1 || len(failed) != 1 {
t.Fatalf("data=%#v", data)
}
downloadedItem, _ := downloaded[0].(map[string]interface{})
failedItem, _ := failed[0].(map[string]interface{})
if downloadedItem["file_token"] != "box_a" || failedItem["file_token"] != "box_b" {
t.Fatalf("data=%#v", data)
}
if data["log_id"] != "202605270001" {
t.Fatalf("data=%#v, want log_id", data)
}
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil {
t.Fatalf("expected first file to remain: %v", err)

View File

@@ -42,7 +42,7 @@ var BaseFormQuestionsCreate = common.Shortcut{
var questions []interface{}
if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil {
return output.Errorf(output.ExitValidation, "invalid_json", "--questions must be a valid JSON array: %s", err)
return baseValidationErrorf("--questions must be a valid JSON array: %s", err)
}
data, err := baseV3Call(runtime, "POST",

View File

@@ -7,7 +7,6 @@ import (
"context"
"encoding/json"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -43,7 +42,7 @@ var BaseFormQuestionsDelete = common.Shortcut{
var questionIds []string
if err := json.Unmarshal([]byte(questionIdsJSON), &questionIds); err != nil {
return output.Errorf(output.ExitValidation, "invalid_json", "--question-ids must be a valid JSON array of strings: %s", err)
return baseValidationErrorf("--question-ids must be a valid JSON array of strings: %s", err)
}
_, err := baseV3Call(runtime, "DELETE",

View File

@@ -42,7 +42,7 @@ var BaseFormQuestionsUpdate = common.Shortcut{
var questions []interface{}
if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil {
return output.Errorf(output.ExitValidation, "invalid_json", "--questions must be a valid JSON array: %s", err)
return baseValidationErrorf("--questions must be a valid JSON array: %s", err)
}
data, err := baseV3Call(runtime, "PATCH",

View File

@@ -14,7 +14,6 @@ import (
"golang.org/x/sync/errgroup"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -62,31 +61,31 @@ func validateFormSubmit(runtime *common.RuntimeContext) error {
attachments, hasAttachments := raw["attachments"]
if !hasAttachments && fields == nil {
return common.FlagErrorf("--json must contain at least \"fields\" or \"attachments\"")
return baseFlagErrorf("--json must contain at least \"fields\" or \"attachments\"")
}
if hasAttachments {
// 有附件时 --base-token 必填(上传附件到 Base Drive Media 需要)
if runtime.Str("base-token") == "" {
return common.FlagErrorf("--base-token is required when --json contains \"attachments\"")
return baseFlagErrorf("--base-token is required when --json contains \"attachments\"")
}
attMap, ok := attachments.(map[string]interface{})
if !ok {
return common.FlagErrorf("--json.attachments must be a JSON object mapping field names to file path arrays")
return baseFlagErrorf("--json.attachments must be a JSON object mapping field names to file path arrays")
}
for fieldName, value := range attMap {
paths, ok := value.([]interface{})
if !ok {
return common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
return baseFlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
}
for i, item := range paths {
if _, ok := item.(string); !ok {
return common.FlagErrorf("--json.attachments.%q[%d] must be a file path string, got %T", fieldName, i, item)
return baseFlagErrorf("--json.attachments.%q[%d] must be a file path string, got %T", fieldName, i, item)
}
}
if len(paths) == 0 {
return common.FlagErrorf("--json.attachments.%q must not be empty; remove it or provide at least one file path", fieldName)
return baseFlagErrorf("--json.attachments.%q must not be empty; remove it or provide at least one file path", fieldName)
}
}
}
@@ -111,21 +110,21 @@ func parseFormSubmitJSON(runtime *common.RuntimeContext) (map[string]interface{}
if attachments, ok := raw["attachments"]; ok {
attObj, ok := attachments.(map[string]interface{})
if !ok {
return nil, nil, common.FlagErrorf(`--json.attachments must be a JSON object mapping field names to file path arrays`)
return nil, nil, baseFlagErrorf(`--json.attachments must be a JSON object mapping field names to file path arrays`)
}
if len(attObj) > 0 {
attMap = make(map[string][]string, len(attObj))
for fieldName, value := range attObj {
paths, ok := value.([]interface{})
if !ok {
return nil, nil, common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
return nil, nil, baseFlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
}
filePaths := make([]string, 0, len(paths))
for _, item := range paths {
if s, ok := item.(string); ok {
filePaths = append(filePaths, s)
} else {
return nil, nil, common.FlagErrorf("--json.attachments.%q must contain file path strings only, got %T", fieldName, item)
return nil, nil, baseFlagErrorf("--json.attachments.%q must contain file path strings only, got %T", fieldName, item)
}
}
if len(filePaths) > 0 {
@@ -195,33 +194,33 @@ func executeFormSubmit(runtime *common.RuntimeContext) error {
baseToken := runtime.Str("base-token")
fio := runtime.FileIO()
if fio == nil {
return output.ErrValidation("file operations require a FileIO provider (needed for attachments in --json)")
return baseMissingFileIOError("file operations require a FileIO provider (needed for attachments in --json)")
}
// Step 1: 收集所有唯一路径(跨字段去重)
allPaths := collectUniquePaths(attachmentMap)
if len(allPaths) == 0 {
return common.FlagErrorf("attachments in --json contains no valid file paths")
return baseFlagErrorf("attachments in --json contains no valid file paths")
}
// Step 2: 前置校验所有文件路径安全性与可访问性,同时收集文件大小供上传使用
sizeMap := make(map[string]int64, len(allPaths))
for _, filePath := range allPaths {
if _, err := validate.SafeInputPath(filePath); err != nil {
return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
return baseValidationErrorf("unsafe attachment file path: %s: %v", filePath, err)
}
fileInfo, err := fio.Stat(filePath)
if err != nil {
if errors.Is(err, fileio.ErrPathValidation) {
return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
return baseValidationErrorf("unsafe attachment file path: %s: %v", filePath, err)
}
return output.ErrValidation("attachment file not accessible: %s: %v", filePath, err)
return baseValidationErrorf("attachment file not accessible: %s: %v", filePath, err)
}
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
return output.ErrValidation("attachment file %s exceeds 2GB limit", filePath)
return baseValidationErrorf("attachment file %s exceeds 2GB limit", filePath)
}
if !fileInfo.Mode().IsRegular() {
return output.ErrValidation("attachment file %s is not a regular file", filePath)
return baseValidationErrorf("attachment file %s is not a regular file", filePath)
}
sizeMap[filePath] = fileInfo.Size()
}
@@ -328,7 +327,7 @@ func uploadAttachmentsParallel(runtime *common.RuntimeContext, paths []string, t
func uploadSingleAttachment(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, target baseAttachmentUploadTarget) (interface{}, error) {
att, err := uploadAttachmentToBase(runtime, filePath, fileName, fileSize, target)
if err != nil {
return nil, fmt.Errorf("failed to upload attachment %s: %w", filePath, err)
return nil, baseUploadAttachmentError(filePath, err)
}
return att, nil
}

View File

@@ -5,9 +5,10 @@ package base
import (
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/output"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -17,6 +18,14 @@ import (
// - Inner: business-level code/message inside the data object
//
// The data field may be a JSON object (actual behavior) or a JSON string (per doc).
func handleRoleAPIResponse(runtime *common.RuntimeContext, apiResp *larkcore.ApiResp, action string) error {
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
enriched := enrichBaseAPIErrorFromBody(err, apiResp.RawBody, runtime.APIClassifyContext())
return prefixRoleActionError(enriched, action)
}
return handleRoleResponse(runtime, apiResp.RawBody, action)
}
func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action string) error {
var resp struct {
Code int `json:"code"`
@@ -24,23 +33,17 @@ func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action s
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(rawBody, &resp); err != nil {
return fmt.Errorf("failed to parse response: %v", err)
return errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: failed to parse response: %v", action, err).WithCause(err)
}
if resp.Code != 0 {
msg := resp.Msg
// When outer msg is empty, try to extract error details from data.error.message
if msg == "" && len(resp.Data) > 0 {
var errData struct {
Error struct {
Message string `json:"message"`
Hint string `json:"hint"`
} `json:"error"`
}
if json.Unmarshal(resp.Data, &errData) == nil && errData.Error.Message != "" {
msg = errData.Error.Message
result := map[string]interface{}{"code": resp.Code, "msg": resp.Msg}
if len(resp.Data) > 0 {
var data interface{}
if json.Unmarshal(resp.Data, &data) == nil {
result["data"] = data
}
}
return output.ErrAPI(resp.Code, fmt.Sprintf("%s: [%d] %s", action, resp.Code, msg), nil)
return baseRoleAPIError(runtime, result, action)
}
if len(resp.Data) == 0 || string(resp.Data) == "null" || string(resp.Data) == `""` {
@@ -75,7 +78,8 @@ func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action s
}
if codeInt != 0 {
msg, _ := m["message"].(string)
return output.ErrAPI(codeInt, fmt.Sprintf("%s: [%d] %s", action, codeInt, msg), nil)
result := map[string]interface{}{"code": codeInt, "msg": msg, "data": m}
return baseRoleAPIError(runtime, result, action)
}
// code == 0, extract the inner data if present
if innerData, hasInner := m["data"]; hasInner {
@@ -98,3 +102,20 @@ func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action s
runtime.Out(data, nil)
return nil
}
func baseRoleAPIError(runtime *common.RuntimeContext, result map[string]interface{}, action string) error {
return prefixRoleActionError(baseAPIErrorFromResult(result, runtime.APIClassifyContext()), action)
}
// prefixRoleActionError prepends the failed role action ("create role failed",
// "get role failed", ...) to a typed error's message so both the classified
// outer-response path and the parsed-body path carry the same context.
func prefixRoleActionError(err error, action string) error {
if err == nil {
return nil
}
if p, ok := errs.ProblemOf(err); ok && action != "" {
p.Message = action + ": " + p.Message
}
return err
}

View File

@@ -34,11 +34,11 @@ var BaseRoleCreate = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
var body map[string]any
if err := json.Unmarshal([]byte(runtime.Str("json")), &body); err != nil {
return common.FlagErrorf("--json must be valid JSON: %v", err)
return baseFlagErrorf("--json must be valid JSON: %v", err)
}
return nil
},
@@ -64,6 +64,6 @@ var BaseRoleCreate = common.Shortcut{
return err
}
return handleRoleResponse(runtime, apiResp.RawBody, "create role failed")
return handleRoleAPIResponse(runtime, apiResp, "create role failed")
},
}

View File

@@ -34,10 +34,10 @@ var BaseRoleDelete = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
if strings.TrimSpace(runtime.Str("role-id")) == "" {
return common.FlagErrorf("--role-id must not be blank")
return baseFlagErrorf("--role-id must not be blank")
}
return nil
},
@@ -60,6 +60,6 @@ var BaseRoleDelete = common.Shortcut{
return err
}
return handleRoleResponse(runtime, apiResp.RawBody, "delete role failed")
return handleRoleAPIResponse(runtime, apiResp, "delete role failed")
},
}

View File

@@ -33,10 +33,10 @@ var BaseRoleGet = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
if strings.TrimSpace(runtime.Str("role-id")) == "" {
return common.FlagErrorf("--role-id must not be blank")
return baseFlagErrorf("--role-id must not be blank")
}
return nil
},
@@ -58,6 +58,6 @@ var BaseRoleGet = common.Shortcut{
return err
}
return handleRoleResponse(runtime, apiResp.RawBody, "get role failed")
return handleRoleAPIResponse(runtime, apiResp, "get role failed")
},
}

View File

@@ -32,7 +32,7 @@ var BaseRoleList = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
return nil
},
@@ -52,6 +52,6 @@ var BaseRoleList = common.Shortcut{
return err
}
return handleRoleResponse(runtime, apiResp.RawBody, "list roles failed")
return handleRoleAPIResponse(runtime, apiResp, "list roles failed")
},
}

View File

@@ -375,9 +375,7 @@ func TestBaseRoleCreateExecuteAPIError(t *testing.T) {
},
})
args := []string{"+role-create", "--base-token", "app_x", "--json", `{"role_name":"Bad"}`}
if err := runShortcut(t, BaseRoleCreate, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190001") {
t.Fatalf("err=%v", err)
}
assertProblemCode(t, runShortcut(t, BaseRoleCreate, args, factory, stdout), 190001, "create role failed", "bad request")
}
func TestBaseRoleListExecuteTransportError(t *testing.T) {
@@ -405,9 +403,7 @@ func TestBaseRoleListExecuteAPIError(t *testing.T) {
},
})
args := []string{"+role-list", "--base-token", "app_x"}
if err := runShortcut(t, BaseRoleList, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190002") {
t.Fatalf("err=%v", err)
}
assertProblemCode(t, runShortcut(t, BaseRoleList, args, factory, stdout), 190002, "not found")
}
func TestBaseRoleDeleteExecuteAPIError(t *testing.T) {
@@ -421,9 +417,7 @@ func TestBaseRoleDeleteExecuteAPIError(t *testing.T) {
},
})
args := []string{"+role-delete", "--base-token", "app_x", "--role-id", "rol_1", "--yes"}
if err := runShortcut(t, BaseRoleDelete, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190003") {
t.Fatalf("err=%v", err)
}
assertProblemCode(t, runShortcut(t, BaseRoleDelete, args, factory, stdout), 190003, "forbidden")
}
func TestBaseRoleUpdateExecuteAPIError(t *testing.T) {
@@ -437,9 +431,7 @@ func TestBaseRoleUpdateExecuteAPIError(t *testing.T) {
},
})
args := []string{"+role-update", "--base-token", "app_x", "--role-id", "rol_1", "--json", `{"role_name":"X"}`, "--yes"}
if err := runShortcut(t, BaseRoleUpdate, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190004") {
t.Fatalf("err=%v", err)
}
assertProblemCode(t, runShortcut(t, BaseRoleUpdate, args, factory, stdout), 190004, "invalid params")
}
func TestBaseRoleGetExecuteBusinessError(t *testing.T) {
@@ -457,9 +449,7 @@ func TestBaseRoleGetExecuteBusinessError(t *testing.T) {
},
})
args := []string{"+role-get", "--base-token", "app_x", "--role-id", "rol_bad"}
if err := runShortcut(t, BaseRoleGet, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "100001") || !strings.Contains(err.Error(), "role not found") {
t.Fatalf("err=%v", err)
}
assertProblemCode(t, runShortcut(t, BaseRoleGet, args, factory, stdout), 100001, "role not found")
}
// ---------------------------------------------------------------------------
@@ -487,9 +477,7 @@ func TestHandleRoleResponse(t *testing.T) {
t.Run("outer error code", func(t *testing.T) {
rt := newRoleResponseRuntime(t)
if err := handleRoleResponse(rt, []byte(`{"code":999,"msg":"outer error"}`), "test"); err == nil || !strings.Contains(err.Error(), "999") {
t.Fatalf("err=%v", err)
}
assertProblemCode(t, handleRoleResponse(rt, []byte(`{"code":999,"msg":"outer error"}`), "test"), 999, "outer error")
})
t.Run("outer error code with empty msg and data.error.message", func(t *testing.T) {
@@ -574,9 +562,7 @@ func TestHandleRoleResponse(t *testing.T) {
t.Run("business code non-zero", func(t *testing.T) {
rt := newRoleResponseRuntime(t)
body := `{"code":0,"msg":"ok","data":{"code":50001,"message":"permission denied"}}`
if err := handleRoleResponse(rt, []byte(body), "test"); err == nil || !strings.Contains(err.Error(), "50001") {
t.Fatalf("err=%v", err)
}
assertProblemCode(t, handleRoleResponse(rt, []byte(body), "test"), 50001, "permission denied")
})
t.Run("data is array", func(t *testing.T) {

View File

@@ -36,14 +36,14 @@ var BaseRoleUpdate = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
if strings.TrimSpace(runtime.Str("role-id")) == "" {
return common.FlagErrorf("--role-id must not be blank")
return baseFlagErrorf("--role-id must not be blank")
}
var body map[string]any
if err := json.Unmarshal([]byte(runtime.Str("json")), &body); err != nil {
return common.FlagErrorf("--json must be valid JSON: %v", err)
return baseFlagErrorf("--json must be valid JSON: %v", err)
}
return nil
},
@@ -72,6 +72,6 @@ var BaseRoleUpdate = common.Shortcut{
return err
}
return handleRoleResponse(runtime, apiResp.RawBody, "update role failed")
return handleRoleAPIResponse(runtime, apiResp, "update role failed")
},
}

View File

@@ -30,34 +30,34 @@ func baseTableID(runtime *common.RuntimeContext) string {
func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", common.FlagErrorf("--%s cannot be empty", flagName)
return "", baseFlagErrorf("--%s cannot be empty", flagName)
}
if !strings.HasPrefix(raw, "@") {
return raw, nil
}
path := strings.TrimSpace(strings.TrimPrefix(raw, "@"))
if path == "" {
return "", common.FlagErrorf("--%s file path cannot be empty after @", flagName)
return "", baseFlagErrorf("--%s file path cannot be empty after @", flagName)
}
if pc.fio == nil {
return "", common.FlagErrorf("--%s @file inputs require a FileIO provider", flagName)
return "", baseMissingFileIOError("--%s @file inputs require a FileIO provider", flagName)
}
f, err := pc.fio.Open(path)
if err != nil {
var pathErr *fileio.PathValidationError
if errors.As(err, &pathErr) {
return "", common.FlagErrorf("--%s invalid JSON file path %q: %v", flagName, path, pathErr.Err)
return "", baseFlagErrorf("--%s invalid JSON file path %q: %v", flagName, path, pathErr.Err)
}
return "", common.FlagErrorf("--%s cannot open JSON file %q: %v", flagName, path, err)
return "", baseFlagErrorf("--%s cannot open JSON file %q: %v", flagName, path, err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return "", common.FlagErrorf("--%s cannot read JSON file %q: %v", flagName, path, err)
return "", baseFlagErrorf("--%s cannot read JSON file %q: %v", flagName, path, err)
}
content := strings.TrimSpace(string(data))
if content == "" {
return "", common.FlagErrorf("--%s JSON file %q is empty", flagName, path)
return "", baseFlagErrorf("--%s JSON file %q is empty", flagName, path)
}
return content, nil
}
@@ -68,15 +68,15 @@ func jsonInputTip(flagName string) string {
func formatJSONError(flagName string, target string, err error) error {
if syntaxErr, ok := err.(*json.SyntaxError); ok {
return common.FlagErrorf("--%s invalid JSON %s near byte %d (%v); %s", flagName, target, syntaxErr.Offset, err, jsonInputTip(flagName))
return baseFlagErrorf("--%s invalid JSON %s near byte %d (%v); %s", flagName, target, syntaxErr.Offset, err, jsonInputTip(flagName))
}
if typeErr, ok := err.(*json.UnmarshalTypeError); ok {
if typeErr.Field != "" {
return common.FlagErrorf("--%s invalid JSON %s at field %q (%v); %s", flagName, target, typeErr.Field, err, jsonInputTip(flagName))
return baseFlagErrorf("--%s invalid JSON %s at field %q (%v); %s", flagName, target, typeErr.Field, err, jsonInputTip(flagName))
}
return common.FlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName))
return baseFlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName))
}
return common.FlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName))
return baseFlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName))
}
func baseAction(runtime *common.RuntimeContext, boolFlags []string, stringFlags []string) (string, error) {
@@ -92,14 +92,14 @@ func baseAction(runtime *common.RuntimeContext, boolFlags []string, stringFlags
}
}
if len(active) == 0 {
return "", common.FlagErrorf("specify one action")
return "", baseFlagErrorf("specify one action")
}
if len(active) > 1 {
flags := make([]string, 0, len(active))
for _, item := range active {
flags = append(flags, "--"+item)
}
return "", common.FlagErrorf("actions are mutually exclusive: %s", strings.Join(flags, ", "))
return "", baseFlagErrorf("actions are mutually exclusive: %s", strings.Join(flags, ", "))
}
return active[0], nil
}
@@ -123,7 +123,7 @@ func parseObjectList(pc *parseCtx, raw string, flagName string) ([]map[string]in
for idx, item := range arr {
obj, ok := item.(map[string]interface{})
if !ok {
return nil, common.FlagErrorf("--%s item %d must be an object", flagName, idx+1)
return nil, baseFlagErrorf("--%s item %d must be an object", flagName, idx+1)
}
items = append(items, obj)
}
@@ -150,6 +150,6 @@ func parseJSONValue(pc *parseCtx, raw string, flagName string) (interface{}, err
case map[string]interface{}, []interface{}:
return value, nil
default:
return nil, common.FlagErrorf("--%s must be a JSON object or array", flagName)
return nil, baseFlagErrorf("--%s must be a JSON object or array", flagName)
}
}

View File

@@ -6,9 +6,9 @@ package base
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -47,7 +47,7 @@ var BaseDashboardBlockCreate = common.Shortcut{
if strings.TrimSpace(raw) == "" {
// text 类型必须提供 data-config含 text 内容)
if strings.ToLower(runtime.Str("type")) == "text" {
return fmt.Errorf("text 类型组件必须提供 data-config包含必填字段 text")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "text 类型组件必须提供 data-config包含必填字段 text").WithParam("--data-config")
}
return nil
}

View File

@@ -91,7 +91,7 @@ func validateFormulaLookupGuideAck(runtime *common.RuntimeContext, command strin
if fieldType == "lookup" {
guidePath = "skills/lark-base/references/lookup-field-guide.md"
}
return common.FlagErrorf("--i-have-read-guide is required for %s when --json.type is %q; read %s first, then retry with --i-have-read-guide", command, fieldType, guidePath)
return baseFlagErrorf("--i-have-read-guide is required for %s when --json.type is %q; read %s first, then retry with --i-have-read-guide", command, fieldType, guidePath)
}
return nil
}

View File

@@ -17,6 +17,7 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -41,10 +42,10 @@ func parseJSONObject(pc *parseCtx, raw string, flagName string) (map[string]inte
if errors.As(err, &syntaxErr) {
return nil, formatJSONError(flagName, "object", err)
}
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
return nil, baseFlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
}
if result == nil {
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
return nil, baseFlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
}
return result, nil
}
@@ -152,7 +153,7 @@ func cloneValue(value interface{}) interface{} {
func resolveFieldTypeSpec(typeName string) (fieldTypeSpec, error) {
trimmed := strings.TrimSpace(typeName)
if trimmed == "" {
return fieldTypeSpec{}, fmt.Errorf("field type cannot be empty")
return fieldTypeSpec{}, baseValidationErrorf("field type cannot be empty")
}
switch strings.ToLower(trimmed) {
case "text", "phone", "url", "email", "barcode":
@@ -192,7 +193,7 @@ func resolveFieldTypeSpec(typeName string) (fieldTypeSpec, error) {
case "modifiedtime", "modified_time", "modified-time":
return fieldTypeSpec{Type: "updated_at", Extra: map[string]interface{}{"style": map[string]interface{}{"format": "yyyy/MM/dd"}}}, nil
default:
return fieldTypeSpec{}, fmt.Errorf("unsupported field type %q in base/v3", typeName)
return fieldTypeSpec{}, baseValidationErrorf("unsupported field type %q in base/v3", typeName)
}
}
@@ -252,10 +253,10 @@ func normalizeSelectOptions(raw interface{}) []interface{} {
func buildFieldBody(fieldName string, typeName string, property map[string]interface{}, uiType string, description string, isPrimary bool, isHidden bool) (map[string]interface{}, error) {
if isPrimary {
return nil, fmt.Errorf("base/v3 does not support setting primary field in field body")
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "base/v3 does not support setting primary field in field body")
}
if isHidden {
return nil, fmt.Errorf("base/v3 does not support hidden field creation in field body")
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "base/v3 does not support hidden field creation in field body")
}
spec, err := resolveFieldTypeSpec(typeName)
if err != nil {
@@ -354,7 +355,7 @@ func buildTableFieldBodies(rawFields string, rawFieldSpecs string) ([]interface{
if rawFields != "" {
var fields []interface{}
if err := common.ParseJSON([]byte(rawFields), &fields); err != nil {
return nil, fmt.Errorf("--fields invalid JSON, must be a field definition array")
return nil, baseValidationErrorf("--fields invalid JSON, must be a field definition array")
}
return fields, nil
}
@@ -366,7 +367,7 @@ func buildTableFieldBodies(rawFields string, rawFieldSpecs string) ([]interface{
for _, spec := range specs {
body, err := buildFieldBody(spec.Name, normalizeFieldTypeName(spec.Type), nil, "", "", false, false)
if err != nil {
return nil, fmt.Errorf("field %q: %w", spec.Name, err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "field %q: %s", spec.Name, err).WithCause(err)
}
fields = append(fields, body)
}
@@ -410,20 +411,15 @@ func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[s
h.Set("X-App-Id", runtime.Config.AppID)
resp, err := runtime.DoAPI(req, larkcore.WithHeaders(h))
if err != nil {
return nil, err
return nil, baseAPIBoundaryError(err, "API call failed")
}
if _, err := runtime.ClassifyAPIResponse(resp); err != nil {
if statusErr := baseHTTPStatusErrorFromInvalidResponse(resp, err); statusErr != nil {
return nil, statusErr
}
return nil, enrichBaseAPIErrorFromBody(err, resp.RawBody, runtime.APIClassifyContext())
}
result, parseErr := decodeBaseV3Response(resp.RawBody)
if parseErr == nil && baseV3ResultCode(result) != 0 {
attachBaseErrorLogID(result, baseResponseLogID(resp))
return result, nil
}
if resp.StatusCode >= http.StatusBadRequest {
body := strings.TrimSpace(string(resp.RawBody))
if body == "" {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
}
if parseErr != nil {
return nil, parseErr
}
@@ -435,18 +431,14 @@ func decodeBaseV3Response(body []byte) (map[string]interface{}, error) {
dec := json.NewDecoder(bytes.NewReader(body))
dec.UseNumber()
if err := dec.Decode(&result); err != nil {
return nil, fmt.Errorf("response parse error: %w", err)
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned an invalid JSON response: %v", err).WithCause(err)
}
if result == nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned a non-object JSON response")
}
return result, nil
}
func baseV3ResultCode(result map[string]interface{}) int {
if result == nil {
return 0
}
return toInt(result["code"])
}
func attachBaseErrorLogID(result map[string]interface{}, logID string) {
if result == nil || strings.TrimSpace(logID) == "" {
return
@@ -480,6 +472,33 @@ func baseResponseLogID(resp *larkcore.ApiResp) string {
return strings.TrimSpace(resp.Header.Get("x-tt-logid"))
}
func baseHTTPStatusErrorFromInvalidResponse(resp *larkcore.ApiResp, classified error) error {
if resp == nil || resp.StatusCode < http.StatusBadRequest {
return nil
}
p, ok := errs.ProblemOf(classified)
if !ok || p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
return nil
}
body := strings.TrimSpace(string(resp.RawBody))
if resp.StatusCode >= http.StatusInternalServerError {
err := errs.NewNetworkError(errs.SubtypeNetworkServer, "HTTP %d: %s", resp.StatusCode, body).WithCode(resp.StatusCode).WithRetryable()
if logID := baseResponseLogID(resp); logID != "" {
err = err.WithLogID(logID)
}
return err
}
subtype := errs.SubtypeUnknown
if resp.StatusCode == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
err := errs.NewAPIError(subtype, "HTTP %d: %s", resp.StatusCode, body).WithCode(resp.StatusCode)
if logID := baseResponseLogID(resp); logID != "" {
err = err.WithLogID(logID)
}
return err
}
func baseV3Call(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
result, err := baseV3Raw(runtime, method, path, params, data)
return handleBaseAPIResult(result, err, "API call failed")
@@ -525,7 +544,7 @@ func toStringSlice(v interface{}) []string {
func listAllTables(runtime *common.RuntimeContext, baseToken string, offset, limit int) ([]map[string]interface{}, int, error) {
if limit <= 0 {
return nil, 0, fmt.Errorf("limit must be greater than 0")
return nil, 0, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0")
}
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
if err != nil {
@@ -555,7 +574,7 @@ func listAllTables(runtime *common.RuntimeContext, baseToken string, offset, lim
func listAllFields(runtime *common.RuntimeContext, baseToken, tableID string, offset, limit int) ([]map[string]interface{}, int, error) {
if limit <= 0 {
return nil, 0, fmt.Errorf("limit must be greater than 0")
return nil, 0, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0")
}
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableID, "fields"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
if err != nil {
@@ -577,7 +596,7 @@ func listAllFields(runtime *common.RuntimeContext, baseToken, tableID string, of
func listAllViews(runtime *common.RuntimeContext, baseToken, tableID string, offset, limit int) ([]map[string]interface{}, int, error) {
if limit <= 0 {
return nil, 0, fmt.Errorf("limit must be greater than 0")
return nil, 0, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0")
}
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableID, "views"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
if err != nil {
@@ -603,7 +622,7 @@ func resolveFieldRef(fields []map[string]interface{}, ref string) (map[string]in
return field, nil
}
}
return nil, fmt.Errorf("field %q not found", ref)
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "field %q not found", ref)
}
func resolveTableRef(tables []map[string]interface{}, ref string) (map[string]interface{}, error) {
@@ -612,7 +631,7 @@ func resolveTableRef(tables []map[string]interface{}, ref string) (map[string]in
return table, nil
}
}
return nil, fmt.Errorf("table %q not found", ref)
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "table %q not found", ref)
}
func resolveViewRef(views []map[string]interface{}, ref string) (map[string]interface{}, error) {
@@ -621,7 +640,7 @@ func resolveViewRef(views []map[string]interface{}, ref string) (map[string]inte
return view, nil
}
}
return nil, fmt.Errorf("view %q not found", ref)
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "view %q not found", ref)
}
func chunkRecords(records []map[string]interface{}, size int) [][]map[string]interface{} {
@@ -738,18 +757,18 @@ func canonicalValue(v interface{}) string {
func parseNamedTypeSpecs(raw string, flagName string) ([]namedTypeSpec, error) {
var tuples []interface{}
if err := common.ParseJSON([]byte(raw), &tuples); err != nil {
return nil, fmt.Errorf("--%s invalid JSON array", flagName)
return nil, baseValidationErrorf("--%s invalid JSON array", flagName)
}
result := make([]namedTypeSpec, 0, len(tuples))
for idx, item := range tuples {
pair, ok := item.([]interface{})
if !ok || len(pair) != 2 {
return nil, fmt.Errorf("--%s item %d must be [name, type]", flagName, idx+1)
return nil, baseValidationErrorf("--%s item %d must be [name, type]", flagName, idx+1)
}
name, ok1 := pair[0].(string)
typeName, ok2 := pair[1].(string)
if !ok1 || !ok2 {
return nil, fmt.Errorf("--%s item %d must be [string, string]", flagName, idx+1)
return nil, baseValidationErrorf("--%s item %d must be [string, string]", flagName, idx+1)
}
result = append(result, namedTypeSpec{Name: name, Type: typeName})
}
@@ -1155,9 +1174,9 @@ func validateBlockDataConfig(blockType string, cfg map[string]interface{}) []str
return errs
}
func formatDataConfigErrors(errs []string) error {
if len(errs) == 0 {
func formatDataConfigErrors(problems []string) error {
if len(problems) == 0 {
return nil
}
return fmt.Errorf("data_config 校验失败:\n- %s\n参考: skills/lark-base/references/dashboard-block-data-config.md", strings.Join(errs, "\n- "))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "data_config 校验失败:\n- %s\n参考: skills/lark-base/references/dashboard-block-data-config.md", strings.Join(problems, "\n- "))
}

View File

@@ -8,6 +8,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -19,7 +20,7 @@ func validateRecordReadFormat(runtime *common.RuntimeContext) error {
case "", "json", "markdown":
return nil
default:
return output.ErrValidation("--format must be json or markdown")
return baseValidationErrorf("--format must be json or markdown")
}
}
@@ -33,7 +34,7 @@ func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[s
runtime.Out(data, nil)
return nil
}
return output.ErrValidation("--jq and --format markdown are mutually exclusive")
return baseValidationErrorf("--jq and --format markdown are mutually exclusive")
}
rendered, err := renderer(data)
if err != nil {
@@ -43,7 +44,7 @@ func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[s
}
scanResult := output.ScanForSafety(runtime.Cmd.CommandPath(), data, runtime.IO().ErrOut)
if scanResult.Blocked {
return scanResult.BlockErr
return baseContentSafetyBlockError(scanResult)
}
if scanResult.Alert != nil {
output.WriteAlertWarning(runtime.IO().ErrOut, scanResult.Alert)
@@ -52,6 +53,20 @@ func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[s
return nil
}
func baseContentSafetyBlockError(scanResult output.ScanResult) error {
message := "content safety violation detected"
var rules []string
if scanResult.Alert != nil {
rules = scanResult.Alert.MatchedRules
}
if len(rules) > 0 {
message = fmt.Sprintf("content safety violation detected (rules: %s)", strings.Join(rules, ", "))
}
return errs.NewContentSafetyError(errs.SubtypeUnknown, "%s", message).
WithRules(rules...).
WithCause(scanResult.BlockErr)
}
func outputRecordGetMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error {
return outputRecordMarkdownWithRenderer(runtime, data, renderRecordGetMarkdown)
}
@@ -61,7 +76,7 @@ func renderRecordGetMarkdown(data map[string]interface{}) (string, error) {
recordIDs := stringSliceValue(data["record_id_list"])
rows, ok := data["data"].([]interface{})
if len(fields) == 0 || !ok {
return "", output.ErrValidation("--format markdown requires record matrix response with fields, record_id_list, and data")
return "", baseValidationErrorf("--format markdown requires record matrix response with fields, record_id_list, and data")
}
if len(recordIDs) == 1 && len(rows) == 1 {
rowItems, _ := rows[0].([]interface{})
@@ -78,7 +93,7 @@ func renderRecordMarkdown(data map[string]interface{}) (string, error) {
recordIDs := stringSliceValue(data["record_id_list"])
rows, ok := data["data"].([]interface{})
if len(fields) == 0 || !ok {
return "", output.ErrValidation("--format markdown requires record matrix response with fields, record_id_list, and data")
return "", baseValidationErrorf("--format markdown requires record matrix response with fields, record_id_list, and data")
}
var b strings.Builder

View File

@@ -14,6 +14,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -212,9 +213,12 @@ func TestOutputRecordMarkdownContentSafetyBlockDoesNotWriteStdout(t *testing.T)
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Alice"}},
})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Code != output.ExitContentSafety {
t.Fatalf("err=%v, want content safety exit error", err)
var csErr *errs.ContentSafetyError
if !errors.As(err, &csErr) {
t.Fatalf("err=%v, want typed content safety error", err)
}
if len(csErr.Rules) != 1 || csErr.Rules[0] != "r1" {
t.Fatalf("rules=%v", csErr.Rules)
}
if stdout.Len() > 0 {
t.Fatalf("block mode should not write stdout, got:\n%s", stdout.String())

View File

@@ -49,7 +49,7 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
fieldIDs := runtime.StrArray("field-id")
jsonRaw := strings.TrimSpace(runtime.Str("json"))
if len(recordIDs) > 0 && jsonRaw != "" {
return recordSelection{}, common.FlagErrorf("--record-id and --json are mutually exclusive")
return recordSelection{}, baseFlagErrorf("--record-id and --json are mutually exclusive")
}
if jsonRaw != "" {
pc := newParseCtx(runtime)
@@ -59,11 +59,11 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
}
recordIDListValue, ok := body["record_id_list"]
if !ok {
return recordSelection{}, common.FlagErrorf(`--json must include "record_id_list" as a non-empty string array; %s`, jsonInputTip("json"))
return recordSelection{}, baseFlagErrorf(`--json must include "record_id_list" as a non-empty string array; %s`, jsonInputTip("json"))
}
recordIDItems, ok := recordIDListValue.([]interface{})
if !ok {
return recordSelection{}, common.FlagErrorf(`--json field "record_id_list" must be a string array; %s`, jsonInputTip("json"))
return recordSelection{}, baseFlagErrorf(`--json field "record_id_list" must be a string array; %s`, jsonInputTip("json"))
}
normalized, err := normalizeRecordIDs(recordIDItems)
if err != nil {
@@ -117,14 +117,14 @@ func resolveRecordGetSelectFields(flagFields []string, body map[string]interface
return fromFlags, nil
}
if len(fromFlags) > 0 {
return nil, common.FlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`)
return nil, baseFlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`)
}
items, ok := rawJSONFields.([]interface{})
if !ok {
return nil, common.FlagErrorf(`--json field "select_fields" must be a string array; %s`, jsonInputTip("json"))
return nil, baseFlagErrorf(`--json field "select_fields" must be a string array; %s`, jsonInputTip("json"))
}
if len(items) == 0 {
return nil, common.FlagErrorf(`--json field "select_fields" must not be empty; %s`, jsonInputTip("json"))
return nil, baseFlagErrorf(`--json field "select_fields" must not be empty; %s`, jsonInputTip("json"))
}
normalized, err := normalizeRecordGetSelectFields(items)
if err != nil {
@@ -152,7 +152,7 @@ func normalizeStringList(values interface{}, opts stringListNormalizeOptions) ([
if opts.allowNil {
return nil, nil
}
return nil, common.FlagErrorf(opts.typeError)
return nil, baseFlagErrorf(opts.typeError)
case []interface{}:
rawItems = typed
case []string:
@@ -161,30 +161,30 @@ func normalizeStringList(values interface{}, opts stringListNormalizeOptions) ([
rawItems = append(rawItems, item)
}
default:
return nil, common.FlagErrorf(opts.typeError)
return nil, baseFlagErrorf(opts.typeError)
}
if len(rawItems) == 0 {
if opts.allowEmpty {
return nil, nil
}
return nil, common.FlagErrorf(opts.emptyError)
return nil, baseFlagErrorf(opts.emptyError)
}
if opts.max > 0 && len(rawItems) > opts.max {
return nil, common.FlagErrorf("%s exceeds maximum limit of %d (got %d)", opts.limitName, opts.max, len(rawItems))
return nil, baseFlagErrorf("%s exceeds maximum limit of %d (got %d)", opts.limitName, opts.max, len(rawItems))
}
seen := make(map[string]int, len(rawItems))
result := make([]string, 0, len(rawItems))
for index, value := range rawItems {
item, ok := value.(string)
if !ok {
return nil, common.FlagErrorf("%s %d must be a string", opts.itemName, index+1)
return nil, baseFlagErrorf("%s %d must be a string", opts.itemName, index+1)
}
item = strings.TrimSpace(item)
if item == "" {
return nil, common.FlagErrorf("%s %d must not be empty", opts.itemName, index+1)
return nil, baseFlagErrorf("%s %d must not be empty", opts.itemName, index+1)
}
if first, exists := seen[item]; exists {
return nil, common.FlagErrorf("duplicate %s %q at positions %d and %d", opts.duplicateName, item, first, index+1)
return nil, baseFlagErrorf("duplicate %s %q at positions %d and %d", opts.duplicateName, item, first, index+1)
}
seen[item] = index + 1
result = append(result, item)
@@ -332,10 +332,10 @@ const maxShareBatchSize = 100
func validateRecordShareBatch(runtime *common.RuntimeContext) error {
recordIDs := deduplicateRecordIDs(runtime)
if len(recordIDs) == 0 {
return common.FlagErrorf("--record-ids is required and must not be empty")
return baseFlagErrorf("--record-ids is required and must not be empty")
}
if len(recordIDs) > maxShareBatchSize {
return common.FlagErrorf("--record-ids exceeds maximum limit of %d (got %d)", maxShareBatchSize, len(recordIDs))
return baseFlagErrorf("--record-ids exceeds maximum limit of %d (got %d)", maxShareBatchSize, len(recordIDs))
}
return nil
}

View File

@@ -71,18 +71,18 @@ func normalizeRecordSortValue(value interface{}, label string) ([]interface{}, e
} else if obj, ok := value.(map[string]interface{}); ok {
rawSortConfig, ok := obj["sort_config"]
if !ok {
return nil, common.FlagErrorf("%s must be a JSON array or an object with sort_config array", label)
return nil, baseFlagErrorf("%s must be a JSON array or an object with sort_config array", label)
}
parsed, ok := rawSortConfig.([]interface{})
if !ok {
return nil, common.FlagErrorf("%s.sort_config must be a JSON array", label)
return nil, baseFlagErrorf("%s.sort_config must be a JSON array", label)
}
sortConfig = parsed
} else {
return nil, common.FlagErrorf("%s must be a JSON array or an object with sort_config array", label)
return nil, baseFlagErrorf("%s must be a JSON array or an object with sort_config array", label)
}
if len(sortConfig) > recordSortMaxCount {
return nil, common.FlagErrorf("sort supports at most %d sort conditions; got %d", recordSortMaxCount, len(sortConfig))
return nil, baseFlagErrorf("sort supports at most %d sort conditions; got %d", recordSortMaxCount, len(sortConfig))
}
return sortConfig, nil
}
@@ -90,7 +90,7 @@ func normalizeRecordSortValue(value interface{}, label string) ([]interface{}, e
func marshalRecordQueryFlag(flagName string, value interface{}) (string, error) {
data, err := json.Marshal(value)
if err != nil {
return "", common.FlagErrorf("--%s cannot encode JSON: %v", flagName, err)
return "", baseFlagErrorf("--%s cannot encode JSON: %v", flagName, err)
}
return string(data), nil
}
@@ -220,16 +220,16 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
jsonRaw := strings.TrimSpace(runtime.Str("json"))
if jsonRaw != "" {
if recordSearchHasJSONExclusiveFlagInputs(runtime) {
return common.FlagErrorf("--json is mutually exclusive with keyword/search/projection/pagination flags; put those fields inside --json, or omit --json")
return baseFlagErrorf("--json is mutually exclusive with keyword/search/projection/pagination flags; put those fields inside --json, or omit --json")
}
_, err := recordSearchJSONBody(runtime)
return err
}
if strings.TrimSpace(runtime.Str("keyword")) == "" {
return common.FlagErrorf("--keyword is required unless --json is used")
return baseFlagErrorf("--keyword is required unless --json is used")
}
if len(runtime.StrArray("search-field")) == 0 {
return common.FlagErrorf("--search-field is required unless --json is used")
return baseFlagErrorf("--search-field is required unless --json is used")
}
return validateRecordQueryOptions(runtime)
}

View File

@@ -22,7 +22,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -225,7 +224,7 @@ func dryRunRecordRemoveAttachment(_ context.Context, runtime *common.RuntimeCont
func validateRecordUploadAttachment(runtime *common.RuntimeContext) error {
if runtime.Changed("name") {
return common.FlagErrorf("--name is no longer supported; uploaded attachment names are derived from local file basenames")
return baseFlagErrorf("--name is no longer supported; uploaded attachment names are derived from local file basenames")
}
files, err := normalizeAttachmentFiles(runtime.StrArray("file"))
if err != nil {
@@ -245,9 +244,16 @@ func validateRecordDownloadAttachment(runtime *common.RuntimeContext) error {
return err
}
if len(tokens) != 1 {
const outputDirRequired = "--output must be an existing directory when downloading multiple attachments or when --file-token is omitted"
info, statErr := runtime.FileIO().Stat(runtime.Str("output"))
if statErr != nil || !info.IsDir() {
return common.FlagErrorf("--output must be an existing directory when downloading multiple attachments or when --file-token is omitted")
if statErr != nil {
if errors.Is(statErr, fileio.ErrPathValidation) {
return baseValidationErrorf("unsafe output path: %s", statErr)
}
return baseFlagErrorf(outputDirRequired)
}
if !info.IsDir() {
return baseFlagErrorf(outputDirRequired)
}
}
return nil
@@ -269,7 +275,7 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
return err
}
if normalized := normalizeFieldTypeName(fieldTypeName(field)); normalized != "attachment" {
return output.ErrValidation("field %q is type %q, expected attachment", fieldName(field), normalized)
return baseValidationErrorf("field %q is type %q, expected attachment", fieldName(field), normalized)
}
resolvedFieldID := fieldID(field)
if resolvedFieldID == "" {
@@ -316,7 +322,7 @@ func executeRecordRemoveAttachment(runtime *common.RuntimeContext) error {
return err
}
if normalized := normalizeFieldTypeName(fieldTypeName(field)); normalized != "attachment" {
return output.ErrValidation("field %q is type %q, expected attachment", fieldName(field), normalized)
return baseValidationErrorf("field %q is type %q, expected attachment", fieldName(field), normalized)
}
resolvedFieldID := fieldID(field)
if resolvedFieldID == "" {
@@ -353,7 +359,7 @@ func executeRecordDownloadAttachment(ctx context.Context, runtime *common.Runtim
saved, err := downloadBaseAttachment(ctx, runtime, target.Item, target.TargetPath, runtime.Bool("overwrite"))
if err != nil {
failed := attachmentDownloadFailure(target, err)
return attachmentDownloadProgressError(err, downloaded, []map[string]interface{}{failed})
return attachmentDownloadProgressError(runtime, err, downloaded, []map[string]interface{}{failed})
}
downloaded = append(downloaded, saved)
}
@@ -364,20 +370,20 @@ func executeRecordDownloadAttachment(ctx context.Context, runtime *common.Runtim
func validateAttachmentInputFile(runtime *common.RuntimeContext, filePath string) (fileio.FileInfo, error) {
fio := runtime.FileIO()
if fio == nil {
return nil, output.ErrValidation("file operations require a FileIO provider")
return nil, baseValidationErrorf("file operations require a FileIO provider")
}
fileInfo, err := fio.Stat(filePath)
if err != nil {
if errors.Is(err, fileio.ErrPathValidation) {
return nil, output.ErrValidation("unsafe file path: %s", err)
return nil, baseValidationErrorf("unsafe file path: %s", err)
}
return nil, output.ErrValidation("file not accessible: %s: %v", filePath, err)
return nil, baseValidationErrorf("file not accessible: %s: %v", filePath, err)
}
if fileInfo.IsDir() {
return nil, output.ErrValidation("file path is a directory: %s", filePath)
return nil, baseValidationErrorf("file path is a directory: %s", filePath)
}
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
return nil, output.ErrValidation("file %s exceeds 2GB limit", common.FormatSize(fileInfo.Size()))
return nil, baseValidationErrorf("file %s exceeds 2GB limit (size: %s)", filePath, common.FormatSize(fileInfo.Size()))
}
return fileInfo, nil
}
@@ -412,13 +418,13 @@ func normalizeOptionalDownloadAttachmentFileTokens(tokens []string) ([]string, e
for index, token := range tokens {
token = strings.TrimSpace(token)
if token == "" {
return nil, common.FlagErrorf("attachment file token %d must not be empty", index+1)
return nil, baseFlagErrorf("attachment file token %d must not be empty", index+1)
}
normalized = append(normalized, token)
}
normalized = dedupeStringsPreserveOrder(normalized)
if len(normalized) > baseAttachmentMaxBatchSize {
return nil, common.FlagErrorf("attachment file token count exceeds maximum limit of %d (got %d)", baseAttachmentMaxBatchSize, len(normalized))
return nil, baseFlagErrorf("attachment file token count exceeds maximum limit of %d (got %d)", baseAttachmentMaxBatchSize, len(normalized))
}
return normalized, nil
}
@@ -453,10 +459,10 @@ func fetchBaseField(runtime *common.RuntimeContext, baseToken, tableIDValue, fie
func fetchBaseAttachments(runtime *common.RuntimeContext, baseToken, tableIDValue string, recordIDs []string) (map[string]interface{}, error) {
if len(recordIDs) == 0 {
return nil, output.ErrValidation("provide at least one record id")
return nil, baseValidationErrorf("provide at least one record id")
}
if len(recordIDs) > baseAttachmentGetMaxRecords {
return nil, output.ErrValidation("get attachments record selection exceeds maximum limit of %d (got %d)", baseAttachmentGetMaxRecords, len(recordIDs))
return nil, baseValidationErrorf("get attachments record selection exceeds maximum limit of %d (got %d)", baseAttachmentGetMaxRecords, len(recordIDs))
}
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableIDValue, "get_attachments"), nil, map[string]interface{}{
"record_id_list": recordIDs,
@@ -560,14 +566,14 @@ func detectAttachmentMIMEType(fio fileio.FileIO, filePath, fileName string) (str
f, err := fio.Open(filePath)
if err != nil {
return "", common.WrapInputStatError(err)
return "", baseInputStatError(err)
}
defer f.Close()
buf := make([]byte, 512)
n, readErr := f.Read(buf)
if readErr != nil && !errors.Is(readErr, io.EOF) {
return "", output.ErrValidation("cannot read file: %s", readErr)
return "", baseValidationErrorf("cannot read file: %s", readErr)
}
return detectAttachmentMIMEFromContent(buf[:n]), nil
}
@@ -617,11 +623,11 @@ type baseAttachmentDownloadTarget struct {
func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID string, tokens []string) ([]baseAttachmentDownloadItem, error) {
recordRaw, ok := attachments[recordID]
if !ok {
return nil, output.ErrValidation("record %q has no attachment metadata; verify the record-id", recordID)
return nil, baseValidationErrorf("record %q has no attachment metadata; verify the record-id", recordID)
}
fields, ok := recordRaw.(map[string]interface{})
if !ok {
return nil, output.ErrValidation("record %q attachment metadata has unexpected type %T", recordID, recordRaw)
return nil, baseValidationErrorf("record %q attachment metadata has unexpected type %T", recordID, recordRaw)
}
byToken := map[string]baseAttachmentDownloadItem{}
fieldIDs := make([]string, 0, len(fields))
@@ -633,12 +639,12 @@ func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID
rawList := fields[currentFieldID]
items, ok := rawList.([]interface{})
if !ok {
return nil, output.ErrValidation("record %q field %q attachment metadata has unexpected type %T", recordID, currentFieldID, rawList)
return nil, baseValidationErrorf("record %q field %q attachment metadata has unexpected type %T", recordID, currentFieldID, rawList)
}
for _, rawItem := range items {
item, ok := rawItem.(map[string]interface{})
if !ok {
return nil, output.ErrValidation("record %q field %q contains unexpected attachment item type %T", recordID, currentFieldID, rawItem)
return nil, baseValidationErrorf("record %q field %q contains unexpected attachment item type %T", recordID, currentFieldID, rawItem)
}
fileToken, _ := item["file_token"].(string)
if fileToken == "" {
@@ -668,7 +674,7 @@ func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID
result = append(result, item)
}
if len(result) == 0 {
return nil, output.ErrValidation("record %q has no attachments to download", recordID)
return nil, baseValidationErrorf("record %q has no attachments to download", recordID)
}
sort.SliceStable(result, func(i, j int) bool {
leftName := strings.ToLower(baseAttachmentDownloadName(result[i]))
@@ -683,7 +689,7 @@ func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID
for _, token := range tokens {
item, ok := byToken[token]
if !ok {
return nil, output.ErrValidation("attachment file_token %q not found in record %q; verify the record-id/file-token pair", token, recordID)
return nil, baseValidationErrorf("attachment file_token %q not found in record %q; verify the record-id/file-token pair", token, recordID)
}
result = append(result, item)
}
@@ -702,15 +708,15 @@ func planAttachmentDownloadTargets(runtime *common.RuntimeContext, items []baseA
}
resolved, err := runtime.ResolveSavePath(targetPath)
if err != nil {
return nil, output.ErrValidation("unsafe output path: %s", err)
return nil, baseValidationErrorf("unsafe output path: %s", err)
}
if previous, exists := seen[resolved]; exists {
return nil, output.ErrValidation("multiple attachments resolve to the same output path %q (%s and %s); download them separately or choose a different directory", resolved, previous.FileToken, item.FileToken)
return nil, baseValidationErrorf("multiple attachments resolve to the same output path %q (%s and %s); download them separately or choose a different directory", resolved, previous.FileToken, item.FileToken)
}
seen[resolved] = item
if !overwrite {
if _, statErr := runtime.FileIO().Stat(targetPath); statErr == nil {
return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", targetPath)
return nil, baseValidationErrorf("output file already exists: %s (use --overwrite to replace)", targetPath)
}
}
targets = append(targets, baseAttachmentDownloadTarget{
@@ -776,7 +782,7 @@ func safeAttachmentFileTokenSuffix(fileToken string) string {
func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext, item baseAttachmentDownloadItem, targetPath string, overwrite bool) (map[string]interface{}, error) {
if _, err := runtime.ResolveSavePath(targetPath); err != nil {
return nil, output.ErrValidation("unsafe output path: %s", err)
return nil, baseValidationErrorf("unsafe output path: %s", err)
}
query := larkcore.QueryParams{}
@@ -795,7 +801,7 @@ func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext,
if !overwrite {
if _, statErr := runtime.FileIO().Stat(targetPath); statErr == nil {
return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", targetPath)
return nil, baseValidationErrorf("output file already exists: %s (use --overwrite to replace)", targetPath)
}
}
result, err := runtime.FileIO().Save(targetPath, fileio.SaveOptions{
@@ -803,7 +809,7 @@ func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext,
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return nil, common.WrapSaveErrorByCategory(err, "io")
return nil, baseSaveError(err)
}
savedPath, _ := runtime.ResolveSavePath(targetPath)
if savedPath == "" {
@@ -822,7 +828,7 @@ func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext,
}
func attachmentDownloadFailure(target baseAttachmentDownloadTarget, err error) map[string]interface{} {
return map[string]interface{}{
failure := map[string]interface{}{
"record_id": target.Item.RecordID,
"field_id": target.Item.FieldID,
"file_token": target.Item.FileToken,
@@ -831,72 +837,45 @@ func attachmentDownloadFailure(target baseAttachmentDownloadTarget, err error) m
"resolved_path": target.ResolvedPath,
"error": err.Error(),
}
if p, ok := errs.ProblemOf(err); ok {
failure["type"] = string(p.Category)
failure["subtype"] = string(p.Subtype)
if p.Code != 0 {
failure["code"] = p.Code
}
if p.LogID != "" {
failure["log_id"] = p.LogID
}
}
return failure
}
func attachmentDownloadProgressError(err error, downloaded []map[string]interface{}, failed []map[string]interface{}) error {
func attachmentDownloadProgressError(runtime *common.RuntimeContext, err error, downloaded []map[string]interface{}, failed []map[string]interface{}) error {
msg := fmt.Sprintf("download failed after %d attachment(s) succeeded and %d failed: %v", len(downloaded), len(failed), err)
detail := map[string]interface{}{
payload := map[string]interface{}{
"message": msg,
"downloaded": downloaded,
"failed": failed,
}
const hint = "Some files may already have been saved. Inspect downloaded before retrying, or rerun with --overwrite if the failed target now exists."
payload["hint"] = hint
if p, ok := errs.ProblemOf(err); ok {
payload["type"] = string(p.Category)
payload["subtype"] = string(p.Subtype)
if p.Code != 0 {
payload["code"] = p.Code
}
}
if logID := baseAttachmentDownloadLogID(err); logID != "" {
detail["log_id"] = logID
}
const hint = "Some files may already have been saved. Inspect error.detail.downloaded before retrying, or rerun with --overwrite if the failed target now exists."
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
return &output.ExitError{
Code: exitErr.Code,
Detail: &output.ErrDetail{
Type: exitErr.Detail.Type,
Code: exitErr.Detail.Code,
Message: msg,
Hint: hint,
Detail: detail,
},
Err: err,
}
}
var netErr *errs.NetworkError
if errors.As(err, &netErr) {
return &output.ExitError{
Code: output.ExitNetwork,
Detail: &output.ErrDetail{
Type: "network",
Code: netErr.Code,
Message: msg,
Hint: hint,
Detail: detail,
},
Err: err,
}
}
return &output.ExitError{
Code: output.ExitInternal,
Detail: &output.ErrDetail{
Type: "io",
Message: msg,
Hint: hint,
Detail: detail,
},
Err: err,
payload["log_id"] = logID
}
return runtime.OutPartialFailure(payload, nil)
}
func baseAttachmentDownloadLogID(err error) string {
var netErr *errs.NetworkError
if errors.As(err, &netErr) {
if id := strings.TrimSpace(netErr.LogID); id != "" {
return id
}
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
if detail, ok := exitErr.Detail.Detail.(map[string]interface{}); ok {
if logID, _ := detail["log_id"].(string); logID != "" {
return strings.TrimSpace(logID)
}
if p, ok := errs.ProblemOf(err); ok {
if logID := strings.TrimSpace(p.LogID); logID != "" {
return logID
}
}
return ""

View File

@@ -5,7 +5,6 @@ package base
import (
"context"
"fmt"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -117,7 +116,7 @@ func executeTableCreate(runtime *common.RuntimeContext) error {
for idx, item := range fieldItems {
body, ok := item.(map[string]interface{})
if !ok {
return fmt.Errorf("--fields item %d must be an object", idx+1)
return baseValidationErrorf("--fields item %d must be an object", idx+1)
}
if idx == 0 && len(defaultFields) > 0 {
fieldData, err := baseV3Call(runtime, "PUT", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldID(defaultFields[0])), nil, body)

View File

@@ -31,7 +31,7 @@ var BaseWorkflowCreate = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
pc := newParseCtx(runtime)
raw, err := loadJSONInput(pc, runtime.Str("json"), "json")

View File

@@ -27,10 +27,10 @@ var BaseWorkflowDisable = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
if strings.TrimSpace(runtime.Str("workflow-id")) == "" {
return common.FlagErrorf("--workflow-id must not be blank")
return baseFlagErrorf("--workflow-id must not be blank")
}
return nil
},

View File

@@ -28,10 +28,10 @@ var BaseWorkflowEnable = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
if strings.TrimSpace(runtime.Str("workflow-id")) == "" {
return common.FlagErrorf("--workflow-id must not be blank")
return baseFlagErrorf("--workflow-id must not be blank")
}
return nil
},

View File

@@ -30,10 +30,10 @@ var BaseWorkflowGet = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
if strings.TrimSpace(runtime.Str("workflow-id")) == "" {
return common.FlagErrorf("--workflow-id must not be blank")
return baseFlagErrorf("--workflow-id must not be blank")
}
return nil
},

View File

@@ -28,7 +28,7 @@ var BaseWorkflowList = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
return nil
},

View File

@@ -33,10 +33,10 @@ var BaseWorkflowUpdate = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return common.FlagErrorf("--base-token must not be blank")
return baseFlagErrorf("--base-token must not be blank")
}
if strings.TrimSpace(runtime.Str("workflow-id")) == "" {
return common.FlagErrorf("--workflow-id must not be blank")
return baseFlagErrorf("--workflow-id must not be blank")
}
pc := newParseCtx(runtime)
if _, err := parseJSONObject(pc, runtime.Str("json"), "json"); err != nil {

View File

@@ -12,8 +12,8 @@ import (
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -29,7 +29,7 @@ const (
func fetchInstanceViewRange(ctx context.Context, runtime *common.RuntimeContext, calendarId string, startTime, endTime int64, depth int) ([]map[string]interface{}, error) {
if depth > 10 {
return nil, output.Errorf(output.ExitInternal, "recursion_limit", "too many splits for instance_view")
return nil, errs.NewInternalError(errs.SubtypeUnknown, "too many splits for instance_view")
}
if startTime > endTime {
return nil, nil
@@ -48,68 +48,67 @@ func fetchInstanceViewRange(ctx context.Context, runtime *common.RuntimeContext,
return append(left, right...), nil
}
result, err := runtime.RawAPI("GET",
data, err := runtime.CallAPITyped("GET",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/instance_view", validate.EncodePathSegment(calendarId)),
map[string]interface{}{
"start_time": fmt.Sprintf("%d", startTime),
"end_time": fmt.Sprintf("%d", endTime),
}, nil)
err = wrapPredefinedError(err)
if err != nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", err)
}
resultMap, _ := result.(map[string]interface{})
code, _ := util.ToFloat64(resultMap["code"])
if code == 0 {
data, _ := resultMap["data"].(map[string]interface{})
items, _ := data["items"].([]interface{})
var events []map[string]interface{}
for _, item := range items {
if m, ok := item.(map[string]interface{}); ok {
events = append(events, m)
// CallAPITyped returns a typed error for any non-zero API code. The two
// calendar instance_view limits (193103 time-range, 193104 too-many) are
// recoverable by narrowing the window, so inspect the typed code and
// recurse instead of treating them as fatal. Any other code falls through
// to return the typed error unchanged.
p, ok := errs.ProblemOf(err)
if !ok {
return nil, err
}
switch p.Code {
case larkErrCalendarTimeRangeExceeded:
mid := startTime + span/2
if mid <= startTime {
return nil, errs.NewAPIError(errs.SubtypeInvalidParameters,
"query failed: time range exceeds 40-day limit, please narrow the range").
WithCode(larkErrCalendarTimeRangeExceeded)
}
return fetchInstanceViewSplit(ctx, runtime, calendarId, startTime, mid, endTime, depth)
case larkErrCalendarTooManyInstances:
if span <= minSplitWindowSeconds {
return nil, errs.NewAPIError(errs.SubtypeInvalidParameters,
"query failed: more than 1000 instances in the time range, please narrow the range").
WithCode(larkErrCalendarTooManyInstances)
}
mid := startTime + span/2
return fetchInstanceViewSplit(ctx, runtime, calendarId, startTime, mid, endTime, depth)
default:
return nil, err
}
return events, nil
}
// Error 193103: time range exceeds limit -> split
if int(code) == larkErrCalendarTimeRangeExceeded {
mid := startTime + span/2
if mid <= startTime {
return nil, output.Errorf(output.ExitAPI, "api_error", "query failed: time range exceeds 40-day limit, please narrow the range")
items, _ := data["items"].([]interface{})
var events []map[string]interface{}
for _, item := range items {
if m, ok := item.(map[string]interface{}); ok {
events = append(events, m)
}
left, err := fetchInstanceViewRange(ctx, runtime, calendarId, startTime, mid, depth+1)
if err != nil {
return nil, err
}
right, err := fetchInstanceViewRange(ctx, runtime, calendarId, mid+1, endTime, depth+1)
if err != nil {
return nil, err
}
return append(left, right...), nil
}
return events, nil
}
// Error 193104: too many instances -> split
if int(code) == larkErrCalendarTooManyInstances {
if span <= minSplitWindowSeconds {
return nil, output.Errorf(output.ExitAPI, "api_error", "query failed: more than 1000 instances in the time range, please narrow the range")
}
mid := startTime + span/2
left, err := fetchInstanceViewRange(ctx, runtime, calendarId, startTime, mid, depth+1)
if err != nil {
return nil, err
}
right, err := fetchInstanceViewRange(ctx, runtime, calendarId, mid+1, endTime, depth+1)
if err != nil {
return nil, err
}
return append(left, right...), nil
// fetchInstanceViewSplit halves [startTime, endTime] at mid and concatenates the
// results of the two recursive sub-range queries. Shared by the 193103/193104
// split paths.
func fetchInstanceViewSplit(ctx context.Context, runtime *common.RuntimeContext, calendarId string, startTime, mid, endTime int64, depth int) ([]map[string]interface{}, error) {
left, err := fetchInstanceViewRange(ctx, runtime, calendarId, startTime, mid, depth+1)
if err != nil {
return nil, err
}
msg, _ := resultMap["msg"].(string)
return nil, output.ErrAPI(int(code), msg, resultMap["error"])
right, err := fetchInstanceViewRange(ctx, runtime, calendarId, mid+1, endTime, depth+1)
if err != nil {
return nil, err
}
return append(left, right...), nil
}
func dedupeAndSortItems(items []map[string]interface{}) []map[string]interface{} {
@@ -147,20 +146,20 @@ func parseTimeRange(runtime *common.RuntimeContext) (int64, int64, error) {
startTime, err := common.ParseTime(startInput)
if err != nil {
return 0, 0, output.ErrValidation("--start: %v", err)
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
}
endTime, err := common.ParseTime(endInput, "end")
if err != nil {
return 0, 0, output.ErrValidation("--end: %v", err)
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
startInt, err := strconv.ParseInt(startTime, 10, 64)
if err != nil {
return 0, 0, output.ErrValidation("invalid start time: %v", err)
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time: %v", err).WithParam("--start")
}
endInt, err := strconv.ParseInt(endTime, 10, 64)
if err != nil {
return 0, 0, output.ErrValidation("invalid end time: %v", err)
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time: %v", err).WithParam("--end")
}
return startInt, endInt, nil

View File

@@ -11,6 +11,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -60,7 +61,7 @@ func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]str
case strings.HasPrefix(id, "ou_"):
attendees = append(attendees, map[string]string{"type": "user", "user_id": id})
default:
return nil, fmt.Errorf("unsupported attendee id format: %s", id)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported attendee id format: %s", id)
}
}
return attendees, nil
@@ -89,8 +90,8 @@ var CalendarCreate = common.Shortcut{
}
for _, flag := range []string{"summary", "description", "rrule", "calendar-id"} {
if val := runtime.Str(flag); val != "" {
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
return output.ErrValidation(err.Error())
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
return err
}
}
}
@@ -102,35 +103,35 @@ var CalendarCreate = common.Shortcut{
continue
}
if !strings.HasPrefix(id, "ou_") && !strings.HasPrefix(id, "oc_") && !strings.HasPrefix(id, "omm_") {
return output.ErrValidation("invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id).WithParam("--attendee-ids")
}
}
}
if runtime.Str("start") == "" {
return common.FlagErrorf("specify --start (e.g. '2026-03-12T14:00+08:00')")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --start (e.g. '2026-03-12T14:00+08:00')").WithParam("--start")
}
if runtime.Str("end") == "" {
return common.FlagErrorf("specify --end (e.g. '2026-03-12T15:00+08:00')")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --end (e.g. '2026-03-12T15:00+08:00')").WithParam("--end")
}
startTs, err := common.ParseTime(runtime.Str("start"))
if err != nil {
return common.FlagErrorf("--start: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
}
endTs, err := common.ParseTime(runtime.Str("end"), "end")
if err != nil {
return common.FlagErrorf("--end: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
s, err := strconv.ParseInt(startTs, 10, 64)
if err != nil {
return common.FlagErrorf("invalid start time: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time: %v", err).WithParam("--start")
}
e, err := strconv.ParseInt(endTs, 10, 64)
if err != nil {
return common.FlagErrorf("invalid end time: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time: %v", err).WithParam("--end")
}
if e <= s {
return common.FlagErrorf("end time must be after start time")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "end time must be after start time")
}
return nil
},
@@ -183,27 +184,26 @@ var CalendarCreate = common.Shortcut{
startTs, err := common.ParseTime(runtime.Str("start"))
if err != nil {
return output.ErrValidation("--start: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
}
endTs, err := common.ParseTime(runtime.Str("end"), "end")
if err != nil {
return output.ErrValidation("--end: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
eventData := buildEventData(runtime, startTs, endTs)
// Create event
data, err := runtime.CallAPI("POST",
data, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events", validate.EncodePathSegment(calendarId)),
nil, eventData)
err = wrapPredefinedError(err)
if err != nil {
return err
}
event, _ := data["event"].(map[string]interface{})
eventId, _ := event["event_id"].(string)
if eventId == "" {
return output.Errorf(output.ExitAPI, "api_error", "failed to create event: no event_id returned")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to create event: no event_id returned")
}
// Add attendees if specified
@@ -214,27 +214,25 @@ var CalendarCreate = common.Shortcut{
}
attendees, err := parseAttendees(attendeesStr, currentUserId)
if err != nil {
return output.ErrValidation("invalid attendee id: %v", err)
return withParam(err, "--attendee-ids")
}
_, err = runtime.CallAPI("POST",
_, err = runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s/attendees", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
map[string]interface{}{"user_id_type": "open_id"},
map[string]interface{}{
"attendees": attendees,
"need_notification": true,
})
err = wrapPredefinedError(err)
if err != nil {
// Rollback: delete the event
_, rollbackErr := runtime.RawAPI("DELETE",
_, rollbackErr := runtime.CallAPITyped("DELETE",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
map[string]interface{}{"need_notification": false}, nil)
rollbackErr = wrapPredefinedError(rollbackErr)
if rollbackErr != nil {
return output.Errorf(output.ExitAPI, "api_error", "failed to add attendees: %v; rollback also failed, orphan event_id=%s needs manual cleanup", rollbackErr, eventId)
return withStepContext(err, "rollback also failed (%v); orphan event_id=%s needs manual cleanup", rollbackErr, eventId)
}
return output.Errorf(output.ExitAPI, "api_error", "failed to add attendees: %v; event rolled back successfully", err)
return withStepContext(err, "event rolled back successfully")
}
}

View File

@@ -10,6 +10,7 @@ import (
"strconv"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -20,20 +21,20 @@ func parseFreebusyTimeRange(runtime *common.RuntimeContext) (string, string, err
startTs, err := common.ParseTime(startInput)
if err != nil {
return "", "", output.ErrValidation("--start: %v", err)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
}
endTs, err := common.ParseTime(endInput, "end")
if err != nil {
return "", "", output.ErrValidation("--end: %v", err)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
startSec, err := strconv.ParseInt(startTs, 10, 64)
if err != nil {
return "", "", output.ErrValidation("invalid start timestamp: %v", err)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start timestamp: %v", err)
}
endSec, err := strconv.ParseInt(endTs, 10, 64)
if err != nil {
return "", "", output.ErrValidation("invalid end timestamp: %v", err)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end timestamp: %v", err)
}
timeMin := time.Unix(startSec, 0).Format(time.RFC3339)
@@ -73,13 +74,13 @@ var CalendarFreebusy = common.Shortcut{
}
userId := runtime.Str("user-id")
if userId == "" && runtime.IsBot() {
return common.FlagErrorf("--user-id is required for bot identity")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id is required for bot identity").WithParam("--user-id")
}
if userId == "" && runtime.UserOpenId() == "" {
return common.FlagErrorf("cannot determine user ID, specify --user-id or ensure you are logged in")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot determine user ID, specify --user-id or ensure you are logged in").WithParam("--user-id")
}
if userId != "" {
if _, err := common.ValidateUserID(userId); err != nil {
if _, err := common.ValidateUserIDTyped("--user-id", userId); err != nil {
return err
}
}
@@ -93,16 +94,17 @@ var CalendarFreebusy = common.Shortcut{
timeMin, timeMax, err := parseFreebusyTimeRange(runtime)
if err != nil {
return output.ErrValidation("--start/--end: %v", err)
// parseFreebusyTimeRange already returns a typed *errs.ValidationError
// carrying the offending flag in .Param; pass it through unchanged.
return err
}
data, err := runtime.CallAPI("POST", "/open-apis/calendar/v4/freebusy/list", nil, map[string]interface{}{
data, err := runtime.CallAPITyped("POST", "/open-apis/calendar/v4/freebusy/list", nil, map[string]interface{}{
"time_min": timeMin,
"time_max": timeMax,
"user_id": userId,
"need_rsvp_status": true,
})
err = wrapPredefinedError(err)
if err != nil {
return err
}

View File

@@ -8,13 +8,13 @@ import (
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -126,40 +126,40 @@ func collectRoomFindResults(slots []roomFindSlot, limit int, fetch func(roomFind
func parseRoomFindSlots(runtime *common.RuntimeContext) ([]roomFindSlot, error) {
rawSlots := runtime.StrArray(flagSlot)
if len(rawSlots) == 0 {
return nil, output.ErrValidation("specify at least one --slot")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one --slot").WithParam("--slot")
}
slots := make([]roomFindSlot, 0, len(rawSlots))
for _, raw := range rawSlots {
parts := strings.Split(strings.TrimSpace(raw), "~")
if len(parts) != 2 {
return nil, output.ErrValidation("invalid --slot format %q, expected start~end", raw)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --slot format %q, expected start~end", raw).WithParam("--slot")
}
startTs, err := common.ParseTime(parts[0])
if err != nil {
return nil, output.ErrValidation("invalid slot start time %q: %v", parts[0], err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot start time %q: %v", parts[0], err).WithParam("--slot")
}
endTs, err := common.ParseTime(parts[1])
if err != nil {
return nil, output.ErrValidation("invalid slot end time %q: %v", parts[1], err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot end time %q: %v", parts[1], err).WithParam("--slot")
}
startSec, err := strconv.ParseInt(startTs, 10, 64)
if err != nil {
return nil, output.ErrValidation("invalid slot start timestamp %q: %v", startTs, err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot start timestamp %q: %v", startTs, err).WithParam("--slot")
}
endSec, err := strconv.ParseInt(endTs, 10, 64)
if err != nil {
return nil, output.ErrValidation("invalid slot end timestamp %q: %v", endTs, err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot end timestamp %q: %v", endTs, err).WithParam("--slot")
}
if endSec <= startSec {
return nil, output.ErrValidation("--slot end time must be after start time: %q", raw)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--slot end time must be after start time: %q", raw).WithParam("--slot")
}
startRFC3339, err := unixStringToRFC3339(startTs)
if err != nil {
return nil, output.ErrValidation("invalid slot start timestamp %q: %v", startTs, err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot start timestamp %q: %v", startTs, err).WithParam("--slot")
}
endRFC3339, err := unixStringToRFC3339(endTs)
if err != nil {
return nil, output.ErrValidation("invalid slot end timestamp %q: %v", endTs, err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot end timestamp %q: %v", endTs, err).WithParam("--slot")
}
slots = append(slots, roomFindSlot{Start: startRFC3339, End: endRFC3339})
}
@@ -196,7 +196,7 @@ func parseRoomFindAttendees(attendeesStr string, currentUserID string) ([]string
seenChats[id] = true
}
default:
return nil, nil, output.ErrValidation("invalid attendee id format %q: should start with 'ou_' or 'oc_'", id)
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid attendee id format %q: should start with 'ou_' or 'oc_'", id).WithParam("--" + flagAttendees)
}
}
if currentUserID != "" && !seenUsers[currentUserID] {
@@ -249,20 +249,19 @@ func callRoomFind(runtime *common.RuntimeContext, req *roomFindRequest) ([]*room
Body: req,
})
if err != nil {
return nil, err
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.WrapInternal(err)
}
if apiResp.StatusCode < http.StatusOK || apiResp.StatusCode >= http.StatusMultipleChoices {
return nil, output.ErrAPI(apiResp.StatusCode, "", string(apiResp.RawBody))
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
return nil, err
}
var resp = &OpenAPIResponse[*roomFindData]{}
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
return nil, output.ErrWithHint(output.ExitInternal, "validation", "unmarshal response fail", err.Error())
}
if resp.Code != 0 {
return nil, output.ErrAPI(resp.Code, resp.Msg, resp.Data)
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "unmarshal response fail").WithCause(err)
}
if resp.Data != nil {
@@ -317,8 +316,8 @@ var CalendarRoomFind = common.Shortcut{
}
for _, flag := range []string{flagCity, flagBuilding, flagFloor, flagEventRrule, flagTimezone} {
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
return output.ErrValidation(err.Error())
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
return err
}
}
}
@@ -327,8 +326,8 @@ var CalendarRoomFind = common.Shortcut{
if name == "" {
continue
}
if err := common.RejectDangerousChars("--"+flagRoomName, name); err != nil {
return output.ErrValidation(err.Error())
if err := common.RejectDangerousCharsTyped("--"+flagRoomName, name); err != nil {
return err
}
}
if _, err := parseRoomFindSlots(runtime); err != nil {
@@ -338,13 +337,13 @@ var CalendarRoomFind = common.Shortcut{
return err
}
if minCapacity := runtime.Int(flagMinCapacity); minCapacity < 0 {
return output.ErrValidation("--min-capacity must be >= 0")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--min-capacity must be >= 0").WithParam("--min-capacity")
}
if maxCapacity := runtime.Int(flagMaxCapacity); maxCapacity < 0 {
return output.ErrValidation("--max-capacity must be >= 0")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--max-capacity must be >= 0").WithParam("--max-capacity")
}
if minCapacity, maxCapacity := runtime.Int(flagMinCapacity), runtime.Int(flagMaxCapacity); minCapacity > 0 && maxCapacity > 0 && minCapacity > maxCapacity {
return output.ErrValidation("--min-capacity must be <= --max-capacity")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--min-capacity must be <= --max-capacity").WithParam("--min-capacity")
}
return nil
},

View File

@@ -8,7 +8,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -51,15 +51,15 @@ var CalendarRsvp = common.Shortcut{
}
for _, flag := range []string{"calendar-id", "event-id", "rsvp-status"} {
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
return output.ErrValidation(err.Error())
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
return err
}
}
}
eventId := strings.TrimSpace(runtime.Str("event-id"))
if eventId == "" {
return output.ErrValidation("event-id cannot be empty")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "event-id cannot be empty").WithParam("--event-id")
}
return nil
},
@@ -71,7 +71,7 @@ var CalendarRsvp = common.Shortcut{
eventId := strings.TrimSpace(runtime.Str("event-id"))
status := strings.TrimSpace(runtime.Str("rsvp-status"))
_, err := runtime.DoAPIJSON("POST",
_, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s/reply",
validate.EncodePathSegment(calendarId),
validate.EncodePathSegment(eventId)),

View File

@@ -8,13 +8,13 @@ import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -70,11 +70,11 @@ func buildSuggestionRequest(runtime *common.RuntimeContext) (*SuggestionRequest,
timeMin, err := common.ParseTime(startInput)
if err != nil {
return nil, output.ErrValidation("invalid --start: %v", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --start: %v", err).WithParam("--start")
}
minSec, err := strconv.ParseInt(timeMin, 10, 64)
if err != nil {
return nil, output.ErrValidation("invalid start timestamp: %v", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start timestamp: %v", err)
}
startTime := time.Unix(minSec, 0)
@@ -87,12 +87,12 @@ func buildSuggestionRequest(runtime *common.RuntimeContext) (*SuggestionRequest,
timeMax, err := common.ParseTime(endInput, "end")
if err != nil {
return nil, output.ErrValidation("invalid --end: %v", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --end: %v", err).WithParam("--end")
}
// Convert Unix timestamp string back to RFC3339 since the API requires RFC3339
maxSec, err := strconv.ParseInt(timeMax, 10, 64)
if err != nil {
return nil, output.ErrValidation("invalid end timestamp: %v", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end timestamp: %v", err)
}
req.SearchStartTime = startTime.Format(time.RFC3339)
req.SearchEndTime = time.Unix(maxSec, 0).Format(time.RFC3339)
@@ -157,23 +157,23 @@ func buildSuggestionRequest(runtime *common.RuntimeContext) (*SuggestionRequest,
}
parts := strings.Split(r, "~")
if len(parts) != 2 {
return nil, output.ErrValidation("invalid --exclude format %q, expected 'start~end'", r)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --exclude format %q, expected 'start~end'", r).WithParam("--exclude")
}
startTsStr, err := common.ParseTime(parts[0])
if err != nil {
return nil, output.ErrValidation("invalid start time in --exclude: %q (%v)", parts[0], err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time in --exclude: %q (%v)", parts[0], err).WithParam("--exclude")
}
endTsStr, err := common.ParseTime(parts[1], "end")
if err != nil {
return nil, output.ErrValidation("invalid end time in --exclude: %q (%v)", parts[1], err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time in --exclude: %q (%v)", parts[1], err).WithParam("--exclude")
}
startSec, err := strconv.ParseInt(startTsStr, 10, 64)
if err != nil {
return nil, output.ErrValidation("invalid start timestamp in --exclude: %v", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start timestamp in --exclude: %v", err).WithParam("--exclude")
}
endSec, err := strconv.ParseInt(endTsStr, 10, 64)
if err != nil {
return nil, output.ErrValidation("invalid end timestamp in --exclude: %v", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end timestamp in --exclude: %v", err).WithParam("--exclude")
}
excludedTimes = append(excludedTimes, &EventTime{
EventStartTime: time.Unix(startSec, 0).Format(time.RFC3339),
@@ -219,13 +219,13 @@ var CalendarSuggestion = common.Shortcut{
}
durationMinutes := runtime.Int(flagDurationMinutes)
if durationMinutes != 0 && (durationMinutes < 1 || durationMinutes > 1440) {
return output.ErrValidation("--duration-minutes must be between 1 and 1440")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--duration-minutes must be between 1 and 1440").WithParam("--duration-minutes")
}
for _, flag := range []string{flagEventRrule, flagTimezone} {
if val := runtime.Str(flag); val != "" {
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
return output.ErrValidation(err.Error())
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
return err
}
}
}
@@ -237,7 +237,7 @@ var CalendarSuggestion = common.Shortcut{
continue
}
if !strings.HasPrefix(id, "ou_") && !strings.HasPrefix(id, "oc_") {
return output.ErrValidation("invalid attendee id format %q: should start with 'ou_' or 'oc_'", id)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid attendee id format %q: should start with 'ou_' or 'oc_'", id).WithParam("--" + flagAttendees)
}
}
}
@@ -245,14 +245,14 @@ var CalendarSuggestion = common.Shortcut{
startInput := runtime.Str(flagStart)
if startInput != "" {
if _, err := common.ParseTime(startInput); err != nil {
return output.ErrValidation("invalid start time: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time: %v", err).WithParam("--start")
}
}
endInput := runtime.Str(flagEnd)
if endInput != "" {
if _, err := common.ParseTime(endInput, "end"); err != nil {
return output.ErrValidation("invalid end time: %v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time: %v", err).WithParam("--end")
}
}
@@ -267,13 +267,13 @@ var CalendarSuggestion = common.Shortcut{
}
parts := strings.Split(r, "~")
if len(parts) != 2 {
return output.ErrValidation("invalid range format in --exclude: %q, expect start~end", r)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid range format in --exclude: %q, expect start~end", r).WithParam("--exclude")
}
if _, err := common.ParseTime(parts[0]); err != nil {
return output.ErrValidation("invalid start time in --exclude: %q (%v)", parts[0], err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time in --exclude: %q (%v)", parts[0], err).WithParam("--exclude")
}
if _, err := common.ParseTime(parts[1], "end"); err != nil {
return output.ErrValidation("invalid end time in --exclude: %q (%v)", parts[1], err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time in --exclude: %q (%v)", parts[1], err).WithParam("--exclude")
}
}
}
@@ -292,20 +292,19 @@ var CalendarSuggestion = common.Shortcut{
Body: req,
})
if err != nil {
return err
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.WrapInternal(err)
}
if apiResp.StatusCode < http.StatusOK || apiResp.StatusCode >= http.StatusMultipleChoices {
return output.ErrAPI(apiResp.StatusCode, "", string(apiResp.RawBody))
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
return err
}
var resp = &OpenAPIResponse[*SuggestionResponse]{}
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
return output.ErrWithHint(output.ExitInternal, "validation", "unmarshal response fail", err.Error())
}
if resp.Code != 0 {
return output.ErrAPI(resp.Code, resp.Msg, resp.Data)
return errs.NewInternalError(errs.SubtypeInvalidResponse, "unmarshal response fail").WithCause(err)
}
data := resp.Data

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -53,14 +54,14 @@ func validateCalendarUpdate(runtime *common.RuntimeContext) error {
}
for _, flag := range []string{"event-id", "summary", "description", "rrule", "calendar-id", "start", "end", "add-attendee-ids", "remove-attendee-ids"} {
if val := runtime.Str(flag); val != "" {
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
return output.ErrValidation(err.Error())
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
return err
}
}
}
if strings.TrimSpace(runtime.Str("event-id")) == "" {
return common.FlagErrorf("specify --event-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --event-id").WithParam("--event-id")
}
if _, _, err := buildCalendarUpdateEventData(runtime); err != nil {
return err
@@ -69,7 +70,7 @@ func validateCalendarUpdate(runtime *common.RuntimeContext) error {
return err
}
if !hasCalendarUpdateOperation(runtime) {
return common.FlagErrorf("nothing to update: specify at least one of --summary, --description, --start/--end, --rrule, --add-attendee-ids, or --remove-attendee-ids")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "nothing to update: specify at least one of --summary, --description, --start/--end, --rrule, --add-attendee-ids, or --remove-attendee-ids")
}
return nil
}
@@ -77,11 +78,11 @@ func validateCalendarUpdate(runtime *common.RuntimeContext) error {
func validateCalendarUpdateAttendees(runtime *common.RuntimeContext) error {
addIDs, err := parseCalendarAttendeeIDs(runtime.Str("add-attendee-ids"))
if err != nil {
return err
return withParam(err, "--add-attendee-ids")
}
removeIDs, err := parseCalendarAttendeeIDs(runtime.Str("remove-attendee-ids"))
if err != nil {
return err
return withParam(err, "--remove-attendee-ids")
}
removeSet := make(map[string]struct{}, len(removeIDs))
for _, id := range removeIDs {
@@ -89,7 +90,7 @@ func validateCalendarUpdateAttendees(runtime *common.RuntimeContext) error {
}
for _, id := range addIDs {
if _, ok := removeSet[id]; ok {
return output.ErrValidation("attendee id %q appears in both --add-attendee-ids and --remove-attendee-ids", id)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "attendee id %q appears in both --add-attendee-ids and --remove-attendee-ids", id)
}
}
return nil
@@ -124,27 +125,27 @@ func buildCalendarUpdateEventData(runtime *common.RuntimeContext) (map[string]in
startChanged := runtime.Cmd.Flags().Changed("start")
endChanged := runtime.Cmd.Flags().Changed("end")
if startChanged != endChanged {
return nil, false, common.FlagErrorf("--start and --end must be specified together when updating event time")
return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start and --end must be specified together when updating event time")
}
if startChanged {
startTs, err := common.ParseTime(runtime.Str("start"))
if err != nil {
return nil, false, common.FlagErrorf("--start: %v", err)
return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
}
endTs, err := common.ParseTime(runtime.Str("end"), "end")
if err != nil {
return nil, false, common.FlagErrorf("--end: %v", err)
return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
s, err := strconv.ParseInt(startTs, 10, 64)
if err != nil {
return nil, false, common.FlagErrorf("invalid start time: %v", err)
return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time: %v", err).WithParam("--start")
}
e, err := strconv.ParseInt(endTs, 10, 64)
if err != nil {
return nil, false, common.FlagErrorf("invalid end time: %v", err)
return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time: %v", err).WithParam("--end")
}
if e <= s {
return nil, false, common.FlagErrorf("end time must be after start time")
return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, "end time must be after start time")
}
body["start_time"] = map[string]string{"timestamp": startTs}
body["end_time"] = map[string]string{"timestamp": endTs}
@@ -169,7 +170,7 @@ func parseCalendarAttendeeIDs(attendeesStr string) ([]string, error) {
continue
}
if !strings.HasPrefix(id, "ou_") && !strings.HasPrefix(id, "oc_") && !strings.HasPrefix(id, "omm_") {
return nil, output.ErrValidation("invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id)
}
if _, ok := seen[id]; ok {
continue
@@ -195,7 +196,7 @@ func attendeeDeleteIDs(attendeesStr string) ([]map[string]string, error) {
case strings.HasPrefix(id, "ou_"):
deleteIDs = append(deleteIDs, map[string]string{"type": "user", "user_id": id})
default:
return nil, output.ErrValidation("invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id).WithParam("--remove-attendee-ids")
}
}
return deleteIDs, nil
@@ -280,7 +281,7 @@ func dryRunCalendarUpdate(runtime *common.RuntimeContext) *common.DryRunAPI {
func executeCalendarUpdate(_ context.Context, runtime *common.RuntimeContext) error {
calendarID, eventID := calendarUpdateIDs(runtime)
if eventID == "" {
return output.ErrValidation("specify --event-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --event-id").WithParam("--event-id")
}
body, hasEventFields, err := buildCalendarUpdateEventData(runtime)
@@ -291,10 +292,9 @@ func executeCalendarUpdate(_ context.Context, runtime *common.RuntimeContext) er
completed := []string{}
event := map[string]interface{}{}
if hasEventFields {
data, err := runtime.CallAPI("PATCH", calendarUpdateEventPath(calendarID, eventID), map[string]interface{}{"user_id_type": "open_id"}, body)
err = wrapPredefinedError(err)
data, err := runtime.CallAPITyped("PATCH", calendarUpdateEventPath(calendarID, eventID), map[string]interface{}{"user_id_type": "open_id"}, body)
if err != nil {
return output.Errorf(output.ExitAPI, "api_error", "failed to update event %s: %v", eventID, err)
return withStepContext(err, "failed to update event %s after completed steps %v", eventID, completed)
}
if v, _ := data["event"].(map[string]interface{}); v != nil {
event = v
@@ -308,12 +308,11 @@ func executeCalendarUpdate(_ context.Context, runtime *common.RuntimeContext) er
if err != nil {
return err
}
_, err = runtime.CallAPI("POST", calendarUpdateAttendeesPath(calendarID, eventID)+"/batch_delete",
_, err = runtime.CallAPITyped("POST", calendarUpdateAttendeesPath(calendarID, eventID)+"/batch_delete",
map[string]interface{}{"user_id_type": "open_id"},
map[string]interface{}{"delete_ids": deleteIDs, "need_notification": runtime.Bool("notify")})
err = wrapPredefinedError(err)
if err != nil {
return output.Errorf(output.ExitAPI, "api_error", "failed to remove attendees from event %s after completed steps %v: %v", eventID, completed, err)
return withStepContext(err, "failed to remove attendees from event %s after completed steps %v", eventID, completed)
}
removedCount = len(deleteIDs)
completed = append(completed, "remove_attendees")
@@ -323,14 +322,13 @@ func executeCalendarUpdate(_ context.Context, runtime *common.RuntimeContext) er
if addStr := runtime.Str("add-attendee-ids"); strings.TrimSpace(addStr) != "" {
attendees, err := parseAttendees(addStr, "")
if err != nil {
return output.ErrValidation("invalid attendee id: %v", err)
return withParam(err, "--add-attendee-ids")
}
_, err = runtime.CallAPI("POST", calendarUpdateAttendeesPath(calendarID, eventID),
_, err = runtime.CallAPITyped("POST", calendarUpdateAttendeesPath(calendarID, eventID),
map[string]interface{}{"user_id_type": "open_id"},
map[string]interface{}{"attendees": attendees, "need_notification": runtime.Bool("notify")})
err = wrapPredefinedError(err)
if err != nil {
return output.Errorf(output.ExitAPI, "api_error", "failed to add attendees to event %s after completed steps %v: %v", eventID, completed, err)
return withStepContext(err, "failed to add attendees to event %s after completed steps %v", eventID, completed)
}
addedCount = len(attendees)
}

View File

@@ -6,68 +6,39 @@ package calendar
import (
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
)
const (
errCodeInvalidParamsWithDetail = 190014
)
// getErrorDetailValue extracts the first detail value from the output.ErrDetail.
// It assumes Detail is a map containing a "details" array of objects with "value" string fields.
// For example: {"details": [{"value": "error message 1"}, {"value": "error message 2"}]}
// Returns an empty string if the structure doesn't match or the array is empty.
//
// Deprecated: getErrorDetailValue reads from the legacy *output.ErrDetail
// that predates the typed error contract introduced by errs/. New code MUST
// NOT use it — typed errs.* errors expose Message, Hint, and extension
// fields directly on the typed struct via errors.As / errs.ProblemOf. This
// helper is retained only while existing call sites are migrated; it will
// be removed once they have moved to the typed surface.
func getErrorDetailValue(e *output.ErrDetail) string {
if e == nil || e.Detail == nil {
return ""
}
errMap, ok := e.Detail.(map[string]interface{})
if !ok {
return ""
}
details, ok := errMap["details"].([]interface{})
if !ok || len(details) == 0 {
return ""
}
detailObj, ok := details[0].(map[string]interface{})
if !ok {
return ""
}
val, _ := detailObj["value"].(string)
return val
}
// wrapPredefinedError wraps an error into *output.ExitError if it matches predefined error codes.
// Currently handles error code 190014 (invalid params with detail), extracting the detail value into the message.
// If the error is nil or doesn't match predefined codes, returns the original error.
func wrapPredefinedError(err error) error {
// withStepContext annotates err with multi-step context (e.g. which steps
// already completed, or that a rollback ran) while preserving the underlying
// failure's classification. An already-typed error keeps its own
// category/subtype/code/log_id; we only append the formatted context to its
// Hint so the top-level envelope still tells the truth about what failed.
// Only an unclassified error falls back to a typed internal wrap.
func withStepContext(err error, format string, args ...any) error {
if err == nil {
return nil
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
extra := fmt.Sprintf(format, args...)
if p, ok := errs.ProblemOf(err); ok {
if strings.TrimSpace(p.Hint) != "" {
p.Hint = p.Hint + "\n" + extra
} else {
p.Hint = extra
}
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "%s", err.Error()).WithHint(extra).WithCause(err)
}
if exitErr.Detail.Code == errCodeInvalidParamsWithDetail {
if val := getErrorDetailValue(exitErr.Detail); val != "" {
fullMsg := fmt.Sprintf("%s: %s", exitErr.Detail.Message, val)
return output.ErrAPI(exitErr.Detail.Code, fullMsg, exitErr.Detail.Detail)
}
// withParam attaches the offending flag to a typed validation error, preserving
// the original error instead of re-wrapping it. Non-validation errors pass through.
func withParam(err error, flag string) error {
var ve *errs.ValidationError
if errors.As(err, &ve) {
return ve.WithParam(flag)
}
return err
}

View File

@@ -0,0 +1,242 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// newAttendeeValidateRuntime builds a RuntimeContext with the add/remove
// attendee-id flags set, for exercising validateCalendarUpdateAttendees.
func newAttendeeValidateRuntime(t *testing.T, add, remove string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("add-attendee-ids", "", "")
cmd.Flags().String("remove-attendee-ids", "", "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags: %v", err)
}
if add != "" {
_ = cmd.Flags().Set("add-attendee-ids", add)
}
if remove != "" {
_ = cmd.Flags().Set("remove-attendee-ids", remove)
}
return &common.RuntimeContext{Cmd: cmd}
}
// assertValidationParam asserts err is a *errs.ValidationError whose Param
// equals wantParam, and returns it for any further message assertions.
func assertValidationParam(t *testing.T, err error, wantParam string) *errs.ValidationError {
t.Helper()
if err == nil {
t.Fatalf("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err)
}
if ve.Param != wantParam {
t.Errorf("Param = %q, want %q", ve.Param, wantParam)
}
return ve
}
// ---------------------------------------------------------------------------
// withStepContext helper
// ---------------------------------------------------------------------------
func TestWithStepContext_Nil(t *testing.T) {
if got := withStepContext(nil, "step %d", 1); got != nil {
t.Fatalf("withStepContext(nil) = %v, want nil", got)
}
}
func TestWithStepContext_AppendsToTypedHint(t *testing.T) {
// A typed error keeps its classification; the context is appended to Hint.
inner := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithHint("first")
got := withStepContext(inner, "after steps %v", []string{"event"})
var ae *errs.APIError
if !errors.As(got, &ae) {
t.Fatalf("want *errs.APIError, got %T", got)
}
if ae.Hint == "" || !strings.Contains(ae.Hint, "first") || !strings.Contains(ae.Hint, "after steps") {
t.Errorf("hint should append context, got %q", ae.Hint)
}
}
func TestWithStepContext_SetsHintWhenEmpty(t *testing.T) {
inner := errs.NewAPIError(errs.SubtypeUnknown, "boom")
got := withStepContext(inner, "after steps %v", []string{"event"})
var ae *errs.APIError
if !errors.As(got, &ae) {
t.Fatalf("want *errs.APIError, got %T", got)
}
if !strings.Contains(ae.Hint, "after steps") {
t.Errorf("hint should be set, got %q", ae.Hint)
}
}
func TestWithStepContext_UnclassifiedFallsBackToInternal(t *testing.T) {
// A plain, unclassified error is wrapped into a typed internal error so the
// envelope still tells the truth.
got := withStepContext(errors.New("raw failure"), "after steps %v", []string{"event"})
var ie *errs.InternalError
if !errors.As(got, &ie) {
t.Fatalf("want *errs.InternalError, got %T", got)
}
if ie.Subtype != errs.SubtypeSDKError {
t.Errorf("subtype=%q, want sdk_error", ie.Subtype)
}
if !strings.Contains(ie.Message, "raw failure") {
t.Errorf("message should preserve original, got %q", ie.Message)
}
}
// ---------------------------------------------------------------------------
// withParam helper
// ---------------------------------------------------------------------------
func TestWithParam_AttachesToValidationError(t *testing.T) {
inner := errs.NewValidationError(errs.SubtypeInvalidArgument, "boom")
got := withParam(inner, "--attendee-ids")
ve := assertValidationParam(t, got, "--attendee-ids")
if ve != inner {
t.Errorf("withParam should return the same underlying error, got a different pointer")
}
if ve.Message != "boom" {
t.Errorf("message mutated: got %q, want %q", ve.Message, "boom")
}
}
func TestWithParam_NonValidationPassesThrough(t *testing.T) {
inner := errs.NewInternalError(errs.SubtypeSDKError, "io failure")
got := withParam(inner, "--attendee-ids")
if got != inner {
t.Fatalf("non-validation error should pass through unchanged, got %v", got)
}
var ve *errs.ValidationError
if errors.As(got, &ve) {
t.Fatalf("non-validation error must not become a ValidationError")
}
}
func TestWithParam_NilPassesThrough(t *testing.T) {
if got := withParam(nil, "--attendee-ids"); got != nil {
t.Fatalf("withParam(nil) = %v, want nil", got)
}
}
// ---------------------------------------------------------------------------
// Part A — re-wrap sites: the parseAttendees error, attributed by the caller's
// flag, must be the inner typed error (not a re-wrapped nesting).
// ---------------------------------------------------------------------------
func TestParseAttendees_AttributedToCreateFlag(t *testing.T) {
_, err := parseAttendees("bad-id", "")
// create's add path: withParam(err, "--attendee-ids")
got := withParam(err, "--attendee-ids")
assertValidationParam(t, got, "--attendee-ids")
}
func TestParseAttendees_AttributedToAddFlag(t *testing.T) {
_, err := parseAttendees("bad-id", "")
// update's add path: withParam(err, "--add-attendee-ids")
got := withParam(err, "--add-attendee-ids")
assertValidationParam(t, got, "--add-attendee-ids")
}
func TestParseAttendees_InnerStaysFlagAgnostic(t *testing.T) {
// The shared inner parser must not pre-attribute a flag; callers do.
_, err := parseAttendees("bad-id", "")
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if ve.Param != "" {
t.Errorf("inner parseAttendees should stay flag-agnostic, got Param = %q", ve.Param)
}
}
// ---------------------------------------------------------------------------
// Part B — direct attendee-id format validations carry their flag.
// ---------------------------------------------------------------------------
func TestParseRoomFindAttendees_FormatErrorParam(t *testing.T) {
_, _, err := parseRoomFindAttendees("bad-id", "")
assertValidationParam(t, err, "--"+flagAttendees)
}
func TestParseRoomFindAttendees_RejectsRoomID(t *testing.T) {
// room find only supports ou_/oc_; omm_ rooms are not valid attendees.
_, _, err := parseRoomFindAttendees("omm_room", "")
assertValidationParam(t, err, "--"+flagAttendees)
}
func TestParseCalendarAttendeeIDs_StaysFlagAgnostic(t *testing.T) {
// parseCalendarAttendeeIDs serves BOTH --add-attendee-ids and
// --remove-attendee-ids, so it must not pre-attribute a flag.
_, err := parseCalendarAttendeeIDs("bad-id")
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if ve.Param != "" {
t.Errorf("shared parser should stay flag-agnostic, got Param = %q", ve.Param)
}
}
func TestValidateCalendarUpdateAttendees_RemoveFormatParam(t *testing.T) {
// The remove path attributes its parser error to --remove-attendee-ids.
rt := newAttendeeValidateRuntime(t, "", "bad-id")
err := validateCalendarUpdateAttendees(rt)
assertValidationParam(t, err, "--remove-attendee-ids")
}
func TestValidateCalendarUpdateAttendees_AddFormatParam(t *testing.T) {
// The add path attributes its parser error to --add-attendee-ids.
rt := newAttendeeValidateRuntime(t, "bad-id", "")
err := validateCalendarUpdateAttendees(rt)
assertValidationParam(t, err, "--add-attendee-ids")
}
// attendeeDeleteIDs's switch default is defensive: parseCalendarAttendeeIDs
// already rejects any non-ou_/oc_/omm_ id, so only a well-formed id reaches the
// switch and the valid branches map it. This asserts the happy path maps types.
func TestAttendeeDeleteIDs_MapsKnownTypes(t *testing.T) {
got, err := attendeeDeleteIDs("ou_a,oc_b,omm_c")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 3 {
t.Fatalf("expected 3 delete ids, got %d: %v", len(got), got)
}
wantTypes := map[string]string{"user": "user_id", "chat": "chat_id", "resource": "room_id"}
for _, m := range got {
key, ok := wantTypes[m["type"]]
if !ok {
t.Errorf("unexpected type %q in %v", m["type"], m)
continue
}
if m[key] == "" {
t.Errorf("missing %s for type %q in %v", key, m["type"], m)
}
}
}
func TestParseCalendarAttendeeIDs_Valid(t *testing.T) {
ids, err := parseCalendarAttendeeIDs(" ou_a , oc_b , ou_a ")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ids) != 2 || ids[0] != "ou_a" || ids[1] != "oc_b" {
t.Errorf("dedup/trim failed: got %v", ids)
}
}

View File

@@ -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)
}
}

View File

@@ -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,
@@ -603,27 +625,6 @@ func (ctx *RuntimeContext) ResolveSavePath(path string) (string, error) {
return resolved, nil
}
// WrapSaveError matches a FileIO.Save error against known categories and wraps
// it with the caller-provided message prefix, preserving backward-compatible
// error text per shortcut.
func WrapSaveError(err error, pathMsg, mkdirMsg, writeMsg string) error {
if err == nil {
return nil
}
var me *fileio.MkdirError
var we *fileio.WriteError
switch {
case errors.Is(err, fileio.ErrPathValidation):
return fmt.Errorf("%s: %w", pathMsg, err)
case errors.As(err, &me):
return fmt.Errorf("%s: %w", mkdirMsg, err)
case errors.As(err, &we):
return fmt.Errorf("%s: %w", writeMsg, err)
default:
return fmt.Errorf("%s: %w", writeMsg, err)
}
}
// WrapOpenError matches a FileIO.Open/Stat error and wraps it with the
// caller-provided message prefix.
func WrapOpenError(err error, pathMsg, readMsg string) error {
@@ -703,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):

View File

@@ -4,7 +4,6 @@
package common
import (
"fmt"
"strconv"
"strings"
@@ -177,25 +176,6 @@ func ValidateSafePathTyped(fio fileio.FileIO, path string) error {
return nil
}
// RejectDangerousChars returns an error if value contains ASCII control
// characters or dangerous Unicode code points.
//
// Deprecated: use RejectDangerousCharsTyped for typed error envelopes.
func RejectDangerousChars(paramName, value string) error {
for _, r := range value {
if r < 0x20 && r != '\t' && r != '\n' {
return fmt.Errorf("parameter %q contains control character U+%04X", paramName, r)
}
if r == 0x7F {
return fmt.Errorf("parameter %q contains DEL character", paramName)
}
if IsDangerousUnicode(r) {
return fmt.Errorf("parameter %q contains dangerous Unicode character U+%04X", paramName, r)
}
}
return nil
}
// RejectDangerousCharsTyped returns an error if value contains ASCII control
// characters or dangerous Unicode code points.
func RejectDangerousCharsTyped(paramName, value string) error {

View File

@@ -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.

View File

@@ -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

View File

@@ -13,7 +13,7 @@ import (
)
const (
secureLabelReadScope = "drive:file.meta.sec_label.read_only"
secureLabelReadScope = "docs:secure_label:readonly"
secureLabelUpdateScope = "docs:secure_label:write_only"
)

View File

@@ -12,6 +12,17 @@ import (
"github.com/larksuite/cli/internal/httpmock"
)
func TestDriveSecureLabelScopes(t *testing.T) {
t.Parallel()
if len(DriveSecureLabelList.Scopes) != 1 || DriveSecureLabelList.Scopes[0] != "docs:secure_label:readonly" {
t.Fatalf("list scopes = %v, want docs:secure_label:readonly", DriveSecureLabelList.Scopes)
}
if len(DriveSecureLabelUpdate.Scopes) != 1 || DriveSecureLabelUpdate.Scopes[0] != "docs:secure_label:write_only" {
t.Fatalf("update scopes = %v, want docs:secure_label:write_only", DriveSecureLabelUpdate.Scopes)
}
}
func TestDriveSecureLabelList_DryRun(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())

View File

@@ -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
}

View File

@@ -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

View File

@@ -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},

View File

@@ -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{})

View File

@@ -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,15 +1387,324 @@ 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
}
return ItemTypeMsgThread, nil
}
// ShortcutType enumerates the OpenAPI feed-shortcut types.
// Currently the server only opens CHAT (1) externally; other internal values
// (DOC, OPENAPP, etc.) are not yet whitelisted on the OAPI gateway.
type ShortcutType int
const (
ShortcutTypeUnknown ShortcutType = 0
ShortcutTypeChat ShortcutType = 1
)
const (
feedShortcutBatchLimit = 10
feedShortcutWriteScope = "im:feed.shortcut:write"
feedShortcutReadScope = "im:feed.shortcut:read"
)
// shortcutItem is one entry in the feed_shortcuts API body.
type shortcutItem struct {
FeedCardID string `json:"feed_card_id"`
Type int `json:"type"`
}
// collectChatIDs reads --chat-id values (repeatable + comma-split) and
// returns deduped, validated oc_ IDs. The server batch limit is 10.
func collectChatIDs(rt *common.RuntimeContext) ([]string, error) {
raw := rt.StrSlice("chat-id")
if len(raw) == 0 {
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))
out := make([]string, 0, len(raw))
for _, v := range raw {
v = strings.TrimSpace(v)
if v == "" {
continue
}
if !strings.HasPrefix(v, "oc_") {
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
}
seen[v] = struct{}{}
out = append(out, v)
}
if len(out) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-id is required (oc_xxx)").WithParam("--chat-id")
}
if len(out) > feedShortcutBatchLimit {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"too many --chat-id values (%d); the server accepts up to %d per request",
len(out), feedShortcutBatchLimit).WithParam("--chat-id")
}
return out, nil
}
// buildShortcutItems converts chat IDs to API payload entries (type=CHAT).
func buildShortcutItems(ids []string) []shortcutItem {
items := make([]shortcutItem, 0, len(ids))
for _, id := range ids {
items = append(items, shortcutItem{FeedCardID: id, Type: int(ShortcutTypeChat)})
}
return items
}
// shortcutFailedReasonString converts the numeric failed-reason enum returned
// by the server into a human-readable label. Used to enrich the response
// when the API reports per-item failures.
func shortcutFailedReasonString(reason int) string {
switch reason {
case 0:
return "unknown"
case 1:
return "no_permission"
case 2:
return "invalid_item"
case 3:
return "has_pending_delete"
case 4:
return "type_not_support"
case 5:
return "internal_error"
}
return "unknown"
}
// chatBatchQueryScope is the scope required by im.chats.batch_query, which
// the CHAT detail resolver depends on. Surfaced as a conditional scope on
// +feed-shortcut-list so the framework's scope diagnostics know about it.
const chatBatchQueryScope = "im:chat:read"
// chatBatchQuerySize matches the server-side limit on /im/v1/chats/batch_query.
const chatBatchQuerySize = 50
// shortcutTypeFromValue parses the type field as returned by the v2
// feed_shortcuts API. JSON numbers come back as float64 after generic
// unmarshal; we also tolerate the int form for forward-compat.
func shortcutTypeFromValue(v any) ShortcutType {
switch n := v.(type) {
case float64:
return ShortcutType(int(n))
case int:
return ShortcutType(n)
case json.Number:
i, err := n.Int64()
if err == nil {
return ShortcutType(i)
}
}
return ShortcutTypeUnknown
}
// queryChatBatch fetches one im.chats.batch_query page (at most
// chatBatchQuerySize ids) and merges the full chat objects into dst keyed by
// 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.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 {
return err
}
items, _ := res["items"].([]any)
for _, ci := range items {
cm, _ := ci.(map[string]any)
if cm == nil {
continue
}
if id := asString(cm["chat_id"]); id != "" {
dst[id] = cm
}
}
return nil
}
// resolveChatDetail batch-fetches the full chat object via
// im.chats.batch_query (50 ids per request — server limit) and returns the
// objects keyed by chat_id, verbatim, so the caller can decide which fields
// to surface. The server's `name` field is empty for p2p chats (client UI
// shows the partner's display name there), but the full object still carries
// `chat_mode`, `p2p_target_id`, `description`, etc., so callers can render
// p2p entries however they want.
func resolveChatDetail(rt *common.RuntimeContext, ids []string) (map[string]map[string]any, error) {
out := map[string]map[string]any{}
if len(ids) == 0 {
return out, nil
}
if err := checkFlagRequiredScopes(rt.Ctx(), rt, []string{chatBatchQueryScope}); err != nil {
return nil, err
}
for _, batch := range chunkStrings(ids, chatBatchQuerySize) {
if err := queryChatBatch(rt, batch, out); err != nil {
return nil, err
}
}
return out, nil
}
// enrichFeedShortcutDetail walks the list response and attaches the full chat
// object under `detail` for CHAT-type entries — the only type the OpenAPI
// gateway exposes today. Mutates data in place.
//
// Failures are returned to the caller so it can decide whether to hard-fail
// the command or downgrade to a warning. Listing the shortcuts succeeds even
// if enrichment is unavailable (missing scope, network error, etc.).
func enrichFeedShortcutDetail(rt *common.RuntimeContext, data map[string]any) error {
items, _ := data["shortcuts"].([]any)
if len(items) == 0 {
return nil
}
seen := map[string]struct{}{}
ids := make([]string, 0, len(items))
for _, it := range items {
m, _ := it.(map[string]any)
if m == nil || shortcutTypeFromValue(m["type"]) != ShortcutTypeChat {
continue
}
id := asString(m["feed_card_id"])
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
if len(ids) == 0 {
return nil
}
details, err := resolveChatDetail(rt, ids)
if err != nil {
return err
}
// Missing items (server didn't return one for an id we asked about) are
// left untouched, so the presence of `detail` signals a successful lookup.
for _, it := range items {
m, _ := it.(map[string]any)
if m == nil || shortcutTypeFromValue(m["type"]) != ShortcutTypeChat {
continue
}
if info, ok := details[asString(m["feed_card_id"])]; ok {
m["detail"] = info
}
}
return nil
}
// annotateFailedShortcuts walks the API response and attaches a
// reason_label string next to each numeric reason. Mutates data in place.
func annotateFailedShortcuts(data map[string]any) {
items, ok := data["failed_shortcuts"].([]any)
if !ok {
return
}
for _, it := range items {
m, _ := it.(map[string]any)
if m == nil {
continue
}
// reason is serialized as a JSON number → float64 after generic unmarshal.
switch r := m["reason"].(type) {
case float64:
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))
}
}
}
}
// emitFeedShortcutWriteResult preserves the server payload while adding a
// batch ledger. A feed-shortcut write can return HTTP/API success with
// failed_shortcuts populated; callers still need a complete account of which
// requested entries succeeded and which failed.
func emitFeedShortcutWriteResult(rt *common.RuntimeContext, requested []shortcutItem, data map[string]any) error {
// A fully-successful write can come back as code:0 with data:null, in
// which case DoAPIJSON hands us a nil map; the caller is still owed a
// ledger, so start from an empty object instead of panicking on write.
if data == nil {
data = map[string]any{}
}
annotateFailedShortcuts(data)
addFeedShortcutWriteLedger(data, requested)
if hasFailedShortcuts(data) {
return rt.OutPartialFailure(data, nil)
}
rt.Out(data, nil)
return nil
}
func addFeedShortcutWriteLedger(data map[string]any, requested []shortcutItem) {
failed := failedShortcutItems(data)
// Failed entries are matched back to requested items by feed_card_id
// alone: every requested item is CHAT-type, so the id is the identity,
// and a failed echo with a missing or zero type still excludes its item
// from the success list.
failedIDs := map[string]struct{}{}
for _, it := range failed {
m, _ := it.(map[string]any)
if m == nil {
continue
}
shortcut, _ := m["shortcut"].(map[string]any)
if shortcut == nil {
continue
}
if id := asString(shortcut["feed_card_id"]); id != "" {
failedIDs[id] = struct{}{}
}
}
succeeded := make([]shortcutItem, 0, len(requested))
for _, it := range requested {
if _, isFailed := failedIDs[it.FeedCardID]; isFailed {
continue
}
succeeded = append(succeeded, it)
}
// Counts are derived from the requested-item accounting alone so the
// success+failure==total invariant holds even if the server echoes a
// failed entry twice or reports one we never asked about;
// failed_shortcuts still carries the raw server report.
data["total"] = len(requested)
data["success_count"] = len(succeeded)
data["failure_count"] = len(requested) - len(succeeded)
data["succeeded_shortcuts"] = succeeded
}
func hasFailedShortcuts(data map[string]any) bool {
return len(failedShortcutItems(data)) > 0
}
func failedShortcutItems(data map[string]any) []any {
items, _ := data["failed_shortcuts"].([]any)
return items
}

View File

@@ -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) {

View File

@@ -606,6 +606,9 @@ func TestShortcuts(t *testing.T) {
"+flag-create",
"+flag-cancel",
"+flag-list",
"+feed-shortcut-create",
"+feed-shortcut-remove",
"+feed-shortcut-list",
}
if !reflect.DeepEqual(commands, want) {
t.Fatalf("Shortcuts() commands = %#v, want %#v", commands, want)

View File

@@ -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 {

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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,

63
shortcuts/im/im_errors.go Normal file
View File

@@ -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)
}

View File

@@ -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")
}
}

View File

@@ -0,0 +1,97 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// ImFeedShortcutCreate provides the +feed-shortcut-create shortcut for adding
// chats to the user's feed shortcuts. Currently only CHAT-type shortcuts are
// exposed by the OpenAPI gateway; feed_card_id must be an open_chat_id
// (oc_xxx).
var ImFeedShortcutCreate = common.Shortcut{
Service: "im",
Command: "+feed-shortcut-create",
Description: "Add chats to the user's feed shortcuts; user-only; batch up to 10 chat IDs per call; --head/--tail controls insertion order",
Risk: "write",
UserScopes: []string{feedShortcutWriteScope},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
// chat-id is mandatory but intentionally not cobra-Required: the
// requiredness check lives in collectChatIDs so a missing flag is
// reported through the structured validation envelope (exit 2)
// instead of cobra's plain-text error.
{Name: "chat-id", Type: "string_slice",
Desc: "open_chat_id to add as a feed shortcut (oc_xxx); required; repeat the flag or pass comma-separated; max 10 per call"},
{Name: "head", Type: "bool",
Desc: "insert at the top of the shortcut list (default); mutually exclusive with --tail"},
{Name: "tail", Type: "bool",
Desc: "append at the bottom of the shortcut list; mutually exclusive with --head"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := collectChatIDs(runtime); err != nil {
return err
}
_, err := resolveIsHeader(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ids, err := collectChatIDs(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
isHeader, err := resolveIsHeader(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST("/open-apis/im/v2/feed_shortcuts").
Body(map[string]any{
"shortcuts": buildShortcutItems(ids),
"is_header": isHeader,
})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ids, err := collectChatIDs(runtime)
if err != nil {
return err
}
isHeader, err := resolveIsHeader(runtime)
if err != nil {
return err
}
items := buildShortcutItems(ids)
data, err := runtime.DoAPIJSONTyped("POST", "/open-apis/im/v2/feed_shortcuts", nil,
map[string]any{
"shortcuts": items,
"is_header": isHeader,
})
if err != nil {
return err
}
return emitFeedShortcutWriteResult(runtime, items, data)
},
}
// resolveIsHeader determines the insertion position.
// - default (neither flag set) → true (head)
// - --head → true
// - --tail → false
// - both set → error
func resolveIsHeader(rt *common.RuntimeContext) (bool, error) {
head := rt.Bool("head")
tail := rt.Bool("tail")
if head && tail {
return false, errs.NewValidationError(errs.SubtypeInvalidArgument, "--head and --tail are mutually exclusive")
}
if tail {
return false, nil
}
return true, nil
}

View File

@@ -0,0 +1,79 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"fmt"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// ImFeedShortcutList provides the +feed-shortcut-list shortcut for listing
// the user's feed shortcuts. The server-controlled page size covers the full
// list in practice, but pagination is version-locked: when the list changes
// between calls the server rejects the stale token and the caller has to
// restart by omitting --page-token.
//
// The shortcut is a thin one-page wrapper — there is no automatic walking.
// Callers are expected to drive their own loop when they actually need to
// paginate, because the version-lock means each page is a real checkpoint
// that the caller must consciously decide what to do with on failure.
var ImFeedShortcutList = common.Shortcut{
Service: "im",
Command: "+feed-shortcut-list",
Description: "List one page of the user's feed shortcuts; user-only; first call omits --page-token, subsequent calls pass the previous response's page_token; each entry is auto-enriched with the full per-type info object attached as `detail` (pass --no-detail to skip)",
Risk: "read",
UserScopes: []string{feedShortcutReadScope},
ConditionalUserScopes: []string{chatBatchQueryScope},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "page-token",
Desc: "opaque pagination token from the previous response; omit for the first page. If a token is rejected because the list changed, restart by omitting it."},
{Name: "no-detail", Type: "bool",
Desc: "skip fetching the full info object for each shortcut (default: enrichment enabled — CHAT-type entries call im.chats.batch_query, require im:chat:read, and attach the object under the detail field)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
d := common.NewDryRunAPI().
GET("/open-apis/im/v2/feed_shortcuts")
if token := runtime.Str("page-token"); token != "" {
d.Params(map[string]any{"page_token": token})
}
if !runtime.Bool("no-detail") {
d.Desc("conditional enrichment: if CHAT-type entries exist, execution also calls POST /open-apis/im/v1/chats/batch_query and requires scope im:chat:read; pass --no-detail to skip this extra call and extra scope")
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
data, err := runtime.DoAPIJSONTyped("GET", "/open-apis/im/v2/feed_shortcuts",
feedShortcutListQuery(runtime.Str("page-token")), nil)
if err != nil {
return err
}
if !runtime.Bool("no-detail") {
if err := enrichFeedShortcutDetail(runtime, data); err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: detail enrichment failed: %v\n", err)
// Mirror the warning into the data payload so stdout-only
// consumers can tell "enrichment skipped" from "nothing to
// enrich" (same convention as mail's data-level _notice).
if data != nil {
data["_notice"] = fmt.Sprintf("detail enrichment skipped: %v", err)
}
}
}
runtime.Out(data, nil)
return nil
},
}
// feedShortcutListQuery omits the page_token key entirely when the token is
// empty, so the server treats the call as a first-page request.
func feedShortcutListQuery(token string) larkcore.QueryParams {
if token == "" {
return larkcore.QueryParams{}
}
return larkcore.QueryParams{"page_token": []string{token}}
}

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
// ImFeedShortcutRemove provides the +feed-shortcut-remove shortcut for
// removing chats from the user's feed shortcuts. Per-item failures are kept
// in stdout and returned as a partial-failure exit.
var ImFeedShortcutRemove = common.Shortcut{
Service: "im",
Command: "+feed-shortcut-remove",
Description: "Remove chats from the user's feed shortcuts; user-only; batch up to 10 chat IDs per call; per-item failures return ok:false with failed_shortcuts",
Risk: "write",
UserScopes: []string{feedShortcutWriteScope},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
// chat-id is mandatory but intentionally not cobra-Required: the
// requiredness check lives in collectChatIDs so a missing flag is
// reported through the structured validation envelope (exit 2)
// instead of cobra's plain-text error.
{Name: "chat-id", Type: "string_slice",
Desc: "open_chat_id to remove from feed shortcuts (oc_xxx); required; repeat the flag or pass comma-separated; max 10 per call"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := collectChatIDs(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ids, err := collectChatIDs(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST("/open-apis/im/v2/feed_shortcuts/remove").
Body(map[string]any{"shortcuts": buildShortcutItems(ids)})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ids, err := collectChatIDs(runtime)
if err != nil {
return err
}
items := buildShortcutItems(ids)
data, err := runtime.DoAPIJSONTyped("POST", "/open-apis/im/v2/feed_shortcuts/remove", nil,
map[string]any{"shortcuts": items})
if err != nil {
return err
}
return emitFeedShortcutWriteResult(runtime, items, data)
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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

View File

@@ -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},

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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"
@@ -22,7 +23,6 @@ const (
messagesSearchDefaultPageLimit = 20
messagesSearchMaxPageLimit = 40
messagesSearchMGetBatchSize = 50
messagesSearchChatBatchSize = 50
)
var ImMessagesSearch = common.Shortcut{
@@ -269,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")
}
}
@@ -279,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
@@ -288,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
@@ -298,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 {
@@ -307,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
}
}
@@ -320,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
}
}
@@ -344,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
}
}
@@ -358,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
@@ -421,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
}
@@ -447,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
}
@@ -459,23 +459,9 @@ 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(
http.MethodPost, "/open-apis/im/v1/chats/batch_query",
larkcore.QueryParams{"user_id_type": []string{"open_id"}},
map[string]interface{}{"chat_ids": batch},
)
if chatErr != nil {
continue
}
if chatItems, ok := chatRes["items"].([]interface{}); ok {
for _, ci := range chatItems {
cm, _ := ci.(map[string]interface{})
if cid, _ := cm["chat_id"].(string); cid != "" {
chatContexts[cid] = cm
}
}
}
// Best-effort: a failed chunk only loses its own entries.
for _, batch := range chunkStrings(chatIds, chatBatchQuerySize) {
_ = queryChatBatch(runtime, batch, chatContexts)
}
return chatContexts
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -22,5 +22,8 @@ func Shortcuts() []common.Shortcut {
ImFlagCreate,
ImFlagCancel,
ImFlagList,
ImFeedShortcutCreate,
ImFeedShortcutRemove,
ImFeedShortcutList,
}
}

View File

@@ -11,8 +11,8 @@ import (
"strconv"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// OKRCycleDetail lists all objectives and their key results under a given OKR cycle.
@@ -30,10 +30,10 @@ var OKRCycleDetail = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
cycleID := runtime.Str("cycle-id")
if cycleID == "" {
return common.FlagErrorf("--cycle-id is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id is required").WithParam("--cycle-id")
}
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
return common.FlagErrorf("--cycle-id must be a positive int64")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
}
return nil
},
@@ -52,8 +52,7 @@ var OKRCycleDetail = common.Shortcut{
cycleID := runtime.Str("cycle-id")
// Paginate objectives under the cycle.
queryParams := make(larkcore.QueryParams)
queryParams.Set("page_size", "100")
queryParams := map[string]interface{}{"page_size": "100"}
var objectives []Objective
page := 0
@@ -71,7 +70,7 @@ var OKRCycleDetail = common.Shortcut{
page++
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
data, err := runtime.DoAPIJSON("GET", path, queryParams, nil)
data, err := runtime.CallAPITyped("GET", path, queryParams, nil)
if err != nil {
return err
}
@@ -93,7 +92,7 @@ var OKRCycleDetail = common.Shortcut{
if !hasMore || pageToken == "" {
break
}
queryParams.Set("page_token", pageToken)
queryParams["page_token"] = pageToken
}
// For each objective, paginate key results and convert to response format.
@@ -104,8 +103,7 @@ var OKRCycleDetail = common.Shortcut{
}
obj := &objectives[i]
krQuery := make(larkcore.QueryParams)
krQuery.Set("page_size", "100")
krQuery := map[string]interface{}{"page_size": "100"}
var keyResults []KeyResult
krPage := 0
@@ -123,7 +121,7 @@ var OKRCycleDetail = common.Shortcut{
krPage++
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", obj.ID)
data, err := runtime.DoAPIJSON("GET", path, krQuery, nil)
data, err := runtime.CallAPITyped("GET", path, krQuery, nil)
if err != nil {
return err
}
@@ -145,7 +143,7 @@ var OKRCycleDetail = common.Shortcut{
if !hasMore || pageToken == "" {
break
}
krQuery.Set("page_token", pageToken)
krQuery["page_token"] = pageToken
}
respObj := obj.ToResp()

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