Compare commits

...

9 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
109 changed files with 5971 additions and 1341 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/|shortcuts/drive/|shortcuts/mail/|shortcuts/base/)
- 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/base/|shortcuts/calendar/|shortcuts/common/mcp_client\.go)
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/|shortcuts/common/mcp_client\.go)
text: errs-no-bare-wrap
linters:
- forbidigo
# errs-no-legacy-helper enforced on domains whose shared validation/save
# helpers have migrated to typed final errors.
- path-except: (shortcuts/drive/|shortcuts/mail/|shortcuts/base/|shortcuts/calendar/)
- 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

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

@@ -16,9 +16,12 @@ import (
// common replacements or construct an errs.* typed error directly.
var migratedCommonHelperPaths = []string{
"shortcuts/base/",
"shortcuts/calendar/",
"shortcuts/drive/",
"shortcuts/mail/",
"shortcuts/calendar/",
"shortcuts/okr/",
"shortcuts/task/",
"shortcuts/whiteboard/",
}
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"

View File

@@ -17,9 +17,13 @@ import (
// appending their path prefix here.
var migratedEnvelopePaths = []string{
"shortcuts/base/",
"shortcuts/calendar/",
"shortcuts/drive/",
"shortcuts/mail/",
"shortcuts/calendar/",
"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/im/foo.go", src)
v := CheckNoLegacyEnvelopeLiteral("shortcuts/contact/foo.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path should pass, got: %+v", v)
}
@@ -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 {
@@ -946,7 +998,7 @@ func boom() {
}
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
src := `package im
src := `package contact
import "github.com/larksuite/cli/shortcuts/common"
@@ -954,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

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

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

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

View File

@@ -6,11 +6,13 @@ package okr
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -106,6 +108,31 @@ func TestCycleDetailValidate_InvalidCycleID_Negative(t *testing.T) {
}
}
// TestCycleDetailValidate_TypedError locks the typed-envelope contract shared by
// every okr flag check: an invalid flag surfaces as *errs.ValidationError carrying
// SubtypeInvalidArgument and the offending --flag (readable via errors.As /
// errs.ProblemOf), and maps to the validation exit code rather than a legacy api error.
func TestCycleDetailValidate_TypedError(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "0"})
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error is not *errs.ValidationError: %T (%v)", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != "--cycle-id" {
t.Errorf("Param = %q, want %q", ve.Param, "--cycle-id")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation {
t.Errorf("ProblemOf category = %v (ok=%v), want %q", p, ok, errs.CategoryValidation)
}
}
func TestCycleDetailValidate_ValidCycleID(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))

View File

@@ -12,9 +12,8 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// parseTimeRange parses a "YYYY-MM--YYYY-MM" string into two time.Time values.
@@ -22,20 +21,20 @@ import (
func parseTimeRange(s string) (start, end time.Time, err error) {
parts := strings.SplitN(s, "--", 2)
if len(parts) != 2 {
return time.Time{}, time.Time{}, fmt.Errorf("invalid time-range format %q, expected YYYY-MM--YYYY-MM", s)
return time.Time{}, time.Time{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --time-range format %q, expected YYYY-MM--YYYY-MM", s).WithParam("--time-range")
}
start, err = time.Parse("2006-01", strings.TrimSpace(parts[0]))
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("invalid start month %q: %w", parts[0], err)
return time.Time{}, time.Time{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --time-range start month %q: %v", parts[0], err).WithParam("--time-range").WithCause(err)
}
end, err = time.Parse("2006-01", strings.TrimSpace(parts[1]))
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("invalid end month %q: %w", parts[1], err)
return time.Time{}, time.Time{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --time-range end month %q: %v", parts[1], err).WithParam("--time-range").WithCause(err)
}
// end is the last moment of the end month
end = end.AddDate(0, 1, 0).Add(-time.Millisecond)
if start.After(end) {
return time.Time{}, time.Time{}, fmt.Errorf("start month %s is after end month %s", parts[0], parts[1])
return time.Time{}, time.Time{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --time-range: start month %s is after end month %s", parts[0], parts[1]).WithParam("--time-range")
}
return start, end, nil
}
@@ -69,20 +68,20 @@ var OKRListCycles = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
idType := runtime.Str("user-id-type")
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
return common.FlagErrorf("--user-id-type must be one of: open_id | union_id | user_id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
}
userID := runtime.Str("user-id")
if err := validate.RejectControlChars(userID, "user-id"); err != nil {
if err := common.RejectDangerousCharsTyped("--user-id", userID); err != nil {
return err
}
tr := runtime.Str("time-range")
if tr != "" {
if err := validate.RejectControlChars(tr, "time-range"); err != nil {
if err := common.RejectDangerousCharsTyped("--time-range", tr); err != nil {
return err
}
if _, _, err := parseTimeRange(tr); err != nil {
return common.FlagErrorf("--time-range: %s", err)
return err
}
}
return nil
@@ -110,16 +109,17 @@ var OKRListCycles = common.Shortcut{
var err error
rangeStart, rangeEnd, err = parseTimeRange(timeRange)
if err != nil {
return common.FlagErrorf("--time-range: %s", err)
return err
}
hasRange = true
}
// Paginated fetch of all cycles
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id", userID)
queryParams.Set("user_id_type", userIDType)
queryParams.Set("page_size", "100")
queryParams := map[string]interface{}{
"user_id": userID,
"user_id_type": userIDType,
"page_size": "100",
}
var allCycles []Cycle
page := 0
@@ -136,7 +136,7 @@ var OKRListCycles = common.Shortcut{
}
page++
data, err := runtime.DoAPIJSON("GET", "/open-apis/okr/v2/cycles", queryParams, nil)
data, err := runtime.CallAPITyped("GET", "/open-apis/okr/v2/cycles", queryParams, nil)
if err != nil {
return err
}
@@ -158,7 +158,7 @@ var OKRListCycles = common.Shortcut{
if !hasMore || pageToken == "" {
break
}
queryParams.Set("page_token", pageToken)
queryParams["page_token"] = pageToken
}
// Filter by time-range overlap

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"errors"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
)
// okrInputStatError maps a FileIO.Stat/Open error for input file validation to
// a typed validation error: path validation failures read as "unsafe file
// path", other errors as "cannot read file".
func okrInputStatError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, fileio.ErrPathValidation) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).
WithParam("--file").
WithCause(err)
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read file: %s", err).
WithParam("--file").
WithCause(err)
}
// wrapOkrNetworkErr returns err unchanged when it is already a typed errs.*
// error (preserving subtype / code / log_id from the runtime boundary) and only
// wraps a raw, unclassified error as a transport-level network error.
func wrapOkrNetworkErr(err error, format string, args ...any) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
}

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
)
func TestOkrInputStatError(t *testing.T) {
if okrInputStatError(nil) != nil {
t.Fatal("nil error should map to nil")
}
var ve *errs.ValidationError
pathCause := errors.New("traversal")
pathErr := okrInputStatError(&fileio.PathValidationError{Err: pathCause})
if !errors.As(pathErr, &ve) || ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("path validation: got %T (%v)", pathErr, pathErr)
}
if ve.Param != "--file" {
t.Fatalf("path validation param = %q, want --file", ve.Param)
}
if !errors.Is(pathErr, fileio.ErrPathValidation) || !errors.Is(pathErr, pathCause) {
t.Fatal("path validation cause should be retained")
}
genericCause := errors.New("permission denied")
genericErr := okrInputStatError(genericCause)
if !errors.As(genericErr, &ve) || ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("generic: got %T (%v)", genericErr, genericErr)
}
if ve.Param != "--file" {
t.Fatalf("generic param = %q, want --file", ve.Param)
}
if !errors.Is(genericErr, genericCause) {
t.Fatal("generic cause should be retained")
}
}
func TestWrapOkrNetworkErr(t *testing.T) {
typed := errs.NewValidationError(errs.SubtypeInvalidArgument, "already typed")
if got := wrapOkrNetworkErr(typed, "wrap %v", typed); got != error(typed) {
t.Fatalf("typed error must pass through unchanged, got %v", got)
}
raw := errors.New("dial tcp: i/o timeout")
got := wrapOkrNetworkErr(raw, "upload failed: %v", raw)
var ne *errs.NetworkError
if !errors.As(got, &ne) || ne.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("raw error: got %T (%v)", got, got)
}
if !errors.Is(got, raw) {
t.Fatal("raw cause should be retained via WithCause")
}
}

View File

