From 35f048dd83c70d1ce7ecff9dbfc3f23d1f3a6675 Mon Sep 17 00:00:00 2001 From: Yanxin Lu Date: Wed, 25 Mar 2026 09:32:22 -0700 Subject: [PATCH] calendar: add recurring events, event management, and safety rules Add --rrule flag to send with DTSTART/BYDAY validation (RFC 5545). Add event list (via khal) and event delete (safe single-event removal). Document safety rules from 2026-03-25 incident: always dry-run recurring events, never rm .ics files, space out SMTP sends. --- MEMORY.md | 14 ++- TOOLS.md | 26 +++-- skills/calendar/SKILL.md | 65 +++++++++++- skills/calendar/TESTING.md | 100 +++++++++++++++++- skills/calendar/scripts/cal_tool.py | 157 +++++++++++++++++++++++++++- skills/calendar/scripts/calendar.sh | 4 +- 6 files changed, 347 insertions(+), 19 deletions(-) diff --git a/MEMORY.md b/MEMORY.md index 16b4e4b..6511550 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -73,6 +73,7 @@ _这份文件记录持续性项目和重要状态,跨会话保留。_ ### 3. 日历邀请 + CalDAV 同步 **状态**: 运行中 **创建**: 2026-03-18 +**更新**: 2026-03-25(添加 RRULE 支持 + 事件管理 + 安全规则) **配置**: - 技能: `~/.openclaw/workspace/skills/calendar/` - 日历数据: `~/.openclaw/workspace/calendars/` (home/work/tasks/journals) @@ -82,12 +83,19 @@ _这份文件记录持续性项目和重要状态,跨会话保留。_ - 运行方式: `uv run`(依赖 `icalendar` 库) **功能**: -- 发送日历邀请给外部收件人 +- 发送日历邀请,支持周期性事件(`--rrule`) - 接受/拒绝/暂定回复邀请(回复给 organizer) -- VTODO 待办管理(add/list/complete/delete/check) +- 事件管理(`event list` / `event delete`) +- VTODO 待办管理(add/list/edit/complete/delete/check) - 发送/回复/待办操作后自动 `vdirsyncer sync` 同步到 CalDAV - 心跳定期同步日历 +**⚠️ 重要安全规则**(2026-03-25 事故教训): +- 周期性邀请**必须先 `--dry-run`** 验证 ICS 内容 +- `--rrule` 的 `BYDAY` 必须和 `--start` 日期的星期匹配(工具会自动校验) +- **绝不用 `rm` 删日历 .ics 文件**,只用 `event delete --match` +- 连续发多封邮件时,每封间隔 10 秒以上(Migadu SMTP 限频) + --- ## 📁 项目文件索引 @@ -100,4 +108,4 @@ _这份文件记录持续性项目和重要状态,跨会话保留。_ --- -_最后更新: 2026-03-24_ +_最后更新: 2026-03-25_ diff --git a/TOOLS.md b/TOOLS.md index 185a02a..2746aa3 100644 --- a/TOOLS.md +++ b/TOOLS.md @@ -118,12 +118,20 @@ $SKILL_DIR/scripts/calendar.sh send \ --subject "Lunch" --summary "Lunch at Tartine" \ --start "2026-03-20T12:00:00" --end "2026-03-20T13:00:00" -# 接受邀请(从邮件中提取 .ics) +# 发送周期性邀请(--start 必须落在 BYDAY 指定的那天!) +$SKILL_DIR/scripts/calendar.sh send \ + --to "alice@example.com" \ + --subject "Weekly Shot" --summary "Allergy Shot (Tue)" \ + --start "2026-03-31T14:30:00" --end "2026-03-31T15:00:00" \ + --rrule "FREQ=WEEKLY;COUNT=13;BYDAY=TU" + +# 接受/拒绝邀请 $SKILL_DIR/scripts/calendar.sh reply --envelope-id 42 --action accept -# 拒绝邀请(附带留言) -$SKILL_DIR/scripts/calendar.sh reply --envelope-id 42 --action decline \ - --comment "Sorry, I have a conflict." +# 事件管理(查看、搜索、删除) +$SKILL_DIR/scripts/calendar.sh event list +$SKILL_DIR/scripts/calendar.sh event list --search "Allergy" +$SKILL_DIR/scripts/calendar.sh event delete --match "Allergy Shot (Tue)" # 待办管理 $SKILL_DIR/scripts/calendar.sh todo add --summary "跟进报销" --due "2026-03-25" --priority high @@ -132,16 +140,18 @@ $SKILL_DIR/scripts/calendar.sh todo edit --match "报销" --due "2026-03-28" $SKILL_DIR/scripts/calendar.sh todo complete --match "报销" $SKILL_DIR/scripts/calendar.sh todo delete --match "报销" $SKILL_DIR/scripts/calendar.sh todo check # 每日摘要(cron) - -# 查看日历(检查冲突) -khal list today 7d ``` -**支持操作**: 发送邀请 (`send`)、接受/拒绝/暂定 (`reply`)、待办管理 (`todo add/list/edit/complete/delete/check`) +**支持操作**: 发送邀请 (`send`)、接受/拒绝/暂定 (`reply`)、事件管理 (`event list/delete`)、待办管理 (`todo add/list/edit/complete/delete/check`) **依赖**: himalaya(邮件)、vdirsyncer(CalDAV 同步)、khal(查看日历)、todoman(待办管理) **同步**: 发送/回复/待办变更后自动 `vdirsyncer sync`,心跳也会定期同步 **注意**: 发送日历邀请属于对外邮件,需先确认 +**安全规则**: +- 周期性邀请务必先 `--dry-run` 验证 ICS 内容 +- **绝不用 `rm` 删日历 .ics 文件**,只用 `event delete` +- 连续发多封邮件时,每封间隔 10 秒以上(Migadu SMTP 限频) + ### OpenClaw Cron 定时任务 **规则**: 确定性 shell 任务用 `systemEvent`,需要 LLM 判断的用 `agentTurn` diff --git a/skills/calendar/SKILL.md b/skills/calendar/SKILL.md index c611c9d..41c783e 100644 --- a/skills/calendar/SKILL.md +++ b/skills/calendar/SKILL.md @@ -33,12 +33,16 @@ All commands go through the wrapper script: ```bash SKILL_DIR=~/.openclaw/workspace/skills/calendar -# Send an invite +# Send an invite (supports recurring events via --rrule) $SKILL_DIR/scripts/calendar.sh send [options] # Reply to an invite $SKILL_DIR/scripts/calendar.sh reply [options] +# Manage events +$SKILL_DIR/scripts/calendar.sh event list [options] +$SKILL_DIR/scripts/calendar.sh event delete [options] + # Manage todos $SKILL_DIR/scripts/calendar.sh todo add [options] $SKILL_DIR/scripts/calendar.sh todo list [options] @@ -76,6 +80,7 @@ $SKILL_DIR/scripts/calendar.sh send \ | `--location` | No | Event location | | `--description` | No | Event description / notes | | `--organizer` | No | Organizer display name (defaults to `--from`) | +| `--rrule` | No | Recurrence rule (e.g. `FREQ=WEEKLY;COUNT=13;BYDAY=TU`) | | `--uid` | No | Custom event UID (auto-generated if omitted) | | `--account` | No | Himalaya account name (if not default) | | `--dry-run` | No | Print ICS + MIME without sending | @@ -101,7 +106,17 @@ $SKILL_DIR/scripts/calendar.sh send \ --location "Zoom - https://zoom.us/j/123456" \ --description "Weekly check-in. Agenda: updates, blockers, action items." -# Dry run +# Recurring: every Tuesday for 13 weeks (--start MUST fall on a Tuesday) +$SKILL_DIR/scripts/calendar.sh send \ + --to "alice@example.com" \ + --subject "Allergy Shot (Tue)" \ + --summary "Allergy Shot (Tue)" \ + --start "2026-03-31T14:30:00" \ + --end "2026-03-31T15:00:00" \ + --location "11965 Venice Blvd. #300, LA" \ + --rrule "FREQ=WEEKLY;COUNT=13;BYDAY=TU" + +# Dry run (always use for recurring events to verify) $SKILL_DIR/scripts/calendar.sh send \ --to "test@example.com" \ --subject "Test" \ @@ -111,6 +126,52 @@ $SKILL_DIR/scripts/calendar.sh send \ --dry-run ``` +### Recurring Events (--rrule) + +The `--rrule` flag accepts an RFC 5545 RRULE string. Common patterns: + +| Pattern | RRULE | +|---------|-------| +| Weekly on Tue, 13 weeks | `FREQ=WEEKLY;COUNT=13;BYDAY=TU` | +| Weekly on Mon/Wed/Fri, until date | `FREQ=WEEKLY;UNTIL=20260630T000000Z;BYDAY=MO,WE,FR` | +| Every 2 weeks on Thu | `FREQ=WEEKLY;INTERVAL=2;BYDAY=TH` | +| Monthly on the 15th, 6 times | `FREQ=MONTHLY;COUNT=6;BYMONTHDAY=15` | +| Daily for 5 days | `FREQ=DAILY;COUNT=5` | + +**Critical rule**: For `FREQ=WEEKLY` with a single `BYDAY`, the `--start` date **must fall on that day of the week**. The tool validates this and will reject mismatches. RFC 5545 says mismatched DTSTART/BYDAY produces undefined behavior. + +**Best practice**: Always `--dry-run` first for recurring events to verify the generated ICS. + +--- + +## Managing Events + +```bash +# List upcoming events (next 90 days) +$SKILL_DIR/scripts/calendar.sh event list + +# Search events by text +$SKILL_DIR/scripts/calendar.sh event list --search "Allergy" + +# List with UIDs (for deletion) +$SKILL_DIR/scripts/calendar.sh event list --format "{uid} {title}" + +# Custom date range +$SKILL_DIR/scripts/calendar.sh event list --range-start "2026-04-01" --range-end "2026-04-30" + +# Delete by UID +$SKILL_DIR/scripts/calendar.sh event delete --uid "abc123@openclaw" + +# Delete by summary match +$SKILL_DIR/scripts/calendar.sh event delete --match "Allergy Shot (Tue)" +``` + +### Event Delete Safety + +- Deletes only ONE event at a time. If multiple events match, it lists them and exits. +- **NEVER use `rm` on calendar .ics files directly.** Always use `event delete`. +- After deleting, verify with `event list` or `khal list`. + --- ## Replying to Invites diff --git a/skills/calendar/TESTING.md b/skills/calendar/TESTING.md index eb4da48..02053d5 100644 --- a/skills/calendar/TESTING.md +++ b/skills/calendar/TESTING.md @@ -293,9 +293,98 @@ $SKILL_DIR/scripts/calendar.sh todo check # Should produce no output ``` -## 14. Regression: Existing Invite Commands +## 14. Dry Run: Recurring Event (--rrule) -Verify the rename didn't break VEVENT flow. +Test recurring event generation. Use a date that falls on a Tuesday. + +```bash +# Find next Tuesday +NEXT_TUE=$(python3 -c "from datetime import date,timedelta; d=date.today(); d+=timedelta((1-d.weekday())%7 or 7); print(d)") + +$SKILL_DIR/scripts/calendar.sh send \ + --to "mail@luyx.org" \ + --subject "Recurring Test (Tue)" \ + --summary "Recurring Test (Tue)" \ + --start "${NEXT_TUE}T14:30:00" \ + --end "${NEXT_TUE}T15:00:00" \ + --rrule "FREQ=WEEKLY;COUNT=4;BYDAY=TU" \ + --dry-run +``` + +**Verify:** +- [ ] ICS has `RRULE:FREQ=WEEKLY;BYDAY=TU;COUNT=4` +- [ ] DTSTART falls on a Tuesday +- [ ] No validation errors + +## 15. Validation: DTSTART/BYDAY Mismatch + +Verify the tool rejects mismatched DTSTART and BYDAY. + +```bash +# This should FAIL — start is on a Tuesday but BYDAY=TH +$SKILL_DIR/scripts/calendar.sh send \ + --to "mail@luyx.org" \ + --subject "Mismatch Test" \ + --summary "Mismatch Test" \ + --start "${NEXT_TUE}T09:00:00" \ + --end "${NEXT_TUE}T09:30:00" \ + --rrule "FREQ=WEEKLY;COUNT=4;BYDAY=TH" \ + --dry-run +``` + +**Verify:** +- [ ] Script exits with error +- [ ] Error message says DTSTART falls on TU but RRULE says BYDAY=TH +- [ ] Suggests changing --start to a date that falls on TH + +## 16. Event List + +```bash +# List upcoming events +$SKILL_DIR/scripts/calendar.sh event list + +# Search by text +$SKILL_DIR/scripts/calendar.sh event list --search "Calendar Skill Test" + +# List with UIDs +$SKILL_DIR/scripts/calendar.sh event list --format "{uid} {title}" +``` + +**Verify:** +- [ ] Events from earlier tests appear +- [ ] Search narrows results correctly +- [ ] UIDs are displayed with --format + +## 17. Event Delete + +```bash +# Send a throwaway event first +$SKILL_DIR/scripts/calendar.sh send \ + --to "mail@luyx.org" \ + --subject "Delete Me Event" \ + --summary "Delete Me Event" \ + --start "${TEST_DATE}T20:00:00" \ + --end "${TEST_DATE}T21:00:00" + +# Verify it exists +$SKILL_DIR/scripts/calendar.sh event list --search "Delete Me" + +# Delete it +$SKILL_DIR/scripts/calendar.sh event delete --match "Delete Me Event" + +# Verify it's gone +$SKILL_DIR/scripts/calendar.sh event list --search "Delete Me" +``` + +**Verify:** +- [ ] Event is created and visible +- [ ] Delete removes exactly one event +- [ ] Other events are untouched +- [ ] `vdirsyncer sync` ran after delete + +## 18. Regression: Existing Invite Commands + +Verify new features didn't break VEVENT flow. ```bash $SKILL_DIR/scripts/calendar.sh send \ @@ -309,7 +398,8 @@ $SKILL_DIR/scripts/calendar.sh send \ **Verify:** - [ ] ICS has `BEGIN:VEVENT`, `METHOD:REQUEST` -- [ ] No errors from the renamed script +- [ ] No RRULE present (single event) +- [ ] No errors --- @@ -347,3 +437,7 @@ todo list | `todo` command not found | Install with `uv tool install todoman` | | `todo list` errors | Check `~/.config/todoman/config.py` exists and `path` points to tasks dir | | Todo not syncing | Check `~/.openclaw/workspace/calendars/tasks/` exists, verify vdirsyncer `cal/tasks` pair | +| DTSTART/BYDAY mismatch error | `--start` date doesn't fall on the BYDAY day. Change the start date to match | +| Recurring events on wrong day | DTSTART was not aligned with BYDAY. Delete the event and resend with correct `--start` | +| SMTP rate limit / EOF error | Too many sends too fast. Wait 10+ seconds between sends (Migadu limit) | +| Events disappeared after cleanup | **Never use `rm *.ics`** on calendar dirs. Use `event delete --match` instead | diff --git a/skills/calendar/scripts/cal_tool.py b/skills/calendar/scripts/cal_tool.py index 0235761..0aa50f7 100644 --- a/skills/calendar/scripts/cal_tool.py +++ b/skills/calendar/scripts/cal_tool.py @@ -6,8 +6,10 @@ Uses the icalendar library for proper RFC 5545 ICS generation and parsing. Uses himalaya CLI for email delivery. Syncs to local CalDAV via vdirsyncer. Subcommands: - python calendar.py send [options] # create and send an invite + python calendar.py send [options] # create and send an invite (supports --rrule) python calendar.py reply [options] # accept/decline/tentative + python calendar.py event list [options] # list/search calendar events + python calendar.py event delete [options] # delete an event by UID or summary python calendar.py todo add [options] # create a VTODO task python calendar.py todo list [options] # list pending tasks python calendar.py todo edit [options] # edit a task's fields @@ -27,7 +29,7 @@ from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText -from icalendar import Alarm, Calendar, Event, Todo, vCalAddress, vText +from icalendar import Alarm, Calendar, Event, Todo, vCalAddress, vRecur, vText # --------------------------------------------------------------------------- # Config @@ -111,6 +113,54 @@ def _parse_date(date_str): return datetime.strptime(date_str, "%Y-%m-%d").date() +# iCalendar day abbreviation -> Python weekday (0=Mon, 6=Sun) +_ICAL_DAY_TO_WEEKDAY = { + "MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5, "SU": 6, +} + + +def _validate_rrule_dtstart(rrule_dict, dtstart): + """Validate that DTSTART day-of-week matches BYDAY when FREQ=WEEKLY. + + RFC 5545: 'The DTSTART property value SHOULD be synchronized with the + recurrence rule. The recurrence set generated with a DTSTART property + value not synchronized with the recurrence rule is undefined.' + """ + freq = rrule_dict.get("FREQ") + if not freq: + return + # Normalize: icalendar may return freq as list or string + if isinstance(freq, list): + freq = freq[0] + if str(freq).upper() != "WEEKLY": + return + + byday = rrule_dict.get("BYDAY") + if not byday: + return + if not isinstance(byday, list): + byday = [byday] + + # Only validate alignment when there's a single BYDAY value + if len(byday) != 1: + return + + day_str = str(byday[0]).upper() + expected_weekday = _ICAL_DAY_TO_WEEKDAY.get(day_str) + if expected_weekday is None: + return + + if dtstart.weekday() != expected_weekday: + actual_day = [k for k, v in _ICAL_DAY_TO_WEEKDAY.items() if v == dtstart.weekday()][0] + print( + f"Error: DTSTART falls on {actual_day} but RRULE says BYDAY={day_str}. " + f"RFC 5545 requires these to match for FREQ=WEEKLY. " + f"Change --start to a date that falls on {day_str}.", + file=sys.stderr, + ) + sys.exit(1) + + # --------------------------------------------------------------------------- # Send invite # --------------------------------------------------------------------------- @@ -154,6 +204,12 @@ def cmd_send(args): "RSVP": "TRUE", }) + # Recurrence rule + if args.rrule: + rrule = vRecur.from_ical(args.rrule) + _validate_rrule_dtstart(rrule, start) + event.add("rrule", rrule) + # 1-day reminder alarm = Alarm() alarm.add("action", "DISPLAY") @@ -683,6 +739,81 @@ def cmd_todo_check(args): print(output) +# --------------------------------------------------------------------------- +# Event management +# --------------------------------------------------------------------------- + + +def cmd_event_list(args): + """List calendar events via khal.""" + if args.search: + cmd = ["khal", "search"] + if args.format: + cmd += ["--format", args.format] + cmd.append(args.search) + else: + cmd = ["khal", "list"] + if args.format: + cmd += ["--format", args.format] + cmd += [args.range_start, args.range_end] + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + print(result.stdout.rstrip()) + except subprocess.CalledProcessError as e: + print(f"Error: khal failed: {e.stderr.strip()}", file=sys.stderr) + sys.exit(1) + except FileNotFoundError: + print("Error: khal is not installed", file=sys.stderr) + sys.exit(1) + + +def cmd_event_delete(args): + """Delete a calendar event by UID or summary match.""" + if not args.uid and not args.match: + print("Error: --uid or --match is required", file=sys.stderr) + sys.exit(1) + + if not CALENDAR_DIR.is_dir(): + print(f"Error: Calendar directory not found: {CALENDAR_DIR}", file=sys.stderr) + sys.exit(1) + + ics_files = list(CALENDAR_DIR.glob("*.ics")) + if not ics_files: + print("No events found in calendar.", file=sys.stderr) + sys.exit(1) + + # Find matching event(s) + matches = [] + for ics_path in ics_files: + try: + cal = Calendar.from_ical(ics_path.read_bytes()) + except Exception: + continue + for component in cal.walk(): + if component.name == "VEVENT": + uid = str(component.get("uid", "")) + summary = str(component.get("summary", "")) + if args.uid and args.uid in uid: + matches.append((ics_path, uid, summary)) + elif args.match and args.match in summary: + matches.append((ics_path, uid, summary)) + + if not matches: + target = args.uid or args.match + print(f"Error: No event matching '{target}'", file=sys.stderr) + sys.exit(1) + if len(matches) > 1: + print(f"Error: Multiple events match:", file=sys.stderr) + for _, uid, summary in matches: + print(f" - {summary} (uid: {uid})", file=sys.stderr) + sys.exit(1) + + ics_path, uid, summary = matches[0] + ics_path.unlink() + print(f"Deleted event: {summary} (uid: {uid})") + _sync_calendar() + + # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- @@ -703,6 +834,7 @@ def main(): send_p.add_argument("--location", default="", help="Event location") send_p.add_argument("--description", default="", help="Event description") send_p.add_argument("--organizer", default="", help="Organizer display name") + send_p.add_argument("--rrule", default="", help="RRULE string (e.g. FREQ=WEEKLY;COUNT=13;BYDAY=TU)") send_p.add_argument("--uid", default="", help="Custom event UID") send_p.add_argument("--account", default="", help="Himalaya account") send_p.add_argument("--dry-run", action="store_true", help="Preview without sending") @@ -718,6 +850,22 @@ def main(): reply_p.add_argument("--comment", default="", help="Message to include in reply") reply_p.add_argument("--dry-run", action="store_true", help="Preview without sending") + # --- event --- + event_p = subparsers.add_parser("event", help="Manage calendar events") + event_sub = event_p.add_subparsers(dest="event_command", required=True) + + # event list + elist_p = event_sub.add_parser("list", help="List events (via khal)") + elist_p.add_argument("--search", default="", help="Search events by text") + elist_p.add_argument("--range-start", default="today", help="Start of range (default: today)") + elist_p.add_argument("--range-end", default="90d", help="End of range (default: 90d)") + elist_p.add_argument("--format", default="", help="khal format string (e.g. '{uid} {title}')") + + # event delete + edel_p = event_sub.add_parser("delete", help="Delete an event") + edel_p.add_argument("--uid", default="", help="Event UID") + edel_p.add_argument("--match", default="", help="Match on summary text") + # --- todo --- todo_p = subparsers.add_parser("todo", help="Manage VTODO tasks") todo_sub = todo_p.add_subparsers(dest="todo_command", required=True) @@ -761,6 +909,11 @@ def main(): cmd_send(args) elif args.command == "reply": cmd_reply(args) + elif args.command == "event": + if args.event_command == "list": + cmd_event_list(args) + elif args.event_command == "delete": + cmd_event_delete(args) elif args.command == "todo": if args.todo_command == "add": cmd_todo_add(args) diff --git a/skills/calendar/scripts/calendar.sh b/skills/calendar/scripts/calendar.sh index 0f50e5f..88ff26a 100755 --- a/skills/calendar/scripts/calendar.sh +++ b/skills/calendar/scripts/calendar.sh @@ -2,8 +2,10 @@ # calendar — wrapper script for the calendar and todo tool. # # Usage: -# ./calendar.sh send [options] # send a calendar invite +# ./calendar.sh send [options] # send a calendar invite (supports --rrule) # ./calendar.sh reply [options] # accept/decline/tentative +# ./calendar.sh event list [options] # list/search calendar events +# ./calendar.sh event delete [options] # delete an event by UID or summary # ./calendar.sh todo add [options] # create a todo # ./calendar.sh todo list [options] # list pending todos # ./calendar.sh todo edit [options] # edit a todo's fields