From 44f84a8aecf1eb624dd29258840c8cd898fbf62c Mon Sep 17 00:00:00 2001 From: calendar-assistant Date: Mon, 27 Apr 2026 16:50:29 +0800 Subject: [PATCH] feat: add calendar update shortcut Change-Id: Ie2d4bde6cd28bbf4d7946db38c5c9be13edc6ba9 --- shortcuts/calendar/calendar_test.go | 267 +++++++++++- shortcuts/calendar/calendar_update.go | 384 ++++++++++++++++++ shortcuts/calendar/shortcuts.go | 1 + skills/lark-calendar/SKILL.md | 20 +- .../lark-calendar-schedule-meeting.md | 113 +++++- .../references/lark-calendar-update.md | 105 +++++ .../calendar/calendar_update_dryrun_test.go | 59 +++ .../calendar/calendar_update_event_test.go | 134 ++++++ 8 files changed, 1052 insertions(+), 31 deletions(-) create mode 100644 shortcuts/calendar/calendar_update.go create mode 100644 skills/lark-calendar/references/lark-calendar-update.md create mode 100644 tests/cli_e2e/calendar/calendar_update_dryrun_test.go create mode 100644 tests/cli_e2e/calendar/calendar_update_event_test.go diff --git a/shortcuts/calendar/calendar_test.go b/shortcuts/calendar/calendar_test.go index 8fc8794fa..29815fc1e 100644 --- a/shortcuts/calendar/calendar_test.go +++ b/shortcuts/calendar/calendar_test.go @@ -607,6 +607,260 @@ func TestCreate_WithAttendees_InvalidParamsWithDetail_RollsBack(t *testing.T) { } } +// --------------------------------------------------------------------------- +// CalendarUpdate tests +// --------------------------------------------------------------------------- + +func TestUpdate_PatchEventOnly(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + stub := &httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_update1", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "event": map[string]interface{}{ + "event_id": "evt_update1", + "summary": "Updated Meeting", + "start_time": map[string]interface{}{ + "timestamp": "1742518800", + }, + "end_time": map[string]interface{}{ + "timestamp": "1742522400", + }, + }, + }, + }, + } + reg.Register(stub) + + err := mountAndRun(t, CalendarUpdate, []string{ + "+update", + "--event-id", "evt_update1", + "--calendar-id", "cal_test123", + "--summary", "Updated Meeting", + "--description", "Updated description", + "--start", "2025-03-21T01:00:00+08:00", + "--end", "2025-03-21T02:00:00+08:00", + "--notify=false", + "--as", "bot", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("unmarshal captured patch body: %v", err) + } + if body["summary"] != "Updated Meeting" || body["description"] != "Updated description" { + t.Fatalf("unexpected patch body: %#v", body) + } + if body["need_notification"] != false { + t.Fatalf("need_notification = %#v, want false", body["need_notification"]) + } + if !strings.Contains(stdout.String(), "evt_update1") { + t.Fatalf("stdout should contain event id, got: %s", stdout.String()) + } +} + +func TestUpdate_AddAttendees(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_update2/attendees", + Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}}, + } + reg.Register(stub) + + err := mountAndRun(t, CalendarUpdate, []string{ + "+update", + "--event-id", "evt_update2", + "--calendar-id", "cal_test123", + "--add-attendee-ids", "ou_user1,oc_group1,omm_room1", + "--as", "bot", + }, f, nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + body := decodeCalendarCapturedBody(t, stub) + attendees, _ := body["attendees"].([]interface{}) + if !calendarBodyHasAttendee(attendees, "user", "user_id", "ou_user1") || + !calendarBodyHasAttendee(attendees, "chat", "chat_id", "oc_group1") || + !calendarBodyHasAttendee(attendees, "resource", "room_id", "omm_room1") { + t.Fatalf("unexpected add attendees body: %#v", body) + } +} + +func TestUpdate_RemoveAttendees(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_update3/attendees/batch_delete", + Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}}, + } + reg.Register(stub) + + err := mountAndRun(t, CalendarUpdate, []string{ + "+update", + "--event-id", "evt_update3", + "--calendar-id", "cal_test123", + "--remove-attendee-ids", "ou_user1,oc_group1,omm_room1", + "--notify=false", + "--as", "bot", + }, f, nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + body := decodeCalendarCapturedBody(t, stub) + deleteIDs, _ := body["delete_ids"].([]interface{}) + if body["need_notification"] != false { + t.Fatalf("need_notification = %#v, want false", body["need_notification"]) + } + if !calendarBodyHasAttendee(deleteIDs, "user", "user_id", "ou_user1") || + !calendarBodyHasAttendee(deleteIDs, "chat", "chat_id", "oc_group1") || + !calendarBodyHasAttendee(deleteIDs, "resource", "room_id", "omm_room1") { + t.Fatalf("unexpected remove attendees body: %#v", body) + } +} + +func TestUpdate_CombinedPatchRemoveAdd(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + patchStub := &httpmock.Stub{ + Method: "PATCH", + URL: "/events/evt_update4", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{"event": map[string]interface{}{"event_id": "evt_update4", "summary": "Combined"}}, + }, + } + removeStub := &httpmock.Stub{ + Method: "POST", + URL: "/events/evt_update4/attendees/batch_delete", + Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}}, + } + addStub := &httpmock.Stub{ + Method: "POST", + URL: "/events/evt_update4/attendees", + Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}}, + } + reg.Register(patchStub) + reg.Register(removeStub) + reg.Register(addStub) + + err := mountAndRun(t, CalendarUpdate, []string{ + "+update", + "--event-id", "evt_update4", + "--summary", "Combined", + "--remove-attendee-ids", "ou_old", + "--add-attendee-ids", "ou_new", + "--as", "bot", + }, f, nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(patchStub.CapturedBody) == 0 || len(removeStub.CapturedBody) == 0 || len(addStub.CapturedBody) == 0 { + t.Fatalf("expected patch, remove, and add requests to be captured") + } +} + +func TestUpdate_DryRun_MultiStep(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + + err := mountAndRun(t, CalendarUpdate, []string{ + "+update", + "--event-id", "evt_dry", + "--calendar-id", "cal_test123", + "--summary", "Dry", + "--remove-attendee-ids", "omm_oldroom", + "--add-attendee-ids", "ou_new,omm_newroom", + "--dry-run", + "--as", "bot", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + for _, want := range []string{"PATCH", "batch_delete", "attendees", "omm_oldroom", "omm_newroom"} { + if !strings.Contains(out, want) { + t.Fatalf("dry-run should contain %q, got: %s", want, out) + } + } +} + +func TestUpdate_Validation(t *testing.T) { + cases := []struct { + name string + args []string + want string + }{ + { + name: "no fields", + args: []string{"+update", "--event-id", "evt_1", "--as", "bot"}, + want: "nothing to update", + }, + { + name: "invalid attendee", + args: []string{"+update", "--event-id", "evt_1", "--add-attendee-ids", "bad", "--as", "bot"}, + want: "invalid attendee id format", + }, + { + name: "duplicate add remove", + args: []string{"+update", "--event-id", "evt_1", "--add-attendee-ids", "ou_same", "--remove-attendee-ids", "ou_same", "--as", "bot"}, + want: "appears in both", + }, + { + name: "start without end", + args: []string{"+update", "--event-id", "evt_1", "--start", "2025-03-21T00:00:00+08:00", "--as", "bot"}, + want: "must be specified together", + }, + { + name: "end before start", + args: []string{"+update", "--event-id", "evt_1", "--start", "2025-03-21T10:00:00+08:00", "--end", "2025-03-21T09:00:00+08:00", "--as", "bot"}, + want: "end time must be after start time", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := mountAndRun(t, CalendarUpdate, tc.args, f, nil) + if err == nil { + t.Fatal("expected validation error, got nil") + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("expected error containing %q, got %v", tc.want, err) + } + }) + } +} + +func decodeCalendarCapturedBody(t *testing.T, stub *httpmock.Stub) map[string]interface{} { + t.Helper() + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("unmarshal captured body: %v\nraw=%s", err, string(stub.CapturedBody)) + } + return body +} + +func calendarBodyHasAttendee(items []interface{}, typ, key, value string) bool { + for _, item := range items { + m, _ := item.(map[string]interface{}) + if m["type"] == typ && m[key] == value { + return true + } + } + return false +} + // --------------------------------------------------------------------------- // CalendarAgenda tests // --------------------------------------------------------------------------- @@ -627,6 +881,11 @@ func TestCalendarShortcuts_RequireLoginUnlessExplicitBot(t *testing.T) { shortcut: CalendarCreate, args: []string{"+create", "--summary", "Test Meeting", "--start", "2025-03-21T00:00:00+08:00", "--end", "2025-03-21T01:00:00+08:00"}, }, + { + name: "update", + shortcut: CalendarUpdate, + args: []string{"+update", "--event-id", "evt_1", "--summary", "Updated"}, + }, { name: "freebusy", shortcut: CalendarFreebusy, @@ -1710,17 +1969,17 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) { // Shortcuts() registration test // --------------------------------------------------------------------------- -func TestShortcuts_Returns6(t *testing.T) { +func TestShortcuts_Returns7(t *testing.T) { shortcuts := Shortcuts() - if len(shortcuts) != 6 { - t.Fatalf("expected 6 shortcuts, got %d", len(shortcuts)) + if len(shortcuts) != 7 { + t.Fatalf("expected 7 shortcuts, got %d", len(shortcuts)) } names := map[string]bool{} for _, s := range shortcuts { names[s.Command] = true } - for _, want := range []string{"+agenda", "+create", "+freebusy", "+room-find", "+rsvp", "+suggestion"} { + for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion"} { if !names[want] { t.Errorf("missing shortcut %s", want) } diff --git a/shortcuts/calendar/calendar_update.go b/shortcuts/calendar/calendar_update.go new file mode 100644 index 000000000..f0b24415a --- /dev/null +++ b/shortcuts/calendar/calendar_update.go @@ -0,0 +1,384 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import ( + "context" + "fmt" + "io" + "strconv" + "strings" + "time" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var CalendarUpdate = common.Shortcut{ + Service: "calendar", + Command: "+update", + Description: "Update a calendar event and incrementally add or remove attendees", + Risk: "write", + Scopes: []string{"calendar:calendar.event:update"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "event-id", Desc: "event ID to update", Required: true}, + {Name: "calendar-id", Desc: "calendar ID (default: primary)"}, + {Name: "summary", Desc: "event title"}, + {Name: "description", Desc: "event description"}, + {Name: "start", Desc: "new start time (ISO 8601); requires --end"}, + {Name: "end", Desc: "new end time (ISO 8601); requires --start"}, + {Name: "rrule", Desc: "recurrence rule (rfc5545)"}, + {Name: "add-attendee-ids", Desc: "attendee IDs to add, comma-separated (supports user ou_, chat oc_, room omm_)"}, + {Name: "remove-attendee-ids", Desc: "attendee IDs to remove, comma-separated (supports user ou_, chat oc_, room omm_)"}, + {Name: "notify", Type: "bool", Default: "true", Desc: "send update notification to attendees"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateCalendarUpdate(runtime) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunCalendarUpdate(runtime) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeCalendarUpdate(ctx, runtime) + }, +} + +func validateCalendarUpdate(runtime *common.RuntimeContext) error { + if err := rejectCalendarAutoBotFallback(runtime); err != nil { + return err + } + for _, flag := range []string{"event-id", "summary", "description", "rrule", "calendar-id", "start", "end", "add-attendee-ids", "remove-attendee-ids"} { + if val := runtime.Str(flag); val != "" { + if err := common.RejectDangerousChars("--"+flag, val); err != nil { + return output.ErrValidation(err.Error()) + } + } + } + + if strings.TrimSpace(runtime.Str("event-id")) == "" { + return common.FlagErrorf("specify --event-id") + } + if _, _, err := buildCalendarUpdateEventData(runtime); err != nil { + return err + } + if err := validateCalendarUpdateAttendees(runtime); err != nil { + return err + } + if !hasCalendarUpdateOperation(runtime) { + return common.FlagErrorf("nothing to update: specify at least one of --summary, --description, --start/--end, --rrule, --add-attendee-ids, or --remove-attendee-ids") + } + return nil +} + +func validateCalendarUpdateAttendees(runtime *common.RuntimeContext) error { + addIDs, err := parseCalendarAttendeeIDs(runtime.Str("add-attendee-ids")) + if err != nil { + return err + } + removeIDs, err := parseCalendarAttendeeIDs(runtime.Str("remove-attendee-ids")) + if err != nil { + return err + } + removeSet := make(map[string]struct{}, len(removeIDs)) + for _, id := range removeIDs { + removeSet[id] = struct{}{} + } + for _, id := range addIDs { + if _, ok := removeSet[id]; ok { + return output.ErrValidation("attendee id %q appears in both --add-attendee-ids and --remove-attendee-ids", id) + } + } + return nil +} + +func hasCalendarUpdateOperation(runtime *common.RuntimeContext) bool { + if len(runtime.Str("add-attendee-ids")) > 0 || len(runtime.Str("remove-attendee-ids")) > 0 { + return true + } + body, hasEventFields, err := buildCalendarUpdateEventData(runtime) + return err == nil && hasEventFields && len(body) > 0 +} + +func buildCalendarUpdateEventData(runtime *common.RuntimeContext) (map[string]interface{}, bool, error) { + body := map[string]interface{}{} + hasFields := false + + for _, field := range []string{"summary", "description"} { + if runtime.Cmd.Flags().Changed(field) { + body[field] = runtime.Str(field) + hasFields = true + } + } + if runtime.Cmd.Flags().Changed("rrule") { + rrule := strings.TrimSpace(runtime.Str("rrule")) + if rrule != "" { + body["recurrence"] = rrule + hasFields = true + } + } + + startChanged := runtime.Cmd.Flags().Changed("start") + endChanged := runtime.Cmd.Flags().Changed("end") + if startChanged != endChanged { + return nil, false, common.FlagErrorf("--start and --end must be specified together when updating event time") + } + if startChanged { + startTs, err := common.ParseTime(runtime.Str("start")) + if err != nil { + return nil, false, common.FlagErrorf("--start: %v", err) + } + endTs, err := common.ParseTime(runtime.Str("end"), "end") + if err != nil { + return nil, false, common.FlagErrorf("--end: %v", err) + } + s, err := strconv.ParseInt(startTs, 10, 64) + if err != nil { + return nil, false, common.FlagErrorf("invalid start time: %v", err) + } + e, err := strconv.ParseInt(endTs, 10, 64) + if err != nil { + return nil, false, common.FlagErrorf("invalid end time: %v", err) + } + if e <= s { + return nil, false, common.FlagErrorf("end time must be after start time") + } + body["start_time"] = map[string]string{"timestamp": startTs} + body["end_time"] = map[string]string{"timestamp": endTs} + hasFields = true + } + + if hasFields { + body["need_notification"] = runtime.Bool("notify") + } + return body, hasFields, nil +} + +func parseCalendarAttendeeIDs(attendeesStr string) ([]string, error) { + if strings.TrimSpace(attendeesStr) == "" { + return nil, nil + } + seen := map[string]struct{}{} + var ids []string + for _, raw := range strings.Split(attendeesStr, ",") { + id := strings.TrimSpace(raw) + if id == "" { + continue + } + if !strings.HasPrefix(id, "ou_") && !strings.HasPrefix(id, "oc_") && !strings.HasPrefix(id, "omm_") { + return nil, output.ErrValidation("invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id) + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + return ids, nil +} + +func attendeeDeleteIDs(attendeesStr string) ([]map[string]string, error) { + ids, err := parseCalendarAttendeeIDs(attendeesStr) + if err != nil { + return nil, err + } + deleteIDs := make([]map[string]string, 0, len(ids)) + for _, id := range ids { + switch { + case strings.HasPrefix(id, "oc_"): + deleteIDs = append(deleteIDs, map[string]string{"type": "chat", "chat_id": id}) + case strings.HasPrefix(id, "omm_"): + deleteIDs = append(deleteIDs, map[string]string{"type": "resource", "room_id": id}) + case strings.HasPrefix(id, "ou_"): + deleteIDs = append(deleteIDs, map[string]string{"type": "user", "user_id": id}) + default: + return nil, output.ErrValidation("invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id) + } + } + return deleteIDs, nil +} + +func calendarUpdateIDs(runtime *common.RuntimeContext) (calendarID string, eventID string) { + calendarID = strings.TrimSpace(runtime.Str("calendar-id")) + if calendarID == "" { + calendarID = PrimaryCalendarIDStr + } + eventID = strings.TrimSpace(runtime.Str("event-id")) + return calendarID, eventID +} + +func calendarUpdateEventPath(calendarID, eventID string) string { + return fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarID), validate.EncodePathSegment(eventID)) +} + +func calendarUpdateAttendeesPath(calendarID, eventID string) string { + return calendarUpdateEventPath(calendarID, eventID) + "/attendees" +} + +func dryRunCalendarUpdate(runtime *common.RuntimeContext) *common.DryRunAPI { + calendarID, eventID := calendarUpdateIDs(runtime) + displayCalendarID := calendarID + if displayCalendarID == "" || displayCalendarID == "primary" { + displayCalendarID = "" + } + + body, hasEventFields, err := buildCalendarUpdateEventData(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + + d := common.NewDryRunAPI().Set("calendar_id", displayCalendarID).Set("event_id", eventID) + opCount := 0 + if hasEventFields { + opCount++ + } + if strings.TrimSpace(runtime.Str("remove-attendee-ids")) != "" { + opCount++ + } + if strings.TrimSpace(runtime.Str("add-attendee-ids")) != "" { + opCount++ + } + if opCount > 1 { + d.Desc("multi-step update: event fields, attendee removal, and attendee addition run in order when requested") + } + steps := 0 + if hasEventFields { + steps++ + d.PATCH("/open-apis/calendar/v4/calendars/:calendar_id/events/:event_id"). + Desc(fmt.Sprintf("[%d] Update event fields", steps)). + Params(map[string]interface{}{"user_id_type": "open_id"}). + Body(body) + } + if removeStr := runtime.Str("remove-attendee-ids"); strings.TrimSpace(removeStr) != "" { + deleteIDs, err := attendeeDeleteIDs(removeStr) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + steps++ + d.POST("/open-apis/calendar/v4/calendars/:calendar_id/events/:event_id/attendees/batch_delete"). + Desc(fmt.Sprintf("[%d] Remove attendees", steps)). + Params(map[string]interface{}{"user_id_type": "open_id"}). + Body(map[string]interface{}{"delete_ids": deleteIDs, "need_notification": runtime.Bool("notify")}) + } + if addStr := runtime.Str("add-attendee-ids"); strings.TrimSpace(addStr) != "" { + attendees, err := parseAttendees(addStr, "") + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + steps++ + d.POST("/open-apis/calendar/v4/calendars/:calendar_id/events/:event_id/attendees"). + Desc(fmt.Sprintf("[%d] Add attendees", steps)). + Params(map[string]interface{}{"user_id_type": "open_id"}). + Body(map[string]interface{}{"attendees": attendees, "need_notification": runtime.Bool("notify")}) + } + return d +} + +func executeCalendarUpdate(_ context.Context, runtime *common.RuntimeContext) error { + calendarID, eventID := calendarUpdateIDs(runtime) + if eventID == "" { + return output.ErrValidation("specify --event-id") + } + + body, hasEventFields, err := buildCalendarUpdateEventData(runtime) + if err != nil { + return err + } + + completed := []string{} + event := map[string]interface{}{} + if hasEventFields { + data, err := runtime.CallAPI("PATCH", calendarUpdateEventPath(calendarID, eventID), map[string]interface{}{"user_id_type": "open_id"}, body) + err = wrapPredefinedError(err) + if err != nil { + return output.Errorf(output.ExitAPI, "api_error", "failed to update event %s: %v", eventID, err) + } + if v, _ := data["event"].(map[string]interface{}); v != nil { + event = v + } + completed = append(completed, "event") + } + + removedCount := 0 + if removeStr := runtime.Str("remove-attendee-ids"); strings.TrimSpace(removeStr) != "" { + deleteIDs, err := attendeeDeleteIDs(removeStr) + if err != nil { + return err + } + _, err = runtime.CallAPI("POST", calendarUpdateAttendeesPath(calendarID, eventID)+"/batch_delete", + map[string]interface{}{"user_id_type": "open_id"}, + map[string]interface{}{"delete_ids": deleteIDs, "need_notification": runtime.Bool("notify")}) + err = wrapPredefinedError(err) + if err != nil { + return output.Errorf(output.ExitAPI, "api_error", "failed to remove attendees from event %s after completed steps %v: %v", eventID, completed, err) + } + removedCount = len(deleteIDs) + completed = append(completed, "remove_attendees") + } + + addedCount := 0 + if addStr := runtime.Str("add-attendee-ids"); strings.TrimSpace(addStr) != "" { + attendees, err := parseAttendees(addStr, "") + if err != nil { + return output.ErrValidation("invalid attendee id: %v", err) + } + _, err = runtime.CallAPI("POST", calendarUpdateAttendeesPath(calendarID, eventID), + map[string]interface{}{"user_id_type": "open_id"}, + map[string]interface{}{"attendees": attendees, "need_notification": runtime.Bool("notify")}) + err = wrapPredefinedError(err) + if err != nil { + return output.Errorf(output.ExitAPI, "api_error", "failed to add attendees to event %s after completed steps %v: %v", eventID, completed, err) + } + addedCount = len(attendees) + } + + result := calendarUpdateResult(eventID, event, addedCount, removedCount) + runtime.OutFormat(result, nil, func(w io.Writer) { + output.PrintTable(w, []map[string]interface{}{result}) + fmt.Fprintln(w, "\nEvent updated successfully") + }) + return nil +} + +func calendarUpdateResult(eventID string, event map[string]interface{}, addedCount, removedCount int) map[string]interface{} { + result := map[string]interface{}{ + "event_id": eventID, + "attendees_added_count": addedCount, + "attendees_removed_count": removedCount, + } + if summary, _ := event["summary"].(string); summary != "" { + result["summary"] = summary + } + if description, _ := event["description"].(string); description != "" { + result["description"] = description + } + if start := formatCalendarEventTime(event["start_time"]); start != "" { + result["start"] = start + } + if end := formatCalendarEventTime(event["end_time"]); end != "" { + result["end"] = end + } + return result +} + +func formatCalendarEventTime(v interface{}) string { + m, _ := v.(map[string]interface{}) + if m == nil { + return "" + } + if tsStr, _ := m["timestamp"].(string); tsStr != "" { + if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil { + return time.Unix(ts, 0).Local().Format(time.RFC3339) + } + } + if dt, _ := m["datetime"].(string); dt != "" { + return dt + } + if date, _ := m["date"].(string); date != "" { + return date + } + return "" +} diff --git a/shortcuts/calendar/shortcuts.go b/shortcuts/calendar/shortcuts.go index c2bdade9e..f959068bb 100644 --- a/shortcuts/calendar/shortcuts.go +++ b/shortcuts/calendar/shortcuts.go @@ -10,6 +10,7 @@ func Shortcuts() []common.Shortcut { return []common.Shortcut{ CalendarAgenda, CalendarCreate, + CalendarUpdate, CalendarFreebusy, CalendarRoomFind, CalendarRsvp, diff --git a/skills/lark-calendar/SKILL.md b/skills/lark-calendar/SKILL.md index 982a1c183..0ec242368 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):提供日历与日程(会议)的全面管理能力。核心场景包括:查看/搜索日程、创建/更新日程、管理参会人、查询忙闲状态及推荐空闲时段、查询/搜索与预定会议室。注意:涉及【预约日程/会议】或【查询/预定会议室】时,必须先读取 references/lark-calendar-schedule-meeting.md 工作流!高频操作请优先使用 Shortcuts:+agenda(快速概览今日/近期行程)、+create(创建日程并按需邀请参会人及预定会议室)、+freebusy(查询用户主日历的忙闲信息和rsvp的状态)、+rsvp(回复日程邀请)" +description: "飞书日历(calendar):提供日历与日程(会议)的全面管理能力。核心场景包括:查看/搜索日程、创建/更新日程、管理参会人、查询忙闲状态及推荐空闲时段、查询/搜索与预定会议室。注意:涉及【预约日程/会议】或【查询/预定会议室】时,必须先读取 references/lark-calendar-schedule-meeting.md 工作流!高频操作请优先使用 Shortcuts:+agenda(快速概览今日/近期行程)、+create(创建日程并按需邀请参会人及预定会议室)、+update(更新既有日程字段,或独立增删参会人/会议室)、+freebusy(查询用户主日历的忙闲信息和rsvp的状态)、+rsvp(回复日程邀请)" metadata: requires: bins: ["lark-cli"] @@ -17,8 +17,14 @@ metadata: **CRITICAL — 会议与日程的意图路由:** - **查询过去时间的会议**:如果用户明确查询过去时间的会议(如“昨天的会议”、“上周的会议”),**优先使用 [`../lark-vc/SKILL.md`](../lark-vc/SKILL.md) 搜索会议记录**。因为会议数据不仅包含从日程发起的视频会议,还包含即时会议,仅查询日程数据会导致结果不全。 - **查询日历/日程或未来时间的会议**:如果用户明确表达的是“日历”、“日程”,或者涉及**未来时间**的安排,则属于本技能(lark-calendar)的业务域,请继续使用本技能处理。 +**CRITICAL — 任务类型分流:处理“预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间”时,必须先判断用户是在“新建日程”还是“编辑已有日程”。** +- **编辑已有日程的强信号**:用户明确提到某个已存在的日程锚点(如标题、时间段、`这个日程`、`这场会`)并表达修改动作(如“添加”“移除”“改到”“换会议室”“调整时间”)。这类请求默认走**编辑已有日程**,绝不能直接按新建处理。 +- **编辑已有日程的前置步骤**:一旦判定为编辑,MUST 先定位目标日程或具体实例的 `event_id`,再继续后续流程。若是重复性日程,MUST 先定位到对应实例的 `event_id`。 +- **新建日程**:只有当用户表达的是“新约一个会/创建一个日程/安排一次会议”等新增意图,且没有指向某个既有日程的修改动作时,才进入新建流程。 -**CRITICAL — 验证与同步延迟:在涉及删除日程(delete)或修改日程(patch)之后,如果需要进行二次查询验证操作结果,MUST 等待至少 2 秒后再进行查询,以防止因数据同步延迟导致查不到最新数据。注意:不要向用户提及你等待了这 2 秒钟的事情。** +**CRITICAL — 验证与同步延迟:在涉及删除日程(delete)、修改日程(patch)或者涉及添加移除参与人/会议室之后,如果需要进行二次查询验证操作结果,MUST 等待至少 2 秒后再进行查询,以防止因数据同步延迟导致查不到最新数据。注意:不要向用户提及你等待了这 2 秒钟的事情。** + +**CRITICAL — 重复性日程的实例操作:目前已经完全具备对重复性日程的某个具体实例进行操作的能力(例如:编辑某个实例、删除某个实例、为某个实例添加/删除参与人、为某个实例添加/移除会议室)。只要在对应的操作中传递对应实例的 `event_id` 即可。因此,MUST 先定位到对应的那次实例的 `event_id`(可通过 `events search_event` 搜索日程,或 `+agenda` 查看对应时间范围的日程等相关查询获取),绝对禁止直接使用原重复性日程的 `event_id` 进行操作。** **时间与日期推断规范:** 为确保准确性,在涉及时间推断时,请严格遵循以下规则: @@ -28,14 +34,14 @@ metadata: ## 核心场景 -### 1. 预约日程/会议、查询/搜索可用会议室 +### 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: 执行顺序必须固定为:先判断任务类型(新建/编辑);若为编辑先定位目标日程 `event_id`;再补默认值或继承已定位日程的已知信息;再判断时间是否明确;最后进入“明确时间”或“模糊时间/无时间信息”分支。不要跳步。** +**CRITICAL: 明确时间且需要会议室时,先基于最终确定的时间块执行 `+room-find`,再按需执行 `+freebusy`;模糊时间或无时间信息时,先 `+suggestion`,如需会议室再批量 `+room-find`。如果是编辑已有日程且不改时间,只新增会议室,则必须基于已定位日程的原始时间执行 `+room-find`,且最终落地时默认保留已存在的会议室;只有用户明确表达“更换会议室”或“移除会议室”时,才删除原会议室。** **CRITICAL: 当用户说“查会议室”“找会议室”“搜可用会议室”或“推荐常用会议室”时,默认是查会议室可用性,不是查会议室资源名录,更严禁拉取历史日程做统计分析。完整规则以 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) 为准。** **BLOCKING REQUIREMENT: 即使用户的核心诉求是“查会议室”,只要【没有提供明确的起止时间】,绝对禁止直接调用 `+room-find`!必须先进入【无时间/模糊时间】分支,调用 `+suggestion` 拿到候选时间块后,再将时间块传给 `+room-find`。** -**BLOCKING REQUIREMENT: 只要面临时间方案或会议室方案的选择(如模糊时间、无时间或需要会议室),在调用 `+create` 创建日程之前,必须先向用户展示候选方案并等待用户明确确认。绝对禁止擅自替用户做决定。** +**BLOCKING REQUIREMENT: 只要面临时间方案或会议室方案的选择(如模糊时间、无时间或需要会议室),在最终执行创建新日程或更新既有日程之前,必须先向用户展示候选方案并等待用户明确确认。绝对禁止擅自替用户做决定。** ## 核心概念 @@ -68,6 +74,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli calendar + [flags]` |----------|------| | [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) | | [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人(ISO 8601 时间) | +| [`+update`](references/lark-calendar-update.md) | 更新既有日程字段,或独立增量添加/移除参会人和会议室 | | [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和rsvp的状态 | | [`+room-find`](references/lark-calendar-room-find.md) | 针对一个或多个**明确的**时间块查找可用会议室(**无明确时间时禁止直接调用,需先走 +suggestion**) | | [`+rsvp`](references/lark-calendar-rsvp.md) | 回复日程(接受/拒绝/待定) | @@ -79,6 +86,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli calendar + [flags]` - **凡是用户意图是“预定/查询/搜索可用会议室”时,都必须进入 `references/lark-calendar-schedule-meeting.md` 工作流处理。** - `+room-find` 的时间输入必须是**确定时间块**,不能是时间区间搜索。 - **强制约束:如果用户仅要求“查询会议室”但未提供明确时间,必须先调用 `+suggestion` 获取可用时间块,然后再将时间块交给 `+room-find` 批量查询。严禁直接猜测时间并盲目调用 `+room-find`。** +- **编辑已有日程时,如果用户表达的是“添加会议室/再加一个会议室”,默认语义是增量添加,必须保留已有会议室;只有在用户明确表达“更换会议室”“把原会议室换掉”“移除会议室”时,才执行旧会议室删除。** ## API Resources diff --git a/skills/lark-calendar/references/lark-calendar-schedule-meeting.md b/skills/lark-calendar/references/lark-calendar-schedule-meeting.md index c2686eede..06eb4a66e 100644 --- a/skills/lark-calendar/references/lark-calendar-schedule-meeting.md +++ b/skills/lark-calendar/references/lark-calendar-schedule-meeting.md @@ -1,28 +1,35 @@ -# 预约日程/会议、查询/搜索可用会议室的工作流 +# 预约/改约日程或会议、查询/搜索可用会议室的工作流 ## CRITICAL 执行摘要(先按这个骨架执行,再看下方细则) +- **第一步永远是判断任务类型:新建日程,还是编辑已有日程。** 不要把“预约/查会议室”默认等同于“新建”。 +- **编辑已有日程时,必须先定位目标日程或实例的 `event_id`。** 用户一旦给出了既有日程锚点(标题、时间段、`这个日程`、`这场会`)并表达修改动作(加人、删人、改时间、换会议室等),默认走编辑流。 - **默认做智能助理,不做表单填写机。** 能根据上下文补全的默认值就直接补全,避免把用户带入表单式问答。 -- **先补默认值,再判断时间是否明确。** 默认值包括标题、参会人、时长,以及在“完全无时间信息”时的默认时间范围。 +- **新建流先补默认值,编辑流先继承已定位日程信息。** 默认值包括标题、参会人、时长,以及在“完全无时间信息”时的默认时间范围;编辑流则优先复用已定位日程的标题、时间、已有参与人和会议室信息作为基线。 - **只有三类场景才主动追问用户**:存在时间冲突、搜索结果无法唯一确定、时间语义本身有歧义。 +- **编辑流的时间基准必须明确。** 如果编辑时不改时间,则后续会议室搜索必须基于已定位日程的原始起止时间;如果既改时间又加会议室,必须先确定最终时间,再基于该时间搜索会议室。 +- **编辑流中“新增会议室”默认是增量语义。** 如果用户说的是“加会议室/再加一个会议室”,最终 `+update` 只做 `add`,默认保留已有会议室;只有在用户明确说“更换会议室/移除会议室”时,才执行旧会议室删除。 - **明确时间**:若需要会议室,先 `+room-find`;再 `+freebusy` 判断参会人忙闲;有冲突时先说明冲突,再让用户决定继续当前时间还是改走 `+suggestion`。 - **模糊时间或无时间信息**:先 `+suggestion` 产出候选时间块;若需要会议室,再把这些时间块批量交给 `+room-find`,将“候选时间 + 对应可用会议室”一次性展示给用户选择。 -- **BLOCKING REQUIREMENT: 只要面临时间方案(模糊时间/无时间)或会议室方案(需要会议室)的选择,必须先向用户展示选项并等待用户明确确认,绝对禁止在未获用户确认的情况下直接调用 `+create` 创建日程。** -- **用户选中了 `+suggestion` 返回的候选时间块后,不要再次调用 `+freebusy`。** 用户确认后直接进入 `+create`。 +- **BLOCKING REQUIREMENT: 只要面临时间方案(模糊时间/无时间)或会议室方案(需要会议室)的选择,必须先向用户展示选项并等待用户明确确认,绝对禁止在未获用户确认的情况下直接执行创建新日程或更新既有日程。** +- **用户选中了 `+suggestion` 返回的候选时间块后,不要再次调用 `+freebusy`。** 用户确认后直接进入最终落地操作:创建新日程,或更新既有日程。 - **当用户说“查会议室”“找会议室”“搜可用会议室”时,默认意图是查会议室可用性,不是检索会议室资源名录。** -- **必须按顺序执行。** 不要跳过“补默认值”“判断时间明确性”这两个前置步骤。 +- **必须按顺序执行。** 不要跳过“任务类型判定”“目标日程定位(编辑流)”“补默认值/继承基线信息”“判断时间明确性”这些前置步骤。 > **💡 核心原则:做智能助理,充分利用默认值规则(如默认标题、时长、参与人等)自动补全信息。极力避免像“表单填写机”一样频繁打断并反问用户,仅在必须决策的冲突或无法唯一确定的场景下才发起询问。** ## 严禁行为 - **严禁在未读取对应子命令文档(如 `lark-calendar-room-find.md`、`lark-calendar-suggestion.md`)的情况下直接调用命令!** 必须先阅读文档掌握最新参数要求与规范。 +- **严禁在尚未判断“新建”还是“编辑”之前,就直接进入创建日程或查会议室动作。** +- **严禁把“给明天上午的‘产品发布会’加人/加群/加会议室”这类带有既有日程锚点 + 修改动词的请求,当成新建日程。** 这类请求必须先定位目标日程。 +- **严禁在编辑已有日程时跳过目标定位步骤。** 未拿到唯一的 `event_id` 前,不得调用 `+update`、也不得基于猜测时间去查会议室。 - **严禁在用户仅要求“查会议室”但未提供明确时间时,直接调用 `+room-find`!** 必须先默认一个合理时间范围,调用 `+suggestion` 拿到候选时间块,再将时间块传给 `+room-find`。 - **不要在用户完全没给时间时,直接反问“你想约什么时候”。** 先补一个合理时间范围,再进入 `+suggestion`。 - **不要在“需要会议室 + 时间模糊”的场景下,先让用户只选时间。** 应先批量查出每个候选时间对应的可用会议室,再让用户一次性完成选择。 - **不要在用户已经选中 `+suggestion` 候选时间后,再重复调用 `+freebusy`。** - **不要在用户未明确说出城市时,仅凭园区/办公室名自动补城市。** -- **严禁在面临时间方案或会议室方案的选择时(模糊时间、无时间或需要会议室),未经用户确认就擅自调用 `+create` 创建日程。** +- **严禁在面临时间方案或会议室方案的选择时(模糊时间、无时间或需要会议室),未经用户确认就擅自创建新日程或更新既有日程。** ## 适用场景 @@ -33,11 +40,14 @@ - “帮我推荐一个我以前常用的会议室” - “查询明天下午可用的会议室” - “明天下午3点约个日程/日历” +- “把明天上午的日程‘产品发布会’加上 小明 +- “给下周一的周会换个会议室” +- “把这个日程改到明天下午,并加上学清 F201” ## 核心概念 - **会议室是日程的一种参与人(attendee / resource),不能脱离日程单独预定。** -- **预定或查找会议室,均需先确定时间块。** 在推荐可用会议室后,应顺势引导用户完成最终的**预约日程**操作。 +- **预定或查找会议室,均需先确定时间块。** 在推荐可用会议室后,应顺势引导用户完成最终的**日程落地**操作:创建新日程,或更新既有日程。 ## CRITICAL 约束 @@ -45,9 +55,39 @@ - **当用户说“查会议室”“找会议室”“搜可用会议室”等,默认意图是查询会议室可用性,而不是检索会议室资源名录。** - **必须严格按照下方【工作流】的步骤顺序完成任务。特别是单独查会议室时,若无明确时间,强制先走“模糊时间/无时间信息”分支调用 `+suggestion`。** +## 任务类型判定 + +| 类型 | 典型语言信号 | 第一动作 | +|------|--------------|----------| +| 新建日程 | “约个会”“安排一个会议”“新建日程”“帮我订个会议室开会” | 补默认值,再进入时间判断 | +| 编辑已有日程 | “给某个日程加人/删人/加群/加会议室”“把某个日程改到…”“给这场会换个会议室” | 先定位目标日程 `event_id`,再进入后续流程 | + +进一步规则: + +- 只要同时出现**既有日程锚点**(标题、时间段、`这个日程`、`这场会`、某次实例)和**修改动词**(添加、移除、调整、改到、换、延后、提前),默认判定为**编辑已有日程**。 +- 对重复性日程的编辑,必须先定位到对应实例的 `event_id`,不能直接拿原重复日程的 `event_id` 做更新。 + ## 工作流 -### 1. 智能推断默认值 +### 1. 编辑已有日程:先定位目标日程 + +一旦判定为编辑流,必须先定位目标日程;没有 `event_id` 就不能继续后续修改动作。 + +定位规则: + +- 优先利用用户给出的标题、日期、时间范围、`这个日程/这场会` 等锚点,通过 `+agenda`、`events search_event` 或实例视图缩小范围。 +- 如果命中多个候选日程,必须向用户展示候选项并要求确认,禁止自行猜测。 +- 如果是重复性日程的某一次实例,必须继续定位到该次实例的 `event_id`。 + +编辑流分支规则: + +- **仅增删普通参会人/群组,不改时间,也不涉及会议室**:定位完成后可直接进入最终 `+update`。 +- **新增会议室,但不改时间**:必须基于已定位日程的当前 `start/end` 作为时间块执行 `+room-find`,不能因为用户没重复说时间就退回“无时间信息”。 +- **既改时间,又新增会议室**:必须先处理时间,拿到最终候选时间块后,再基于该时间执行 `+room-find`;最终只增量添加新会议室,不自动删除已有会议室。 +- **既改时间,又更换会议室**:必须先处理时间,拿到最终候选时间块后,再基于该时间执行 `+room-find`;只有在用户明确表达“更换”时,最终才执行“移除旧会议室 + 添加新会议室”。 +- **只改时间,不涉及会议室**:沿用下方时间工作流,但最终落地必须是 `+update`,不是 `+create`。 + +### 2. 新建日程:智能推断默认值 以下信息智能推断,减少频繁询问用户: - **标题**:根据上下文自动生成,例如“沟通对齐”“需求讨论”;如无法推断,默认为“会议” @@ -57,16 +97,24 @@ 当搜索特定参与人(人、群)出现多个结果无法唯一确定时,必须询问用户进行选择确认,并将该偏好记录为长期记忆,以便后续自动识别。 -### 2. 判断时间是否明确 +### 3. 判断时间是否明确 + +这一步判断的是**最终要落地的目标时间**,不是只看用户原句里有没有重复说时间。 + +时间基准规则: + +- **新建流**:使用用户给出的时间,或默认补全出的时间范围作为时间基准。 +- **编辑流且不改时间**:已定位日程的当前 `start/end` 就是时间基准。后续如需查会议室,直接使用这个明确时间块。 +- **编辑流且改时间**:用户想改到的新时间才是时间基准;若表达模糊,则进入 `+suggestion`。 分两类处理: - **明确时间**:如“明天下午3点” - **模糊时间**:如“明天下午”“下周找个时间” -### 3. 明确时间 +### 4. 明确时间 -明确时间时,需先判断是否需要会议室,如果需要,提前查询会议室;然后判断是否有时间冲突。 +明确时间时,需先判断是否需要会议室,如果需要,提前查询会议室;然后判断是否有时间冲突。这里的“明确时间”既可以来自用户直接表达,也可以来自已定位日程的原始时间。 详见 [`+room-find`](./lark-calendar-room-find.md) 与 [`+freebusy`](./lark-calendar-freebusy.md)。 ```bash @@ -89,16 +137,18 @@ lark-cli calendar +freebusy --start "" --end "" - **参会人过多或包含群组时的处理**: - 如果参与人过多(例如超过 5 人),为避免高耗时,仅需查询**当前用户(自己)**及少数核心人员的忙闲状态即可。 - 如果参与人中包含**群组**,无需展开群组成员查询其忙闲状态。 -- **如果没有冲突**:直接让用户选择会议室(如需),然后调用 `calendar +create` 创建日程 +- **编辑已有日程且不改时间,只新增会议室时**:这里的 `--slot` 必须来自已定位日程的当前 `start/end`。 +- **编辑已有日程且既改时间又加会议室时**:这里的 `--slot` 必须来自候选新时间,而不是旧时间;如果用户是“新增会议室”,后续落地只做添加,不删除旧会议室。 +- **如果没有冲突**:直接让用户选择会议室(如需),然后进入最终落地操作:创建新日程,或更新既有日程 - **如果有冲突**:必须先说明冲突情况,询问用户继续选择这个时间还是换个时间 - **如果说换个时间**:放弃当前时间,转入【模糊时间】流程,调用 `+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. 模糊时间或无时间信息 +### 5. 模糊时间或无时间信息 先调用: 详见 [`+suggestion`](./lark-calendar-suggestion.md);若需要会议室,再结合 [`+room-find`](./lark-calendar-room-find.md)。 @@ -115,11 +165,12 @@ lark-cli calendar +suggestion \ 规则: - 若用户完全没有提供时间信息,应先默认一个合理区间后再调用 `+suggestion` -- **不需要会议室**:获取多个推荐时间块后,直接向用户展示候选时间,用户确认后创建日程。 +- 编辑流中,若用户表达的是“改到明天下午”“下周找个时间再约”这类模糊新时间,则基于用户期望的新时间范围调用 `+suggestion`;不要继续沿用旧时间。 +- **不需要会议室**:获取多个推荐时间块后,直接向用户展示候选时间,用户确认后进入最终落地操作:创建新日程,或更新既有日程。 - **需要会议室**:获取多个候选时间块后,**不要急于让用户选时间**。先将这些时间块一次性交给 `calendar +room-find` 批量查询可用会议室,然后将【候选时间】与【对应的可用会议室列表】结构化分行展示,让用户一次性完成选择。(**注意:即使用户最初只说“查会议室”,且未带时间,也必须强制走到这一步,先 suggestion 再 room-find**)。 - 用户一旦选择了 `+suggestion` 返回的时间块,**无需再次调用 `+freebusy`** -### 5. 模糊语义消解与长期记忆构建 +### 6. 模糊语义消解与长期记忆构建 针对用户专属的时间表达习惯或存在歧义的时间场景,严禁主观臆断。典型例子包括: @@ -132,7 +183,7 @@ lark-cli calendar +suggestion \ - 应主动澄清真实意图,而不是自行猜测 - 当用户给出澄清后,应将这类个性化定义沉淀为长期偏好,推动后续直接理解类似表达 -### 6. 重复性日程 +### 7. 重复性日程 若当前会议为重复性日程,调用 `+room-find` 时需携带 `--event-rrule`。 @@ -140,15 +191,16 @@ lark-cli calendar +suggestion \ - `reserve_until_time` -若候选会议室的可预约上限早于重复规则覆盖范围,**不要直接按原规则创建**。应: +若候选会议室的可预约上限早于重复规则覆盖范围,**不要直接按原规则落地日程**。应: - 向用户明确说明该会议室最长可约至何时。 - 若用户确认继续选用该会议室,你必须**自动将日程的重复规则结束时间缩短**至该 `reserve_until_time`,以防止会议室预约失败。 -### 7. 创建日程 +### 8. 落地日程变更 用户确认后调用: -详见 [`+create`](./lark-calendar-create.md)。 +如果是新建会议,详见 [`+create`](./lark-calendar-create.md)。 +如果是更新既有日程,详见 [`+update`](./lark-calendar-update.md)。必须先定位目标 `event_id`,再按用户意图用 `+update` 独立执行字段更新、添加参会人/会议室、移除参会人/会议室,或组合这些动作。若用户意图是“新增会议室”,默认仅追加 `room_id`,不移除已有会议室。 ```bash lark-cli calendar +create \ @@ -156,10 +208,29 @@ lark-cli calendar +create \ --start "" \ --end "" \ --attendee-ids "ou_xxx,oc_xxx,omm_xxx" + +lark-cli calendar +update \ + --event-id "" \ + --start "" \ + --end "" \ + --add-attendee-ids "omm_new_room" + +# 仅当用户明确要求“更换会议室”时,才同时移除旧会议室并添加新会议室 +lark-cli calendar +update \ + --event-id "" \ + --remove-attendee-ids "omm_old_room" \ + --add-attendee-ids "omm_new_room" ``` 规则: -- 需要会议室时,将选中的 `room_id` 写入 `--attendee-ids` +- 新建日程时,可使用 `+create` +- 更新既有日程时,优先使用 `+update`。改时间/标题/描述、添加参会人/会议室、移除参会人/会议室可以分别独立执行; +- 编辑流必须始终沿用前面定位得到的目标 `event_id`;禁止在最后一步重新按标题猜测一次目标日程。 +- 编辑流中如果只是新增群组或普通参会人,不涉及时间和会议室,可直接 `+update --add-attendee-ids ...`。 +- 编辑流中如果是“新增会议室但不改时间”,必须先基于目标日程原始时间查到可用会议室,再 `+update --add-attendee-ids ""`;默认保留已有会议室。 +- 编辑流中如果是“既改时间又新增会议室”,顺序必须是:先确定最终时间,再查会议室,最后一次性 `+update` 时间与新增会议室;默认保留已有会议室。 +- 编辑流中如果是“既改时间又更换会议室”,顺序必须是:先确定最终时间,再查会议室,最后一次性 `+update` 时间、移除旧会议室并添加新会议室。 +- 需要会议室时,将选中的 `room_id` 写入最终落地请求的参与人列表 - 展示会议室候选时,必须保留 CLI/API 返回的完整 `room_name` 原值;允许附加“推断说明”,但禁止用摘要名、楼层及会议室号、容量/视频标签重组后的名称替换原值 ## 用户展示建议 diff --git a/skills/lark-calendar/references/lark-calendar-update.md b/skills/lark-calendar/references/lark-calendar-update.md new file mode 100644 index 000000000..4fdd8db12 --- /dev/null +++ b/skills/lark-calendar/references/lark-calendar-update.md @@ -0,0 +1,105 @@ +# calendar +update + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +更新既有日程字段,或独立增量添加/移除参会人和会议室。 + +`+update` 支持三类互相独立的动作:更新日程字段、添加参会人/会议室、移除参会人/会议室。它们可以单独执行,也可以在同一次命令中组合执行。 + +需要的 scopes: ["calendar:calendar.event:update"] + +## 推荐命令 + +```bash +# 更新标题、描述、时间 +lark-cli calendar +update \ + --event-id "" \ + --summary "产品评审" \ + --description "评审需求范围、排期与风险" \ + --start "2026-03-12T14:00+08:00" \ + --end "2026-03-12T15:00+08:00" + +# 增量添加参会人和会议室 +lark-cli calendar +update \ + --event-id "" \ + --add-attendee-ids "ou_aaa,ou_bbb,omm_room" + +# 移除参会人和会议室 +lark-cli calendar +update \ + --event-id "" \ + --remove-attendee-ids "ou_aaa,omm_room" + +# 同时更新日程信息、移除旧会议室、添加新会议室 +lark-cli calendar +update \ + --event-id "" \ + --summary "产品评审" \ + --start "2026-03-12T15:00+08:00" \ + --end "2026-03-12T16:00+08:00" \ + --remove-attendee-ids "omm_old_room" \ + --add-attendee-ids "omm_new_room" +``` + +参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--event-id ` | 是 | 要更新的日程 ID。重复性日程要先定位到目标实例的 `event_id`,不要直接使用原重复日程 ID | +| `--calendar-id ` | 否 | 日历 ID(省略则使用 `primary`) | +| `--summary ` | 否 | 新日程标题。仅在显式传入 `--summary` 时更新;若传空字符串,会把标题清空 | +| `--description ` | 否 | 新日程描述。目前 API 方式不支持编辑富文本描述;如果日程描述通过客户端编辑为富文本内容,则使用 API 更新描述会导致富文本格式丢失。仅在显式传入 `--description` 时更新;若传空字符串,会把描述清空 | +| `--start