Compare commits

...

22 Commits

Author SHA1 Message Date
shanglei
a0b4263206 fix: clarify chat member identity and recovery 2026-07-21 18:52:45 +08:00
shanglei
70d3566c49 test: parse prefixed scope errors 2026-07-21 18:07:59 +08:00
shanglei
fbd2bb1fd4 test: narrow chat member permission skips 2026-07-21 18:03:24 +08:00
shanglei
2306c8d568 docs: sync chat member recovery hints 2026-07-21 18:01:52 +08:00
shanglei
971da28e47 fix: make chat member recovery hints complete 2026-07-21 18:00:36 +08:00
shanglei
8a146c39e6 test: cover chat member add workflow 2026-07-21 17:54:07 +08:00
shanglei
6c2469e1bf docs: require complete chat member readback 2026-07-21 17:49:06 +08:00
shanglei
7f136405a1 fix: reject missing chat member response data 2026-07-21 17:47:34 +08:00
shanglei
47b23d6173 docs: clarify chat member failure handling 2026-07-21 17:35:56 +08:00
shanglei
d837f7286a docs: document chat member add shortcut 2026-07-21 17:29:36 +08:00
shanglei
10dc432482 fix: confirm shortcuts before scope preflight 2026-07-21 17:22:22 +08:00
shanglei
66e76675dc fix: clarify chat member ID help 2026-07-21 17:12:13 +08:00
shanglei
af378b8bb0 feat: register chat members add shortcut 2026-07-21 17:09:13 +08:00
shanglei
95208dd5d6 fix: preserve partial chat member results 2026-07-21 17:01:08 +08:00
shanglei
989ce085bb fix: mark invalid chat member responses unknown 2026-07-21 16:58:06 +08:00
shanglei
8a2db5f943 test: cover chat member invalid bot response 2026-07-21 16:50:42 +08:00
shanglei
e6d099c183 feat: report chat member partial results 2026-07-21 16:48:01 +08:00
shanglei
6f7066e001 fix: reject invalid chat member responses 2026-07-21 16:41:01 +08:00
shanglei
0114965d23 test: cover chat member request variants 2026-07-21 16:33:10 +08:00
shanglei
4799d4b9f8 feat: execute chat member add requests 2026-07-21 16:29:01 +08:00
shanglei
0d7dc85348 fix: strengthen chat member validation 2026-07-21 16:24:05 +08:00
shanglei
e8c131d043 feat: add chat member request validation 2026-07-21 16:19:14 +08:00
12 changed files with 3480 additions and 17 deletions

View File

@@ -874,7 +874,8 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
}
// runShortcut is the execution pipeline for a declarative shortcut.
// Each step is a clear phase: identity → config → scopes → context → validate → execute.
// Each step is a clear phase: identity → config → context → validate →
// dry-run or confirmation → scopes → execute.
func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error {
// --print-schema short-circuits everything below: it's pure local
// introspection, no identity / scope / network needed. The flag is
@@ -913,10 +914,6 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
// Identity info is now included in the JSON envelope; skip stderr printing.
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, as, config, false)
if err := checkShortcutScopes(f, cmd.Context(), as, config, s.ScopesForIdentity(string(as))); err != nil {
return err
}
rctx, err := newRuntimeContext(cmd, f, s, config, as, botOnly)
if err != nil {
return err
@@ -945,6 +942,10 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
return cmdutil.RequireConfirmation(s.Service + " " + s.Command)
}
if err := checkShortcutScopes(f, rctx.ctx, as, config, s.ScopesForIdentity(string(as))); err != nil {
return err
}
if err := s.Execute(rctx.ctx, rctx); err != nil {
return err
}

View File

