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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions shortcuts/calendar/calendar_rsvp.go
Original file line number Diff line number Diff line change
@@ -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 = "<primary>"
case "primary":
calendarId = "<primary>"
}
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())
}
}
}
Comment thread
calendar-assistant marked this conversation as resolved.

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
},
}
120 changes: 116 additions & 4 deletions shortcuts/calendar/calendar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions shortcuts/calendar/shortcuts.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ func Shortcuts() []common.Shortcut {
CalendarAgenda,
CalendarCreate,
CalendarFreebusy,
CalendarRsvp,
CalendarSuggestion,
}
}
3 changes: 2 additions & 1 deletion skills/lark-calendar/SKILL.md
Original file line number Diff line number Diff line change
@@ -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(针对时间未确定的预约日程需求,提供多个时间推荐方案)。"
Comment thread
calendar-assistant marked this conversation as resolved.
metadata:
requires:
bins: ["lark-cli"]
Expand Down Expand Up @@ -81,6 +81,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli calendar +<verb> [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 使用
Expand Down
42 changes: 42 additions & 0 deletions skills/lark-calendar/references/lark-calendar-rsvp.md
Original file line number Diff line number Diff line change
@@ -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>` | **是** | 日程 ID |
| `--rsvp-status <status>` | **是** | 回复状态,可选值:`accept` (接受), `decline` (拒绝), `tentative` (待定) |
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用主日历) |
| `--dry-run` | 否 | 预览 API 调用,不执行 |

## 提示

- 只能回复你被邀请的日程。
- 调用前通常需要通过 `+agenda` 等命令获取到具体的 `event-id`。

## 参考

- [lark-calendar](../SKILL.md) -- 日历全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
Loading