@@ -5,8 +5,6 @@ package okr
import (
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strconv"
@@ -14,7 +12,7 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -43,24 +41,24 @@ var OKRUploadImage = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
filePath := runtime.Str("file")
if filePath == "" {
return common.FlagErrorf("--file is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
}
ext := strings.ToLower(filepath.Ext(filePath))
if !allowedImageExts[ext] {
return common.FlagErrorf("--file must be an image (supported: JPG, JPEG, PNG, GIF, BMP), got %q", ext)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file must be an image (supported: JPG, JPEG, PNG, GIF, BMP), got %q", ext).WithParam("--file")
}
targetID := runtime.Str("target-id")
if targetID == "" {
return common.FlagErrorf("--target-id is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id is required").WithParam("--target-id")
}
if id, err := strconv.ParseInt(targetID, 10, 64); err != nil || id <= 0 {
return common.FlagErrorf("--target-id must be a positive int64")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id must be a positive int64").WithParam("--target-id")
}
targetType := runtime.Str("target-type")
if _, ok := targetTypeAllowed[targetType]; !ok {
return common.FlagErrorf("--target-type must be one of: objective | key_result")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-type must be one of: objective | key_result").WithParam("--target-type")
}
return nil
},
@@ -87,12 +85,12 @@ var OKRUploadImage = common.Shortcut{
info, err := runtime.FileIO().Stat(filePath)
if err != nil {
return common.WrapInputStatError(err)
return okrInputStatError(err)
}
f, err := runtime.FileIO().Open(filePath)
if err != nil {
return common.WrapInputStatError(err)
return okrInputStatError(err)
}
defer f.Close()
@@ -110,30 +108,22 @@ var OKRUploadImage = common.Shortcut{
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err
}
return output.ErrNetwork("upload failed: %v", err)
// The DoAPI boundary already returns typed errs.* (auth →
// AuthenticationError, transport → NetworkError, etc.); wrapOkrNetworkErr
// passes those through via ProblemOf and only wraps a still-untyped error.
return wrapOkrNetworkErr(err, "upload failed: %v", err)
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return err
}
if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 {
msg, _ := result["msg"].(string)
return output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"])
}
data, _ := result["data"].(map[string]interface{})
fileToken, _ := data["file_token"].(string)
url, _ := data["url"].(string)
fileToken := common.GetString(data, "file_token")
if fileToken == "" {
return output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "upload failed: no file_token returned")
}
url := common.GetString(data, "url")
runtime.Out(map[string]interface{}{
"file_token": fileToken,

View File

@@ -5,6 +5,7 @@ package okr
import (
"bytes"
"errors"
"mime"
"mime/multipart"
"os"
@@ -13,6 +14,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -360,6 +362,15 @@ func TestUploadImageExecute_APIError(t *testing.T) {
if err == nil {
t.Fatal("expected error for API failure")
}
// The upload boundary now classifies the Lark error code into a typed
// envelope carrying the numeric code, instead of a flat legacy error.
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not a typed errs.* envelope: %T (%v)", err, err)
}
if p.Code != 1001001 {
t.Errorf("Problem.Code = %d, want 1001001", p.Code)
}
}
func TestUploadImageExecute_FileNotFound(t *testing.T) {
@@ -407,6 +418,15 @@ func TestUploadImageExecute_NoFileTokenInResponse(t *testing.T) {
if err == nil {
t.Fatal("expected error for missing file_token in response")
}
// A 2xx response that omits the expected file_token is a malformed response,
// surfaced as a typed internal/invalid_response error.
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("error is not *errs.InternalError: %T (%v)", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("Subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if !strings.Contains(err.Error(), "no file_token returned") {
t.Fatalf("unexpected error: %v", err)
}

View File

@@ -11,10 +11,9 @@ import (
"math"
"strconv"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// targetTypeAllowed values for --target-type flag
@@ -39,7 +38,7 @@ func parseCreateProgressRecordParams(runtime *common.RuntimeContext) (*createPro
content := runtime.Str("content")
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return nil, common.FlagErrorf("--content must be valid ContentBlock JSON: %s", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
contentV1 := cb.ToV1()
@@ -60,13 +59,13 @@ func parseCreateProgressRecordParams(runtime *common.RuntimeContext) (*createPro
if v := runtime.Str("progress-percent"); v != "" {
percent, err := strconv.ParseFloat(v, 64)
if err != nil || math.IsNaN(percent) || math.IsInf(percent, 0) || percent < -99999999999 || percent > 99999999999 {
return nil, common.FlagErrorf("--progress-percent must be a number between -99999999999 and 99999999999")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-percent must be a number between -99999999999 and 99999999999").WithParam("--progress-percent")
}
progressRate = &ProgressRateV1{Percent: &percent}
if s := runtime.Str("progress-status"); s != "" {
status, ok := ParseProgressStatus(s)
if !ok {
return nil, common.FlagErrorf("--progress-status must be one of: normal | overdue | done")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-status must be one of: normal | overdue | done").WithParam("--progress-status")
}
progressRate.Status = int32Ptr(int32(status))
}
@@ -105,40 +104,40 @@ var OKRCreateProgressRecord = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
content := runtime.Str("content")
if content == "" {
return common.FlagErrorf("--content is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required").WithParam("--content")
}
if err := validate.RejectControlChars(content, "content"); err != nil {
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
return err
}
// Validate content is valid JSON and can be parsed as ContentBlock
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return common.FlagErrorf("--content must be valid ContentBlock JSON: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
targetID := runtime.Str("target-id")
if targetID == "" {
return common.FlagErrorf("--target-id is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id is required").WithParam("--target-id")
}
if err := validate.RejectControlChars(targetID, "target-id"); err != nil {
if err := common.RejectDangerousCharsTyped("--target-id", targetID); err != nil {
return err
}
if id, err := strconv.ParseInt(targetID, 10, 64); err != nil || id <= 0 {
return common.FlagErrorf("--target-id must be a positive int64")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id must be a positive int64").WithParam("--target-id")
}
targetType := runtime.Str("target-type")
if _, ok := targetTypeAllowed[targetType]; !ok {
return common.FlagErrorf("--target-type must be one of: objective | key_result")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-type must be one of: objective | key_result").WithParam("--target-type")
}
if v := runtime.Str("source-title"); v != "" {
if err := validate.RejectControlChars(v, "source-title"); err != nil {
if err := common.RejectDangerousCharsTyped("--source-title", v); err != nil {
return err
}
}
if v := runtime.Str("source-url"); v != "" {
if err := validate.RejectControlChars(v, "source-url"); err != nil {
if err := common.RejectDangerousCharsTyped("--source-url", v); err != nil {
return err
}
}
@@ -146,21 +145,21 @@ var OKRCreateProgressRecord = common.Shortcut{
if v := runtime.Str("progress-percent"); v != "" {
percent, err := strconv.ParseFloat(v, 64)
if err != nil || math.IsNaN(percent) || math.IsInf(percent, 0) || percent < -99999999999 || percent > 99999999999 {
return common.FlagErrorf("--progress-percent must be a number between -99999999999 and 99999999999")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-percent must be a number between -99999999999 and 99999999999").WithParam("--progress-percent")
}
}
if v := runtime.Str("progress-status"); v != "" {
if _, ok := ParseProgressStatus(v); !ok {
return common.FlagErrorf("--progress-status must be one of: normal | overdue | done")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-status must be one of: normal | overdue | done").WithParam("--progress-status")
}
if v := runtime.Str("progress-percent"); v == "" {
return common.FlagErrorf("--progress-percent must provided with --progress-status")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-percent must provided with --progress-status").WithParam("--progress-percent")
}
}
idType := runtime.Str("user-id-type")
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
return common.FlagErrorf("--user-id-type must be one of: open_id | union_id | user_id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
}
return nil
},
@@ -202,10 +201,9 @@ var OKRCreateProgressRecord = common.Shortcut{
body["progress_rate"] = p.ProgressRate
}
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", p.UserIDType)
queryParams := map[string]interface{}{"user_id_type": p.UserIDType}
data, err := runtime.DoAPIJSON("POST", "/open-apis/okr/v1/progress_records/", queryParams, body)
data, err := runtime.CallAPITyped("POST", "/open-apis/okr/v1/progress_records/", queryParams, body)
if err != nil {
return err
}

View File

@@ -9,8 +9,8 @@ import (
"io"
"strconv"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// OKRDeleteProgressRecord deletes a progress by ID.
@@ -28,10 +28,10 @@ var OKRDeleteProgressRecord = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
progressID := runtime.Str("progress-id")
if progressID == "" {
return common.FlagErrorf("--progress-id is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-id is required").WithParam("--progress-id")
}
if id, err := strconv.ParseInt(progressID, 10, 64); err != nil || id <= 0 {
return common.FlagErrorf("--progress-id must be a positive int64")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-id must be a positive int64").WithParam("--progress-id")
}
return nil
},
@@ -46,7 +46,7 @@ var OKRDeleteProgressRecord = common.Shortcut{
progressID := runtime.Str("progress-id")
path := fmt.Sprintf("/open-apis/okr/v1/progress_records/%s", progressID)
_, err := runtime.DoAPIJSON("DELETE", path, larkcore.QueryParams{}, nil)
_, err := runtime.CallAPITyped("DELETE", path, nil, nil)
if err != nil {
return err
}

View File

@@ -10,8 +10,8 @@ import (
"io"
"strconv"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// OKRGetProgressRecord gets a progress by ID.
@@ -30,14 +30,14 @@ var OKRGetProgressRecord = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
progressID := runtime.Str("progress-id")
if progressID == "" {
return common.FlagErrorf("--progress-id is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-id is required").WithParam("--progress-id")
}
if id, err := strconv.ParseInt(progressID, 10, 64); err != nil || id <= 0 {
return common.FlagErrorf("--progress-id must be a positive int64")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-id must be a positive int64").WithParam("--progress-id")
}
idType := runtime.Str("user-id-type")
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
return common.FlagErrorf("--user-id-type must be one of: open_id | union_id | user_id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
}
return nil
},
@@ -56,11 +56,10 @@ var OKRGetProgressRecord = common.Shortcut{
progressID := runtime.Str("progress-id")
userIDType := runtime.Str("user-id-type")
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", userIDType)
queryParams := map[string]interface{}{"user_id_type": userIDType}
path := fmt.Sprintf("/open-apis/okr/v1/progress_records/%s", progressID)
data, err := runtime.DoAPIJSON("GET", path, queryParams, nil)
data, err := runtime.CallAPITyped("GET", path, queryParams, nil)
if err != nil {
return err
}
@@ -93,11 +92,11 @@ var OKRGetProgressRecord = common.Shortcut{
func parseProgressRecord(data map[string]any) (*ProgressV1, error) {
raw, err := json.Marshal(data)
if err != nil {
return nil, err
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid progress response: marshal failed: %s", err).WithCause(err)
}
var record ProgressV1
if err := json.Unmarshal(raw, &record); err != nil {
return nil, err
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid progress response: unmarshal failed: %s", err).WithCause(err)
}
return &record, nil
}

View File

@@ -5,11 +5,13 @@ package okr
import (
"bytes"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -190,3 +192,19 @@ func TestProgressGetExecute_APIError(t *testing.T) {
t.Fatal("expected error for API failure")
}
}
func TestParseProgressRecord_InvalidResponseTypedError(t *testing.T) {
_, err := parseProgressRecord(map[string]any{
"progress_rate": "not-an-object",
})
if err == nil {
t.Fatal("expected invalid response error")
}
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("error is not *errs.InternalError: %T (%v)", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("Subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
}

View File

@@ -10,9 +10,8 @@ import (
"io"
"strconv"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// OKRListProgress lists progress for an objective or key result.
@@ -33,28 +32,28 @@ var OKRListProgress = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
targetID := runtime.Str("target-id")
if targetID == "" {
return common.FlagErrorf("--target-id is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id is required").WithParam("--target-id")
}
if err := validate.RejectControlChars(targetID, "target-id"); err != nil {
if err := common.RejectDangerousCharsTyped("--target-id", targetID); err != nil {
return err
}
if id, err := strconv.ParseInt(targetID, 10, 64); err != nil || id <= 0 {
return common.FlagErrorf("--target-id must be a positive int64")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id must be a positive int64").WithParam("--target-id")
}
targetType := runtime.Str("target-type")
if _, ok := targetTypeAllowed[targetType]; !ok {
return common.FlagErrorf("--target-type must be one of: objective | key_result")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-type must be one of: objective | key_result").WithParam("--target-type")
}
idType := runtime.Str("user-id-type")
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
return common.FlagErrorf("--user-id-type must be one of: open_id | union_id | user_id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
}
deptIDType := runtime.Str("department-id-type")
if deptIDType != "department_id" && deptIDType != "open_department_id" {
return common.FlagErrorf("--department-id-type must be one of: department_id | open_department_id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--department-id-type must be one of: department_id | open_department_id").WithParam("--department-id-type")
}
return nil
},
@@ -89,10 +88,11 @@ var OKRListProgress = common.Shortcut{
userIDType := runtime.Str("user-id-type")
deptIDType := runtime.Str("department-id-type")
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", userIDType)
queryParams.Set("department_id_type", deptIDType)
queryParams.Set("page_size", "100")
queryParams := map[string]interface{}{
"user_id_type": userIDType,
"department_id_type": deptIDType,
"page_size": "100",
}
var apiPath string
switch targetType {
@@ -108,7 +108,7 @@ var OKRListProgress = common.Shortcut{
return err
}
data, err := runtime.DoAPIJSON("GET", apiPath, queryParams, nil)
data, err := runtime.CallAPITyped("GET", apiPath, queryParams, nil)
if err != nil {
return err
}
@@ -130,7 +130,7 @@ var OKRListProgress = common.Shortcut{
if !hasMore || pageToken == "" {
break
}
queryParams.Set("page_token", pageToken)
queryParams["page_token"] = pageToken
}
// Convert to response format

View File

@@ -11,9 +11,8 @@ import (
"math"
"strconv"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// updateProgressRecordParams holds the parsed parameters for updating a progress.
@@ -29,7 +28,7 @@ func parseUpdateProgressRecordParams(runtime *common.RuntimeContext) (*updatePro
content := runtime.Str("content")
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return nil, common.FlagErrorf("--content must be valid ContentBlock JSON: %s", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
contentV1 := cb.ToV1()
@@ -37,13 +36,13 @@ func parseUpdateProgressRecordParams(runtime *common.RuntimeContext) (*updatePro
if v := runtime.Str("progress-percent"); v != "" {
percent, err := strconv.ParseFloat(v, 64)
if err != nil || math.IsNaN(percent) || math.IsInf(percent, 0) || percent < -99999999999 || percent > 99999999999 {
return nil, common.FlagErrorf("--progress-percent must be a number between -99999999999 and 99999999999")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-percent must be a number between -99999999999 and 99999999999").WithParam("--progress-percent")
}
progressRate = &ProgressRateV1{Percent: &percent}
if s := runtime.Str("progress-status"); s != "" {
status, ok := ParseProgressStatus(s)
if !ok {
return nil, common.FlagErrorf("--progress-status must be one of: normal | overdue | done")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-status must be one of: normal | overdue | done").WithParam("--progress-status")
}
progressRate.Status = int32Ptr(int32(status))
}
@@ -76,42 +75,42 @@ var OKRUpdateProgressRecord = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
progressID := runtime.Str("progress-id")
if progressID == "" {
return common.FlagErrorf("--progress-id is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-id is required").WithParam("--progress-id")
}
if id, err := strconv.ParseInt(progressID, 10, 64); err != nil || id <= 0 {
return common.FlagErrorf("--progress-id must be a positive int64")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-id must be a positive int64").WithParam("--progress-id")
}
content := runtime.Str("content")
if content == "" {
return common.FlagErrorf("--content is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required").WithParam("--content")
}
if err := validate.RejectControlChars(content, "content"); err != nil {
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
return err
}
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return common.FlagErrorf("--content must be valid ContentBlock JSON: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
if v := runtime.Str("progress-percent"); v != "" {
percent, err := strconv.ParseFloat(v, 64)
if err != nil || math.IsNaN(percent) || math.IsInf(percent, 0) || percent < -99999999999 || percent > 99999999999 {
return common.FlagErrorf("--progress-percent must be a number between -99999999999 and 99999999999")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-percent must be a number between -99999999999 and 99999999999").WithParam("--progress-percent")
}
}
if v := runtime.Str("progress-status"); v != "" {
if _, ok := ParseProgressStatus(v); !ok {
return common.FlagErrorf("--progress-status must be one of: normal | overdue | done")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-status must be one of: normal | overdue | done").WithParam("--progress-status")
}
if v := runtime.Str("progress-percent"); v == "" {
return common.FlagErrorf("--progress-percent must provided with --progress-status")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-percent must provided with --progress-status").WithParam("--progress-percent")
}
}
idType := runtime.Str("user-id-type")
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
return common.FlagErrorf("--user-id-type must be one of: open_id | union_id | user_id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
}
return nil
},
@@ -146,11 +145,10 @@ var OKRUpdateProgressRecord = common.Shortcut{
body["progress_rate"] = p.ProgressRate
}
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", p.UserIDType)
queryParams := map[string]interface{}{"user_id_type": p.UserIDType}
path := fmt.Sprintf("/open-apis/okr/v1/progress_records/%s", p.ProgressID)
data, err := runtime.DoAPIJSON("PUT", path, queryParams, body)
data, err := runtime.CallAPITyped("PUT", path, queryParams, body)
if err != nil {
return err
}

View File

@@ -13,9 +13,8 @@ import (
"strings"
"time"
"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"
)
func inferTaskMemberType(id string) string {
@@ -107,7 +106,7 @@ func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}
// Handle generic JSON payload if provided
if dataStr := runtime.Str("data"); dataStr != "" {
if err := json.Unmarshal([]byte(dataStr), &body); err != nil {
return nil, output.ErrValidation("--data must be a valid JSON object: %v", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be a valid JSON object: %v", err).WithParam("--data")
}
}
@@ -143,7 +142,7 @@ func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}
if dueStr := runtime.Str("due"); dueStr != "" {
dueObj, err := parseTaskTime(dueStr)
if err != nil {
return nil, output.ErrValidation("failed to parse due time: %v", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to parse due time: %v", err).WithParam("--due")
}
body["due"] = dueObj
}
@@ -154,7 +153,7 @@ func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}
summary, _ := body["summary"].(string)
if strings.TrimSpace(summary) == "" {
return nil, output.ErrValidation("task summary is required")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "task summary is required").WithParam("--summary")
}
return body, nil
@@ -194,27 +193,11 @@ var CreateTask = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildTaskCreateBody(runtime)
if err != nil {
return WrapTaskError(ErrCodeTaskInvalidParams, err.Error(), "create task")
return err
}
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks",
QueryParams: queryParams,
Body: body,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return output.Errorf(output.ExitAPI, "api_error", "failed to parse response: %v", parseErr)
}
}
data, err := HandleTaskApiResult(result, err, "create task")
params := map[string]interface{}{"user_id_type": "open_id"}
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks", params, body)
if err != nil {
return err
}

View File

@@ -5,15 +5,13 @@ package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -35,7 +33,7 @@ var AssignTask = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Str("add") == "" && runtime.Str("remove") == "" {
return WrapTaskError(ErrCodeTaskInvalidParams, "must specify either --add or --remove", "validate assign")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "must specify either --add or --remove")
}
return nil
},
@@ -62,28 +60,13 @@ var AssignTask = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskId := url.PathEscape(runtime.Str("task-id"))
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
params := map[string]interface{}{"user_id_type": "open_id"}
var lastData map[string]interface{}
if addStr := runtime.Str("add"); addStr != "" {
body := buildMembersBody(addStr, "assignee", runtime.Str("idempotency-key"))
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/add_members",
QueryParams: queryParams,
Body: body,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse add members")
}
}
data, err := HandleTaskApiResult(result, err, "add task members")
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/add_members", params, body)
if err != nil {
return err
}
@@ -92,21 +75,7 @@ var AssignTask = common.Shortcut{
if removeStr := runtime.Str("remove"); removeStr != "" {
body := buildMembersBody(removeStr, "assignee", "")
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/remove_members",
QueryParams: queryParams,
Body: body,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse remove members")
}
}
data, err := HandleTaskApiResult(result, err, "remove task members")
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/remove_members", params, body)
if err != nil {
return err
}

View File

@@ -4,14 +4,100 @@
package task
import (
"errors"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/smartystreets/goconvey/convey"
)
// TestAssignTask_RequiresAddOrRemove covers the Validate guard: neither --add
// nor --remove yields a typed validation error (exit 2) before any API call.
func TestAssignTask_RequiresAddOrRemove(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
s := AssignTask
args := []string{"+assign", "--task-id", "task-1", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T, want *errs.ValidationError; err = %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
}
// TestAssignTask_MalformedResponse covers the Execute parse-response arm: a
// 200 with an unparseable body surfaces a typed internal invalid_response
// error (exit 5).
func TestAssignTask_MalformedResponse(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-1/add_members",
Status: 200,
RawBody: []byte("{not-json"),
})
s := AssignTask
args := []string{"+assign", "--task-id", "task-1", "--add", "ou_user_1", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("err = %T, want *errs.InternalError; err = %v", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
}
}
// TestAssignTask_MalformedResponse_RemoveArm covers the Execute remove-members
// parse arm: with only --remove set, the add arm is skipped and the
// remove_members POST returns a 200 with an unparseable body, which must
// surface a typed internal invalid_response error (exit 5).
func TestAssignTask_MalformedResponse_RemoveArm(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-1/remove_members",
Status: 200,
RawBody: []byte("{not-json"),
})
s := AssignTask
args := []string{"+assign", "--task-id", "task-1", "--remove", "ou_user_1", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("err = %T, want *errs.InternalError; err = %v", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
}
}
func TestBuildMembersBody(t *testing.T) {
convey.Convey("Build with ids and token", t, func() {
body := buildMembersBody("u1, u2 , ", "assignee", "token1")

View File

@@ -5,8 +5,10 @@ package task
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
@@ -19,33 +21,25 @@ func TestBuildTaskCreateBody_StructuredErrors(t *testing.T) {
data string
summary string
due string
wantCode int
wantType string
wantSubstr string
}{
{
name: "invalid JSON data returns ErrValidation",
name: "invalid JSON data returns validation error",
data: "not-json",
summary: "test",
wantCode: output.ExitValidation,
wantType: "validation",
wantSubstr: "--data must be a valid JSON object",
},
{
name: "missing summary returns ErrValidation",
name: "missing summary returns validation error",
data: "",
summary: "",
wantCode: output.ExitValidation,
wantType: "validation",
wantSubstr: "task summary is required",
},
{
name: "invalid due time returns ErrValidation",
name: "invalid due time returns validation error",
data: "",
summary: "test task",
due: "not-a-valid-time",
wantCode: output.ExitValidation,
wantType: "validation",
wantSubstr: "failed to parse due time",
},
}
@@ -68,18 +62,22 @@ func TestBuildTaskCreateBody_StructuredErrors(t *testing.T) {
t.Fatal("expected error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError; error = %v", err, err)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
}
if exitErr.Code != tt.wantCode {
t.Errorf("exit code = %d, want %d", exitErr.Code, tt.wantCode)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf(%T) returned !ok", err)
}
if exitErr.Detail == nil {
t.Fatal("expected non-nil error detail")
if p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
if exitErr.Detail.Type != tt.wantType {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, tt.wantType)
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), tt.wantSubstr) {
t.Errorf("message = %q, want substring %q", err.Error(), tt.wantSubstr)
}
})
}
@@ -91,35 +89,27 @@ func TestBuildTaskUpdateBody_StructuredErrors(t *testing.T) {
data string
summary string
due string
wantCode int
wantType string
wantSubstr string
}{
{
name: "invalid JSON data returns ErrValidation",
name: "invalid JSON data returns validation error",
data: "not-json",
summary: "",
due: "",
wantCode: output.ExitValidation,
wantType: "validation",
wantSubstr: "--data must be a valid JSON object",
},
{
name: "no fields to update returns ErrValidation",
name: "no fields to update returns validation error",
data: "",
summary: "",
due: "",
wantCode: output.ExitValidation,
wantType: "validation",
wantSubstr: "no fields to update",
},
{
name: "invalid due time returns ErrValidation",
name: "invalid due time returns validation error",
data: "",
summary: "",
due: "not-a-valid-time",
wantCode: output.ExitValidation,
wantType: "validation",
wantSubstr: "failed to parse due time",
},
}
@@ -138,18 +128,22 @@ func TestBuildTaskUpdateBody_StructuredErrors(t *testing.T) {
t.Fatal("expected error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError; error = %v", err, err)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
}
if exitErr.Code != tt.wantCode {
t.Errorf("exit code = %d, want %d", exitErr.Code, tt.wantCode)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf(%T) returned !ok", err)
}
if exitErr.Detail == nil {
t.Fatal("expected non-nil error detail")
if p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
if exitErr.Detail.Type != tt.wantType {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, tt.wantType)
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), tt.wantSubstr) {
t.Errorf("message = %q, want substring %q", err.Error(), tt.wantSubstr)
}
})
}

View File

@@ -5,13 +5,10 @@ package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -48,24 +45,9 @@ var CommentTask = common.Shortcut{
"resource_type": "task",
}
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
params := map[string]interface{}{"user_id_type": "open_id"}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/comments",
QueryParams: queryParams,
Body: body,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse comment response")
}
}
data, err := HandleTaskApiResult(result, err, "add task comment")
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/comments", params, body)
if err != nil {
return err
}

View File

@@ -5,15 +5,12 @@ package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -47,28 +44,14 @@ var CompleteTask = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskId := url.PathEscape(runtime.Str("task-id"))
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
params := map[string]interface{}{"user_id_type": "open_id"}
var data map[string]interface{}
// 1. Get current task status
getResp, getErr := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/task/v2/tasks/" + taskId,
QueryParams: queryParams,
})
var getResult map[string]interface{}
if getErr == nil {
if parseErr := json.Unmarshal(getResp.RawBody, &getResult); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse get response: %v", parseErr), "parse get response")
}
}
getData, getErr := HandleTaskApiResult(getResult, getErr, "get task")
if getErr != nil {
return getErr
getData, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+taskId, params, nil)
if err != nil {
return err
}
taskData, _ := getData["task"].(map[string]interface{})
@@ -80,21 +63,7 @@ var CompleteTask = common.Shortcut{
} else {
// 3. Complete the task
body := buildCompleteBody()
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPatch,
ApiPath: "/open-apis/task/v2/tasks/" + taskId,
QueryParams: queryParams,
Body: body,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse complete response")
}
}
data, err = HandleTaskApiResult(result, err, "complete task")
data, err = callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskId, params, body)
if err != nil {
return err
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"errors"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
)
// wrapTaskNetworkErr 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 wrapTaskNetworkErr(err error, format string, args ...any) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
}
// taskInputStatError maps a FileIO.Stat/Open error for input file validation
// to a typed validation error:
// - Path validation failures → "unsafe file path: ..."
// - Other errors → readMsg prefix (default "cannot read file")
//
// param names the input flag/path field that failed (for example "--file").
// Pass an optional readMsg to override the non-path-validation message prefix,
// mirroring the shared input-stat helper so call-site context is preserved.
func taskInputStatError(err error, param string, readMsg ...string) error {
if err == nil {
return nil
}
if errors.Is(err, fileio.ErrPathValidation) {
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).WithCause(err)
if param != "" {
validationErr = validationErr.WithParam(param)
}
return validationErr
}
msg := "cannot read file"
if len(readMsg) > 0 && readMsg[0] != "" {
msg = readMsg[0]
}
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %s", msg, err).WithCause(err)
if param != "" {
validationErr = validationErr.WithParam(param)
}
return validationErr
}

View File

@@ -0,0 +1,103 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
)
func TestTaskInputStatError(t *testing.T) {
t.Run("nil error returns nil", func(t *testing.T) {
if err := taskInputStatError(nil, "--file"); err != nil {
t.Errorf("taskInputStatError(nil) = %v, want nil", err)
}
})
t.Run("path validation failure maps to unsafe file path", func(t *testing.T) {
err := taskInputStatError(fmt.Errorf("bad: %w", fileio.ErrPathValidation), "--file")
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T, want *errs.ValidationError", err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("exit = %d, want %d", output.ExitCodeOf(err), output.ExitValidation)
}
if !strings.Contains(err.Error(), "unsafe file path") {
t.Errorf("message = %q, want 'unsafe file path'", err.Error())
}
if ve.Param != "--file" {
t.Errorf("param = %q, want --file", ve.Param)
}
})
t.Run("generic error uses readMsg prefix", func(t *testing.T) {
err := taskInputStatError(errors.New("permission denied"), "--file", "cannot access file")
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T, want *errs.ValidationError", err)
}
if !strings.Contains(err.Error(), "cannot access file") {
t.Errorf("message = %q, want 'cannot access file' prefix", err.Error())
}
if ve.Param != "--file" {
t.Errorf("param = %q, want --file", ve.Param)
}
})
t.Run("default prefix when no readMsg", func(t *testing.T) {
err := taskInputStatError(errors.New("boom"), "--file")
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T, want *errs.ValidationError", err)
}
if !strings.Contains(err.Error(), "cannot read file") {
t.Errorf("message = %q, want default 'cannot read file'", err.Error())
}
if ve.Param != "--file" {
t.Errorf("param = %q, want --file", ve.Param)
}
})
}
func TestWrapTaskNetworkErr(t *testing.T) {
// wrapTaskNetworkErr is only ever called inside an `if err != nil` guard
// (DoAPIStream failure), mirroring drive's wrapDriveNetworkErr, so it does
// not special-case a nil cause.
t.Run("untyped cause becomes typed network error wrapping the cause", func(t *testing.T) {
cause := errors.New("dial timeout")
err := wrapTaskNetworkErr(cause, "upload failed")
var ne *errs.NetworkError
if !errors.As(err, &ne) {
t.Fatalf("err = %T, want *errs.NetworkError", err)
}
if ne.Subtype != errs.SubtypeNetworkTransport {
t.Errorf("subtype = %q, want %q", ne.Subtype, errs.SubtypeNetworkTransport)
}
if !errors.Is(err, cause) {
t.Error("expected the original cause to be wrapped (errors.Is)")
}
})
t.Run("already-typed cause is passed through unchanged", func(t *testing.T) {
typed := errs.NewAPIError(errs.SubtypeNotFound, "missing")
err := wrapTaskNetworkErr(typed, "upload failed")
var ae *errs.APIError
if !errors.As(err, &ae) {
t.Fatalf("err = %T, want the original *errs.APIError passed through", err)
}
if ae.Subtype != errs.SubtypeNotFound {
t.Errorf("subtype = %q, want %q (not re-wrapped as network)", ae.Subtype, errs.SubtypeNotFound)
}
})
}

View File

@@ -5,15 +5,13 @@ package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -35,7 +33,7 @@ var FollowersTask = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Str("add") == "" && runtime.Str("remove") == "" {
return WrapTaskError(ErrCodeTaskInvalidParams, "must specify either --add or --remove", "validate followers")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "must specify either --add or --remove")
}
return nil
},
@@ -63,28 +61,13 @@ var FollowersTask = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskId := url.PathEscape(runtime.Str("task-id"))
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
params := map[string]interface{}{"user_id_type": "open_id"}
var lastData map[string]interface{}
if addStr := runtime.Str("add"); addStr != "" {
body := buildFollowersBody(addStr, runtime.Str("idempotency-key"))
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/add_members",
QueryParams: queryParams,
Body: body,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse add followers")
}
}
data, err := HandleTaskApiResult(result, err, "add task followers")
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/add_members", params, body)
if err != nil {
return err
}
@@ -93,21 +76,7 @@ var FollowersTask = common.Shortcut{
if removeStr := runtime.Str("remove"); removeStr != "" {
body := buildFollowersBody(removeStr, "")
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/remove_members",
QueryParams: queryParams,
Body: body,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse remove followers")
}
}
data, err := HandleTaskApiResult(result, err, "remove task followers")
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/remove_members", params, body)
if err != nil {
return err
}

