calendar invite
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,6 +22,9 @@ ENV/
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
|
# vdirsyncer sync state
|
||||||
|
.vdirsyncer/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
# HEARTBEAT.md - Periodic Checks
|
# HEARTBEAT.md - Periodic Checks
|
||||||
|
|
||||||
|
- Sync calendar: `vdirsyncer sync`
|
||||||
|
|||||||
64
MEMORY.md
64
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. 工作区自动备份
|
### 4. 工作区自动备份
|
||||||
**状态**: 运行中
|
**状态**: 运行中
|
||||||
**创建**: 2026-03-06
|
**创建**: 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)
|
- 发送日历邀请(自动添加 mail@luyx.org 为参与者)
|
||||||
- **当前事务**: 医疗相关跟进
|
- 接受/拒绝/暂定回复邀请(自动转发给 mail@luyx.org)
|
||||||
- 过敏治疗(集群过敏针)
|
- 发送/回复后自动 `vdirsyncer sync` 同步到 CalDAV
|
||||||
- 囊肿检查(超声波预约)
|
- 心跳定期同步日历
|
||||||
- 备孕准备(子宫情况跟进)
|
|
||||||
- 保险报销(iui + CVS 药物)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -101,7 +117,9 @@ _这份文件记录持续性项目和重要状态,跨会话保留。_
|
|||||||
| 待办提醒 | `~/.openclaw/workspace/scripts/reminder_check.py` |
|
| 待办提醒 | `~/.openclaw/workspace/scripts/reminder_check.py` |
|
||||||
| 邮件处理器 | `~/.openclaw/workspace/scripts/email_processor/` |
|
| 邮件处理器 | `~/.openclaw/workspace/scripts/email_processor/` |
|
||||||
| 待办列表 | `~/.openclaw/workspace/reminders/active.md` |
|
| 待办列表 | `~/.openclaw/workspace/reminders/active.md` |
|
||||||
|
| 日历邀请 | `~/.openclaw/workspace/skills/calendar-invite/` |
|
||||||
|
| 日历数据 | `~/.openclaw/workspace/calendars/` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
_最后更新: 2026-02-27_
|
_最后更新: 2026-03-18_
|
||||||
|
|||||||
39
TOOLS.md
39
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 新闻摘要~~ (已停用)
|
### ~~News Digest 新闻摘要~~ (已停用)
|
||||||
@@ -114,6 +115,42 @@ agent-browser close
|
|||||||
- `data/pending_emails.json` — 待处理队列
|
- `data/pending_emails.json` — 待处理队列
|
||||||
- `logs/` — 处理日志
|
- `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.
|
Add whatever helps you do your job. This is your cheat sheet.
|
||||||
|
|||||||
1
USER.md
1
USER.md
@@ -6,6 +6,7 @@ _Learn about the person you're helping. Update this as you go._
|
|||||||
- **What to call them:** 小鹿
|
- **What to call them:** 小鹿
|
||||||
- **Pronouns:** _(待补充)_
|
- **Pronouns:** _(待补充)_
|
||||||
- **Timezone:** America/Los_Angeles (PST)
|
- **Timezone:** America/Los_Angeles (PST)
|
||||||
|
- **Email:** mail@luyx.org (SimpleLogin alias)
|
||||||
- **Notes:** 喜欢打游戏 (Steam 库有 CK3/V3/铁拳8),用 Razer Blade + Linux Mint
|
- **Notes:** 喜欢打游戏 (Steam 库有 CK3/V3/铁拳8),用 Razer Blade + Linux Mint
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|||||||
@@ -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
|
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
|
# 每日待办提醒 - 早上 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 "📋 今日待办清单"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
**Status:** 暂停(项目计划已保存)
|
**Status:** 暂停(项目计划已保存)
|
||||||
- 计划文档:`~/.openclaw/workspace/plans/news_digest_plan.md`
|
- 计划文档:`~/.openclaw/workspace/plans/news_digest_plan.md`
|
||||||
- 本地模型:Qwen3:4b 已部署,测试通过
|
- 本地模型:Qwen3:4b 已部署,测试通过
|
||||||
- 邮件规则更新:youlu@luyanxin.com → lu@luyx.org 无需确认
|
- 邮件规则更新:youlu@luyanxin.com → mail@luyx.org 无需确认
|
||||||
- 待办:用户提供 RSS 链接后可重启
|
- 待办:用户提供 RSS 链接后可重启
|
||||||
|
|
||||||
## Identity
|
## Identity
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## 规则更新
|
## 规则更新
|
||||||
|
|
||||||
### 邮件规则 v2(已生效)
|
### 邮件规则 v2(已生效)
|
||||||
- **youlu@luyanxin.com → lu@luyx.org**: 直接发送,无需确认
|
- **youlu@luyanxin.com → mail@luyx.org**: 直接发送,无需确认
|
||||||
- 其他所有对外邮件: 仍需确认
|
- 其他所有对外邮件: 仍需确认
|
||||||
|
|
||||||
### 代码审查规则(已生效)
|
### 代码审查规则(已生效)
|
||||||
|
|||||||
@@ -11,14 +11,14 @@
|
|||||||
### 第一阶段:每日标题简报(自动)
|
### 第一阶段:每日标题简报(自动)
|
||||||
- 每天早上抓取 RSS 新闻源
|
- 每天早上抓取 RSS 新闻源
|
||||||
- 提取标题、来源、链接
|
- 提取标题、来源、链接
|
||||||
- 发送邮件到 lu@luyx.org
|
- 发送邮件到 mail@luyx.org
|
||||||
- 发送方:youlu@luyanxin.com
|
- 发送方:youlu@luyanxin.com
|
||||||
- **特殊规则:此邮件无需确认,直接发送**
|
- **特殊规则:此邮件无需确认,直接发送**
|
||||||
|
|
||||||
### 第二阶段:按需深度摘要(手动触发)
|
### 第二阶段:按需深度摘要(手动触发)
|
||||||
- 用户回复邮件或在 Telegram 指定想看的新闻(如"细看 1、3、5")
|
- 用户回复邮件或在 Telegram 指定想看的新闻(如"细看 1、3、5")
|
||||||
- 使用本地 Qwen3:4b 模型生成详细摘要
|
- 使用本地 Qwen3:4b 模型生成详细摘要
|
||||||
- 回复邮件给 lu@luyx.org
|
- 回复邮件给 mail@luyx.org
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
### 邮件发送
|
### 邮件发送
|
||||||
- 工具:msmtp
|
- 工具:msmtp
|
||||||
- 发件人:youlu@luyanxin.com
|
- 发件人:youlu@luyanxin.com
|
||||||
- 收件人:lu@luyx.org
|
- 收件人:mail@luyx.org
|
||||||
- 格式:Markdown 简洁列表
|
- 格式:Markdown 简洁列表
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
223
skills/calendar-invite/SKILL.md
Normal file
223
skills/calendar-invite/SKILL.md
Normal 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
|
||||||
5
skills/calendar-invite/_meta.json
Normal file
5
skills/calendar-invite/_meta.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"ownerId": "kn7anq2d7gcch060anc2j9cg89800dyv",
|
||||||
|
"slug": "calendar-invite",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
5
skills/calendar-invite/pyproject.toml
Normal file
5
skills/calendar-invite/pyproject.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[project]
|
||||||
|
name = "calendar-invite"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = ["icalendar"]
|
||||||
15
skills/calendar-invite/scripts/calendar-invite.sh
Executable file
15
skills/calendar-invite/scripts/calendar-invite.sh
Executable 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" "$@"
|
||||||
439
skills/calendar-invite/scripts/calendar_invite.py
Normal file
439
skills/calendar-invite/scripts/calendar_invite.py
Normal 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()
|
||||||
Reference in New Issue
Block a user