VTODO skill
This commit is contained in:
313
skills/calendar/SKILL.md
Normal file
313
skills/calendar/SKILL.md
Normal file
@@ -0,0 +1,313 @@
|
||||
---
|
||||
name: calendar
|
||||
description: "Calendar invites and VTODO task management via CalDAV. Send/reply to invites, create/list/complete/delete todos. Syncs to Migadu CalDAV via vdirsyncer."
|
||||
metadata: {"clawdbot":{"emoji":"📅","requires":{"bins":["himalaya","vdirsyncer"],"skills":["himalaya"]}}}
|
||||
---
|
||||
|
||||
# Calendar
|
||||
|
||||
Send, accept, and decline calendar invitations via email. Create and manage VTODO tasks with CalDAV sync. Events and tasks sync to Migadu CalDAV via vdirsyncer.
|
||||
|
||||
## Testing
|
||||
|
||||
See `TESTING.md` for dry-run and live test steps, verification checklists, and troubleshooting.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `himalaya` configured and working (see the `himalaya` skill)
|
||||
- `vdirsyncer` configured and syncing to `~/.openclaw/workspace/calendars/`
|
||||
- `khal` for reading calendar (optional but recommended)
|
||||
- Runs via `uv run` (dependencies managed in `pyproject.toml`)
|
||||
|
||||
## Important: Email Sending Rules
|
||||
|
||||
Calendar invites are outbound emails. Follow the workspace email rules:
|
||||
- **youlu@luyanxin.com -> mail@luyx.org**: send directly, no confirmation needed
|
||||
- **All other recipients**: confirm with user before sending
|
||||
|
||||
## Owner Auto-Attendee
|
||||
|
||||
When sending invites, `mail@luyx.org` (owner's SimpleLogin alias) is **always added as an attendee automatically**. All invites include a **1-day reminder** (VALARM) by default. This ensures the owner receives every invite and can Accept/Decline from their own email client. No need to include it in `--to` — it's added by the script.
|
||||
|
||||
When accepting or tentatively accepting a received invite, the original invite is **automatically forwarded to `mail@luyx.org`** so the event lands on the owner's calendar too.
|
||||
|
||||
## Usage
|
||||
|
||||
All commands go through the wrapper script:
|
||||
|
||||
```bash
|
||||
SKILL_DIR=~/.openclaw/workspace/skills/calendar
|
||||
|
||||
# Send an invite
|
||||
$SKILL_DIR/scripts/calendar.sh send [options]
|
||||
|
||||
# Reply to an invite
|
||||
$SKILL_DIR/scripts/calendar.sh reply [options]
|
||||
|
||||
# Manage todos
|
||||
$SKILL_DIR/scripts/calendar.sh todo add [options]
|
||||
$SKILL_DIR/scripts/calendar.sh todo list [options]
|
||||
$SKILL_DIR/scripts/calendar.sh todo complete [options]
|
||||
$SKILL_DIR/scripts/calendar.sh todo delete [options]
|
||||
$SKILL_DIR/scripts/calendar.sh todo check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sending Invites
|
||||
|
||||
```bash
|
||||
$SKILL_DIR/scripts/calendar.sh send \
|
||||
--to "friend@example.com" \
|
||||
--subject "Lunch on Friday" \
|
||||
--summary "Lunch at Tartine" \
|
||||
--start "2026-03-20T12:00:00" \
|
||||
--end "2026-03-20T13:00:00" \
|
||||
--location "Tartine Bakery, SF"
|
||||
```
|
||||
|
||||
### Send Options
|
||||
|
||||
| Flag | Required | Description |
|
||||
|-----------------|----------|------------------------------------------------|
|
||||
| `--to` | Yes | Recipient(s), comma-separated |
|
||||
| `--subject` | Yes | Email subject line |
|
||||
| `--summary` | Yes | Event title (shown on calendar) |
|
||||
| `--start` | Yes | Start time, ISO 8601 (`2026-03-20T14:00:00`) |
|
||||
| `--end` | Yes | End time, ISO 8601 (`2026-03-20T15:00:00`) |
|
||||
| `--from` | No | Sender email (default: `youlu@luyanxin.com`) |
|
||||
| `--timezone` | No | IANA timezone (default: `America/Los_Angeles`) |
|
||||
| `--location` | No | Event location |
|
||||
| `--description` | No | Event description / notes |
|
||||
| `--organizer` | No | Organizer display name (defaults to `--from`) |
|
||||
| `--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 |
|
||||
|
||||
### Send Examples
|
||||
|
||||
```bash
|
||||
# Simple invite (--from and --timezone default to youlu@luyanxin.com / LA)
|
||||
$SKILL_DIR/scripts/calendar.sh send \
|
||||
--to "alice@example.com" \
|
||||
--subject "Coffee Chat" \
|
||||
--summary "Coffee Chat" \
|
||||
--start "2026-03-25T10:00:00" \
|
||||
--end "2026-03-25T10:30:00"
|
||||
|
||||
# Multiple attendees with details
|
||||
$SKILL_DIR/scripts/calendar.sh send \
|
||||
--to "alice@example.com, bob@example.com" \
|
||||
--subject "Team Sync" \
|
||||
--summary "Weekly Team Sync" \
|
||||
--start "2026-03-23T09:00:00" \
|
||||
--end "2026-03-23T09:30:00" \
|
||||
--location "Zoom - https://zoom.us/j/123456" \
|
||||
--description "Weekly check-in. Agenda: updates, blockers, action items."
|
||||
|
||||
# Dry run
|
||||
$SKILL_DIR/scripts/calendar.sh send \
|
||||
--to "test@example.com" \
|
||||
--subject "Test" \
|
||||
--summary "Test Event" \
|
||||
--start "2026-04-01T15:00:00" \
|
||||
--end "2026-04-01T16:00:00" \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Replying to Invites
|
||||
|
||||
```bash
|
||||
# Accept by himalaya envelope ID
|
||||
$SKILL_DIR/scripts/calendar.sh reply \
|
||||
--envelope-id 42 \
|
||||
--action accept
|
||||
|
||||
# Decline with a comment
|
||||
$SKILL_DIR/scripts/calendar.sh reply \
|
||||
--envelope-id 42 \
|
||||
--action decline \
|
||||
--comment "Sorry, I have a conflict."
|
||||
|
||||
# From an .ics file
|
||||
$SKILL_DIR/scripts/calendar.sh reply \
|
||||
--ics-file ~/Downloads/meeting.ics \
|
||||
--action tentative
|
||||
```
|
||||
|
||||
### Reply Options
|
||||
|
||||
| Flag | Required | Description |
|
||||
|-----------------|----------|-----------------------------------------------------|
|
||||
| `--action` | Yes | `accept`, `decline`, or `tentative` |
|
||||
| `--envelope-id` | * | Himalaya envelope ID containing the .ics attachment |
|
||||
| `--ics-file` | * | Path to an .ics file (alternative to `--envelope-id`) |
|
||||
| `--from` | No | Your email (default: `youlu@luyanxin.com`) |
|
||||
| `--account` | No | Himalaya account name |
|
||||
| `--folder` | No | Himalaya folder (default: `INBOX`) |
|
||||
| `--comment` | No | Optional message to include in reply |
|
||||
| `--dry-run` | No | Preview without sending |
|
||||
|
||||
\* One of `--envelope-id` or `--ics-file` is required.
|
||||
|
||||
### Typical Workflow
|
||||
|
||||
1. List emails: `himalaya envelope list`
|
||||
2. Read the invite: `himalaya message read 57`
|
||||
3. Reply: `$SKILL_DIR/scripts/calendar.sh reply --envelope-id 57 --action accept`
|
||||
|
||||
---
|
||||
|
||||
## VTODO Tasks
|
||||
|
||||
Manage tasks as RFC 5545 VTODO components, stored in `~/.openclaw/workspace/calendars/tasks/` and synced to CalDAV.
|
||||
|
||||
### Sync Model
|
||||
|
||||
The agent's local CalDAV is the **source of truth** (no two-way sync). When a todo is created, it's saved locally and emailed to `mail@luyx.org` as a delivery copy. When the user completes a task, they tell the agent, and the agent runs `todo complete`. The daily `todo check` cron reads from local files.
|
||||
|
||||
### Priority Mapping (RFC 5545)
|
||||
|
||||
| Label | `--priority` | RFC 5545 value |
|
||||
|--------|-------------|----------------|
|
||||
| 高 (high) | `high` | 1 |
|
||||
| 中 (medium) | `medium` | 5 (default) |
|
||||
| 低 (low) | `low` | 9 |
|
||||
|
||||
### `todo add` — Create a Todo
|
||||
|
||||
```bash
|
||||
$SKILL_DIR/scripts/calendar.sh todo add \
|
||||
--summary "跟进iui保险报销" \
|
||||
--due "2026-03-25" \
|
||||
--priority high \
|
||||
--description "确认iui费用保险报销进度" \
|
||||
--alarm 1d
|
||||
```
|
||||
|
||||
| Flag | Required | Description |
|
||||
|-----------------|----------|-----------------------------------------------------|
|
||||
| `--summary` | Yes | Todo title |
|
||||
| `--due` | No | Due date, YYYY-MM-DD (default: tomorrow) |
|
||||
| `--priority` | No | `high`, `medium`, or `low` (default: `medium`) |
|
||||
| `--description` | No | Notes / description |
|
||||
| `--alarm` | No | Reminder trigger: `1d`, `2h`, `30m` (default: `1d`) |
|
||||
| `--dry-run` | No | Preview ICS + email without saving |
|
||||
|
||||
### `todo list` — List Todos
|
||||
|
||||
```bash
|
||||
$SKILL_DIR/scripts/calendar.sh todo list # pending only
|
||||
$SKILL_DIR/scripts/calendar.sh todo list --all # include completed
|
||||
```
|
||||
|
||||
### `todo complete` — Mark as Done
|
||||
|
||||
```bash
|
||||
$SKILL_DIR/scripts/calendar.sh todo complete --uid "abc123@openclaw"
|
||||
$SKILL_DIR/scripts/calendar.sh todo complete --match "保险报销"
|
||||
```
|
||||
|
||||
### `todo delete` — Remove a Todo
|
||||
|
||||
```bash
|
||||
$SKILL_DIR/scripts/calendar.sh todo delete --uid "abc123@openclaw"
|
||||
$SKILL_DIR/scripts/calendar.sh todo delete --match "保险报销"
|
||||
```
|
||||
|
||||
### `todo check` — Daily Digest (Cron)
|
||||
|
||||
```bash
|
||||
$SKILL_DIR/scripts/calendar.sh todo check
|
||||
```
|
||||
|
||||
Same as `todo list` but only NEEDS-ACTION items. Exits silently when no pending items. Output is designed for piping to himalaya.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
**Sending invites:**
|
||||
1. Generates an RFC 5545 ICS file with `METHOD:REQUEST` (via `icalendar` library)
|
||||
2. Builds a MIME email with a `text/calendar` attachment (via Python `email.mime`)
|
||||
3. Sends via `himalaya message send` (piped through stdin)
|
||||
4. Saves the event to `~/.openclaw/workspace/calendars/home/`
|
||||
5. Runs `vdirsyncer sync` to push to Migadu CalDAV
|
||||
|
||||
**Replying to invites:**
|
||||
1. Extracts the `.ics` attachment from the email (via `himalaya attachment download`)
|
||||
2. Parses the original event with the `icalendar` library
|
||||
3. Generates a reply ICS with `METHOD:REPLY` and the correct `PARTSTAT`
|
||||
4. Sends the reply to the organizer via `himalaya message send` (stdin)
|
||||
5. On accept/tentative: saves event to local calendar. On decline: removes it
|
||||
6. Runs `vdirsyncer sync` to push changes to Migadu CalDAV
|
||||
|
||||
**Managing todos:**
|
||||
1. Creates a VTODO ICS file with `STATUS:NEEDS-ACTION`, `PRIORITY`, `DUE`, `VALARM`
|
||||
2. Saves to `~/.openclaw/workspace/calendars/tasks/`
|
||||
3. Emails the VTODO to `mail@luyx.org` as a delivery copy
|
||||
4. Runs `vdirsyncer sync` to push to Migadu CalDAV
|
||||
5. `todo check` reads local files for the daily cron digest
|
||||
|
||||
**CalDAV sync:**
|
||||
- Events and tasks sync to Migadu and appear on all connected devices (DAVx5, etc.)
|
||||
- Heartbeat runs `vdirsyncer sync` periodically as a fallback
|
||||
- If sync fails, it warns but doesn't block — next heartbeat catches up
|
||||
|
||||
## Integration with Email Processor
|
||||
|
||||
The email processor (`scripts/email_processor/`) may classify incoming calendar invites as `reminder` or `confirmation`. When reviewing pending emails:
|
||||
1. Check if the email contains a calendar invite (look for `.ics` attachment or "calendar" in subject)
|
||||
2. If it does, use `reply` instead of the email processor's delete/archive/keep actions
|
||||
3. The email processor handles the email lifecycle; this skill handles the calendar response
|
||||
|
||||
## Checking the Calendar
|
||||
|
||||
```bash
|
||||
# List upcoming events (next 7 days)
|
||||
khal list today 7d
|
||||
|
||||
# List events for a specific date
|
||||
khal list 2026-03-25
|
||||
|
||||
# Check for conflicts before sending an invite
|
||||
khal list 2026-03-25 2026-03-26
|
||||
```
|
||||
|
||||
## Timezone Reference
|
||||
|
||||
Common IANA timezones:
|
||||
- `America/Los_Angeles` — Pacific (default)
|
||||
- `America/Denver` — Mountain
|
||||
- `America/Chicago` — Central
|
||||
- `America/New_York` — Eastern
|
||||
- `Asia/Shanghai` — China
|
||||
- `Asia/Tokyo` — Japan
|
||||
- `Europe/London` — UK
|
||||
- `UTC` — Coordinated Universal Time
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Invite shows as attachment instead of calendar event?**
|
||||
- Ensure the MIME part has `Content-Type: text/calendar; method=REQUEST`
|
||||
- Some clients require the `METHOD:REQUEST` line in the ICS body
|
||||
|
||||
**Times are wrong?**
|
||||
- Double-check `--timezone` matches the intended timezone
|
||||
- Use ISO 8601 format: `YYYY-MM-DDTHH:MM:SS` (no timezone offset in the value)
|
||||
|
||||
**Event not showing on phone/other devices?**
|
||||
- Run `vdirsyncer sync` manually to force sync
|
||||
- Check `~/.openclaw/workspace/logs/vdirsyncer.log` for errors
|
||||
- Verify the .ics file exists in `~/.openclaw/workspace/calendars/home/`
|
||||
|
||||
**Todos not syncing?**
|
||||
- Check that `~/.openclaw/workspace/calendars/tasks/` exists
|
||||
- Verify vdirsyncer has a `cal/tasks` pair configured
|
||||
- Run `vdirsyncer sync` manually
|
||||
|
||||
**Recipient doesn't see Accept/Decline?**
|
||||
- Gmail, Outlook, Apple Mail all support `text/calendar` method=REQUEST
|
||||
- Some webmail clients may vary
|
||||
293
skills/calendar/TESTING.md
Normal file
293
skills/calendar/TESTING.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# Testing the Calendar Skill
|
||||
|
||||
End-to-end tests for send, reply, todo, calendar sync, and local calendar. All commands use `--dry-run` first, then live.
|
||||
|
||||
```bash
|
||||
SKILL_DIR=~/.openclaw/workspace/skills/calendar
|
||||
|
||||
# Use a date 3 days from now for test events
|
||||
TEST_DATE=$(date -d "+3 days" +%Y-%m-%d)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Dry Run: Send Invite
|
||||
|
||||
Generates the ICS and MIME email without sending. Check that:
|
||||
- ICS has `METHOD:REQUEST`
|
||||
- MIME has `Content-Type: text/calendar; method=REQUEST`
|
||||
- `mail@luyx.org` appears as attendee (auto-added)
|
||||
- Times and timezone look correct
|
||||
|
||||
```bash
|
||||
$SKILL_DIR/scripts/calendar.sh send \
|
||||
--to "mail@luyx.org" \
|
||||
--subject "Test Invite" \
|
||||
--summary "Test Event" \
|
||||
--start "${TEST_DATE}T15:00:00" \
|
||||
--end "${TEST_DATE}T16:00:00" \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
## 2. Live Send: Self-Invite
|
||||
|
||||
Send a real invite to `mail@luyx.org` only (no confirmation needed per email rules).
|
||||
|
||||
```bash
|
||||
$SKILL_DIR/scripts/calendar.sh send \
|
||||
--to "mail@luyx.org" \
|
||||
--subject "Calendar Skill Test" \
|
||||
--summary "Calendar Skill Test" \
|
||||
--start "${TEST_DATE}T15:00:00" \
|
||||
--end "${TEST_DATE}T16:00:00" \
|
||||
--location "Test Location"
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
- [ ] Script exits without error
|
||||
- [ ] Email arrives at `mail@luyx.org`
|
||||
- [ ] Email shows Accept/Decline/Tentative buttons (not just an attachment)
|
||||
- [ ] `.ics` file saved to `~/.openclaw/workspace/calendars/home/`
|
||||
|
||||
## 3. Verify Calendar Sync and Local Calendar
|
||||
|
||||
After sending in step 2, check that the event synced and appears locally.
|
||||
|
||||
```bash
|
||||
# Check vdirsyncer sync ran (should have printed "Synced to CalDAV server" in step 2)
|
||||
# If not, run manually:
|
||||
vdirsyncer sync
|
||||
|
||||
# List .ics files in local calendar
|
||||
ls ~/.openclaw/workspace/calendars/home/
|
||||
|
||||
# Check the event shows up in khal
|
||||
khal list "$TEST_DATE"
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
- [ ] `vdirsyncer sync` completes without errors
|
||||
- [ ] `.ics` file exists in `~/.openclaw/workspace/calendars/home/`
|
||||
- [ ] `khal list` shows "Calendar Skill Test" on the test date
|
||||
|
||||
## 4. Reply: Accept the Self-Invite
|
||||
|
||||
The invite sent in step 2 should be in the inbox. Find it, then accept it. This tests the full reply flow without needing an external sender.
|
||||
|
||||
```bash
|
||||
# Find the test invite in inbox
|
||||
himalaya envelope list
|
||||
|
||||
# Confirm it's the calendar invite
|
||||
himalaya message read <envelope-id>
|
||||
|
||||
# Accept it
|
||||
$SKILL_DIR/scripts/calendar.sh reply \
|
||||
--envelope-id <envelope-id> \
|
||||
--action accept
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
- [ ] Reply sent to organizer (youlu@luyanxin.com, i.e. ourselves)
|
||||
- [ ] Original invite forwarded to `mail@luyx.org`
|
||||
- [ ] Event still in `~/.openclaw/workspace/calendars/home/`
|
||||
- [ ] `vdirsyncer sync` ran
|
||||
- [ ] `khal list "$TEST_DATE"` still shows the event
|
||||
|
||||
## 5. Reply: Decline an Invite
|
||||
|
||||
Send another self-invite, then decline it. This verifies decline removes the event from local calendar.
|
||||
|
||||
```bash
|
||||
# Send a second test invite
|
||||
$SKILL_DIR/scripts/calendar.sh send \
|
||||
--to "mail@luyx.org" \
|
||||
--subject "Decline Test" \
|
||||
--summary "Decline Test Event" \
|
||||
--start "${TEST_DATE}T17:00:00" \
|
||||
--end "${TEST_DATE}T18:00:00"
|
||||
|
||||
# Find it in inbox
|
||||
himalaya envelope list
|
||||
|
||||
# Decline it
|
||||
$SKILL_DIR/scripts/calendar.sh reply \
|
||||
--envelope-id <envelope-id> \
|
||||
--action decline \
|
||||
--comment "Testing decline flow."
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
- [ ] Reply sent to organizer with comment
|
||||
- [ ] Event NOT forwarded to `mail@luyx.org`
|
||||
- [ ] Event removed from local calendar
|
||||
- [ ] `khal list "$TEST_DATE"` does NOT show "Decline Test Event"
|
||||
|
||||
## 6. Verify Final Calendar State
|
||||
|
||||
After all tests, confirm the calendar is in a clean state.
|
||||
|
||||
```bash
|
||||
# Sync one more time
|
||||
vdirsyncer sync
|
||||
|
||||
# Only the accepted event should remain
|
||||
khal list "$TEST_DATE"
|
||||
|
||||
# List all upcoming events
|
||||
khal list today 7d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Dry Run: Add Todo
|
||||
|
||||
Generates the VTODO ICS and MIME email without saving. Check that:
|
||||
- ICS has `BEGIN:VTODO`
|
||||
- ICS has correct `PRIORITY` value (1 for high)
|
||||
- ICS has `STATUS:NEEDS-ACTION`
|
||||
- ICS has `BEGIN:VALARM`
|
||||
- MIME has `Content-Type: text/calendar`
|
||||
|
||||
```bash
|
||||
$SKILL_DIR/scripts/calendar.sh todo add \
|
||||
--summary "Test Todo" \
|
||||
--due "$TEST_DATE" \
|
||||
--priority high \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
## 8. Live Add: Create a Todo
|
||||
|
||||
```bash
|
||||
$SKILL_DIR/scripts/calendar.sh todo add \
|
||||
--summary "Test Todo" \
|
||||
--due "$TEST_DATE" \
|
||||
--priority medium \
|
||||
--description "Test description"
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
- [ ] Script exits without error
|
||||
- [ ] `.ics` file created in `~/.openclaw/workspace/calendars/tasks/`
|
||||
- [ ] Email arrives at `mail@luyx.org` with .ics attachment
|
||||
- [ ] `vdirsyncer sync` ran
|
||||
|
||||
## 9. List Todos
|
||||
|
||||
```bash
|
||||
# List pending todos
|
||||
$SKILL_DIR/scripts/calendar.sh todo list
|
||||
|
||||
# List all (including completed)
|
||||
$SKILL_DIR/scripts/calendar.sh todo list --all
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
- [ ] "Test Todo" appears with correct urgency label
|
||||
- [ ] Priority grouping is correct
|
||||
- [ ] `--all` flag works (same output when none are completed)
|
||||
|
||||
## 10. Complete a Todo
|
||||
|
||||
```bash
|
||||
$SKILL_DIR/scripts/calendar.sh todo complete --match "Test Todo"
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
- [ ] .ics file updated with `STATUS:COMPLETED` and `COMPLETED:` timestamp
|
||||
- [ ] `todo list` — "Test Todo" no longer appears
|
||||
- [ ] `todo list --all` — "Test Todo" appears as completed (with checkmark)
|
||||
- [ ] `vdirsyncer sync` ran
|
||||
|
||||
## 11. Delete a Todo
|
||||
|
||||
Create a second test todo, then delete it.
|
||||
|
||||
```bash
|
||||
# Create
|
||||
$SKILL_DIR/scripts/calendar.sh todo add \
|
||||
--summary "Delete Me Todo" \
|
||||
--due "$TEST_DATE" \
|
||||
--priority low
|
||||
|
||||
# Delete
|
||||
$SKILL_DIR/scripts/calendar.sh todo delete --match "Delete Me"
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
- [ ] .ics file removed from tasks dir
|
||||
- [ ] `todo list` does not show "Delete Me Todo"
|
||||
- [ ] `vdirsyncer sync` ran
|
||||
|
||||
## 12. Todo Check (Cron Output)
|
||||
|
||||
```bash
|
||||
# Create a test todo
|
||||
$SKILL_DIR/scripts/calendar.sh todo add \
|
||||
--summary "Check Test Todo" \
|
||||
--due "$TEST_DATE"
|
||||
|
||||
# Run check
|
||||
$SKILL_DIR/scripts/calendar.sh todo check
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
- [ ] Output matches daily digest format (priority groups, urgency labels)
|
||||
- [ ] Complete the todo, run `todo check` again — silent exit (no output)
|
||||
|
||||
```bash
|
||||
$SKILL_DIR/scripts/calendar.sh todo complete --match "Check Test"
|
||||
$SKILL_DIR/scripts/calendar.sh todo check
|
||||
# Should produce no output
|
||||
```
|
||||
|
||||
## 13. Regression: Existing Invite Commands
|
||||
|
||||
Verify the rename didn't break VEVENT flow.
|
||||
|
||||
```bash
|
||||
$SKILL_DIR/scripts/calendar.sh send \
|
||||
--to "test@example.com" \
|
||||
--subject "Regression Test" \
|
||||
--summary "Regression Test Event" \
|
||||
--start "${TEST_DATE}T10:00:00" \
|
||||
--end "${TEST_DATE}T11:00:00" \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
- [ ] ICS has `BEGIN:VEVENT`, `METHOD:REQUEST`
|
||||
- [ ] No errors from the renamed script
|
||||
|
||||
---
|
||||
|
||||
## Quick Health Checks
|
||||
|
||||
Run these first if any step fails.
|
||||
|
||||
```bash
|
||||
# icalendar library is available
|
||||
uv run --project $SKILL_DIR python -c "import icalendar; print('ok')"
|
||||
|
||||
# himalaya can list emails
|
||||
himalaya envelope list --page-size 5
|
||||
|
||||
# vdirsyncer can sync
|
||||
vdirsyncer sync
|
||||
|
||||
# khal can read local calendar
|
||||
khal list today 7d
|
||||
```
|
||||
|
||||
## Common Failures
|
||||
|
||||
| Symptom | Likely Cause |
|
||||
|---------|-------------|
|
||||
| `himalaya message send` errors | SMTP config issue, check `~/.config/himalaya/config.toml` |
|
||||
| No `.ics` attachment found | Email doesn't have a calendar invite, or himalaya can't download attachments |
|
||||
| `vdirsyncer sync` fails | Check credentials in `~/.config/vdirsyncer/config`, or server is unreachable |
|
||||
| `ModuleNotFoundError: icalendar` | Run `uv sync --project $SKILL_DIR` to install dependencies |
|
||||
| Invite shows as attachment (no Accept/Decline) | Check MIME `Content-Type` includes `method=REQUEST` |
|
||||
| Event not in `khal list` after sync | Check `.ics` file exists in `~/.openclaw/workspace/calendars/home/` |
|
||||
| Todo not syncing | Check `~/.openclaw/workspace/calendars/tasks/` exists, verify vdirsyncer `cal/tasks` pair |
|
||||
5
skills/calendar/_meta.json
Normal file
5
skills/calendar/_meta.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"ownerId": "kn7anq2d7gcch060anc2j9cg89800dyv",
|
||||
"slug": "calendar",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
5
skills/calendar/pyproject.toml
Normal file
5
skills/calendar/pyproject.toml
Normal file
@@ -0,0 +1,5 @@
|
||||
[project]
|
||||
name = "calendar"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["icalendar"]
|
||||
820
skills/calendar/scripts/cal_tool.py
Normal file
820
skills/calendar/scripts/cal_tool.py
Normal file
@@ -0,0 +1,820 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Calendar — Send/reply to calendar invites and manage VTODO tasks via CalDAV.
|
||||
|
||||
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 reply [options] # accept/decline/tentative
|
||||
python calendar.py todo add [options] # create a VTODO task
|
||||
python calendar.py todo list [options] # list pending tasks
|
||||
python calendar.py todo complete [options] # mark task as done
|
||||
python calendar.py todo delete [options] # remove a task
|
||||
python calendar.py todo check # daily digest for cron
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_TIMEZONE = "America/Los_Angeles"
|
||||
DEFAULT_FROM = "youlu@luyanxin.com"
|
||||
DEFAULT_OWNER_EMAIL = "mail@luyx.org" # Always added as attendee
|
||||
CALENDAR_DIR = Path.home() / ".openclaw" / "workspace" / "calendars" / "home"
|
||||
TASKS_DIR = Path.home() / ".openclaw" / "workspace" / "calendars" / "tasks"
|
||||
PRODID = "-//OpenClaw//Calendar//EN"
|
||||
|
||||
# RFC 5545 priority mapping
|
||||
PRIORITY_MAP = {"high": 1, "medium": 5, "low": 9}
|
||||
PRIORITY_LABELS = {1: "高", 5: "中", 9: "低"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _sync_calendar():
|
||||
"""Sync local calendar to CalDAV server via vdirsyncer."""
|
||||
try:
|
||||
subprocess.run(
|
||||
["vdirsyncer", "sync"],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
print("Synced to CalDAV server")
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
print("Warning: CalDAV sync failed (will retry on next heartbeat)")
|
||||
|
||||
|
||||
def _send_email(email_str, account=None):
|
||||
"""Send a raw MIME email via himalaya message send (stdin)."""
|
||||
cmd = ["himalaya"]
|
||||
if account:
|
||||
cmd += ["--account", account]
|
||||
cmd += ["message", "send"]
|
||||
subprocess.run(cmd, input=email_str, text=True, check=True)
|
||||
|
||||
|
||||
def _build_calendar_email(from_addr, to_addr, subject, body, ics_bytes, method="REQUEST"):
|
||||
"""Build a MIME email with a text/calendar attachment."""
|
||||
msg = MIMEMultipart('mixed')
|
||||
msg['From'] = from_addr
|
||||
msg['To'] = to_addr
|
||||
msg['Subject'] = subject
|
||||
msg.attach(MIMEText(body, 'plain', 'utf-8'))
|
||||
ics_part = MIMEBase('text', 'calendar', method=method, charset='utf-8')
|
||||
ics_part.set_payload(ics_bytes.decode('utf-8'))
|
||||
ics_part.add_header('Content-Disposition', 'attachment; filename="invite.ics"')
|
||||
msg.attach(ics_part)
|
||||
return msg.as_string()
|
||||
|
||||
|
||||
def _strip_method(ics_bytes):
|
||||
"""Remove METHOD property from ICS for CalDAV storage.
|
||||
|
||||
CalDAV servers reject METHOD (it's an iTIP/email concept, not a storage one).
|
||||
"""
|
||||
cal = Calendar.from_ical(ics_bytes)
|
||||
if "method" in cal:
|
||||
del cal["method"]
|
||||
return cal.to_ical()
|
||||
|
||||
|
||||
def _parse_iso_datetime(dt_str):
|
||||
"""Parse ISO 8601 datetime string to a datetime object."""
|
||||
# Handle both 2026-03-20T14:00:00 and 2026-03-20T14:00
|
||||
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"):
|
||||
try:
|
||||
return datetime.strptime(dt_str, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
raise ValueError(f"Cannot parse datetime: {dt_str}")
|
||||
|
||||
|
||||
def _parse_date(date_str):
|
||||
"""Parse YYYY-MM-DD date string."""
|
||||
return datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Send invite
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_send(args):
|
||||
"""Create and send a calendar invite."""
|
||||
start = _parse_iso_datetime(args.start)
|
||||
end = _parse_iso_datetime(args.end)
|
||||
uid = args.uid or f"{uuid.uuid4()}@openclaw"
|
||||
organizer_name = args.organizer or args.sender
|
||||
|
||||
# Build ICS
|
||||
cal = Calendar()
|
||||
cal.add("prodid", PRODID)
|
||||
cal.add("version", "2.0")
|
||||
cal.add("calscale", "GREGORIAN")
|
||||
cal.add("method", "REQUEST")
|
||||
|
||||
event = Event()
|
||||
event.add("uid", uid)
|
||||
event.add("dtstamp", datetime.now(timezone.utc))
|
||||
event.add("dtstart", start, parameters={"TZID": args.timezone})
|
||||
event.add("dtend", end, parameters={"TZID": args.timezone})
|
||||
event.add("summary", args.summary)
|
||||
event.add("status", "CONFIRMED")
|
||||
event.add("sequence", 0)
|
||||
organizer = vCalAddress(f"mailto:{args.sender}")
|
||||
organizer.params["CN"] = vText(organizer_name)
|
||||
event.add("organizer", organizer)
|
||||
|
||||
if args.location:
|
||||
event.add("location", args.location)
|
||||
if args.description:
|
||||
event.add("description", args.description)
|
||||
|
||||
recipients = [addr.strip() for addr in args.to.split(",")]
|
||||
|
||||
# Always include owner as attendee
|
||||
all_attendees = list(recipients)
|
||||
if DEFAULT_OWNER_EMAIL not in all_attendees:
|
||||
all_attendees.append(DEFAULT_OWNER_EMAIL)
|
||||
|
||||
for addr in all_attendees:
|
||||
event.add("attendee", f"mailto:{addr}", parameters={
|
||||
"ROLE": "REQ-PARTICIPANT",
|
||||
"RSVP": "TRUE",
|
||||
})
|
||||
|
||||
# 1-day reminder
|
||||
alarm = Alarm()
|
||||
alarm.add("action", "DISPLAY")
|
||||
alarm.add("description", f"Reminder: {args.summary}")
|
||||
alarm.add("trigger", timedelta(days=-1))
|
||||
event.add_component(alarm)
|
||||
|
||||
cal.add_component(event)
|
||||
ics_bytes = cal.to_ical()
|
||||
|
||||
# Build plain text body
|
||||
body = f"You're invited to: {args.summary}\n\nWhen: {args.start} - {args.end} ({args.timezone})"
|
||||
if args.location:
|
||||
body += f"\nWhere: {args.location}"
|
||||
if args.description:
|
||||
body += f"\n\n{args.description}"
|
||||
|
||||
# Email goes to all attendees (including owner)
|
||||
all_to = ", ".join(all_attendees)
|
||||
|
||||
# Build MIME email
|
||||
email_str = _build_calendar_email(args.sender, all_to, args.subject, body, ics_bytes, method="REQUEST")
|
||||
|
||||
if args.dry_run:
|
||||
print("=== ICS Content ===")
|
||||
print(ics_bytes.decode())
|
||||
print("=== Email Message ===")
|
||||
print(email_str)
|
||||
return
|
||||
|
||||
# Send email via himalaya message send (stdin)
|
||||
_send_email(email_str, args.account)
|
||||
print(f"Calendar invite sent to: {args.to}")
|
||||
|
||||
# Save to local calendar (without METHOD for CalDAV compatibility)
|
||||
if CALENDAR_DIR.is_dir():
|
||||
dest = CALENDAR_DIR / f"{uid}.ics"
|
||||
dest.write_bytes(_strip_method(ics_bytes))
|
||||
print(f"Saved to local calendar: {dest}")
|
||||
_sync_calendar()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reply to invite
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PARTSTAT_MAP = {
|
||||
"accept": "ACCEPTED",
|
||||
"accepted": "ACCEPTED",
|
||||
"decline": "DECLINED",
|
||||
"declined": "DECLINED",
|
||||
"tentative": "TENTATIVE",
|
||||
}
|
||||
|
||||
SUBJECT_PREFIX = {
|
||||
"ACCEPTED": "Accepted",
|
||||
"DECLINED": "Declined",
|
||||
"TENTATIVE": "Tentative",
|
||||
}
|
||||
|
||||
|
||||
def _extract_ics_from_email(envelope_id, folder, account):
|
||||
"""Download attachments from an email and find the .ics file."""
|
||||
download_dir = Path(f"/tmp/openclaw-ics-extract-{envelope_id}")
|
||||
download_dir.mkdir(exist_ok=True)
|
||||
|
||||
cmd = ["himalaya"]
|
||||
if account:
|
||||
cmd += ["--account", account]
|
||||
cmd += ["attachment", "download", "--folder", folder, str(envelope_id), "--dir", str(download_dir)]
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
except subprocess.CalledProcessError:
|
||||
pass # some emails have no attachments
|
||||
|
||||
ics_files = list(download_dir.glob("*.ics"))
|
||||
if not ics_files:
|
||||
print(f"Error: No .ics attachment found in envelope {envelope_id}", file=sys.stderr)
|
||||
# Cleanup
|
||||
for f in download_dir.iterdir():
|
||||
f.unlink()
|
||||
download_dir.rmdir()
|
||||
sys.exit(1)
|
||||
|
||||
return ics_files[0], download_dir
|
||||
|
||||
|
||||
def cmd_reply(args):
|
||||
"""Accept, decline, or tentatively accept a calendar invite."""
|
||||
partstat = PARTSTAT_MAP.get(args.action.lower())
|
||||
if not partstat:
|
||||
print(f"Error: --action must be accept, decline, or tentative", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Get the ICS file
|
||||
cleanup_dir = None
|
||||
if args.envelope_id:
|
||||
ics_path, cleanup_dir = _extract_ics_from_email(args.envelope_id, args.folder, args.account)
|
||||
elif args.ics_file:
|
||||
ics_path = Path(args.ics_file)
|
||||
if not ics_path.is_file():
|
||||
print(f"Error: ICS file not found: {ics_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("Error: --envelope-id or --ics-file is required", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Parse original ICS
|
||||
original_cal = Calendar.from_ical(ics_path.read_bytes())
|
||||
|
||||
# Find the VEVENT
|
||||
original_event = None
|
||||
for component in original_cal.walk():
|
||||
if component.name == "VEVENT":
|
||||
original_event = component
|
||||
break
|
||||
|
||||
if not original_event:
|
||||
print("Error: No VEVENT found in ICS file", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Extract fields from original
|
||||
uid = str(original_event.get("uid", ""))
|
||||
summary = str(original_event.get("summary", ""))
|
||||
organizer = original_event.get("organizer")
|
||||
|
||||
if not organizer:
|
||||
print("Error: No ORGANIZER found in ICS", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
organizer_email = str(organizer).replace("mailto:", "").replace("MAILTO:", "")
|
||||
|
||||
# Build reply calendar
|
||||
reply_cal = Calendar()
|
||||
reply_cal.add("prodid", PRODID)
|
||||
reply_cal.add("version", "2.0")
|
||||
reply_cal.add("calscale", "GREGORIAN")
|
||||
reply_cal.add("method", "REPLY")
|
||||
|
||||
reply_event = Event()
|
||||
reply_event.add("uid", uid)
|
||||
reply_event.add("dtstamp", datetime.now(timezone.utc))
|
||||
|
||||
# Copy timing from original
|
||||
if original_event.get("dtstart"):
|
||||
reply_event["dtstart"] = original_event["dtstart"]
|
||||
if original_event.get("dtend"):
|
||||
reply_event["dtend"] = original_event["dtend"]
|
||||
|
||||
reply_event.add("summary", summary)
|
||||
reply_event["organizer"] = original_event["organizer"]
|
||||
reply_event.add("attendee", f"mailto:{args.sender}", parameters={
|
||||
"PARTSTAT": partstat,
|
||||
"RSVP": "FALSE",
|
||||
})
|
||||
|
||||
if original_event.get("sequence"):
|
||||
reply_event.add("sequence", original_event.get("sequence"))
|
||||
|
||||
reply_cal.add_component(reply_event)
|
||||
reply_ics_bytes = reply_cal.to_ical()
|
||||
|
||||
# Build email
|
||||
prefix = SUBJECT_PREFIX[partstat]
|
||||
subject = f"{prefix}: {summary}"
|
||||
|
||||
body = f"{prefix}: {summary}"
|
||||
if args.comment:
|
||||
body += f"\n\n{args.comment}"
|
||||
|
||||
email_str = _build_calendar_email(args.sender, organizer_email, subject, body, reply_ics_bytes, method="REPLY")
|
||||
|
||||
if args.dry_run:
|
||||
print("=== Original Event ===")
|
||||
print(f"Summary: {summary}")
|
||||
print(f"Organizer: {organizer_email}")
|
||||
print(f"Action: {partstat}")
|
||||
print()
|
||||
print("=== Reply ICS ===")
|
||||
print(reply_ics_bytes.decode())
|
||||
print("=== Email Message ===")
|
||||
print(email_str)
|
||||
if cleanup_dir:
|
||||
for f in cleanup_dir.iterdir():
|
||||
f.unlink()
|
||||
cleanup_dir.rmdir()
|
||||
return
|
||||
|
||||
# Send reply
|
||||
_send_email(email_str, args.account)
|
||||
print(f"Calendar invite {partstat.lower()}: {summary} (replied to {organizer_email})")
|
||||
|
||||
# Forward invite to owner on accept/tentative
|
||||
if partstat in ("ACCEPTED", "TENTATIVE"):
|
||||
fwd_body = f"{prefix}: {summary}"
|
||||
fwd_email = _build_calendar_email(
|
||||
args.sender, DEFAULT_OWNER_EMAIL,
|
||||
f"{prefix}: {summary}", fwd_body,
|
||||
ics_path.read_bytes(), method="REQUEST",
|
||||
)
|
||||
try:
|
||||
_send_email(fwd_email, args.account)
|
||||
print(f"Forwarded invite to {DEFAULT_OWNER_EMAIL}")
|
||||
except subprocess.CalledProcessError:
|
||||
print(f"Warning: Failed to forward invite to {DEFAULT_OWNER_EMAIL}")
|
||||
|
||||
# Save to / remove from local calendar
|
||||
if CALENDAR_DIR.is_dir():
|
||||
dest = CALENDAR_DIR / f"{uid}.ics"
|
||||
if partstat in ("ACCEPTED", "TENTATIVE"):
|
||||
# Save the original event to local calendar (without METHOD for CalDAV)
|
||||
dest.write_bytes(_strip_method(ics_path.read_bytes()))
|
||||
print(f"Saved to local calendar: {dest}")
|
||||
elif partstat == "DECLINED" and dest.is_file():
|
||||
dest.unlink()
|
||||
print("Removed from local calendar")
|
||||
_sync_calendar()
|
||||
|
||||
# Cleanup
|
||||
if cleanup_dir:
|
||||
for f in cleanup_dir.iterdir():
|
||||
f.unlink()
|
||||
cleanup_dir.rmdir()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VTODO: helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_todos():
|
||||
"""Load all VTODO items from TASKS_DIR. Returns list of (path, vtodo) tuples."""
|
||||
if not TASKS_DIR.is_dir():
|
||||
return []
|
||||
todos = []
|
||||
for ics_path in TASKS_DIR.glob("*.ics"):
|
||||
try:
|
||||
cal = Calendar.from_ical(ics_path.read_bytes())
|
||||
for component in cal.walk():
|
||||
if component.name == "VTODO":
|
||||
todos.append((ics_path, component))
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
return todos
|
||||
|
||||
|
||||
def _get_due_date(vtodo):
|
||||
"""Extract due date from a VTODO as a date object, or None."""
|
||||
due = vtodo.get("due")
|
||||
if due is None:
|
||||
return None
|
||||
dt = due.dt
|
||||
if isinstance(dt, datetime):
|
||||
return dt.date()
|
||||
return dt
|
||||
|
||||
|
||||
def _get_priority_int(vtodo):
|
||||
"""Get priority as int (1=high, 5=medium, 9=low). Default 5."""
|
||||
p = vtodo.get("priority")
|
||||
if p is None:
|
||||
return 5
|
||||
return int(p)
|
||||
|
||||
|
||||
def _days_until(due_date):
|
||||
"""Days from today until due_date. Negative means overdue."""
|
||||
if due_date is None:
|
||||
return None
|
||||
return (due_date - date.today()).days
|
||||
|
||||
|
||||
def _urgency_label(days):
|
||||
"""Urgency label with emoji, matching reminder_check.py style."""
|
||||
if days is None:
|
||||
return "❓ 日期未知"
|
||||
elif days < 0:
|
||||
return f"🔴 逾期 {-days} 天"
|
||||
elif days == 0:
|
||||
return "🔴 今天"
|
||||
elif days == 1:
|
||||
return "🟡 明天"
|
||||
elif days <= 3:
|
||||
return f"🟡 {days} 天后"
|
||||
else:
|
||||
return f"🟢 {days} 天后"
|
||||
|
||||
|
||||
def _find_todo_by_match(todos, match_str):
|
||||
"""Find a single todo by fuzzy match on SUMMARY. Exits on 0 or >1 matches."""
|
||||
matches = []
|
||||
for path, vtodo in todos:
|
||||
summary = str(vtodo.get("summary", ""))
|
||||
if match_str in summary:
|
||||
matches.append((path, vtodo))
|
||||
if not matches:
|
||||
print(f"Error: No todo matching '{match_str}'", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if len(matches) > 1:
|
||||
print(f"Error: Multiple todos match '{match_str}':", file=sys.stderr)
|
||||
for _, vt in matches:
|
||||
print(f" - {vt.get('summary')} (UID: {vt.get('uid')})", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _find_todo_by_uid(todos, uid):
|
||||
"""Find a todo by UID. Exits if not found."""
|
||||
for path, vtodo in todos:
|
||||
if str(vtodo.get("uid", "")) == uid:
|
||||
return path, vtodo
|
||||
print(f"Error: No todo with UID '{uid}'", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _resolve_todo(args, todos):
|
||||
"""Resolve a todo from --uid or --match args."""
|
||||
if args.uid:
|
||||
return _find_todo_by_uid(todos, args.uid)
|
||||
elif args.match:
|
||||
return _find_todo_by_match(todos, args.match)
|
||||
else:
|
||||
print("Error: --uid or --match is required", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VTODO: subcommands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cmd_todo_add(args):
|
||||
"""Create a VTODO and save to TASKS_DIR."""
|
||||
TASKS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
uid = f"{uuid.uuid4()}@openclaw"
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Parse due date (default: tomorrow)
|
||||
if args.due:
|
||||
due_date = _parse_date(args.due)
|
||||
else:
|
||||
due_date = date.today() + timedelta(days=1)
|
||||
|
||||
# Parse priority
|
||||
priority = PRIORITY_MAP.get(args.priority, 5)
|
||||
|
||||
# Parse alarm trigger
|
||||
alarm_trigger = timedelta(days=-1) # default: 1 day before
|
||||
if args.alarm:
|
||||
alarm_str = args.alarm
|
||||
if alarm_str.endswith("d"):
|
||||
alarm_trigger = timedelta(days=-int(alarm_str[:-1]))
|
||||
elif alarm_str.endswith("h"):
|
||||
alarm_trigger = timedelta(hours=-int(alarm_str[:-1]))
|
||||
elif alarm_str.endswith("m"):
|
||||
alarm_trigger = timedelta(minutes=-int(alarm_str[:-1]))
|
||||
|
||||
# Build VTODO calendar
|
||||
cal = Calendar()
|
||||
cal.add("prodid", PRODID)
|
||||
cal.add("version", "2.0")
|
||||
cal.add("method", "REQUEST")
|
||||
|
||||
todo = Todo()
|
||||
todo.add("uid", uid)
|
||||
todo.add("dtstamp", now)
|
||||
todo.add("created", now)
|
||||
todo.add("summary", args.summary)
|
||||
todo.add("due", due_date)
|
||||
todo.add("priority", priority)
|
||||
todo.add("status", "NEEDS-ACTION")
|
||||
|
||||
if args.description:
|
||||
todo.add("description", args.description)
|
||||
|
||||
# VALARM reminder
|
||||
alarm = Alarm()
|
||||
alarm.add("action", "DISPLAY")
|
||||
alarm.add("description", f"Todo: {args.summary}")
|
||||
alarm.add("trigger", alarm_trigger)
|
||||
todo.add_component(alarm)
|
||||
|
||||
cal.add_component(todo)
|
||||
ics_bytes = cal.to_ical()
|
||||
|
||||
# Build email body
|
||||
prio_label = PRIORITY_LABELS.get(priority, "中")
|
||||
body = f"待办事项: {args.summary}\n截止日期: {due_date}\n优先级: {prio_label}"
|
||||
if args.description:
|
||||
body += f"\n\n{args.description}"
|
||||
|
||||
# Build MIME email
|
||||
email_str = _build_calendar_email(
|
||||
DEFAULT_FROM, DEFAULT_OWNER_EMAIL,
|
||||
f"📋 待办: {args.summary}",
|
||||
body, ics_bytes, method="REQUEST",
|
||||
)
|
||||
|
||||
if args.dry_run:
|
||||
print("=== ICS Content ===")
|
||||
print(ics_bytes.decode())
|
||||
print("=== Email Message ===")
|
||||
print(email_str)
|
||||
return
|
||||
|
||||
# Save to TASKS_DIR (without METHOD for CalDAV)
|
||||
dest = TASKS_DIR / f"{uid}.ics"
|
||||
dest.write_bytes(_strip_method(ics_bytes))
|
||||
print(f"Todo created: {args.summary} (due: {due_date}, priority: {prio_label})")
|
||||
print(f"Saved to: {dest}")
|
||||
|
||||
# Sync
|
||||
_sync_calendar()
|
||||
|
||||
# Email the VTODO to owner
|
||||
try:
|
||||
_send_email(email_str)
|
||||
print(f"Emailed todo to {DEFAULT_OWNER_EMAIL}")
|
||||
except subprocess.CalledProcessError:
|
||||
print(f"Warning: Failed to email todo to {DEFAULT_OWNER_EMAIL}")
|
||||
|
||||
|
||||
def cmd_todo_list(args):
|
||||
"""List todos, optionally including completed ones."""
|
||||
todos = _load_todos()
|
||||
if not todos:
|
||||
print("No todos found.")
|
||||
return
|
||||
|
||||
# Filter
|
||||
if not args.all:
|
||||
todos = [(p, vt) for p, vt in todos
|
||||
if str(vt.get("status", "NEEDS-ACTION")) in ("NEEDS-ACTION", "IN-PROCESS")]
|
||||
|
||||
if not todos:
|
||||
print("No pending todos.")
|
||||
return
|
||||
|
||||
# Sort by priority then due date
|
||||
def sort_key(item):
|
||||
_, vt = item
|
||||
prio = _get_priority_int(vt)
|
||||
due = _get_due_date(vt)
|
||||
return (prio, due or date.max)
|
||||
|
||||
todos.sort(key=sort_key)
|
||||
|
||||
# Format output
|
||||
today_str = date.today().isoformat()
|
||||
print(f"📋 待办事项 ({today_str})")
|
||||
print("=" * 50)
|
||||
|
||||
# Group by priority
|
||||
groups = {1: [], 5: [], 9: []}
|
||||
for path, vt in todos:
|
||||
prio = _get_priority_int(vt)
|
||||
# Bucket into nearest standard priority
|
||||
if prio <= 3:
|
||||
groups[1].append((path, vt))
|
||||
elif prio <= 7:
|
||||
groups[5].append((path, vt))
|
||||
else:
|
||||
groups[9].append((path, vt))
|
||||
|
||||
for prio, emoji, label in [(1, "🔴", "高优先级"), (5, "🟡", "中优先级"), (9, "🟢", "低优先级")]:
|
||||
items = groups[prio]
|
||||
if not items:
|
||||
continue
|
||||
print(f"\n{emoji} {label}:")
|
||||
for _, vt in items:
|
||||
summary = str(vt.get("summary", ""))
|
||||
due = _get_due_date(vt)
|
||||
days = _days_until(due)
|
||||
urgency = _urgency_label(days)
|
||||
status = str(vt.get("status", ""))
|
||||
desc = str(vt.get("description", ""))
|
||||
|
||||
line = f" • {summary} ({urgency})"
|
||||
if status == "COMPLETED":
|
||||
line = f" • ✅ {summary} (已完成)"
|
||||
if desc:
|
||||
line += f" | {desc}"
|
||||
print(line)
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
|
||||
|
||||
def cmd_todo_complete(args):
|
||||
"""Mark a todo as COMPLETED."""
|
||||
todos = _load_todos()
|
||||
path, vtodo = _resolve_todo(args, todos)
|
||||
|
||||
# Read, modify, rewrite
|
||||
cal = Calendar.from_ical(path.read_bytes())
|
||||
for component in cal.walk():
|
||||
if component.name == "VTODO":
|
||||
component["status"] = "COMPLETED"
|
||||
component.add("completed", datetime.now(timezone.utc))
|
||||
break
|
||||
|
||||
path.write_bytes(cal.to_ical())
|
||||
summary = str(vtodo.get("summary", ""))
|
||||
print(f"Completed: {summary}")
|
||||
_sync_calendar()
|
||||
|
||||
|
||||
def cmd_todo_delete(args):
|
||||
"""Delete a todo .ics file."""
|
||||
todos = _load_todos()
|
||||
path, vtodo = _resolve_todo(args, todos)
|
||||
|
||||
summary = str(vtodo.get("summary", ""))
|
||||
path.unlink()
|
||||
print(f"Deleted: {summary}")
|
||||
_sync_calendar()
|
||||
|
||||
|
||||
def cmd_todo_check(args):
|
||||
"""Daily digest of pending todos (for cron). Silent when empty."""
|
||||
todos = _load_todos()
|
||||
# Filter to NEEDS-ACTION only
|
||||
pending = [(p, vt) for p, vt in todos
|
||||
if str(vt.get("status", "NEEDS-ACTION")) in ("NEEDS-ACTION", "IN-PROCESS")]
|
||||
|
||||
if not pending:
|
||||
return # silent exit
|
||||
|
||||
# Sort by priority then due date
|
||||
def sort_key(item):
|
||||
_, vt = item
|
||||
prio = _get_priority_int(vt)
|
||||
due = _get_due_date(vt)
|
||||
return (prio, due or date.max)
|
||||
|
||||
pending.sort(key=sort_key)
|
||||
|
||||
# Format output (same style as todo list but without footer)
|
||||
today_str = date.today().isoformat()
|
||||
print(f"📋 待办事项 ({today_str})")
|
||||
print("=" * 50)
|
||||
|
||||
groups = {1: [], 5: [], 9: []}
|
||||
for path, vt in pending:
|
||||
prio = _get_priority_int(vt)
|
||||
if prio <= 3:
|
||||
groups[1].append((path, vt))
|
||||
elif prio <= 7:
|
||||
groups[5].append((path, vt))
|
||||
else:
|
||||
groups[9].append((path, vt))
|
||||
|
||||
for prio, emoji, label in [(1, "🔴", "高优先级"), (5, "🟡", "中优先级"), (9, "🟢", "低优先级")]:
|
||||
items = groups[prio]
|
||||
if not items:
|
||||
continue
|
||||
print(f"\n{emoji} {label}:")
|
||||
for _, vt in items:
|
||||
summary = str(vt.get("summary", ""))
|
||||
due = _get_due_date(vt)
|
||||
days = _days_until(due)
|
||||
urgency = _urgency_label(days)
|
||||
desc = str(vt.get("description", ""))
|
||||
line = f" • {summary} ({urgency})"
|
||||
if desc:
|
||||
line += f" | {desc}"
|
||||
print(line)
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Calendar and todo tool")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# --- send ---
|
||||
send_p = subparsers.add_parser("send", help="Send a calendar invite")
|
||||
send_p.add_argument("--from", dest="sender", default=DEFAULT_FROM, help="Sender email")
|
||||
send_p.add_argument("--to", required=True, help="Recipient(s), comma-separated")
|
||||
send_p.add_argument("--subject", required=True, help="Email subject")
|
||||
send_p.add_argument("--summary", required=True, help="Event title")
|
||||
send_p.add_argument("--start", required=True, help="Start time (ISO 8601)")
|
||||
send_p.add_argument("--end", required=True, help="End time (ISO 8601)")
|
||||
send_p.add_argument("--timezone", default=DEFAULT_TIMEZONE, help="IANA timezone")
|
||||
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("--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")
|
||||
|
||||
# --- reply ---
|
||||
reply_p = subparsers.add_parser("reply", help="Reply to a calendar invite")
|
||||
reply_p.add_argument("--from", dest="sender", default=DEFAULT_FROM, help="Your email")
|
||||
reply_p.add_argument("--action", required=True, help="accept, decline, or tentative")
|
||||
reply_p.add_argument("--envelope-id", default="", help="Himalaya envelope ID")
|
||||
reply_p.add_argument("--ics-file", default="", help="Path to .ics file")
|
||||
reply_p.add_argument("--account", default="", help="Himalaya account")
|
||||
reply_p.add_argument("--folder", default="INBOX", help="Himalaya folder")
|
||||
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")
|
||||
|
||||
# --- todo ---
|
||||
todo_p = subparsers.add_parser("todo", help="Manage VTODO tasks")
|
||||
todo_sub = todo_p.add_subparsers(dest="todo_command", required=True)
|
||||
|
||||
# todo add
|
||||
add_p = todo_sub.add_parser("add", help="Create a new todo")
|
||||
add_p.add_argument("--summary", required=True, help="Todo title")
|
||||
add_p.add_argument("--due", default="", help="Due date (YYYY-MM-DD, default: tomorrow)")
|
||||
add_p.add_argument("--priority", default="medium", choices=["high", "medium", "low"], help="Priority")
|
||||
add_p.add_argument("--description", default="", help="Notes / description")
|
||||
add_p.add_argument("--alarm", default="1d", help="Reminder trigger (e.g. 1d, 2h, 30m)")
|
||||
add_p.add_argument("--dry-run", action="store_true", help="Preview without saving")
|
||||
|
||||
# todo list
|
||||
list_p = todo_sub.add_parser("list", help="List todos")
|
||||
list_p.add_argument("--all", action="store_true", help="Include completed todos")
|
||||
|
||||
# todo complete
|
||||
comp_p = todo_sub.add_parser("complete", help="Mark a todo as done")
|
||||
comp_p.add_argument("--uid", default="", help="Todo UID")
|
||||
comp_p.add_argument("--match", default="", help="Fuzzy match on summary")
|
||||
|
||||
# todo delete
|
||||
del_p = todo_sub.add_parser("delete", help="Delete a todo")
|
||||
del_p.add_argument("--uid", default="", help="Todo UID")
|
||||
del_p.add_argument("--match", default="", help="Fuzzy match on summary")
|
||||
|
||||
# todo check
|
||||
todo_sub.add_parser("check", help="Daily digest (for cron)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "send":
|
||||
cmd_send(args)
|
||||
elif args.command == "reply":
|
||||
cmd_reply(args)
|
||||
elif args.command == "todo":
|
||||
if args.todo_command == "add":
|
||||
cmd_todo_add(args)
|
||||
elif args.todo_command == "list":
|
||||
cmd_todo_list(args)
|
||||
elif args.todo_command == "complete":
|
||||
cmd_todo_complete(args)
|
||||
elif args.todo_command == "delete":
|
||||
cmd_todo_delete(args)
|
||||
elif args.todo_command == "check":
|
||||
cmd_todo_check(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
20
skills/calendar/scripts/calendar.sh
Executable file
20
skills/calendar/scripts/calendar.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# calendar — wrapper script for the calendar and todo tool.
|
||||
#
|
||||
# Usage:
|
||||
# ./calendar.sh send [options] # send a calendar invite
|
||||
# ./calendar.sh reply [options] # accept/decline/tentative
|
||||
# ./calendar.sh todo add [options] # create a todo
|
||||
# ./calendar.sh todo list [options] # list pending todos
|
||||
# ./calendar.sh todo complete [options] # mark todo as done
|
||||
# ./calendar.sh todo delete [options] # remove a todo
|
||||
# ./calendar.sh todo check # daily digest for cron
|
||||
#
|
||||
# Requires: uv, himalaya, vdirsyncer (for CalDAV sync).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
exec uv run --project "$SKILL_DIR" python "$SCRIPT_DIR/cal_tool.py" "$@"
|
||||
67
skills/calendar/uv.lock
generated
Normal file
67
skills/calendar/uv.lock
generated
Normal file
@@ -0,0 +1,67 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[[package]]
|
||||
name = "calendar"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "icalendar" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "icalendar" }]
|
||||
|
||||
[[package]]
|
||||
name = "icalendar"
|
||||
version = "7.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "tzdata" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b8/60/6b0356a2ed1c9689ae14bd8e44f22eac67c420a0ecca4df8306b70906600/icalendar-7.0.3.tar.gz", hash = "sha256:95027ece087ab87184d765f03761f25875821f74cdd18d3b57e9c868216d8fde", size = 443788, upload-time = "2026-03-03T12:00:10.952Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/c6/431fbf9063a6a4306d4cedae7823d69baf0979ba6ca57ab24a9d898cd0aa/icalendar-7.0.3-py3-none-any.whl", hash = "sha256:8c9fea6d3a89671bba8b6938d8565b4d0ec465c6a2796ef0f92790dcb9e627cd", size = 442406, upload-time = "2026-03-03T12:00:09.228Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2025.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user