View File

@@ -4,11 +4,36 @@
package task
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/smartystreets/goconvey/convey"
)
// TestFollowersTask_RequiresAddOrRemove covers the Validate guard: neither
// --add nor --remove yields a typed validation error (exit 2) before any API
// call.
func TestFollowersTask_RequiresAddOrRemove(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
s := FollowersTask
args := []string{"+followers", "--task-id", "task-1", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T, want *errs.ValidationError; err = %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
}
func TestBuildFollowersBody(t *testing.T) {
convey.Convey("Build with ids and token", t, func() {
body := buildFollowersBody("u1, u2 , ", "token1")

View File

@@ -5,15 +5,14 @@ package task
import (
"context"
"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/shortcuts/common"
)
@@ -59,19 +58,20 @@ var GetMyTasks = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
startTime := time.Now()
queryParams := make(larkcore.QueryParams)
queryParams.Set("type", "my_tasks")
queryParams.Set("user_id_type", "open_id")
queryParams.Set("page_size", "50")
params := map[string]interface{}{
"type": "my_tasks",
"user_id_type": "open_id",
"page_size": 50,
}
if runtime.Cmd.Flags().Changed("complete") {
if runtime.Bool("complete") {
queryParams.Set("completed", "true")
params["completed"] = "true"
} else {
queryParams.Set("completed", "false")
params["completed"] = "false"
}
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
queryParams.Set("page_token", pageToken)
params["page_token"] = pageToken
}
// parse time flags to ms timestamp if provided
@@ -79,7 +79,7 @@ var GetMyTasks = common.Shortcut{
if createdStr := runtime.Str("created_at"); createdStr != "" {
tStr, err := parseTimeFlagSec(createdStr, "start")
if err != nil {
return WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid created_at: %v", err), "parse created_at")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid created_at: %v", err).WithParam("--created_at")
}
createdAfterMs, _ = strconv.ParseInt(tStr, 10, 64)
createdAfterMs *= 1000 // Convert sec to ms
@@ -88,7 +88,7 @@ var GetMyTasks = common.Shortcut{
if dueStartStr := runtime.Str("due-start"); dueStartStr != "" {
tStr, err := parseTimeFlagSec(dueStartStr, "start")
if err != nil {
return WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid due-start: %v", err), "parse due-start")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid due-start: %v", err).WithParam("--due-start")
}
dueStartMs, _ = strconv.ParseInt(tStr, 10, 64)
dueStartMs *= 1000
@@ -97,7 +97,7 @@ var GetMyTasks = common.Shortcut{
if dueEndStr := runtime.Str("due-end"); dueEndStr != "" {
tStr, err := parseTimeFlagSec(dueEndStr, "end")
if err != nil {
return WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid due-end: %v", err), "parse due-end")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid due-end: %v", err).WithParam("--due-end")
}
dueEndMs, _ = strconv.ParseInt(tStr, 10, 64)
dueEndMs *= 1000
@@ -114,22 +114,7 @@ var GetMyTasks = common.Shortcut{
for {
pageCount++
apiReq := &larkcore.ApiReq{
HttpMethod: "GET",
ApiPath: "/open-apis/task/v2/tasks",
QueryParams: queryParams,
}
apiResp, err := runtime.DoAPI(apiReq)
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse my tasks")
}
}
data, err := HandleTaskApiResult(result, err, "list tasks")
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks", params, nil)
if err != nil {
return err
}
@@ -150,7 +135,7 @@ var GetMyTasks = common.Shortcut{
}
// Set page_token for next iteration
queryParams.Set("page_token", lastPageToken)
params["page_token"] = lastPageToken
}
var filteredItems []map[string]interface{}

View File

@@ -4,12 +4,15 @@
package task
import (
"errors"
"strconv"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestGetMyTasks_LocalTimeFormatting(t *testing.T) {
@@ -106,3 +109,50 @@ func TestGetMyTasks_LocalTimeFormatting(t *testing.T) {
})
}
}
// TestGetMyTasks_InvalidTimeFlags locks the three time-flag validation arms in
// Execute (--created_at / --due-start / --due-end). The parse runs before any
// API call, so a malformed value deterministically surfaces a typed
// *errs.ValidationError (exit 2) regardless of credentials — the command runs
// as user with a throwaway token. Each error carries the corresponding --flag
// param so the caller can point at the offending input.
func TestGetMyTasks_InvalidTimeFlags(t *testing.T) {
tests := []struct {
name string
flag string
wantParam string
}{
{name: "created_at", flag: "--created_at", wantParam: "--created_at"},
{name: "due-start", flag: "--due-start", wantParam: "--due-start"},
{name: "due-end", flag: "--due-end", wantParam: "--due-end"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
s := GetMyTasks
s.AuthTypes = []string{"bot", "user"}
args := []string{"+get-my-tasks", tt.flag, "not-a-time", "--as", "user"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
if err == nil {
t.Fatalf("expected validation error for %s, got nil", tt.flag)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
if ve.Param != tt.wantParam {
t.Errorf("param = %q, want %q", ve.Param, tt.wantParam)
}
})
}
}

View File

@@ -5,14 +5,11 @@ package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -55,14 +52,15 @@ var GetRelatedTasks = common.Shortcut{
Params(params)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
queryParams.Set("page_size", fmt.Sprintf("%d", relatedTasksPageSize))
params := map[string]interface{}{
"user_id_type": "open_id",
"page_size": relatedTasksPageSize,
}
if runtime.Cmd.Flags().Changed("include-complete") && !runtime.Bool("include-complete") {
queryParams.Set("completed", "false")
params["completed"] = "false"
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
queryParams.Set("page_token", pageToken)
params["page_token"] = pageToken
}
pageLimit := runtime.Int("page-limit")
@@ -80,18 +78,7 @@ var GetRelatedTasks = common.Shortcut{
var lastPageToken string
var lastHasMore bool
for page := 0; page < pageLimit; page++ {
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/task/v2/task_v2/list_related_task",
QueryParams: queryParams,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse related tasks")
}
}
data, err := HandleTaskApiResult(result, err, "list related tasks")
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/task_v2/list_related_task", params, nil)
if err != nil {
return err
}
@@ -103,7 +90,7 @@ var GetRelatedTasks = common.Shortcut{
if !lastHasMore || lastPageToken == "" {
break
}
queryParams.Set("page_token", lastPageToken)
params["page_token"] = lastPageToken
}
userOpenID := runtime.UserOpenId()

View File

@@ -9,7 +9,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
)
func splitAndTrimCSV(input string) []string {
@@ -46,7 +46,7 @@ func parseTimeRangeMillis(input string) (string, string, error) {
}
startSecInt, err = strconv.ParseInt(startSec, 10, 64)
if err != nil {
return "", "", output.ErrValidation("invalid start timestamp: %v", err)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start timestamp: %v", err)
}
hasStart = true
startMillis = startSec + "000"
@@ -58,13 +58,13 @@ func parseTimeRangeMillis(input string) (string, string, error) {
}
endSecInt, err = strconv.ParseInt(endSec, 10, 64)
if err != nil {
return "", "", output.ErrValidation("invalid end timestamp: %v", err)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end timestamp: %v", err)
}
hasEnd = true
endMillis = endSec + "000"
}
if hasStart && hasEnd && startSecInt > endSecInt {
return "", "", output.ErrValidation("start time must be earlier than or equal to end time")
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "start time must be earlier than or equal to end time")
}
return startMillis, endMillis, nil
}
@@ -91,7 +91,7 @@ func parseTimeRangeRFC3339(input string) (string, string, error) {
}
startSecInt, err = strconv.ParseInt(startSec, 10, 64)
if err != nil {
return "", "", output.ErrValidation("invalid start timestamp: %v", err)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start timestamp: %v", err)
}
hasStart = true
startTime = time.Unix(startSecInt, 0).Local().Format(time.RFC3339)
@@ -103,13 +103,13 @@ func parseTimeRangeRFC3339(input string) (string, string, error) {
}
endSecInt, err = strconv.ParseInt(endSec, 10, 64)
if err != nil {
return "", "", output.ErrValidation("invalid end timestamp: %v", err)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end timestamp: %v", err)
}
hasEnd = true
endTime = time.Unix(endSecInt, 0).Local().Format(time.RFC3339)
}
if hasStart && hasEnd && startSecInt > endSecInt {
return "", "", output.ErrValidation("start time must be earlier than or equal to end time")
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "start time must be earlier than or equal to end time")
}
return startTime, endTime, nil
}
@@ -220,7 +220,7 @@ func requireSearchFilter(query string, filter map[string]interface{}, action str
if len(filter) > 0 {
return nil
}
return WrapTaskError(ErrCodeTaskInvalidParams, "query is empty and no filter is provided", action)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: query is empty and no filter is provided", action)
}
func renderRelatedTasksPretty(items []map[string]interface{}, hasMore bool, pageToken string) string {

View File

@@ -8,6 +8,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
@@ -88,6 +89,7 @@ func TestParseTimeRangeMillisAndRequireSearchFilter(t *testing.T) {
}{
{name: "empty input", input: "", wantStart: "", wantEnd: ""},
{name: "invalid input", input: "bad-time", wantErr: true},
{name: "invalid end input", input: "-1d,bad-time", wantErr: true},
{name: "range input", input: "-1d,+1d", wantStart: "non-empty", wantEnd: "non-empty"},
{name: "reversed range fails fast", input: "+1d,-1d", wantErr: true},
}
@@ -99,15 +101,16 @@ func TestParseTimeRangeMillisAndRequireSearchFilter(t *testing.T) {
t.Fatalf("parseTimeRangeMillis(%q) expected error, got nil", tt.input)
}
if tt.name == "reversed range fails fast" {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError; error = %v", err, err)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "validation" {
t.Errorf("error detail type = %q, want %q", exitErr.Detail.Type, "validation")
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
}
return
@@ -264,6 +267,7 @@ func TestRenderRelatedTasksPretty(t *testing.T) {
}{
{name: "empty input", input: "", wantStart: "", wantEnd: ""},
{name: "invalid input", input: "bad-time", wantErr: true},
{name: "invalid end input", input: "-1d,bad-time", wantErr: true},
{name: "range input", input: "-1d,+1d", wantStart: "rfc3339", wantEnd: "rfc3339"},
{name: "reversed range fails fast", input: "+1d,-1d", wantErr: true},
}
@@ -276,12 +280,16 @@ func TestRenderRelatedTasksPretty(t *testing.T) {
t.Fatal("expected error, got nil")
}
if tt.name == "reversed range fails fast" {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError; error = %v", err, err)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
}
return

View File

@@ -5,7 +5,6 @@ package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
@@ -13,8 +12,7 @@ import (
"strconv"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -35,10 +33,10 @@ var ReminderTask = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Str("set") == "" && !runtime.Bool("remove") {
return WrapTaskError(ErrCodeTaskInvalidParams, "must specify either --set or --remove", "validate reminder")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "must specify either --set or --remove")
}
if runtime.Str("set") != "" && runtime.Bool("remove") {
return WrapTaskError(ErrCodeTaskInvalidParams, "cannot specify both --set and --remove", "validate reminder")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot specify both --set and --remove")
}
return nil
},
@@ -66,24 +64,10 @@ var ReminderTask = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskId := url.PathEscape(runtime.Str("task-id"))
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
params := map[string]interface{}{"user_id_type": "open_id"}
// First, get the task to find existing reminders
getResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/task/v2/tasks/" + taskId,
QueryParams: queryParams,
})
var getResult map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(getResp.RawBody, &getResult); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse task details: %v", parseErr), "parse task details")
}
}
data, err := HandleTaskApiResult(getResult, err, "get task reminders")
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+taskId, params, nil)
if err != nil {
return err
}
@@ -112,21 +96,7 @@ var ReminderTask = common.Shortcut{
body := map[string]interface{}{
"reminder_ids": reminderIds,
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/remove_reminders",
QueryParams: queryParams,
Body: body,
})
var removeResult map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &removeResult); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse remove response")
}
}
if _, err := HandleTaskApiResult(removeResult, err, "remove task reminders"); err != nil {
if _, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/remove_reminders", params, body); err != nil {
return err
}
}
@@ -155,7 +125,7 @@ var ReminderTask = common.Shortcut{
}
if parseErr != nil {
return WrapTaskError(ErrCodeTaskInvalidParams, parseErr.Error(), "set reminder")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", parseErr)
}
// If any reminders exist, remove them first
@@ -173,21 +143,7 @@ var ReminderTask = common.Shortcut{
body := map[string]interface{}{
"reminder_ids": reminderIds,
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/remove_reminders",
QueryParams: queryParams,
Body: body,
})
var removeResult map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &removeResult); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse remove response")
}
}
if _, err := HandleTaskApiResult(removeResult, err, "remove existing task reminders before setting new one"); err != nil {
if _, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/remove_reminders", params, body); err != nil {
return err
}
}
@@ -200,21 +156,7 @@ var ReminderTask = common.Shortcut{
},
},
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/add_reminders",
QueryParams: queryParams,
Body: body,
})
var addResult map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &addResult); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse add response")
}
}
if _, err := HandleTaskApiResult(addResult, err, "add task reminder"); err != nil {
if _, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/add_reminders", params, body); err != nil {
return err
}
}

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
// TestReminderTask_RequiresSetOrRemove covers the first Validate guard: neither
// --set nor --remove yields a typed validation error (exit 2) before any API
// call.
func TestReminderTask_RequiresSetOrRemove(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
s := ReminderTask
args := []string{"+reminder", "--task-id", "task-1", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T, want *errs.ValidationError; err = %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
}
// TestReminderTask_CannotSpecifyBoth covers the second Validate guard: passing
// both --set and --remove yields a typed validation error (exit 2).
func TestReminderTask_CannotSpecifyBoth(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
s := ReminderTask
args := []string{"+reminder", "--task-id", "task-1", "--set", "15m", "--remove", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T, want *errs.ValidationError; err = %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
}

View File

@@ -5,14 +5,11 @@ package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -42,24 +39,8 @@ var ReopenTask = common.Shortcut{
taskId := url.PathEscape(runtime.Str("task-id"))
body := buildReopenBody()
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPatch,
ApiPath: "/open-apis/task/v2/tasks/" + taskId,
QueryParams: queryParams,
Body: body,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse reopen response")
}
}
data, err := HandleTaskApiResult(result, err, "reopen task")
params := map[string]interface{}{"user_id_type": "open_id"}
data, err := callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskId, params, body)
if err != nil {
return err
}

