Compare commits

...

8 Commits

Author SHA1 Message Date
zhangheng.023
cdb5343bd4 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.
2026-07-21 18:26:41 +08:00
zhangheng.023
93aa355365 test(im): update shortcuts golden list for +chat-members-add 2026-07-21 17:46:31 +08:00
zhangheng.023
ba9534214f docs(im): document chat-members-add auth-both-fail short-circuit 2026-07-21 17:38:59 +08:00
zhangheng.023
a3a2be51a4 docs(im): add +chat-members-add skill reference 2026-07-21 17:31:30 +08:00
zhangheng.023
5c2c8ab0a8 feat(im): register +chat-members-add shortcut 2026-07-21 17:29:08 +08:00
zhangheng.023
597567f8af feat(im): add +chat-members-add shortcut 2026-07-21 17:21:15 +08:00
zhangheng.023
a022d52238 feat(im): merge chat-members-add batch results into a ledger 2026-07-21 17:01:08 +08:00
zhangheng.023
e02039185e feat(im): add chat-members-add validation 2026-07-21 16:53:30 +08:00
6 changed files with 737 additions and 1 deletions

View File

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

View File

@@ -0,0 +1,305 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"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"
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
}
// chatMembersAddResult is the merged ledger across the users-call and the
// 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 {
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(),
})
res.rawCallErrors = append(res.rawCallErrors, err)
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
}
// 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",
// 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"},
{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
}

View File

@@ -0,0 +1,357 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
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"
)
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
}
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)
}
}
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)
}
}

View File

@@ -10,6 +10,7 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
ImChatCreate,
ImChatList,
ImChatMembersAdd,
ImChatMembersList,
ImChatMessageList,
ImChatSearch,

View File

@@ -106,6 +106,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [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 <resource> <method> [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

View File

@@ -0,0 +1,71 @@
# 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 <id>` | Yes | `oc_xxx` | Target chat |
| `--users <ids>` | No* | `ou_xxx` (comma-separated or repeated), max 50 | User open_ids to invite |
| `--bots <ids>` | 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".
## 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 |
|---------|---------|---------|
| `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 |