@@ -4,12 +4,16 @@
package common
import (
"bytes"
"context"
"errors"
"fmt"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -25,6 +29,194 @@ func (r *scopeCheckTokenResolver) ResolveToken(ctx context.Context, req credenti
return r.result, r.err
}
type orderedScopeTokenResolver struct {
calls int
events *[]string
result *credential.TokenResult
}
func (r *orderedScopeTokenResolver) ResolveToken(_ context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
r.calls++
if r.events != nil {
*r.events = append(*r.events, "resolve:"+string(req.Type))
}
return r.result, nil
}
func TestRunShortcut_HighRiskScopePreflightOrdering(t *testing.T) {
tests := []struct {
identity core.Identity
tokenType credential.TokenType
}{
{identity: core.AsUser, tokenType: credential.TokenTypeUAT},
{identity: core.AsBot, tokenType: credential.TokenTypeTAT},
}
for _, tt := range tests {
t.Run(string(tt.identity), func(t *testing.T) {
t.Run("confirmation before scope preflight", func(t *testing.T) {
resolver := &orderedScopeTokenResolver{
result: &credential.TokenResult{Token: "token", Scopes: "test:write"},
}
executeCalls := 0
shortcut := scopeOrderingShortcut(&executeCalls, nil)
factory, cmd := scopeOrderingRuntime(t, shortcut, resolver, tt.identity)
err := runShortcut(cmd, factory, shortcut, false)
problem, ok := errs.ProblemOf(err)
if !ok || problem.Subtype != errs.SubtypeConfirmationRequired {
t.Fatalf("error = %T %v, want confirmation_required", err, err)
}
if resolver.calls != 0 {
t.Fatalf("token resolver calls = %d, want 0", resolver.calls)
}
if executeCalls != 0 {
t.Fatalf("Execute calls = %d, want 0", executeCalls)
}
})
t.Run("dry run before scope preflight", func(t *testing.T) {
resolver := &orderedScopeTokenResolver{
result: &credential.TokenResult{Token: "token", Scopes: "test:write"},
}
executeCalls := 0
shortcut := scopeOrderingShortcut(&executeCalls, nil)
factory, cmd := scopeOrderingRuntime(t, shortcut, resolver, tt.identity)
if err := cmd.Flags().Set("dry-run", "true"); err != nil {
t.Fatalf("set dry-run: %v", err)
}
if err := runShortcut(cmd, factory, shortcut, false); err != nil {
t.Fatalf("runShortcut() error = %v", err)
}
if resolver.calls != 0 {
t.Fatalf("token resolver calls = %d, want 0", resolver.calls)
}
if executeCalls != 0 {
t.Fatalf("Execute calls = %d, want 0", executeCalls)
}
stdout := factory.IOStreams.Out.(*bytes.Buffer).String()
if !strings.Contains(stdout, "/open-apis/test/v1/items") {
t.Fatalf("dry-run output has no request preview: %s", stdout)
}
})
t.Run("confirmed execution checks scopes first", func(t *testing.T) {
events := []string{}
resolver := &orderedScopeTokenResolver{
events: &events,
result: &credential.TokenResult{Token: "token", Scopes: "test:write"},
}
executeCalls := 0
shortcut := scopeOrderingShortcut(&executeCalls, &events)
factory, cmd := scopeOrderingRuntime(t, shortcut, resolver, tt.identity)
if err := cmd.Flags().Set("yes", "true"); err != nil {
t.Fatalf("set yes: %v", err)
}
if err := runShortcut(cmd, factory, shortcut, false); err != nil {
t.Fatalf("runShortcut() error = %v", err)
}
if resolver.calls != 1 {
t.Fatalf("token resolver calls = %d, want 1", resolver.calls)
}
wantEvents := []string{"resolve:" + string(tt.tokenType), "execute"}
if !reflect.DeepEqual(events, wantEvents) {
t.Fatalf("events = %#v, want %#v", events, wantEvents)
}
})
})
}
}
func TestRunShortcut_LowRiskExecutionStillChecksScopes(t *testing.T) {
events := []string{}
resolver := &orderedScopeTokenResolver{
events: &events,
result: &credential.TokenResult{Token: "token", Scopes: "test:write"},
}
executeCalls := 0
shortcut := scopeOrderingShortcut(&executeCalls, &events)
shortcut.Risk = "read"
factory, cmd := scopeOrderingRuntime(t, shortcut, resolver, core.AsUser)
if err := runShortcut(cmd, factory, shortcut, false); err != nil {
t.Fatalf("runShortcut() error = %v", err)
}
if resolver.calls != 1 {
t.Fatalf("token resolver calls = %d, want 1", resolver.calls)
}
if want := []string{"resolve:uat", "execute"}; !reflect.DeepEqual(events, want) {
t.Fatalf("events = %#v, want %#v", events, want)
}
}
func TestRunShortcut_LocalValidationPrecedesScopeAndConfirmation(t *testing.T) {
resolver := &orderedScopeTokenResolver{
result: &credential.TokenResult{Token: "token", Scopes: "test:write"},
}
executeCalls := 0
shortcut := scopeOrderingShortcut(&executeCalls, nil)
shortcut.Flags = append(shortcut.Flags, Flag{Name: "value"})
shortcut.Validate = func(_ context.Context, runtime *RuntimeContext) error {
if runtime.Str("value") == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--value is required").WithParam("--value")
}
return nil
}
factory, cmd := scopeOrderingRuntime(t, shortcut, resolver, core.AsUser)
err := runShortcut(cmd, factory, shortcut, false)
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("error = %T %v, want validation invalid_argument", err, err)
}
if resolver.calls != 0 {
t.Fatalf("token resolver calls = %d, want 0", resolver.calls)
}
if executeCalls != 0 {
t.Fatalf("Execute calls = %d, want 0", executeCalls)
}
}
func scopeOrderingShortcut(executeCalls *int, events *[]string) *Shortcut {
return &Shortcut{
Service: "test",
Command: "+scope-order",
Risk: "high-risk-write",
Scopes: []string{"test:write"},
AuthTypes: []string{"user", "bot"},
Description: "test scope ordering",
DryRun: func(_ context.Context, _ *RuntimeContext) *DryRunAPI {
return NewDryRunAPI().GET("/open-apis/test/v1/items")
},
Execute: func(_ context.Context, _ *RuntimeContext) error {
(*executeCalls)++
if events != nil {
*events = append(*events, "execute")
}
return nil
},
}
}
func scopeOrderingRuntime(
t *testing.T,
shortcut *Shortcut,
resolver *orderedScopeTokenResolver,
identity core.Identity,
) (*cmdutil.Factory, *cobra.Command) {
t.Helper()
factory := newTestFactory()
factory.Credential = credential.NewCredentialProvider(nil, nil, resolver, nil)
cmd := newTestShortcutCmd(shortcut, factory)
if err := cmd.Flags().Set("as", string(identity)); err != nil {
t.Fatalf("set as: %v", err)
}
return factory, cmd
}
// TestEnhancePermissionError_TypedPermissionErrorRouted pins typed routing:
// an *errs.PermissionError gets enhanced regardless of its Message text,
// decoupling this helper from canonical-message rewrites that would

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,589 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
imChatMembersAddPathFormat = "/open-apis/im/v1/chats/%s/members"
imChatMembersAddUserLimit = 50
imChatMembersAddBotLimit = 5
imChatMembersAddIDMaxBytes = 256
imChatMembersAddReadbackHint = "List current chat members with lark-cli im +chat-members-list --chat-id <chat_id> --member-types user --page-all --page-limit 0 --as <same-identity> before retrying. Treat a member not found as missing only when has_more:false and truncations has no entry for member_type user; otherwise do not retry the unknown batch. Retry only user members not confirmed present."
imChatBotsAddReadbackHint = "List current chat members with lark-cli im +chat-members-list --chat-id <chat_id> --member-types bot --page-all --page-limit 0 --as <same-identity> before retrying. Treat a member not found as missing only when has_more:false and truncations has no entry for member_type bot; otherwise do not retry the unknown batch. Retry only bots not confirmed present."
)
var (
imChatMembersAddIDSuffix = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
errChatMembersAddMissingResponseData = chatMembersAddInvalidResponseCause{
reason: "chat member response data is missing or invalid",
}
)
type chatMembersAddInvalidResponseCause struct {
field string
reason string
}
func (e chatMembersAddInvalidResponseCause) Error() string {
if e.field == "" {
return e.reason
}
return e.field + " " + e.reason
}
type chatMembersAddSpecContextKey struct{}
// ImChatMembersAdd is the +chat-members-add shortcut. User open IDs and bot
// app IDs are sent in separate requests, with the user request first.
var ImChatMembersAdd = common.Shortcut{
Service: "im",
Command: "+chat-members-add",
Description: "Add user open_id values and bot app_id values to a chat; users are processed first; partial results return ok:false",
Risk: "high-risk-write",
Scopes: []string{"im:chat.members:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "chat-id", Required: true, Desc: "chat ID or supported chat URL (oc_xxx)"},
{Name: "users", Desc: "comma-separated user open_id values (ou_xxx), max 50 unique IDs"},
{Name: "bots", Desc: "comma-separated bot app_id values (cli_xxx), max 5 unique IDs"},
},
Tips: []string{
"At least one of --users or --bots is required; duplicate IDs are removed in first-seen order.",
"When both are present, user members are added before bot members in separate requests with succeed_type=1.",
"--as user uses the authenticated user's chat membership and invite permissions; --as bot uses the app bot's chat membership and invite permissions.",
"Partial member results return ok:false and exit 1; outcome_unknown requires listing current members before retrying.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readChatMembersAddSpec(runtime)
if err != nil {
return err
}
runtime.Cmd.SetContext(context.WithValue(ctx, chatMembersAddSpecContextKey{}, spec))
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, ok := validatedChatMembersAddSpec(runtime)
if !ok {
return nil
}
return buildChatMembersAddDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, ok := validatedChatMembersAddSpec(runtime)
if !ok {
return errs.NewInternalError(
errs.SubtypeUnknown,
"validated chat member specification is unavailable",
)
}
return executeChatMembersAdd(runtime, spec)
},
}
type chatMembersAddSpec struct {
ChatID string
Users []string
Bots []string
}
func validatedChatMembersAddSpec(runtime *common.RuntimeContext) (chatMembersAddSpec, bool) {
if runtime == nil || runtime.Cmd == nil || runtime.Cmd.Context() == nil {
return chatMembersAddSpec{}, false
}
spec, ok := runtime.Cmd.Context().Value(chatMembersAddSpecContextKey{}).(chatMembersAddSpec)
return spec, ok
}
type chatMembersAddResponse struct {
InvalidIDList []string
NotExistedIDList []string
PendingApprovalIDList []string
}
type chatMembersAddResult struct {
ChatID string `json:"chat_id"`
SuccessCount int `json:"success_count"`
InvalidIDList []string `json:"invalid_id_list"`
NotExistedIDList []string `json:"not_existed_id_list"`
PendingApprovalIDList []string `json:"pending_approval_id_list"`
FailedMemberType string `json:"failed_member_type,omitempty"`
OutcomeUnknown bool `json:"-"`
Error *chatMembersAddError `json:"error,omitempty"`
}
type chatMembersAddError struct {
Type errs.Category `json:"type"`
Subtype errs.Subtype `json:"subtype,omitempty"`
Code int `json:"code,omitempty"`
Message string `json:"message"`
Hint string `json:"hint,omitempty"`
LogID string `json:"log_id,omitempty"`
Troubleshooter string `json:"troubleshooter,omitempty"`
Retryable bool `json:"retryable"`
MissingScopes []string `json:"missing_scopes,omitempty"`
RequestedScopes []string `json:"requested_scopes,omitempty"`
GrantedScopes []string `json:"granted_scopes,omitempty"`
Identity string `json:"identity,omitempty"`
ConsoleURL string `json:"console_url,omitempty"`
}
func (r chatMembersAddResult) MarshalJSON() ([]byte, error) {
type resultAlias chatMembersAddResult
var outcomeUnknown *bool
if r.FailedMemberType != "" {
value := r.OutcomeUnknown
outcomeUnknown = &value
}
return json.Marshal(struct {
resultAlias
OutcomeUnknown *bool `json:"outcome_unknown,omitempty"`
}{
resultAlias: resultAlias(r),
OutcomeUnknown: outcomeUnknown,
})
}
func readChatMembersAddSpec(runtime *common.RuntimeContext) (chatMembersAddSpec, error) {
chatID, err := common.ValidateChatIDTyped("--chat-id", runtime.Str("chat-id"))
if err != nil {
return chatMembersAddSpec{}, err
}
if err := validateChatMembersAddID("--chat-id", chatID, "oc_"); err != nil {
return chatMembersAddSpec{}, err
}
spec := chatMembersAddSpec{
ChatID: chatID,
Users: dedupeChatMemberIDs(common.SplitCSV(runtime.Str("users"))),
Bots: dedupeChatMemberIDs(common.SplitCSV(runtime.Str("bots"))),
}
if len(spec.Users) == 0 && len(spec.Bots) == 0 {
return chatMembersAddSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"specify at least one of --users or --bots",
).WithParams(
errs.InvalidParam{Name: "--users", Reason: "required; specify at least one"},
errs.InvalidParam{Name: "--bots", Reason: "required; specify at least one"},
)
}
if len(spec.Users) > imChatMembersAddUserLimit {
return chatMembersAddSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--users accepts at most %d unique IDs",
imChatMembersAddUserLimit,
).WithParam("--users")
}
if len(spec.Bots) > imChatMembersAddBotLimit {
return chatMembersAddSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--bots accepts at most %d unique IDs",
imChatMembersAddBotLimit,
).WithParam("--bots")
}
for _, id := range spec.Users {
if err := validateChatMembersAddID("--users", id, "ou_"); err != nil {
return chatMembersAddSpec{}, err
}
if _, err := common.ValidateUserIDTyped("--users", id); err != nil {
return chatMembersAddSpec{}, err
}
}
for _, id := range spec.Bots {
if err := validateChatMembersAddID("--bots", id, "cli_"); err != nil {
return chatMembersAddSpec{}, err
}
}
return spec, nil
}
func dedupeChatMemberIDs(ids []string) []string {
if len(ids) == 0 {
return nil
}
seen := make(map[string]struct{}, len(ids))
result := make([]string, 0, len(ids))
for _, id := range ids {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
return result
}
func validateChatMembersAddID(param, id, prefix string) error {
if !strings.HasPrefix(id, prefix) {
if param == "--users" {
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"invalid user ID format, should start with 'ou_' (e.g., ou_abc123)",
).WithParam(param)
}
if param == "--bots" {
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"invalid bot id %q: expected app ID (cli_xxx)",
id,
).WithParam(param)
}
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"invalid %s: identifier must start with %s",
param,
prefix,
).WithParam(param)
}
if len(id) > imChatMembersAddIDMaxBytes {
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"invalid %s: identifier must not exceed %d bytes",
param,
imChatMembersAddIDMaxBytes,
).WithParam(param)
}
suffix := strings.TrimPrefix(id, prefix)
if suffix == "" {
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"invalid %s: identifier suffix cannot be empty",
param,
).WithParam(param)
}
if !imChatMembersAddIDSuffix.MatchString(suffix) {
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"invalid %s: identifier suffix must use only ASCII letters, digits, underscores, or hyphens",
param,
).WithParam(param)
}
return nil
}
func buildChatMembersAddDryRun(spec chatMembersAddSpec) *common.DryRunAPI {
dryRun := common.NewDryRunAPI()
path := fmt.Sprintf(imChatMembersAddPathFormat, validate.EncodePathSegment(spec.ChatID))
if len(spec.Users) > 0 {
dryRun.POST(path).
Params(chatMembersAddParams("open_id")).
Body(chatMembersAddBody(spec.Users))
}
if len(spec.Bots) > 0 {
dryRun.POST(path).
Params(chatMembersAddParams("app_id")).
Body(chatMembersAddBody(spec.Bots))
}
return dryRun
}
func executeChatMembersAdd(runtime *common.RuntimeContext, spec chatMembersAddSpec) error {
responses := make([]chatMembersAddResponse, 0, 2)
completedCount := 0
if len(spec.Users) > 0 {
response, err := callChatMembersAddBatch(runtime, spec.ChatID, "open_id", spec.Users)
if err != nil {
return withChatMembersAddUnknownOutcome(err, false)
}
responses = append(responses, response)
completedCount += len(spec.Users)
}
if len(spec.Bots) > 0 {
response, err := callChatMembersAddBatch(runtime, spec.ChatID, "app_id", spec.Bots)
if err != nil {
if completedCount == 0 {
return withChatMembersAddUnknownOutcome(err, true)
}
projectedErr := withChatMembersAddUnknownOutcome(err, true)
merged := mergeChatMembersAddResponse(responses...)
result := newChatMembersAddResult(spec.ChatID, completedCount, merged)
result.FailedMemberType = "bot"
result.OutcomeUnknown = isChatMembersAddOutcomeUnknown(err)
result.Error = projectChatMembersAddError(projectedErr, true)
writeChatMembersAddProgressWarning(runtime, result.SuccessCount)
return runtime.OutPartialFailure(result, nil)
}
responses = append(responses, response)
completedCount += len(spec.Bots)
}
merged := mergeChatMembersAddResponse(responses...)
result := newChatMembersAddResult(spec.ChatID, completedCount, merged)
if hasUnfinishedChatMembersAdd(result) {
return runtime.OutPartialFailure(result, nil)
}
runtime.OutFormat(result, &output.Meta{Count: result.SuccessCount}, func(w io.Writer) {
renderChatMembersAddPretty(w, result)
})
return nil
}
func newChatMembersAddResult(chatID string, completedCount int, response chatMembersAddResponse) chatMembersAddResult {
return chatMembersAddResult{
ChatID: chatID,
SuccessCount: confirmedChatMembersAddCount(completedCount, response),
InvalidIDList: response.InvalidIDList,
NotExistedIDList: response.NotExistedIDList,
PendingApprovalIDList: response.PendingApprovalIDList,
}
}
func hasUnfinishedChatMembersAdd(result chatMembersAddResult) bool {
return len(result.InvalidIDList) > 0 ||
len(result.NotExistedIDList) > 0 ||
len(result.PendingApprovalIDList) > 0
}
func renderChatMembersAddPretty(w io.Writer, result chatMembersAddResult) {
fmt.Fprintf(w, "Chat: %s\n", result.ChatID)
fmt.Fprintf(w, "Added members: %d\n", result.SuccessCount)
}
func withChatMembersAddUnknownOutcome(err error, botBatch bool) error {
var networkErr *errs.NetworkError
if errors.As(err, &networkErr) {
cloned := *networkErr
cloned.Problem = networkErr.Problem
cloned.Retryable = false
cloned.Hint = chatMembersAddUnknownOutcomeHint(botBatch)
return &cloned
}
var internalErr *errs.InternalError
if errors.As(err, &internalErr) && internalErr.Subtype == errs.SubtypeInvalidResponse {
cloned := *internalErr
cloned.Problem = internalErr.Problem
cloned.Retryable = false
cloned.Hint = chatMembersAddUnknownOutcomeHint(botBatch)
return &cloned
}
return err
}
func projectChatMembersAddError(err error, botBatch bool) *chatMembersAddError {
problem, ok := errs.ProblemOf(err)
if !ok {
return &chatMembersAddError{
Type: errs.CategoryInternal,
Subtype: errs.SubtypeUnknown,
Message: "member request failed",
Retryable: false,
}
}
projected := &chatMembersAddError{
Type: problem.Category,
Subtype: problem.Subtype,
Code: problem.Code,
Message: problem.Message,
Hint: problem.Hint,
LogID: problem.LogID,
Troubleshooter: problem.Troubleshooter,
Retryable: problem.Retryable,
}
if isChatMembersAddOutcomeUnknown(err) {
projected.Retryable = false
projected.Hint = chatMembersAddUnknownOutcomeHint(botBatch)
}
var permissionErr *errs.PermissionError
if errors.As(err, &permissionErr) {
projected.MissingScopes = append([]string(nil), permissionErr.MissingScopes...)
projected.RequestedScopes = append([]string(nil), permissionErr.RequestedScopes...)
projected.GrantedScopes = append([]string(nil), permissionErr.GrantedScopes...)
projected.Identity = permissionErr.Identity
projected.ConsoleURL = permissionErr.ConsoleURL
}
return projected
}
func chatMembersAddUnknownOutcomeHint(botBatch bool) string {
if botBatch {
return imChatBotsAddReadbackHint
}
return imChatMembersAddReadbackHint
}
func isChatMembersAddOutcomeUnknown(err error) bool {
if errs.IsNetwork(err) {
return true
}
problem, ok := errs.ProblemOf(err)
return ok && problem.Subtype == errs.SubtypeInvalidResponse
}
func writeChatMembersAddProgressWarning(runtime *common.RuntimeContext, successCount int) {
// The stdout partial result is authoritative, so a stderr warning failure is best-effort only.
_, _ = fmt.Fprintf(
runtime.IO().ErrOut,
"Added %d user member(s) before the bot member request failed.\n",
successCount,
)
}
func callChatMembersAddBatch(
runtime *common.RuntimeContext,
chatID string,
memberIDType string,
ids []string,
) (chatMembersAddResponse, error) {
path := fmt.Sprintf(imChatMembersAddPathFormat, validate.EncodePathSegment(chatID))
data, err := runtime.CallAPITyped(
http.MethodPost,
path,
chatMembersAddParams(memberIDType),
chatMembersAddBody(ids),
)
if err != nil {
return chatMembersAddResponse{}, err
}
if data == nil {
return chatMembersAddResponse{}, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"API returned missing or invalid chat member response data",
).WithCause(errChatMembersAddMissingResponseData)
}
return projectChatMembersAddResponse(data, ids)
}
func chatMembersAddParams(memberIDType string) map[string]interface{} {
return map[string]interface{}{
"member_id_type": memberIDType,
"succeed_type": "1",
}
}
func chatMembersAddBody(ids []string) map[string]interface{} {
return map[string]interface{}{
"id_list": ids,
}
}
func projectChatMembersAddResponse(data map[string]interface{}, requestedIDs []string) (chatMembersAddResponse, error) {
response := chatMembersAddResponse{}
fields := []struct {
name string
target *[]string
}{
{name: "invalid_id_list", target: &response.InvalidIDList},
{name: "not_existed_id_list", target: &response.NotExistedIDList},
{name: "pending_approval_id_list", target: &response.PendingApprovalIDList},
}
requested := make(map[string]struct{}, len(requestedIDs))
for _, id := range requestedIDs {
requested[id] = struct{}{}
}
seen := make(map[string]struct{})
for _, field := range fields {
values, err := projectChatMembersAddList(data, field.name)
if err != nil {
return chatMembersAddResponse{}, err
}
for _, id := range values {
if _, ok := requested[id]; !ok {
return chatMembersAddResponse{}, newInvalidChatMembersAddResponseError(
field.name,
"contains an identifier outside the request",
)
}
if _, ok := seen[id]; ok {
return chatMembersAddResponse{}, newInvalidChatMembersAddResponseError(
field.name,
"contains a duplicate identifier",
)
}
seen[id] = struct{}{}
}
*field.target = values
}
return response, nil
}
func projectChatMembersAddList(data map[string]interface{}, key string) ([]string, error) {
raw, exists := data[key]
if !exists {
return []string{}, nil
}
switch values := raw.(type) {
case []string:
return append([]string{}, values...), nil
case []interface{}:
result := make([]string, 0, len(values))
for _, value := range values {
id, ok := value.(string)
if !ok {
return nil, newInvalidChatMembersAddResponseError(key, "contains a non-string element")
}
result = append(result, id)
}
return result, nil
default:
return nil, newInvalidChatMembersAddResponseError(key, "is not an array of strings")
}
}
func newInvalidChatMembersAddResponseError(field, reason string) error {
cause := chatMembersAddInvalidResponseCause{field: field, reason: reason}
return errs.NewInternalError(
errs.SubtypeInvalidResponse,
"API returned invalid %s",
field,
).WithCause(cause)
}
func mergeChatMembersAddResponse(responses ...chatMembersAddResponse) chatMembersAddResponse {
merged := chatMembersAddResponse{
InvalidIDList: []string{},
NotExistedIDList: []string{},
PendingApprovalIDList: []string{},
}
for _, response := range responses {
merged.InvalidIDList = append(merged.InvalidIDList, response.InvalidIDList...)
merged.NotExistedIDList = append(merged.NotExistedIDList, response.NotExistedIDList...)
merged.PendingApprovalIDList = append(merged.PendingApprovalIDList, response.PendingApprovalIDList...)
}
return merged
}
func confirmedChatMembersAddCount(requested int, response chatMembersAddResponse) int {
if requested <= 0 {
return 0
}
unfinished := len(response.InvalidIDList) + len(response.NotExistedIDList) + len(response.PendingApprovalIDList)
confirmed := requested - unfinished
if confirmed < 0 {
return 0
}
if confirmed > requested {
return requested
}
return confirmed
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,7 +1,7 @@
---
name: lark-im
version: 1.0.0
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复、发送应用内/短信/电话加急、发送和处理交互卡片Interactive Card、监听卡片按钮回调card.action.trigger。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据、管理 Feed 置顶(添加/移除/查询置顶会话)、管理标签数据、处理卡片回调时使用。"
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、添加或查看群聊成员、将用户或应用机器人加入群聊、上传下载图片和文件(支持大文件分片下载)、管理表情回复、发送应用内/短信/电话加急、发送和处理交互卡片Interactive Card、监听卡片按钮回调card.action.trigger。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、群聊加人、添加用户或机器人、查看群成员、搜索群、创建群聊或话题群、管理标记数据、管理 Feed 置顶(添加/移除/查询置顶会话)、管理标签数据、处理卡片回调时使用。"
metadata:
requires:
bins: ["lark-cli"]
@@ -41,6 +41,10 @@ Chat (oc_xxx)
- `--as bot` means **bot identity** and uses `tenant_access_token`. Calls run as the app bot, so behavior depends on the bot's membership, app visibility, availability range, and bot-specific scopes.
- If an IM API says it supports both `user` and `bot`, the token type changes who the operator is. The same API can succeed with one identity and fail with the other because owner/admin status, chat membership, tenant boundary, or app availability are checked against the current caller.
### Chat Member Addition and Recovery
Before adding user or bot members, or recovering from a member request with `outcome_unknown:true`, you MUST read [`references/lark-im-chat-members-add.md`](references/lark-im-chat-members-add.md). Follow its identifier-source rules and retry checklist. In particular, keep the original target chat and identity, preserve every confirmed batch, and retry only members whose absence is proven by a complete member listing.
### Sender Name Resolution
When fetching messages (`+chat-messages-list`, `+threads-messages-list`, `+messages-mget`, `+messages-search`), the CLI shows a display name for both user and bot senders:
@@ -105,6 +109,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-add`](references/lark-im-chat-members-add.md) | Add user open_id values and bot app_id values to a chat; user/bot; sends users first in separate requests; returns one total success count and member-level failure arrays |
| [`+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-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) |
@@ -143,7 +148,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` — 将用户或机器人拉入群聊。群成员新增优先使用 [`+chat-members-add`](references/lark-im-chat-members-add.md),由 Shortcut 处理用户与机器人的分批请求及合并输出。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`.
- `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
@@ -215,7 +220,7 @@ lark-cli im <resource> <method> [flags] # 调用 API
| `chats.get` | `im:chat:read` |
| `chats.link` | `im:chat:read` |
| `chats.update` | `im:chat:update` |
| `chat.members.create` | `im:chat.members:write_only` |
| `chat.members.create`, `+chat-members-add` | `im:chat.members:write_only` |
| `chat.members.delete` | `im:chat.members:write_only` |
| `chat.members.get` | `im:chat.members:read` |
| `+chat-members-list` | `im:chat.members:read` |

View File

@@ -85,16 +85,19 @@ Bot may fail to invite users who are mutually invisible to it during group creat
3. **Add other members via user identity** (requires the current user to be in the group):
Add `--yes` only after the operator has explicitly confirmed these member changes. Before confirmation, use the same command with `--dry-run` instead of `--yes` to inspect the request, or omit `--yes` to receive `confirmation_required` and exit code `10`. After confirmation, execute the command shown below.
```bash
lark-cli im chat.members create \
--params '{"chat_id":"<chat_id from step 2>","member_id_type":"open_id","succeed_type":1}' \
--data '{"id_list":["ou_aaa","ou_bbb"]}' \
--as user
lark-cli im +chat-members-add \
--chat-id "<chat_id from step 2>" \
--users "ou_aaa,ou_bbb" \
--as user \
--yes
```
`succeed_type=1` ensures reachable users are added successfully; unreachable ones are returned in `invalid_id_list` instead of failing the whole request.
Read `success_count`, `invalid_id_list`, `not_existed_id_list`, and `pending_approval_id_list` from the response. The shortcut fixes `succeed_type=1`, so members that can be added continue to be added while the three arrays report members that were not confirmed as added.
4. **Check `invalid_id_list`** in the response. If non-empty, report to the user which members could not be added.
4. **Include bots when requested:** Add `--bots "cli_aaa,cli_bbb"` using bot application `app_id` values. When both `--users` and `--bots` are present, the shortcut sends the user request first and the bot request second, then returns one combined result. Follow [`+chat-members-add`](lark-im-chat-members-add.md) when `outcome_unknown` is present.
### When using `--as user`

View File

@@ -0,0 +1,130 @@
# im +chat-members-add
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
Add user and bot members to an existing group chat. User members are identified by `open_id`; bot members are identified by application `app_id`.
This skill maps to the shortcut: `lark-cli im +chat-members-add` (internally calls `POST /open-apis/im/v1/chats/{chat_id}/members`). Prefer this shortcut over the native Meta API command because it validates identifiers, separates user and bot requests, and returns one stable result.
## Command
```bash
lark-cli im +chat-members-add \
--chat-id oc_xxx \
--users ou_a,ou_b \
--bots cli_a \
--as user \
--yes
```
Append `--yes` only after the operator has explicitly confirmed the requested member changes. Before confirmation, replace `--yes` with `--dry-run` to inspect both requests without changing the chat, or omit `--yes` to receive the `confirmation_required` error and exit code `10`. After confirmation, execute the command with `--yes` to perform the write.
## Parameters
| Parameter | Required | Limits | Description |
|------|------|------|------|
| `--chat-id <id-or-url>` | Yes | `oc_xxx` or a supported chat link | Target group ID or supported group link |
| `--users <ids>` | At least one member type is required | Up to 50 unique values | Comma-separated user `open_id` values in `ou_xxx` form |
| `--bots <ids>` | At least one member type is required | Up to 5 unique values | Comma-separated bot application `app_id` values in `cli_xxx` form |
| `--as <identity>` | No | `user` or `bot` | Identity used for both requests |
| `--yes` | Required for execution | - | Performs the write only after explicit operator confirmation |
| `--dry-run` | No | - | Prints the request preview without executing it or requiring `--yes` |
At least one of `--users` or `--bots` must be present. Duplicate values are removed automatically while preserving first-seen order. Limits are applied after deduplication.
Both `--as user` and `--as bot` are supported. The selected identity requires the `im:chat.members:write_only` scope and sufficient permission to add members to the target chat.
## Resolve Member Identifiers
Use only identifiers obtained from a verified command response:
- For the currently authorized user, use either `contact +get-user --as user` or `auth status --json --verify` and read `identities.user.openId`.
- For the current configured application bot, use `auth status --json --verify` and read the top-level `appId`.
- For any other bot, list a chat that contains that bot and read `bots[].app_id` from a complete `+chat-members-list` result. If the lookup fails, `has_more:true`, or bot truncation is present, stop before the write instead of substituting the current application ID or guessing an ID.
Keep identifier lookup chats separate from the write target. A chat used only to discover `bots[].app_id` must never replace the original `--chat-id`.
## Request Behavior
User and bot members are always sent in separate requests because they use different identifier types. The user request runs first with `member_id_type=open_id`; the bot request follows with `member_id_type=app_id`. Every request fixes `succeed_type=1`, so members that can be added continue to be added while member-level failures are returned in the response.
If the user request fails for any reason, execution stops immediately and the bot request is not sent. For a network, transport, or invalid-response failure in this first request, the typed error's fixed hint already contains the complete user readback command and completeness conditions. Read back every visible user member with the same identity before considering a retry:
```bash
lark-cli im +chat-members-list \
--chat-id oc_xxx \
--member-types user \
--page-all \
--page-limit 0 \
--as <same-identity>
```
The user list is sufficient for an absence check only when `has_more:false` and `truncations` contains no entry for `member_type:"user"`. If `has_more:true` or user truncation is present, a missing identifier does not prove that the user was not added; do not retry the unknown user batch. When the list is complete, retry only users that are not confirmed present. If the user request succeeds and the bot request fails, confirmed user results remain in the output and in the chat. The operation has no transaction or automatic rollback. See [List chat members](lark-im-chat-members-list.md) for pagination and truncation details.
## Output
The `data` object contains one combined result. It reports only the chat identifier, the total `success_count`, and three member-level failure arrays; counts are not separated by user and bot type.
| Field | Description |
|------|------|
| `chat_id` | Target chat ID |
| `success_count` | Total number of members confirmed as added after deduplication |
| `invalid_id_list` | Identifiers rejected as invalid |
| `not_existed_id_list` | Identifiers that do not exist |
| `pending_approval_id_list` | Identifiers awaiting approval and not yet confirmed as added |
A successful response exits `0` with `ok:true`:
```json
{"ok":true,"identity":"user","data":{"chat_id":"oc_xxx","success_count":3,"invalid_id_list":[],"not_existed_id_list":[],"pending_approval_id_list":[]},"meta":{"count":3}}
```
If any member-level failure array is non-empty, stdout carries `ok:false` with the complete result and the process exits `1`. Members already accepted by the service remain in the chat:
```json
{"ok":false,"identity":"user","data":{"chat_id":"oc_xxx","success_count":1,"invalid_id_list":["ou_invalid"],"not_existed_id_list":["cli_missing"],"pending_approval_id_list":["ou_pending"]}}
```
Scripts and agents must read `success_count` and all three arrays even when the process exits `1`.
## Bot Request Failure
The partial-result fields described in this section appear only when the user batch has already returned a normal API response with `code=0`, including a response with member-level failure arrays, and the following bot batch fails. In that case, `data` also contains `failed_member_type:"bot"`, `outcome_unknown`, and a structured `error` object. `success_count` and the three arrays describe only the confirmed user response; the shortcut does not guess the bot outcome.
```json
{"ok":false,"identity":"user","data":{"chat_id":"oc_xxx","success_count":1,"invalid_id_list":[],"not_existed_id_list":[],"pending_approval_id_list":[],"failed_member_type":"bot","outcome_unknown":true,"error":{"type":"network","subtype":"network_transport","message":"member request failed","hint":"List current chat members with lark-cli im +chat-members-list --chat-id <chat_id> --member-types bot --page-all --page-limit 0 --as <same-identity> before retrying. Treat a member not found as missing only when has_more:false and truncations has no entry for member_type bot; otherwise do not retry the unknown batch. Retry only bots not confirmed present.","retryable":false}}}
```
`outcome_unknown:true` covers network or transport failures and responses that cannot be parsed or validated. The JSON example preserves the implementation's exact fixed `error.hint`. The hint already contains the bot filter, unlimited page count, same-identity placeholder, completeness conditions, and retry rule. The standalone command is shown here for readability:
```bash
lark-cli im +chat-members-list \
--chat-id oc_xxx \
--member-types bot \
--page-all \
--page-limit 0 \
--as <same-identity>
```
The bot list is sufficient for an absence check only when `has_more:false` and `truncations` contains no entry for `member_type:"bot"`. If `has_more:true` or bot truncation is present, a missing identifier does not prove that the bot was not added; do not retry the unknown bot batch. When the list is complete, process only bots that are not confirmed present. Do not repeat the complete `+chat-members-add` command, because the service may have accepted the bot request before the response became unavailable.
For `outcome_unknown:true` after a confirmed user batch, apply this checklist in order:
1. Preserve the original target `chat_id` and the original `--as` identity.
2. Treat the confirmed user batch as complete. Every recovery command must omit `--users`.
3. Resolve each bot `app_id` from a verified source as described above. Do not replace an unverified bot with `auth status.appId` unless the requested bot is explicitly the current configured application bot.
4. List the original target chat's bot members with `--member-types bot --page-all --page-limit 0` and the same identity.
5. If the listing is incomplete or fails, stop and report that error. If the list is complete, omit bots already present and retry only missing bots with `--bots <unconfirmed-app-ids> --yes` against the original target chat.
The confirmed user count is never rolled back by a later bot failure. Replaying `--users`, changing the target chat, changing identity, or retrying a bot already present can duplicate or misdirect a high-impact write.
Deterministic permission, authentication, and API errors use `outcome_unknown:false`. Handle these through the structured `error` fields. The confirmed user additions remain in the chat.
When only `--bots` is supplied, the bot request is the first batch and there is no preceding user result. A deterministic failure returns the standard typed error envelope with top-level `ok:false`, `identity`, and `error`; it does not include `data.failed_member_type`, `data.outcome_unknown`, or any preceding result. A network or transport failure, or a response that cannot be parsed or validated, also remains a top-level typed error. Automatic retry is disabled, and the fixed `error.hint` already includes the complete bot readback command and completeness conditions, but the failure still has no partial-result fields. Apply the same complete bot readback conditions above, use the same identity, and retry only bots that are not confirmed present.
## References
- [List chat members](lark-im-chat-members-list.md)
- [Create a chat](lark-im-chat-create.md)
- [lark-im](../SKILL.md)
- [lark-shared](../../lark-shared/SKILL.md)

View File

@@ -0,0 +1,102 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestIM_ChatMembersAddDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
tests := []struct {
name string
args []string
requests []chatMembersAddDryRunRequest
}{
{
name: "users only",
args: []string{"--users", "ou_user_b,ou_user_a,ou_user_b"},
requests: []chatMembersAddDryRunRequest{
{memberIDType: "open_id", ids: []string{"ou_user_b", "ou_user_a"}},
},
},
{
name: "bots only",
args: []string{"--bots", "cli_bot_b,cli_bot_a,cli_bot_b"},
requests: []chatMembersAddDryRunRequest{
{memberIDType: "app_id", ids: []string{"cli_bot_b", "cli_bot_a"}},
},
},
{
name: "users before bots",
args: []string{
"--users", "ou_user_b,ou_user_a,ou_user_b",
"--bots", "cli_bot_b,cli_bot_a,cli_bot_b",
},
requests: []chatMembersAddDryRunRequest{
{memberIDType: "open_id", ids: []string{"ou_user_b", "ou_user_a"}},
{memberIDType: "app_id", ids: []string{"cli_bot_b", "cli_bot_a"}},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
args := []string{
"im", "+chat-members-add",
"--chat-id", "oc_e2e_chat",
}
args = append(args, tt.args...)
args = append(args, "--dry-run")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: args,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
require.True(t, gjson.Get(result.Stdout, "dry_run").Bool(), "expected a dry-run response")
requests := clie2e.DryRunGet(result.Stdout, "api").Array()
require.Len(t, requests, len(tt.requests), "unexpected dry-run request count")
for i, expected := range tt.requests {
assertChatMembersAddDryRunRequest(t, requests[i], expected)
}
})
}
}
type chatMembersAddDryRunRequest struct {
memberIDType string
ids []string
}
func assertChatMembersAddDryRunRequest(t *testing.T, request gjson.Result, expected chatMembersAddDryRunRequest) {
t.Helper()
require.Equal(t, "POST", request.Get("method").String())
require.Equal(t, "/open-apis/im/v1/chats/oc_e2e_chat/members", request.Get("url").String())
require.Equal(t, expected.memberIDType, request.Get("params.member_id_type").String())
require.Equal(t, "1", request.Get("params.succeed_type").String())
actualIDs := make([]string, 0, len(request.Get("body.id_list").Array()))
for _, item := range request.Get("body.id_list").Array() {
actualIDs = append(actualIDs, item.String())
}
require.Equal(t, expected.ids, actualIDs)
}

View File

@@ -0,0 +1,447 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"sort"
"strconv"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
const chatMembersAddWriteScope = "im:chat.members:write_only"
func TestIM_ChatMembersAddWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
clie2e.SkipWithoutUserToken(t)
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
parentT := t
suffix := clie2e.GenerateSuffix()
selfOpenID := getSelfOpenIDForChatMembersAdd(t, ctx)
botChatID := createChatAs(t, parentT, ctx, "lark-cli-e2e-member-add-bot-"+suffix, "bot")
botAppID := getBotAppIDFromChat(t, ctx, botChatID)
userChatID := createChatAs(t, parentT, ctx, "lark-cli-e2e-member-add-user-"+suffix, "user")
t.Run("add user as bot and read back", func(t *testing.T) {
baseline := listChatMembers(t, ctx, botChatID, "user", "bot")
assertCompleteChatMemberList(t, baseline)
require.False(t, chatMemberListContains(baseline, "users", "member_id", selfOpenID), "target user must be absent before add")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+chat-members-add",
"--chat-id", botChatID,
"--users", selfOpenID,
},
DefaultAs: "bot",
Yes: true,
})
require.NoError(t, err)
skipIfMissingChatMemberPermission(t, result)
assertChatMembersAddSuccess(t, result)
readback := waitForChatMember(t, ctx, botChatID, "user", "bot", "users", "member_id", selfOpenID)
assertCompleteChatMemberList(t, readback)
})
t.Run("add bot as user and read back", func(t *testing.T) {
baseline := listChatMembers(t, ctx, userChatID, "bot", "user")
assertCompleteChatMemberList(t, baseline)
require.False(t, chatMemberListContains(baseline, "bots", "app_id", botAppID), "target bot must be absent before add")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+chat-members-add",
"--chat-id", userChatID,
"--bots", botAppID,
},
DefaultAs: "user",
Yes: true,
})
require.NoError(t, err)
skipIfMissingChatMemberPermission(t, result)
assertChatMembersAddSuccess(t, result)
readback := waitForChatMember(t, ctx, userChatID, "bot", "user", "bots", "app_id", botAppID)
assertCompleteChatMemberList(t, readback)
})
}
func getSelfOpenIDForChatMembersAdd(t *testing.T, ctx context.Context) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"contact", "+get-user"},
DefaultAs: "user",
})
require.NoError(t, err)
require.Equal(t, 0, result.ExitCode, "contact lookup failed: %s", resultErrorSummary(result))
require.True(t, gjson.Get(result.Stdout, "ok").Bool(), "contact lookup returned ok=false")
openID := gjson.Get(result.Stdout, "data.user.open_id").String()
require.NotEmpty(t, openID, "contact lookup returned an empty open_id")
return openID
}
func getBotAppIDFromChat(t *testing.T, ctx context.Context, chatID string) string {
t.Helper()
result, err := clie2e.RunCmdWithRetry(ctx, chatMembersListRequest(chatID, "bot", "bot"), clie2e.RetryOptions{
Attempts: 6,
InitialDelay: time.Second,
MaxDelay: 4 * time.Second,
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil {
return true
}
if result.ExitCode != 0 {
return clie2e.ResultHasRetryableError(result)
}
for _, bot := range gjson.Get(result.Stdout, "data.bots").Array() {
if bot.Get("app_id").String() != "" {
return false
}
}
return true
},
})
require.NoError(t, err)
skipIfMissingChatMemberPermission(t, result)
require.Equal(t, 0, result.ExitCode, "bot member discovery failed: %s", resultErrorSummary(result))
require.True(t, gjson.Get(result.Stdout, "ok").Bool(), "bot member discovery returned ok=false")
assertCompleteChatMemberList(t, result)
for _, bot := range gjson.Get(result.Stdout, "data.bots").Array() {
if appID := bot.Get("app_id").String(); appID != "" {
return appID
}
}
t.Fatal("bot member discovery returned no app_id")
return ""
}
func chatMembersListRequest(chatID, memberType, defaultAs string) clie2e.Request {
return clie2e.Request{
Args: []string{
"im", "+chat-members-list",
"--chat-id", chatID,
"--member-types", memberType,
"--page-all",
"--page-limit", "0",
},
DefaultAs: defaultAs,
}
}
func listChatMembers(t *testing.T, ctx context.Context, chatID, memberType, defaultAs string) *clie2e.Result {
t.Helper()
result, err := clie2e.RunCmd(ctx, chatMembersListRequest(chatID, memberType, defaultAs))
require.NoError(t, err)
skipIfMissingChatMemberPermission(t, result)
require.Equal(t, 0, result.ExitCode, "member list failed: %s", resultErrorSummary(result))
require.True(t, gjson.Get(result.Stdout, "ok").Bool(), "member list returned ok=false")
return result
}
func waitForChatMember(
t *testing.T,
ctx context.Context,
chatID string,
memberType string,
defaultAs string,
bucket string,
idField string,
id string,
) *clie2e.Result {
t.Helper()
result, err := clie2e.RunCmdWithRetry(ctx, chatMembersListRequest(chatID, memberType, defaultAs), clie2e.RetryOptions{
Attempts: 8,
InitialDelay: time.Second,
MaxDelay: 5 * time.Second,
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil {
return true
}
if result.ExitCode != 0 {
return clie2e.ResultHasRetryableError(result)
}
return !chatMemberListContains(result, bucket, idField, id)
},
})
require.NoError(t, err)
skipIfMissingChatMemberPermission(t, result)
require.Equal(t, 0, result.ExitCode, "member readback failed: %s", resultErrorSummary(result))
require.True(t, gjson.Get(result.Stdout, "ok").Bool(), "member readback returned ok=false")
require.True(t, chatMemberListContains(result, bucket, idField, id), "added member was absent after bounded readback")
return result
}
func chatMemberListContains(result *clie2e.Result, bucket, idField, id string) bool {
if result == nil {
return false
}
for _, member := range gjson.Get(result.Stdout, "data."+bucket).Array() {
if member.Get(idField).String() == id {
return true
}
}
return false
}
func assertCompleteChatMemberList(t *testing.T, result *clie2e.Result) {
t.Helper()
require.False(t, gjson.Get(result.Stdout, "data.has_more").Bool(), "member list must include all pages")
require.Empty(t, gjson.Get(result.Stdout, "data.truncations").Array(), "member list must not contain truncation markers")
}
func assertChatMembersAddSuccess(t *testing.T, result *clie2e.Result) {
t.Helper()
require.Equal(t, 0, result.ExitCode, "member add failed: %s", resultErrorSummary(result))
require.True(t, gjson.Get(result.Stdout, "ok").Bool(), "member add returned ok=false")
require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.success_count").Int())
for _, field := range []string{"invalid_id_list", "not_existed_id_list", "pending_approval_id_list"} {
value := gjson.Get(result.Stdout, "data."+field)
require.True(t, value.Exists(), "%s must be present", field)
require.True(t, value.IsArray(), "%s must be an array", field)
require.Empty(t, value.Array(), "%s must be empty", field)
}
}
func skipIfMissingChatMemberPermission(t *testing.T, result *clie2e.Result) {
t.Helper()
if result == nil || result.ExitCode == 0 {
return
}
scopes := missingPermissionNames(result)
if len(scopes) == 0 {
return
}
t.Skipf("skipped: missing IM member permissions: %s", strings.Join(scopes, ", "))
}
func missingPermissionNames(result *clie2e.Result) []string {
if result == nil {
return nil
}
names := map[string]struct{}{}
fallbackNames := map[string]struct{}{}
missingScopeFailure := false
for _, raw := range []string{result.Stdout, result.Stderr} {
payload := extractTrailingJSONEnvelope(raw)
if payload == "" {
continue
}
subtype := gjson.Get(payload, "error.subtype").String()
code := gjson.Get(payload, "error.code").Int()
if !isMissingIMMemberScope(subtype, code) {
continue
}
missingScopeFailure = true
if isMissingIMMemberScopeCode(code) {
fallbackNames["scope code "+strconv.FormatInt(code, 10)] = struct{}{}
} else {
fallbackNames["IM chat member scope"] = struct{}{}
}
for _, scope := range gjson.Get(payload, "error.missing_scopes").Array() {
if name := scope.String(); name != "" {
names[name] = struct{}{}
}
}
}
if !missingScopeFailure {
return nil
}
if len(names) == 0 {
for name := range fallbackNames {
names[name] = struct{}{}
}
}
resultNames := make([]string, 0, len(names))
for name := range names {
resultNames = append(resultNames, name)
}
sort.Strings(resultNames)
return resultNames
}
func extractTrailingJSONEnvelope(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
if strings.HasPrefix(trimmed, "{") && gjson.Valid(trimmed) {
return trimmed
}
searchEnd := len(trimmed)
for searchEnd > 0 {
candidateStart := strings.LastIndex(trimmed[:searchEnd], "{")
if candidateStart < 0 {
return ""
}
candidate := strings.TrimSpace(trimmed[candidateStart:])
if gjson.Valid(candidate) {
return candidate
}
searchEnd = candidateStart
}
return ""
}
func isMissingIMMemberScope(subtype string, code int64) bool {
switch subtype {
case "missing_scope", "app_scope_not_applied", "token_scope_insufficient":
return true
default:
return isMissingIMMemberScopeCode(code)
}
}
func isMissingIMMemberScopeCode(code int64) bool {
switch code {
case 99991672, 99991676, 99991679:
return true
default:
return false
}
}
func TestMissingIMMemberPermissionNames(t *testing.T) {
tests := []struct {
name string
stderr string
stdout string
want []string
}{
{
name: "missing scope subtype",
stderr: `{"error":{"type":"authorization","subtype":"missing_scope","missing_scopes":["im:chat.members:read"]}}`,
want: []string{"im:chat.members:read"},
},
{
name: "app scope not applied subtype",
stderr: `{"error":{"type":"authorization","subtype":"app_scope_not_applied","missing_scopes":["im:chat.members:write_only"]}}`,
want: []string{chatMembersAddWriteScope},
},
{
name: "token scope insufficient subtype",
stderr: `{"error":{"type":"authorization","subtype":"token_scope_insufficient"}}`,
want: []string{"IM chat member scope"},
},
{
name: "app scope not applied code",
stderr: `{"error":{"type":"api_error","code":99991672}}`,
want: []string{"scope code 99991672"},
},
{
name: "token scope insufficient code",
stderr: `{"error":{"type":"api_error","code":99991676}}`,
want: []string{"scope code 99991676"},
},
{
name: "missing scope code on stdout",
stdout: `{"error":{"type":"api_error","code":99991679}}`,
want: []string{"scope code 99991679"},
},
{
name: "page progress before missing scope",
stderr: "[page 1] fetching...\n{\"error\":{\"type\":\"authorization\",\"subtype\":\"missing_scope\",\"missing_scopes\":[\"im:chat.members:read\"]}}",
want: []string{"im:chat.members:read"},
},
{
name: "page progress before multiline scope envelope",
stderr: "[page 1] fetching...\n{\n" +
" \"error\": {\n" +
" \"type\": \"authorization\",\n" +
" \"subtype\": \"app_scope_not_applied\",\n" +
" \"missing_scopes\": [\"im:chat.members:write_only\"]\n" +
" }\n" +
"}\n",
want: []string{chatMembersAddWriteScope},
},
{
name: "authorization category alone",
stderr: `{"error":{"type":"authorization","subtype":"unknown"}}`,
},
{
name: "ordinary permission denied",
stderr: `{"error":{"type":"authorization","subtype":"permission_denied"}}`,
},
{
name: "page progress before permission denied",
stderr: "[page 1] fetching...\n{\"error\":{\"type\":\"authorization\",\"subtype\":\"permission_denied\"}}",
},
{
name: "not in chat resource state",
stderr: `{"error":{"type":"validation","subtype":"failed_precondition","message":"not in chat"}}`,
},
{
name: "page progress before resource state error",
stderr: "[page 1] fetching...\n{\"error\":{\"type\":\"validation\",\"subtype\":\"failed_precondition\",\"message\":\"not in chat\"}}",
},
{
name: "no invite permission",
stderr: `{"error":{"type":"authorization","subtype":"permission_denied","message":"no invite permission"}}`,
},
{
name: "scope substring is not accepted",
stderr: `{"error":{"type":"authorization","subtype":"unknown_scope_state"}}`,
},
{
name: "unknown error",
stderr: `{"error":{"type":"internal","subtype":"unknown"}}`,
},
{
name: "non JSON progress text",
stderr: "[page 1] fetching...\nrequest failed",
},
{
name: "embedded JSON does not cover text end",
stderr: `[page {"error":{"subtype":"missing_scope"}}] fetching...`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := &clie2e.Result{
ExitCode: 3,
Stdout: tt.stdout,
Stderr: tt.stderr,
}
require.Equal(t, tt.want, missingPermissionNames(result))
})
}
}
func resultErrorSummary(result *clie2e.Result) string {
if result == nil {
return "result=nil"
}
for _, raw := range []string{result.Stderr, result.Stdout} {
payload := extractTrailingJSONEnvelope(raw)
if payload == "" {
continue
}
return "type=" + gjson.Get(payload, "error.type").String() +
" subtype=" + gjson.Get(payload, "error.subtype").String()
}
return "structured error unavailable"
}

View File

@@ -1,9 +1,9 @@
# IM CLI E2E Coverage
## Metrics
- Denominator: 30 leaf commands
- Covered: 11
- Coverage: 36.7%
- Denominator: 31 leaf commands
- Covered: 12
- Coverage: 38.7%
## Summary
- TestIM_ChatUpdateWorkflow: proves `im +chat-create`, `im +chat-update`, and `im chats get`; key `t.Run(...)` proof points are `update chat name as bot`, `update chat description as bot`, and `get updated chat as bot`.
@@ -14,6 +14,8 @@
- TestIM_MessageReplyWorkflowAsBot: proves threaded reply flow through `reply to message in thread as bot` and `list thread replies as bot`, reading back the reply from `im +threads-messages-list`.
- TestIM_MessagesSendAudioDryRunRejectsNonOpus: proves the `im +messages-send --audio` dry-run validation rejects non-Opus local audio before upload, with typed validation metadata and recovery guidance.
- TestIM_MessageForwardWorkflowAsUser: proves UAT-backed API forwarding through `im messages forward` and `im threads forward` using a fresh message/thread fixture; skips the forward assertions when the current test app/UAT lacks IM forward permission.
- TestIM_ChatMembersAddDryRun: proves `im +chat-members-add` builds users-only, bots-only, and users-before-bots requests with fixed `succeed_type=1`, preserved first-seen order, duplicate removal, and no `--yes` requirement during dry-run.
- TestIM_ChatMembersAddWorkflow: proves live user and bot member additions through two isolated private chats, checks each target member is initially absent, and reads the added `open_id` or `app_id` back from `im +chat-members-list`.
- Blocked area: `im +chat-search` did not reliably return freshly created private chats in UAT, and `im +messages-search` did not reliably index freshly sent messages in time for a deterministic read-after-write assertion, so both remain uncovered.
## Command Table
@@ -21,6 +23,7 @@
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | im +chat-create | shortcut | im/chat_message_workflow_test.go::TestIM_ChatMessageWorkflowAsUser/create chat as user; im/chat_workflow_test.go::TestIM_ChatUpdateWorkflow; im/chat_workflow_test.go::TestIM_ChatsGetWorkflow; im/chat_workflow_test.go::TestIM_ChatsLinkWorkflow; im/message_get_workflow_test.go::TestIM_MessageGetWorkflowAsUser; im/message_reply_workflow_test.go::TestIM_MessageReplyWorkflowAsBot | `--name`; `--type private` | covered via workflow setup with created chat IDs asserted |
| ✓ | im +chat-members-add | shortcut | im/chat_members_add_dryrun_test.go::TestIM_ChatMembersAddDryRun; im/chat_members_add_workflow_test.go::TestIM_ChatMembersAddWorkflow/add user as bot and read back; im/chat_members_add_workflow_test.go::TestIM_ChatMembersAddWorkflow/add bot as user and read back | dry-run: users only, bots only, users then bots; live: `--users <open_id> --as bot --yes`, `--bots <app_id> --as user --yes` | dry-run proves request count, request order, fixed parameters, and duplicate removal; live tests confirm absence before add and presence after add |
| ✓ | im +chat-messages-list | shortcut | im/chat_message_workflow_test.go::TestIM_ChatMessageWorkflowAsUser/list chat messages as user; im/message_reply_workflow_test.go::TestIM_MessageReplyWorkflowAsBot/list thread replies as bot | `--chat-id`; `--start`; `--end` | reads back created message and discovers thread ID |
| ✕ | im +chat-search | shortcut | | none | UAT did not reliably return freshly created private chats, so it is left uncovered |
| ✓ | im +chat-update | shortcut | im/chat_workflow_test.go::TestIM_ChatUpdateWorkflow/update chat name as bot; im/chat_workflow_test.go::TestIM_ChatUpdateWorkflow/update chat description as bot | `--chat-id`; `--name`; `--description` | |