From de6528335c134851d9259a91bfda0a82c59b93d4 Mon Sep 17 00:00:00 2001 From: Yanxin Lu Date: Wed, 18 Mar 2026 13:36:25 -0700 Subject: [PATCH] calendar invite --- .gitignore | 3 + HEARTBEAT.md | 2 + MEMORY.md | 64 ++- TOOLS.md | 39 +- USER.md | 1 + crontab.txt | 2 +- memory/2026-02-14.md | 2 +- memory/2026-02-15.md | 2 +- plans/news_digest_plan.md | 6 +- skills/calendar-invite/SKILL.md | 223 +++++++++ skills/calendar-invite/_meta.json | 5 + skills/calendar-invite/pyproject.toml | 5 + .../scripts/calendar-invite.sh | 15 + .../scripts/calendar_invite.py | 439 ++++++++++++++++++ 14 files changed, 778 insertions(+), 30 deletions(-) create mode 100644 skills/calendar-invite/SKILL.md create mode 100644 skills/calendar-invite/_meta.json create mode 100644 skills/calendar-invite/pyproject.toml create mode 100755 skills/calendar-invite/scripts/calendar-invite.sh create mode 100644 skills/calendar-invite/scripts/calendar_invite.py diff --git a/.gitignore b/.gitignore index 035dc3b..3ab3ce9 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ ENV/ *.swp *.swo +# vdirsyncer sync state +.vdirsyncer/ + # OS .DS_Store Thumbs.db diff --git a/HEARTBEAT.md b/HEARTBEAT.md index 447e19d..386427d 100644 --- a/HEARTBEAT.md +++ b/HEARTBEAT.md @@ -1 +1,3 @@ # HEARTBEAT.md - Periodic Checks + +- Sync calendar: `vdirsyncer sync` diff --git a/MEMORY.md b/MEMORY.md index a90d709..18b54a6 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -2,6 +2,30 @@ _这份文件记录持续性项目和重要状态,跨会话保留。_ +## 📝 重要规则 + +### 邮件发送规则(v2) +- **youlu@luyanxin.com → mail@luyx.org**: 直接发送,无需确认(用户 SimpleLogin 别名,日历邀请自动抄送) +- 其他所有对外邮件: 仍需确认 + +### 代码审查规则 +写/改/部署代码前,必须先确认: +1. 为什么需要? +2. 改了什么功能? +3. 文件放在哪里? + +--- + +## 👤 用户背景 + +- **称呼**: 小鹿 +- **时区**: America/Los_Angeles (PST) +- **当前事务**: 医疗相关跟进 + - 过敏治疗(集群过敏针) + - 囊肿检查(超声波预约) + - 备孕准备(子宫情况跟进) + - 保险报销(iui + CVS 药物) + --- ## 🎯 活跃项目 @@ -51,20 +75,6 @@ _这份文件记录持续性项目和重要状态,跨会话保留。_ --- -## 📝 重要规则 - -### 邮件发送规则(v2) -- **youlu@luyanxin.com → lu@luyx.org**: 直接发送,无需确认 -- 其他所有对外邮件: 仍需确认 - -### 代码审查规则 -写/改/部署代码前,必须先确认: -1. 为什么需要? -2. 改了什么功能? -3. 文件放在哪里? - ---- - ### 4. 工作区自动备份 **状态**: 运行中 **创建**: 2026-03-06 @@ -82,15 +92,21 @@ _这份文件记录持续性项目和重要状态,跨会话保留。_ --- -## 👤 用户背景 +### 5. 日历邀请 + CalDAV 同步 +**状态**: 运行中 +**创建**: 2026-03-18 +**配置**: +- 技能: `~/.openclaw/workspace/skills/calendar-invite/` +- 日历数据: `~/.openclaw/workspace/calendars/` (home/work/tasks/journals) +- CalDAV: Migadu (`cdav.migadu.com`),通过 vdirsyncer 同步 +- 查看日历: khal +- 运行方式: `uv run`(依赖 `icalendar` 库) -- **称呼**: 小鹿 -- **时区**: America/Los_Angeles (PST) -- **当前事务**: 医疗相关跟进 - - 过敏治疗(集群过敏针) - - 囊肿检查(超声波预约) - - 备孕准备(子宫情况跟进) - - 保险报销(iui + CVS 药物) +**功能**: +- 发送日历邀请(自动添加 mail@luyx.org 为参与者) +- 接受/拒绝/暂定回复邀请(自动转发给 mail@luyx.org) +- 发送/回复后自动 `vdirsyncer sync` 同步到 CalDAV +- 心跳定期同步日历 --- @@ -101,7 +117,9 @@ _这份文件记录持续性项目和重要状态,跨会话保留。_ | 待办提醒 | `~/.openclaw/workspace/scripts/reminder_check.py` | | 邮件处理器 | `~/.openclaw/workspace/scripts/email_processor/` | | 待办列表 | `~/.openclaw/workspace/reminders/active.md` | +| 日历邀请 | `~/.openclaw/workspace/skills/calendar-invite/` | +| 日历数据 | `~/.openclaw/workspace/calendars/` | --- -_最后更新: 2026-02-27_ +_最后更新: 2026-03-18_ diff --git a/TOOLS.md b/TOOLS.md index e7675f9..3d75090 100644 --- a/TOOLS.md +++ b/TOOLS.md @@ -40,7 +40,8 @@ himalaya message write # 写新邮件(交互式) ``` **邮件发送规则:** -- **youlu@luyanxin.com → lu@luyx.org**: 直接发送,无需确认 +- **youlu@luyanxin.com → mail@luyx.org**: 直接发送,无需确认 +- **youlu@luyanxin.com → mail@luyx.org**: 直接发送,无需确认(用户 SimpleLogin 别名) - 其他所有对外邮件: 仍需确认 ### ~~News Digest 新闻摘要~~ (已停用) @@ -114,6 +115,42 @@ agent-browser close - `data/pending_emails.json` — 待处理队列 - `logs/` — 处理日志 +### Calendar Invite 日历邀请 + +**文档**: `~/.openclaw/workspace/skills/calendar-invite/SKILL.md` +**目录**: `~/.openclaw/workspace/skills/calendar-invite/` +**默认发件人**: youlu@luyanxin.com +**默认时区**: America/Los_Angeles +**日历数据**: `~/.openclaw/workspace/calendars/home/` +**运行方式**: `uv run`(依赖 `icalendar` 库) + +**核心用法**: +```bash +SKILL_DIR=~/.openclaw/workspace/skills/calendar-invite + +# 发送日历邀请(--from 默认 youlu@luyanxin.com) +$SKILL_DIR/scripts/calendar-invite.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-invite.sh reply --envelope-id 42 --action decline \ + --comment "Sorry, I have a conflict." + +# 查看日历(检查冲突) +khal list today 7d +``` + +**支持操作**: 发送邀请 (`send`)、接受/拒绝/暂定 (`reply`) +**依赖**: himalaya(邮件)、vdirsyncer(CalDAV 同步)、khal(查看日历) +**同步**: 发送/回复后自动 `vdirsyncer sync`,心跳也会定期同步 +**自动抄送**: mail@luyx.org(用户别名)自动加入所有邀请 +**注意**: 发送日历邀请属于对外邮件,除 mail@luyx.org 外需先确认 + --- Add whatever helps you do your job. This is your cheat sheet. diff --git a/USER.md b/USER.md index df53658..51ef60b 100644 --- a/USER.md +++ b/USER.md @@ -6,6 +6,7 @@ _Learn about the person you're helping. Update this as you go._ - **What to call them:** 小鹿 - **Pronouns:** _(待补充)_ - **Timezone:** America/Los_Angeles (PST) +- **Email:** mail@luyx.org (SimpleLogin alias) - **Notes:** 喜欢打游戏 (Steam 库有 CK3/V3/铁拳8),用 Razer Blade + Linux Mint ## Context diff --git a/crontab.txt b/crontab.txt index d081263..a9f45b1 100644 --- a/crontab.txt +++ b/crontab.txt @@ -7,6 +7,6 @@ PATH=/usr/local/bin:/usr/bin:/bin 0 5 * * * cd ~/.openclaw/workspace/scripts/news_digest && ./run.sh -v >> ~/.openclaw/workspace/logs/news_digest.log 2>&1 && python3 ~/.openclaw/workspace/scripts/news_digest/send_digest.py >> ~/.openclaw/workspace/logs/news_digest.log 2>&1 # 每日待办提醒 - 早上 8:00 -0 8 * * * cd ~/.openclaw/workspace && python3 scripts/reminder_check.py 2>&1 | ~/.local/bin/himalaya message write --to lu@luyx.org --from youlu@luyanxin.com --subject "📋 今日待办清单" +0 8 * * * cd ~/.openclaw/workspace && python3 scripts/reminder_check.py 2>&1 | ~/.local/bin/himalaya message write --to mail@luyx.org --from youlu@luyanxin.com --subject "📋 今日待办清单" diff --git a/memory/2026-02-14.md b/memory/2026-02-14.md index af4c367..c2b5044 100644 --- a/memory/2026-02-14.md +++ b/memory/2026-02-14.md @@ -49,7 +49,7 @@ **Status:** 暂停(项目计划已保存) - 计划文档:`~/.openclaw/workspace/plans/news_digest_plan.md` - 本地模型:Qwen3:4b 已部署,测试通过 -- 邮件规则更新:youlu@luyanxin.com → lu@luyx.org 无需确认 +- 邮件规则更新:youlu@luyanxin.com → mail@luyx.org 无需确认 - 待办:用户提供 RSS 链接后可重启 ## Identity diff --git a/memory/2026-02-15.md b/memory/2026-02-15.md index e9c1352..728b326 100644 --- a/memory/2026-02-15.md +++ b/memory/2026-02-15.md @@ -3,7 +3,7 @@ ## 规则更新 ### 邮件规则 v2(已生效) -- **youlu@luyanxin.com → lu@luyx.org**: 直接发送,无需确认 +- **youlu@luyanxin.com → mail@luyx.org**: 直接发送,无需确认 - 其他所有对外邮件: 仍需确认 ### 代码审查规则(已生效) diff --git a/plans/news_digest_plan.md b/plans/news_digest_plan.md index 8410fc9..a5c67a2 100644 --- a/plans/news_digest_plan.md +++ b/plans/news_digest_plan.md @@ -11,14 +11,14 @@ ### 第一阶段:每日标题简报(自动) - 每天早上抓取 RSS 新闻源 - 提取标题、来源、链接 -- 发送邮件到 lu@luyx.org +- 发送邮件到 mail@luyx.org - 发送方:youlu@luyanxin.com - **特殊规则:此邮件无需确认,直接发送** ### 第二阶段:按需深度摘要(手动触发) - 用户回复邮件或在 Telegram 指定想看的新闻(如"细看 1、3、5") - 使用本地 Qwen3:4b 模型生成详细摘要 -- 回复邮件给 lu@luyx.org +- 回复邮件给 mail@luyx.org --- @@ -99,7 +99,7 @@ ### 邮件发送 - 工具:msmtp - 发件人:youlu@luyanxin.com -- 收件人:lu@luyx.org +- 收件人:mail@luyx.org - 格式:Markdown 简洁列表 --- diff --git a/skills/calendar-invite/SKILL.md b/skills/calendar-invite/SKILL.md new file mode 100644 index 0000000..2b840bd --- /dev/null +++ b/skills/calendar-invite/SKILL.md @@ -0,0 +1,223 @@ +--- +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. + +## 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**. 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 + MML 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 message 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 message 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 an MML email with a `text/calendar` attachment +3. Sends via `himalaya template send` +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 template send` +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 `type=text/calendar method=REQUEST` is set on the MML part +- 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/_meta.json b/skills/calendar-invite/_meta.json new file mode 100644 index 0000000..f53b176 --- /dev/null +++ b/skills/calendar-invite/_meta.json @@ -0,0 +1,5 @@ +{ + "ownerId": "kn7anq2d7gcch060anc2j9cg89800dyv", + "slug": "calendar-invite", + "version": "1.0.0" +} diff --git a/skills/calendar-invite/pyproject.toml b/skills/calendar-invite/pyproject.toml new file mode 100644 index 0000000..5062c69 --- /dev/null +++ b/skills/calendar-invite/pyproject.toml @@ -0,0 +1,5 @@ +[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 new file mode 100755 index 0000000..44ff0e1 --- /dev/null +++ b/skills/calendar-invite/scripts/calendar-invite.sh @@ -0,0 +1,15 @@ +#!/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 new file mode 100644 index 0000000..d4f3227 --- /dev/null +++ b/skills/calendar-invite/scripts/calendar_invite.py @@ -0,0 +1,439 @@ +#!/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 +from pathlib import Path + +from icalendar import Calendar, Event, 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 _himalaya(*args): + """Run a himalaya command and return stdout.""" + result = subprocess.run( + ["himalaya", *args], + capture_output=True, text=True, check=True, + ) + return result.stdout + + +def _himalaya_with_account(account, *args): + """Run a himalaya command with optional account flag.""" + cmd = ["himalaya"] + if account: + cmd += ["--account", account] + cmd += list(args) + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + return result.stdout + + +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_mml(mml, account=None): + """Send an MML message via himalaya template send.""" + cmd = ["himalaya"] + if account: + cmd += ["--account", account] + cmd += ["template", "send"] + subprocess.run(cmd, input=mml, text=True, check=True) + + +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.utcnow()) + 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) + event["organizer"] = f"mailto:{args.sender}" + event["organizer"].params["CN"] = vText(organizer_name) + + 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", + }) + + cal.add_component(event) + ics_bytes = cal.to_ical() + + # Write ICS to temp file + tmp_ics = Path(f"/tmp/openclaw-invite-{int(datetime.now().timestamp())}.ics") + tmp_ics.write_bytes(ics_bytes) + + # 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 MML message + mml = ( + f"From: {args.sender}\n" + f"To: {all_to}\n" + f"Subject: {args.subject}\n" + f"\n" + f"<#multipart type=mixed>\n" + f"<#part type=text/plain>\n" + f"{body}\n" + f"<#part type=text/calendar method=REQUEST filename={tmp_ics} name=invite.ics><#/part>\n" + f"<#/multipart>" + ) + + if args.dry_run: + print("=== ICS Content ===") + print(ics_bytes.decode()) + print("=== MML Message ===") + print(mml) + tmp_ics.unlink(missing_ok=True) + return + + # Send email + _send_mml(mml, args.account) + print(f"Calendar invite sent to: {args.to}") + + # Save to local calendar + if CALENDAR_DIR.is_dir(): + dest = CALENDAR_DIR / f"{uid}.ics" + dest.write_bytes(ics_bytes) + print(f"Saved to local calendar: {dest}") + _sync_calendar() + + tmp_ics.unlink(missing_ok=True) + + +# --------------------------------------------------------------------------- +# 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.utcnow()) + + # 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() + + # Write reply ICS to temp file + tmp_reply = Path(f"/tmp/openclaw-reply-{int(datetime.now().timestamp())}.ics") + tmp_reply.write_bytes(reply_ics_bytes) + + # Build email + prefix = SUBJECT_PREFIX[partstat] + subject = f"{prefix}: {summary}" + + body = f"{prefix}: {summary}" + if args.comment: + body += f"\n\n{args.comment}" + + mml = ( + f"From: {args.sender}\n" + f"To: {organizer_email}\n" + f"Subject: {subject}\n" + f"\n" + f"<#multipart type=mixed>\n" + f"<#part type=text/plain>\n" + f"{body}\n" + f"<#part type=text/calendar method=REPLY filename={tmp_reply} name=invite.ics><#/part>\n" + f"<#/multipart>" + ) + + 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("=== MML Message ===") + print(mml) + tmp_reply.unlink(missing_ok=True) + if cleanup_dir: + for f in cleanup_dir.iterdir(): + f.unlink() + cleanup_dir.rmdir() + return + + # Send reply + _send_mml(mml, 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"): + tmp_fwd = Path(f"/tmp/openclaw-fwd-{int(datetime.now().timestamp())}.ics") + tmp_fwd.write_bytes(ics_path.read_bytes()) + fwd_mml = ( + f"From: {args.sender}\n" + f"To: {DEFAULT_OWNER_EMAIL}\n" + f"Subject: {prefix}: {summary}\n" + f"\n" + f"<#multipart type=mixed>\n" + f"<#part type=text/plain>\n" + f"{prefix}: {summary}\n" + f"<#part type=text/calendar method=REQUEST filename={tmp_fwd} name=invite.ics><#/part>\n" + f"<#/multipart>" + ) + try: + _send_mml(fwd_mml, args.account) + print(f"Forwarded invite to {DEFAULT_OWNER_EMAIL}") + except subprocess.CalledProcessError: + print(f"Warning: Failed to forward invite to {DEFAULT_OWNER_EMAIL}") + tmp_fwd.unlink(missing_ok=True) + + # 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 + dest.write_bytes(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 + tmp_reply.unlink(missing_ok=True) + 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()