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
3 changes: 3 additions & 0 deletions shortcuts/calendar/calendar_agenda.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ var CalendarAgenda = common.Shortcut{
{Name: "end", Desc: "end time (ISO 8601, default: end of start day)"},
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return rejectCalendarAutoBotFallback(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
startInt, endInt, err := parseTimeRange(runtime)
if err != nil {
Expand Down
3 changes: 3 additions & 0 deletions shortcuts/calendar/calendar_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ var CalendarCreate = common.Shortcut{
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
return err
}
for _, flag := range []string{"summary", "description", "rrule", "calendar-id"} {
if val := runtime.Str(flag); val != "" {
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
Expand Down
3 changes: 3 additions & 0 deletions shortcuts/calendar/calendar_freebusy.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ var CalendarFreebusy = common.Shortcut{
Body(map[string]interface{}{"time_min": timeMin, "time_max": timeMax, "user_id": userId, "need_rsvp_status": true})
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
return err
}
userId := runtime.Str("user-id")
if userId == "" && runtime.IsBot() {
return common.FlagErrorf("--user-id is required for bot identity")
Expand Down
3 changes: 3 additions & 0 deletions shortcuts/calendar/calendar_rsvp.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ var CalendarRsvp = common.Shortcut{
Set("event_id", eventId)
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
return err
}
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 {
Expand Down
3 changes: 3 additions & 0 deletions shortcuts/calendar/calendar_suggestion.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,9 @@ var CalendarSuggestion = common.Shortcut{
Body(req)
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
return err
}
durationMinutes := runtime.Int(flagDurationMinutes)
if durationMinutes != 0 && (durationMinutes < 1 || durationMinutes > 1440) {
return output.ErrValidation("--duration-minutes must be between 1 and 1440")
Expand Down
115 changes: 115 additions & 0 deletions shortcuts/calendar/calendar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,19 @@ func defaultConfig() *core.CliConfig {
}
}

func noLoginConfig() *core.CliConfig {
return &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
}

func noLoginBotDefaultConfig() *core.CliConfig {
return &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
DefaultAs: "bot",
}
}

// ---------------------------------------------------------------------------
// CalendarCreate tests
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -337,6 +350,108 @@ func TestCreate_NoEventIdReturned(t *testing.T) {
// CalendarAgenda tests
// ---------------------------------------------------------------------------

func TestCalendarShortcuts_RequireLoginUnlessExplicitBot(t *testing.T) {
cases := []struct {
name string
shortcut common.Shortcut
args []string
}{
{
name: "agenda",
shortcut: CalendarAgenda,
args: []string{"+agenda", "--start", "2025-03-21", "--end", "2025-03-21"},
},
{
name: "create",
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: "freebusy",
shortcut: CalendarFreebusy,
args: []string{"+freebusy", "--start", "2025-03-21", "--end", "2025-03-21"},
},
{
name: "rsvp",
shortcut: CalendarRsvp,
args: []string{"+rsvp", "--event-id", "evt_rsvp1", "--rsvp-status", "accept"},
},
{
name: "suggestion",
shortcut: CalendarSuggestion,
args: []string{"+suggestion", "--start", "2025-03-21", "--end", "2025-03-21"},
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, noLoginConfig())

err := mountAndRun(t, tc.shortcut, tc.args, f, nil)
if err == nil {
t.Fatal("expected auth guard error")
}
if !strings.Contains(err.Error(), "auth login") {
t.Fatalf("expected auth login guidance, got: %v", err)
}
if !strings.Contains(err.Error(), "--as bot") {
t.Fatalf("expected explicit bot guidance, got: %v", err)
}
})
}
}

func TestAgenda_ExplicitBotBypassesLoginGuard(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, noLoginConfig())

reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/events/instance_view",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{},
},
},
})

err := mountAndRun(t, CalendarAgenda, []string{
"+agenda",
"--start", "2025-03-21",
"--end", "2025-03-21",
"--as", "bot",
}, f, stdout)

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}

func TestAgenda_DefaultAsBotBypassesLoginGuard(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, noLoginBotDefaultConfig())

reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/events/instance_view",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{},
},
},
})

err := mountAndRun(t, CalendarAgenda, []string{
"+agenda",
"--start", "2025-03-21",
"--end", "2025-03-21",
}, f, stdout)

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}

func TestAgenda_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())

Expand Down
24 changes: 24 additions & 0 deletions shortcuts/calendar/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
package calendar

