From d12c68b2b702289369790eb86f2115990f3bdfd7 Mon Sep 17 00:00:00 2001 From: calendar-assistant Date: Tue, 31 Mar 2026 19:18:40 +0800 Subject: [PATCH] feat(calendar): implement rsvp shortcut Change-Id: I96170f024f1c8bb6f44de752961e58e5fec61644 --- shortcuts/calendar/calendar_rsvp.go | 90 +++++++++++++ shortcuts/calendar/calendar_test.go | 120 +++++++++++++++++- shortcuts/calendar/shortcuts.go | 1 + skills/lark-calendar/SKILL.md | 3 +- .../references/lark-calendar-rsvp.md | 42 ++++++ 5 files changed, 251 insertions(+), 5 deletions(-) create mode 100644 shortcuts/calendar/calendar_rsvp.go create mode 100644 skills/lark-calendar/references/lark-calendar-rsvp.md diff --git a/shortcuts/calendar/calendar_rsvp.go b/shortcuts/calendar/calendar_rsvp.go new file mode 100644 index 000000000..2f1260875 --- /dev/null +++ b/shortcuts/calendar/calendar_rsvp.go @@ -0,0 +1,90 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import ( + "context" + "fmt" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var CalendarRsvp = common.Shortcut{ + Service: "calendar", + Command: "+rsvp", + Description: "Reply to a calendar event (accept/decline/tentative)", + Risk: "write", + Scopes: []string{"calendar:calendar.event:reply"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: false, + Flags: []common.Flag{ + {Name: "calendar-id", Desc: "calendar ID (default: primary)"}, + {Name: "event-id", Desc: "event ID", Required: true}, + {Name: "rsvp-status", Desc: "reply status", Required: true, Enum: []string{"accept", "decline", "tentative"}}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + calendarId := strings.TrimSpace(runtime.Str("calendar-id")) + d := common.NewDryRunAPI() + switch calendarId { + case "": + d.Desc("(calendar-id omitted) Will use primary calendar") + calendarId = "" + case "primary": + calendarId = "" + } + eventId := strings.TrimSpace(runtime.Str("event-id")) + status := strings.TrimSpace(runtime.Str("rsvp-status")) + + return d. + POST("/open-apis/calendar/v4/calendars/:calendar_id/events/:event_id/reply"). + Body(map[string]interface{}{"rsvp_status": status}). + Set("calendar_id", calendarId). + Set("event_id", eventId) + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + for _, flag := range []string{"calendar-id", "event-id", "rsvp-status"} { + if val := strings.TrimSpace(runtime.Str(flag)); val != "" { + if err := common.RejectDangerousChars("--"+flag, val); err != nil { + return output.ErrValidation(err.Error()) + } + } + } + + eventId := strings.TrimSpace(runtime.Str("event-id")) + if eventId == "" { + return output.ErrValidation("event-id cannot be empty") + } + return nil + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + calendarId := strings.TrimSpace(runtime.Str("calendar-id")) + if calendarId == "" { + calendarId = PrimaryCalendarIDStr + } + eventId := strings.TrimSpace(runtime.Str("event-id")) + status := strings.TrimSpace(runtime.Str("rsvp-status")) + + _, err := runtime.DoAPIJSON("POST", + fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s/reply", + validate.EncodePathSegment(calendarId), + validate.EncodePathSegment(eventId)), + nil, + map[string]interface{}{ + "rsvp_status": status, + }) + if err != nil { + return err + } + + runtime.Out(map[string]interface{}{ + "calendar_id": calendarId, + "event_id": eventId, + "rsvp_status": status, + }, nil) + return nil + }, +} diff --git a/shortcuts/calendar/calendar_test.go b/shortcuts/calendar/calendar_test.go index 00823f564..6b2493243 100644 --- a/shortcuts/calendar/calendar_test.go +++ b/shortcuts/calendar/calendar_test.go @@ -580,6 +580,118 @@ func TestFreebusy_APIError(t *testing.T) { // CalendarSuggestion tests // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// CalendarRsvp tests +// --------------------------------------------------------------------------- + +func TestRsvp_Success(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/primary/events/evt_rsvp1/reply", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + }, + }) + + err := mountAndRun(t, CalendarRsvp, []string{ + "+rsvp", + "--event-id", "evt_rsvp1", + "--rsvp-status", "accept", + "--as", "bot", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, want := range []string{`"event_id": "evt_rsvp1"`, `"rsvp_status": "accept"`} { + if !strings.Contains(stdout.String(), want) { + t.Errorf("stdout should contain %s, got: %s", want, stdout.String()) + } + } +} + +func TestRsvp_InvalidStatus(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + + err := mountAndRun(t, CalendarRsvp, []string{ + "+rsvp", + "--event-id", "evt_rsvp1", + "--rsvp-status", "invalid_status", + "--as", "bot", + }, f, nil) + + if err == nil { + t.Fatal("expected validation error for invalid status, got nil") + } + if !strings.Contains(err.Error(), "invalid value") { + t.Errorf("error should mention invalid value, got: %v", err) + } +} + +func TestRsvp_APIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/primary/events/evt_rsvp1/reply", + Body: map[string]interface{}{ + "code": 190001, + "msg": "permission denied", + }, + }) + + err := mountAndRun(t, CalendarRsvp, []string{ + "+rsvp", + "--event-id", "evt_rsvp1", + "--rsvp-status", "decline", + "--as", "bot", + }, f, nil) + + if err == nil { + t.Fatal("expected error for API failure, got nil") + } +} + +func TestRsvp_RejectsDangerousChars(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + + err := mountAndRun(t, CalendarRsvp, []string{ + "+rsvp", + "--event-id", "evt_rsvp1\u202e", + "--rsvp-status", "accept", + "--as", "bot", + }, f, nil) + + if err == nil { + t.Fatal("expected validation error for dangerous characters, got nil") + } + if !strings.Contains(err.Error(), "dangerous Unicode") && !strings.Contains(err.Error(), "control character") { + t.Errorf("error should mention dangerous input, got: %v", err) + } +} + +func TestRsvp_DryRun_TrimmedPrimaryCalendar(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + + err := mountAndRun(t, CalendarRsvp, []string{ + "+rsvp", + "--calendar-id", " primary ", + "--event-id", "evt_rsvp1", + "--rsvp-status", "accept", + "--dry-run", + "--as", "bot", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), `"calendar_id": "\u003cprimary\u003e"`) { + t.Errorf("dry-run should normalize primary calendar, got: %s", stdout.String()) + } +} + func TestSuggestion_Success(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) reg.Register(&httpmock.Stub{ @@ -867,17 +979,17 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) { // Shortcuts() registration test // --------------------------------------------------------------------------- -func TestShortcuts_Returns4(t *testing.T) { +func TestShortcuts_Returns5(t *testing.T) { shortcuts := Shortcuts() - if len(shortcuts) != 4 { - t.Fatalf("expected 4 shortcuts, got %d", len(shortcuts)) + if len(shortcuts) != 5 { + t.Fatalf("expected 5 shortcuts, got %d", len(shortcuts)) } names := map[string]bool{} for _, s := range shortcuts { names[s.Command] = true } - for _, want := range []string{"+agenda", "+create", "+freebusy", "+suggestion"} { + for _, want := range []string{"+agenda", "+create", "+freebusy", "+rsvp", "+suggestion"} { if !names[want] { t.Errorf("missing shortcut %s", want) } diff --git a/shortcuts/calendar/shortcuts.go b/shortcuts/calendar/shortcuts.go index 5f2ca92b9..aed4fe1cd 100644 --- a/shortcuts/calendar/shortcuts.go +++ b/shortcuts/calendar/shortcuts.go @@ -11,6 +11,7 @@ func Shortcuts() []common.Shortcut { CalendarAgenda, CalendarCreate, CalendarFreebusy, + CalendarRsvp, CalendarSuggestion, } } diff --git a/skills/lark-calendar/SKILL.md b/skills/lark-calendar/SKILL.md index 46dd4eb24..056fc0842 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的状态)、+suggestion(针对时间未确定的预约日程需求,提供多个时间推荐方案)。" +description: "飞书日历(calendar):提供日历与日程(会议)的全面管理能力。核心场景包括:查看/搜索日程、创建/更新日程、管理参会人、查询忙闲状态及推荐空闲时段。高频操作请优先使用 Shortcuts:+agenda(快速概览今日/近期行程)、+create(创建日程并按需邀请参会人)、+freebusy(查询用户主日历的忙闲信息和rsvp的状态)、+rsvp(回复日程邀请)、+suggestion(针对时间未确定的预约日程需求,提供多个时间推荐方案)。" metadata: requires: bins: ["lark-cli"] @@ -81,6 +81,7 @@ 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的状态 | +| [`+rsvp`](references/lark-calendar-rsvp.md) | 回复日程(接受/拒绝/待定) | | [`+suggestion`](references/lark-calendar-suggestion.md) | 针对时间未确定的预约日程需求,提供多个时间推荐方案 | ## +suggestion 使用 diff --git a/skills/lark-calendar/references/lark-calendar-rsvp.md b/skills/lark-calendar/references/lark-calendar-rsvp.md new file mode 100644 index 000000000..c63280560 --- /dev/null +++ b/skills/lark-calendar/references/lark-calendar-rsvp.md @@ -0,0 +1,42 @@ +# calendar +rsvp + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +回复指定的日程,更新当前用户的 RSVP 状态(接受、拒绝或待定)。 + +需要的scopes: ["calendar:calendar.event:reply"] + +## 命令 + +```bash +# 回复日程为接受 (使用主日历) +lark-cli calendar +rsvp --event-id evt_xxx --rsvp-status accept + +# 回复日程为拒绝 +lark-cli calendar +rsvp --event-id evt_xxx --rsvp-status decline + +# 回复日程为待定 +lark-cli calendar +rsvp --event-id evt_xxx --rsvp-status tentative + +# 指定其他日历下的日程 +lark-cli calendar +rsvp --calendar-id cal_xxx --event-id evt_xxx --rsvp-status accept +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--event-id ` | **是** | 日程 ID | +| `--rsvp-status ` | **是** | 回复状态,可选值:`accept` (接受), `decline` (拒绝), `tentative` (待定) | +| `--calendar-id ` | 否 | 日历 ID(省略则使用主日历) | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +## 提示 + +- 只能回复你被邀请的日程。 +- 调用前通常需要通过 `+agenda` 等命令获取到具体的 `event-id`。 + +## 参考 + +- [lark-calendar](../SKILL.md) -- 日历全部命令 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数