Files
larksuite__cli/shortcuts/calendar/calendar_create.go
calendar-assistant 9b845a5b8b feat(calendar): support setting VC meeting owner in +create (#2328)
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.
2026-08-13 15:28:57 +08:00

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 (`![name](url)`; 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`, `![p](url)<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
},
}