diff --git a/shortcuts/calendar/calendar_create.go b/shortcuts/calendar/calendar_create.go index d089d595a..6483a2f7f 100644 --- a/shortcuts/calendar/calendar_create.go +++ b/shortcuts/calendar/calendar_create.go @@ -6,6 +6,7 @@ package calendar import ( "context" "fmt" + "io" "strconv" "strings" "time" @@ -72,6 +73,7 @@ var CalendarCreate = common.Shortcut{ 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}, @@ -279,12 +281,19 @@ var CalendarCreate = common.Shortcut{ } } - runtime.Out(map[string]interface{}{ + resultData := map[string]interface{}{ "event_id": eventId, "summary": event["summary"], "start": startStr, "end": endStr, - }, nil) + } + + 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 }, } diff --git a/shortcuts/calendar/calendar_room_find.go b/shortcuts/calendar/calendar_room_find.go index 1954c6bd5..743942e94 100644 --- a/shortcuts/calendar/calendar_room_find.go +++ b/shortcuts/calendar/calendar_room_find.go @@ -205,12 +205,24 @@ func parseRoomFindAttendees(attendeesStr string, currentUserID string) ([]string return userIDs, chatIDs, nil } +func normalizeCommaSeparatedNames(raw string) string { + parts := strings.Split(raw, ",") + var cleaned []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + cleaned = append(cleaned, p) + } + } + return strings.Join(cleaned, ",") +} + func buildRoomFindBaseRequest(runtime *common.RuntimeContext) (*roomFindRequest, error) { req := &roomFindRequest{ City: strings.TrimSpace(runtime.Str(flagCity)), Building: strings.TrimSpace(runtime.Str(flagBuilding)), Floor: strings.TrimSpace(runtime.Str(flagFloor)), - RoomName: strings.TrimSpace(runtime.Str(flagRoomName)), + RoomName: normalizeCommaSeparatedNames(runtime.Str(flagRoomName)), MinCapacity: runtime.Int(flagMinCapacity), MaxCapacity: runtime.Int(flagMaxCapacity), Timezone: strings.TrimSpace(runtime.Str(flagTimezone)), @@ -272,7 +284,7 @@ var CalendarRoomFind = common.Shortcut{ {Name: flagCity, Type: "string", Desc: "meeting room city constraint"}, {Name: flagBuilding, Type: "string", Desc: "meeting room building constraint"}, {Name: flagFloor, Type: "string", Desc: "meeting room floor constraint (e.g., F2)"}, - {Name: flagRoomName, Type: "string", Desc: "meeting room name constraint (e.g., 木星, 02)"}, + {Name: flagRoomName, Type: "string", Desc: "meeting room name constraint; comma-separated for multiple names (e.g., 01,02,03)"}, {Name: flagMinCapacity, Type: "int", Desc: "minimum meeting room capacity"}, {Name: flagMaxCapacity, Type: "int", Desc: "maximum meeting room capacity"}, {Name: flagAttendees, Type: "string", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_)"}, @@ -303,13 +315,22 @@ var CalendarRoomFind = common.Shortcut{ if err := rejectCalendarAutoBotFallback(runtime); err != nil { return err } - for _, flag := range []string{flagCity, flagBuilding, flagFloor, flagRoomName, flagEventRrule, flagTimezone} { + for _, flag := range []string{flagCity, flagBuilding, flagFloor, flagEventRrule, flagTimezone} { if val := strings.TrimSpace(runtime.Str(flag)); val != "" { if err := common.RejectDangerousChars("--"+flag, val); err != nil { return output.ErrValidation(err.Error()) } } } + for _, name := range strings.Split(runtime.Str(flagRoomName), ",") { + name = strings.TrimSpace(name) + if name == "" { + continue + } + if err := common.RejectDangerousChars("--"+flagRoomName, name); err != nil { + return output.ErrValidation(err.Error()) + } + } if _, err := parseRoomFindSlots(runtime); err != nil { return err } diff --git a/shortcuts/calendar/calendar_room_find_test.go b/shortcuts/calendar/calendar_room_find_test.go index e540eff01..681509cb8 100644 --- a/shortcuts/calendar/calendar_room_find_test.go +++ b/shortcuts/calendar/calendar_room_find_test.go @@ -8,6 +8,28 @@ import ( "time" ) +func TestNormalizeCommaSeparatedNames(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"木星", "木星"}, + {"01,02,03", "01,02,03"}, + {" 01 , 02 , 03 ", "01,02,03"}, + {"16,17,18,19,20", "16,17,18,19,20"}, + {"", ""}, + {" , , ", ""}, + {"01,,03", "01,03"}, + {" 木星 ", "木星"}, + } + for _, tt := range tests { + got := normalizeCommaSeparatedNames(tt.input) + if got != tt.want { + t.Errorf("normalizeCommaSeparatedNames(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + func TestCollectRoomFindResults_LimitsConcurrency(t *testing.T) { slots := []roomFindSlot{ {Start: "2026-03-27T14:00:00+08:00", End: "2026-03-27T15:00:00+08:00"}, diff --git a/shortcuts/calendar/calendar_test.go b/shortcuts/calendar/calendar_test.go index 29815fc1e..ed1d3a3bb 100644 --- a/shortcuts/calendar/calendar_test.go +++ b/shortcuts/calendar/calendar_test.go @@ -148,6 +148,51 @@ func TestCreate_CreateEventOnly(t *testing.T) { } } +func TestCreate_CreateEventOnly_PrettyFormat(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&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_001", + "summary": "Test Meeting", + "start_time": map[string]interface{}{ + "timestamp": "1742515200", + }, + "end_time": map[string]interface{}{ + "timestamp": "1742518800", + }, + }, + }, + }, + }) + + err := mountAndRun(t, CalendarCreate, []string{ + "+create", + "--summary", "Test Meeting", + "--start", "2025-03-21T00:00:00+08:00", + "--end", "2025-03-21T01:00:00+08:00", + "--calendar-id", "cal_test123", + "--as", "bot", + "--format", "pretty", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "evt_001") { + t.Errorf("stdout should contain event_id, got: %s", out) + } + if !strings.Contains(out, "Event created successfully") { + t.Errorf("stdout should contain success message, got: %s", out) + } +} + func TestBuildEventData_DefaultVChat(t *testing.T) { cmd := &cobra.Command{Use: "test"} cmd.Flags().String("summary", "", "") diff --git a/skills/lark-calendar/SKILL.md b/skills/lark-calendar/SKILL.md index 0ec242368..5a49cc80a 100644 --- a/skills/lark-calendar/SKILL.md +++ b/skills/lark-calendar/SKILL.md @@ -120,7 +120,7 @@ lark-cli calendar [flags] # 调用 API - `get` — 获取日程 - `instance_view` — 查询日程视图 - `patch` — 更新日程 - - `search` — 搜索日程 + - `search_event` — 搜索日程(注:目前只会返回日程id、日程主题、日程时间的信息,需要更多的日程详情,需要走 `events get` 命令) - `share_info` — 获取日程分享链接 ### freebusys @@ -146,7 +146,7 @@ lark-cli calendar [flags] # 调用 API | `events.get` | `calendar:calendar.event:read` | | `events.instance_view` | `calendar:calendar.event:read` | | `events.patch` | `calendar:calendar.event:update` | -| `events.search` | `calendar:calendar.event:read` | +| `events.search_event` | `calendar:calendar.event:read` | | `events.share_info` | `calendar:calendar.event:read` | | `freebusys.list` | `calendar:calendar.free_busy:read` | diff --git a/skills/lark-calendar/references/lark-calendar-room-find.md b/skills/lark-calendar/references/lark-calendar-room-find.md index 44028e6d3..0a94e1eec 100644 --- a/skills/lark-calendar/references/lark-calendar-room-find.md +++ b/skills/lark-calendar/references/lark-calendar-room-find.md @@ -9,6 +9,7 @@ ## 适用场景 - 已知一个或多个待选时间块,需要查找可用会议室 +- 需要在一组连续编号的会议室中批量搜索可用房间(如"帮我约一个 16~20 号之间的会议室") ## 命令 @@ -23,6 +24,24 @@ lark-cli calendar +room-find \ --event-rrule "FREQ=DAILY;INTERVAL=1" ``` +### 批量会议室名称查询 + +当用户想在一组编号会议室中挑选可用房间时,可用英文逗号拼接多个会议室名称传入 `--room-name`: + +```bash +# 场景:帮我约一个 16~20 号之间的会议室 +lark-cli calendar +room-find \ + --slot "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00" \ + --room-name "16,17,18,19,20" +``` + +```bash +# 场景:查找 木星 或 火星 会议室 +lark-cli calendar +room-find \ + --slot "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00" \ + --room-name "木星,火星" +``` + ## 参数 | 参数 | 必填 | 说明 | @@ -31,7 +50,7 @@ lark-cli calendar +room-find \ | `--city ` | 否 | 会议室所在城市强约束。**仅当**用户明确说出具体城市(如北京、上海)时才提取,**严禁**根据园区或楼宇名称自行联想或补全。 | | `--building ` | 否 | 会议室所在楼宇强约束,承载城市以下、楼层以上的办公区/园区/楼栋描述。| | `--floor ` | 否 | 仅用于筛选会议室所在楼层。应先做归一化,再传递规范值;例如 `2楼` / `二楼` / `2F` 统一为 `F2`。注意:此参数只筛选楼层,不可混入区域定位(如“A区”)或具体会议室号。 | -| `--room-name ` | 否 | 会议室名强约束。仅当用户明确提到会议室专名或会议室号(如“木星”“02”)时使用。应优先传递去后缀、去冗余后的规范名,例如 `木星会议室` → `木星`,`会议室 02` / `02会议室` → `02`。 | +| `--room-name ` | 否 | 会议室名称约束,支持以**英文逗号**分隔传入多个名称。仅当用户明确提到会议室专名或会议室号(如"木星""02")时使用。当用户需要在一组编号会议室中搜索时(如"帮我约 16~20 号的会议室"),应将编号展开为逗号分隔列表,如 `"16,17,18,19,20"`。应优先传递去后缀、去冗余后的规范名,例如 `木星会议室` → `木星`,`会议室 02` / `02会议室` → `02`。 | | `--min-capacity ` | 否 | 会议室最小容纳人数。当用户明确参会人数或提出“至少容纳N人”等要求时,提取数字放入此参数,必须为正整数。 | | `--max-capacity ` | 否 | 会议室最大容纳人数。用于过滤过大空间,必须为正整数。 | | `--attendee-ids ` | 否 | 参会对象 ID 列表。支持用户 ID(`ou_` 前缀)和群组 ID(`oc_` 前缀),多个 ID 以逗号分隔。 | @@ -48,6 +67,10 @@ lark-cli calendar +room-find \ - 同一语义槽位只保留一个规范值。例如用户说“2楼”,应转换为 `--floor "F2"`;**禁止**同时传 `2楼 F2` 这类重复楼层信息。 - 参数归类顺序应为:`city/building/floor` > `floor + room-name` 复合表达 > `room-name`。若短词更像楼层/区域定位(如 `2L`、`2F`),优先落到 `--floor`,不要默认落到 `--room-name`。像 `学清2层` 这种表达,通常拆为 `--building "学清"` 与 `--floor "F2"`。 - 对会议室名要做轻量归一化:`木星会议室` 应提取为 `--room-name "木星"`;`会议室 02` / `02会议室` 应提取为 `--room-name "02"`。 +- **多会议室名称场景**:当用户表达"帮我约 XX 到 YY 号之间的会议室"或一次提及多个会议室名称时,应将所有目标名称用英文逗号拼接传入 `--room-name`。例如: + - "帮我约 16~20 号的会议室" → `--room-name "16,17,18,19,20"` + - "查下木星和火星是否有空" → `--room-name "木星,火星"` + - "看看 01、02、03 会议室" → `--room-name "01,02,03"` - 对复合会议室号要优先拆分结构化信息:`F3-05` / `F5-07` / `3楼-08` 这类表达,若可稳定识别楼层与会议室号,应优先提取为 `--floor "F3"` + `--room-name "05"`、`--floor "F5"` + `--room-name "07"`、`--floor "F3"` + `--room-name "08"`,不要把整段直接作为 `--room-name`。 - 当提供了会议室搜索筛选条件时,返回结果也**不保证**与搜索词完全字面匹配。底层可能会结合邻近楼层做推荐,例如用户搜索 `2层`,即使 `2层` 没有空闲会议室,也可能返回相近的 `3层` 候选。这不应被误判为接口返回异常。 @@ -68,7 +91,8 @@ lark-cli calendar +room-find \ > **AI 行为指导:** > - **结构化展示时间块与会议室**:默认按“时间块 -> 会议室候选”的层级结构展示。**严禁将时间与会议室名称输出在同一行**。以清晰的分行列表呈现可用会议室,并直接询问用户意向。默认原样展示完整 `room_name`;不要擅自缩写、截断、改写,或仅提取楼层及会议室号替代完整名称。 -> - **`room_name` 必须逐字透传**:展示给用户的会议室名称,必须直接使用 CLI/API 返回的 `room_name` 原值。禁止提取楼层、会议室号、容量、视频能力后重组成新的名称,禁止意译、缩写、去前缀、去后缀,或仅保留“便于阅读”的摘要名。 +> - **`room_name` 必须逐字透传**:展示给用户的会议室名称,必须直接使用 CLI/API 返回的 `room_name` 原值。禁止提取楼层、会议室号、容量、视频能力后重组成新的名称,禁止意译、缩写、去前缀、去后缀,或仅保留"便于阅读"的摘要名。 +> - **主动识别区间/多名称意图**:当用户提到"帮我约 XX 到 YY 号的会议室""XX~YY 之间的会议室"或一次列出多个会议室名称时,将所有目标名称展开为英文逗号分隔列表,传入 `--room-name`。例如"帮我约 16 到 20 号的会议室"应生成 `--room-name "16,17,18,19,20"`。 > - **重复日程要明确阻断原因与自动缩短**:若某候选会议室的 `reserve_until_time` 无法覆盖重复性日程,**必须**向用户明确说明该会议室最长可约至何时。若用户确认继续选用该会议室,你必须**自动将日程的重复规则结束时间缩短**至该 `reserve_until_time`,以防止会议室预约失败。不能直接按原规则继续。 > - **正确解释推荐结果**:如果返回结果与用户输入条件不完全字面一致,先说明底层可能返回邻近位置或相近条件的推荐候选,不要直接将其判定为异常。 > - **默认减少用户输入成本**:应主动引导用户不必一开始就提供很详细的会议室搜索条件。只要时间块已明确,用户直接表达“想约会议室”即可,先基于当前信息查询候选;只有在用户对结果不满意时,再引导其补充更具体的楼宇、楼层、会议室名或容量条件。