Files
larksuite__cli/shortcuts/apps/apps_member_common.go
yxy-bd db102ab314 feat (apps): add miaoda app collaborator management (#2230)
* feat: add miaoda app collaborator management

* refactor: remove collaborator list pagination

* fix: make external invite setting read-only

* fix: make Miaoda copy setting read-only

* docs: record BOE collaborator verification

* fix: remove unsupported Miaoda setting flags
2026-08-07 16:20:02 +08:00

671 lines
23 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"fmt"
"strings"
"unicode"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
memberListPath = apiBasePath + "/apps/%s/members"
memberRemovePath = apiBasePath + "/apps/%s/members/remove"
memberSettingsPath = apiBasePath + "/apps/%s/member-settings"
)
var (
memberWriteTypes = []string{"openid", "openchat", "opendepartmentid"}
memberListTypes = []string{"user", "department", "chat"}
memberRoles = []string{"view", "edit", "full_access"}
)
func normalizeMemberAPIError(err error) error {
if err == nil {
return nil
}
problem, ok := errs.ProblemOf(err)
if !ok {
return err
}
switch problem.Code {
case 40005, 3340005:
problem.Subtype = errs.SubtypeFeatureNotAvailable
problem.Message = "Collaborator management is not available for this app via lark-cli."
problem.Hint = "Open this app in Miaoda and manage collaborators from its permission settings."
problem.Retryable = false
case 40006, 3340006:
problem.Subtype = errs.SubtypeFeatureNotAvailable
problem.Message = "External collaborator invitations cannot be configured independently."
problem.Hint = "Set --external-access instead; external_invite follows that setting."
problem.Retryable = false
case 40007, 3340007:
problem.Subtype = errs.SubtypeFeatureNotAvailable
problem.Message = "Copy, print, and download permissions are read-only for Miaoda apps."
problem.Hint = "Inspect copy_download_by with +member-settings-get; do not retry this setting through lark-cli."
problem.Retryable = false
case 40400, 3340400:
problem.Subtype = errs.SubtypeNotFound
}
return err
}
type memberIdentityRequest struct {
UserOpenID string `json:"user_open_id,omitempty"`
DepartmentID string `json:"department_id,omitempty"`
ChatID string `json:"chat_id,omitempty"`
}
type memberAddRequest struct {
memberIdentityRequest
Role string `json:"role"`
NeedNotification *bool `json:"need_notification,omitempty"`
}
type memberUpdateRequest struct {
memberIdentityRequest
Role string `json:"role"`
}
type memberRemoveRequest struct {
memberIdentityRequest
}
type memberSettingsUpdateRequest struct {
ExternalAccess *string `json:"external_access,omitempty"`
LinkShare *string `json:"link_share,omitempty"`
ManageCollaboratorsBy *string `json:"manage_collaborators_by,omitempty"`
CommentBy *string `json:"comment_by,omitempty"`
}
type memberAPIRecord struct {
MemberType string `json:"member_type"`
UserOpenID *string `json:"user_open_id,omitempty"`
DepartmentID *string `json:"department_id,omitempty"`
ChatID *string `json:"chat_id,omitempty"`
Name string `json:"name,omitempty"`
Role string `json:"role"`
}
type memberOutput struct {
MemberType string `json:"member_type"`
MemberID string `json:"member_id"`
Name string `json:"name,omitempty"`
Role string `json:"role"`
}
type memberSettingsResponse struct {
ExternalAccess *string `json:"external_access,omitempty"`
ExternalInvite *string `json:"external_invite,omitempty"`
LinkShare *string `json:"link_share,omitempty"`
ManageCollaboratorsBy *string `json:"manage_collaborators_by,omitempty"`
CommentBy *string `json:"comment_by,omitempty"`
CopyDownloadBy *string `json:"copy_download_by,omitempty"`
}
type memberSettingSpec struct {
flag string
field string
description string
allowed []string
writable bool
setRequest func(*memberSettingsUpdateRequest, *string)
responseValue func(memberSettingsResponse) *string
}
var memberSettingSpecs = []memberSettingSpec{
{
flag: "external-access", field: "external_access", description: "external sharing",
allowed: []string{"enabled", "disabled"}, writable: true,
setRequest: func(req *memberSettingsUpdateRequest, value *string) { req.ExternalAccess = value },
responseValue: func(settings memberSettingsResponse) *string { return settings.ExternalAccess },
},
{
field: "external_invite",
allowed: []string{"enabled", "disabled"},
responseValue: func(settings memberSettingsResponse) *string { return settings.ExternalInvite },
},
{
flag: "link-share", field: "link_share", description: "link sharing",
allowed: []string{"closed", "tenant-readable", "tenant-editable", "anyone-readable"}, writable: true,
setRequest: func(req *memberSettingsUpdateRequest, value *string) { req.LinkShare = value },
responseValue: func(settings memberSettingsResponse) *string { return settings.LinkShare },
},
{
flag: "manage-collaborators-by", field: "manage_collaborators_by", description: "who can manage collaborators",
allowed: []string{"anyone", "same-tenant", "full-access"}, writable: true,
setRequest: func(req *memberSettingsUpdateRequest, value *string) { req.ManageCollaboratorsBy = value },
responseValue: func(settings memberSettingsResponse) *string { return settings.ManageCollaboratorsBy },
},
{
flag: "comment-by", field: "comment_by", description: "who can comment",
allowed: []string{"viewer", "editor"}, writable: true,
setRequest: func(req *memberSettingsUpdateRequest, value *string) { req.CommentBy = value },
responseValue: func(settings memberSettingsResponse) *string { return settings.CommentBy },
},
{
field: "copy_download_by",
allowed: []string{"viewer", "editor", "full-access"},
responseValue: func(settings memberSettingsResponse) *string { return settings.CopyDownloadBy },
},
}
type memberSettingChangeResponse struct {
Field string `json:"field"`
Before *string `json:"before,omitempty"`
After *string `json:"after,omitempty"`
}
type memberListAPIResponse struct {
Items *[]memberAPIRecord `json:"items"`
}
type memberListOutput struct {
Items []memberOutput `json:"items"`
}
type memberAddAPIResponse struct {
Member *memberAPIRecord `json:"member"`
Changed bool `json:"changed"`
}
type memberAddOutput struct {
Member memberOutput `json:"member"`
Changed bool `json:"changed"`
}
type memberUpdateAPIResponse struct {
Member *memberAPIRecord `json:"member"`
BeforeRole string `json:"before_role"`
AfterRole string `json:"after_role"`
Changed bool `json:"changed"`
}
type memberUpdateOutput struct {
Member memberOutput `json:"member"`
BeforeRole string `json:"before_role"`
AfterRole string `json:"after_role"`
Changed bool `json:"changed"`
}
type memberRemoveAPIResponse struct {
Member *memberAPIRecord `json:"member"`
Changed bool `json:"changed"`
}
type memberRemoveOutput struct {
Member memberOutput `json:"member"`
Changed bool `json:"changed"`
}
type memberSettingsGetAPIResponse struct {
Settings *memberSettingsResponse `json:"settings"`
}
type memberSettingsGetOutput struct {
Settings memberSettingsResponse `json:"settings"`
}
type memberSettingsSetAPIResponse struct {
Settings *memberSettingsResponse `json:"settings"`
Changes *[]memberSettingChangeResponse `json:"changes"`
Changed bool `json:"changed"`
}
type memberSettingsSetOutput struct {
Settings memberSettingsResponse `json:"settings"`
Changes []memberSettingChangeResponse `json:"changes"`
Changed bool `json:"changed"`
}
func memberAppID(rctx *common.RuntimeContext) string {
return strings.TrimSpace(rctx.Str("app-id"))
}
func validateMemberAppID(rctx *common.RuntimeContext) error {
appID := memberAppID(rctx)
if appID == "" {
return appsValidationParamError("--app-id", "--app-id is required").
WithHint("list your Miaoda apps with `lark-cli apps +list`")
}
if strings.HasPrefix(appID, "cli_") {
return appsValidationParamError("--app-id", "--app-id must be a Miaoda app_id, not a credential app id").
WithHint("pass the app_... value returned by `lark-cli apps +list`, not the cli_... credential app id")
}
if !strings.HasPrefix(appID, "app_") || len(appID) == len("app_") {
return appsValidationParamError("--app-id", "--app-id must start with app_ and include an identifier").
WithHint("list your Miaoda apps with `lark-cli apps +list`, then pass its app_id")
}
for _, r := range appID {
if unicode.IsSpace(r) || unicode.IsControl(r) || r == '/' || r == '\\' {
return appsValidationParamError("--app-id", "--app-id must not contain slashes, whitespace, or control characters").
WithHint("pass one app_... identifier exactly as returned by `lark-cli apps +list`")
}
}
if err := validate.ResourceName(appID, "--app-id"); err != nil {
return appsValidationParamError("--app-id", "invalid --app-id: %v", err).
WithCause(err).
WithHint("pass one app_... identifier exactly as returned by `lark-cli apps +list`")
}
return nil
}
func buildMemberListParams(rctx *common.RuntimeContext) (map[string]interface{}, error) {
params := make(map[string]interface{})
if role := strings.TrimSpace(rctx.Str("role")); role != "" {
if !memberStringAllowed(role, memberRoles) {
return nil, appsValidationParamError("--role", "--role must be one of: view, edit, full_access").
WithHint("omit --role to list every collaborator role")
}
params["role"] = role
}
if memberType := strings.TrimSpace(rctx.Str("member-type")); memberType != "" {
if !memberStringAllowed(memberType, memberListTypes) {
return nil, appsValidationParamError("--member-type", "--member-type must be one of: user, department, chat").
WithHint("omit --member-type to list every collaborator type")
}
params["member_type"] = memberType
}
return params, nil
}
func buildMemberIdentity(memberType, memberID string) (memberIdentityRequest, error) {
memberType = strings.TrimSpace(memberType)
memberID = strings.TrimSpace(memberID)
if !memberStringAllowed(memberType, memberWriteTypes) {
return memberIdentityRequest{}, appsValidationParamError("--member-type", "--member-type must be one of: openid, openchat, opendepartmentid").
WithHint("choose the type that matches the open ID you are passing")
}
if memberID == "" {
return memberIdentityRequest{}, appsValidationParamError("--member-id", "--member-id is required").
WithHint("resolve the collaborator to a user, chat, or department open ID first")
}
prefix := map[string]string{
"openid": "ou_",
"openchat": "oc_",
"opendepartmentid": "od-",
}[memberType]
if !strings.HasPrefix(memberID, prefix) || len(memberID) == len(prefix) {
return memberIdentityRequest{}, appsValidationParamError("--member-id", "--member-id for --member-type=%s must start with %s", memberType, prefix).
WithHint(fmt.Sprintf("pass the matching external ID: %s...; internal numeric IDs are not accepted", prefix))
}
for _, r := range memberID {
if unicode.IsSpace(r) || unicode.IsControl(r) || strings.ContainsRune("?#%/\\", r) {
return memberIdentityRequest{}, appsValidationParamError("--member-id", "--member-id must not contain whitespace, control characters, or URL metacharacters").
WithHint("pass one external open ID exactly as returned by Lark")
}
}
switch memberType {
case "openid":
return memberIdentityRequest{UserOpenID: memberID}, nil
case "openchat":
return memberIdentityRequest{ChatID: memberID}, nil
case "opendepartmentid":
return memberIdentityRequest{DepartmentID: memberID}, nil
default:
return memberIdentityRequest{}, appsValidationParamError("--member-type", "--member-type has no typed request mapping").
WithHint("choose one of: openid, openchat, opendepartmentid")
}
}
func memberListURL(rctx *common.RuntimeContext) string {
return fmt.Sprintf(memberListPath, validate.EncodePathSegment(memberAppID(rctx)))
}
func memberRemoveURL(rctx *common.RuntimeContext) string {
return fmt.Sprintf(memberRemovePath, validate.EncodePathSegment(memberAppID(rctx)))
}
func memberSettingsURL(rctx *common.RuntimeContext) string {
return fmt.Sprintf(memberSettingsPath, validate.EncodePathSegment(memberAppID(rctx)))
}
func buildMemberAddRequest(rctx *common.RuntimeContext) (memberAddRequest, error) {
identity, err := buildMemberIdentity(rctx.Str("member-type"), rctx.Str("member-id"))
if err != nil {
return memberAddRequest{}, err
}
req := memberAddRequest{
memberIdentityRequest: identity,
Role: strings.TrimSpace(rctx.Str("perm")),
}
if rctx.Changed("need-notification") {
needNotification := rctx.Bool("need-notification")
req.NeedNotification = &needNotification
}
return req, nil
}
func buildMemberUpdateRequest(rctx *common.RuntimeContext) (memberUpdateRequest, error) {
identity, err := buildMemberIdentity(rctx.Str("member-type"), rctx.Str("member-id"))
if err != nil {
return memberUpdateRequest{}, err
}
return memberUpdateRequest{
memberIdentityRequest: identity,
Role: strings.TrimSpace(rctx.Str("perm")),
}, nil
}
func buildMemberRemoveRequest(rctx *common.RuntimeContext) (memberRemoveRequest, error) {
identity, err := buildMemberIdentity(rctx.Str("member-type"), rctx.Str("member-id"))
if err != nil {
return memberRemoveRequest{}, err
}
return memberRemoveRequest{memberIdentityRequest: identity}, nil
}
func buildMemberSettingsUpdateRequest(rctx *common.RuntimeContext) (memberSettingsUpdateRequest, error) {
var req memberSettingsUpdateRequest
for _, spec := range memberSettingSpecs {
if !spec.writable {
continue
}
if !rctx.Changed(spec.flag) {
continue
}
value := strings.TrimSpace(rctx.Str(spec.flag))
if !memberStringAllowed(value, spec.allowed) {
return memberSettingsUpdateRequest{}, appsValidationParamError("--"+spec.flag, "invalid value %q for --%s", value, spec.flag).
WithHint("choose one of the documented setting values shown by --help")
}
spec.setRequest(&req, &value)
}
return req, nil
}
func validateMemberMutation(rctx *common.RuntimeContext, requirePerm bool) error {
if err := validateMemberAppID(rctx); err != nil {
return err
}
if _, err := buildMemberIdentity(rctx.Str("member-type"), rctx.Str("member-id")); err != nil {
return err
}
if requirePerm {
perm := strings.TrimSpace(rctx.Str("perm"))
if !memberStringAllowed(perm, memberRoles) {
return appsValidationParamError("--perm", "--perm must be one of: view, edit, full_access").
WithHint("choose the collaborator permission explicitly")
}
}
return nil
}
func validateMemberSettingsSet(rctx *common.RuntimeContext) error {
if err := validateMemberAppID(rctx); err != nil {
return err
}
for _, spec := range memberSettingSpecs {
if !spec.writable {
continue
}
if rctx.Changed(spec.flag) {
_, err := buildMemberSettingsUpdateRequest(rctx)
return err
}
}
return appsValidationError("at least one collaborator setting must be provided").
WithParams(
appsInvalidParam("--external-access", "not provided"),
appsInvalidParam("--link-share", "not provided"),
appsInvalidParam("--manage-collaborators-by", "not provided"),
appsInvalidParam("--comment-by", "not provided"),
).
WithHint("pass at least one setting flag; omitted settings remain unchanged")
}
func memberStringAllowed(value string, allowed []string) bool {
for _, item := range allowed {
if value == item {
return true
}
}
return false
}
func decodeMemberAPIData(data map[string]interface{}, out interface{}) error {
if data == nil {
return memberInvalidResponse("member API response data must be an object")
}
raw, err := json.Marshal(data)
if err != nil {
return memberInvalidResponse("member API response could not be decoded").WithCause(err)
}
if err := json.Unmarshal(raw, out); err != nil {
return memberInvalidResponse("member API response has an invalid shape").WithCause(err)
}
return nil
}
func projectMemberListData(data map[string]interface{}) (memberListOutput, error) {
var decoded memberListAPIResponse
if err := decodeMemberAPIData(data, &decoded); err != nil {
return memberListOutput{}, err
}
if decoded.Items == nil {
return memberListOutput{}, memberInvalidResponse("member list response is missing items")
}
items := make([]memberOutput, 0, len(*decoded.Items))
for _, raw := range *decoded.Items {
item, err := projectMemberRecord(raw)
if err != nil {
return memberListOutput{}, err
}
items = append(items, item)
}
return memberListOutput{Items: items}, nil
}
func projectMemberAddData(data map[string]interface{}) (memberAddOutput, error) {
var decoded memberAddAPIResponse
if err := decodeMemberAPIData(data, &decoded); err != nil {
return memberAddOutput{}, err
}
if decoded.Member == nil {
return memberAddOutput{}, memberInvalidResponse("member add response is missing member")
}
member, err := projectMemberRecord(*decoded.Member)
if err != nil {
return memberAddOutput{}, err
}
return memberAddOutput{Member: member, Changed: decoded.Changed}, nil
}
func projectMemberUpdateData(data map[string]interface{}) (memberUpdateOutput, error) {
var decoded memberUpdateAPIResponse
if err := decodeMemberAPIData(data, &decoded); err != nil {
return memberUpdateOutput{}, err
}
if decoded.Member == nil {
return memberUpdateOutput{}, memberInvalidResponse("member update response is missing member")
}
if !memberStringAllowed(decoded.BeforeRole, memberRoles) || !memberStringAllowed(decoded.AfterRole, memberRoles) {
return memberUpdateOutput{}, memberInvalidResponse("member update response contains an unsupported role transition")
}
member, err := projectMemberRecord(*decoded.Member)
if err != nil {
return memberUpdateOutput{}, err
}
return memberUpdateOutput{
Member: member, BeforeRole: decoded.BeforeRole, AfterRole: decoded.AfterRole, Changed: decoded.Changed,
}, nil
}
func projectMemberRemoveData(data map[string]interface{}) (memberRemoveOutput, error) {
var decoded memberRemoveAPIResponse
if err := decodeMemberAPIData(data, &decoded); err != nil {
return memberRemoveOutput{}, err
}
if decoded.Member == nil {
return memberRemoveOutput{}, memberInvalidResponse("member remove response is missing member")
}
member, err := projectMemberRecord(*decoded.Member)
if err != nil {
return memberRemoveOutput{}, err
}
return memberRemoveOutput{Member: member, Changed: decoded.Changed}, nil
}
func projectMemberSettingsGetData(data map[string]interface{}) (memberSettingsGetOutput, error) {
var decoded memberSettingsGetAPIResponse
if err := decodeMemberAPIData(data, &decoded); err != nil {
return memberSettingsGetOutput{}, err
}
if decoded.Settings == nil {
return memberSettingsGetOutput{}, memberInvalidResponse("member settings response is missing settings")
}
if err := validateMemberSettingsResponse(*decoded.Settings); err != nil {
return memberSettingsGetOutput{}, err
}
return memberSettingsGetOutput{Settings: *decoded.Settings}, nil
}
func projectMemberSettingsSetData(data map[string]interface{}) (memberSettingsSetOutput, error) {
var decoded memberSettingsSetAPIResponse
if err := decodeMemberAPIData(data, &decoded); err != nil {
return memberSettingsSetOutput{}, err
}
if decoded.Settings == nil {
return memberSettingsSetOutput{}, memberInvalidResponse("member settings update response is missing settings")
}
if decoded.Changes == nil {
return memberSettingsSetOutput{}, memberInvalidResponse("member settings update response is missing changes")
}
if err := validateMemberSettingsResponse(*decoded.Settings); err != nil {
return memberSettingsSetOutput{}, err
}
if err := validateMemberSettingChanges(*decoded.Changes); err != nil {
return memberSettingsSetOutput{}, err
}
return memberSettingsSetOutput{
Settings: *decoded.Settings, Changes: *decoded.Changes, Changed: decoded.Changed,
}, nil
}
func projectMemberRecord(raw memberAPIRecord) (memberOutput, error) {
if !memberStringAllowed(raw.Role, memberRoles) {
return memberOutput{}, memberInvalidResponse("member response contains an unsupported role")
}
typedCount := 0
for _, value := range []*string{raw.UserOpenID, raw.DepartmentID, raw.ChatID} {
if value != nil {
typedCount++
}
}
if typedCount != 1 {
return memberOutput{}, memberInvalidResponse("member response must contain exactly one typed external ID")
}
var memberID, prefix string
switch raw.MemberType {
case "user":
if raw.UserOpenID == nil {
return memberOutput{}, memberInvalidResponse("member_type does not match the typed external ID")
}
memberID, prefix = *raw.UserOpenID, "ou_"
case "department":
if raw.DepartmentID == nil {
return memberOutput{}, memberInvalidResponse("member_type does not match the typed external ID")
}
memberID, prefix = *raw.DepartmentID, "od-"
case "chat":
if raw.ChatID == nil {
return memberOutput{}, memberInvalidResponse("member_type does not match the typed external ID")
}
memberID, prefix = *raw.ChatID, "oc_"
default:
return memberOutput{}, memberInvalidResponse("member response contains an unsupported member_type")
}
if !validExternalMemberID(memberID, prefix) {
return memberOutput{}, memberInvalidResponse("member response contains a malformed external ID; refusing to expose it")
}
return memberOutput{
MemberType: raw.MemberType,
MemberID: memberID,
Name: raw.Name,
Role: raw.Role,
}, nil
}
func validExternalMemberID(value, prefix string) bool {
if value != strings.TrimSpace(value) || !strings.HasPrefix(value, prefix) || len(value) == len(prefix) {
return false
}
for _, r := range value {
if unicode.IsSpace(r) || unicode.IsControl(r) || strings.ContainsRune("?#%/\\", r) {
return false
}
}
return true
}
func validateMemberSettingsResponse(settings memberSettingsResponse) error {
for _, spec := range memberSettingSpecs {
value := spec.responseValue(settings)
if value != nil && !memberStringAllowed(*value, spec.allowed) {
return memberInvalidResponse("member settings response contains an unsupported %s value", spec.field)
}
}
return nil
}
func validateMemberSettingChanges(changes []memberSettingChangeResponse) error {
for _, change := range changes {
spec := memberSettingSpecForField(change.Field)
if spec == nil {
return memberInvalidResponse("member settings response contains an unsupported changed field")
}
for _, value := range []*string{change.Before, change.After} {
if value != nil && !memberStringAllowed(*value, spec.allowed) {
return memberInvalidResponse("member settings response contains an unsupported changed value")
}
}
}
return nil
}
func memberSettingSpecForField(field string) *memberSettingSpec {
for index := range memberSettingSpecs {
if memberSettingSpecs[index].field == field {
return &memberSettingSpecs[index]
}
}
return nil
}
func memberSettingsSetFlags() []common.Flag {
flags := make([]common.Flag, 0, len(memberSettingSpecs)+1)
flags = append(flags, common.Flag{Name: "app-id", Desc: "Miaoda app ID (app_...)"})
for _, spec := range memberSettingSpecs {
if !spec.writable {
continue
}
flags = append(flags, common.Flag{
Name: spec.flag,
Desc: spec.description,
Enum: append([]string(nil), spec.allowed...),
})
}
return flags
}
func memberInvalidResponse(format string, args ...interface{}) *errs.InternalError {
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...).
WithHint("retry the operation; do not use any member ID from this response")
}
func memberDisplayValue(value string) string {
value = validate.SanitizeForTerminal(value)
return strings.TrimSpace(strings.NewReplacer("\n", " ", "\r", " ", "\t", " ").Replace(value))
}