VTODO
This commit is contained in:
@@ -96,7 +96,7 @@ _这份文件记录持续性项目和重要状态,跨会话保留。_
|
|||||||
**状态**: 运行中
|
**状态**: 运行中
|
||||||
**创建**: 2026-03-18
|
**创建**: 2026-03-18
|
||||||
**配置**:
|
**配置**:
|
||||||
- 技能: `~/.openclaw/workspace/skills/calendar-invite/`
|
- 技能: `~/.openclaw/workspace/skills/calendar/`
|
||||||
- 日历数据: `~/.openclaw/workspace/calendars/` (home/work/tasks/journals)
|
- 日历数据: `~/.openclaw/workspace/calendars/` (home/work/tasks/journals)
|
||||||
- CalDAV: Migadu (`cdav.migadu.com`),通过 vdirsyncer 同步
|
- CalDAV: Migadu (`cdav.migadu.com`),通过 vdirsyncer 同步
|
||||||
- 查看日历: khal
|
- 查看日历: khal
|
||||||
@@ -105,7 +105,8 @@ _这份文件记录持续性项目和重要状态,跨会话保留。_
|
|||||||
**功能**:
|
**功能**:
|
||||||
- 发送日历邀请(自动添加 mail@luyx.org 为参与者)
|
- 发送日历邀请(自动添加 mail@luyx.org 为参与者)
|
||||||
- 接受/拒绝/暂定回复邀请(自动转发给 mail@luyx.org)
|
- 接受/拒绝/暂定回复邀请(自动转发给 mail@luyx.org)
|
||||||
- 发送/回复后自动 `vdirsyncer sync` 同步到 CalDAV
|
- VTODO 待办管理(add/list/complete/delete/check)
|
||||||
|
- 发送/回复/待办操作后自动 `vdirsyncer sync` 同步到 CalDAV
|
||||||
- 心跳定期同步日历
|
- 心跳定期同步日历
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -117,7 +118,7 @@ _这份文件记录持续性项目和重要状态,跨会话保留。_
|
|||||||
| 待办提醒 | `~/.openclaw/workspace/scripts/reminder_check.py` |
|
| 待办提醒 | `~/.openclaw/workspace/scripts/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/skills/calendar/` |
|
||||||
| 日历数据 | `~/.openclaw/workspace/calendars/` |
|
| 日历数据 | `~/.openclaw/workspace/calendars/` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
24
TOOLS.md
24
TOOLS.md
@@ -115,37 +115,43 @@ agent-browser close
|
|||||||
- `data/pending_emails.json` — 待处理队列
|
- `data/pending_emails.json` — 待处理队列
|
||||||
- `logs/` — 处理日志
|
- `logs/` — 处理日志
|
||||||
|
|
||||||
### Calendar Invite 日历邀请
|
### Calendar 日历 + 待办
|
||||||
|
|
||||||
**文档**: `~/.openclaw/workspace/skills/calendar-invite/SKILL.md`
|
**文档**: `~/.openclaw/workspace/skills/calendar/SKILL.md`
|
||||||
**目录**: `~/.openclaw/workspace/skills/calendar-invite/`
|
**目录**: `~/.openclaw/workspace/skills/calendar/`
|
||||||
**默认发件人**: youlu@luyanxin.com
|
**默认发件人**: youlu@luyanxin.com
|
||||||
**默认时区**: America/Los_Angeles
|
**默认时区**: America/Los_Angeles
|
||||||
**日历数据**: `~/.openclaw/workspace/calendars/home/`
|
**日历数据**: `~/.openclaw/workspace/calendars/home/`(事件)、`calendars/tasks/`(待办)
|
||||||
**运行方式**: `uv run`(依赖 `icalendar` 库)
|
**运行方式**: `uv run`(依赖 `icalendar` 库)
|
||||||
|
|
||||||
**核心用法**:
|
**核心用法**:
|
||||||
```bash
|
```bash
|
||||||
SKILL_DIR=~/.openclaw/workspace/skills/calendar-invite
|
SKILL_DIR=~/.openclaw/workspace/skills/calendar
|
||||||
|
|
||||||
# 发送日历邀请(--from 默认 youlu@luyanxin.com)
|
# 发送日历邀请(--from 默认 youlu@luyanxin.com)
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh send \
|
$SKILL_DIR/scripts/calendar.sh send \
|
||||||
--to "friend@example.com" \
|
--to "friend@example.com" \
|
||||||
--subject "Lunch" --summary "Lunch at Tartine" \
|
--subject "Lunch" --summary "Lunch at Tartine" \
|
||||||
--start "2026-03-20T12:00:00" --end "2026-03-20T13:00:00"
|
--start "2026-03-20T12:00:00" --end "2026-03-20T13:00:00"
|
||||||
|
|
||||||
# 接受邀请(从邮件中提取 .ics)
|
# 接受邀请(从邮件中提取 .ics)
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh reply --envelope-id 42 --action accept
|
$SKILL_DIR/scripts/calendar.sh reply --envelope-id 42 --action accept
|
||||||
|
|
||||||
# 拒绝邀请(附带留言)
|
# 拒绝邀请(附带留言)
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh reply --envelope-id 42 --action decline \
|
$SKILL_DIR/scripts/calendar.sh reply --envelope-id 42 --action decline \
|
||||||
--comment "Sorry, I have a conflict."
|
--comment "Sorry, I have a conflict."
|
||||||
|
|
||||||
|
# 待办管理
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo add --summary "跟进报销" --due "2026-03-25" --priority high
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo list
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo complete --match "报销"
|
||||||
|
$SKILL_DIR/scripts/calendar.sh todo check # 每日摘要(cron)
|
||||||
|
|
||||||
# 查看日历(检查冲突)
|
# 查看日历(检查冲突)
|
||||||
khal list today 7d
|
khal list today 7d
|
||||||
```
|
```
|
||||||
|
|
||||||
**支持操作**: 发送邀请 (`send`)、接受/拒绝/暂定 (`reply`)
|
**支持操作**: 发送邀请 (`send`)、接受/拒绝/暂定 (`reply`)、待办管理 (`todo add/list/complete/delete/check`)
|
||||||
**依赖**: himalaya(邮件)、vdirsyncer(CalDAV 同步)、khal(查看日历)
|
**依赖**: himalaya(邮件)、vdirsyncer(CalDAV 同步)、khal(查看日历)
|
||||||
**同步**: 发送/回复后自动 `vdirsyncer sync`,心跳也会定期同步
|
**同步**: 发送/回复后自动 `vdirsyncer sync`,心跳也会定期同步
|
||||||
**自动抄送**: mail@luyx.org(用户别名)自动加入所有邀请
|
**自动抄送**: mail@luyx.org(用户别名)自动加入所有邀请
|
||||||
|
|||||||
@@ -1,227 +0,0 @@
|
|||||||
---
|
|
||||||
name: calendar-invite
|
|
||||||
description: "Send, accept, and decline calendar invite emails (ICS/iCalendar) via himalaya. Syncs events to CalDAV (Migadu) via vdirsyncer."
|
|
||||||
metadata: {"clawdbot":{"emoji":"📅","requires":{"bins":["himalaya","vdirsyncer"],"skills":["himalaya"]}}}
|
|
||||||
---
|
|
||||||
|
|
||||||
# Calendar Invite
|
|
||||||
|
|
||||||
Send, accept, and decline calendar invitations via email using himalaya. Events are saved to local calendar and synced to CalDAV (Migadu) via vdirsyncer.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
See `TESTING.md` for dry-run and live test steps, verification checklists, and troubleshooting.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- `himalaya` configured and working (see the `himalaya` skill)
|
|
||||||
- `vdirsyncer` configured and syncing to `~/.openclaw/workspace/calendars/`
|
|
||||||
- `khal` for reading calendar (optional but recommended)
|
|
||||||
- Runs via `uv run` (dependencies managed in `pyproject.toml`)
|
|
||||||
|
|
||||||
## Important: Email Sending Rules
|
|
||||||
|
|
||||||
Calendar invites are outbound emails. Follow the workspace email rules:
|
|
||||||
- **youlu@luyanxin.com -> mail@luyx.org**: send directly, no confirmation needed
|
|
||||||
- **All other recipients**: confirm with user before sending
|
|
||||||
|
|
||||||
## Owner Auto-Attendee
|
|
||||||
|
|
||||||
When sending invites, `mail@luyx.org` (owner's SimpleLogin alias) is **always added as an attendee automatically**. All invites include a **1-day reminder** (VALARM) by default. This ensures the owner receives every invite and can Accept/Decline from their own email client. No need to include it in `--to` — it's added by the script.
|
|
||||||
|
|
||||||
When accepting or tentatively accepting a received invite, the original invite is **automatically forwarded to `mail@luyx.org`** so the event lands on the owner's calendar too.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
All commands go through the wrapper script:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SKILL_DIR=~/.openclaw/workspace/skills/calendar-invite
|
|
||||||
|
|
||||||
# Send an invite
|
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh send [options]
|
|
||||||
|
|
||||||
# Reply to an invite
|
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh reply [options]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sending Invites
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh send \
|
|
||||||
--to "friend@example.com" \
|
|
||||||
--subject "Lunch on Friday" \
|
|
||||||
--summary "Lunch at Tartine" \
|
|
||||||
--start "2026-03-20T12:00:00" \
|
|
||||||
--end "2026-03-20T13:00:00" \
|
|
||||||
--location "Tartine Bakery, SF"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Send Options
|
|
||||||
|
|
||||||
| Flag | Required | Description |
|
|
||||||
|-----------------|----------|------------------------------------------------|
|
|
||||||
| `--to` | Yes | Recipient(s), comma-separated |
|
|
||||||
| `--subject` | Yes | Email subject line |
|
|
||||||
| `--summary` | Yes | Event title (shown on calendar) |
|
|
||||||
| `--start` | Yes | Start time, ISO 8601 (`2026-03-20T14:00:00`) |
|
|
||||||
| `--end` | Yes | End time, ISO 8601 (`2026-03-20T15:00:00`) |
|
|
||||||
| `--from` | No | Sender email (default: `youlu@luyanxin.com`) |
|
|
||||||
| `--timezone` | No | IANA timezone (default: `America/Los_Angeles`) |
|
|
||||||
| `--location` | No | Event location |
|
|
||||||
| `--description` | No | Event description / notes |
|
|
||||||
| `--organizer` | No | Organizer display name (defaults to `--from`) |
|
|
||||||
| `--uid` | No | Custom event UID (auto-generated if omitted) |
|
|
||||||
| `--account` | No | Himalaya account name (if not default) |
|
|
||||||
| `--dry-run` | No | Print ICS + MIME without sending |
|
|
||||||
|
|
||||||
### Send Examples
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Simple invite (--from and --timezone default to youlu@luyanxin.com / LA)
|
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh send \
|
|
||||||
--to "alice@example.com" \
|
|
||||||
--subject "Coffee Chat" \
|
|
||||||
--summary "Coffee Chat" \
|
|
||||||
--start "2026-03-25T10:00:00" \
|
|
||||||
--end "2026-03-25T10:30:00"
|
|
||||||
|
|
||||||
# Multiple attendees with details
|
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh send \
|
|
||||||
--to "alice@example.com, bob@example.com" \
|
|
||||||
--subject "Team Sync" \
|
|
||||||
--summary "Weekly Team Sync" \
|
|
||||||
--start "2026-03-23T09:00:00" \
|
|
||||||
--end "2026-03-23T09:30:00" \
|
|
||||||
--location "Zoom - https://zoom.us/j/123456" \
|
|
||||||
--description "Weekly check-in. Agenda: updates, blockers, action items."
|
|
||||||
|
|
||||||
# Dry run
|
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh send \
|
|
||||||
--to "test@example.com" \
|
|
||||||
--subject "Test" \
|
|
||||||
--summary "Test Event" \
|
|
||||||
--start "2026-04-01T15:00:00" \
|
|
||||||
--end "2026-04-01T16:00:00" \
|
|
||||||
--dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Replying to Invites
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Accept by himalaya envelope ID
|
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh reply \
|
|
||||||
--envelope-id 42 \
|
|
||||||
--action accept
|
|
||||||
|
|
||||||
# Decline with a comment
|
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh reply \
|
|
||||||
--envelope-id 42 \
|
|
||||||
--action decline \
|
|
||||||
--comment "Sorry, I have a conflict."
|
|
||||||
|
|
||||||
# From an .ics file
|
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh reply \
|
|
||||||
--ics-file ~/Downloads/meeting.ics \
|
|
||||||
--action tentative
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reply Options
|
|
||||||
|
|
||||||
| Flag | Required | Description |
|
|
||||||
|-----------------|----------|-----------------------------------------------------|
|
|
||||||
| `--action` | Yes | `accept`, `decline`, or `tentative` |
|
|
||||||
| `--envelope-id` | * | Himalaya envelope ID containing the .ics attachment |
|
|
||||||
| `--ics-file` | * | Path to an .ics file (alternative to `--envelope-id`) |
|
|
||||||
| `--from` | No | Your email (default: `youlu@luyanxin.com`) |
|
|
||||||
| `--account` | No | Himalaya account name |
|
|
||||||
| `--folder` | No | Himalaya folder (default: `INBOX`) |
|
|
||||||
| `--comment` | No | Optional message to include in reply |
|
|
||||||
| `--dry-run` | No | Preview without sending |
|
|
||||||
|
|
||||||
\* One of `--envelope-id` or `--ics-file` is required.
|
|
||||||
|
|
||||||
### Typical Workflow
|
|
||||||
|
|
||||||
1. List emails: `himalaya envelope list`
|
|
||||||
2. Read the invite: `himalaya message read 57`
|
|
||||||
3. Reply: `$SKILL_DIR/scripts/calendar-invite.sh reply --envelope-id 57 --action accept`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
**Sending invites:**
|
|
||||||
1. Generates an RFC 5545 ICS file with `METHOD:REQUEST` (via `icalendar` library)
|
|
||||||
2. Builds a MIME email with a `text/calendar` attachment (via Python `email.mime`)
|
|
||||||
3. Sends via `himalaya message send` (piped through stdin)
|
|
||||||
4. Saves the event to `~/.openclaw/workspace/calendars/home/`
|
|
||||||
5. Runs `vdirsyncer sync` to push to Migadu CalDAV
|
|
||||||
|
|
||||||
**Replying to invites:**
|
|
||||||
1. Extracts the `.ics` attachment from the email (via `himalaya attachment download`)
|
|
||||||
2. Parses the original event with the `icalendar` library
|
|
||||||
3. Generates a reply ICS with `METHOD:REPLY` and the correct `PARTSTAT`
|
|
||||||
4. Sends the reply to the organizer via `himalaya message send` (stdin)
|
|
||||||
5. On accept/tentative: saves event to local calendar. On decline: removes it
|
|
||||||
6. Runs `vdirsyncer sync` to push changes to Migadu CalDAV
|
|
||||||
|
|
||||||
**CalDAV sync:**
|
|
||||||
- Events sync to Migadu and appear on all connected devices (DAVx5, etc.)
|
|
||||||
- Heartbeat runs `vdirsyncer sync` periodically as a fallback
|
|
||||||
- If sync fails, it warns but doesn't block — next heartbeat catches up
|
|
||||||
|
|
||||||
## Integration with Email Processor
|
|
||||||
|
|
||||||
The email processor (`scripts/email_processor/`) may classify incoming calendar invites as `reminder` or `confirmation`. When reviewing pending emails:
|
|
||||||
1. Check if the email contains a calendar invite (look for `.ics` attachment or "calendar" in subject)
|
|
||||||
2. If it does, use `reply` instead of the email processor's delete/archive/keep actions
|
|
||||||
3. The email processor handles the email lifecycle; this skill handles the calendar response
|
|
||||||
|
|
||||||
## Checking the Calendar
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List upcoming events (next 7 days)
|
|
||||||
khal list today 7d
|
|
||||||
|
|
||||||
# List events for a specific date
|
|
||||||
khal list 2026-03-25
|
|
||||||
|
|
||||||
# Check for conflicts before sending an invite
|
|
||||||
khal list 2026-03-25 2026-03-26
|
|
||||||
```
|
|
||||||
|
|
||||||
## Timezone Reference
|
|
||||||
|
|
||||||
Common IANA timezones:
|
|
||||||
- `America/Los_Angeles` — Pacific (default)
|
|
||||||
- `America/Denver` — Mountain
|
|
||||||
- `America/Chicago` — Central
|
|
||||||
- `America/New_York` — Eastern
|
|
||||||
- `Asia/Shanghai` — China
|
|
||||||
- `Asia/Tokyo` — Japan
|
|
||||||
- `Europe/London` — UK
|
|
||||||
- `UTC` — Coordinated Universal Time
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
**Invite shows as attachment instead of calendar event?**
|
|
||||||
- Ensure the MIME part has `Content-Type: text/calendar; method=REQUEST`
|
|
||||||
- Some clients require the `METHOD:REQUEST` line in the ICS body
|
|
||||||
|
|
||||||
**Times are wrong?**
|
|
||||||
- Double-check `--timezone` matches the intended timezone
|
|
||||||
- Use ISO 8601 format: `YYYY-MM-DDTHH:MM:SS` (no timezone offset in the value)
|
|
||||||
|
|
||||||
**Event not showing on phone/other devices?**
|
|
||||||
- Run `vdirsyncer sync` manually to force sync
|
|
||||||
- Check `~/.openclaw/workspace/logs/vdirsyncer.log` for errors
|
|
||||||
- Verify the .ics file exists in `~/.openclaw/workspace/calendars/home/`
|
|
||||||
|
|
||||||
**Recipient doesn't see Accept/Decline?**
|
|
||||||
- Gmail, Outlook, Apple Mail all support `text/calendar` method=REQUEST
|
|
||||||
- Some webmail clients may vary
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
# Testing the Calendar Invite Skill
|
|
||||||
|
|
||||||
End-to-end tests for send, reply, calendar sync, and local calendar. All commands use `--dry-run` first, then live.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SKILL_DIR=~/.openclaw/workspace/skills/calendar-invite
|
|
||||||
|
|
||||||
# Use a date 3 days from now for test events
|
|
||||||
TEST_DATE=$(date -d "+3 days" +%Y-%m-%d)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Dry Run: Send Invite
|
|
||||||
|
|
||||||
Generates the ICS and MIME email without sending. Check that:
|
|
||||||
- ICS has `METHOD:REQUEST`
|
|
||||||
- MIME has `Content-Type: text/calendar; method=REQUEST`
|
|
||||||
- `mail@luyx.org` appears as attendee (auto-added)
|
|
||||||
- Times and timezone look correct
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh send \
|
|
||||||
--to "mail@luyx.org" \
|
|
||||||
--subject "Test Invite" \
|
|
||||||
--summary "Test Event" \
|
|
||||||
--start "${TEST_DATE}T15:00:00" \
|
|
||||||
--end "${TEST_DATE}T16:00:00" \
|
|
||||||
--dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Live Send: Self-Invite
|
|
||||||
|
|
||||||
Send a real invite to `mail@luyx.org` only (no confirmation needed per email rules).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh send \
|
|
||||||
--to "mail@luyx.org" \
|
|
||||||
--subject "Calendar Skill Test" \
|
|
||||||
--summary "Calendar Skill Test" \
|
|
||||||
--start "${TEST_DATE}T15:00:00" \
|
|
||||||
--end "${TEST_DATE}T16:00:00" \
|
|
||||||
--location "Test Location"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Verify:**
|
|
||||||
- [ ] Script exits without error
|
|
||||||
- [ ] Email arrives at `mail@luyx.org`
|
|
||||||
- [ ] Email shows Accept/Decline/Tentative buttons (not just an attachment)
|
|
||||||
- [ ] `.ics` file saved to `~/.openclaw/workspace/calendars/home/`
|
|
||||||
|
|
||||||
## 3. Verify Calendar Sync and Local Calendar
|
|
||||||
|
|
||||||
After sending in step 2, check that the event synced and appears locally.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check vdirsyncer sync ran (should have printed "Synced to CalDAV server" in step 2)
|
|
||||||
# If not, run manually:
|
|
||||||
vdirsyncer sync
|
|
||||||
|
|
||||||
# List .ics files in local calendar
|
|
||||||
ls ~/.openclaw/workspace/calendars/home/
|
|
||||||
|
|
||||||
# Check the event shows up in khal
|
|
||||||
khal list "$TEST_DATE"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Verify:**
|
|
||||||
- [ ] `vdirsyncer sync` completes without errors
|
|
||||||
- [ ] `.ics` file exists in `~/.openclaw/workspace/calendars/home/`
|
|
||||||
- [ ] `khal list` shows "Calendar Skill Test" on the test date
|
|
||||||
|
|
||||||
## 4. Reply: Accept the Self-Invite
|
|
||||||
|
|
||||||
The invite sent in step 2 should be in the inbox. Find it, then accept it. This tests the full reply flow without needing an external sender.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Find the test invite in inbox
|
|
||||||
himalaya envelope list
|
|
||||||
|
|
||||||
# Confirm it's the calendar invite
|
|
||||||
himalaya message read <envelope-id>
|
|
||||||
|
|
||||||
# Accept it
|
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh reply \
|
|
||||||
--envelope-id <envelope-id> \
|
|
||||||
--action accept
|
|
||||||
```
|
|
||||||
|
|
||||||
**Verify:**
|
|
||||||
- [ ] Reply sent to organizer (youlu@luyanxin.com, i.e. ourselves)
|
|
||||||
- [ ] Original invite forwarded to `mail@luyx.org`
|
|
||||||
- [ ] Event still in `~/.openclaw/workspace/calendars/home/`
|
|
||||||
- [ ] `vdirsyncer sync` ran
|
|
||||||
- [ ] `khal list "$TEST_DATE"` still shows the event
|
|
||||||
|
|
||||||
## 5. Reply: Decline an Invite
|
|
||||||
|
|
||||||
Send another self-invite, then decline it. This verifies decline removes the event from local calendar.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Send a second test invite
|
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh send \
|
|
||||||
--to "mail@luyx.org" \
|
|
||||||
--subject "Decline Test" \
|
|
||||||
--summary "Decline Test Event" \
|
|
||||||
--start "${TEST_DATE}T17:00:00" \
|
|
||||||
--end "${TEST_DATE}T18:00:00"
|
|
||||||
|
|
||||||
# Find it in inbox
|
|
||||||
himalaya envelope list
|
|
||||||
|
|
||||||
# Decline it
|
|
||||||
$SKILL_DIR/scripts/calendar-invite.sh reply \
|
|
||||||
--envelope-id <envelope-id> \
|
|
||||||
--action decline \
|
|
||||||
--comment "Testing decline flow."
|
|
||||||
```
|
|
||||||
|
|
||||||
**Verify:**
|
|
||||||
- [ ] Reply sent to organizer with comment
|
|
||||||
- [ ] Event NOT forwarded to `mail@luyx.org`
|
|
||||||
- [ ] Event removed from local calendar
|
|
||||||
- [ ] `khal list "$TEST_DATE"` does NOT show "Decline Test Event"
|
|
||||||
|
|
||||||
## 6. Verify Final Calendar State
|
|
||||||
|
|
||||||
After all tests, confirm the calendar is in a clean state.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Sync one more time
|
|
||||||
vdirsyncer sync
|
|
||||||
|
|
||||||
# Only the accepted event should remain
|
|
||||||
khal list "$TEST_DATE"
|
|
||||||
|
|
||||||
# List all upcoming events
|
|
||||||
khal list today 7d
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Health Checks
|
|
||||||
|
|
||||||
Run these first if any step fails.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# icalendar library is available
|
|
||||||
uv run --project $SKILL_DIR python -c "import icalendar; print('ok')"
|
|
||||||
|
|
||||||
# himalaya can list emails
|
|
||||||
himalaya envelope list --page-size 5
|
|
||||||
|
|
||||||
# vdirsyncer can sync
|
|
||||||
vdirsyncer sync
|
|
||||||
|
|
||||||
# khal can read local calendar
|
|
||||||
khal list today 7d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Failures
|
|
||||||
|
|
||||||
| Symptom | Likely Cause |
|
|
||||||
|---------|-------------|
|
|
||||||
| `himalaya message send` errors | SMTP config issue, check `~/.config/himalaya/config.toml` |
|
|
||||||
| No `.ics` attachment found | Email doesn't have a calendar invite, or himalaya can't download attachments |
|
|
||||||
| `vdirsyncer sync` fails | Check credentials in `~/.config/vdirsyncer/config`, or server is unreachable |
|
|
||||||
| `ModuleNotFoundError: icalendar` | Run `uv sync --project $SKILL_DIR` to install dependencies |
|
|
||||||
| Invite shows as attachment (no Accept/Decline) | Check MIME `Content-Type` includes `method=REQUEST` |
|
|
||||||
| Event not in `khal list` after sync | Check `.ics` file exists in `~/.openclaw/workspace/calendars/home/` |
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"ownerId": "kn7anq2d7gcch060anc2j9cg89800dyv",
|
|
||||||
"slug": "calendar-invite",
|
|
||||||
"version": "1.0.0"
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
[project]
|
|
||||||
name = "calendar-invite"
|
|
||||||
version = "0.1.0"
|
|
||||||
requires-python = ">=3.10"
|
|
||||||
dependencies = ["icalendar"]
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# calendar-invite — wrapper script for the calendar invite tool.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./calendar-invite.sh send [options] # send a calendar invite
|
|
||||||
# ./calendar-invite.sh reply [options] # accept/decline/tentative
|
|
||||||
#
|
|
||||||
# Requires: uv, himalaya, vdirsyncer (for CalDAV sync).
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
|
|
||||||
exec uv run --project "$SKILL_DIR" python "$SCRIPT_DIR/calendar_invite.py" "$@"
|
|
||||||
@@ -1,417 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Calendar Invite — Send, accept, and decline calendar invites via himalaya.
|
|
||||||
|
|
||||||
Uses the icalendar library for proper RFC 5545 ICS generation and parsing.
|
|
||||||
Uses himalaya CLI for email delivery. Syncs to local CalDAV via vdirsyncer.
|
|
||||||
|
|
||||||
Subcommands:
|
|
||||||
python calendar_invite.py send [options] # create and send an invite
|
|
||||||
python calendar_invite.py reply [options] # accept/decline/tentative
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from email.mime.base import MIMEBase
|
|
||||||
from email.mime.multipart import MIMEMultipart
|
|
||||||
from email.mime.text import MIMEText
|
|
||||||
|
|
||||||
from icalendar import Alarm, Calendar, Event, vCalAddress, vText
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Config
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
DEFAULT_TIMEZONE = "America/Los_Angeles"
|
|
||||||
DEFAULT_FROM = "youlu@luyanxin.com"
|
|
||||||
DEFAULT_OWNER_EMAIL = "mail@luyx.org" # Always added as attendee
|
|
||||||
CALENDAR_DIR = Path.home() / ".openclaw" / "workspace" / "calendars" / "home"
|
|
||||||
PRODID = "-//OpenClaw//CalendarInvite//EN"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _sync_calendar():
|
|
||||||
"""Sync local calendar to CalDAV server via vdirsyncer."""
|
|
||||||
try:
|
|
||||||
subprocess.run(
|
|
||||||
["vdirsyncer", "sync"],
|
|
||||||
capture_output=True, text=True, check=True,
|
|
||||||
)
|
|
||||||
print("Synced to CalDAV server")
|
|
||||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
||||||
print("Warning: CalDAV sync failed (will retry on next heartbeat)")
|
|
||||||
|
|
||||||
|
|
||||||
def _send_email(email_str, account=None):
|
|
||||||
"""Send a raw MIME email via himalaya message send (stdin)."""
|
|
||||||
cmd = ["himalaya"]
|
|
||||||
if account:
|
|
||||||
cmd += ["--account", account]
|
|
||||||
cmd += ["message", "send"]
|
|
||||||
subprocess.run(cmd, input=email_str, text=True, check=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_calendar_email(from_addr, to_addr, subject, body, ics_bytes, method="REQUEST"):
|
|
||||||
"""Build a MIME email with a text/calendar attachment."""
|
|
||||||
msg = MIMEMultipart('mixed')
|
|
||||||
msg['From'] = from_addr
|
|
||||||
msg['To'] = to_addr
|
|
||||||
msg['Subject'] = subject
|
|
||||||
msg.attach(MIMEText(body, 'plain', 'utf-8'))
|
|
||||||
ics_part = MIMEBase('text', 'calendar', method=method, charset='utf-8')
|
|
||||||
ics_part.set_payload(ics_bytes.decode('utf-8'))
|
|
||||||
ics_part.add_header('Content-Disposition', 'attachment; filename="invite.ics"')
|
|
||||||
msg.attach(ics_part)
|
|
||||||
return msg.as_string()
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_method(ics_bytes):
|
|
||||||
"""Remove METHOD property from ICS for CalDAV storage.
|
|
||||||
|
|
||||||
CalDAV servers reject METHOD (it's an iTIP/email concept, not a storage one).
|
|
||||||
"""
|
|
||||||
cal = Calendar.from_ical(ics_bytes)
|
|
||||||
if "method" in cal:
|
|
||||||
del cal["method"]
|
|
||||||
return cal.to_ical()
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_iso_datetime(dt_str):
|
|
||||||
"""Parse ISO 8601 datetime string to a datetime object."""
|
|
||||||
# Handle both 2026-03-20T14:00:00 and 2026-03-20T14:00
|
|
||||||
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"):
|
|
||||||
try:
|
|
||||||
return datetime.strptime(dt_str, fmt)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
raise ValueError(f"Cannot parse datetime: {dt_str}")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Send invite
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def cmd_send(args):
|
|
||||||
"""Create and send a calendar invite."""
|
|
||||||
start = _parse_iso_datetime(args.start)
|
|
||||||
end = _parse_iso_datetime(args.end)
|
|
||||||
uid = args.uid or f"{uuid.uuid4()}@openclaw"
|
|
||||||
organizer_name = args.organizer or args.sender
|
|
||||||
|
|
||||||
# Build ICS
|
|
||||||
cal = Calendar()
|
|
||||||
cal.add("prodid", PRODID)
|
|
||||||
cal.add("version", "2.0")
|
|
||||||
cal.add("calscale", "GREGORIAN")
|
|
||||||
cal.add("method", "REQUEST")
|
|
||||||
|
|
||||||
event = Event()
|
|
||||||
event.add("uid", uid)
|
|
||||||
event.add("dtstamp", datetime.now(timezone.utc))
|
|
||||||
event.add("dtstart", start, parameters={"TZID": args.timezone})
|
|
||||||
event.add("dtend", end, parameters={"TZID": args.timezone})
|
|
||||||
event.add("summary", args.summary)
|
|
||||||
event.add("status", "CONFIRMED")
|
|
||||||
event.add("sequence", 0)
|
|
||||||
organizer = vCalAddress(f"mailto:{args.sender}")
|
|
||||||
organizer.params["CN"] = vText(organizer_name)
|
|
||||||
event.add("organizer", organizer)
|
|
||||||
|
|
||||||
if args.location:
|
|
||||||
event.add("location", args.location)
|
|
||||||
if args.description:
|
|
||||||
event.add("description", args.description)
|
|
||||||
|
|
||||||
recipients = [addr.strip() for addr in args.to.split(",")]
|
|
||||||
|
|
||||||
# Always include owner as attendee
|
|
||||||
all_attendees = list(recipients)
|
|
||||||
if DEFAULT_OWNER_EMAIL not in all_attendees:
|
|
||||||
all_attendees.append(DEFAULT_OWNER_EMAIL)
|
|
||||||
|
|
||||||
for addr in all_attendees:
|
|
||||||
event.add("attendee", f"mailto:{addr}", parameters={
|
|
||||||
"ROLE": "REQ-PARTICIPANT",
|
|
||||||
"RSVP": "TRUE",
|
|
||||||
})
|
|
||||||
|
|
||||||
# 1-day reminder
|
|
||||||
alarm = Alarm()
|
|
||||||
alarm.add("action", "DISPLAY")
|
|
||||||
alarm.add("description", f"Reminder: {args.summary}")
|
|
||||||
alarm.add("trigger", timedelta(days=-1))
|
|
||||||
event.add_component(alarm)
|
|
||||||
|
|
||||||
cal.add_component(event)
|
|
||||||
ics_bytes = cal.to_ical()
|
|
||||||
|
|
||||||
# Build plain text body
|
|
||||||
body = f"You're invited to: {args.summary}\n\nWhen: {args.start} - {args.end} ({args.timezone})"
|
|
||||||
if args.location:
|
|
||||||
body += f"\nWhere: {args.location}"
|
|
||||||
if args.description:
|
|
||||||
body += f"\n\n{args.description}"
|
|
||||||
|
|
||||||
# Email goes to all attendees (including owner)
|
|
||||||
all_to = ", ".join(all_attendees)
|
|
||||||
|
|
||||||
# Build MIME email
|
|
||||||
email_str = _build_calendar_email(args.sender, all_to, args.subject, body, ics_bytes, method="REQUEST")
|
|
||||||
|
|
||||||
if args.dry_run:
|
|
||||||
print("=== ICS Content ===")
|
|
||||||
print(ics_bytes.decode())
|
|
||||||
print("=== Email Message ===")
|
|
||||||
print(email_str)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Send email via himalaya message send (stdin)
|
|
||||||
_send_email(email_str, args.account)
|
|
||||||
print(f"Calendar invite sent to: {args.to}")
|
|
||||||
|
|
||||||
# Save to local calendar (without METHOD for CalDAV compatibility)
|
|
||||||
if CALENDAR_DIR.is_dir():
|
|
||||||
dest = CALENDAR_DIR / f"{uid}.ics"
|
|
||||||
dest.write_bytes(_strip_method(ics_bytes))
|
|
||||||
print(f"Saved to local calendar: {dest}")
|
|
||||||
_sync_calendar()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Reply to invite
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
PARTSTAT_MAP = {
|
|
||||||
"accept": "ACCEPTED",
|
|
||||||
"accepted": "ACCEPTED",
|
|
||||||
"decline": "DECLINED",
|
|
||||||
"declined": "DECLINED",
|
|
||||||
"tentative": "TENTATIVE",
|
|
||||||
}
|
|
||||||
|
|
||||||
SUBJECT_PREFIX = {
|
|
||||||
"ACCEPTED": "Accepted",
|
|
||||||
"DECLINED": "Declined",
|
|
||||||
"TENTATIVE": "Tentative",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_ics_from_email(envelope_id, folder, account):
|
|
||||||
"""Download attachments from an email and find the .ics file."""
|
|
||||||
download_dir = Path(f"/tmp/openclaw-ics-extract-{envelope_id}")
|
|
||||||
download_dir.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
cmd = ["himalaya"]
|
|
||||||
if account:
|
|
||||||
cmd += ["--account", account]
|
|
||||||
cmd += ["attachment", "download", "--folder", folder, str(envelope_id), "--dir", str(download_dir)]
|
|
||||||
|
|
||||||
try:
|
|
||||||
subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
pass # some emails have no attachments
|
|
||||||
|
|
||||||
ics_files = list(download_dir.glob("*.ics"))
|
|
||||||
if not ics_files:
|
|
||||||
print(f"Error: No .ics attachment found in envelope {envelope_id}", file=sys.stderr)
|
|
||||||
# Cleanup
|
|
||||||
for f in download_dir.iterdir():
|
|
||||||
f.unlink()
|
|
||||||
download_dir.rmdir()
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
return ics_files[0], download_dir
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_reply(args):
|
|
||||||
"""Accept, decline, or tentatively accept a calendar invite."""
|
|
||||||
partstat = PARTSTAT_MAP.get(args.action.lower())
|
|
||||||
if not partstat:
|
|
||||||
print(f"Error: --action must be accept, decline, or tentative", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Get the ICS file
|
|
||||||
cleanup_dir = None
|
|
||||||
if args.envelope_id:
|
|
||||||
ics_path, cleanup_dir = _extract_ics_from_email(args.envelope_id, args.folder, args.account)
|
|
||||||
elif args.ics_file:
|
|
||||||
ics_path = Path(args.ics_file)
|
|
||||||
if not ics_path.is_file():
|
|
||||||
print(f"Error: ICS file not found: {ics_path}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
else:
|
|
||||||
print("Error: --envelope-id or --ics-file is required", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Parse original ICS
|
|
||||||
original_cal = Calendar.from_ical(ics_path.read_bytes())
|
|
||||||
|
|
||||||
# Find the VEVENT
|
|
||||||
original_event = None
|
|
||||||
for component in original_cal.walk():
|
|
||||||
if component.name == "VEVENT":
|
|
||||||
original_event = component
|
|
||||||
break
|
|
||||||
|
|
||||||
if not original_event:
|
|
||||||
print("Error: No VEVENT found in ICS file", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Extract fields from original
|
|
||||||
uid = str(original_event.get("uid", ""))
|
|
||||||
summary = str(original_event.get("summary", ""))
|
|
||||||
organizer = original_event.get("organizer")
|
|
||||||
|
|
||||||
if not organizer:
|
|
||||||
print("Error: No ORGANIZER found in ICS", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
organizer_email = str(organizer).replace("mailto:", "").replace("MAILTO:", "")
|
|
||||||
|
|
||||||
# Build reply calendar
|
|
||||||
reply_cal = Calendar()
|
|
||||||
reply_cal.add("prodid", PRODID)
|
|
||||||
reply_cal.add("version", "2.0")
|
|
||||||
reply_cal.add("calscale", "GREGORIAN")
|
|
||||||
reply_cal.add("method", "REPLY")
|
|
||||||
|
|
||||||
reply_event = Event()
|
|
||||||
reply_event.add("uid", uid)
|
|
||||||
reply_event.add("dtstamp", datetime.now(timezone.utc))
|
|
||||||
|
|
||||||
# Copy timing from original
|
|
||||||
if original_event.get("dtstart"):
|
|
||||||
reply_event["dtstart"] = original_event["dtstart"]
|
|
||||||
if original_event.get("dtend"):
|
|
||||||
reply_event["dtend"] = original_event["dtend"]
|
|
||||||
|
|
||||||
reply_event.add("summary", summary)
|
|
||||||
reply_event["organizer"] = original_event["organizer"]
|
|
||||||
reply_event.add("attendee", f"mailto:{args.sender}", parameters={
|
|
||||||
"PARTSTAT": partstat,
|
|
||||||
"RSVP": "FALSE",
|
|
||||||
})
|
|
||||||
|
|
||||||
if original_event.get("sequence"):
|
|
||||||
reply_event.add("sequence", original_event.get("sequence"))
|
|
||||||
|
|
||||||
reply_cal.add_component(reply_event)
|
|
||||||
reply_ics_bytes = reply_cal.to_ical()
|
|
||||||
|
|
||||||
# Build email
|
|
||||||
prefix = SUBJECT_PREFIX[partstat]
|
|
||||||
subject = f"{prefix}: {summary}"
|
|
||||||
|
|
||||||
body = f"{prefix}: {summary}"
|
|
||||||
if args.comment:
|
|
||||||
body += f"\n\n{args.comment}"
|
|
||||||
|
|
||||||
email_str = _build_calendar_email(args.sender, organizer_email, subject, body, reply_ics_bytes, method="REPLY")
|
|
||||||
|
|
||||||
if args.dry_run:
|
|
||||||
print("=== Original Event ===")
|
|
||||||
print(f"Summary: {summary}")
|
|
||||||
print(f"Organizer: {organizer_email}")
|
|
||||||
print(f"Action: {partstat}")
|
|
||||||
print()
|
|
||||||
print("=== Reply ICS ===")
|
|
||||||
print(reply_ics_bytes.decode())
|
|
||||||
print("=== Email Message ===")
|
|
||||||
print(email_str)
|
|
||||||
if cleanup_dir:
|
|
||||||
for f in cleanup_dir.iterdir():
|
|
||||||
f.unlink()
|
|
||||||
cleanup_dir.rmdir()
|
|
||||||
return
|
|
||||||
|
|
||||||
# Send reply
|
|
||||||
_send_email(email_str, args.account)
|
|
||||||
print(f"Calendar invite {partstat.lower()}: {summary} (replied to {organizer_email})")
|
|
||||||
|
|
||||||
# Forward invite to owner on accept/tentative
|
|
||||||
if partstat in ("ACCEPTED", "TENTATIVE"):
|
|
||||||
fwd_body = f"{prefix}: {summary}"
|
|
||||||
fwd_email = _build_calendar_email(
|
|
||||||
args.sender, DEFAULT_OWNER_EMAIL,
|
|
||||||
f"{prefix}: {summary}", fwd_body,
|
|
||||||
ics_path.read_bytes(), method="REQUEST",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
_send_email(fwd_email, args.account)
|
|
||||||
print(f"Forwarded invite to {DEFAULT_OWNER_EMAIL}")
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
print(f"Warning: Failed to forward invite to {DEFAULT_OWNER_EMAIL}")
|
|
||||||
|
|
||||||
# Save to / remove from local calendar
|
|
||||||
if CALENDAR_DIR.is_dir():
|
|
||||||
dest = CALENDAR_DIR / f"{uid}.ics"
|
|
||||||
if partstat in ("ACCEPTED", "TENTATIVE"):
|
|
||||||
# Save the original event to local calendar (without METHOD for CalDAV)
|
|
||||||
dest.write_bytes(_strip_method(ics_path.read_bytes()))
|
|
||||||
print(f"Saved to local calendar: {dest}")
|
|
||||||
elif partstat == "DECLINED" and dest.is_file():
|
|
||||||
dest.unlink()
|
|
||||||
print("Removed from local calendar")
|
|
||||||
_sync_calendar()
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
if cleanup_dir:
|
|
||||||
for f in cleanup_dir.iterdir():
|
|
||||||
f.unlink()
|
|
||||||
cleanup_dir.rmdir()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# CLI
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description="Calendar invite tool")
|
|
||||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
||||||
|
|
||||||
# --- send ---
|
|
||||||
send_p = subparsers.add_parser("send", help="Send a calendar invite")
|
|
||||||
send_p.add_argument("--from", dest="sender", default=DEFAULT_FROM, help="Sender email")
|
|
||||||
send_p.add_argument("--to", required=True, help="Recipient(s), comma-separated")
|
|
||||||
send_p.add_argument("--subject", required=True, help="Email subject")
|
|
||||||
send_p.add_argument("--summary", required=True, help="Event title")
|
|
||||||
send_p.add_argument("--start", required=True, help="Start time (ISO 8601)")
|
|
||||||
send_p.add_argument("--end", required=True, help="End time (ISO 8601)")
|
|
||||||
send_p.add_argument("--timezone", default=DEFAULT_TIMEZONE, help="IANA timezone")
|
|
||||||
send_p.add_argument("--location", default="", help="Event location")
|
|
||||||
send_p.add_argument("--description", default="", help="Event description")
|
|
||||||
send_p.add_argument("--organizer", default="", help="Organizer display name")
|
|
||||||
send_p.add_argument("--uid", default="", help="Custom event UID")
|
|
||||||
send_p.add_argument("--account", default="", help="Himalaya account")
|
|
||||||
send_p.add_argument("--dry-run", action="store_true", help="Preview without sending")
|
|
||||||
|
|
||||||
# --- reply ---
|
|
||||||
reply_p = subparsers.add_parser("reply", help="Reply to a calendar invite")
|
|
||||||
reply_p.add_argument("--from", dest="sender", default=DEFAULT_FROM, help="Your email")
|
|
||||||
reply_p.add_argument("--action", required=True, help="accept, decline, or tentative")
|
|
||||||
reply_p.add_argument("--envelope-id", default="", help="Himalaya envelope ID")
|
|
||||||
reply_p.add_argument("--ics-file", default="", help="Path to .ics file")
|
|
||||||
reply_p.add_argument("--account", default="", help="Himalaya account")
|
|
||||||
reply_p.add_argument("--folder", default="INBOX", help="Himalaya folder")
|
|
||||||
reply_p.add_argument("--comment", default="", help="Message to include in reply")
|
|
||||||
reply_p.add_argument("--dry-run", action="store_true", help="Preview without sending")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.command == "send":
|
|
||||||
cmd_send(args)
|
|
||||||
elif args.command == "reply":
|
|
||||||
cmd_reply(args)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
67
skills/calendar-invite/uv.lock
generated
67
skills/calendar-invite/uv.lock
generated
@@ -1,67 +0,0 @@
|
|||||||
version = 1
|
|
||||||
revision = 3
|
|
||||||
requires-python = ">=3.10"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "calendar-invite"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = { virtual = "." }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "icalendar" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.metadata]
|
|
||||||
requires-dist = [{ name = "icalendar" }]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "icalendar"
|
|
||||||
version = "7.0.3"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "python-dateutil" },
|
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
|
||||||
{ name = "tzdata" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b8/60/6b0356a2ed1c9689ae14bd8e44f22eac67c420a0ecca4df8306b70906600/icalendar-7.0.3.tar.gz", hash = "sha256:95027ece087ab87184d765f03761f25875821f74cdd18d3b57e9c868216d8fde", size = 443788, upload-time = "2026-03-03T12:00:10.952Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/c6/431fbf9063a6a4306d4cedae7823d69baf0979ba6ca57ab24a9d898cd0aa/icalendar-7.0.3-py3-none-any.whl", hash = "sha256:8c9fea6d3a89671bba8b6938d8565b4d0ec465c6a2796ef0f92790dcb9e627cd", size = 442406, upload-time = "2026-03-03T12:00:09.228Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "python-dateutil"
|
|
||||||
version = "2.9.0.post0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "six" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "six"
|
|
||||||
version = "1.17.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "typing-extensions"
|
|
||||||
version = "4.15.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tzdata"
|
|
||||||
version = "2025.3"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
|
|
||||||
]
|
|
||||||
Reference in New Issue
Block a user