import (
"strings"
"time"

"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)

const (
Expand All @@ -26,3 +29,24 @@ func resolveStartEnd(runtime *common.RuntimeContext) (string, string) {
}
return startInput, endInput
}

func hasExplicitBotFlag(cmd *cobra.Command) bool {
if cmd == nil {
return false
}
flag := cmd.Flag("as")
return flag != nil && flag.Changed && flag.Value != nil && strings.TrimSpace(flag.Value.String()) == "bot"
}

func rejectCalendarAutoBotFallback(runtime *common.RuntimeContext) error {
if runtime == nil || !runtime.IsBot() || hasExplicitBotFlag(runtime.Cmd) {
return nil
}
if runtime.Factory == nil || !runtime.Factory.IdentityAutoDetected {
return nil
}

msg := "calendar commands require a valid user login by default; when no valid user login state is available, auto identity falls back to bot and may operate on the bot calendar instead of your own. Run `lark-cli auth login --domain calendar` for your calendar, or rerun with `--as bot` if bot identity is intentional."
hint := "restore user login: `lark-cli auth login --domain calendar`\nintentional bot usage: rerun with `--as bot`"
return output.ErrWithHint(output.ExitAuth, "calendar_user_login_required", msg, hint)
}
5 changes: 2 additions & 3 deletions skills/lark-calendar/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ metadata:

**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
**CRITICAL — 所有的 Shortcuts 在执行之前,务必先使用 Read 工具读取其对应的说明文档,禁止直接盲目调用命令。**

## 核心场景

日历技能包含以下核心场景:
Expand Down Expand Up @@ -54,7 +53,7 @@ metadata:
## 核心概念

- **日历(Calendar)**:日程的容器。每个用户有一个主日历(primary calendar),也可以创建或订阅共享日历。
- **日程(Event)**:日历中的单个事件条目,包含起止时间、地点、标题、参与人等属性。支持单次日程和重复日程,遵循RFC5545 iCalendar国际标准。
- **日程(Event)**:日历中的单个日程,包含起止时间、地点、标题、参与人等属性。支持单次日程和重复日程,遵循RFC5545 iCalendar国际标准。
- ***全天日程(All-day Event)***: 只按日期占用、没有具体起止时刻的日程,结束日期是包含在日程时间内的。
- **日程实例(Instance)**:日程的具体时间实例,本质是对日程的展开。普通日程和例外日程对应1个Instance,重复性日程对应N个Instance。在按时间段查询时,可通过实例视图将重复日程展开为独立的实例返回,以便在时间线上准确展示和管理。
- **重复规则(Rrule/Recurrence Rule)**:定义重复性日程的重复规则,比如`FREQ=DAILY;UNTIL=20230307T155959Z;INTERVAL=14`表示每14天重复一次。
Expand Down Expand Up @@ -151,4 +150,4 @@ lark-cli calendar <resource> <method> [flags] # 调用 API
| `freebusys.list` | `calendar:calendar.free_busy:read` |

**注意(强制性):**
- 涉及日期(时间)字符串与时间戳的相互转换时,务必调用系统命令或脚本代码等外部工具进行处理,以确保转换的绝对准确。违者将导致严重的逻辑错误!
- 涉及日期(时间)字符串与时间戳的相互转换时,务必调用系统命令或脚本代码等外部工具进行处理,以确保转换的绝对准确。违者将导致严重的逻辑错误!
2 changes: 1 addition & 1 deletion skills/lark-calendar/references/lark-calendar-agenda.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ lark-cli calendar +agenda --calendar-id cal_xxx
(无日程)
```

**注意:按日期分组,并严格按照开始时间升序(从早到晚的时间线)排序输出。** 显示标题、时长、忙闲状态和rsvp状态。
**注意:按日期分组,并严格按照开始时间升序(从早到晚的时间线)排序输出。** 显示标题、时长

## 提示

Expand Down
3 changes: 1 addition & 2 deletions skills/lark-calendar/references/lark-calendar-suggestion.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,5 +122,4 @@ lark-cli calendar +suggestion \

- [lark-calendar-create](lark-calendar-create.md) — 创建日程
- [lark-calendar-freebusy](lark-calendar-freebusy.md) — 查询忙闲时段和rsvp状态
- [lark-calendar](../SKILL.md) — 日历完整 API

- [lark-calendar](../SKILL.md) — 日历完整 API
Loading