View File

@@ -5,14 +5,12 @@ package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
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"
)
@@ -77,18 +75,7 @@ var SearchTask = common.Shortcut{
var lastHasMore bool
currentBody := body
for page := 0; page < pageLimit; page++ {
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/search",
Body: currentBody,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse task search")
}
}
data, err := HandleTaskApiResult(result, err, "search tasks")
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/search", nil, currentBody)
if err != nil {
return err
}
@@ -173,7 +160,7 @@ func buildTaskSearchBody(runtime *common.RuntimeContext) (map[string]interface{}
if dueRange := runtime.Str("due"); dueRange != "" {
start, end, err := parseTimeRangeRFC3339(dueRange)
if err != nil {
return nil, WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid due: %v", err), "build task search")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid due: %v", err).WithParam("--due")
}
if dueFilter := buildTimeRangeFilter("due_time", start, end); dueFilter != nil {
mergeIntoFilter(filter, dueFilter)
@@ -196,27 +183,15 @@ func buildTaskSearchBody(runtime *common.RuntimeContext) (map[string]interface{}
}
func getTaskDetail(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
params := map[string]interface{}{"user_id_type": "open_id"}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/task/v2/tasks/" + url.PathEscape(taskID),
QueryParams: queryParams,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return nil, WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse task detail response: %v", parseErr), "parse task detail")
}
}
data, err := HandleTaskApiResult(result, err, "get task detail "+taskID)
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+url.PathEscape(taskID), params, nil)
if err != nil {
return nil, err
}
task, _ := data["task"].(map[string]interface{})
if task == nil {
return nil, WrapTaskError(ErrCodeTaskInternalError, "task detail response missing task object", "get task detail")
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "task detail response missing task object")
}
return task, nil
}

View File

@@ -4,12 +4,17 @@
package task
import (
"context"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -298,3 +303,129 @@ func TestSearchTask_Execute(t *testing.T) {
})
}
}
// TestSearchTask_InvalidDue_Validation drives the --due validation arm through
// the mounted command. buildTaskSearchBody runs before any API call, so a
// malformed range deterministically surfaces a typed *errs.ValidationError
// (invalid_argument, exit 2) carrying the --due param.
func TestSearchTask_InvalidDue_Validation(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
s := SearchTask
s.AuthTypes = []string{"bot", "user"}
args := []string{"+search", "--query", "release", "--due", "not-a-time", "--as", "user"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
if err == nil {
t.Fatal("expected validation error for malformed --due, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
if ve.Param != "--due" {
t.Errorf("param = %q, want %q", ve.Param, "--due")
}
}
// TestSearchTask_MalformedSearchResponse covers the search raw-body parse arm:
// the SDK returns a 200 with a non-JSON body and nil error, so the shortcut's
// own json.Unmarshal fails and must surface a typed *errs.InternalError
// (invalid_response, exit 5).
func TestSearchTask_MalformedSearchResponse(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/search",
RawBody: []byte("{not-json"),
})
s := SearchTask
s.AuthTypes = []string{"bot", "user"}
args := []string{"+search", "--query", "release", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
if err == nil {
t.Fatal("expected internal error for malformed response, got nil")
}
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("error type = %T, want *errs.InternalError; error = %v", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
}
}
// TestGetTaskDetail_MalformedResponse exercises getTaskDetail directly. In the
// +search Execute loop a detail-fetch error is intentionally swallowed (the hit
// falls back to its app_link), so the only way to lock the helper's two
// internal arms — a non-JSON body and a code-0 response missing the task object
// — is to call it directly. Both must surface a typed *errs.InternalError
// (invalid_response, exit 5).
func TestGetTaskDetail_MalformedResponse(t *testing.T) {
tests := []struct {
name string
stub *httpmock.Stub
}{
{
name: "body not json",
stub: &httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks/task-123",
RawBody: []byte("{not-json"),
},
},
{
name: "missing task object",
stub: &httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks/task-123",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, _, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(tt.stub)
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, taskTestConfig(t), f, core.AsBot)
_, err := getTaskDetail(runtime, "task-123")
if err == nil {
t.Fatal("expected internal error, got nil")
}
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("error type = %T, want *errs.InternalError; error = %v", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
}
})
}
}

View File

@@ -5,14 +5,11 @@ package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -37,22 +34,9 @@ var SetAncestorTask = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskID := runtime.Str("task-id")
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
params := map[string]interface{}{"user_id_type": "open_id"}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/" + url.PathEscape(taskID) + "/set_ancestor_task",
QueryParams: queryParams,
Body: buildSetAncestorBody(runtime.Str("ancestor-id")),
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "set ancestor task")
}
}
if _, err = HandleTaskApiResult(result, err, "set ancestor task"); err != nil {
if _, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+url.PathEscape(taskID)+"/set_ancestor_task", params, buildSetAncestorBody(runtime.Str("ancestor-id"))); err != nil {
return err
}

View File

@@ -5,13 +5,10 @@ package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -29,23 +26,8 @@ var SubscribeTaskEvent = common.Shortcut{
Params(map[string]interface{}{"user_id_type": "open_id"})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/task_v2/task_subscription",
QueryParams: queryParams,
})
// DoAPI may return HTTP 200 while the JSON body contains a non-zero business "code".
// Parse and validate the envelope to avoid false-success output.
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "subscribe task events")
}
}
if _, err := HandleTaskApiResult(result, err, "subscribe task events"); err != nil {
params := map[string]interface{}{"user_id_type": "open_id"}
if _, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/task_v2/task_subscription", params, nil); err != nil {
return err
}

View File

@@ -4,12 +4,15 @@
package task
import (
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -129,3 +132,32 @@ func TestSubscribeTaskEvent(t *testing.T) {
})
}
}
// TestSubscribeTaskEvent_MalformedResponse covers the parse-response arm: a 200
// with an unparseable body surfaces a typed internal invalid_response error
// (exit 5).
func TestSubscribeTaskEvent_MalformedResponse(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/task_v2/task_subscription",
Status: 200,
RawBody: []byte("{not-json"),
})
args := []string{"+subscribe-event", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, SubscribeTaskEvent, args, f, stdout)
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("err = %T, want *errs.InternalError; err = %v", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
}
}

View File

@@ -5,14 +5,12 @@ package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
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"
)
@@ -74,18 +72,7 @@ var SearchTasklist = common.Shortcut{
var lastHasMore bool
currentBody := body
for page := 0; page < pageLimit; page++ {
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasklists/search",
Body: currentBody,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse tasklist search")
}
}
data, err := HandleTaskApiResult(result, err, "search tasklists")
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/search", nil, currentBody)
if err != nil {
return err
}
@@ -160,7 +147,7 @@ func buildTasklistSearchBody(runtime *common.RuntimeContext) (map[string]interfa
if createTime := runtime.Str("create-time"); createTime != "" {
start, end, err := parseTimeRangeRFC3339(createTime)
if err != nil {
return nil, WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid create-time: %v", err), "build tasklist search")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid create-time: %v", err).WithParam("--create-time")
}
if timeFilter := buildTimeRangeFilter("create_time", start, end); timeFilter != nil {
mergeIntoFilter(filter, timeFilter)
@@ -183,27 +170,15 @@ func buildTasklistSearchBody(runtime *common.RuntimeContext) (map[string]interfa
}
func getTasklistDetail(runtime *common.RuntimeContext, tasklistID string) (map[string]interface{}, error) {
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
params := map[string]interface{}{"user_id_type": "open_id"}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/task/v2/tasklists/" + url.PathEscape(tasklistID),
QueryParams: queryParams,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return nil, WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse tasklist detail response: %v", parseErr), "parse tasklist detail")
}
}
data, err := HandleTaskApiResult(result, err, "get tasklist detail "+tasklistID)
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasklists/"+url.PathEscape(tasklistID), params, nil)
if err != nil {
return nil, err
}
tasklist, _ := data["tasklist"].(map[string]interface{})
if tasklist == nil {
return nil, WrapTaskError(ErrCodeTaskInternalError, "tasklist detail response missing tasklist object", "get tasklist detail")
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "tasklist detail response missing tasklist object")
}
return tasklist, nil
}

View File

@@ -4,12 +4,15 @@
package task
import (
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -261,3 +264,35 @@ func TestSearchTasklist_Execute(t *testing.T) {
})
}
}
// TestSearchTasklist_MalformedResponse covers the search parse arm: a 200 with
// an unparseable search body surfaces a typed internal invalid_response error
// (exit 5). The detail parse arm is swallowed into the fallback path, so only
// the top-level search parse propagates.
func TestSearchTasklist_MalformedResponse(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/search",
Status: 200,
RawBody: []byte("{not-json"),
})
s := SearchTasklist
s.AuthTypes = []string{"bot", "user"}
args := []string{"+tasklist-search", "--query", "Q2", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("err = %T, want *errs.InternalError; err = %v", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
}
}

View File

