mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
9b845a5b8b
Add --meeting-owner-id to the +create shortcut so bot (tenant-access-token) callers can set the VC meeting owner via vchat.meeting_settings.owner_id without dropping to the raw API. buildEventData now builds vchat as a nested map, keeping the default vc_type: vc and injecting meeting_settings.owner_id only when the flag is set; dry-run reflects it automatically. Validate the flag with typed errs errors: reject dangerous chars, require a non-bare ou_ user open_id, and enforce bot-only effectiveness (--as bot). Cover the behavior with buildEventData unit tests plus command-boundary tests (dry-run owner_id assertion and typed Validate errors for bare ou_, non-ou_ ids, and non-bot identity), and document the flag and the raw-API default-value gap in the calendar create reference.
362 lines
14 KiB
Go
362 lines
14 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package calendar
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/output"
|
|
"github.com/larksuite/cli/internal/validate"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[string]interface{} {
|
|
vchat := map[string]interface{}{"vc_type": "vc"}
|
|
if ownerId := strings.TrimSpace(runtime.Str("meeting-owner-id")); ownerId != "" {
|
|
vchat["meeting_settings"] = map[string]interface{}{"owner_id": ownerId}
|
|
}
|
|
eventData := map[string]interface{}{
|
|
"summary": runtime.Str("summary"),
|
|
"start_time": map[string]string{"timestamp": startTs},
|
|
"end_time": map[string]string{"timestamp": endTs},
|
|
"attendee_ability": "can_modify_event",
|
|
"free_busy_status": "busy",
|
|
"vchat": vchat,
|
|
"reminders": []map[string]int{
|
|
{"minutes": 5},
|
|
},
|
|
}
|
|
if rrule := runtime.Str("rrule"); rrule != "" {
|
|
eventData["recurrence"] = rrule
|
|
}
|
|
if description := descriptionToSend(runtime); description != "" {
|
|
eventData["description_rich"] = description
|
|
}
|
|
return eventData
|
|
}
|
|
|
|
func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]string, error) {
|
|
if attendeesStr == "" && currentUserId == "" {
|
|
return nil, nil
|
|
}
|
|
ids := strings.Split(attendeesStr, ",")
|
|
uniqueIds := make(map[string]bool)
|
|
if currentUserId != "" {
|
|
uniqueIds[currentUserId] = true
|
|
}
|
|
for _, id := range ids {
|
|
id = strings.TrimSpace(id)
|
|
if id != "" {
|
|
uniqueIds[id] = true
|
|
}
|
|
}
|
|
var attendees []map[string]string
|
|
for id := range uniqueIds {
|
|
switch {
|
|
case strings.HasPrefix(id, "oc_"):
|
|
attendees = append(attendees, map[string]string{"type": "chat", "chat_id": id})
|
|
case strings.HasPrefix(id, "omm_"):
|
|
attendees = append(attendees, map[string]string{"type": "resource", "room_id": id})
|
|
case strings.HasPrefix(id, "ou_"):
|
|
attendees = append(attendees, map[string]string{"type": "user", "user_id": id})
|
|
default:
|
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported attendee id format: %s", id)
|
|
}
|
|
}
|
|
return attendees, nil
|
|
}
|
|
|
|
// selfAttendeeId resolves the open_id of the identity running the command so it
|
|
// can be auto-added to the attendee list, mirroring how a human user is joined
|
|
// to their own events. For a user it comes from config; for a bot it is fetched
|
|
// from /bot/v3/info. If the bot lookup fails, we warn and return "" so the event
|
|
// is still created with the explicitly requested attendees.
|
|
func selfAttendeeId(runtime *common.RuntimeContext) string {
|
|
if !runtime.IsBot() {
|
|
return runtime.UserOpenId()
|
|
}
|
|
info, err := runtime.BotInfo()
|
|
if err != nil {
|
|
fmt.Fprintf(runtime.IO().ErrOut,
|
|
"[calendar +create] warning: could not resolve bot identity to add it as an attendee (%v); proceeding without the bot\n",
|
|
err)
|
|
return ""
|
|
}
|
|
return info.OpenID
|
|
}
|
|
|
|
func attendeesIncludeRoom(attendees []map[string]string) bool {
|
|
for _, attendee := range attendees {
|
|
if attendee["type"] == "resource" || attendee["room_id"] != "" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func guideApprovalRoomReasonError(err error, attendees []map[string]string) error {
|
|
if err == nil || !attendeesIncludeRoom(attendees) {
|
|
return err
|
|
}
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok || !strings.Contains(strings.ToLower(p.Hint), "approval_reason") {
|
|
return err
|
|
}
|
|
return withStepContext(err, "approval meeting rooms require attendees[].approval_reason; calendar +create does not expose this low-frequency field. Create the event with the raw API flow, then use `lark-cli calendar event.attendees create --as user` with attendees[].approval_reason for the room attendee.")
|
|
}
|
|
|
|
var CalendarCreate = common.Shortcut{
|
|
Service: "calendar",
|
|
Command: "+create",
|
|
Description: "Create a calendar event and optionally invite attendees",
|
|
Risk: "write",
|
|
Scopes: []string{"calendar:calendar.event:create", "calendar:calendar.event:update"},
|
|
AuthTypes: []string{"user", "bot"},
|
|
HasFormat: true,
|
|
Flags: []common.Flag{
|
|
{Name: "summary", Desc: "event title"},
|
|
{Name: "start", Desc: "start time (ISO 8601)", Required: true},
|
|
{Name: "end", Desc: "end time (ISO 8601)", Required: true},
|
|
{Name: "description", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (``; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `<br>**bold**`).", Input: []string{common.File, common.Stdin}},
|
|
{Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"},
|
|
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
|
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
|
|
{Name: "meeting-owner-id", Desc: "VC meeting owner open_id (ou_). Only effective as a bot on the app calendar; owner must be an in-tenant user"},
|
|
},
|
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
|
|
return err
|
|
}
|
|
for _, flag := range []string{"summary", "description", "rrule", "calendar-id"} {
|
|
if val := runtime.Str(flag); val != "" {
|
|
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
if attendeesStr := runtime.Str("attendee-ids"); attendeesStr != "" {
|
|
for _, id := range strings.Split(attendeesStr, ",") {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
continue
|
|
}
|
|
if !strings.HasPrefix(id, "ou_") && !strings.HasPrefix(id, "oc_") && !strings.HasPrefix(id, "omm_") {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id).WithParam("--attendee-ids")
|
|
}
|
|
}
|
|
}
|
|
|
|
if ownerId := strings.TrimSpace(runtime.Str("meeting-owner-id")); ownerId != "" {
|
|
if err := common.RejectDangerousCharsTyped("--meeting-owner-id", ownerId); err != nil {
|
|
return err
|
|
}
|
|
if !strings.HasPrefix(ownerId, "ou_") || ownerId == "ou_" {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --meeting-owner-id %q: meeting owner must be a user open_id starting with 'ou_'", ownerId).WithParam("--meeting-owner-id")
|
|
}
|
|
if !runtime.IsBot() {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--meeting-owner-id only takes effect when running as a bot on the app calendar; re-run with --as bot").WithParam("--meeting-owner-id")
|
|
}
|
|
}
|
|
|
|
if runtime.Str("start") == "" {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --start (e.g. '2026-03-12T14:00+08:00')").WithParam("--start")
|
|
}
|
|
if runtime.Str("end") == "" {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --end (e.g. '2026-03-12T15:00+08:00')").WithParam("--end")
|
|
}
|
|
startTs, err := common.ParseTime(runtime.Str("start"))
|
|
if err != nil {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
|
|
}
|
|
endTs, err := common.ParseTime(runtime.Str("end"), "end")
|
|
if err != nil {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
|
}
|
|
s, err := strconv.ParseInt(startTs, 10, 64)
|
|
if err != nil {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time: %v", err).WithParam("--start")
|
|
}
|
|
e, err := strconv.ParseInt(endTs, 10, 64)
|
|
if err != nil {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time: %v", err).WithParam("--end")
|
|
}
|
|
if e <= s {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "end time must be after start time")
|
|
}
|
|
return nil
|
|
},
|
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
calendarId := runtime.Str("calendar-id")
|
|
d := common.NewDryRunAPI()
|
|
switch calendarId {
|
|
case "":
|
|
d.Desc("(calendar-id omitted) Will use primary calendar")
|
|
calendarId = "<primary>"
|
|
case "primary":
|
|
calendarId = "<primary>"
|
|
}
|
|
startTs, err := common.ParseTime(runtime.Str("start"))
|
|
if err != nil {
|
|
return common.NewDryRunAPI().Set("error", fmt.Sprintf("--start: %v", err))
|
|
}
|
|
endTs, err := common.ParseTime(runtime.Str("end"), "end")
|
|
if err != nil {
|
|
return common.NewDryRunAPI().Set("error", fmt.Sprintf("--end: %v", err))
|
|
}
|
|
eventData := buildEventData(runtime, startTs, endTs)
|
|
attendeesStr := runtime.Str("attendee-ids")
|
|
if attendeesStr != "" {
|
|
// Note: dry-run doesn't network resolve the running identity's own
|
|
// open_id (user from config, bot from /bot/v3/info), so the auto-joined
|
|
// self attendee is not shown here.
|
|
attendees, err := parseAttendees(attendeesStr, "")
|
|
if err != nil {
|
|
return common.NewDryRunAPI().Set("error", err.Error())
|
|
}
|
|
|
|
d.Desc("2-step: create event → add attendees (auto-rollback on failure)").
|
|
POST("/open-apis/calendar/v4/calendars/:calendar_id/events").
|
|
Desc("[1/2] Create event").
|
|
Body(eventData).
|
|
POST("/open-apis/calendar/v4/calendars/:calendar_id/events/<event_id>/attendees").
|
|
Desc("[2/2] Add attendees (on failure: auto-delete event)").
|
|
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
|
Body(map[string]interface{}{"attendees": attendees, "need_notification": true})
|
|
} else {
|
|
d.POST("/open-apis/calendar/v4/calendars/:calendar_id/events").
|
|
Body(eventData)
|
|
}
|
|
return d.Set("calendar_id", calendarId)
|
|
},
|
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
|
|
if calendarId == "" {
|
|
calendarId = PrimaryCalendarIDStr
|
|
}
|
|
|
|
startTs, err := common.ParseTime(runtime.Str("start"))
|
|
if err != nil {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
|
|
}
|
|
endTs, err := common.ParseTime(runtime.Str("end"), "end")
|
|
if err != nil {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
|
}
|
|
if err := resolveDescriptionImages(runtime, calendarId); err != nil {
|
|
return err
|
|
}
|
|
|
|
eventData := buildEventData(runtime, startTs, endTs)
|
|
|
|
// Create event
|
|
data, err := runtime.CallAPITyped("POST",
|
|
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events", validate.EncodePathSegment(calendarId)),
|
|
nil, eventData)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
event, _ := data["event"].(map[string]interface{})
|
|
eventId, _ := event["event_id"].(string)
|
|
if eventId == "" {
|
|
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to create event: no event_id returned")
|
|
}
|
|
|
|
// Add attendees if specified
|
|
if attendeesStr := runtime.Str("attendee-ids"); attendeesStr != "" {
|
|
selfId := selfAttendeeId(runtime)
|
|
attendees, err := parseAttendees(attendeesStr, selfId)
|
|
if err != nil {
|
|
return withParam(err, "--attendee-ids")
|
|
}
|
|
|
|
_, err = runtime.CallAPITyped("POST",
|
|
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s/attendees", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
|
|
map[string]interface{}{"user_id_type": "open_id"},
|
|
map[string]interface{}{
|
|
"attendees": attendees,
|
|
"need_notification": true,
|
|
})
|
|
if err != nil {
|
|
err = guideApprovalRoomReasonError(err, attendees)
|
|
// Rollback: delete the event
|
|
_, rollbackErr := runtime.CallAPITyped("DELETE",
|
|
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
|
|
map[string]interface{}{"need_notification": false}, nil)
|
|
if rollbackErr != nil {
|
|
return withStepContext(err, "rollback also failed (%v); orphan event_id=%s needs manual cleanup", rollbackErr, eventId)
|
|
}
|
|
return withStepContext(err, "event rolled back successfully")
|
|
}
|
|
}
|
|
|
|
startMap, _ := event["start_time"].(map[string]interface{})
|
|
endMap, _ := event["end_time"].(map[string]interface{})
|
|
|
|
// Replace timestamp with datetime (RFC3339, device timezone)
|
|
if startMap != nil {
|
|
if tsStr, ok := startMap["timestamp"].(string); ok && tsStr != "" {
|
|
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
|
|
startMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
|
delete(startMap, "timestamp")
|
|
}
|
|
}
|
|
}
|
|
if endMap != nil {
|
|
if tsStr, ok := endMap["timestamp"].(string); ok && tsStr != "" {
|
|
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
|
|
endMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
|
delete(endMap, "timestamp")
|
|
}
|
|
}
|
|
// If datetime is empty (all-day event), adjust date: date -> timestamp(00:00:00 UTC) -> -1s -> date
|
|
if dt, _ := endMap["datetime"].(string); dt == "" {
|
|
if dateStr, ok := endMap["date"].(string); ok && dateStr != "" {
|
|
if t, err := time.ParseInLocation("2006-01-02", dateStr, time.UTC); err == nil {
|
|
endMap["date"] = t.Add(-1 * time.Second).Format("2006-01-02")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var startStr, endStr string
|
|
if startMap != nil {
|
|
startStr, _ = startMap["datetime"].(string)
|
|
if startStr == "" {
|
|
startStr, _ = startMap["date"].(string)
|
|
}
|
|
}
|
|
if endMap != nil {
|
|
endStr, _ = endMap["datetime"].(string)
|
|
if endStr == "" {
|
|
endStr, _ = endMap["date"].(string)
|
|
}
|
|
}
|
|
|
|
resultData := map[string]interface{}{
|
|
"event_id": eventId,
|
|
"summary": event["summary"],
|
|
"start": startStr,
|
|
"end": endStr,
|
|
}
|
|
if recurrence, _ := event["recurrence"].(string); recurrence != "" {
|
|
resultData["recurrence"] = recurrence
|
|
}
|
|
|
|
runtime.OutFormat(resultData, nil, func(w io.Writer) {
|
|
var rows []map[string]interface{}
|
|
rows = append(rows, resultData)
|
|
output.PrintTable(w, rows)
|
|
fmt.Fprintln(w, "\nEvent created successfully")
|
|
})
|
|
return nil
|
|
},
|
|
}
|