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