// 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{} { eventData := map[string]interface{}{ "summary": runtime.Str("summary"), "description": runtime.Str("description"), "start_time": map[string]string{"timestamp": startTs}, "end_time": map[string]string{"timestamp": endTs}, "attendee_ability": "can_modify_event", "free_busy_status": "busy", "vchat": map[string]string{"vc_type": "vc"}, "reminders": []map[string]int{ {"minutes": 5}, }, } if rrule := runtime.Str("rrule"); rrule != "" { eventData["recurrence"] = rrule } 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 } 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"}, {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)"}, }, 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 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 = "" case "primary": calendarId = "" } 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 current user's open_id. 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//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") } 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 != "" { currentUserId := "" if !runtime.IsBot() { currentUserId = runtime.UserOpenId() } attendees, err := parseAttendees(attendeesStr, currentUserId) 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 }, }