From e02039185e7d0f53bc06c11ab059d2b573842004 Mon Sep 17 00:00:00 2001 From: "zhangheng.023" Date: Tue, 21 Jul 2026 16:53:30 +0800 Subject: [PATCH 1/8] feat(im): add chat-members-add validation --- shortcuts/im/im_chat_members_add.go | 72 +++++++++++ shortcuts/im/im_chat_members_add_test.go | 154 +++++++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 shortcuts/im/im_chat_members_add.go create mode 100644 shortcuts/im/im_chat_members_add_test.go diff --git a/shortcuts/im/im_chat_members_add.go b/shortcuts/im/im_chat_members_add.go new file mode 100644 index 0000000000..c0d3091d19 --- /dev/null +++ b/shortcuts/im/im_chat_members_add.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +const imChatMembersAddPathFmt = "/open-apis/im/v1/chats/%s/members" + +const ( + chatMembersAddMaxUsers = 50 + chatMembersAddMaxBots = 5 +) + +// collectMemberAddIDs reads a string_slice flag, trims/dedupes its values, +// and enforces the ID prefix and the per-call count limit dictated by +// chat.members.create (50 users / 5 bots per request). +func collectMemberAddIDs(runtime *common.RuntimeContext, flag, prefix string, max int) ([]string, error) { + raw := runtime.StrSlice(flag) + 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, prefix) { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, + "invalid --%s value %q: must start with %q", flag, v, prefix).WithParam("--" + flag) + } + if _, dup := seen[v]; dup { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + if len(out) > max { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--%s exceeds the maximum of %d (got %d)", flag, max, len(out)).WithParam("--" + flag) + } + return out, nil +} + +// validateChatMembersAdd checks --chat-id format and that --users/--bots +// each satisfy their prefix and count-limit rules, and that at least one of +// them is non-empty. All checks happen locally so bad input never reaches +// the API layer. +func validateChatMembersAdd(runtime *common.RuntimeContext) error { + chatID := strings.TrimSpace(runtime.Str("chat-id")) + if !strings.HasPrefix(chatID, "oc_") { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "invalid --chat-id %q: must be an open_chat_id starting with oc_", chatID).WithParam("--chat-id") + } + + users, err := collectMemberAddIDs(runtime, "users", "ou_", chatMembersAddMaxUsers) + if err != nil { + return err + } + bots, err := collectMemberAddIDs(runtime, "bots", "cli_", chatMembersAddMaxBots) + if err != nil { + return err + } + if len(users) == 0 && len(bots) == 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "at least one of --users or --bots is required") + } + return nil +} diff --git a/shortcuts/im/im_chat_members_add_test.go b/shortcuts/im/im_chat_members_add_test.go new file mode 100644 index 0000000000..fb26169466 --- /dev/null +++ b/shortcuts/im/im_chat_members_add_test.go @@ -0,0 +1,154 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "errors" + "fmt" + "net/http" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func TestCollectMemberAddIDs(t *testing.T) { + cases := []struct { + name string + flag string + prefix string + max int + raw []string + want []string + wantErr bool + }{ + {"empty is ok", "users", "ou_", chatMembersAddMaxUsers, nil, []string{}, false}, + {"dedupes", "users", "ou_", chatMembersAddMaxUsers, []string{"ou_a", "ou_a", "ou_b"}, []string{"ou_a", "ou_b"}, false}, + {"trims whitespace", "users", "ou_", chatMembersAddMaxUsers, []string{" ou_a ", ""}, []string{"ou_a"}, false}, + {"wrong prefix", "users", "ou_", chatMembersAddMaxUsers, []string{"cli_a"}, nil, true}, + {"over limit", "bots", "cli_", 2, []string{"cli_a", "cli_b", "cli_c"}, nil, true}, + } + for _, c := range cases { + got, err := collectMemberAddIDs(newBareTestRuntime(t, map[string][]string{c.flag: c.raw}), c.flag, c.prefix, c.max) + if c.wantErr { + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Errorf("%s: want *errs.ValidationError, got %T (%v)", c.name, err, err) + } + continue + } + if err != nil { + t.Fatalf("%s: unexpected error %v", c.name, err) + } + if !equalStringSlices(got, c.want) { + t.Errorf("%s: got %v, want %v", c.name, got, c.want) + } + } +} + +func equalStringSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestValidateChatMembersAdd(t *testing.T) { + cases := []struct { + name string + chatID string + users []string + bots []string + wantErr bool + wantParam string + }{ + {"valid users only", "oc_x", []string{"ou_a"}, nil, false, ""}, + {"valid bots only", "oc_x", nil, []string{"cli_a"}, false, ""}, + {"valid both", "oc_x", []string{"ou_a"}, []string{"cli_a"}, false, ""}, + {"missing chat-id", "", []string{"ou_a"}, nil, true, "--chat-id"}, + {"bad chat-id prefix", "abc", []string{"ou_a"}, nil, true, "--chat-id"}, + {"neither users nor bots", "oc_x", nil, nil, true, ""}, + } + for _, c := range cases { + rt := newChatMembersAddTestRuntime(t, nil, map[string]string{"chat-id": c.chatID}, map[string][]string{"users": c.users, "bots": c.bots}) + err := validateChatMembersAdd(rt) + if c.wantErr { + if err == nil { + t.Errorf("%s: want error, got nil", c.name) + continue + } + if c.wantParam != "" { + assertValidationError(t, c.name, err, c.wantParam) + } + continue + } + if err != nil { + t.Errorf("%s: unexpected error %v", c.name, err) + } + } +} + +// newBareTestRuntime builds a runtime with only string_slice flags registered +// (no HTTP transport needed) for pure-function tests like collectMemberAddIDs. +func newBareTestRuntime(t *testing.T, slices map[string][]string) *common.RuntimeContext { + t.Helper() + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("unexpected request: %s", req.URL.String()) + })) + cmd := &cobra.Command{Use: "test"} + for flag := range slices { + cmd.Flags().StringSlice(flag, nil, "") + } + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + for flag, vals := range slices { + for _, v := range vals { + if err := cmd.Flags().Set(flag, v); err != nil { + t.Fatalf("set %s: %v", flag, err) + } + } + } + rt.Cmd = cmd + return rt +} + +// newChatMembersAddTestRuntime wires --chat-id/--users/--bots for the full +// Validate/DryRun/Execute surface. +func newChatMembersAddTestRuntime(t *testing.T, rtRoundTripper http.RoundTripper, str map[string]string, slices map[string][]string) *common.RuntimeContext { + t.Helper() + if rtRoundTripper == nil { + rtRoundTripper = shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("unexpected request: %s", req.URL.String()) + }) + } + runtime := newUserShortcutRuntime(t, rtRoundTripper) + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("chat-id", "", "") + cmd.Flags().StringSlice("users", nil, "") + cmd.Flags().StringSlice("bots", nil, "") + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + for k, v := range str { + if err := cmd.Flags().Set(k, v); err != nil { + t.Fatalf("set %s: %v", k, err) + } + } + for flag, vals := range slices { + for _, v := range vals { + if err := cmd.Flags().Set(flag, v); err != nil { + t.Fatalf("set %s: %v", flag, err) + } + } + } + runtime.Cmd = cmd + return runtime +} From a022d522386dfb388df950d54a2a67d1a394de12 Mon Sep 17 00:00:00 2001 From: "zhangheng.023" Date: Tue, 21 Jul 2026 17:01:08 +0800 Subject: [PATCH 2/8] feat(im): merge chat-members-add batch results into a ledger --- shortcuts/im/im_chat_members_add.go | 86 ++++++++++++++++++++++++ shortcuts/im/im_chat_members_add_test.go | 66 ++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/shortcuts/im/im_chat_members_add.go b/shortcuts/im/im_chat_members_add.go index c0d3091d19..1d069ccc07 100644 --- a/shortcuts/im/im_chat_members_add.go +++ b/shortcuts/im/im_chat_members_add.go @@ -4,10 +4,14 @@ package im import ( + "fmt" + "net/http" "strings" "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" ) const imChatMembersAddPathFmt = "/open-apis/im/v1/chats/%s/members" @@ -70,3 +74,85 @@ func validateChatMembersAdd(runtime *common.RuntimeContext) error { } return nil } + +// chatMembersAddResult is the merged ledger across the users-call and the +// bots-call. +type chatMembersAddResult struct { + succeeded []string + invalid []string + notExisted []string + pendingApproval []string + callErrors []map[string]interface{} +} + +func newChatMembersAddResult() *chatMembersAddResult { + return &chatMembersAddResult{ + succeeded: []string{}, + invalid: []string{}, + notExisted: []string{}, + pendingApproval: []string{}, + callErrors: []map[string]interface{}{}, + } +} + +// addChatMembersBatch issues one chat.members.create call for a single +// member_id_type and folds the outcome into res. A full-call error (e.g. +// missing scope, chat-wide bot cap exceeded) is recorded as a call_errors +// entry carrying the affected id_list, rather than aborting the other call. +func addChatMembersBatch(runtime *common.RuntimeContext, chatID, memberType, memberIDType string, ids []string, res *chatMembersAddResult) { + path := fmt.Sprintf(imChatMembersAddPathFmt, validate.EncodePathSegment(chatID)) + data, err := runtime.DoAPIJSONTyped(http.MethodPost, path, + larkcore.QueryParams{ + "member_id_type": []string{memberIDType}, + "succeed_type": []string{"1"}, + }, + map[string]interface{}{"id_list": ids}, + ) + if err != nil { + res.callErrors = append(res.callErrors, map[string]interface{}{ + "member_type": memberType, + "id_list": ids, + "error": err.Error(), + }) + return + } + + invalid := stringsFromAny(data["invalid_id_list"]) + notExisted := stringsFromAny(data["not_existed_id_list"]) + pending := stringsFromAny(data["pending_approval_id_list"]) + res.invalid = append(res.invalid, invalid...) + res.notExisted = append(res.notExisted, notExisted...) + res.pendingApproval = append(res.pendingApproval, pending...) + + failed := make(map[string]struct{}, len(invalid)+len(notExisted)+len(pending)) + for _, id := range invalid { + failed[id] = struct{}{} + } + for _, id := range notExisted { + failed[id] = struct{}{} + } + for _, id := range pending { + failed[id] = struct{}{} + } + for _, id := range ids { + if _, isFailed := failed[id]; !isFailed { + res.succeeded = append(res.succeeded, id) + } + } +} + +// stringsFromAny converts a JSON-decoded []interface{} of strings to []string, +// skipping any non-string entries defensively. +func stringsFromAny(v interface{}) []string { + arr, ok := v.([]interface{}) + if !ok { + return nil + } + out := make([]string, 0, len(arr)) + for _, item := range arr { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out +} diff --git a/shortcuts/im/im_chat_members_add_test.go b/shortcuts/im/im_chat_members_add_test.go index fb26169466..a88c04b817 100644 --- a/shortcuts/im/im_chat_members_add_test.go +++ b/shortcuts/im/im_chat_members_add_test.go @@ -152,3 +152,69 @@ func newChatMembersAddTestRuntime(t *testing.T, rtRoundTripper http.RoundTripper runtime.Cmd = cmd return runtime } + +func TestAddChatMembersBatch_AllSucceed(t *testing.T) { + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": map[string]interface{}{}}), nil + })) + res := newChatMembersAddResult() + addChatMembersBatch(rt, "oc_x", "user", "open_id", []string{"ou_a", "ou_b"}, res) + + if !equalStringSlices(res.succeeded, []string{"ou_a", "ou_b"}) { + t.Errorf("succeeded = %v, want [ou_a ou_b]", res.succeeded) + } + if len(res.invalid) != 0 || len(res.notExisted) != 0 || len(res.pendingApproval) != 0 || len(res.callErrors) != 0 { + t.Errorf("expected no failures, got %+v", res) + } +} + +func TestAddChatMembersBatch_PartialFailure(t *testing.T) { + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return shortcutJSONResponse(200, map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "invalid_id_list": []interface{}{"ou_c"}, + "not_existed_id_list": []interface{}{"ou_d"}, + "pending_approval_id_list": []interface{}{"ou_e"}, + }, + }), nil + })) + res := newChatMembersAddResult() + addChatMembersBatch(rt, "oc_x", "user", "open_id", []string{"ou_a", "ou_b", "ou_c", "ou_d", "ou_e"}, res) + + if !equalStringSlices(res.succeeded, []string{"ou_a", "ou_b"}) { + t.Errorf("succeeded = %v, want [ou_a ou_b]", res.succeeded) + } + if !equalStringSlices(res.invalid, []string{"ou_c"}) { + t.Errorf("invalid = %v, want [ou_c]", res.invalid) + } + if !equalStringSlices(res.notExisted, []string{"ou_d"}) { + t.Errorf("notExisted = %v, want [ou_d]", res.notExisted) + } + if !equalStringSlices(res.pendingApproval, []string{"ou_e"}) { + t.Errorf("pendingApproval = %v, want [ou_e]", res.pendingApproval) + } +} + +func TestAddChatMembersBatch_CallLevelFailure(t *testing.T) { + rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return shortcutJSONResponse(400, map[string]interface{}{"code": 123, "msg": "bot count exceeds chat limit"}), nil + })) + res := newChatMembersAddResult() + addChatMembersBatch(rt, "oc_x", "bot", "app_id", []string{"cli_y"}, res) + + if len(res.succeeded) != 0 { + t.Errorf("succeeded = %v, want empty (call failed)", res.succeeded) + } + if len(res.callErrors) != 1 { + t.Fatalf("callErrors = %v, want 1 entry", res.callErrors) + } + ce := res.callErrors[0] + if ce["member_type"] != "bot" { + t.Errorf("call_errors[0].member_type = %v, want bot", ce["member_type"]) + } + ids, _ := ce["id_list"].([]string) + if !equalStringSlices(ids, []string{"cli_y"}) { + t.Errorf("call_errors[0].id_list = %v, want [cli_y]", ids) + } +} From 597567f8afefb53918f2093e7a957dc248629d99 Mon Sep 17 00:00:00 2001 From: "zhangheng.023" Date: Tue, 21 Jul 2026 17:21:15 +0800 Subject: [PATCH 3/8] feat(im): add +chat-members-add shortcut --- shortcuts/im/im_chat_members_add.go | 142 ++++++++++++++++++++++- shortcuts/im/im_chat_members_add_test.go | 137 ++++++++++++++++++++++ 2 files changed, 278 insertions(+), 1 deletion(-) diff --git a/shortcuts/im/im_chat_members_add.go b/shortcuts/im/im_chat_members_add.go index 1d069ccc07..2772ae4038 100644 --- a/shortcuts/im/im_chat_members_add.go +++ b/shortcuts/im/im_chat_members_add.go @@ -4,6 +4,7 @@ package im import ( + "context" "fmt" "net/http" "strings" @@ -76,13 +77,17 @@ func validateChatMembersAdd(runtime *common.RuntimeContext) error { } // chatMembersAddResult is the merged ledger across the users-call and the -// bots-call. +// bots-call. rawCallErrors is parallel to callErrors (same append order) and +// carries the original typed error instead of its stringified form, so the +// caller can classify it (e.g. auth/permission) without re-parsing error +// text; it never enters the JSON output. type chatMembersAddResult struct { succeeded []string invalid []string notExisted []string pendingApproval []string callErrors []map[string]interface{} + rawCallErrors []error } func newChatMembersAddResult() *chatMembersAddResult { @@ -114,6 +119,7 @@ func addChatMembersBatch(runtime *common.RuntimeContext, chatID, memberType, mem "id_list": ids, "error": err.Error(), }) + res.rawCallErrors = append(res.rawCallErrors, err) return } @@ -156,3 +162,137 @@ func stringsFromAny(v interface{}) []string { } return out } + +// ImChatMembersAdd is the +chat-members-add shortcut: adds users and/or bots +// to a group chat. --users (open_id) and --bots (app_id) map to two +// independent underlying calls to POST /open-apis/im/v1/chats/{chat_id}/members +// (member_id_type differs per call — a single call cannot mix open_id and +// app_id, because chat.members.create only accepts one member_id_type per +// request). Both calls use succeed_type=1 (best effort): valid IDs are added +// even if others are invalid/nonexistent/pending approval, so a single +// resigned user does not block everyone else. Results are merged into one +// ledger so the caller doesn't have to reconcile two API responses by hand. +var ImChatMembersAdd = common.Shortcut{ + Service: "im", + Command: "+chat-members-add", + Description: "Add users and/or bots to a group chat; user/bot; batches --users (open_id) and --bots (app_id) into up to 2 API calls under best-effort semantics; returns a merged succeeded/invalid/not_existed/pending_approval ledger", + Risk: "write", + Scopes: []string{"im:chat", "im:chat.members:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "chat-id", Required: true, Desc: "chat ID to add members to (oc_xxx)"}, + {Name: "users", Type: "string_slice", Desc: "user open_ids to invite (ou_xxx); comma-separated or repeat the flag; max 50"}, + {Name: "bots", Type: "string_slice", Desc: "bot app_ids to invite (cli_xxx); comma-separated or repeat the flag; max 5"}, + }, + Tips: []string{ + "lark-cli im +chat-members-add --chat-id oc_xxx --users ou_a,ou_b --bots cli_x", + "--users and --bots are independent; at least one is required, but providing both issues two API calls internally and merges the results into one ledger.", + "Partial failures (resigned users, nonexistent IDs, pending approval) don't block the other valid IDs from being added — check failure_count and the per-reason lists in the result.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateChatMembersAdd(runtime) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + chatID := strings.TrimSpace(runtime.Str("chat-id")) + users, _ := collectMemberAddIDs(runtime, "users", "ou_", chatMembersAddMaxUsers) + bots, _ := collectMemberAddIDs(runtime, "bots", "cli_", chatMembersAddMaxBots) + path := fmt.Sprintf(imChatMembersAddPathFmt, validate.EncodePathSegment(chatID)) + + dry := common.NewDryRunAPI() + if len(users) > 0 { + dry.POST(path). + Params(map[string]interface{}{"member_id_type": "open_id", "succeed_type": 1}). + Body(map[string]interface{}{"id_list": users}) + } + if len(bots) > 0 { + dry.POST(path). + Params(map[string]interface{}{"member_id_type": "app_id", "succeed_type": 1}). + Body(map[string]interface{}{"id_list": bots}) + } + return dry + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeChatMembersAdd(runtime) + }, +} + +// executeChatMembersAdd issues one call per non-empty member list and merges +// the results. The two calls are independent: a full-call failure on one +// (e.g. bot count over the chat-wide cap) does not prevent the other from +// running or from having its successes reported. +func executeChatMembersAdd(runtime *common.RuntimeContext) error { + chatID := strings.TrimSpace(runtime.Str("chat-id")) + users, err := collectMemberAddIDs(runtime, "users", "ou_", chatMembersAddMaxUsers) + if err != nil { + return err + } + bots, err := collectMemberAddIDs(runtime, "bots", "cli_", chatMembersAddMaxBots) + if err != nil { + return err + } + + res := newChatMembersAddResult() + attempted := 0 + if len(users) > 0 { + attempted++ + addChatMembersBatch(runtime, chatID, "user", "open_id", users, res) + } + if len(bots) > 0 { + attempted++ + addChatMembersBatch(runtime, chatID, "bot", "app_id", bots, res) + } + + // Per spec: only when BOTH attempted calls failed, and both failures + // classify as auth/permission errors, surface the typed error directly + // instead of building a ledger. A single attempted call failing this way + // still falls through to the normal ledger path below. + if attempted == 2 && len(res.rawCallErrors) == 2 && allAuthClassified(res.rawCallErrors) { + return res.rawCallErrors[0] + } + + total := len(users) + len(bots) + failureCount := len(res.invalid) + len(res.notExisted) + len(res.pendingApproval) + for _, ce := range res.callErrors { + if ids, ok := ce["id_list"].([]string); ok { + failureCount += len(ids) + } + } + successCount := total - failureCount + + outData := map[string]interface{}{ + "chat_id": chatID, + "succeeded_id_list": res.succeeded, + "invalid_id_list": res.invalid, + "not_existed_id_list": res.notExisted, + "pending_approval_id_list": res.pendingApproval, + "call_errors": res.callErrors, + "success_count": successCount, + "failure_count": failureCount, + "total": total, + } + + if failureCount > 0 { + return runtime.OutPartialFailure(outData, nil) + } + runtime.Out(outData, nil) + return nil +} + +// allAuthClassified reports whether every error in errsList classifies as an +// auth/permission-category typed error (errs.CategoryAuthentication or +// errs.CategoryAuthorization). An empty slice is not "all classified" — the +// caller only calls this when len(errsList) == 2, but the explicit false +// guards against a future call site passing an empty slice by mistake. +func allAuthClassified(errsList []error) bool { + if len(errsList) == 0 { + return false + } + for _, e := range errsList { + cat := errs.CategoryOf(e) + if cat != errs.CategoryAuthentication && cat != errs.CategoryAuthorization { + return false + } + } + return true +} diff --git a/shortcuts/im/im_chat_members_add_test.go b/shortcuts/im/im_chat_members_add_test.go index a88c04b817..866ef4fc48 100644 --- a/shortcuts/im/im_chat_members_add_test.go +++ b/shortcuts/im/im_chat_members_add_test.go @@ -4,12 +4,16 @@ package im import ( + "context" + "encoding/json" "errors" "fmt" "net/http" + "strings" "testing" "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) @@ -218,3 +222,136 @@ func TestAddChatMembersBatch_CallLevelFailure(t *testing.T) { t.Errorf("call_errors[0].id_list = %v, want [cli_y]", ids) } } + +func TestImChatMembersAddExecute_AllSucceed(t *testing.T) { + var gotPaths []string + rt := newChatMembersAddTestRuntime(t, + shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + gotPaths = append(gotPaths, req.URL.Path+"?"+req.URL.RawQuery) + return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": map[string]interface{}{}}), nil + }), + map[string]string{"chat-id": "oc_x"}, + map[string][]string{"users": {"ou_a"}, "bots": {"cli_x"}}, + ) + + err := ImChatMembersAdd.Execute(context.Background(), rt) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if len(gotPaths) != 2 { + t.Fatalf("want 2 API calls (users + bots), got %d: %v", len(gotPaths), gotPaths) + } + + out := rt.Factory.IOStreams.Out.(interface{ String() string }).String() + if !strings.Contains(out, `"ok": true`) { + t.Errorf("output = %s, want ok:true", out) + } + if !strings.Contains(out, `"success_count": 2`) { + t.Errorf("output = %s, want success_count 2", out) + } +} + +func TestImChatMembersAddExecute_PartialFailure(t *testing.T) { + rt := newChatMembersAddTestRuntime(t, + shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Query().Get("member_id_type") == "open_id" { + return shortcutJSONResponse(200, map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"invalid_id_list": []interface{}{"ou_c"}}, + }), nil + } + return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": map[string]interface{}{}}), nil + }), + map[string]string{"chat-id": "oc_x"}, + map[string][]string{"users": {"ou_a", "ou_c"}, "bots": {"cli_x"}}, + ) + + err := ImChatMembersAdd.Execute(context.Background(), rt) + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) { + t.Fatalf("Execute() error = %T %v, want partial failure", err, err) + } + if pfErr.Code != output.ExitAPI { + t.Fatalf("partial failure exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI) + } + + out := rt.Factory.IOStreams.Out.(interface{ String() string }).String() + if !strings.Contains(out, `"ok": false`) { + t.Errorf("output = %s, want ok:false", out) + } + if !strings.Contains(out, `"failure_count": 1`) { + t.Errorf("output = %s, want failure_count 1", out) + } + if !strings.Contains(out, `"success_count": 2`) { + t.Errorf("output = %s, want success_count 2", out) + } +} + +func TestImChatMembersAddDryRun_TwoCallsWhenBothFlags(t *testing.T) { + rt := newChatMembersAddTestRuntime(t, nil, + map[string]string{"chat-id": "oc_x"}, + map[string][]string{"users": {"ou_a"}, "bots": {"cli_x"}}, + ) + dry := ImChatMembersAdd.DryRun(context.Background(), rt) + b, err := json.Marshal(dry) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + if strings.Count(string(b), `"method":"POST"`) != 2 { + t.Errorf("dry-run json = %s, want 2 POST calls", b) + } +} + +// TestImChatMembersAddExecute_BothCallsAuthFailure_ReturnsTypedError covers +// the spec rule: when BOTH the users-call and the bots-call fail at the +// auth/permission classification layer, Execute must return that typed error +// directly (no ledger, no OutPartialFailure) instead of folding it into +// call_errors. Lark error code 99991672 ("app_missing_scope") is the same +// fixture code internal/errclass/classify_test.go uses to assert +// CategoryAuthorization — using it here means DoAPIJSONTyped's real +// classifier produces the *errs.PermissionError, not a hand-built stand-in. +func TestImChatMembersAddExecute_BothCallsAuthFailure_ReturnsTypedError(t *testing.T) { + rt := newChatMembersAddTestRuntime(t, + shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return shortcutJSONResponse(200, map[string]interface{}{"code": 99991672, "msg": "app_missing_scope"}), nil + }), + map[string]string{"chat-id": "oc_x"}, + map[string][]string{"users": {"ou_a"}, "bots": {"cli_x"}}, + ) + + err := ImChatMembersAdd.Execute(context.Background(), rt) + var permErr *errs.PermissionError + if !errors.As(err, &permErr) { + t.Fatalf("Execute() error = %T %v, want *errs.PermissionError (both calls failed at auth layer)", err, err) + } + + out := rt.Factory.IOStreams.Out.(interface{ String() string }).String() + if out != "" { + t.Errorf("Execute() must not write a ledger when returning the typed error directly, got stdout = %s", out) + } +} + +// TestImChatMembersAddExecute_SingleCallAuthFailure_StillBuildsLedger covers +// the companion rule: when only ONE call was attempted (only --users given) +// and it fails at the auth layer, spec does not apply the "both failed" +// short-circuit — it still builds a ledger (call_errors + OutPartialFailure). +func TestImChatMembersAddExecute_SingleCallAuthFailure_StillBuildsLedger(t *testing.T) { + rt := newChatMembersAddTestRuntime(t, + shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return shortcutJSONResponse(200, map[string]interface{}{"code": 99991672, "msg": "app_missing_scope"}), nil + }), + map[string]string{"chat-id": "oc_x"}, + map[string][]string{"users": {"ou_a"}}, + ) + + err := ImChatMembersAdd.Execute(context.Background(), rt) + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) { + t.Fatalf("Execute() error = %T %v, want partial failure (single call, not the both-failed short-circuit)", err, err) + } + + out := rt.Factory.IOStreams.Out.(interface{ String() string }).String() + if !strings.Contains(out, `"failure_count": 1`) { + t.Errorf("output = %s, want failure_count 1 (call_errors ledger entry)", out) + } +} From 5c2c8ab0a81a2bb59e635145fbbd1eb8a5cee372 Mon Sep 17 00:00:00 2001 From: "zhangheng.023" Date: Tue, 21 Jul 2026 17:29:08 +0800 Subject: [PATCH 4/8] feat(im): register +chat-members-add shortcut --- shortcuts/im/shortcuts.go | 1 + 1 file changed, 1 insertion(+) diff --git a/shortcuts/im/shortcuts.go b/shortcuts/im/shortcuts.go index 3dd032dad4..521920efd7 100644 --- a/shortcuts/im/shortcuts.go +++ b/shortcuts/im/shortcuts.go @@ -10,6 +10,7 @@ func Shortcuts() []common.Shortcut { return []common.Shortcut{ ImChatCreate, ImChatList, + ImChatMembersAdd, ImChatMembersList, ImChatMessageList, ImChatSearch, From a3a2be51a4f96781e61631e1c15a8766a7541fb7 Mon Sep 17 00:00:00 2001 From: "zhangheng.023" Date: Tue, 21 Jul 2026 17:31:30 +0800 Subject: [PATCH 5/8] docs(im): add +chat-members-add skill reference --- skills/lark-im/SKILL.md | 3 +- .../references/lark-im-chat-members-add.md | 68 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 skills/lark-im/references/lark-im-chat-members-add.md diff --git a/skills/lark-im/SKILL.md b/skills/lark-im/SKILL.md index bc0b363e60..0849123478 100644 --- a/skills/lark-im/SKILL.md +++ b/skills/lark-im/SKILL.md @@ -106,6 +106,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli im + [flags]`)。 | [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager | | [`+chat-list`](references/lark-im-chat-list.md) | List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only) | | [`+chat-members-list`](references/lark-im-chat-members-list.md) | List members of a chat; returns separate users[] / bots[] buckets; callable as user or bot; --member-types filters which kinds to return; --page-all pagination; surfaces truncations[] when the server caps a bucket | +| [`+chat-members-add`](references/lark-im-chat-members-add.md) | Add users and/or bots to a group chat; user/bot; batches --users (open_id) and --bots (app_id) into up to 2 API calls under best-effort semantics; returns a merged succeeded/invalid/not_existed/pending_approval ledger | | [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination | | [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only) | | [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description | @@ -143,7 +144,7 @@ lark-cli im [flags] # 调用 API ### chat.members - - `create` — 将用户或机器人拉入群聊。Identity: supports `user` and `bot`; the caller must be in the target chat; for `bot` calls, added users must be within the app's availability; for internal chats the operator must belong to the same tenant; if only owners/admins can add members, the caller must be an owner/admin, or a chat-creator bot with `im:chat:operate_as_owner`. + - `create` — 将用户或机器人拉入群聊。Identity: supports `user` and `bot`; the caller must be in the target chat; for `bot` calls, added users must be within the app's availability; for internal chats the operator must belong to the same tenant; if only owners/admins can add members, the caller must be an owner/admin, or a chat-creator bot with `im:chat:operate_as_owner`. Prefer `+chat-members-add` over calling this directly — it batches user/bot invites and merges the result into one ledger. - `delete` — 将用户或机器人移出群聊。Identity: supports `user` and `bot`; only group owner, admin, or creator bot can remove others; max 50 users or 5 bots per request. ### chat.user_setting diff --git a/skills/lark-im/references/lark-im-chat-members-add.md b/skills/lark-im/references/lark-im-chat-members-add.md new file mode 100644 index 0000000000..1f126413ca --- /dev/null +++ b/skills/lark-im/references/lark-im-chat-members-add.md @@ -0,0 +1,68 @@ +# im +chat-members-add + +> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules. + +Add users and/or bots to a group chat in one command. `--users` (open_id) and `--bots` (app_id) map to two independent underlying calls — `chat.members.create` only accepts one `member_id_type` per request, so a single call cannot mix user and bot IDs. This shortcut issues both calls as needed and merges the results into one ledger. + +This skill maps to the shortcut: `lark-cli im +chat-members-add` (internally calls `POST /open-apis/im/v1/chats/{chat_id}/members` up to twice, with `succeed_type=1`/best-effort). + +## Commands + +```bash +# Add users only +lark-cli im +chat-members-add --chat-id oc_xxx --users ou_a,ou_b + +# Add bots only +lark-cli im +chat-members-add --chat-id oc_xxx --bots cli_x + +# Add both (two API calls internally, merged into one result) +lark-cli im +chat-members-add --chat-id oc_xxx --users ou_a,ou_b --bots cli_x + +# JSON output / preview the request +lark-cli im +chat-members-add --chat-id oc_xxx --users ou_a --format json +lark-cli im +chat-members-add --chat-id oc_xxx --users ou_a --bots cli_x --dry-run +``` + +## Parameters + +| Parameter | Required | Limits | Description | +|------|------|------|------| +| `--chat-id ` | Yes | `oc_xxx` | Target chat | +| `--users ` | No* | `ou_xxx` (comma-separated or repeated), max 50 | User open_ids to invite | +| `--bots ` | No* | `cli_xxx` (comma-separated or repeated), max 5 | Bot app_ids to invite | +| `--format json` | No | - | Output as JSON | +| `--dry-run` | No | - | Preview the request(s) without executing them | + +\* At least one of `--users`/`--bots` is required. + +> Supports both `--as user` (default) and `--as bot`. The caller must be in the target chat; for bot calls, invited users must be within the app's availability; for internal chats the operator must belong to the same tenant. + +## Output Fields + +| Field | Description | +|------|------| +| `chat_id` | The target chat ID | +| `succeeded_id_list` | IDs (users + bots merged) that were actually added to the chat | +| `invalid_id_list` | IDs that are resigned / invisible / from a disabled app | +| `not_existed_id_list` | IDs that don't exist | +| `pending_approval_id_list` | IDs submitted but awaiting owner/admin approval — **not yet actually in the chat** | +| `call_errors` | Whole-call failures (not per-ID) — each entry has `member_type`, the `id_list` that call carried, and `error` | +| `success_count` / `failure_count` / `total` | `total` = requested ID count (post-validation, per-flag deduped); `failure_count` sums the three failure buckets plus every `call_errors[].id_list`; `success_count + failure_count == total` always holds | + +## Partial failure: valid IDs still get added + +The shortcut always uses best-effort semantics (`succeed_type=1`): a single resigned/nonexistent/unapprovable ID does not block the rest of the batch. When `failure_count > 0`, the command exits non-zero (`ok: false` in the JSON envelope) even though some IDs did succeed — always check `success_count`/`failure_count`, not just the exit code, to know exactly what happened. + +`pending_approval_id_list` is a distinct case: those members were **not** added — they're waiting on an owner/admin decision. Don't treat that bucket as "succeeded". + +## Common Errors and Troubleshooting + +| Symptom | Root Cause | Solution | +|---------|---------|---------| +| `invalid --chat-id ...: must be an open_chat_id starting with oc_` | `--chat-id` missing or malformed | Provide the `oc_xxx` chat ID | +| `at least one of --users or --bots is required` | Both flags omitted | Provide at least one | +| `invalid --users value ...: must start with "ou_"` | Wrong ID type in `--users` | Use `open_id` (`ou_xxx`), not `union_id`/`user_id`/`app_id` | +| `invalid --bots value ...: must start with "cli_"` | Wrong ID type in `--bots` | Use the app's `app_id` (`cli_xxx`) | +| `--users exceeds the maximum of 50` / `--bots exceeds the maximum of 5` | Batch too large | Split into multiple calls | +| Permission denied | Missing `im:chat.members:write_only`, or caller not in the chat / not owner-admin when restricted | Bot: enable the scope in the console. User: `lark-cli auth login --scope "im:chat.members:write_only"`; confirm the caller is in the chat | +``` From ba9534214f673bed3a60ee9a04a241d207b8d41b Mon Sep 17 00:00:00 2001 From: "zhangheng.023" Date: Tue, 21 Jul 2026 17:38:59 +0800 Subject: [PATCH 6/8] docs(im): document chat-members-add auth-both-fail short-circuit --- skills/lark-im/references/lark-im-chat-members-add.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/skills/lark-im/references/lark-im-chat-members-add.md b/skills/lark-im/references/lark-im-chat-members-add.md index 1f126413ca..fed463bf1e 100644 --- a/skills/lark-im/references/lark-im-chat-members-add.md +++ b/skills/lark-im/references/lark-im-chat-members-add.md @@ -55,6 +55,10 @@ The shortcut always uses best-effort semantics (`succeed_type=1`): a single resi `pending_approval_id_list` is a distinct case: those members were **not** added — they're waiting on an owner/admin decision. Don't treat that bucket as "succeeded". +## Auth-failure short-circuit (both calls, both auth errors) + +This only applies when **both** `--users` and `--bots` were supplied (so both calls are attempted) **and both** calls fail with an auth/permission-classified error (e.g. missing scope, unauthenticated). In that specific case the command returns that error directly as a normal error envelope — it does **not** build the usual ledger (`chat_id`/`succeeded_id_list`/.../`success_count`), and does not go through the partial-failure path above. If only one of `--users`/`--bots` is given, or only one of the two calls fails this way, the normal ledger output still applies. + ## Common Errors and Troubleshooting | Symptom | Root Cause | Solution | @@ -64,5 +68,4 @@ The shortcut always uses best-effort semantics (`succeed_type=1`): a single resi | `invalid --users value ...: must start with "ou_"` | Wrong ID type in `--users` | Use `open_id` (`ou_xxx`), not `union_id`/`user_id`/`app_id` | | `invalid --bots value ...: must start with "cli_"` | Wrong ID type in `--bots` | Use the app's `app_id` (`cli_xxx`) | | `--users exceeds the maximum of 50` / `--bots exceeds the maximum of 5` | Batch too large | Split into multiple calls | -| Permission denied | Missing `im:chat.members:write_only`, or caller not in the chat / not owner-admin when restricted | Bot: enable the scope in the console. User: `lark-cli auth login --scope "im:chat.members:write_only"`; confirm the caller is in the chat | -``` +| Permission denied | Missing `im:chat` or `im:chat.members:write_only`, or caller not in the chat / not owner-admin when restricted | Bot: enable both scopes in the console. User: `lark-cli auth login --scope "im:chat,im:chat.members:write_only"`; confirm the caller is in the chat | From 93aa355365b56844713f7f2617d167a56345173e Mon Sep 17 00:00:00 2001 From: "zhangheng.023" Date: Tue, 21 Jul 2026 17:46:31 +0800 Subject: [PATCH 7/8] test(im): update shortcuts golden list for +chat-members-add --- shortcuts/im/helpers_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/shortcuts/im/helpers_test.go b/shortcuts/im/helpers_test.go index 58a577b107..6ad0e418f2 100644 --- a/shortcuts/im/helpers_test.go +++ b/shortcuts/im/helpers_test.go @@ -651,6 +651,7 @@ func TestShortcuts(t *testing.T) { want := []string{ "+chat-create", "+chat-list", + "+chat-members-add", "+chat-members-list", "+chat-messages-list", "+chat-search", From cdb5343bd435a29c390d90c60f13e1bfa7accd63 Mon Sep 17 00:00:00 2001 From: "zhangheng.023" Date: Tue, 21 Jul 2026 18:26:41 +0800 Subject: [PATCH 8/8] fix(im): narrow chat-members-add scope to avoid over-restrictive AND-check chat.members.create raw meta lists ["im:chat", "im:chat.members:write_only"] as OR alternatives, but the local scope precheck (MissingScopes) treats every entry in Scopes as required (AND), so declaring both wrongly rejected tokens holding only the narrower write_only scope. Declare only that narrow scope, matching the chat-members-list shortcut pattern, and update the doc troubleshooting row/login example to match. --- shortcuts/im/im_chat_members_add.go | 13 ++++++++++--- .../lark-im/references/lark-im-chat-members-add.md | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/shortcuts/im/im_chat_members_add.go b/shortcuts/im/im_chat_members_add.go index 2772ae4038..39d9e7f337 100644 --- a/shortcuts/im/im_chat_members_add.go +++ b/shortcuts/im/im_chat_members_add.go @@ -177,9 +177,16 @@ var ImChatMembersAdd = common.Shortcut{ Command: "+chat-members-add", Description: "Add users and/or bots to a group chat; user/bot; batches --users (open_id) and --bots (app_id) into up to 2 API calls under best-effort semantics; returns a merged succeeded/invalid/not_existed/pending_approval ledger", Risk: "write", - Scopes: []string{"im:chat", "im:chat.members:write_only"}, - AuthTypes: []string{"user", "bot"}, - HasFormat: true, + // Declare the narrowest scope the API accepts so tokens carrying only + // im:chat.members:write_only are honored (same rationale as + // +chat-members-list): chat.members.create's raw meta lists + // ["im:chat", "im:chat.members:write_only"] as OR alternatives, but the + // local scope precheck (internal/auth/scope.go's MissingScopes) treats + // every entry in Scopes as required (AND semantics), so listing both here + // would wrongly reject a token that only carries the narrow scope. + Scopes: []string{"im:chat.members:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, Flags: []common.Flag{ {Name: "chat-id", Required: true, Desc: "chat ID to add members to (oc_xxx)"}, {Name: "users", Type: "string_slice", Desc: "user open_ids to invite (ou_xxx); comma-separated or repeat the flag; max 50"}, diff --git a/skills/lark-im/references/lark-im-chat-members-add.md b/skills/lark-im/references/lark-im-chat-members-add.md index fed463bf1e..47fbe48b3b 100644 --- a/skills/lark-im/references/lark-im-chat-members-add.md +++ b/skills/lark-im/references/lark-im-chat-members-add.md @@ -68,4 +68,4 @@ This only applies when **both** `--users` and `--bots` were supplied (so both ca | `invalid --users value ...: must start with "ou_"` | Wrong ID type in `--users` | Use `open_id` (`ou_xxx`), not `union_id`/`user_id`/`app_id` | | `invalid --bots value ...: must start with "cli_"` | Wrong ID type in `--bots` | Use the app's `app_id` (`cli_xxx`) | | `--users exceeds the maximum of 50` / `--bots exceeds the maximum of 5` | Batch too large | Split into multiple calls | -| Permission denied | Missing `im:chat` or `im:chat.members:write_only`, or caller not in the chat / not owner-admin when restricted | Bot: enable both scopes in the console. User: `lark-cli auth login --scope "im:chat,im:chat.members:write_only"`; confirm the caller is in the chat | +| Permission denied | Missing `im:chat.members:write_only`, or caller not in the chat / not owner-admin when restricted | Bot: enable the scope in the console. User: `lark-cli auth login --scope "im:chat.members:write_only"`; confirm the caller is in the chat |