From ceb7af543ba3e36c75f1c83100cda9e3d3146073 Mon Sep 17 00:00:00 2001 From: Yanxin Lu Date: Sun, 22 Mar 2026 14:10:41 -0700 Subject: [PATCH] VTODO --- MEMORY.md | 7 +- TOOLS.md | 24 +- skills/calendar-invite/SKILL.md | 227 ---------- skills/calendar-invite/TESTING.md | 170 ------- skills/calendar-invite/_meta.json | 5 - skills/calendar-invite/pyproject.toml | 5 - .../scripts/calendar-invite.sh | 15 - .../scripts/calendar_invite.py | 417 ------------------ skills/calendar-invite/uv.lock | 67 --- 9 files changed, 19 insertions(+), 918 deletions(-) delete mode 100644 skills/calendar-invite/SKILL.md delete mode 100644 skills/calendar-invite/TESTING.md delete mode 100644 skills/calendar-invite/_meta.json delete mode 100644 skills/calendar-invite/pyproject.toml delete mode 100755 skills/calendar-invite/scripts/calendar-invite.sh delete mode 100644 skills/calendar-invite/scripts/calendar_invite.py delete mode 100644 skills/calendar-invite/uv.lock diff --git a/MEMORY.md b/MEMORY.md index 18b54a6..67b8e7b 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -96,7 +96,7 @@ _这份文件记录持续性项目和重要状态,跨会话保留。_ **状态**: 运行中 **创建**: 2026-03-18 **配置**: -- 技能: `~/.openclaw/workspace/skills/calendar-invite/` +- 技能: `~/.openclaw/workspace/skills/calendar/` - 日历数据: `~/.openclaw/workspace/calendars/` (home/work/tasks/journals) - CalDAV: Migadu (`cdav.migadu.com`),通过 vdirsyncer 同步 - 查看日历: khal @@ -105,7 +105,8 @@ _这份文件记录持续性项目和重要状态,跨会话保留。_ **功能**: - 发送日历邀请(自动添加 mail@luyx.org 为参与者) - 接受/拒绝/暂定回复邀请(自动转发给 mail@luyx.org) -- 发送/回复后自动 `vdirsyncer sync` 同步到 CalDAV +- VTODO 待办管理(add/list/complete/delete/check) +- 发送/回复/待办操作后自动 `vdirsyncer sync` 同步到 CalDAV - 心跳定期同步日历 --- @@ -117,7 +118,7 @@ _这份文件记录持续性项目和重要状态,跨会话保留。_ | 待办提醒 | `~/.openclaw/workspace/scripts/reminder_check.py` | | 邮件处理器 | `~/.openclaw/workspace/scripts/email_processor/` | | 待办列表 | `~/.openclaw/workspace/reminders/active.md` | -| 日历邀请 | `~/.openclaw/workspace/skills/calendar-invite/` | +| 日历/待办 | `~/.openclaw/workspace/skills/calendar/` | | 日历数据 | `~/.openclaw/workspace/calendars/` | --- diff --git a/TOOLS.md b/TOOLS.md index 3d75090..b3466cf 100644 --- a/TOOLS.md +++ b/TOOLS.md @@ -115,37 +115,43 @@ agent-browser close - `data/pending_emails.json` — 待处理队列 - `logs/` — 处理日志 -### Calendar Invite 日历邀请 +### Calendar 日历 + 待办 -**文档**: `~/.openclaw/workspace/skills/calendar-invite/SKILL.md` -**目录**: `~/.openclaw/workspace/skills/calendar-invite/` +**文档**: `~/.openclaw/workspace/skills/calendar/SKILL.md` +**目录**: `~/.openclaw/workspace/skills/calendar/` **默认发件人**: youlu@luyanxin.com **默认时区**: America/Los_Angeles -**日历数据**: `~/.openclaw/workspace/calendars/home/` +**日历数据**: `~/.openclaw/workspace/calendars/home/`(事件)、`calendars/tasks/`(待办) **运行方式**: `uv run`(依赖 `icalendar` 库) **核心用法**: ```bash -SKILL_DIR=~/.openclaw/workspace/skills/calendar-invite +SKILL_DIR=~/.openclaw/workspace/skills/calendar # 发送日历邀请(--from 默认 youlu@luyanxin.com) -$SKILL_DIR/scripts/calendar-invite.sh send \ +$SKILL_DIR/scripts/calendar.sh send \ --to "friend@example.com" \ --subject "Lunch" --summary "Lunch at Tartine" \ --start "2026-03-20T12:00:00" --end "2026-03-20T13:00:00" # 接受邀请(从邮件中提取 .ics) -$SKILL_DIR/scripts/calendar-invite.sh reply --envelope-id 42 --action accept +$SKILL_DIR/scripts/calendar.sh reply --envelope-id 42 --action accept # 拒绝邀请(附带留言) -$SKILL_DIR/scripts/calendar-invite.sh reply --envelope-id 42 --action decline \ +$SKILL_DIR/scripts/calendar.sh reply --envelope-id 42 --action decline \ --comment "Sorry, I have a conflict." +# 待办管理 +$SKILL_DIR/scripts/calendar.sh todo add --summary "跟进报销" --due "2026-03-25" --priority high +$SKILL_DIR/scripts/calendar.sh todo list +$SKILL_DIR/scripts/calendar.sh todo complete --match "报销" +$SKILL_DIR/scripts/calendar.sh todo check # 每日摘要(cron) + # 查看日历(检查冲突) khal list today 7d ``` -**支持操作**: 发送邀请 (`send`)、接受/拒绝/暂定 (`reply`) +**支持操作**: 发送邀请 (`send`)、接受/拒绝/暂定 (`reply`)、待办管理 (`todo add/list/complete/delete/check`) **依赖**: himalaya(邮件)、vdirsyncer(CalDAV 同步)、khal(查看日历) **同步**: 发送/回复后自动 `vdirsyncer sync`,心跳也会定期同步 **自动抄送**: mail@luyx.org(用户别名)自动加入所有邀请 diff --git a/skills/calendar-invite/SKILL.md b/skills/calendar-invite/SKILL.md deleted file mode 100644 index 75fcc54..0000000 --- a/skills/calendar-invite/SKILL.md +++ /dev/null @@ -1,227 +0,0 @@ ---- -name: calendar-invite -description: "Send, accept, and decline calendar invite emails (ICS/iCalendar) via himalaya. Syncs events to CalDAV (Migadu) via vdirsyncer." -metadata: {"clawdbot":{"emoji":"📅","requires":{"bins":["himalaya","vdirsyncer"],"skills":["himalaya"]}}} ---- - -# Calendar Invite - -Send, accept, and decline calendar invitations via email using himalaya. Events are saved to local calendar and synced to CalDAV (Migadu) 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-invite - -# Send an invite -$SKILL_DIR/scripts/calendar-invite.sh send [options] - -# Reply to an invite -$SKILL_DIR/scripts/calendar-invite.sh reply [options] -``` - ---- - -## Sending Invites - -```bash -$SKILL_DIR/scripts/calendar-invite.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-invite.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-invite.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-invite.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-invite.sh reply \ - --envelope-id 42 \ - --action accept - -# Decline with a comment -$SKILL_DIR/scripts/calendar-invite.sh reply \ - --envelope-id 42 \ - --action decline \ - --comment "Sorry, I have a conflict." - -# From an .ics file -$SKILL_DIR/scripts/calendar-invite.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-invite.sh reply --envelope-id 57 --action accept` - ---- - -## 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 - -**CalDAV sync:** -- Events 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/` - -**Recipient doesn't see Accept/Decline?** -- Gmail, Outlook, Apple Mail all support `text/calendar` method=REQUEST -- Some webmail clients may vary diff --git a/skills/calendar-invite/TESTING.md b/skills/calendar-invite/TESTING.md deleted file mode 100644 index 63e75d0..0000000 --- a/skills/calendar-invite/TESTING.md +++ /dev/null @@ -1,170 +0,0 @@ -# Testing the Calendar Invite Skill - -End-to-end tests for send, reply, calendar sync, and local calendar. All commands use `--dry-run` first, then live. - -```bash -SKILL_DIR=~/.openclaw/workspace/skills/calendar-invite - -# 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-invite.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-invite.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 - -# Accept it -$SKILL_DIR/scripts/calendar-invite.sh reply \ - --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-invite.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-invite.sh reply \ - --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 -``` - ---- - -## 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/` | diff --git a/skills/calendar-invite/_meta.json b/skills/calendar-invite/_meta.json deleted file mode 100644 index f53b176..0000000 --- a/skills/calendar-invite/_meta.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "ownerId": "kn7anq2d7gcch060anc2j9cg89800dyv", - "slug": "calendar-invite", - "version": "1.0.0" -} diff --git a/skills/calendar-invite/pyproject.toml b/skills/calendar-invite/pyproject.toml deleted file mode 100644 index 5062c69..0000000 --- a/skills/calendar-invite/pyproject.toml +++ /dev/null @@ -1,5 +0,0 @@ -[project] -name = "calendar-invite" -version = "0.1.0" -requires-python = ">=3.10" -dependencies = ["icalendar"] diff --git a/skills/calendar-invite/scripts/calendar-invite.sh b/skills/calendar-invite/scripts/calendar-invite.sh deleted file mode 100755 index 44ff0e1..0000000 --- a/skills/calendar-invite/scripts/calendar-invite.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -# calendar-invite — wrapper script for the calendar invite tool. -# -# Usage: -# ./calendar-invite.sh send [options] # send a calendar invite -# ./calendar-invite.sh reply [options] # accept/decline/tentative -# -# 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/calendar_invite.py" "$@" diff --git a/skills/calendar-invite/scripts/calendar_invite.py b/skills/calendar-invite/scripts/calendar_invite.py deleted file mode 100644 index d0ee73e..0000000 --- a/skills/calendar-invite/scripts/calendar_invite.py +++ /dev/null @@ -1,417 +0,0 @@ -#!/usr/bin/env python3 -""" -Calendar Invite — Send, accept, and decline calendar invites via himalaya. - -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_invite.py send [options] # create and send an invite - python calendar_invite.py reply [options] # accept/decline/tentative -""" - -import argparse -import subprocess -import sys -import uuid -from datetime import 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, 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" -PRODID = "-//OpenClaw//CalendarInvite//EN" - - -# --------------------------------------------------------------------------- -# 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}") - - -# --------------------------------------------------------------------------- -# 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() - - -# --------------------------------------------------------------------------- -# CLI -# --------------------------------------------------------------------------- - -def main(): - parser = argparse.ArgumentParser(description="Calendar invite 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") - - args = parser.parse_args() - - if args.command == "send": - cmd_send(args) - elif args.command == "reply": - cmd_reply(args) - - -if __name__ == "__main__": - main() diff --git a/skills/calendar-invite/uv.lock b/skills/calendar-invite/uv.lock deleted file mode 100644 index 461557f..0000000 --- a/skills/calendar-invite/uv.lock +++ /dev/null @@ -1,67 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.10" - -[[package]] -name = "calendar-invite" -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" }, -]