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.
This commit is contained in:
Yanxin Lu
2026-03-25 09:32:22 -07:00
parent 810a9923f9
commit 35f048dd83
6 changed files with 347 additions and 19 deletions

View File

@@ -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

View File

@@ -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 |

View File

@@ -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)

View File

@@ -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