mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
8 Commits
codex/fix-
...
feat/im-ch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdb5343bd4 | ||
|
|
93aa355365 | ||
|
|
ba9534214f | ||
|
|
a3a2be51a4 | ||
|
|
5c2c8ab0a8 | ||
|
|
597567f8af | ||
|
|
a022d52238 | ||
|
|
e02039185e |
@@ -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",
|
||||
|
||||
305
shortcuts/im/im_chat_members_add.go
Normal file
305
shortcuts/im/im_chat_members_add.go
Normal 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
|
||||
}
|
||||
357
shortcuts/im/im_chat_members_add_test.go
Normal file
357
shortcuts/im/im_chat_members_add_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
ImChatCreate,
|
||||
ImChatList,
|
||||
ImChatMembersAdd,
|
||||
ImChatMembersList,
|
||||
ImChatMessageList,
|
||||
ImChatSearch,
|
||||
|
||||
@@ -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
|
||||
|
||||
71
skills/lark-im/references/lark-im-chat-members-add.md
Normal file
71
skills/lark-im/references/lark-im-chat-members-add.md
Normal 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 |
|
||||
Reference in New Issue
Block a user