From 2091327d76f04c8fd33073c22f8baf70cdd38697 Mon Sep 17 00:00:00 2001 From: calendar-assistant Date: Fri, 10 Apr 2026 20:21:54 +0800 Subject: [PATCH] feat(calendar): add room find workflow Fix room-find multi-slot verification. Change-Id: I3ba4c8dbe30bbb1eb12c0996bb8bc5d54e6339ca --- shortcuts/calendar/calendar_room_find.go | 372 ++++++++++++++++++ shortcuts/calendar/calendar_room_find_test.go | 62 +++ shortcuts/calendar/calendar_suggestion.go | 4 +- shortcuts/calendar/calendar_test.go | 282 ++++++++++++- shortcuts/calendar/shortcuts.go | 1 + skills/lark-calendar/SKILL.md | 57 ++- .../references/lark-calendar-create.md | 5 +- .../references/lark-calendar-freebusy.md | 2 +- .../references/lark-calendar-room-find.md | 89 +++++ .../lark-calendar-schedule-meeting.md | 194 +++++++++ .../references/lark-calendar-suggestion.md | 12 +- 11 files changed, 1028 insertions(+), 52 deletions(-) create mode 100644 shortcuts/calendar/calendar_room_find.go create mode 100644 shortcuts/calendar/calendar_room_find_test.go create mode 100644 skills/lark-calendar/references/lark-calendar-room-find.md create mode 100644 skills/lark-calendar/references/lark-calendar-schedule-meeting.md diff --git a/shortcuts/calendar/calendar_room_find.go b/shortcuts/calendar/calendar_room_find.go new file mode 100644 index 000000000..1954c6bd5 --- /dev/null +++ b/shortcuts/calendar/calendar_room_find.go @@ -0,0 +1,372 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +const ( + roomFindPath = "/open-apis/calendar/v4/freebusy/room_find" + roomFindWorkers = 10 + flagSlot = "slot" + flagCity = "city" + flagBuilding = "building" + flagFloor = "floor" + flagRoomName = "room-name" + flagMinCapacity = "min-capacity" + flagMaxCapacity = "max-capacity" +) + +type roomFindRequest struct { + City string `json:"city,omitempty"` + Building string `json:"building,omitempty"` + Floor string `json:"floor,omitempty"` + RoomName string `json:"room_name,omitempty"` + MinCapacity int `json:"min_capacity,omitempty"` + MaxCapacity int `json:"max_capacity,omitempty"` + EventStartTime string `json:"event_start_time,omitempty"` + EventEndTime string `json:"event_end_time,omitempty"` + AttendeeUserIDs []string `json:"attendee_user_ids,omitempty"` + AttendeeChatIDs []string `json:"attendee_chat_ids,omitempty"` + EventRrule string `json:"event_rrule,omitempty"` + Timezone string `json:"timezone,omitempty"` +} + +type roomFindSuggestion struct { + RoomID string `json:"room_id,omitempty"` + RoomName string `json:"room_name,omitempty"` + Capacity int `json:"capacity,omitempty"` + ReserveUntilTime string `json:"reserve_until_time,omitempty"` +} + +type roomFindData struct { + AvailableRooms []*roomFindSuggestion `json:"available_rooms,omitempty"` +} + +type roomFindSlot struct { + Start string `json:"start,omitempty"` + End string `json:"end,omitempty"` +} + +type roomFindTimeSlot struct { + Start string `json:"start,omitempty"` + End string `json:"end,omitempty"` + MeetingRooms []*roomFindSuggestion `json:"meeting_rooms,omitempty"` +} + +type roomFindOutput struct { + TimeSlots []*roomFindTimeSlot `json:"time_slots,omitempty"` +} + +func collectRoomFindResults(slots []roomFindSlot, limit int, fetch func(roomFindSlot) ([]*roomFindSuggestion, error)) (*roomFindOutput, error) { + if limit <= 0 { + limit = 1 + } + + out := &roomFindOutput{ + TimeSlots: make([]*roomFindTimeSlot, 0, len(slots)), + } + + var wg sync.WaitGroup + var mu sync.Mutex + var firstErr error + sem := make(chan struct{}, limit) + + for _, slot := range slots { + wg.Add(1) + sem <- struct{}{} + go func(slot roomFindSlot) { + defer wg.Done() + defer func() { <-sem }() + + suggestions, err := fetch(slot) + mu.Lock() + defer mu.Unlock() + if err != nil { + if firstErr == nil { + firstErr = err + } + return + } + out.TimeSlots = append(out.TimeSlots, &roomFindTimeSlot{ + Start: slot.Start, + End: slot.End, + MeetingRooms: suggestions, + }) + }(slot) + } + wg.Wait() + + if firstErr != nil { + return nil, firstErr + } + + sort.Slice(out.TimeSlots, func(i, j int) bool { + return out.TimeSlots[i].Start < out.TimeSlots[j].Start + }) + + return out, nil +} + +func parseRoomFindSlots(runtime *common.RuntimeContext) ([]roomFindSlot, error) { + rawSlots := runtime.StrArray(flagSlot) + if len(rawSlots) == 0 { + return nil, output.ErrValidation("specify at least one --slot") + } + slots := make([]roomFindSlot, 0, len(rawSlots)) + for _, raw := range rawSlots { + parts := strings.Split(strings.TrimSpace(raw), "~") + if len(parts) != 2 { + return nil, output.ErrValidation("invalid --slot format %q, expected start~end", raw) + } + startTs, err := common.ParseTime(parts[0]) + if err != nil { + return nil, output.ErrValidation("invalid slot start time %q: %v", parts[0], err) + } + endTs, err := common.ParseTime(parts[1]) + if err != nil { + return nil, output.ErrValidation("invalid slot end time %q: %v", parts[1], err) + } + startSec, err := strconv.ParseInt(startTs, 10, 64) + if err != nil { + return nil, output.ErrValidation("invalid slot start timestamp %q: %v", startTs, err) + } + endSec, err := strconv.ParseInt(endTs, 10, 64) + if err != nil { + return nil, output.ErrValidation("invalid slot end timestamp %q: %v", endTs, err) + } + if endSec <= startSec { + return nil, output.ErrValidation("--slot end time must be after start time: %q", raw) + } + startRFC3339, err := unixStringToRFC3339(startTs) + if err != nil { + return nil, output.ErrValidation("invalid slot start timestamp %q: %v", startTs, err) + } + endRFC3339, err := unixStringToRFC3339(endTs) + if err != nil { + return nil, output.ErrValidation("invalid slot end timestamp %q: %v", endTs, err) + } + slots = append(slots, roomFindSlot{Start: startRFC3339, End: endRFC3339}) + } + return slots, nil +} + +func unixStringToRFC3339(ts string) (string, error) { + sec, err := strconv.ParseInt(ts, 10, 64) + if err != nil { + return "", err + } + return time.Unix(sec, 0).Format(time.RFC3339), nil +} + +func parseRoomFindAttendees(attendeesStr string, currentUserID string) ([]string, []string, error) { + var userIDs []string + var chatIDs []string + seenUsers := map[string]bool{} + seenChats := map[string]bool{} + for _, id := range strings.Split(attendeesStr, ",") { + id = strings.TrimSpace(id) + if id == "" { + continue + } + switch { + case strings.HasPrefix(id, "ou_"): + if !seenUsers[id] { + userIDs = append(userIDs, id) + seenUsers[id] = true + } + case strings.HasPrefix(id, "oc_"): + if !seenChats[id] { + chatIDs = append(chatIDs, id) + seenChats[id] = true + } + default: + return nil, nil, output.ErrValidation("invalid attendee id format %q: should start with 'ou_' or 'oc_'", id) + } + } + if currentUserID != "" && !seenUsers[currentUserID] { + userIDs = append(userIDs, currentUserID) + } + return userIDs, chatIDs, nil +} + +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)), + MinCapacity: runtime.Int(flagMinCapacity), + MaxCapacity: runtime.Int(flagMaxCapacity), + Timezone: strings.TrimSpace(runtime.Str(flagTimezone)), + EventRrule: strings.TrimSpace(runtime.Str(flagEventRrule)), + } + + currentUserID := "" + if !runtime.IsBot() { + currentUserID = runtime.UserOpenId() + } + attendeeUserIDs, attendeeChatIDs, err := parseRoomFindAttendees(runtime.Str(flagAttendees), currentUserID) + if err != nil { + return nil, err + } + req.AttendeeUserIDs = attendeeUserIDs + req.AttendeeChatIDs = attendeeChatIDs + return req, nil +} + +func callRoomFind(runtime *common.RuntimeContext, req *roomFindRequest) ([]*roomFindSuggestion, error) { + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: "POST", + ApiPath: roomFindPath, + Body: req, + }) + if err != nil { + return nil, err + } + + if apiResp.StatusCode < http.StatusOK || apiResp.StatusCode >= http.StatusMultipleChoices { + return nil, output.ErrAPI(apiResp.StatusCode, "", string(apiResp.RawBody)) + } + + var resp = &OpenAPIResponse[*roomFindData]{} + if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil { + return nil, output.ErrWithHint(output.ExitInternal, "validation", "unmarshal response fail", err.Error()) + } + + if resp.Code != 0 { + return nil, output.ErrAPI(resp.Code, resp.Msg, resp.Data) + } + + if resp.Data != nil { + return resp.Data.AvailableRooms, nil + } + return nil, nil +} + +var CalendarRoomFind = common.Shortcut{ + Service: "calendar", + Command: "+room-find", + Description: "Find available meeting room candidates for one or more event time slots", + Risk: "read", + Scopes: []string{"calendar:calendar.free_busy:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: flagSlot, Type: "string_array", Desc: "event time slot in start~end format; repeatable"}, + {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: 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_)"}, + {Name: flagEventRrule, Type: "string", Desc: "event recurrence rule"}, + {Name: flagTimezone, Type: "string", Desc: "current time zone"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + baseReq, err := buildRoomFindBaseRequest(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + slots, err := parseRoomFindSlots(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + d := common.NewDryRunAPI() + for _, slot := range slots { + req := *baseReq + req.EventStartTime = slot.Start + req.EventEndTime = slot.End + d.POST(roomFindPath). + Desc(fmt.Sprintf("Lookup meeting room suggestions for %s - %s", slot.Start, slot.End)). + Body(req) + } + return d + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if err := rejectCalendarAutoBotFallback(runtime); err != nil { + return err + } + for _, flag := range []string{flagCity, flagBuilding, flagFloor, flagRoomName, flagEventRrule, flagTimezone} { + if val := strings.TrimSpace(runtime.Str(flag)); val != "" { + if err := common.RejectDangerousChars("--"+flag, val); err != nil { + return output.ErrValidation(err.Error()) + } + } + } + if _, err := parseRoomFindSlots(runtime); err != nil { + return err + } + if _, _, err := parseRoomFindAttendees(runtime.Str(flagAttendees), ""); err != nil { + return err + } + if minCapacity := runtime.Int(flagMinCapacity); minCapacity < 0 { + return output.ErrValidation("--min-capacity must be >= 0") + } + if maxCapacity := runtime.Int(flagMaxCapacity); maxCapacity < 0 { + return output.ErrValidation("--max-capacity must be >= 0") + } + if minCapacity, maxCapacity := runtime.Int(flagMinCapacity), runtime.Int(flagMaxCapacity); minCapacity > 0 && maxCapacity > 0 && minCapacity > maxCapacity { + return output.ErrValidation("--min-capacity must be <= --max-capacity") + } + return nil + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseReq, err := buildRoomFindBaseRequest(runtime) + if err != nil { + return err + } + slots, err := parseRoomFindSlots(runtime) + if err != nil { + return err + } + + out, err := collectRoomFindResults(slots, roomFindWorkers, func(slot roomFindSlot) ([]*roomFindSuggestion, error) { + req := *baseReq + req.EventStartTime = slot.Start + req.EventEndTime = slot.End + return callRoomFind(runtime, &req) + }) + if err != nil { + return err + } + + runtime.OutFormat(out, &output.Meta{Count: len(out.TimeSlots)}, func(w io.Writer) { + if len(out.TimeSlots) == 0 { + fmt.Fprintln(w, "No meeting room suggestions available.") + return + } + for _, slot := range out.TimeSlots { + fmt.Fprintf(w, "%s - %s\n", slot.Start, slot.End) + var rows []map[string]interface{} + for _, room := range slot.MeetingRooms { + rows = append(rows, map[string]interface{}{ + "room_id": room.RoomID, + "room_name": room.RoomName, + "capacity": room.Capacity, + "reserve_until_time": room.ReserveUntilTime, + }) + } + output.PrintTable(w, rows) + fmt.Fprintln(w) + } + }) + return nil + }, +} diff --git a/shortcuts/calendar/calendar_room_find_test.go b/shortcuts/calendar/calendar_room_find_test.go new file mode 100644 index 000000000..e540eff01 --- /dev/null +++ b/shortcuts/calendar/calendar_room_find_test.go @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import ( + "testing" + "time" +) + +func TestCollectRoomFindResults_LimitsConcurrency(t *testing.T) { + slots := []roomFindSlot{ + {Start: "2026-03-27T14:00:00+08:00", End: "2026-03-27T15:00:00+08:00"}, + {Start: "2026-03-27T15:00:00+08:00", End: "2026-03-27T16:00:00+08:00"}, + {Start: "2026-03-27T16:00:00+08:00", End: "2026-03-27T17:00:00+08:00"}, + } + + entered := make(chan struct{}, len(slots)) + release := make(chan struct{}) + done := make(chan *roomFindOutput, 1) + errCh := make(chan error, 1) + + go func() { + out, err := collectRoomFindResults(slots, 2, func(slot roomFindSlot) ([]*roomFindSuggestion, error) { + entered <- struct{}{} + <-release + return []*roomFindSuggestion{{RoomName: slot.Start}}, nil + }) + errCh <- err + done <- out + }() + + for range 2 { + select { + case <-entered: + case <-time.After(200 * time.Millisecond): + t.Fatal("timed out waiting for room-find workers to start") + } + } + + select { + case <-entered: + t.Fatal("room-find exceeded the configured concurrency limit") + case <-time.After(50 * time.Millisecond): + } + + close(release) + + select { + case err := <-errCh: + if err != nil { + t.Fatalf("collectRoomFindResults returned error: %v", err) + } + case <-time.After(200 * time.Millisecond): + t.Fatal("timed out waiting for room-find results") + } + + out := <-done + if len(out.TimeSlots) != len(slots) { + t.Fatalf("expected %d time slots, got %d", len(slots), len(out.TimeSlots)) + } +} diff --git a/shortcuts/calendar/calendar_suggestion.go b/shortcuts/calendar/calendar_suggestion.go index a9941c222..9c84ca442 100644 --- a/shortcuts/calendar/calendar_suggestion.go +++ b/shortcuts/calendar/calendar_suggestion.go @@ -190,7 +190,7 @@ func buildSuggestionRequest(runtime *common.RuntimeContext) (*SuggestionRequest, var CalendarSuggestion = common.Shortcut{ Service: "calendar", Command: "+suggestion", - Description: "Intelligently suggest available meeting times to simplify scheduling", + Description: "Intelligently suggest available time blocks based on unclear time ranges", Risk: "read", Scopes: []string{"calendar:calendar.free_busy:read"}, AuthTypes: []string{"user", "bot"}, @@ -292,7 +292,7 @@ var CalendarSuggestion = common.Shortcut{ Body: req, }) if err != nil { - return output.ErrWithHint(output.ExitInternal, "request_fail", "api request fail", err.Error()) + return err } if apiResp.StatusCode < http.StatusOK || apiResp.StatusCode >= http.StatusMultipleChoices { diff --git a/shortcuts/calendar/calendar_test.go b/shortcuts/calendar/calendar_test.go index d072a320d..4f64983bc 100644 --- a/shortcuts/calendar/calendar_test.go +++ b/shortcuts/calendar/calendar_test.go @@ -7,16 +7,18 @@ import ( "bytes" "context" "encoding/json" + "errors" "strings" "sync" "testing" - "github.com/spf13/cobra" - "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" ) // --------------------------------------------------------------------------- @@ -88,6 +90,20 @@ func noLoginBotDefaultConfig() *core.CliConfig { } } +type missingTokenResolver struct{} + +func (r *missingTokenResolver) ResolveToken(context.Context, credential.TokenSpec) (*credential.TokenResult, error) { + return nil, &credential.TokenUnavailableError{Source: "test", Type: credential.TokenTypeUAT} +} + +type staticAccountResolver struct { + config *core.CliConfig +} + +func (r *staticAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) { + return credential.AccountFromCliConfig(r.config), nil +} + // --------------------------------------------------------------------------- // CalendarCreate tests // --------------------------------------------------------------------------- @@ -384,6 +400,11 @@ func TestCalendarShortcuts_RequireLoginUnlessExplicitBot(t *testing.T) { shortcut: CalendarFreebusy, args: []string{"+freebusy", "--start", "2025-03-21", "--end", "2025-03-21"}, }, + { + name: "room-find", + shortcut: CalendarRoomFind, + args: []string{"+room-find", "--slot", "2025-03-21T00:00:00+08:00~2025-03-21T01:00:00+08:00"}, + }, { name: "rsvp", shortcut: CalendarRsvp, @@ -1043,6 +1064,255 @@ func TestSuggestion_APIError(t *testing.T) { } } +// --------------------------------------------------------------------------- +// CalendarRoomFind tests +// --------------------------------------------------------------------------- + +func TestRoomFind_MultiSlot_NewEventContext(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + for range 2 { + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/freebusy/room_find", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "available_rooms": []interface{}{ + map[string]interface{}{ + "room_id": "omm_room1", + "room_name": "F2-02", + "capacity": 7, + "reserve_until_time": "2026-04-01T00:00:00Z", + }, + }, + }, + }, + }) + } + + err := mountAndRun(t, CalendarRoomFind, []string{ + "+room-find", + "--slot", "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00", + "--slot", "2026-03-27T16:00:00+08:00~2026-03-27T17:00:00+08:00", + "--attendee-ids", "ou_user1,ou_user2", + "--format", "json", + "--as", "bot", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "\"time_slots\"") { + t.Fatalf("expected aggregated time_slots output, got: %s", stdout.String()) + } +} + +func TestRoomFind_RejectsDangerousChars(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + + err := mountAndRun(t, CalendarRoomFind, []string{ + "+room-find", + "--slot", "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00", + "--room-name", "F2-02\x7f", + "--as", "bot", + }, f, nil) + + if err == nil { + t.Fatal("expected validation error for dangerous characters") + } + if !strings.Contains(err.Error(), "--room-name") { + t.Fatalf("expected dangerous char error for --room-name, got: %v", err) + } +} + +func TestRoomFind_DryRun_SplitsUserAndChatAttendees(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + + err := mountAndRun(t, CalendarRoomFind, []string{ + "+room-find", + "--slot", "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00", + "--attendee-ids", "ou_user1,oc_group1", + "--dry-run", + "--as", "bot", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, `"attendee_user_ids"`) || !strings.Contains(out, `"ou_user1"`) || !strings.Contains(out, `"attendee_chat_ids"`) || !strings.Contains(out, `"oc_group1"`) { + t.Fatalf("dry-run should split attendee IDs by prefix, got: %s", out) + } +} + +func TestRoomFind_DryRun_IncludesStructuredLocationFields(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + + err := mountAndRun(t, CalendarRoomFind, []string{ + "+room-find", + "--slot", "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00", + "--city", "北京", + "--building", "学清嘉创大厦B座", + "--floor", "F2", + "--room-name", "木星", + "--dry-run", + "--as", "bot", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + for _, want := range []string{`"city": "北京"`, `"building": "学清嘉创大厦B座"`, `"floor": "F2"`, `"room_name": "木星"`} { + if !strings.Contains(out, want) { + t.Fatalf("dry-run should include %s, got: %s", want, out) + } + } +} + +func TestRoomFind_RequestIncludesStructuredLocationFields(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/freebusy/room_find", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "available_rooms": []interface{}{}, + }, + }, + } + reg.Register(stub) + + err := mountAndRun(t, CalendarRoomFind, []string{ + "+room-find", + "--slot", "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00", + "--city", "北京", + "--building", "学清嘉创大厦B座", + "--floor", "F2", + "--room-name", "木星", + "--as", "bot", + }, f, nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &got); err != nil { + t.Fatalf("unmarshal captured request: %v", err) + } + for key, want := range map[string]string{ + "city": "北京", + "building": "学清嘉创大厦B座", + "floor": "F2", + "room_name": "木星", + } { + if got[key] != want { + t.Fatalf("expected %s=%q, got %#v", key, want, got[key]) + } + } +} + +func TestRoomFind_RejectsInvertedOrZeroLengthSlots(t *testing.T) { + cases := []struct { + name string + slot string + }{ + { + name: "inverted", + slot: "2026-03-27T15:00:00+08:00~2026-03-27T14:00:00+08:00", + }, + { + name: "zero-length", + slot: "2026-03-27T15:00:00+08:00~2026-03-27T15:00:00+08:00", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + + err := mountAndRun(t, CalendarRoomFind, []string{ + "+room-find", + "--slot", tc.slot, + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected slot validation error") + } + if !strings.Contains(err.Error(), "--slot end time must be after start time") { + t.Fatalf("expected invalid slot range error, got: %v", err) + } + }) + } +} + +func TestRoomFind_PreservesAuthErrorFromDoAPI(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, noLoginConfig()) + f.Credential = credential.NewCredentialProvider( + nil, + &staticAccountResolver{config: noLoginConfig()}, + &missingTokenResolver{}, + nil, + ) + + err := mountAndRun(t, CalendarRoomFind, []string{ + "+room-find", + "--slot", "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected auth error") + } + + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected structured exit error, got %T", err) + } + if exitErr.Code != output.ExitAuth { + t.Fatalf("expected exit code %d, got %d (%v)", output.ExitAuth, exitErr.Code, err) + } + if exitErr.Detail == nil || exitErr.Detail.Type != "auth" { + t.Fatalf("expected auth error detail, got %#v", exitErr.Detail) + } +} + +func TestSuggestion_PreservesAuthErrorFromDoAPI(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, noLoginConfig()) + f.Credential = credential.NewCredentialProvider( + nil, + &staticAccountResolver{config: noLoginConfig()}, + &missingTokenResolver{}, + nil, + ) + + err := mountAndRun(t, CalendarSuggestion, []string{ + "+suggestion", + "--start", "2026-03-27T14:00:00+08:00", + "--end", "2026-03-27T15:00:00+08:00", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected auth error") + } + + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected structured exit error, got %T", err) + } + if exitErr.Code != output.ExitAuth { + t.Fatalf("expected exit code %d, got %d (%v)", output.ExitAuth, exitErr.Code, err) + } + if exitErr.Detail == nil || exitErr.Detail.Type != "auth" { + t.Fatalf("expected auth error detail, got %#v", exitErr.Detail) + } +} + // --------------------------------------------------------------------------- // helpers unit tests // --------------------------------------------------------------------------- @@ -1107,17 +1377,17 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) { // Shortcuts() registration test // --------------------------------------------------------------------------- -func TestShortcuts_Returns5(t *testing.T) { +func TestShortcuts_Returns6(t *testing.T) { shortcuts := Shortcuts() - if len(shortcuts) != 5 { - t.Fatalf("expected 5 shortcuts, got %d", len(shortcuts)) + if len(shortcuts) != 6 { + t.Fatalf("expected 6 shortcuts, got %d", len(shortcuts)) } names := map[string]bool{} for _, s := range shortcuts { names[s.Command] = true } - for _, want := range []string{"+agenda", "+create", "+freebusy", "+rsvp", "+suggestion"} { + for _, want := range []string{"+agenda", "+create", "+freebusy", "+room-find", "+rsvp", "+suggestion"} { if !names[want] { t.Errorf("missing shortcut %s", want) } diff --git a/shortcuts/calendar/shortcuts.go b/shortcuts/calendar/shortcuts.go index aed4fe1cd..c2bdade9e 100644 --- a/shortcuts/calendar/shortcuts.go +++ b/shortcuts/calendar/shortcuts.go @@ -11,6 +11,7 @@ func Shortcuts() []common.Shortcut { CalendarAgenda, CalendarCreate, CalendarFreebusy, + CalendarRoomFind, CalendarRsvp, CalendarSuggestion, } diff --git a/skills/lark-calendar/SKILL.md b/skills/lark-calendar/SKILL.md index f85c9a083..877802c24 100644 --- a/skills/lark-calendar/SKILL.md +++ b/skills/lark-calendar/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-calendar version: 1.0.0 -description: "飞书日历(calendar):提供日历与日程(会议)的全面管理能力。核心场景包括:查看/搜索日程、创建/更新日程、管理参会人、查询忙闲状态及推荐空闲时段。高频操作请优先使用 Shortcuts:+agenda(快速概览今日/近期行程)、+create(创建日程并按需邀请参会人)、+freebusy(查询用户主日历的忙闲信息和rsvp的状态)、+rsvp(回复日程邀请)、+suggestion(针对时间未确定的预约日程需求,提供多个时间推荐方案)。" +description: "飞书日历(calendar):提供日历与日程(会议)的全面管理能力。核心场景包括:查看/搜索日程、创建/更新日程、管理参会人、查询忙闲状态及推荐空闲时段、查询/搜索与预定会议室。注意:涉及【预约日程/会议】或【查询/预定会议室】时,必须先读取 references/lark-calendar-schedule-meeting.md 工作流!高频操作请优先使用 Shortcuts:+agenda(快速概览今日/近期行程)、+create(创建日程并按需邀请参会人及预定会议室)、+freebusy(查询用户主日历的忙闲信息和rsvp的状态)、+rsvp(回复日程邀请)" metadata: requires: bins: ["lark-cli"] @@ -12,15 +12,9 @@ metadata: **CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理** **CRITICAL — 所有的 Shortcuts 在执行之前,务必先使用 Read 工具读取其对应的说明文档,禁止直接盲目调用命令。** -## 核心场景 - -日历技能包含以下核心场景: - -### 1. 预约日程 +**CRITICAL — 凡涉及【预约日程/会议】或【查询/搜索会议室】,第一步 MUST 强制使用 Read 工具读取 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)。禁止跳过此步直接调用 API 或 Shortcut!** +**CRITICAL — 术语约束:用户日常表达中常说的“帮我约个日历”、“查一下今天的日历”等,其实际意图通常是针对 日程(Event) 的创建或查询,而非操作 日历(Calendar) 容器本身。请自动将口语化的“日历”意图映射为“日程”操作(如 `+create`, `+agenda`)。** -这是日历技能最核心的场景,核心是让用户低成本地实现日程预约。 - -> **💡 核心原则:做智能助理,提供辅助决策,而不是表单填写机或替用户做主。** **时间与日期推断规范:** 为确保准确性,在涉及时间推断时,请严格遵循以下规则: @@ -28,27 +22,16 @@ metadata: - **一天的范围**:当用户提到`明天`、`今天`等泛指某一天时,时间范围应默认覆盖整天时间范围。**切勿**自行缩减查询范围,以免遗漏晚上的时间安排。 - **历史时间约束**:不能预约已经完全过去的时间。唯一的例外情况是“跨越当前时间”的日程,即日程的开始时间在过去,但结束时间在未来。 -**预约日程的工作流:** - -1. **智能推断默认值** - - 标题,参与人,时长均存在默认值,无需频繁的和用户确认。 - - **参会人**:如未明确指定其他人,默认参会人仅为**用户自己**。当搜索特定参与人(人、群、会议室)出现多个结果无法唯一确定时,必须询问用户进行选择确认,并将该偏好记录为长期记忆,以便后续自动识别。 - - **会议室**:目前不支持主动预定会议室,除非当前上下文中已经存在对应的会议室ID(omm_ 前缀) 且需要添加到日程中。 - - **标题**:根据对话上下文自动生成(例如“沟通对齐”或“需求讨论”),如无法推断则默认为“会议”。 - - **时长**:基于会议类型和上下文动态推断(例如:“评审/汇报”推断为 60 分钟等),如无法推断,则默认为 30 分钟。 - -2. **时间建议与辅助决策(核心体验)** - - **有明确时间点**(如`明早10点`):调用相关工具(如 `lark-cli calendar +freebusy` [lark-calendar-freebusy](references/lark-calendar-freebusy.md))先查询该时间段参会人的忙闲状态(注:若参会人已有日程的 RSVP 状态为拒绝,则认为该时段为空闲)。若均无冲突,直接进入下一步确认并创建;若有冲突,提示用户冲突情况并询问是否继续创建或重新选择时间。 - - **有时间区间**(如`明天`、`下午`、`本周`):调用相关工具(如 `lark-cli calendar +suggestion` [lark-calendar-suggestion](references/lark-calendar-suggestion.md))获取该区间内所有参会人的**多个时间推荐方案**供用户选择。**必须在用户确认方案后**,才能执行创建日程操作;且用户一旦选择了推荐的方案,**无需再次查询忙闲信息**。 - - **无任何时间信息**:默认推断一个合理区间(如“今天”或“近两天”),并同样获取**多个时间推荐方案**供用户快速选择。 - - **生活类需求**(如健身、游泳、遛弯、约饭、奶茶等,注意“约咖啡”算工作场景):预期**不调用** `suggestion` 工具。应自行推断合适的非工作时间给到用户确认。如果无法推断,请尝试主动询问用户,并在用户给出反馈后形成记忆,以便后续直接应用。 - - **模糊语义消解与长期记忆构建 (Aha Moment)**:针对用户专属的时间表达习惯(如“上班后”、“下班前”)或存在歧义的时间场景(如未指明上下午的12小时制),严禁主观臆断。应通过主动澄清明确真实意图,并将此类个性化定义沉淀为长期偏好,推动系统认知能力的持续进化,最终实现“下次即懂”的智能化体验。 - -3. **非阻断式执行** - - 待用户确认具体时间选项后,执行 `lark-cli calendar +create --summary "..." --start "..." --end "..." --attendee-ids ...` +## 核心场景 -4. **友好反馈** - - 报告结果:返回创建成功的日程摘要信息 +### 1. 预约日程/会议、查询/搜索可用会议室 +**BLOCKING REQUIREMENT (阻塞性要求): 只要用户的意图包含“预约日程/会议”或“查询/搜索可用会议室”,你必须立即停止其他思考,优先使用 Read 工具完整读取 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)!未读取该文件前,绝对禁止执行任何日程创建或会议室查询操作。** +**CRITICAL: 必须严格按照上述文档中定义的工作流(Workflow)执行后续操作。处理该场景时,默认做“智能助理”,不要做“表单填写机”。能补全的默认值先补全,只有在时间冲突、结果无法唯一确定、时间语义存在歧义时才主动追问。** +**CRITICAL: 执行顺序必须固定为:先补默认值,再判断时间是否明确,再进入“明确时间”或“模糊时间/无时间信息”分支。不要跳步。** +**CRITICAL: 明确时间且需要会议室时,先 `+room-find`,再 `+freebusy`;模糊时间或无时间信息时,先 `+suggestion`,如需会议室再批量 `+room-find`。** +**CRITICAL: 当用户说“查会议室”“找会议室”“搜可用会议室”或“推荐常用会议室”时,默认是查会议室可用性,不是查会议室资源名录,更严禁拉取历史日程做统计分析。完整规则以 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) 为准。** +**BLOCKING REQUIREMENT: 即使用户的核心诉求是“查会议室”,只要【没有提供明确的起止时间】,绝对禁止直接调用 `+room-find`!必须先进入【无时间/模糊时间】分支,调用 `+suggestion` 拿到候选时间块后,再将时间块传给 `+room-find`。** +**BLOCKING REQUIREMENT: 只要面临时间方案或会议室方案的选择(如模糊时间、无时间或需要会议室),在调用 `+create` 创建日程之前,必须先向用户展示候选方案并等待用户明确确认。绝对禁止擅自替用户做决定。** ## 核心概念 @@ -61,6 +44,8 @@ metadata: - **参会人(Attendee)**:日程的参与者,可以是用户、群、会议室资源、外部邮箱地址等。每个参与人有独立的RSVP状态。 - **响应状态(RSVP)**:参与人对日程邀请的回复状态(接受/拒绝/待定)。 - **忙闲时间(FreeBusy)**:查询用户在指定时间段的忙闲状态,用于会议时间协调。 +- **会议室(Room)**:“room”不是“房间”,是“会议室”。请在理解和处理意图时将“room”和“房间”准确映射为“会议室”及其相关操作。 +- **时间块(Time Slot / Time Block)**:指一个**具体且确定**的连续时间段(如 `14:00~15:00`)。在文档中,它与泛指的“时间范围/区间”(如“今天下午”、“下周”)有严格区别。在调用预定、查询可用会议室等确切操作时,必须基于确定的“时间块”而非模糊的“时间范围”。 ## 资源关系 @@ -80,14 +65,16 @@ Shortcut 是对常用操作的高级封装(`lark-cli calendar + [flags]` | [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) | | [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人(ISO 8601 时间) | | [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和rsvp的状态 | +| [`+room-find`](references/lark-calendar-room-find.md) | 针对一个或多个**明确的**时间块查找可用会议室(**无明确时间时禁止直接调用,需先走 +suggestion**) | | [`+rsvp`](references/lark-calendar-rsvp.md) | 回复日程(接受/拒绝/待定) | -| [`+suggestion`](references/lark-calendar-suggestion.md) | 针对时间未确定的预约日程需求,提供多个时间推荐方案 | +| [`+suggestion`](references/lark-calendar-suggestion.md) | 根据非明确时间或一段时间范围,推荐多个可用时间块方案 | -## +suggestion 使用 -在调用 `+suggestion` 之前,务必读取 [lark-calendar-suggestion](references/lark-calendar-suggestion.md) 中的使用说明,禁止直接调用命令。 -```bash -lark-cli calendar +suggestion --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T11:00:00+08:00" --attendee-ids "ou_xxx,oc_yyy" --duration-minutes 30 # 为用户ou_xxx和群组oc_yyy里的成员推荐空闲时段 -````` +## 会议室相关规则 + +- **会议室是日程的一种参与人(resource attendee),不能脱离日程单独存在或单独预定。** +- **凡是用户意图是“预定/查询/搜索可用会议室”时,都必须进入 `references/lark-calendar-schedule-meeting.md` 工作流处理。** +- `+room-find` 的时间输入必须是**确定时间块**,不能是时间区间搜索。 +- **强制约束:如果用户仅要求“查询会议室”但未提供明确时间,必须先调用 `+suggestion` 获取可用时间块,然后再将时间块交给 `+room-find` 批量查询。严禁直接猜测时间并盲目调用 `+room-find`。** ## API Resources diff --git a/skills/lark-calendar/references/lark-calendar-create.md b/skills/lark-calendar/references/lark-calendar-create.md index c512e096f..e0aab9c9c 100644 --- a/skills/lark-calendar/references/lark-calendar-create.md +++ b/skills/lark-calendar/references/lark-calendar-create.md @@ -38,9 +38,10 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \ | `--description ` | 否 | 日程详细描述。提供会议议程、活动内容、注意事项或链接等。与 summary 配合使用,仅关注当前日程信息 | | `--attendee-ids ` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`)。AI 提取时请务必保留对应前缀 | | `--calendar-id ` | 否 | 日历 ID(省略则使用主日历) | -| `--rrule ` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。注意:COUNT 和UNTIL 不支持同时出现。示例值:"FREQ=DAILY;INTERVAL=1" | +| `--rrule ` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT,如需限制重复次数,必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" | | `--dry-run` | 否 | 预览 API 调用,不执行 | +> **⚠️ `rrule` 规则限制:飞书日历系统不支持 `COUNT` 参数。遇到限制重复次数的需求,必须根据开始时间和频率自行推算并转换成 `UNTIL=<具体日期>` 格式。** > 自动设置 `attendee_ability: "can_modify_event"`,参会人可查看彼此并编辑日程。 > 自动设置 `free_busy_status: "busy"`,默认日程忙闲状态为忙碌。 > 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。 @@ -105,4 +106,4 @@ lark-cli calendar events delete \ - [lark-calendar](../SKILL.md) -- 日历全部命令 - [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 -- [lark-calendar-suggestion](lark-calendar-suggestion.md) -- 智能推荐空闲时段 +- [lark-calendar-suggestion](lark-calendar-suggestion.md) -- 根据非明确时间或一段时间范围,推荐多个可用时间块方案 diff --git a/skills/lark-calendar/references/lark-calendar-freebusy.md b/skills/lark-calendar/references/lark-calendar-freebusy.md index 2cc4a72df..bd59f933e 100644 --- a/skills/lark-calendar/references/lark-calendar-freebusy.md +++ b/skills/lark-calendar/references/lark-calendar-freebusy.md @@ -120,5 +120,5 @@ lark-cli calendar +freebusy --start 2026-03-12 --user-id ou_member_b - [lark-calendar-agenda](lark-calendar-agenda.md) — 查看日程安排 - [lark-calendar-create](lark-calendar-create.md) — 创建日程 -- [lark-calendar-suggestion](lark-calendar-suggestion.md) — 针对时间未确定的预约日程需求,提供多个时间推荐方案 +- [lark-calendar-suggestion](lark-calendar-suggestion.md) — 根据非明确时间或一段时间范围,推荐多个可用时间块方案 - [lark-calendar](../SKILL.md) — 日历完整 API diff --git a/skills/lark-calendar/references/lark-calendar-room-find.md b/skills/lark-calendar/references/lark-calendar-room-find.md new file mode 100644 index 000000000..44028e6d3 --- /dev/null +++ b/skills/lark-calendar/references/lark-calendar-room-find.md @@ -0,0 +1,89 @@ +# calendar +room-find + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。 + +针对一个或多个时间块查找/搜索可用会议室。会议室是日程的一种资源型参与人,不能脱离日程单独预定。 + +需要的 scopes: ["calendar:calendar.free_busy:read"] + +## 适用场景 + +- 已知一个或多个待选时间块,需要查找可用会议室 + +## 命令 + +```bash +lark-cli calendar +room-find \ + --slot "2026-03-27T14:00:00+08:00~2026-03-27T15:00:00+08:00" \ + --slot "2026-03-27T16:00:00+08:00~2026-03-27T17:00:00+08:00" \ + --attendee-ids "ou_xxx,ou_yyy" \ + --city "北京" \ + --building "学清嘉创大厦B座" \ + --floor "F2" \ + --event-rrule "FREQ=DAILY;INTERVAL=1" +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--slot ` | 是 | 期望查询的时间块,格式需遵循 `开始时间~结束时间`。若存在多个候选时间块,可重复传入此参数。 | +| `--city ` | 否 | 会议室所在城市强约束。**仅当**用户明确说出具体城市(如北京、上海)时才提取,**严禁**根据园区或楼宇名称自行联想或补全。 | +| `--building ` | 否 | 会议室所在楼宇强约束,承载城市以下、楼层以上的办公区/园区/楼栋描述。| +| `--floor ` | 否 | 仅用于筛选会议室所在楼层。应先做归一化,再传递规范值;例如 `2楼` / `二楼` / `2F` 统一为 `F2`。注意:此参数只筛选楼层,不可混入区域定位(如“A区”)或具体会议室号。 | +| `--room-name ` | 否 | 会议室名强约束。仅当用户明确提到会议室专名或会议室号(如“木星”“02”)时使用。应优先传递去后缀、去冗余后的规范名,例如 `木星会议室` → `木星`,`会议室 02` / `02会议室` → `02`。 | +| `--min-capacity ` | 否 | 会议室最小容纳人数。当用户明确参会人数或提出“至少容纳N人”等要求时,提取数字放入此参数,必须为正整数。 | +| `--max-capacity ` | 否 | 会议室最大容纳人数。用于过滤过大空间,必须为正整数。 | +| `--attendee-ids ` | 否 | 参会对象 ID 列表。支持用户 ID(`ou_` 前缀)和群组 ID(`oc_` 前缀),多个 ID 以逗号分隔。 | +| `--event-rrule ` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT,如需限制重复次数,必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" | +| `--timezone ` | 否 | 对话中明确提及的预约日程所使用的时区(默认取用户设备时区,例如 `Asia/Shanghai`) | + +## 规则 + +- 多个 `--slot` 会由 CLI 内部并发调用单时间块接口,再聚合成一次输出 +- `+room-find` 的时间输入必须是**确定时间块**,不是时间区间搜索。 +- 如果是重复性日程,必须校验返回中的 `reserve_until_time`(该会议室最晚可预约时间)是否覆盖 `event-rrule` 对应的重复范围。 +- `--city` 仅在用户明确说出城市时才提取;不要仅凭 `望京办公室`、`漕河泾园区`、`南山办公室` 这类位置名自动补城市。 +- 若已经提取了 `--city`,则 `--building` 中不要再重复携带城市前缀。例如用户说 `北京学清嘉创大厦B座` 时,应提取为 `--city "北京"` 与 `--building "学清嘉创大厦B座"`,不要把 `北京学清嘉创大厦B座` 原样整体传入 `--building`。 +- 同一语义槽位只保留一个规范值。例如用户说“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"`。 +- 对复合会议室号要优先拆分结构化信息:`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层` 候选。这不应被误判为接口返回异常。 + +## 输出格式 + +**将返回的候选会议室整理为易读的结构化排版向用户展示。严禁将时间和会议室名称放在同一行展示,必须分行并使用编号列表呈现可用会议室,严禁揉成一团纯文本堆叠。** + +```text +## 2026-03-27 周五 + +[选项 1] 14:00 - 15:00 + 可用会议室: + 1. 学清嘉创大厦B座-F2-02🎦(7人) + 2. 学清嘉创大厦B座-F3-05🎦(11人) + +💡 请回复您倾向的选项编号以及对应的会议室序号,我来为您完成预定。 +``` + +> **AI 行为指导:** +> - **结构化展示时间块与会议室**:默认按“时间块 -> 会议室候选”的层级结构展示。**严禁将时间与会议室名称输出在同一行**。以清晰的分行列表呈现可用会议室,并直接询问用户意向。默认原样展示完整 `room_name`;不要擅自缩写、截断、改写,或仅提取楼层及会议室号替代完整名称。 +> - **`room_name` 必须逐字透传**:展示给用户的会议室名称,必须直接使用 CLI/API 返回的 `room_name` 原值。禁止提取楼层、会议室号、容量、视频能力后重组成新的名称,禁止意译、缩写、去前缀、去后缀,或仅保留“便于阅读”的摘要名。 +> - **重复日程要明确阻断原因与自动缩短**:若某候选会议室的 `reserve_until_time` 无法覆盖重复性日程,**必须**向用户明确说明该会议室最长可约至何时。若用户确认继续选用该会议室,你必须**自动将日程的重复规则结束时间缩短**至该 `reserve_until_time`,以防止会议室预约失败。不能直接按原规则继续。 +> - **正确解释推荐结果**:如果返回结果与用户输入条件不完全字面一致,先说明底层可能返回邻近位置或相近条件的推荐候选,不要直接将其判定为异常。 +> - **默认减少用户输入成本**:应主动引导用户不必一开始就提供很详细的会议室搜索条件。只要时间块已明确,用户直接表达“想约会议室”即可,先基于当前信息查询候选;只有在用户对结果不满意时,再引导其补充更具体的楼宇、楼层、会议室名或容量条件。 + +**字段说明:** + +| 字段名 | 说明 | +| :--- | :--- | +| `room_id` | 会议室唯一标识,用于后续创建日程时添加为会议室参与人使用。 | +| `room_name` | 会议室名称,默认原样完整展示给用户,不要自行缩写、截断、改写,也不要用楼层及会议室号摘要替代原值。 | +| `capacity` | 会议室最大容纳人数。 | +| `reserve_until_time` | 该会议室当前允许被预约到的最晚时间点,用于校验重复性日程是否超期。 | + +## 参考 + +- [lark-calendar-create](lark-calendar-create.md) +- [lark-calendar-suggestion](lark-calendar-suggestion.md) +- [lark-calendar](../SKILL.md) — 日历完整 API diff --git a/skills/lark-calendar/references/lark-calendar-schedule-meeting.md b/skills/lark-calendar/references/lark-calendar-schedule-meeting.md new file mode 100644 index 000000000..c2686eede --- /dev/null +++ b/skills/lark-calendar/references/lark-calendar-schedule-meeting.md @@ -0,0 +1,194 @@ +# 预约日程/会议、查询/搜索可用会议室的工作流 + +## CRITICAL 执行摘要(先按这个骨架执行,再看下方细则) + +- **默认做智能助理,不做表单填写机。** 能根据上下文补全的默认值就直接补全,避免把用户带入表单式问答。 +- **先补默认值,再判断时间是否明确。** 默认值包括标题、参会人、时长,以及在“完全无时间信息”时的默认时间范围。 +- **只有三类场景才主动追问用户**:存在时间冲突、搜索结果无法唯一确定、时间语义本身有歧义。 +- **明确时间**:若需要会议室,先 `+room-find`;再 `+freebusy` 判断参会人忙闲;有冲突时先说明冲突,再让用户决定继续当前时间还是改走 `+suggestion`。 +- **模糊时间或无时间信息**:先 `+suggestion` 产出候选时间块;若需要会议室,再把这些时间块批量交给 `+room-find`,将“候选时间 + 对应可用会议室”一次性展示给用户选择。 +- **BLOCKING REQUIREMENT: 只要面临时间方案(模糊时间/无时间)或会议室方案(需要会议室)的选择,必须先向用户展示选项并等待用户明确确认,绝对禁止在未获用户确认的情况下直接调用 `+create` 创建日程。** +- **用户选中了 `+suggestion` 返回的候选时间块后,不要再次调用 `+freebusy`。** 用户确认后直接进入 `+create`。 +- **当用户说“查会议室”“找会议室”“搜可用会议室”时,默认意图是查会议室可用性,不是检索会议室资源名录。** +- **必须按顺序执行。** 不要跳过“补默认值”“判断时间明确性”这两个前置步骤。 + +> **💡 核心原则:做智能助理,充分利用默认值规则(如默认标题、时长、参与人等)自动补全信息。极力避免像“表单填写机”一样频繁打断并反问用户,仅在必须决策的冲突或无法唯一确定的场景下才发起询问。** + +## 严禁行为 + +- **严禁在未读取对应子命令文档(如 `lark-calendar-room-find.md`、`lark-calendar-suggestion.md`)的情况下直接调用命令!** 必须先阅读文档掌握最新参数要求与规范。 +- **严禁在用户仅要求“查会议室”但未提供明确时间时,直接调用 `+room-find`!** 必须先默认一个合理时间范围,调用 `+suggestion` 拿到候选时间块,再将时间块传给 `+room-find`。 +- **不要在用户完全没给时间时,直接反问“你想约什么时候”。** 先补一个合理时间范围,再进入 `+suggestion`。 +- **不要在“需要会议室 + 时间模糊”的场景下,先让用户只选时间。** 应先批量查出每个候选时间对应的可用会议室,再让用户一次性完成选择。 +- **不要在用户已经选中 `+suggestion` 候选时间后,再重复调用 `+freebusy`。** +- **不要在用户未明确说出城市时,仅凭园区/办公室名自动补城市。** +- **严禁在面临时间方案或会议室方案的选择时(模糊时间、无时间或需要会议室),未经用户确认就擅自调用 `+create` 创建日程。** + +## 适用场景 + +- “帮我约个会” +- “下周找时间和 XX 开会” +- “帮我订个会议室” +- “帮我找/搜索一个可用的会议室” +- “帮我推荐一个我以前常用的会议室” +- “查询明天下午可用的会议室” +- “明天下午3点约个日程/日历” + +## 核心概念 + +- **会议室是日程的一种参与人(attendee / resource),不能脱离日程单独预定。** +- **预定或查找会议室,均需先确定时间块。** 在推荐可用会议室后,应顺势引导用户完成最终的**预约日程**操作。 + +## CRITICAL 约束 + +- **在调用任何具体的 CLI 子命令(如 `+room-find`、`+suggestion`、`+freebusy`、`+create`)前,必须先读取其对应的 Markdown 文档。** 禁止仅凭记忆组装命令参数,以确保符合各命令最新的业务约束和格式规范。 +- **当用户说“查会议室”“找会议室”“搜可用会议室”等,默认意图是查询会议室可用性,而不是检索会议室资源名录。** +- **必须严格按照下方【工作流】的步骤顺序完成任务。特别是单独查会议室时,若无明确时间,强制先走“模糊时间/无时间信息”分支调用 `+suggestion`。** + +## 工作流 + +### 1. 智能推断默认值 +以下信息智能推断,减少频繁询问用户: + +- **标题**:根据上下文自动生成,例如“沟通对齐”“需求讨论”;如无法推断,默认为“会议” +- **参会人**:如未明确指定其他人,默认参会人仅为**用户自己** +- **时长**:基于会议类型和上下文动态推断;如无法推断,默认为 30 分钟 +- **无任何时间信息**:默认推断一个合理区间(如“今天”或“近两天”),并进入时间推荐流程,禁止询问用户 + +当搜索特定参与人(人、群)出现多个结果无法唯一确定时,必须询问用户进行选择确认,并将该偏好记录为长期记忆,以便后续自动识别。 + +### 2. 判断时间是否明确 + +分两类处理: + +- **明确时间**:如“明天下午3点” +- **模糊时间**:如“明天下午”“下周找个时间” + +### 3. 明确时间 + +明确时间时,需先判断是否需要会议室,如果需要,提前查询会议室;然后判断是否有时间冲突。 +详见 [`+room-find`](./lark-calendar-room-find.md) 与 [`+freebusy`](./lark-calendar-freebusy.md)。 + +```bash +# 1. 如果需要会议室,提前查询会议室 +lark-cli calendar +room-find \ + --slot "~" \ + --attendee-ids "" \ + --city "" \ + --building "" \ + --floor "" \ + --room-name "" + +# 2. 查询当前用户及其他参会人忙闲 +# (如果有多名参会人,需分别调用查询:--user-id "") +lark-cli calendar +freebusy --start "" --end "" +``` + +规则: + +- **参会人过多或包含群组时的处理**: + - 如果参与人过多(例如超过 5 人),为避免高耗时,仅需查询**当前用户(自己)**及少数核心人员的忙闲状态即可。 + - 如果参与人中包含**群组**,无需展开群组成员查询其忙闲状态。 +- **如果没有冲突**:直接让用户选择会议室(如需),然后调用 `calendar +create` 创建日程 +- **如果有冲突**:必须先说明冲突情况,询问用户继续选择这个时间还是换个时间 + - **如果说换个时间**:放弃当前时间,转入【模糊时间】流程,调用 `+suggestion` 推荐多个可用时间块 + - **如果继续选择这个时间**:直接让用户选择会议室(如需),然后调用 `calendar +create` 创建日程 +- 位置信息要优先拆到结构化字段:用户明确说了城市才提取 `--city`;`--building` 不要再重复携带城市前缀。 +- 参数归类顺序应为:`city/building/floor` > `floor + room-name` 复合表达 > `room-name`。像 `2L`、`2F` 这类更像楼层或区域定位的短词,优先视为 `--floor`,不要默认当作 `--room-name`。像 `学清2层` 这种表达,通常拆为 `--building "学清"` 与 `--floor "F2"`。 +- 会议室名要做轻量归一化:`木星会议室` -> `--room-name "木星"`;`会议室 02` / `02会议室` -> `--room-name "02"`。 +- 对 `F3-05` / `F5-07` / `3楼-08` 这类复合表达,若能稳定识别楼层与会议室号,应优先提取为 `--floor + --room-name`,不要把整段直接退化成 `--room-name`。 + +### 4. 模糊时间或无时间信息 + +先调用: +详见 [`+suggestion`](./lark-calendar-suggestion.md);若需要会议室,再结合 [`+room-find`](./lark-calendar-room-find.md)。 + +```bash +lark-cli calendar +suggestion \ + --start "" \ + --end "" \ + --attendee-ids "" \ + --duration-minutes \ + --event-rrule "" +``` + +规则: + +- 若用户完全没有提供时间信息,应先默认一个合理区间后再调用 `+suggestion` +- **不需要会议室**:获取多个推荐时间块后,直接向用户展示候选时间,用户确认后创建日程。 +- **需要会议室**:获取多个候选时间块后,**不要急于让用户选时间**。先将这些时间块一次性交给 `calendar +room-find` 批量查询可用会议室,然后将【候选时间】与【对应的可用会议室列表】结构化分行展示,让用户一次性完成选择。(**注意:即使用户最初只说“查会议室”,且未带时间,也必须强制走到这一步,先 suggestion 再 room-find**)。 +- 用户一旦选择了 `+suggestion` 返回的时间块,**无需再次调用 `+freebusy`** + +### 5. 模糊语义消解与长期记忆构建 + +针对用户专属的时间表达习惯或存在歧义的时间场景,严禁主观臆断。典型例子包括: + +- “上班后” +- “下班前” +- 未明确上下午的 12 小时制时间表达 + +处理规则: + +- 应主动澄清真实意图,而不是自行猜测 +- 当用户给出澄清后,应将这类个性化定义沉淀为长期偏好,推动后续直接理解类似表达 + +### 6. 重复性日程 + +若当前会议为重复性日程,调用 `+room-find` 时需携带 `--event-rrule`。 + +必须检查返回中的: + +- `reserve_until_time` + +若候选会议室的可预约上限早于重复规则覆盖范围,**不要直接按原规则创建**。应: + +- 向用户明确说明该会议室最长可约至何时。 +- 若用户确认继续选用该会议室,你必须**自动将日程的重复规则结束时间缩短**至该 `reserve_until_time`,以防止会议室预约失败。 + +### 7. 创建日程 + +用户确认后调用: +详见 [`+create`](./lark-calendar-create.md)。 + +```bash +lark-cli calendar +create \ + --summary "..." \ + --start "" \ + --end "" \ + --attendee-ids "ou_xxx,oc_xxx,omm_xxx" +``` + +规则: +- 需要会议室时,将选中的 `room_id` 写入 `--attendee-ids` +- 展示会议室候选时,必须保留 CLI/API 返回的完整 `room_name` 原值;允许附加“推断说明”,但禁止用摘要名、楼层及会议室号、容量/视频标签重组后的名称替换原值 + +## 用户展示建议 + +当向用户展示多个时间块及对应的多个会议室时,**必须使用结构化清晰的格式排版**。**严禁将时间与会议室名称放在同一行展示**,必须分行并使用编号列表呈现可用会议室,严禁将所有信息揉成一团纯文本堆叠。 + +**推荐展示格式参考:** + +```text +## 2026-03-27 周五 + +[选项 1] 14:00 - 15:00(参会人均空闲) + 可用会议室: + 1. 学清嘉创大厦B座-F2-02🎦(7人) + 2. 学清嘉创大厦B座-F2-05🎦(10人) + +[选项 2] 16:00 - 17:00(参会人均空闲) + 可用会议室: + 1. 学清嘉创大厦B座-F3-01🎦(6人) + 2. 学清嘉创大厦B座-F3-06🎦(8人) + +💡 请回复您倾向的选项编号以及对应的会议室序号,我来为您完成预定。 +``` + +## 参考 + +- [lark-calendar-room-find.md](./lark-calendar-room-find.md) +- [lark-calendar-freebusy.md](./lark-calendar-freebusy.md) +- [lark-calendar-suggestion.md](./lark-calendar-suggestion.md) +- [lark-calendar-create.md](./lark-calendar-create.md) +- [lark-shared](../../lark-shared/SKILL.md) +- [lark-calendar](../SKILL.md) diff --git a/skills/lark-calendar/references/lark-calendar-suggestion.md b/skills/lark-calendar/references/lark-calendar-suggestion.md index 25ad0add9..1606ebd4c 100644 --- a/skills/lark-calendar/references/lark-calendar-suggestion.md +++ b/skills/lark-calendar/references/lark-calendar-suggestion.md @@ -2,11 +2,11 @@ > **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。 -针对时间未确定的预约日程需求,提供多个时间推荐方案,用户可以根据需要选择方案,解决协调会议时间的难题。 +根据非明确时间或一段时间范围,推荐多个可用时间块方案。帮助用户解决协调时间的难题。 **调用时机 (Agent Guidance):** -- ✅ **当用户意图是预约日程,但时间未完全确定**(如`今天`、`近三天`、`本周`、`下午`, `无时间描述`)时,调用此工具来获取推荐时间块给用户选择。 -- ❌ **当用户已经明确了具体的时间点**(如`今天下午3点`),则**不需要**调用此工具,应直接调用 `lark-cli calendar +create` 工具创建日程。 +- ✅ **当用户需求涉及寻找时间块,且时间未完全确定**(如`今天`、`近三天`、`本周`、`下午`, `无时间描述`)时,调用此工具来获取推荐时间块给用户选择(包括但不限于预约日程)。 +- ❌ **当用户已经明确了具体的时间点**(如`今天下午3点`),则**不需要**调用此工具 需要的scopes: ["calendar:calendar.free_busy:read"] @@ -49,7 +49,7 @@ lark-cli calendar +suggestion \ | `--start