@@ -12,8 +12,7 @@ import (
"net/url"
"strings"
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"
)
@@ -51,7 +50,9 @@ var UpdateTask = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildTaskUpdateBody(runtime)
if err != nil {
return WrapTaskError(ErrCodeTaskInvalidParams, err.Error(), "update task")
// buildTaskUpdateBody already returns a typed validation error;
// propagate it directly instead of re-wrapping as an API error.
return err
}
taskIds := strings.Split(runtime.Str("task-id"), ",")
@@ -63,24 +64,8 @@ var UpdateTask = common.Shortcut{
continue
}
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPatch,
ApiPath: "/open-apis/task/v2/tasks/" + url.PathEscape(taskId),
QueryParams: queryParams,
Body: body,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return output.Errorf(output.ExitAPI, "api_error", "failed to parse response for task %s: %v", taskId, parseErr)
}
}
data, err := HandleTaskApiResult(result, err, "update task "+taskId)
params := map[string]interface{}{"user_id_type": "open_id"}
data, err := callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+url.PathEscape(taskId), params, body)
if err != nil {
return err
}
@@ -133,7 +118,7 @@ func buildTaskUpdateBody(runtime *common.RuntimeContext) (map[string]interface{}
if dataStr := runtime.Str("data"); dataStr != "" {
if err := json.Unmarshal([]byte(dataStr), &taskObj); err != nil {
return nil, output.ErrValidation("--data must be a valid JSON object: %v", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be a valid JSON object: %v", err).WithParam("--data")
}
// If data is provided, assume keys are update fields
for k := range taskObj {
@@ -158,7 +143,7 @@ func buildTaskUpdateBody(runtime *common.RuntimeContext) (map[string]interface{}
if dueStr := runtime.Str("due"); dueStr != "" {
dueObj, err := parseTaskTime(dueStr)
if err != nil {
return nil, output.ErrValidation("failed to parse due time: %v", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to parse due time: %v", err).WithParam("--due")
}
taskObj["due"] = dueObj
if !contains(updateFields, "due") {
@@ -167,7 +152,7 @@ func buildTaskUpdateBody(runtime *common.RuntimeContext) (map[string]interface{}
}
if len(updateFields) == 0 {
return nil, output.ErrValidation("no fields to update")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no fields to update")
}
return map[string]interface{}{

View File

@@ -14,8 +14,8 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -91,20 +91,22 @@ var UploadAttachmentTask = common.Shortcut{
fio := runtime.FileIO()
if fio == nil {
return output.ErrValidation("file operations require a FileIO provider")
// A nil FileIO is a runtime wiring fault, not user input.
return errs.NewInternalError(errs.SubtypeUnknown, "file operations require a FileIO provider")
}
stat, err := fio.Stat(filePath)
if err != nil {
return common.WrapInputStatError(err, "file not found")
return taskInputStatError(err, "--file", "cannot access file")
}
if !stat.Mode().IsRegular() {
return output.ErrValidation("file must be a regular file: %s", filePath)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
}
if stat.Size() > taskAttachmentUploadMaxSize {
return output.ErrValidation(
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"attachment %s exceeds the 50MB per-file limit",
common.FormatSize(stat.Size()),
)
).WithParam("--file")
}
fileName := filepath.Base(filePath)
@@ -118,7 +120,7 @@ var UploadAttachmentTask = common.Shortcut{
f, err := fio.Open(filePath)
if err != nil {
return common.WrapInputStatError(err, "cannot open file")
return taskInputStatError(err, "--file", "cannot open file")
}
defer f.Close()
@@ -129,20 +131,20 @@ var UploadAttachmentTask = common.Shortcut{
var bodyBuf bytes.Buffer
mw := common.NewMultipartWriter(&bodyBuf)
if err := mw.WriteField("resource_type", resourceType); err != nil {
return output.Errorf(output.ExitInternal, "internal", "build multipart body: %s", err)
return errs.NewInternalError(errs.SubtypeFileIO, "build multipart body: %s", err)
}
if err := mw.WriteField("resource_id", resourceID); err != nil {
return output.Errorf(output.ExitInternal, "internal", "build multipart body: %s", err)
return errs.NewInternalError(errs.SubtypeFileIO, "build multipart body: %s", err)
}
filePart, err := mw.CreateFormFile("file", fileName)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "build multipart body: %s", err)
return errs.NewInternalError(errs.SubtypeFileIO, "build multipart body: %s", err)
}
if _, err := io.Copy(filePart, f); err != nil {
return output.Errorf(output.ExitInternal, "internal", "write file to multipart body: %s", err)
return errs.NewInternalError(errs.SubtypeFileIO, "write file to multipart body: %s", err)
}
if err := mw.Close(); err != nil {
return output.Errorf(output.ExitInternal, "internal", "finalize multipart body: %s", err)
return errs.NewInternalError(errs.SubtypeFileIO, "finalize multipart body: %s", err)
}
queryParams := make(larkcore.QueryParams)
@@ -167,7 +169,7 @@ var UploadAttachmentTask = common.Shortcut{
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut,
"[+upload-attachment] http response: error=%v\n", err)
return err
return wrapTaskNetworkErr(err, "upload attachment request failed")
}
defer httpResp.Body.Close()
@@ -175,21 +177,17 @@ var UploadAttachmentTask = common.Shortcut{
if readErr != nil {
fmt.Fprintf(runtime.IO().ErrOut,
"[+upload-attachment] http response: read_error=%v\n", readErr)
return WrapTaskError(ErrCodeTaskInternalError,
fmt.Sprintf("failed to read response: %v", readErr),
"upload task attachment")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to read response: %v", readErr)
}
var result map[string]interface{}
if parseErr := json.Unmarshal(rawBody, &result); parseErr != nil {
fmt.Fprintf(runtime.IO().ErrOut,
"[+upload-attachment] http response: parse_error=%v\n", parseErr)
return WrapTaskError(ErrCodeTaskInternalError,
fmt.Sprintf("failed to parse response: %v", parseErr),
"upload task attachment")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to parse response: %v", parseErr)
}
data, err := HandleTaskApiResult(result, nil, "upload task attachment")
data, err := HandleTaskApiResultWithContext(result, nil, "upload task attachment", runtime.APIClassifyContext())
if err != nil {
code, _ := result["code"]
msg, _ := result["msg"].(string)

View File

@@ -14,6 +14,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
@@ -246,12 +247,15 @@ func TestUploadAttachmentTask_SizeLimit(t *testing.T) {
if err == nil {
t.Fatal("expected error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T: %v", err, err)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), "50MB") {
t.Fatalf("error message should mention 50MB limit, got: %v", err)
@@ -274,12 +278,139 @@ func TestUploadAttachmentTask_FileMissing(t *testing.T) {
if err == nil {
t.Fatal("expected error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T: %v", err, err)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
}
if ve.Param != "--file" {
t.Fatalf("param = %q, want %q", ve.Param, "--file")
}
}
func TestUploadAttachmentTask_NotRegularFile(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
// A directory stats fine but is not a regular file; we must reject it as
// invalid --file input before any HTTP call (no stub registered).
if err := os.Mkdir("a-dir", 0o755); err != nil {
t.Fatalf("Mkdir error: %v", err)
}
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
"+upload-attachment",
"--resource-id", "task-guid-123",
"--file", "a-dir",
"--as", "bot",
"--format", "json",
}, f, stdout)
if err == nil {
t.Fatal("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.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != "--file" {
t.Fatalf("param = %q, want %q", ve.Param, "--file")
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), "regular file") {
t.Fatalf("error message should mention regular file, got: %v", err)
}
}
func TestUploadAttachmentTask_StatErrorMessage(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
// A missing path fails Stat → taskInputStatError surfaces "cannot access
// file" (no longer "file not found"). No HTTP stub: must fail before any call.
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
"+upload-attachment",
"--resource-id", "task-guid-123",
"--file", "missing.bin",
"--as", "bot",
"--format", "json",
}, f, stdout)
if err == nil {
t.Fatal("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.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), "cannot access file") {
t.Fatalf("error message should contain %q, got: %v", "cannot access file", err)
}
}
func TestUploadAttachmentTask_MalformedResponse(t *testing.T) {
f, stdout, stderr, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
filePath := writeTestFile(t, "note.txt", 4)
// A 200 response whose body is not valid JSON: the parse failure must
// surface a typed internal invalid_response error (exit 5), not a panic
// or a silent success.
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/attachments/upload",
RawBody: []byte("this is not json{"),
})
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
"+upload-attachment",
"--resource-id", "task-guid-123",
"--file", filePath,
"--as", "bot",
"--format", "json",
}, f, stdout)
if err == nil {
t.Fatal("expected error, got nil")
}
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Fatalf("exit code = %d, want %d", got, output.ExitInternal)
}
if !strings.Contains(err.Error(), "parse response") {
t.Fatalf("error message should mention parse response, got: %v", err)
}
// The parse-error observability log should be emitted on stderr.
if errOut := stderr.String(); !strings.Contains(errOut, "parse_error") {
t.Errorf("stderr missing parse_error log; got:\n%s", errOut)
}
}
@@ -311,12 +442,23 @@ func TestUploadAttachmentTask_APIError(t *testing.T) {
if err == nil {
t.Fatal("expected error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T: %v", err, err)
var pe *errs.PermissionError
if !errors.As(err, &pe) {
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
}
if exitErr.Detail == nil || exitErr.Detail.Code != ErrCodeTaskPermissionDenied {
t.Fatalf("expected task permission denied code %d, got: %+v", ErrCodeTaskPermissionDenied, exitErr.Detail)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf(err) = !ok, want typed errs.* error; err = %v", err)
}
if p.Subtype != errs.SubtypePermissionDenied {
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypePermissionDenied)
}
if p.Code != ErrCodeTaskPermissionDenied {
t.Fatalf("code = %d, want %d", p.Code, ErrCodeTaskPermissionDenied)
}
// permission_denied maps to CategoryAuthorization → exit 3 (was exit 1 under legacy).
if got := output.ExitCodeOf(err); got != output.ExitAuth {
t.Fatalf("exit code = %d, want %d", got, output.ExitAuth)
}
// Key-path log should still be emitted on failure.

View File

@@ -10,7 +10,8 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -24,7 +25,7 @@ func isRelativeTime(s string) bool {
func parseRelativeTime(s string) (time.Time, error) {
matches := relativeTimeRe.FindStringSubmatch(s)
if len(matches) == 0 {
return time.Time{}, output.ErrValidation("invalid relative time format: %s", s)
return time.Time{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid relative time format: %s", s)
}
sign := matches[1]
@@ -75,75 +76,53 @@ const (
ErrCodeTaskReminderExists = 1470613
)
// TaskErrorCode maps Lark error codes to standardized error info.
type TaskErrorInfo struct {
Type string
Message string
Hint string
ExitCode int
// taskAPIHints carries the task-specific recovery hint for each known Lark API
// code, layered onto the typed error after errclass.BuildAPIError classifies
// it. errclass.APIHint only covers context-free subtypes (e.g. conflict); these
// hints carry the resource context APIHint intentionally leaves to the caller.
// Authorization (1470403) is omitted: BuildAPIError already attaches the
// canonical permission hint.
var taskAPIHints = map[int]string{
ErrCodeTaskInvalidParams: "Please check required fields, field lengths, or parameter logic (e.g., reminders require a due date).",
ErrCodeTaskNotFound: "Please verify if the task, tasklist, or group ID is correct and has not been deleted.",
ErrCodeTaskConflict: "Avoid making concurrent API calls using the same client_token.",
ErrCodeTaskInternalError: "Please try again. If the error persists, check the content validity or contact support.",
ErrCodeTaskAssigneeLimit: "The current task has reached the maximum number of assignees.",
ErrCodeTaskFollowerLimit: "The current task has reached the maximum number of followers.",
ErrCodeTasklistMemberLimit: "The current tasklist has reached the maximum number of members.",
ErrCodeTaskReminderExists: "The task already has a reminder set. Remove the existing reminder before adding a new one.",
}
var taskErrorMap = map[int]TaskErrorInfo{
// Generic Task errors from docs
ErrCodeTaskInvalidParams: {"validation_error", "Invalid request parameters", "Please check required fields, field lengths, or parameter logic (e.g., reminders require a due date).", output.ExitValidation},
ErrCodeTaskNotFound: {"not_found", "Resource not found", "Please verify if the task, tasklist, or group ID is correct and has not been deleted.", output.ExitAPI},
ErrCodeTaskPermissionDenied: {"permission_error", "Permission denied", "Please check if the calling identity has the necessary edit or read permissions for the resource (task/tasklist).", output.ExitAPI},
ErrCodeTaskInternalError: {"api_error", "Internal server error", "Please try again. If the error persists, check the content validity or contact support.", output.ExitAPI},
ErrCodeTaskConflict: {"conflict", "Concurrent call conflict", "Avoid making concurrent API calls using the same client_token.", output.ExitAPI},
ErrCodeTaskAssigneeLimit: {"api_error", "Assignee limit exceeded", "The current task has reached the maximum number of assignees.", output.ExitAPI},
ErrCodeTaskFollowerLimit: {"api_error", "Follower limit exceeded", "The current task has reached the maximum number of followers.", output.ExitAPI},
ErrCodeTasklistMemberLimit: {"api_error", "Tasklist member limit exceeded", "The current tasklist has reached the maximum number of members.", output.ExitAPI},
ErrCodeTaskReminderExists: {"api_error", "Reminder already exists", "The task already has a reminder set. Remove the existing reminder before adding a new one.", output.ExitAPI},
func callTaskAPITyped(runtime *common.RuntimeContext, method, url string, params map[string]interface{}, body interface{}) (map[string]interface{}, error) {
data, err := runtime.CallAPITyped(method, url, params, body)
return data, applyTaskAPIHint(err)
}
// WrapTaskError wraps a Lark API error into a standardized ExitError based on task-specific rules.
func WrapTaskError(larkCode int, rawMsg string, action string) error {
info, ok := taskErrorMap[larkCode]
if !ok {
// Fallback to generic classification if not in task-specific map
exitCode, errType, hint := output.ClassifyLarkError(larkCode, rawMsg)
// Generic message based on type
genericMsg := ""
switch errType {
case "permission":
genericMsg = "Permission denied"
case "auth":
genericMsg = "Authentication failed"
case "config":
genericMsg = "Configuration error"
case "rate_limit":
genericMsg = "Rate limit exceeded"
default:
genericMsg = "API error"
}
displayMsg := fmt.Sprintf("%s: %s [%d] (Details: %s)", action, genericMsg, larkCode, rawMsg)
return &output.ExitError{
Code: exitCode,
Detail: &output.ErrDetail{
Type: errType,
Code: larkCode,
Message: displayMsg,
Hint: hint,
},
func applyTaskAPIHint(err error) error {
if err == nil {
return nil
}
if p, ok := errs.ProblemOf(err); ok {
if hint := taskAPIHints[p.Code]; hint != "" {
p.Hint = hint
}
}
return &output.ExitError{
Code: info.ExitCode,
Detail: &output.ErrDetail{
Type: info.Type,
Code: larkCode,
Message: fmt.Sprintf("%s: %s (Details: %s)", action, info.Message, rawMsg),
Hint: info.Hint,
},
}
return err
}
// HandleTaskApiResult is a wrapper around common.HandleApiResult that applies task-specific error mapping.
// HandleTaskApiResult interprets a parsed Lark API response. A non-zero code is
// classified into a typed errs.* error by errclass.BuildAPIError — Category,
// Subtype, Code, and log_id are sourced from internal/errclass/codemeta_task.go
// — with the task-specific recovery hint (taskAPIHints) layered on top.
func HandleTaskApiResult(result interface{}, err error, action string) (map[string]interface{}, error) {
return handleTaskAPIResult(result, err, action, errclass.ClassifyContext{})
}
func HandleTaskApiResultWithContext(result interface{}, err error, action string, cc errclass.ClassifyContext) (map[string]interface{}, error) {
return handleTaskAPIResult(result, err, action, cc)
}
func handleTaskAPIResult(result interface{}, err error, action string, cc errclass.ClassifyContext) (map[string]interface{}, error) {
if err != nil {
return nil, err
}
@@ -151,16 +130,19 @@ func HandleTaskApiResult(result interface{}, err error, action string) (map[stri
resultMap, _ := result.(map[string]interface{})
codeVal, hasCode := resultMap["code"]
if !hasCode {
// Try to see if it's already an error from common.HandleApiResult (e.g. network error)
data, err := common.HandleApiResult(result, err, action)
return data, err
// A Lark response always carries a top-level code; its absence (with no
// transport error) means a malformed or unexpected body.
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: unexpected response (missing code field)", action)
}
code, _ := util.ToFloat64(codeVal)
code, ok := util.ToFloat64(codeVal)
if !ok {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: malformed response (non-numeric code %v)", action, codeVal)
}
larkCode := int(code)
if larkCode != 0 {
rawMsg, _ := resultMap["msg"].(string)
return nil, WrapTaskError(larkCode, rawMsg, action)
typedErr := errclass.BuildAPIError(resultMap, cc)
return nil, applyTaskAPIHint(typedErr)
}
data, _ := resultMap["data"].(map[string]interface{})

View File

@@ -4,10 +4,21 @@
package task
import (
"context"
"errors"
"net/http"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/smartystreets/goconvey/convey"
)
@@ -20,43 +31,235 @@ func TestContains(t *testing.T) {
})
}
func TestParseRelativeTime_StructuredErrors(t *testing.T) {
func TestParseRelativeTime_TypedError(t *testing.T) {
_, err := parseRelativeTime("not-relative")
if err == nil {
t.Fatal("parseRelativeTime(\"not-relative\") expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), "invalid relative time format") {
t.Errorf("message = %q, want substring %q", err.Error(), "invalid relative time format")
}
}
// apiResult builds a parsed Lark API response with a non-zero code, as
// HandleTaskApiResult receives it after json.Unmarshal.
func apiResult(code int, msg string) map[string]interface{} {
return map[string]interface{}{"code": float64(code), "msg": msg}
}
// TestHandleTaskApiResult_TypedMapping locks the API code → typed
// category/subtype/exit mapping. Classification is sourced from
// internal/errclass/codemeta_task.go via errclass.BuildAPIError; the
// task-specific recovery hint is layered on from taskAPIHints. 1470400 surfaces
// exit 1 (API-side parameter rejection, was exit 2 under legacy); 1470403
// routes to CategoryAuthorization and surfaces exit 3 (was exit 1).
func TestHandleTaskApiResult_TypedMapping(t *testing.T) {
tests := []struct {
name string
input string
wantCode int
wantType string
wantSubstr string
name string
code int
wantSubtype errs.Subtype
wantExit int
wantRetry bool
}{
{
name: "invalid format returns ErrValidation",
input: "not-relative",
wantCode: output.ExitValidation,
wantType: "validation",
wantSubstr: "invalid relative time format",
},
{"invalid_params", ErrCodeTaskInvalidParams, errs.SubtypeInvalidParameters, output.ExitAPI, false},
{"not_found", ErrCodeTaskNotFound, errs.SubtypeNotFound, output.ExitAPI, false},
{"conflict", ErrCodeTaskConflict, errs.SubtypeConflict, output.ExitAPI, true},
{"internal", ErrCodeTaskInternalError, errs.SubtypeServerError, output.ExitAPI, true},
{"assignee_limit", ErrCodeTaskAssigneeLimit, errs.SubtypeQuotaExceeded, output.ExitAPI, false},
{"follower_limit", ErrCodeTaskFollowerLimit, errs.SubtypeQuotaExceeded, output.ExitAPI, false},
{"member_limit", ErrCodeTasklistMemberLimit, errs.SubtypeQuotaExceeded, output.ExitAPI, false},
{"reminder_exists", ErrCodeTaskReminderExists, errs.SubtypeAlreadyExists, output.ExitAPI, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := parseRelativeTime(tt.input)
if err == nil {
t.Fatalf("parseRelativeTime(%q) expected error, got nil", tt.input)
data, err := HandleTaskApiResult(apiResult(tt.code, "raw upstream detail"), nil, "do thing")
if data != nil {
t.Errorf("data = %v, want nil on error", data)
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError; error = %v", err, err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T, want typed errs.* error", err)
}
if exitErr.Code != tt.wantCode {
t.Errorf("exit code = %d, want %d", exitErr.Code, tt.wantCode)
if p.Subtype != tt.wantSubtype {
t.Errorf("subtype = %q, want %q", p.Subtype, tt.wantSubtype)
}
if exitErr.Detail == nil {
t.Fatal("expected non-nil error detail")
if p.Code != tt.code {
t.Errorf("code = %d, want %d", p.Code, tt.code)
}
if exitErr.Detail.Type != tt.wantType {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, tt.wantType)
if got := output.ExitCodeOf(err); got != tt.wantExit {
t.Errorf("exit code = %d, want %d", got, tt.wantExit)
}
if p.Retryable != tt.wantRetry {
t.Errorf("retryable = %v, want %v", p.Retryable, tt.wantRetry)
}
// These CategoryAPI codes carry the task-specific recovery hint.
if p.Hint != taskAPIHints[tt.code] {
t.Errorf("hint = %q, want %q", p.Hint, taskAPIHints[tt.code])
}
// BuildAPIError uses the raw upstream msg as the message.
if !strings.Contains(err.Error(), "raw upstream detail") {
t.Errorf("message = %q, want raw upstream detail", err.Error())
}
})
}
}
// TestHandleTaskApiResult_PermissionDenied verifies 1470403 routes to a typed
// *errs.PermissionError (exit 3) carrying the canonical permission hint from
// BuildAPIError — taskAPIHints intentionally omits it so the canonical hint
// stands.
func TestHandleTaskApiResult_PermissionDenied(t *testing.T) {
_, err := HandleTaskApiResult(apiResult(ErrCodeTaskPermissionDenied, "no permission"), nil, "do thing")
var pe *errs.PermissionError
if !errors.As(err, &pe) {
t.Fatalf("err = %T, want *errs.PermissionError", err)
}
if pe.Subtype != errs.SubtypePermissionDenied {
t.Errorf("subtype = %q, want %q", pe.Subtype, errs.SubtypePermissionDenied)
}
if got := output.ExitCodeOf(err); got != output.ExitAuth {
t.Errorf("exit code = %d, want %d", got, output.ExitAuth)
}
if strings.TrimSpace(pe.Hint) == "" {
t.Error("expected a canonical permission hint, got empty")
}
}
func TestHandleTaskApiResultWithContext_PermissionConsoleURL(t *testing.T) {
_, err := HandleTaskApiResultWithContext(map[string]interface{}{
"code": float64(99991672),
"msg": "access denied",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "task:attachment:write"},
},
},
}, nil, "upload task attachment", errclass.ClassifyContext{
Brand: "lark",
AppID: "cli_a123",
Identity: "bot",
})
var pe *errs.PermissionError
if !errors.As(err, &pe) {
t.Fatalf("err = %T, want *errs.PermissionError", err)
}
if pe.Subtype != errs.SubtypeAppScopeNotApplied {
t.Errorf("subtype = %q, want %q", pe.Subtype, errs.SubtypeAppScopeNotApplied)
}
if pe.ConsoleURL == "" || !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123/auth") {
t.Errorf("ConsoleURL = %q, want Lark developer console URL", pe.ConsoleURL)
}
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "task:attachment:write" {
t.Errorf("MissingScopes = %#v, want task:attachment:write", pe.MissingScopes)
}
if !strings.Contains(pe.Hint, pe.ConsoleURL) {
t.Errorf("hint = %q, want to include console URL %q", pe.Hint, pe.ConsoleURL)
}
}
func TestCallTaskAPITyped_TaskHint(t *testing.T) {
cfg := taskTestConfig(t)
f, _, _, reg := cmdutil.TestFactory(t, cfg)
rt := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+x"}, cfg, f, core.AsUser)
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/task/v2/tasks/t-1",
Body: map[string]interface{}{
"code": float64(ErrCodeTaskReminderExists),
"msg": "reminder exists",
},
})
_, err := callTaskAPITyped(rt, http.MethodGet, "/open-apis/task/v2/tasks/t-1", nil, nil)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T, want typed errs.* error", err)
}
if p.Hint != taskAPIHints[ErrCodeTaskReminderExists] {
t.Errorf("hint = %q, want %q", p.Hint, taskAPIHints[ErrCodeTaskReminderExists])
}
}
// TestHandleTaskApiResult_MalformedResponse covers the two malformed-body arms:
// a response with no top-level code, and one whose code is non-numeric. Both
// must surface a typed internal invalid_response error (exit 5) rather than
// silently passing through as a success.
func TestHandleTaskApiResult_MalformedResponse(t *testing.T) {
cases := []struct {
name string
result map[string]interface{}
}{
{"missing code field", map[string]interface{}{"msg": "weird", "data": map[string]interface{}{}}},
{"non-numeric code", map[string]interface{}{"code": "oops", "msg": "weird"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
data, err := HandleTaskApiResult(tc.result, nil, "do thing")
if data != nil {
t.Errorf("data = %v, want nil", data)
}
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("err = %T, want *errs.InternalError", err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
}
})
}
}
// TestHandleTaskApiResult_Success returns the data map unchanged when code == 0.
func TestHandleTaskApiResult_Success(t *testing.T) {
want := map[string]interface{}{"guid": "t-1"}
data, err := HandleTaskApiResult(map[string]interface{}{
"code": float64(0),
"data": want,
}, nil, "do thing")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if data["guid"] != "t-1" {
t.Errorf("data = %v, want guid=t-1", data)
}
}
// TestHandleTaskApiResult_UnknownCode covers the fallback arm: an uncatalogued
// code becomes a generic CategoryAPI error with SubtypeUnknown and no layered
// hint.
func TestHandleTaskApiResult_UnknownCode(t *testing.T) {
_, err := HandleTaskApiResult(apiResult(9999999, "weird"), nil, "do thing")
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T, want typed errs.* error", err)
}
if p.Subtype != errs.SubtypeUnknown {
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeUnknown)
}
if p.Code != 9999999 {
t.Errorf("code = %d, want 9999999", p.Code)
}
if got := output.ExitCodeOf(err); got != output.ExitAPI {
t.Errorf("exit code = %d, want %d", got, output.ExitAPI)
}
var ae *errs.APIError
if !errors.As(err, &ae) {
t.Errorf("error type = %T, want *errs.APIError", err)
}
}

View File

@@ -5,16 +5,13 @@ package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -55,8 +52,7 @@ var AddTaskToTasklist = common.Shortcut{
tasklistGuid := extractTasklistGuid(runtime.Str("tasklist-id"))
taskIds := strings.Split(runtime.Str("task-id"), ",")
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
params := map[string]interface{}{"user_id_type": "open_id"}
body := map[string]interface{}{
"tasklist_guid": tasklistGuid,
@@ -75,30 +71,16 @@ var AddTaskToTasklist = common.Shortcut{
continue
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/" + url.PathEscape(taskId) + "/add_tasklist",
QueryParams: queryParams,
Body: body,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
err = WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse add task response")
}
}
data, err := HandleTaskApiResult(result, err, "add task to tasklist")
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+url.PathEscape(taskId)+"/add_tasklist", params, body)
if err != nil {
failDetail := map[string]interface{}{
"guid": taskId,
}
if exitErr, ok := err.(*output.ExitError); ok && exitErr.Detail != nil {
failDetail["type"] = exitErr.Detail.Type
failDetail["code"] = exitErr.Detail.Code
failDetail["message"] = exitErr.Detail.Message
failDetail["hint"] = exitErr.Detail.Hint
if p, ok := errs.ProblemOf(err); ok {
failDetail["type"] = string(p.Subtype)
failDetail["code"] = p.Code
failDetail["message"] = p.Message
failDetail["hint"] = p.Hint
} else {
failDetail["type"] = "api_error"
failDetail["message"] = err.Error()
@@ -123,6 +105,13 @@ var AddTaskToTasklist = common.Shortcut{
"tasklist_guid": tasklistGuid,
}
// Item-level failures surface as a non-zero exit (ok:false) so callers
// don't have to inspect failed_tasks to detect a partial add; the full
// payload (successful + failed) stays on stdout either way.
if len(failed) > 0 {
return runtime.OutPartialFailure(resultData, nil)
}
runtime.OutFormat(resultData, nil, func(w io.Writer) {
fmt.Fprintf(w, "✅ Tasks added to tasklist %s!\n", tasklistGuid)
fmt.Fprintf(w, "Successful: %d, Failed: %d\n", len(successful), len(failed))

View File

@@ -4,10 +4,13 @@
package task
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestAddTaskToTasklist_Success(t *testing.T) {
@@ -41,3 +44,76 @@ func TestAddTaskToTasklist_Success(t *testing.T) {
t.Errorf("expected tasklist_guid in output, got: %s", out)
}
}
// TestAddTaskToTasklist_PartialFailure exercises the batch path: some tasks
// succeed, others fail with typed API errors. Successful and failed tasks both
// land in stdout as an ok:false envelope, and the command returns the typed
// partial-failure exit signal (exit 1) via runtime.OutPartialFailure. The
// failed_tasks[].type carries the typed subtype (e.g. "permission_denied",
// "not_found") read off errs.ProblemOf, not the legacy
// *output.ExitError.Detail.Type ("permission_error" etc).
func TestAddTaskToTasklist_PartialFailure(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-ok/add_tasklist",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{
"guid": "task-ok",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-perm/add_tasklist",
Body: map[string]interface{}{
"code": ErrCodeTaskPermissionDenied, "msg": "no permission",
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-missing/add_tasklist",
Body: map[string]interface{}{
"code": ErrCodeTaskNotFound, "msg": "task not found",
},
})
s := AddTaskToTasklist
s.AuthTypes = []string{"bot", "user"}
args := []string{"+tasklist-task-add", "--tasklist-id", "tl-123", "--task-id", "task-ok,task-perm,task-missing", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
// Partial failure now surfaces as a non-zero exit (ok:false), not nil.
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError on partial failure, got %T: %v", err, err)
}
if pfErr.Code != output.ExitAPI {
t.Errorf("exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
}
out := stdout.String()
// Successful task is in stdout.
if !strings.Contains(out, "task-ok") {
t.Errorf("expected successful task-ok in output, got: %s", out)
}
// Failed tasks carry the typed subtype, not the legacy Detail.Type.
if !strings.Contains(out, string(errs.SubtypePermissionDenied)) {
t.Errorf("expected typed subtype %q in failed_tasks, got: %s", errs.SubtypePermissionDenied, out)
}
if !strings.Contains(out, string(errs.SubtypeNotFound)) {
t.Errorf("expected typed subtype %q in failed_tasks, got: %s", errs.SubtypeNotFound, out)
}
// The legacy shapes must not leak.
if strings.Contains(out, "permission_error") {
t.Errorf("legacy type \"permission_error\" leaked into output: %s", out)
}
}

View File

@@ -12,8 +12,8 @@ import (
"strings"
"sync"
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"
)
@@ -50,24 +50,18 @@ var CreateTasklist = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body := buildTasklistCreateBody(runtime)
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
params := map[string]interface{}{"user_id_type": "open_id"}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasklists",
QueryParams: queryParams,
Body: body,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse create tasklist")
// Validate --data (client input) before any remote write, so a malformed
// payload fails fast without creating an orphan tasklist.
var tasks []map[string]interface{}
if dataStr := runtime.Str("data"); dataStr != "" {
if err := json.Unmarshal([]byte(dataStr), &tasks); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to parse --data as JSON array: %v", err).WithParam("--data")
}
}
data, err := HandleTaskApiResult(result, err, "create tasklist")
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists", params, body)
if err != nil {
return err
}
@@ -78,16 +72,10 @@ var CreateTasklist = common.Shortcut{
tasklistUrl, _ := tasklist["url"].(string)
tasklistUrl = truncateTaskURL(tasklistUrl)
// Create tasks if data is provided
var tasks []map[string]interface{}
var createdTasks []map[string]interface{}
var failedTasks []string
if dataStr := runtime.Str("data"); dataStr != "" {
if err := json.Unmarshal([]byte(dataStr), &tasks); err != nil {
return WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("failed to parse --data as JSON array: %v", err), "parse data")
}
var failedTasks []map[string]interface{}
if len(tasks) > 0 {
var wg sync.WaitGroup
var mu sync.Mutex
@@ -120,27 +108,14 @@ var CreateTasklist = common.Shortcut{
delete(tDef, "assignee")
}
tResp, tErr := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks",
QueryParams: queryParams,
Body: tDef,
})
tData, tErr := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks", params, tDef)
mu.Lock()
defer mu.Unlock()
var tResult map[string]interface{}
if tErr == nil {
if json.Unmarshal(tResp.RawBody, &tResult) != nil {
tErr = WrapTaskError(ErrCodeTaskInternalError, "failed to parse task response", "parse task")
}
}
tData, tErr := HandleTaskApiResult(tResult, tErr, "create task in tasklist")
if tErr != nil {
summary, _ := tDef["summary"].(string)
failedTasks = append(failedTasks, fmt.Sprintf("Index %d (%s): %v", idx, summary, tErr))
failedTasks = append(failedTasks, buildTaskCreateFailure(idx, summary, tErr))
return
}
@@ -163,9 +138,10 @@ var CreateTasklist = common.Shortcut{
"guid": tasklistGuid,
"url": tasklistUrl,
"created_tasks": createdTasks,
"failed_tasks": failedTasks,
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
pretty := func(w io.Writer) {
fmt.Fprintf(w, "✅ Tasklist created successfully!\n")
fmt.Fprintf(w, "Tasklist Name: %s\n", tasklistName)
fmt.Fprintf(w, "Tasklist ID: %s\n", tasklistGuid)
@@ -188,15 +164,45 @@ var CreateTasklist = common.Shortcut{
if len(failedTasks) > 0 {
fmt.Fprintf(w, "\nFailed tasks:\n")
for _, f := range failedTasks {
fmt.Fprintf(w, " - %s\n", f)
fmt.Fprintf(w, " - Index %v (%s): %s\n", f["index"], f["summary"], f["message"])
}
}
}
})
}
// Sub-task creation failures surface as a non-zero exit. JSON/JQ callers
// need an ok:false envelope, while pretty output should preserve the
// command-specific human-readable summary.
if len(failedTasks) > 0 {
if runtime.Format == "pretty" && runtime.JqExpr == "" {
runtime.OutFormat(outData, nil, pretty)
return output.PartialFailure(output.ExitAPI)
}
return runtime.OutPartialFailure(outData, nil)
}
runtime.OutFormat(outData, nil, pretty)
return nil
},
}
func buildTaskCreateFailure(index int, summary string, err error) map[string]interface{} {
failDetail := map[string]interface{}{
"index": index,
"summary": summary,
}
if p, ok := errs.ProblemOf(err); ok {
failDetail["type"] = string(p.Subtype)
failDetail["code"] = p.Code
failDetail["message"] = p.Message
failDetail["hint"] = p.Hint
} else {
failDetail["type"] = "api_error"
failDetail["message"] = err.Error()
}
return failDetail
}
func buildTasklistCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{
"name": runtime.Str("name"),

View File

@@ -0,0 +1,236 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"bytes"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
// TestCreateTasklist_PartialFailure exercises the batch sub-task path: the
// tasklist is created (code 0), then two sub-tasks are created concurrently —
// one succeeds, one fails with a typed API error. The command returns the typed
// partial-failure exit signal (*output.PartialFailureError, ExitAPI) via
// runtime.OutPartialFailure, and stdout carries both created_tasks (the
// success) and failed_tasks (the failure) so the partial result is inspectable.
// Sub-tasks are routed by summary via BodyFilter because both POST the same
// /tasks URL and run on separate goroutines.
func TestCreateTasklist_PartialFailure(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"tasklist": map[string]interface{}{
"guid": "tl-new",
"name": "My List",
"url": "https://example.feishu.cn/tl-new",
},
},
},
})
// Succeeding sub-task (summary "ok-task").
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks",
BodyFilter: func(b []byte) bool { return bytes.Contains(b, []byte("ok-task")) },
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{
"guid": "task-ok",
"url": "https://example.feishu.cn/task-ok",
},
},
},
})
// Failing sub-task (summary "bad-task") → typed permission_denied.
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks",
BodyFilter: func(b []byte) bool { return bytes.Contains(b, []byte("bad-task")) },
Body: map[string]interface{}{
"code": ErrCodeTaskPermissionDenied, "msg": "no permission",
},
})
s := CreateTasklist
s.AuthTypes = []string{"bot", "user"}
data := `[{"summary":"ok-task"},{"summary":"bad-task"}]`
args := []string{"+tasklist-create", "--name", "My List", "--data", data, "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("err = %T, want *output.PartialFailureError; err = %v", err, err)
}
if pfErr.Code != output.ExitAPI {
t.Errorf("exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
}
out := stdout.String()
// The tasklist itself is created and stays in the payload.
if !strings.Contains(out, "tl-new") {
t.Errorf("expected created tasklist guid tl-new in output, got: %s", out)
}
// Success lands in created_tasks.
if !strings.Contains(out, "task-ok") {
t.Errorf("expected created sub-task task-ok in output, got: %s", out)
}
// Failure lands in failed_tasks (keyed by index + summary).
if !strings.Contains(out, "bad-task") {
t.Errorf("expected failed sub-task bad-task in output, got: %s", out)
}
if !strings.Contains(out, string(errs.SubtypePermissionDenied)) {
t.Errorf("expected typed subtype %q in failed_tasks, got: %s", errs.SubtypePermissionDenied, out)
}
if !strings.Contains(out, `"code": 1470403`) && !strings.Contains(out, `"code":1470403`) {
t.Errorf("expected task permission code in failed_tasks, got: %s", out)
}
if strings.Contains(out, "permission_error") {
t.Errorf("legacy type \"permission_error\" leaked into output: %s", out)
}
}
func TestCreateTasklist_PartialFailurePrettyOutput(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"tasklist": map[string]interface{}{
"guid": "tl-new",
"name": "My List",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks",
BodyFilter: func(b []byte) bool { return bytes.Contains(b, []byte("ok-task")) },
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{"guid": "task-ok"},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks",
BodyFilter: func(b []byte) bool { return bytes.Contains(b, []byte("bad-task")) },
Body: map[string]interface{}{"code": ErrCodeTaskPermissionDenied, "msg": "no permission"},
})
s := CreateTasklist
s.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, s, []string{
"+tasklist-create",
"--name", "My List",
"--data", `[{"summary":"ok-task"},{"summary":"bad-task"}]`,
"--as", "bot",
"--format", "pretty",
}, f, stdout)
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("err = %T, want *output.PartialFailureError; err = %v", err, err)
}
out := stdout.String()
for _, want := range []string{
"Tasklist created successfully",
"Tasks created: 1/2",
"Failed tasks:",
"Index",
"bad-task",
"user lacks permission",
} {
if !strings.Contains(out, want) {
t.Errorf("pretty output missing %q; got:\n%s", want, out)
}
}
if strings.Contains(out, `"ok":`) {
t.Errorf("pretty partial failure should use text output, got JSON envelope:\n%s", out)
}
}
// TestCreateTasklist_InvalidDataJSON covers the --data validation arm: a string
// that is not a JSON array must surface a typed *errs.ValidationError
// (invalid_argument, exit 2) after the tasklist create succeeds.
func TestCreateTasklist_InvalidDataJSON(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
// No POST /tasklists stub is registered on purpose: invalid --data must be
// rejected before any remote write, leaving no orphan tasklist. If the
// ordering regressed (create first), the POST would hit no stub and surface
// as a non-validation transport error, failing the assertion below.
s := CreateTasklist
s.AuthTypes = []string{"bot", "user"}
args := []string{"+tasklist-create", "--name", "My List", "--data", "{not-an-array", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T, want *errs.ValidationError; err = %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", got, output.ExitValidation)
}
}
// TestCreateTasklist_MalformedResponse covers the create-tasklist parse arm: a
// 200 with a non-JSON body must surface a typed
// *errs.InternalError(invalid_response) (exit 5) from the json.Unmarshal guard.
func TestCreateTasklist_MalformedResponse(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists",
RawBody: []byte("not json"),
})
s := CreateTasklist
s.AuthTypes = []string{"bot", "user"}
args := []string{"+tasklist-create", "--name", "My List", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("err = %T, want *errs.InternalError; err = %v", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("exit code = %d, want %d (ExitInternal)", got, output.ExitInternal)
}
}

