mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Emit structured validation, API, network, file, and internal error envelopes for Wiki shortcuts so users and agents can recover from failed wiki workflows using stable type, subtype, param, and code fields. Add Wiki domain errscontract and golangci guards to prevent legacy envelope and common helper regressions.
177 lines
7.1 KiB
Go
177 lines
7.1 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package wiki
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/validate"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
// WikiMemberAdd wraps POST /open-apis/wiki/v2/spaces/{space_id}/members. The
|
|
// shortcut adds flag ergonomics over the raw API: explicit --member-type and
|
|
// --member-role enum hints, optional --need-notification, my_library
|
|
// resolution, and a flattened single-member output envelope.
|
|
var WikiMemberAdd = common.Shortcut{
|
|
Service: "wiki",
|
|
Command: "+member-add",
|
|
Description: "Add a member to a wiki space",
|
|
Risk: "write",
|
|
// The API also accepts wiki:wiki, but the framework's preflight does
|
|
// exact-string scope matching (see +space-list), so declare the narrowest
|
|
// scope so tokens that only carry wiki:member:create aren't false-rejected.
|
|
Scopes: []string{"wiki:member:create"},
|
|
AuthTypes: []string{"user", "bot"},
|
|
Flags: []common.Flag{
|
|
{Name: "space-id", Desc: "wiki space ID; use my_library for the personal document library (user only)", Required: true},
|
|
{Name: "member-id", Desc: "member ID; interpretation is decided by --member-type", Required: true},
|
|
{Name: "member-type", Desc: "ID type for --member-id", Required: true, Enum: wikiMemberTypes},
|
|
{Name: "member-role", Desc: "role granted within the space", Required: true, Enum: wikiMemberRoles},
|
|
{Name: "need-notification", Type: "bool", Desc: "send an in-app notification to the new member after the grant"},
|
|
},
|
|
Tips: []string{
|
|
"Use --member-type=email with the user's mailbox if you do not know their open_id.",
|
|
"--member-role=admin grants full space administration; pick --member-role=member for collaborator access.",
|
|
"--space-id my_library is a per-user alias and is only valid with --as user.",
|
|
},
|
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
_, err := readWikiMemberAddSpec(runtime)
|
|
return err
|
|
},
|
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
spec, err := readWikiMemberAddSpec(runtime)
|
|
if err != nil {
|
|
return common.NewDryRunAPI().Set("error", err.Error())
|
|
}
|
|
return buildWikiMemberAddDryRun(spec)
|
|
},
|
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
spec, err := readWikiMemberAddSpec(runtime)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
spaceID, err := resolveWikiMemberSpaceID(runtime, spec.SpaceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Fprintf(runtime.IO().ErrOut, "Adding wiki space member %s (type=%s, role=%s) to space %s...\n",
|
|
common.MaskToken(spec.MemberID), spec.MemberType, spec.MemberRole, common.MaskToken(spaceID))
|
|
|
|
path := fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/members", validate.EncodePathSegment(spaceID))
|
|
data, err := runtime.CallAPITyped("POST", path, spec.QueryParams(), spec.RequestBody())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
out := wikiMemberAddOutput(spaceID, common.GetMap(data, "member"))
|
|
// Defensive default: mirror +member-remove and fall back to the caller's
|
|
// inputs per-field when the API echoes empty strings or omits member
|
|
// fields, so scripts always see what was added.
|
|
if common.GetString(out, "member_id") == "" {
|
|
out["member_id"] = spec.MemberID
|
|
}
|
|
if common.GetString(out, "member_type") == "" {
|
|
out["member_type"] = spec.MemberType
|
|
}
|
|
if common.GetString(out, "member_role") == "" {
|
|
out["member_role"] = spec.MemberRole
|
|
}
|
|
fmt.Fprintf(runtime.IO().ErrOut, "Added wiki space member %s\n", common.MaskToken(common.GetString(out, "member_id")))
|
|
runtime.Out(out, nil)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// wikiMemberAddSpec is the normalized CLI input.
|
|
type wikiMemberAddSpec struct {
|
|
SpaceID string
|
|
MemberID string
|
|
MemberType string
|
|
MemberRole string
|
|
NeedNotification bool
|
|
NotificationSet bool
|
|
}
|
|
|
|
// RequestBody builds the JSON body for POST /spaces/{id}/members.
|
|
func (spec wikiMemberAddSpec) RequestBody() map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"member_id": spec.MemberID,
|
|
"member_type": spec.MemberType,
|
|
"member_role": spec.MemberRole,
|
|
}
|
|
}
|
|
|
|
// QueryParams returns nil unless the caller explicitly set --need-notification,
|
|
// so the request stays clean when the flag is omitted instead of always
|
|
// forcing need_notification=false.
|
|
func (spec wikiMemberAddSpec) QueryParams() map[string]interface{} {
|
|
if !spec.NotificationSet {
|
|
return nil
|
|
}
|
|
return map[string]interface{}{"need_notification": spec.NeedNotification}
|
|
}
|
|
|
|
func readWikiMemberAddSpec(runtime *common.RuntimeContext) (wikiMemberAddSpec, error) {
|
|
spec := wikiMemberAddSpec{
|
|
SpaceID: strings.TrimSpace(runtime.Str("space-id")),
|
|
MemberID: strings.TrimSpace(runtime.Str("member-id")),
|
|
MemberType: strings.ToLower(strings.TrimSpace(runtime.Str("member-type"))),
|
|
MemberRole: strings.ToLower(strings.TrimSpace(runtime.Str("member-role"))),
|
|
NeedNotification: runtime.Bool("need-notification"),
|
|
NotificationSet: runtime.Cmd.Flags().Changed("need-notification"),
|
|
}
|
|
if err := validateWikiMemberSpaceID(runtime, spec.SpaceID); err != nil {
|
|
return wikiMemberAddSpec{}, err
|
|
}
|
|
if spec.MemberID == "" {
|
|
return wikiMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id is required and cannot be blank").WithParam("--member-id")
|
|
}
|
|
// The space-member API rejects opendepartmentid grants under a
|
|
// tenant_access_token; surface that as a CLI validation error so callers do
|
|
// not waste a network round-trip on a server-side 403. The escape hatch is
|
|
// --as user, which is the only identity the API accepts for departments.
|
|
if runtime.As().IsBot() && spec.MemberType == "opendepartmentid" {
|
|
return wikiMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
"--as bot does not support --member-type opendepartmentid; rerun with --as user",
|
|
).WithParam("--member-type")
|
|
}
|
|
// --member-type / --member-role enum membership is enforced by the
|
|
// framework's validateEnumFlags (runner.go) before Validate runs, so no
|
|
// extra membership check is needed here.
|
|
return spec, nil
|
|
}
|
|
|
|
func buildWikiMemberAddDryRun(spec wikiMemberAddSpec) *common.DryRunAPI {
|
|
dry := common.NewDryRunAPI()
|
|
if spec.SpaceID == wikiMyLibrarySpaceID {
|
|
dry.Desc("2-step orchestration: resolve my_library -> add wiki space member").
|
|
GET("/open-apis/wiki/v2/spaces/my_library").
|
|
Desc("[1] Resolve my_library space ID")
|
|
dry.POST(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/members", "<resolved_space_id>")).
|
|
Desc("[2] Add wiki space member").
|
|
Params(spec.QueryParams()).
|
|
Body(spec.RequestBody())
|
|
return dry
|
|
}
|
|
return dry.POST(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/members", validate.EncodePathSegment(spec.SpaceID))).
|
|
Params(spec.QueryParams()).
|
|
Body(spec.RequestBody())
|
|
}
|
|
|
|
// wikiMemberAddOutput flattens data.member onto a top-level envelope so
|
|
// scripts can read member fields without traversing the nested response.
|
|
func wikiMemberAddOutput(spaceID string, raw map[string]interface{}) map[string]interface{} {
|
|
out := map[string]interface{}{"space_id": spaceID}
|
|
for k, v := range wikiMemberRecord(raw) {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|