VTODO skill

This commit is contained in:
Yanxin Lu
2026-03-22 14:10:54 -07:00
parent ceb7af543b
commit 44fbbea29b
7 changed files with 1523 additions and 0 deletions

313
skills/calendar/SKILL.md Normal file
View 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
View 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 |

View File

@@ -0,0 +1,5 @@
{
"ownerId": "kn7anq2d7gcch060anc2j9cg89800dyv",
"slug": "calendar",
"version": "1.0.0"
}

View File

@@ -0,0 +1,5 @@
[project]
name = "calendar"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["icalendar"]

View 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()

View 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
View 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" },
]