View File

@@ -5,15 +5,13 @@ package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -39,7 +37,7 @@ var MembersTasklist = common.Shortcut{
hasRemove := runtime.Str("remove") != ""
if hasSet && (hasAdd || hasRemove) {
return WrapTaskError(ErrCodeTaskInvalidParams, "cannot combine --set with --add or --remove", "validate tasklist members")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot combine --set with --add or --remove")
}
return nil
},
@@ -74,8 +72,7 @@ var MembersTasklist = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
tlId := url.PathEscape(extractTasklistGuid(runtime.Str("tasklist-id")))
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
params := map[string]interface{}{"user_id_type": "open_id"}
setStr := runtime.Str("set")
addStr := runtime.Str("add")
@@ -83,20 +80,7 @@ var MembersTasklist = common.Shortcut{
// If no modifications, just list
if setStr == "" && addStr == "" && removeStr == "" {
getResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/task/v2/tasklists/" + tlId,
QueryParams: queryParams,
})
var getResult map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(getResp.RawBody, &getResult); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse tasklist details")
}
}
data, err := HandleTaskApiResult(getResult, err, "get tasklist members")
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasklists/"+tlId, params, nil)
if err != nil {
return err
}
@@ -142,20 +126,7 @@ var MembersTasklist = common.Shortcut{
var lastTasklist map[string]interface{}
if setStr != "" {
// Query existing to diff for "set" behavior
getResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/task/v2/tasklists/" + tlId,
QueryParams: queryParams,
})
var getResult map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(getResp.RawBody, &getResult); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse tasklist details")
}
}
data, err := HandleTaskApiResult(getResult, err, "get tasklist details for set")
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasklists/"+tlId, params, nil)
if err != nil {
return err
}
@@ -198,21 +169,7 @@ var MembersTasklist = common.Shortcut{
if len(toAdd) > 0 {
body := buildTlMembersBody(strings.Join(toAdd, ","))
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasklists/" + tlId + "/add_members",
QueryParams: queryParams,
Body: body,
})
var addResult map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &addResult); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse add members")
}
}
data, err := HandleTaskApiResult(addResult, err, "add tasklist members")
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/"+tlId+"/add_members", params, body)
if err != nil {
return err
}
@@ -221,21 +178,7 @@ var MembersTasklist = common.Shortcut{
if len(toRemove) > 0 {
body := buildTlMembersBody(strings.Join(toRemove, ","))
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasklists/" + tlId + "/remove_members",
QueryParams: queryParams,
Body: body,
})
var removeResult map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &removeResult); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse remove members")
}
}
data, err := HandleTaskApiResult(removeResult, err, "remove tasklist members")
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/"+tlId+"/remove_members", params, body)
if err != nil {
return err
}
@@ -246,21 +189,7 @@ var MembersTasklist = common.Shortcut{
// Add / Remove mode
if addStr != "" {
body := buildTlMembersBody(addStr)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasklists/" + tlId + "/add_members",
QueryParams: queryParams,
Body: body,
})
var addResult map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &addResult); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse add members")
}
}
data, err := HandleTaskApiResult(addResult, err, "add tasklist members")
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/"+tlId+"/add_members", params, body)
if err != nil {
return err
}
@@ -269,21 +198,7 @@ var MembersTasklist = common.Shortcut{
if removeStr != "" {
body := buildTlMembersBody(removeStr)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasklists/" + tlId + "/remove_members",
QueryParams: queryParams,
Body: body,
})
var removeResult map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &removeResult); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse remove members")
}
}
data, err := HandleTaskApiResult(removeResult, err, "remove tasklist members")
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/"+tlId+"/remove_members", params, body)
if err != nil {
return err
}

