Merge branch 'main' of ssh://luyanxin.com:8103/lyx/youlu-openclaw-workspace

merge
This commit is contained in:
2026-03-18 13:40:45 -07:00
14 changed files with 778 additions and 30 deletions

3
.gitignore vendored
View File

@@ -22,6 +22,9 @@ ENV/
*.swp
*.swo
# vdirsyncer sync state
.vdirsyncer/
# OS
.DS_Store
Thumbs.db

View File

@@ -1 +1,3 @@
# HEARTBEAT.md - Periodic Checks
- Sync calendar: `vdirsyncer sync`

View File

@@ -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_

View File

@@ -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邮件、vdirsyncerCalDAV 同步、khal查看日历
**同步**: 发送/回复后自动 `vdirsyncer sync`,心跳也会定期同步
**自动抄送**: mail@luyx.org用户别名自动加入所有邀请
**注意**: 发送日历邀请属于对外邮件,除 mail@luyx.org 外需先确认
---
Add whatever helps you do your job. This is your cheat sheet.

View File

@@ -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

View File

@@ -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 "📋 今日待办清单"

View File

@@ -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

View File

@@ -3,7 +3,7 @@
## 规则更新
### 邮件规则 v2已生效
- **youlu@luyanxin.com → lu@luyx.org**: 直接发送,无需确认
- **youlu@luyanxin.com → mail@luyx.org**: 直接发送,无需确认
- 其他所有对外邮件: 仍需确认
### 代码审查规则(已生效)

View File

@@ -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 简洁列表
---

View File

@@ -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

View File

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

View File

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

View File

@@ -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" "$@"

View File

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