Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 75 additions & 37 deletions shortcuts/calendar/calendar_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,72 @@ func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[s
return eventData
}

func buildAllDayEventData(runtime *common.RuntimeContext, startDate, endDate string) map[string]interface{} {
eventData := map[string]interface{}{
"summary": runtime.Str("summary"),
"description": runtime.Str("description"),
"start_time": map[string]string{"date": startDate},
"end_time": map[string]string{"date": endDate},
"attendee_ability": "can_modify_event",
"free_busy_status": "free",
"vchat": map[string]string{"vc_type": "no_meeting"},
"reminders": []map[string]int{
{"minutes": 5},
},
}
if rrule := runtime.Str("rrule"); rrule != "" {
eventData["recurrence"] = rrule
}
return eventData
}

func parseCalendarDateFlag(value, param string) (time.Time, string, error) {
date := strings.TrimSpace(value)
parsed, err := time.Parse("2006-01-02", date)
if err != nil || parsed.Format("2006-01-02") != date {
return time.Time{}, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s must be a date in YYYY-MM-DD format when --all-day is set", param).WithParam(param)
}
return parsed, date, nil
}

func buildCalendarCreateEventData(runtime *common.RuntimeContext) (map[string]interface{}, error) {
if runtime.Bool("all-day") {
startDate, start, err := parseCalendarDateFlag(runtime.Str("start"), "--start")
if err != nil {
return nil, err
}
endDate, end, err := parseCalendarDateFlag(runtime.Str("end"), "--end")
if err != nil {
return nil, err
}
if endDate.Before(startDate) {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "end date must be on or after start date for all-day events").WithParam("--end")
}
return buildAllDayEventData(runtime, start, end), nil
}

startTs, err := common.ParseTime(runtime.Str("start"))
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
}
endTs, err := common.ParseTime(runtime.Str("end"), "end")
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
s, err := strconv.ParseInt(startTs, 10, 64)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time: %v", err).WithParam("--start")
}
e, err := strconv.ParseInt(endTs, 10, 64)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time: %v", err).WithParam("--end")
}
if e <= s {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "end time must be after start time")
}
return buildEventData(runtime, startTs, endTs), nil
}

func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]string, error) {
if attendeesStr == "" && currentUserId == "" {
return nil, nil
Expand Down Expand Up @@ -77,12 +143,13 @@ var CalendarCreate = common.Shortcut{
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: "start", Desc: "start time (ISO 8601; YYYY-MM-DD when --all-day)", Required: true},
{Name: "end", Desc: "end time (ISO 8601; YYYY-MM-DD when --all-day)", 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)"},
{Name: "all-day", Type: "bool", Desc: "create all-day event using YYYY-MM-DD dates"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
Expand Down Expand Up @@ -114,26 +181,8 @@ var CalendarCreate = common.Shortcut{
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
_, err := buildCalendarCreateEventData(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
calendarId := runtime.Str("calendar-id")
Expand All @@ -145,15 +194,10 @@ var CalendarCreate = common.Shortcut{
case "primary":
calendarId = "<primary>"
}
startTs, err := common.ParseTime(runtime.Str("start"))
eventData, err := buildCalendarCreateEventData(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", fmt.Sprintf("--start: %v", err))
return common.NewDryRunAPI().Set("error", err.Error())
}
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.
Expand Down Expand Up @@ -182,17 +226,11 @@ var CalendarCreate = common.Shortcut{
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")
eventData, err := buildCalendarCreateEventData(runtime)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
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)),
Expand Down
164 changes: 164 additions & 0 deletions shortcuts/calendar/calendar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,170 @@ func TestBuildEventData_DefaultVChat(t *testing.T) {
}
}

func TestCreate_AllDayRequestUsesDateFieldsAndDefaults(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())

stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_all_day",
"summary": "Conference",
"start_time": map[string]interface{}{
"date": "2026-05-18",
},
"end_time": map[string]interface{}{
"date": "2026-05-21",
},
},
},
},
}
reg.Register(stub)

err := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Conference",
"--start", "2026-05-18",
"--end", "2026-05-21",
"--all-day",
"--calendar-id", "cal_test123",
"--as", "bot",
}, f, nil)

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeCalendarCapturedBody(t, stub)
startTime, _ := body["start_time"].(map[string]interface{})
endTime, _ := body["end_time"].(map[string]interface{})
if got := startTime["date"]; got != "2026-05-18" {
t.Fatalf("start_time.date = %#v, want %q; body=%#v", got, "2026-05-18", body)
}
if _, ok := startTime["timestamp"]; ok {
t.Fatalf("start_time.timestamp should be omitted for all-day events; body=%#v", body)
}
if got := endTime["date"]; got != "2026-05-21" {
t.Fatalf("end_time.date = %#v, want %q; body=%#v", got, "2026-05-21", body)
}
if _, ok := endTime["timestamp"]; ok {
t.Fatalf("end_time.timestamp should be omitted for all-day events; body=%#v", body)
}
if got := body["free_busy_status"]; got != "free" {
t.Fatalf("free_busy_status = %#v, want %q; body=%#v", got, "free", body)
}
vchat, _ := body["vchat"].(map[string]interface{})
if got := vchat["vc_type"]; got != "no_meeting" {
t.Fatalf("vchat.vc_type = %#v, want %q; body=%#v", got, "no_meeting", body)
}
}

func TestCreate_AllDaySingleDayAllowsSameStartAndEnd(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())

stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_all_day_single",
"summary": "Birthday",
"start_time": map[string]interface{}{"date": "2026-05-18"},
"end_time": map[string]interface{}{"date": "2026-05-18"},
},
},
},
}
reg.Register(stub)

err := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Birthday",
"--start", "2026-05-18",
"--end", "2026-05-18",
"--all-day",
"--calendar-id", "cal_test123",
"--as", "bot",
}, f, nil)

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeCalendarCapturedBody(t, stub)
startTime, _ := body["start_time"].(map[string]interface{})
endTime, _ := body["end_time"].(map[string]interface{})
if got := startTime["date"]; got != "2026-05-18" {
t.Fatalf("start_time.date = %#v, want %q; body=%#v", got, "2026-05-18", body)
}
if got := endTime["date"]; got != "2026-05-18" {
t.Fatalf("end_time.date = %#v, want %q; body=%#v", got, "2026-05-18", body)
}
}

func TestCreate_AllDayValidationTypedErrors(t *testing.T) {
cases := []struct {
name string
args []string
wantParam string
}{
{
name: "start must be date only",
args: []string{
"+create",
"--summary", "Conference",
"--start", "2026-05-18T09:00:00+08:00",
"--end", "2026-05-21",
"--all-day",
"--as", "bot",
},
wantParam: "--start",
},
{
name: "end before start",
args: []string{
"+create",
"--summary", "Conference",
"--start", "2026-05-21",
"--end", "2026-05-18",
"--all-day",
"--as", "bot",
},
wantParam: "--end",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, CalendarCreate, tc.args, f, nil)
if err == nil {
t.Fatal("expected validation error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if p.Category != errs.CategoryValidation {
t.Fatalf("category = %q, want %q", p.Category, errs.CategoryValidation)
}
if p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
if validationErr.Param != tc.wantParam {
t.Fatalf("param = %q, want %q", validationErr.Param, tc.wantParam)
}
})
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func TestCreate_WithAttendees_Success(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())

Expand Down
2 changes: 1 addition & 1 deletion skills/lark-calendar/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ lark-cli calendar +agenda --as user
| [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) |
| [`+search-event`](references/lark-calendar-search-event.md) | 按关键词、时间范围和参会人搜索日程, 仅返回 日程ID/主题/时间等信息,详情需走 `events get` |
| [`+meeting`](references/lark-calendar-meeting.md) | 通过日程事件 ID 获取关联的视频会议信息(meeting_id、meeting_note),日程开过视频会议才会有meeting_id |
| [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人(ISO 8601 时间) |
| [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人(ISO 8601 时间;`--all-day` 使用日期) |
| [`+update`](references/lark-calendar-update.md) | 更新既有日程字段,或独立增量添加/移除参会人和会议室 |
| [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和 RSVP 状态 |
| [`+room-find`](references/lark-calendar-room-find.md) | 针对一个或多个**明确的**时间块查找可用会议室(无明确时间时禁止直接调用,需先走 +suggestion) |
Expand Down
16 changes: 11 additions & 5 deletions skills/lark-calendar/references/lark-calendar-create.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ lark-cli calendar +create \
--start "2026-03-12T12:00+08:00" \
--end "2026-03-12T13:00+08:00"

# 全天日程(开始和结束都是包含在日程内的日期)
lark-cli calendar +create \
--summary "行业大会" \
--start "2026-05-18" \
--end "2026-05-21" \
--all-day

# 指定日历
lark-cli calendar +create --summary "..." --start "..." --end "..." \
--calendar-id cal_xxx
Expand All @@ -39,20 +46,19 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`)。AI 提取时请务必保留对应前缀 |
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用主日历) |
| `--rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT,如需限制重复次数,必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" |
| `--all-day` | 否 | 创建全天日程。设置后 `--start` / `--end` 必须是 `YYYY-MM-DD` 日期,结束日期包含在日程内 |
| `--dry-run` | 否 | 预览 API 调用,不执行 |

> **⚠️ `rrule` 规则限制:飞书日历系统不支持 `COUNT` 参数。遇到限制重复次数的需求,必须根据开始时间和频率自行推算并转换成 `UNTIL=<具体日期>` 格式。**
> 自动设置 `attendee_ability: "can_modify_event"`,参会人可查看彼此并编辑日程。
> 自动设置 `free_busy_status: "busy"`,默认日程忙闲状态为忙碌。
> 普通日程自动设置 `free_busy_status: "busy"`,默认日程忙闲状态为忙碌;全天日程自动设置为 `free`
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
> 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。
> 普通日程自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议;全天日程自动设置 `vchat: {"vc_type": "no_meeting"}`。如需其他视频会议类型,请使用完整 API 命令。
> 失败保护:若添加参会人失败(如 open_id 错误),CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。

## 高级用法(完整 API 命令)

如需配置 `location`(地理位置,不含会议室位置)、`visibility`(日程公开范围)、自定义 `reminders`(提醒设置)、自定义 `attendee_ability`(参与人权限)、自定义 `free_busy_status`(日程忙闲状态)、参与人可选参加状态或全天日程等高级参数,请使用完整的 API 命令:
**注意**:
- 全天日程的开始日期和结束日期必须分别是日程开始的第一天和结束的最后一天。如果只有一天的话,开始日期和结束日期是相同。
如需配置 `location`(地理位置,不含会议室位置)、`visibility`(日程公开范围)、自定义 `reminders`(提醒设置)、自定义 `attendee_ability`(参与人权限)、自定义 `free_busy_status`(日程忙闲状态)、参与人可选参加状态等高级参数,请使用完整的 API 命令:

```bash
# 第一步:创建日程(含高级参数)
Expand Down
Loading
Loading