View File

@@ -4,9 +4,14 @@
package task
import (
"errors"
"testing"
"github.com/smartystreets/goconvey/convey"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestBuildTlMembersBody(t *testing.T) {
@@ -16,3 +21,179 @@ func TestBuildTlMembersBody(t *testing.T) {
convey.So(len(members), convey.ShouldEqual, 2)
})
}
// TestMembersTasklist_SetCombinedWithAddRejected covers the Validate guard:
// --set is mutually exclusive with --add/--remove. It must surface a typed
// *errs.ValidationError (exit 2) before any API call is made.
func TestMembersTasklist_SetCombinedWithAddRejected(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
s := MembersTasklist
s.AuthTypes = []string{"bot", "user"}
args := []string{"+tasklist-members", "--tasklist-id", "tl-123", "--set", "ou_a", "--add", "ou_b", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T, want *errs.ValidationError; err = %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", got, output.ExitValidation)
}
}
// TestMembersTasklist_ListMalformedResponse covers the list arm (no
// set/add/remove): a 200 with a non-JSON body must surface a typed
// *errs.InternalError(invalid_response) (exit 5) from the json.Unmarshal guard,
// not a silent success.
func TestMembersTasklist_ListMalformedResponse(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasklists/tl-123",
RawBody: []byte("not json"),
})
s := MembersTasklist
s.AuthTypes = []string{"bot", "user"}
args := []string{"+tasklist-members", "--tasklist-id", "tl-123", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
assertInvalidResponse(t, err)
}
// TestMembersTasklist_SetMalformedResponse covers the --set arm: the diff path
// first GETs the tasklist; a non-JSON body there must surface the typed
// internal invalid_response error.
func TestMembersTasklist_SetMalformedResponse(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasklists/tl-123",
RawBody: []byte("not json"),
})
s := MembersTasklist
s.AuthTypes = []string{"bot", "user"}
args := []string{"+tasklist-members", "--tasklist-id", "tl-123", "--set", "ou_a", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
assertInvalidResponse(t, err)
}
// TestMembersTasklist_AddMalformedResponse covers the add/remove arm: the POST
// to add_members returns a non-JSON body, which must surface the typed internal
// invalid_response error.
func TestMembersTasklist_AddMalformedResponse(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/tl-123/add_members",
RawBody: []byte("not json"),
})
s := MembersTasklist
s.AuthTypes = []string{"bot", "user"}
args := []string{"+tasklist-members", "--tasklist-id", "tl-123", "--add", "ou_a", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
assertInvalidResponse(t, err)
}
// TestMembersTasklist_SetRemoveDiffMalformedResponse covers the --set diff's
// remove_members arm: the GET returns an existing member absent from the target
// set, so the shortcut issues a remove_members POST whose 200 carries a
// non-JSON body, which must surface the typed internal invalid_response error.
// The target equals one existing member, so no add_members call precedes it.
func TestMembersTasklist_SetRemoveDiffMalformedResponse(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasklists/tl-123",
Status: 200,
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"tasklist": map[string]interface{}{
"url": "https://example.com/tl-123",
"members": []interface{}{
map[string]interface{}{"id": "ou_keep"},
map[string]interface{}{"id": "ou_drop"},
},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/tl-123/remove_members",
Status: 200,
RawBody: []byte("not json"),
})
s := MembersTasklist
s.AuthTypes = []string{"bot", "user"}
args := []string{"+tasklist-members", "--tasklist-id", "tl-123", "--set", "ou_keep", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
assertInvalidResponse(t, err)
}
// TestMembersTasklist_RemoveMalformedResponse covers the add/remove mode's
// remove_members arm: with only --remove set, the add arm is skipped and the
// remove_members POST returns a non-JSON body, which must surface the typed
// internal invalid_response error.
func TestMembersTasklist_RemoveMalformedResponse(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/tl-123/remove_members",
Status: 200,
RawBody: []byte("not json"),
})
s := MembersTasklist
s.AuthTypes = []string{"bot", "user"}
args := []string{"+tasklist-members", "--tasklist-id", "tl-123", "--remove", "ou_a", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
assertInvalidResponse(t, err)
}
// assertInvalidResponse asserts a typed *errs.InternalError(invalid_response)
// with exit 5 — the contract for a parse-response failure across the members
// arms.
func assertInvalidResponse(t *testing.T, err error) {
t.Helper()
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("err = %T, want *errs.InternalError; err = %v", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("exit code = %d, want %d (ExitInternal)", got, output.ExitInternal)
}
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"errors"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
)
// wrapWbNetworkErr returns err unchanged when it is already a typed errs.* error
// (preserving subtype / code / log_id from the runtime boundary) and only wraps
// a raw, unclassified error as a transport-level network error.
func wrapWbNetworkErr(err error, format string, args ...any) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
}
// wbSaveError maps a FileIO.Save error to a typed error. Path validation
// failures are validation errors (exit code 2); mkdir / write failures are
// internal file-I/O errors (exit code 5).
func wbSaveError(err error) error {
if err == nil {
return nil
}
var me *fileio.MkdirError
switch {
case errors.Is(err, fileio.ErrPathValidation):
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).
WithParam("--output").
WithCause(err)
case errors.As(err, &me):
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create parent directory: %s", err).WithCause(err)
default:
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create file: %s", err).WithCause(err)
}
}
func wbInvalidResponse(format string, args ...any) error {
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...)
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
)
func TestWrapWbNetworkErr(t *testing.T) {
typed := errs.NewAPIError(errs.SubtypeRateLimit, "already typed")
if got := wrapWbNetworkErr(typed, "wrap"); got != error(typed) {
t.Fatalf("typed error must pass through unchanged, got %v", got)
}
raw := errors.New("connection reset by peer")
got := wrapWbNetworkErr(raw, "fetch failed: %v", raw)
var ne *errs.NetworkError
if !errors.As(got, &ne) || ne.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("raw error: got %T (%v)", got, got)
}
if !errors.Is(got, raw) {
t.Fatal("raw cause should be retained via WithCause")
}
}
func TestWbSaveError(t *testing.T) {
if wbSaveError(nil) != nil {
t.Fatal("nil error should map to nil")
}
var ve *errs.ValidationError
pathCause := errors.New("escape")
if got := wbSaveError(&fileio.PathValidationError{Err: pathCause}); !errors.As(got, &ve) || ve.Subtype != errs.SubtypeInvalidArgument || !errors.Is(got, pathCause) {
t.Fatalf("path validation: got %T (%v)", got, got)
}
if ve.Param != "--output" {
t.Fatalf("path validation param = %q, want --output", ve.Param)
}
var ie *errs.InternalError
mkdirCause := errors.New("mkdir denied")
if got := wbSaveError(&fileio.MkdirError{Err: mkdirCause}); !errors.As(got, &ie) || ie.Subtype != errs.SubtypeFileIO || !errors.Is(got, mkdirCause) {
t.Fatalf("mkdir: got %T (%v)", got, got)
}
writeCause := errors.New("disk full")
if got := wbSaveError(writeCause); !errors.As(got, &ie) || ie.Subtype != errs.SubtypeFileIO || !errors.Is(got, writeCause) {
t.Fatalf("default: got %T (%v)", got, got)
}
}

View File

