Skip to content

Commit d8bd1df

Browse files
committed
feat(contact): emit typed error envelopes across the contact domain
1 parent 076f4d5 commit d8bd1df

14 files changed

Lines changed: 518 additions & 156 deletions

.golangci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,20 +73,20 @@ linters:
7373
- forbidigo
7474
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
7575
# Add a path when its migration is complete.
76-
- 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/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
76+
- 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/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
7777
text: errs-typed-only
7878
linters:
7979
- forbidigo
8080
# errs-no-bare-wrap enforced on paths fully migrated to typed final
8181
# errors. Scoped separately from errs-typed-only because cmd/auth/,
8282
# cmd/config/ still have residual fmt.Errorf and must not be caught.
83-
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go)
83+
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go)
8484
text: errs-no-bare-wrap
8585
linters:
8686
- forbidigo
8787
# errs-no-legacy-helper enforced on domains whose shared validation/save
8888
# helpers have migrated to typed final errors.
89-
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
89+
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
9090
text: errs-no-legacy-helper
9191
linters:
9292
- forbidigo

lint/errscontract/rule_no_legacy_common_helper_call.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
var migratedCommonHelperPaths = []string{
1818
"shortcuts/base/",
1919
"shortcuts/calendar/",
20+
"shortcuts/contact/",
2021
"shortcuts/drive/",
2122
"shortcuts/mail/",
2223
"shortcuts/minutes/",

lint/errscontract/rule_no_legacy_envelope_literal.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
var migratedEnvelopePaths = []string{
1919
"shortcuts/base/",
2020
"shortcuts/calendar/",
21+
"shortcuts/contact/",
2122
"shortcuts/drive/",
2223
"shortcuts/mail/",
2324
"shortcuts/minutes/",

lint/errscontract/rules_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -691,7 +691,7 @@ func boom() error {
691691
return &output.ExitError{Code: 1}
692692
}
693693
`
694-
v := CheckNoLegacyEnvelopeLiteral("shortcuts/contact/foo.go", src)
694+
v := CheckNoLegacyEnvelopeLiteral("shortcuts/unmigrated/foo.go", src)
695695
if len(v) != 0 {
696696
t.Errorf("non-migrated path should pass, got: %+v", v)
697697
}
@@ -907,7 +907,7 @@ func boom(runtime *common.RuntimeContext) error {
907907
return err
908908
}
909909
`
910-
v := CheckNoLegacyRuntimeAPICall("shortcuts/contact/contact_get.go", src)
910+
v := CheckNoLegacyRuntimeAPICall("shortcuts/unmigrated/sample.go", src)
911911
if len(v) != 0 {
912912
t.Errorf("non-migrated path must not fire, got: %+v", v)
913913
}
@@ -1006,7 +1006,7 @@ func boom() {
10061006
common.FlagErrorf("legacy allowed until domain migrates")
10071007
}
10081008
`
1009-
v := CheckNoLegacyCommonHelperCall("shortcuts/contact/contact_get.go", src)
1009+
v := CheckNoLegacyCommonHelperCall("shortcuts/unmigrated/sample.go", src)
10101010
if len(v) != 0 {
10111011
t.Errorf("non-migrated path must pass, got: %+v", v)
10121012
}

shortcuts/common/userids.go

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,8 @@ package common
66
import (
77
"fmt"
88
"strings"
9-
10-
"github.com/larksuite/cli/internal/output"
119
)
1210

13-
// ResolveOpenIDs expands the special identifier "me" to the current user's
14-
// open_id, removes duplicates case-insensitively while preserving the
15-
// first-occurrence form, and returns nil for an empty input. flagName is
16-
// used in error messages to point the user at the offending CLI flag.
17-
//
18-
// Deprecated: use ResolveOpenIDsTyped for typed error envelopes.
19-
func ResolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]string, error) {
20-
out, msg := resolveOpenIDs(flagName, ids, runtime)
21-
if msg != "" {
22-
return nil, output.ErrValidation("%s", msg)
23-
}
24-
return out, nil
25-
}
26-
2711
// ResolveOpenIDsTyped expands the special identifier "me" to the current
2812
// user's open_id, removes duplicates case-insensitively while preserving the
2913
// first-occurrence form, and returns nil for an empty input. flagName names

shortcuts/common/userids_test.go

Lines changed: 6 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ func resolveOpenIDsTestRuntime(userOpenID string) *RuntimeContext {
1717
return TestNewRuntimeContext(cmd, cfg)
1818
}
1919

20-
func TestResolveOpenIDs_Empty(t *testing.T) {
20+
func TestResolveOpenIDsTyped_Empty(t *testing.T) {
2121
rt := resolveOpenIDsTestRuntime("ou_self")
22-
out, err := ResolveOpenIDs("--user-ids", nil, rt)
22+
out, err := ResolveOpenIDsTyped("--user-ids", nil, rt)
2323
if err != nil {
2424
t.Fatalf("unexpected error: %v", err)
2525
}
@@ -28,21 +28,9 @@ func TestResolveOpenIDs_Empty(t *testing.T) {
2828
}
2929
}
3030

31-
func TestResolveOpenIDs_ExpandsMeAndDedups(t *testing.T) {
31+
func TestResolveOpenIDsTyped_MeIsCaseInsensitive(t *testing.T) {
3232
rt := resolveOpenIDsTestRuntime("ou_self")
33-
out, err := ResolveOpenIDs("--user-ids", []string{"me", "ou_a", "me", "ou_a"}, rt)
34-
if err != nil {
35-
t.Fatalf("unexpected error: %v", err)
36-
}
37-
want := []string{"ou_self", "ou_a"}
38-
if len(out) != len(want) || out[0] != want[0] || out[1] != want[1] {
39-
t.Fatalf("got %v, want %v", out, want)
40-
}
41-
}
42-
43-
func TestResolveOpenIDs_MeIsCaseInsensitive(t *testing.T) {
44-
rt := resolveOpenIDsTestRuntime("ou_self")
45-
out, err := ResolveOpenIDs("--user-ids", []string{"ou_other", "me", "Me", "ME"}, rt)
33+
out, err := ResolveOpenIDsTyped("--user-ids", []string{"ou_other", "me", "Me", "ME"}, rt)
4634
if err != nil {
4735
t.Fatalf("unexpected error: %v", err)
4836
}
@@ -52,22 +40,11 @@ func TestResolveOpenIDs_MeIsCaseInsensitive(t *testing.T) {
5240
}
5341
}
5442

55-
func TestResolveOpenIDs_MeWithoutLogin(t *testing.T) {
56-
rt := resolveOpenIDsTestRuntime("")
57-
_, err := ResolveOpenIDs("--user-ids", []string{"me"}, rt)
58-
if err == nil {
59-
t.Fatal("expected validation error")
60-
}
61-
if !strings.Contains(err.Error(), "--user-ids") {
62-
t.Fatalf("error should mention the offending flag name; got: %v", err)
63-
}
64-
}
65-
66-
func TestResolveOpenIDs_DedupIsCaseInsensitive(t *testing.T) {
43+
func TestResolveOpenIDsTyped_DedupIsCaseInsensitive(t *testing.T) {
6744
rt := resolveOpenIDsTestRuntime("ou_self")
6845
// Same underlying open_id with three case variants — should collapse to
6946
// one entry, preserving the first-occurrence form.
70-
out, err := ResolveOpenIDs("--user-ids", []string{"ou_abc123", "OU_ABC123", "Ou_Abc123"}, rt)
47+
out, err := ResolveOpenIDsTyped("--user-ids", []string{"ou_abc123", "OU_ABC123", "Ou_Abc123"}, rt)
7148
if err != nil {
7249
t.Fatalf("unexpected error: %v", err)
7350
}

shortcuts/common/validate_ids.go

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ package common
55

66
import (
77
"strings"
8-
9-
"github.com/larksuite/cli/internal/output"
108
)
119

1210
// ValidateChatIDTyped checks if a chat ID has valid format (oc_ prefix).
@@ -42,17 +40,6 @@ func normalizeChatID(input string) (string, string) {
4240
return input, ""
4341
}
4442

45-
// ValidateUserID checks if a user ID has valid format (ou_ prefix).
46-
//
47-
// Deprecated: use ValidateUserIDTyped for typed error envelopes.
48-
func ValidateUserID(input string) (string, error) {
49-
userID, msg := normalizeUserID(input)
50-
if msg != "" {
51-
return "", output.ErrValidation("%s", msg)
52-
}
53-
return userID, nil
54-
}
55-
5643
// ValidateUserIDTyped checks if a user ID has valid format (ou_ prefix).
5744
// param names the flag being validated (e.g. "--creator-ids") and is
5845
// recorded on the typed error.
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package contact
5+
6+
import (
7+
"errors"
8+
"fmt"
9+
"net/http"
10+
"strings"
11+
12+
"github.com/larksuite/cli/errs"
13+
)
14+
15+
const contactFanoutRetryHint = "retry the command; if it persists, narrow --queries to a single term to isolate the failing input"
16+
17+
func contactInvalidResponseError(format string, args ...any) *errs.InternalError {
18+
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...)
19+
}
20+
21+
func contactFanoutErrorSummary(err error) string {
22+
if p, ok := errs.ProblemOf(err); ok {
23+
if p.Code >= 100 && p.Code < 600 {
24+
prefix := fmt.Sprintf("HTTP %d:", p.Code)
25+
body := strings.TrimSpace(strings.TrimPrefix(p.Message, prefix))
26+
msg := fmt.Sprintf("HTTP %d %s", p.Code, http.StatusText(p.Code))
27+
if body != "" {
28+
msg = fmt.Sprintf("%s: %s", msg, contactTruncateError(body, 200))
29+
}
30+
return msg
31+
}
32+
if p.Code != 0 {
33+
return fmt.Sprintf("API %d: %s", p.Code, p.Message)
34+
}
35+
return p.Message
36+
}
37+
return err.Error()
38+
}
39+
40+
// contactFanoutAllFailedError builds the top-level error returned when every
41+
// fanout query fails. It mirrors the representative (first) failure's
42+
// classification — category, subtype, code, log_id, retryable, hint — so the
43+
// exit-code classifier still sees the real signal, while carrying the aggregate
44+
// message. The representative error is copied (never mutated) and kept as the
45+
// cause, so a single-query problem object is not rewritten into an aggregate one.
46+
func contactFanoutAllFailedError(err error, msg string) error {
47+
var (
48+
apiErr *errs.APIError
49+
netErr *errs.NetworkError
50+
intErr *errs.InternalError
51+
)
52+
switch {
53+
case errors.As(err, &apiErr):
54+
c := *apiErr
55+
c.Message = msg
56+
c.Cause = err
57+
return &c
58+
case errors.As(err, &netErr):
59+
c := *netErr
60+
c.Message = msg
61+
c.Cause = err
62+
return &c
63+
case errors.As(err, &intErr):
64+
c := *intErr
65+
c.Message = msg
66+
c.Cause = err
67+
return &c
68+
}
69+
return errs.NewInternalError(errs.SubtypeUnknown, "%s", msg).WithHint(contactFanoutRetryHint).WithCause(err)
70+
}
71+
72+
func contactTruncateError(s string, maxRunes int) string {
73+
r := []rune(s)
74+
if len(r) <= maxRunes {
75+
return s
76+
}
77+
return string(r[:maxRunes]) + "..."
78+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package contact
5+
6+
import (
7+
"errors"
8+
"strings"
9+
"testing"
10+
11+
"github.com/larksuite/cli/errs"
12+
)
13+
14+
func TestContactFanoutErrorSummary_HTTPStatus(t *testing.T) {
15+
err := errs.NewNetworkError(errs.SubtypeNetworkServer, `HTTP 503: {"reason":"upstream_unavailable"}`).
16+
WithCode(503).
17+
WithRetryable()
18+
19+
got := contactFanoutErrorSummary(err)
20+
if !strings.HasPrefix(got, "HTTP 503 Service Unavailable: ") {
21+
t.Fatalf("summary: got %q", got)
22+
}
23+
if !strings.Contains(got, "upstream_unavailable") {
24+
t.Fatalf("summary should include truncated body details, got %q", got)
25+
}
26+
}
27+
28+
func TestContactInvalidResponseError_TypedInternal(t *testing.T) {
29+
got := contactInvalidResponseError("decode contact response failed")
30+
p, ok := errs.ProblemOf(got)
31+
if !ok {
32+
t.Fatalf("expected typed problem, got %T", got)
33+
}
34+
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
35+
t.Fatalf("problem type: got %s/%s", p.Category, p.Subtype)
36+
}
37+
}
38+
39+
func TestContactFanoutAllFailedError_PreservesTypedProblem(t *testing.T) {
40+
err := errs.NewAPIError(errs.SubtypeRateLimit, "rate limit").
41+
WithCode(99991663).
42+
WithLogID("log-contact-1").
43+
WithRetryable()
44+
45+
got := contactFanoutAllFailedError(err, "all 2 queries failed; first: API 99991663: rate limit (query=\"alice\")")
46+
p, ok := errs.ProblemOf(got)
47+
if !ok {
48+
t.Fatalf("expected typed problem, got %T", got)
49+
}
50+
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeRateLimit {
51+
t.Fatalf("problem type: got %s/%s", p.Category, p.Subtype)
52+
}
53+
if p.Code != 99991663 || p.LogID != "log-contact-1" || !p.Retryable {
54+
t.Fatalf("problem metadata not preserved: %+v", p)
55+
}
56+
if !strings.Contains(p.Message, "all 2 queries failed") {
57+
t.Fatalf("problem message not decorated: %q", p.Message)
58+
}
59+
// The representative error must not be mutated: it stays a single-query
60+
// failure, while the aggregate is a distinct value carrying it as cause.
61+
if err.Message != "rate limit" {
62+
t.Fatalf("representative error message was mutated: %q", err.Message)
63+
}
64+
if !errors.Is(got, err) {
65+
t.Fatalf("aggregate error should keep the representative failure as its cause")
66+
}
67+
}
68+
69+
func TestContactFanoutAllFailedError_UntypedGetsActionableHint(t *testing.T) {
70+
got := contactFanoutAllFailedError(nil, "all 2 queries failed; first: internal error (query=\"alice\")")
71+
p, ok := errs.ProblemOf(got)
72+
if !ok {
73+
t.Fatalf("expected typed problem, got %T", got)
74+
}
75+
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeUnknown {
76+
t.Fatalf("problem type: got %s/%s", p.Category, p.Subtype)
77+
}
78+
if !strings.Contains(p.Hint, "narrow --queries") {
79+
t.Fatalf("hint should guide recovery, got %q", p.Hint)
80+
}
81+
}

shortcuts/contact/contact_get_user.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ var ContactGetUser = common.Shortcut{
2828
},
2929
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
3030
if runtime.Str("user-id") == "" && runtime.IsBot() {
31-
return common.FlagErrorf("bot identity cannot get current user info, specify --user-id")
31+
return common.ValidationErrorf("bot identity cannot get current user info, specify --user-id").
32+
WithParam("--user-id")
3233
}
3334
return nil
3435
},
@@ -63,7 +64,7 @@ var ContactGetUser = common.Shortcut{
6364

6465
if userId == "" {
6566
// Current user
66-
data, err := runtime.CallAPI("GET", "/open-apis/authen/v1/user_info", nil, nil)
67+
data, err := runtime.CallAPITyped("GET", "/open-apis/authen/v1/user_info", nil, nil)
6768
if err != nil {
6869
return err
6970
}
@@ -87,7 +88,7 @@ var ContactGetUser = common.Shortcut{
8788

8889
if runtime.IsBot() {
8990
// Bot identity: GET /contact/v3/users/:user_id (full profile)
90-
data, err := runtime.CallAPI("GET", "/open-apis/contact/v3/users/"+url.PathEscape(userId),
91+
data, err := runtime.CallAPITyped("GET", "/open-apis/contact/v3/users/"+url.PathEscape(userId),
9192
map[string]interface{}{"user_id_type": userIdType}, nil)
9293
if err != nil {
9394
return err
@@ -110,7 +111,7 @@ var ContactGetUser = common.Shortcut{
110111
}
111112

112113
// User identity: POST /contact/v3/users/basic_batch (lightweight)
113-
data, err := runtime.CallAPI("POST", "/open-apis/contact/v3/users/basic_batch",
114+
data, err := runtime.CallAPITyped("POST", "/open-apis/contact/v3/users/basic_batch",
114115
map[string]interface{}{"user_id_type": userIdType},
115116
map[string]interface{}{"user_ids": []string{userId}})
116117
if err != nil {

0 commit comments

Comments
 (0)