@@ -14,9 +14,8 @@ import (
"path/filepath"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"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"
)
@@ -73,22 +72,22 @@ var WhiteboardQuery = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
// Check if token contains control characters
token := runtime.Str("whiteboard-token")
if err := validate.RejectControlChars(token, "whiteboard-token"); err != nil {
if err := common.RejectDangerousCharsTyped("--whiteboard-token", token); err != nil {
return err
}
out := runtime.Str("output")
if out != "" {
if err := runtime.ValidatePath(out); err != nil {
return output.ErrValidation("invalid output path: %s", err)
if _, err := runtime.ResolveSavePath(out); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
}
}
if out == "" && runtime.Str("output_as") == WhiteboardQueryAsImage {
return output.ErrValidation("need a output directory to query whiteboard as image")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "need a output directory to query whiteboard as image").WithParam("--output")
}
as := runtime.Str("output_as")
if as != WhiteboardQueryAsImage && as != WhiteboardQueryAsCode && as != WhiteboardQueryAsRaw {
return common.FlagErrorf("--output_as flag must be one of: image | code | raw")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | code | raw").WithParam("--output_as")
}
return nil
},
@@ -125,7 +124,7 @@ var WhiteboardQuery = common.Shortcut{
case WhiteboardQueryAsRaw:
return exportWhiteboardRaw(runtime, token, outDir)
default:
return output.ErrValidation("--as flag must be one of: image | code | raw")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | code | raw").WithParam("--output_as")
}
},
@@ -136,14 +135,26 @@ func exportWhiteboardPreview(ctx context.Context, runtime *common.RuntimeContext
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", url.PathEscape(wbToken)),
}
// Execute API request
// Execute API request. The preview endpoint streams raw image bytes (not a
// JSON envelope), so classify by HTTP status: 5xx is retryable network,
// while 4xx remains an API-side rejection.
resp, err := runtime.DoAPI(req, larkcore.WithFileDownload())
if err != nil {
return output.ErrNetwork(fmt.Sprintf("get whiteboard preview failed: %v", err))
return wrapWbNetworkErr(err, "get whiteboard preview failed: %v", err)
}
// Check response status code
if resp.StatusCode != http.StatusOK {
return output.ErrAPI(resp.StatusCode, string(resp.RawBody), nil)
if resp.StatusCode >= 400 {
body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
if resp.StatusCode >= 500 {
return errs.NewNetworkError(errs.SubtypeNetworkServer, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode).
WithRetryable()
}
subtype := errs.SubtypeUnknown
if resp.StatusCode == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode)
}
finalPath, size, err := saveOutputFile(outDir, ".png", wbToken, runtime, bytes.NewReader(resp.RawBody))
@@ -162,33 +173,27 @@ func exportWhiteboardPreview(ctx context.Context, runtime *common.RuntimeContext
}
type wbNodesResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Nodes []interface{} `json:"nodes"`
} `json:"data"`
}
func fetchWhiteboardNodes(runtime *common.RuntimeContext, wbToken string) (*wbNodesResp, error) {
req := &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", wbToken),
}
resp, err := runtime.DoAPI(req)
data, err := runtime.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)), nil, nil)
if err != nil {
return nil, output.ErrNetwork(fmt.Sprintf("get whiteboard nodes failed: %v", err))
}
// 检查响应状态码
if resp.StatusCode != http.StatusOK {
return nil, output.ErrAPI(resp.StatusCode, string(resp.RawBody), nil)
return nil, err
}
var nodes wbNodesResp
err = json.Unmarshal(resp.RawBody, &nodes)
if err != nil {
return nil, output.Errorf(output.ExitInternal, "parsing", fmt.Sprintf("parse whiteboard nodes failed: %v", err))
rawNodes, ok := data["nodes"]
if !ok {
return nil, wbInvalidResponse("get whiteboard nodes failed: missing data.nodes")
}
if nodes.Code != 0 {
return nil, output.ErrAPI(nodes.Code, "get whiteboard nodes failed", fmt.Sprintf("get whiteboard nodes failed: %s", nodes.Msg))
if rawNodes != nil {
var ok bool
nodes.Data.Nodes, ok = rawNodes.([]interface{})
if !ok {
return nil, wbInvalidResponse("get whiteboard nodes failed: data.nodes must be an array")
}
}
return &nodes, nil
}
@@ -229,6 +234,12 @@ func exportWhiteboardCode(runtime *common.RuntimeContext, wbToken, outDir string
code, _ := syntaxMap["code"].(string)
var syntaxType SyntaxType
switch v := syntaxMap["syntax_type"].(type) {
case json.Number:
// runtime.ClassifyAPIResponse decodes the response with UseNumber,
// so numeric fields arrive as json.Number rather than float64.
if n, err := v.Int64(); err == nil {
syntaxType = SyntaxType(n)
}
case float64:
syntaxType = SyntaxType(v)
case SyntaxType:
@@ -299,7 +310,7 @@ func exportWhiteboardRaw(runtime *common.RuntimeContext, wbToken, outDir string)
jsonData, err := json.MarshalIndent(wbNodes.Data, "", " ")
if err != nil {
return output.Errorf(output.ExitInternal, "json_error", "cannot marshal whiteboard data: %s", err)
return errs.NewInternalError(errs.SubtypeInvalidResponse, "cannot marshal whiteboard data: %s", err).WithCause(err)
}
if outDir == "" {
@@ -340,18 +351,18 @@ func saveOutputFile(outPath, ext, token string, runtime *common.RuntimeContext,
}
finalPath = outPath
}
if err := runtime.ValidatePath(finalPath); err != nil { // double check
return "", 0, err
if _, err := runtime.ResolveSavePath(finalPath); err != nil { // double check
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
}
// Step 2: Check overwrite
_, err = runtime.FileIO().Stat(finalPath)
if err == nil {
if !runtime.Bool("overwrite") {
return "", 0, output.ErrValidation(fmt.Sprintf("file already exists: %s (use --overwrite to overwrite)", finalPath))
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file already exists: %s (use --overwrite to overwrite)", finalPath).WithParam("--overwrite")
}
} else if !os.IsNotExist(err) {
return "", 0, output.Errorf(output.ExitInternal, "io_error", "cannot check file existence: %s", err)
return "", 0, errs.NewInternalError(errs.SubtypeFileIO, "cannot check file existence: %s", err).WithCause(err)
}
// Step 3: Save file
@@ -369,7 +380,7 @@ func saveOutputFile(outPath, ext, token string, runtime *common.RuntimeContext,
ContentType: contentType,
}, data)
if err != nil {
return "", 0, common.WrapSaveError(err, "unsafe file path", "cannot create parent directory", "cannot create file")
return "", 0, wbSaveError(err)
}
return finalPath, savResult.Size(), nil

View File

@@ -6,11 +6,13 @@ package whiteboard
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"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"
@@ -149,6 +151,110 @@ func TestWhiteboardQuery_Validate(t *testing.T) {
}
}
// TestWhiteboardQuery_Validate_TypedErrors locks the typed-envelope contract:
// input-validation failures surface as *errs.ValidationError carrying
// SubtypeInvalidArgument and the offending --flag, readable via errs.ProblemOf
// and errors.As — the shape downstream consumers (and exit-code mapping) rely on.
func TestWhiteboardQuery_Validate_TypedErrors(t *testing.T) {
ctx := context.Background()
chdirTemp(t)
tests := []struct {
name string
flags map[string]string
wantParam string
}{
{
name: "image without output",
flags: map[string]string{"whiteboard-token": "t", "output_as": "image"},
wantParam: "--output",
},
{
name: "bad output_as value",
flags: map[string]string{"whiteboard-token": "t", "output_as": "invalid"},
wantParam: "--output_as",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := WhiteboardQuery.Validate(ctx, newTestRuntime(tt.flags, nil))
if err == nil {
t.Fatalf("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error is not *errs.ValidationError: %T", err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != tt.wantParam {
t.Errorf("Param = %q, want %q", ve.Param, tt.wantParam)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("errs.ProblemOf returned false")
}
if p.Category != errs.CategoryValidation {
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
}
})
}
}
// TestExportWhiteboardPreview_HTTPError locks the download-path failure
// behavior: a failed preview download surfaces as a typed errs.* envelope, not
// a flat legacy error.
func TestExportWhiteboardPreview_HTTPError(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
chdirTemp(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/board/v1/whiteboards/test-token-preview-5xx/download_as_image",
Status: 500,
RawBody: []byte("gateway error"),
ContentType: "text/plain",
})
args := []string{"+query", "--whiteboard-token", "test-token-preview-5xx", "--output_as", "image", "--output", "output"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatal("expected error for HTTP 500 download")
}
if _, ok := errs.ProblemOf(err); !ok {
t.Fatalf("error is not a typed errs.* envelope: %T (%v)", err, err)
}
var ne *errs.NetworkError
if !errors.As(err, &ne) || ne.Subtype != errs.SubtypeNetworkServer || ne.Code != 500 || !ne.Retryable {
t.Fatalf("HTTP 500 should be retryable network/server_error, got %T (%v)", err, err)
}
}
func TestExportWhiteboardPreview_HTTPNotFoundIsAPIError(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
chdirTemp(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/board/v1/whiteboards/missing-token/download_as_image",
Status: 404,
RawBody: []byte("not found"),
ContentType: "text/plain",
})
args := []string{"+query", "--whiteboard-token", "missing-token", "--output_as", "image", "--output", "output"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatal("expected error for HTTP 404 download")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) || apiErr.Subtype != errs.SubtypeNotFound || apiErr.Code != 404 {
t.Fatalf("HTTP 404 should be api/not_found, got %T (%v)", err, err)
}
}
func TestWhiteboardQuery_DryRun(t *testing.T) {
t.Parallel()
@@ -370,6 +476,23 @@ func TestSaveOutputFile(t *testing.T) {
}
}
func TestSaveOutputFile_InvalidFinalPathTypedError(t *testing.T) {
chdirTemp(t)
rt := newTestRuntime(nil, nil)
_, _, err := saveOutputFile("../escape", ".png", "token123", rt, strings.NewReader("test content"))
if err == nil {
t.Fatal("expected error for unsafe final path")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error is not *errs.ValidationError: %T (%v)", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--output" {
t.Fatalf("validation details = subtype %q param %q, want %q --output", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument)
}
}
func newExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
config := &core.CliConfig{
@@ -705,10 +828,70 @@ func TestFetchWhiteboardNodes_APIError(t *testing.T) {
args := []string{"+query", "--whiteboard-token", "test-token-api-error", "--output_as", "raw"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
// We expect an error here, but don't fail the test because it's testing error path
if err == nil {
t.Fatalf("Expected API error, but got none")
}
// The nodes fetch now classifies the Lark error code into a typed envelope
// carrying the numeric code.
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not a typed errs.* envelope: %T (%v)", err, err)
}
if p.Code != 10001 {
t.Errorf("Problem.Code = %d, want 10001", p.Code)
}
}
func TestFetchWhiteboardNodes_InvalidResponseTypedError(t *testing.T) {
tests := []struct {
name string
token string
data map[string]interface{}
}{
{
name: "missing nodes",
token: "test-token-missing-nodes",
data: map[string]interface{}{},
},
{
name: "nodes not array",
token: "test-token-bad-nodes",
data: map[string]interface{}{"nodes": "not-an-array"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/board/v1/whiteboards/" + tt.token + "/nodes",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": tt.data,
},
})
args := []string{"+query", "--whiteboard-token", tt.token, "--output_as", "raw"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
assertInvalidResponse(t, err)
})
}
}
func assertInvalidResponse(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected invalid response error")
}
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("error is not *errs.InternalError: %T (%v)", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("Subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
}
// newTestRuntime creates a RuntimeContext with string flags for testing.

View File

@@ -12,10 +12,8 @@ import (
"net/url"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
const (
@@ -42,21 +40,21 @@ var wbUpdateFlags = []common.Flag{
func wbUpdateValidate(ctx context.Context, runtime *common.RuntimeContext) error {
// 检查 token 是否包含控制字符(空字符串下自动跳过了)
if err := validate.RejectControlChars(runtime.Str("whiteboard-token"), "whiteboard-token"); err != nil {
if err := common.RejectDangerousCharsTyped("--whiteboard-token", runtime.Str("whiteboard-token")); err != nil {
return err
}
itoken := runtime.Str("idempotent-token")
if err := validate.RejectControlChars(itoken, "idempotent-token"); err != nil {
if err := common.RejectDangerousCharsTyped("--idempotent-token", itoken); err != nil {
return err
}
if itoken != "" && len(itoken) < 10 {
return common.FlagErrorf("--idempotent-token must be at least 10 characters long.")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--idempotent-token must be at least 10 characters long.").WithParam("--idempotent-token")
}
// 检查 --input_format 标志
format := getFormat(runtime)
if format != FormatRaw && format != FormatPlantUML && format != FormatMermaid {
return common.FlagErrorf("--input_format must be one of: raw | plantuml | mermaid")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--input_format must be one of: raw | plantuml | mermaid").WithParam("--input_format")
}
return nil
}
@@ -116,7 +114,7 @@ func wbUpdateExecute(ctx context.Context, runtime *common.RuntimeContext) error
input := runtime.Str("source")
if input == "" {
return output.ErrValidation("read input failed: source is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "read input failed: source is required").WithParam("--source")
}
switch format {
@@ -125,7 +123,7 @@ func wbUpdateExecute(ctx context.Context, runtime *common.RuntimeContext) error
case FormatPlantUML, FormatMermaid:
return updateWhiteboardByCode(ctx, runtime, token, []byte(input), format, overwrite, idempotentToken)
default:
return output.ErrValidation(fmt.Sprintf("unsupported format: %s", format))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported format: %s", format).WithParam("--input_format")
}
}
@@ -160,15 +158,6 @@ var WhiteboardUpdateOld = common.Shortcut{
Execute: wbUpdateExecute,
}
type createResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
NodeIDs []string `json:"ids"`
IdempotentToken string `json:"client_token"`
} `json:"data"`
}
type plantumlCreateReq struct {
PlantUmlCode string `json:"plant_uml_code"`
SyntaxType int `json:"syntax_type"`
@@ -182,28 +171,20 @@ type rawNodesCreateReq struct {
Overwrite bool `json:"overwrite,omitempty"`
}
type plantumlCreateResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
NodeID string `json:"node_id"`
} `json:"data"`
}
func parseWBcliNodes(rawjson []byte) (wbNodes []interface{}, err error, isRaw bool) {
var wbOutput WbCliOutput
if err := json.Unmarshal(rawjson, &wbOutput); err != nil {
return nil, output.Errorf(output.ExitValidation, "parsing", fmt.Sprintf("unmarshal input json failed: %v", err)), false
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unmarshal input json failed: %v", err).WithParam("--source").WithCause(err), false
}
if (wbOutput.Code != 0 || wbOutput.Data.To != "openapi") && wbOutput.RawNodes == nil {
return nil, output.Errorf(output.ExitValidation, "whiteboard-cli", "whiteboard-cli failed. please check previous log."), false
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "whiteboard-cli failed. please check previous log.").WithParam("--source"), false
}
if wbOutput.RawNodes != nil {
wbNodes = wbOutput.RawNodes
isRaw = true
} else {
if wbOutput.Data.Result.Nodes == nil {
return nil, output.Errorf(output.ExitValidation, "whiteboard-cli", "whiteboard-cli failed. please check previous log."), false
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "whiteboard-cli failed. please check previous log.").WithParam("--source"), false
}
wbNodes = wbOutput.Data.Result.Nodes
}
@@ -221,39 +202,23 @@ func updateWhiteboardByCode(ctx context.Context, runtime *common.RuntimeContext,
Overwrite: overwrite,
}
req := &larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/plantuml", url.PathEscape(wbToken)),
Body: reqBody,
QueryParams: map[string][]string{},
}
params := map[string]interface{}{}
if idempotentToken != "" {
req.QueryParams["client_token"] = []string{idempotentToken}
params["client_token"] = idempotentToken
}
resp, err := runtime.DoAPI(req)
data, err := runtime.CallAPITyped(http.MethodPost, fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/plantuml", url.PathEscape(wbToken)), params, reqBody)
if err != nil {
return output.ErrNetwork(fmt.Sprintf("update whiteboard by code failed: %v", err))
}
if resp.StatusCode != http.StatusOK {
return output.ErrAPI(resp.StatusCode, string(resp.RawBody), nil)
return err
}
var createResp plantumlCreateResp
err = json.Unmarshal(resp.RawBody, &createResp)
if err != nil {
return output.Errorf(output.ExitInternal, "parsing", fmt.Sprintf("parse whiteboard create response failed: %v", err))
nodeID := common.GetString(data, "node_id")
if nodeID == "" {
return wbInvalidResponse("update whiteboard by code failed: missing data.node_id")
}
if createResp.Code != 0 {
return output.ErrAPI(createResp.Code, "update whiteboard by code failed", fmt.Sprintf("update whiteboard by code failed: %s", createResp.Msg))
}
outData := make(map[string]string)
outData["created_node_id"] = createResp.Data.NodeID
outData := map[string]string{"created_node_id": nodeID}
runtime.OutFormat(outData, nil, func(w io.Writer) {
if outData["created_node_id"] != "" {
fmt.Fprintf(w, "New node created.\n")
}
fmt.Fprintf(w, "New node created.\n")
fmt.Fprintf(w, "Update whiteboard success")
})
@@ -266,56 +231,67 @@ func updateWhiteboardByRawNodes(ctx context.Context, runtime *common.RuntimeCont
if err != nil {
return err
}
outData := make(map[string]string)
reqBody := rawNodesCreateReq{
Nodes: nodes,
Overwrite: overwrite,
}
req := &larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)),
Body: reqBody,
QueryParams: map[string][]string{},
}
params := map[string]interface{}{}
if idempotentToken != "" {
req.QueryParams["client_token"] = []string{idempotentToken}
params["client_token"] = idempotentToken
}
resp, err := runtime.DoAPI(req)
data, err := runtime.CallAPITyped(http.MethodPost, fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)), params, reqBody)
if err != nil {
return output.ErrNetwork(fmt.Sprintf("update whiteboard failed: %v", err))
}
if resp.StatusCode != http.StatusOK {
var detail string
// Raw open-api JSON is hand-edited far more often than the DSL path, so
// steer the user back to the recommended workflow on any API failure.
if isRaw {
detail = fmt.Sprintf("It is not advised to edit openapi format json directly. Please follow instruction in lark-whiteboard skill, " +
"using whiteboard-cli to transcript Whiteboard DSL pattern instead.")
if p, ok := errs.ProblemOf(err); ok {
rawHint := "It is not advised to edit openapi format json directly. " +
"Please follow instruction in lark-whiteboard skill, using whiteboard-cli " +
"to transcript Whiteboard DSL pattern instead."
if strings.TrimSpace(p.Hint) != "" {
p.Hint = p.Hint + "\n" + rawHint
} else {
p.Hint = rawHint
}
}
}
return output.ErrAPI(resp.StatusCode, string(resp.RawBody), detail)
return err
}
var createResp createResponse
err = json.Unmarshal(resp.RawBody, &createResp)
nodeIDs, err := stringSlice(data["ids"])
if err != nil {
return output.Errorf(output.ExitInternal, "parsing", fmt.Sprintf("parse whiteboard create response failed: %v", err))
return err
}
if createResp.Code != 0 {
detail := fmt.Sprintf("update whiteboard failed: %s", createResp.Msg)
if isRaw {
detail += fmt.Sprintf("\n It is not advised to edit openapi format json directly. Please follow instruction in lark-whiteboard skill, " +
"using whiteboard-cli to transcript Whiteboard DSL pattern instead.")
}
return output.ErrAPI(createResp.Code, "update whiteboard failed", detail)
}
outData["created_node_ids"] = strings.Join(createResp.Data.NodeIDs, ",")
outData := map[string]string{"created_node_ids": strings.Join(nodeIDs, ",")}
runtime.OutFormat(outData, nil, func(w io.Writer) {
if outData["created_node_ids"] != "" {
fmt.Fprintf(w, "%d new nodes created.\n", len(createResp.Data.NodeIDs))
fmt.Fprintf(w, "%d new nodes created.\n", len(nodeIDs))
}
fmt.Fprintf(w, "Update whiteboard success")
})
return nil
}
// stringSlice coerces the JSON ids array into []string. A missing or malformed
// ids field is a response-shape bug, not a successful update with no output.
func stringSlice(v interface{}) ([]string, error) {
switch raw := v.(type) {
case []interface{}:
out := make([]string, 0, len(raw))
for i, e := range raw {
s, ok := e.(string)
if !ok {
return nil, wbInvalidResponse("update whiteboard failed: data.ids[%d] must be a string", i)
}
out = append(out, s)
}
return out, nil
case []string:
return append([]string(nil), raw...), nil
default:
return nil, wbInvalidResponse("update whiteboard failed: data.ids must be an array of strings")
}
}

View File

@@ -6,9 +6,11 @@ package whiteboard
import (
"bytes"
"context"
"errors"
"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"
@@ -102,6 +104,54 @@ func TestWhiteboardUpdate_Validate(t *testing.T) {
}
}
// TestWhiteboardUpdate_Validate_TypedErrors locks the typed-envelope contract
// for +update input validation: failures are *errs.ValidationError with
// SubtypeInvalidArgument and the offending --flag. parseWBcliNodes likewise
// reports malformed --source input as a typed validation error.
func TestWhiteboardUpdate_Validate_TypedErrors(t *testing.T) {
ctx := context.Background()
t.Run("idempotent-token too short", func(t *testing.T) {
rt := newTestRuntime(map[string]string{
"whiteboard-token": "t",
"idempotent-token": "short",
"source": "{}",
}, nil)
assertValidationParam(t, wbUpdateValidate(ctx, rt), "--idempotent-token")
})
t.Run("bad input_format", func(t *testing.T) {
rt := newTestRuntime(map[string]string{
"whiteboard-token": "t",
"input_format": "svg",
"source": "{}",
}, nil)
assertValidationParam(t, wbUpdateValidate(ctx, rt), "--input_format")
})
t.Run("malformed source json", func(t *testing.T) {
_, err, _ := parseWBcliNodes([]byte("not-json"))
assertValidationParam(t, err, "--source")
})
}
func assertValidationParam(t *testing.T, err error, wantParam string) {
t.Helper()
if err == nil {
t.Fatalf("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error is not *errs.ValidationError: %T", err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != wantParam {
t.Errorf("Param = %q, want %q", ve.Param, wantParam)
}
}
func TestGetFormat(t *testing.T) {
t.Parallel()
@@ -360,6 +410,27 @@ Bob -> Alice : hello
}
}
func TestWhiteboardUpdateExecute_PlantUMLInvalidResponse(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-plantuml-invalid-response/nodes/plantuml",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
source := `@@startuml
Bob -> Alice : hello
@@enduml`
args := []string{"+update", "--whiteboard-token", "test-token-plantuml-invalid-response", "--input_format", "plantuml", "--source", source}
err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout)
assertInvalidResponse(t, err)
}
func TestWhiteboardUpdateExecute_MermaidFormat(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
@@ -384,6 +455,45 @@ A-->B`
}
}
func TestWhiteboardUpdateExecute_RawInvalidResponse(t *testing.T) {
tests := []struct {
name string
token string
data map[string]interface{}
}{
{
name: "missing ids",
token: "test-token-raw-missing-ids",
data: map[string]interface{}{},
},
{
name: "non-string id",
token: "test-token-raw-bad-id",
data: map[string]interface{}{"ids": []interface{}{"node1", 2}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/" + tt.token + "/nodes",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": tt.data,
},
})
source := `{"code":0,"data":{"to":"openapi","result":{"nodes":[]}}}`
args := []string{"+update", "--whiteboard-token", tt.token, "--input_format", "raw", "--source", source}
err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout)
assertInvalidResponse(t, err)
})
}
}
func TestWhiteboardUpdateExecute_RawWithIdempotent(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
@@ -444,12 +554,26 @@ func TestWhiteboardUpdateExecute_RawAPIError(t *testing.T) {
},
})
source := `{"code":0,"data":{"to":"openapi","result":{"nodes":[]}}}`
// Top-level "nodes" is the raw open-api format (isRaw=true), which triggers
// the raw-edit recovery hint on API failure.
source := `{"nodes":[{"type":"composite_shape"}]}`
args := []string{"+update", "--whiteboard-token", "test-token-raw-api-error", "--input_format", "raw", "--source", source}
err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout)
// We expect an error here, but don't fail the test because it's testing error path
if err == nil {
t.Logf("Expected API error, but got none")
t.Fatalf("expected API error, but got none")
}
// The update boundary now yields a typed envelope carrying the Lark code.
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not a typed errs.* envelope: %T (%v)", err, err)
}
if p.Code != 10001 {
t.Errorf("Problem.Code = %d, want 10001", p.Code)
}
// Raw (open-api JSON) input failures steer the user back to the recommended
// DSL workflow via a recovery hint on the typed envelope.
if !strings.Contains(p.Hint, "not advised to edit openapi format json directly") {
t.Errorf("Problem.Hint missing raw-edit guidance, got %q", p.Hint)
}
}
@@ -471,9 +595,15 @@ invalid
@@enduml`
args := []string{"+update", "--whiteboard-token", "test-token-plantuml-error", "--input_format", "plantuml", "--source", source}
err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout)
// We expect an error here, but don't fail the test because it's testing error path
if err == nil {
t.Logf("Expected API error, but got none")
t.Fatalf("expected API error, but got none")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not a typed errs.* envelope: %T (%v)", err, err)
}
if p.Code != 10001 {
t.Errorf("Problem.Code = %d, want 10001", p.Code)
}
}

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