Compare commits

...

2 Commits

Author SHA1 Message Date
Yanxin Lu
44fbbea29b VTODO skill 2026-03-22 14:10:54 -07:00
Yanxin Lu
ceb7af543b VTODO 2026-03-22 14:10:41 -07:00
10 changed files with 685 additions and 61 deletions

View File

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

View File

@@ -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邮件、vdirsyncerCalDAV 同步、khal查看日历 **依赖**: himalaya邮件、vdirsyncerCalDAV 同步、khal查看日历
**同步**: 发送/回复后自动 `vdirsyncer sync`,心跳也会定期同步 **同步**: 发送/回复后自动 `vdirsyncer sync`,心跳也会定期同步
**自动抄送**: mail@luyx.org用户别名自动加入所有邀请 **自动抄送**: mail@luyx.org用户别名自动加入所有邀请

View File

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

View File

@@ -1,12 +1,12 @@
--- ---
name: calendar-invite name: calendar
description: "Send, accept, and decline calendar invite emails (ICS/iCalendar) via himalaya. Syncs events to CalDAV (Migadu) via vdirsyncer." description: "Calendar invites and VTODO task management via CalDAV. Send/reply to invites, create/list/complete/delete todos. Syncs to Migadu CalDAV via vdirsyncer."
metadata: {"clawdbot":{"emoji":"📅","requires":{"bins":["himalaya","vdirsyncer"],"skills":["himalaya"]}}} metadata: {"clawdbot":{"emoji":"📅","requires":{"bins":["himalaya","vdirsyncer"],"skills":["himalaya"]}}}
--- ---
# Calendar Invite # Calendar
Send, accept, and decline calendar invitations via email using himalaya. Events are saved to local calendar and synced to CalDAV (Migadu) via vdirsyncer. Send, accept, and decline calendar invitations via email. Create and manage VTODO tasks with CalDAV sync. Events and tasks sync to Migadu CalDAV via vdirsyncer.
## Testing ## Testing
@@ -36,13 +36,20 @@ When accepting or tentatively accepting a received invite, the original invite i
All commands go through the wrapper script: All commands go through the wrapper script:
```bash ```bash
SKILL_DIR=~/.openclaw/workspace/skills/calendar-invite SKILL_DIR=~/.openclaw/workspace/skills/calendar
# Send an invite # Send an invite
$SKILL_DIR/scripts/calendar-invite.sh send [options] $SKILL_DIR/scripts/calendar.sh send [options]
# Reply to an invite # Reply to an invite
$SKILL_DIR/scripts/calendar-invite.sh reply [options] $SKILL_DIR/scripts/calendar.sh reply [options]
# Manage todos
$SKILL_DIR/scripts/calendar.sh todo add [options]
$SKILL_DIR/scripts/calendar.sh todo list [options]
$SKILL_DIR/scripts/calendar.sh todo complete [options]
$SKILL_DIR/scripts/calendar.sh todo delete [options]
$SKILL_DIR/scripts/calendar.sh todo check
``` ```
--- ---
@@ -50,7 +57,7 @@ $SKILL_DIR/scripts/calendar-invite.sh reply [options]
## Sending Invites ## Sending Invites
```bash ```bash
$SKILL_DIR/scripts/calendar-invite.sh send \ $SKILL_DIR/scripts/calendar.sh send \
--to "friend@example.com" \ --to "friend@example.com" \
--subject "Lunch on Friday" \ --subject "Lunch on Friday" \
--summary "Lunch at Tartine" \ --summary "Lunch at Tartine" \
@@ -81,7 +88,7 @@ $SKILL_DIR/scripts/calendar-invite.sh send \
```bash ```bash
# Simple invite (--from and --timezone default to youlu@luyanxin.com / LA) # Simple invite (--from and --timezone default to youlu@luyanxin.com / LA)
$SKILL_DIR/scripts/calendar-invite.sh send \ $SKILL_DIR/scripts/calendar.sh send \
--to "alice@example.com" \ --to "alice@example.com" \
--subject "Coffee Chat" \ --subject "Coffee Chat" \
--summary "Coffee Chat" \ --summary "Coffee Chat" \
@@ -89,7 +96,7 @@ $SKILL_DIR/scripts/calendar-invite.sh send \
--end "2026-03-25T10:30:00" --end "2026-03-25T10:30:00"
# Multiple attendees with details # Multiple attendees with details
$SKILL_DIR/scripts/calendar-invite.sh send \ $SKILL_DIR/scripts/calendar.sh send \
--to "alice@example.com, bob@example.com" \ --to "alice@example.com, bob@example.com" \
--subject "Team Sync" \ --subject "Team Sync" \
--summary "Weekly Team Sync" \ --summary "Weekly Team Sync" \
@@ -99,7 +106,7 @@ $SKILL_DIR/scripts/calendar-invite.sh send \
--description "Weekly check-in. Agenda: updates, blockers, action items." --description "Weekly check-in. Agenda: updates, blockers, action items."
# Dry run # Dry run
$SKILL_DIR/scripts/calendar-invite.sh send \ $SKILL_DIR/scripts/calendar.sh send \
--to "test@example.com" \ --to "test@example.com" \
--subject "Test" \ --subject "Test" \
--summary "Test Event" \ --summary "Test Event" \
@@ -114,18 +121,18 @@ $SKILL_DIR/scripts/calendar-invite.sh send \
```bash ```bash
# Accept by himalaya envelope ID # Accept by himalaya envelope ID
$SKILL_DIR/scripts/calendar-invite.sh reply \ $SKILL_DIR/scripts/calendar.sh reply \
--envelope-id 42 \ --envelope-id 42 \
--action accept --action accept
# Decline with a comment # Decline with a comment
$SKILL_DIR/scripts/calendar-invite.sh reply \ $SKILL_DIR/scripts/calendar.sh reply \
--envelope-id 42 \ --envelope-id 42 \
--action decline \ --action decline \
--comment "Sorry, I have a conflict." --comment "Sorry, I have a conflict."
# From an .ics file # From an .ics file
$SKILL_DIR/scripts/calendar-invite.sh reply \ $SKILL_DIR/scripts/calendar.sh reply \
--ics-file ~/Downloads/meeting.ics \ --ics-file ~/Downloads/meeting.ics \
--action tentative --action tentative
``` ```
@@ -149,7 +156,74 @@ $SKILL_DIR/scripts/calendar-invite.sh reply \
1. List emails: `himalaya envelope list` 1. List emails: `himalaya envelope list`
2. Read the invite: `himalaya message read 57` 2. Read the invite: `himalaya message read 57`
3. Reply: `$SKILL_DIR/scripts/calendar-invite.sh reply --envelope-id 57 --action accept` 3. Reply: `$SKILL_DIR/scripts/calendar.sh reply --envelope-id 57 --action accept`
---
## VTODO Tasks
Manage tasks as RFC 5545 VTODO components, stored in `~/.openclaw/workspace/calendars/tasks/` and synced to CalDAV.
### Sync Model
The agent's local CalDAV is the **source of truth** (no two-way sync). When a todo is created, it's saved locally and emailed to `mail@luyx.org` as a delivery copy. When the user completes a task, they tell the agent, and the agent runs `todo complete`. The daily `todo check` cron reads from local files.
### Priority Mapping (RFC 5545)
| Label | `--priority` | RFC 5545 value |
|--------|-------------|----------------|
| 高 (high) | `high` | 1 |
| 中 (medium) | `medium` | 5 (default) |
| 低 (low) | `low` | 9 |
### `todo add` — Create a Todo
```bash
$SKILL_DIR/scripts/calendar.sh todo add \
--summary "跟进iui保险报销" \
--due "2026-03-25" \
--priority high \
--description "确认iui费用保险报销进度" \
--alarm 1d
```
| Flag | Required | Description |
|-----------------|----------|-----------------------------------------------------|
| `--summary` | Yes | Todo title |
| `--due` | No | Due date, YYYY-MM-DD (default: tomorrow) |
| `--priority` | No | `high`, `medium`, or `low` (default: `medium`) |
| `--description` | No | Notes / description |
| `--alarm` | No | Reminder trigger: `1d`, `2h`, `30m` (default: `1d`) |
| `--dry-run` | No | Preview ICS + email without saving |
### `todo list` — List Todos
```bash
$SKILL_DIR/scripts/calendar.sh todo list # pending only
$SKILL_DIR/scripts/calendar.sh todo list --all # include completed
```
### `todo complete` — Mark as Done
```bash
$SKILL_DIR/scripts/calendar.sh todo complete --uid "abc123@openclaw"
$SKILL_DIR/scripts/calendar.sh todo complete --match "保险报销"
```
### `todo delete` — Remove a Todo
```bash
$SKILL_DIR/scripts/calendar.sh todo delete --uid "abc123@openclaw"
$SKILL_DIR/scripts/calendar.sh todo delete --match "保险报销"
```
### `todo check` — Daily Digest (Cron)
```bash
$SKILL_DIR/scripts/calendar.sh todo check
```
Same as `todo list` but only NEEDS-ACTION items. Exits silently when no pending items. Output is designed for piping to himalaya.
--- ---
@@ -170,8 +244,15 @@ $SKILL_DIR/scripts/calendar-invite.sh reply \
5. On accept/tentative: saves event to local calendar. On decline: removes it 5. On accept/tentative: saves event to local calendar. On decline: removes it
6. Runs `vdirsyncer sync` to push changes to Migadu CalDAV 6. Runs `vdirsyncer sync` to push changes to Migadu CalDAV
**Managing todos:**
1. Creates a VTODO ICS file with `STATUS:NEEDS-ACTION`, `PRIORITY`, `DUE`, `VALARM`
2. Saves to `~/.openclaw/workspace/calendars/tasks/`
3. Emails the VTODO to `mail@luyx.org` as a delivery copy
4. Runs `vdirsyncer sync` to push to Migadu CalDAV
5. `todo check` reads local files for the daily cron digest
**CalDAV sync:** **CalDAV sync:**
- Events sync to Migadu and appear on all connected devices (DAVx5, etc.) - Events and tasks sync to Migadu and appear on all connected devices (DAVx5, etc.)
- Heartbeat runs `vdirsyncer sync` periodically as a fallback - Heartbeat runs `vdirsyncer sync` periodically as a fallback
- If sync fails, it warns but doesn't block — next heartbeat catches up - If sync fails, it warns but doesn't block — next heartbeat catches up
@@ -222,6 +303,11 @@ Common IANA timezones:
- Check `~/.openclaw/workspace/logs/vdirsyncer.log` for errors - Check `~/.openclaw/workspace/logs/vdirsyncer.log` for errors
- Verify the .ics file exists in `~/.openclaw/workspace/calendars/home/` - Verify the .ics file exists in `~/.openclaw/workspace/calendars/home/`
**Todos not syncing?**
- Check that `~/.openclaw/workspace/calendars/tasks/` exists
- Verify vdirsyncer has a `cal/tasks` pair configured
- Run `vdirsyncer sync` manually
**Recipient doesn't see Accept/Decline?** **Recipient doesn't see Accept/Decline?**
- Gmail, Outlook, Apple Mail all support `text/calendar` method=REQUEST - Gmail, Outlook, Apple Mail all support `text/calendar` method=REQUEST
- Some webmail clients may vary - Some webmail clients may vary

View File

@@ -1,9 +1,9 @@
# Testing the Calendar Invite Skill # Testing the Calendar Skill
End-to-end tests for send, reply, calendar sync, and local calendar. All commands use `--dry-run` first, then live. End-to-end tests for send, reply, todo, calendar sync, and local calendar. All commands use `--dry-run` first, then live.
```bash ```bash
SKILL_DIR=~/.openclaw/workspace/skills/calendar-invite SKILL_DIR=~/.openclaw/workspace/skills/calendar
# Use a date 3 days from now for test events # Use a date 3 days from now for test events
TEST_DATE=$(date -d "+3 days" +%Y-%m-%d) TEST_DATE=$(date -d "+3 days" +%Y-%m-%d)
@@ -20,7 +20,7 @@ Generates the ICS and MIME email without sending. Check that:
- Times and timezone look correct - Times and timezone look correct
```bash ```bash
$SKILL_DIR/scripts/calendar-invite.sh send \ $SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \ --to "mail@luyx.org" \
--subject "Test Invite" \ --subject "Test Invite" \
--summary "Test Event" \ --summary "Test Event" \
@@ -34,7 +34,7 @@ $SKILL_DIR/scripts/calendar-invite.sh send \
Send a real invite to `mail@luyx.org` only (no confirmation needed per email rules). Send a real invite to `mail@luyx.org` only (no confirmation needed per email rules).
```bash ```bash
$SKILL_DIR/scripts/calendar-invite.sh send \ $SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \ --to "mail@luyx.org" \
--subject "Calendar Skill Test" \ --subject "Calendar Skill Test" \
--summary "Calendar Skill Test" \ --summary "Calendar Skill Test" \
@@ -82,7 +82,7 @@ himalaya envelope list
himalaya message read <envelope-id> himalaya message read <envelope-id>
# Accept it # Accept it
$SKILL_DIR/scripts/calendar-invite.sh reply \ $SKILL_DIR/scripts/calendar.sh reply \
--envelope-id <envelope-id> \ --envelope-id <envelope-id> \
--action accept --action accept
``` ```
@@ -100,7 +100,7 @@ Send another self-invite, then decline it. This verifies decline removes the eve
```bash ```bash
# Send a second test invite # Send a second test invite
$SKILL_DIR/scripts/calendar-invite.sh send \ $SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \ --to "mail@luyx.org" \
--subject "Decline Test" \ --subject "Decline Test" \
--summary "Decline Test Event" \ --summary "Decline Test Event" \
@@ -111,7 +111,7 @@ $SKILL_DIR/scripts/calendar-invite.sh send \
himalaya envelope list himalaya envelope list
# Decline it # Decline it
$SKILL_DIR/scripts/calendar-invite.sh reply \ $SKILL_DIR/scripts/calendar.sh reply \
--envelope-id <envelope-id> \ --envelope-id <envelope-id> \
--action decline \ --action decline \
--comment "Testing decline flow." --comment "Testing decline flow."
@@ -140,6 +140,128 @@ khal list today 7d
--- ---
## 7. Dry Run: Add Todo
Generates the VTODO ICS and MIME email without saving. Check that:
- ICS has `BEGIN:VTODO`
- ICS has correct `PRIORITY` value (1 for high)
- ICS has `STATUS:NEEDS-ACTION`
- ICS has `BEGIN:VALARM`
- MIME has `Content-Type: text/calendar`
```bash
$SKILL_DIR/scripts/calendar.sh todo add \
--summary "Test Todo" \
--due "$TEST_DATE" \
--priority high \
--dry-run
```
## 8. Live Add: Create a Todo
```bash
$SKILL_DIR/scripts/calendar.sh todo add \
--summary "Test Todo" \
--due "$TEST_DATE" \
--priority medium \
--description "Test description"
```
**Verify:**
- [ ] Script exits without error
- [ ] `.ics` file created in `~/.openclaw/workspace/calendars/tasks/`
- [ ] Email arrives at `mail@luyx.org` with .ics attachment
- [ ] `vdirsyncer sync` ran
## 9. List Todos
```bash
# List pending todos
$SKILL_DIR/scripts/calendar.sh todo list
# List all (including completed)
$SKILL_DIR/scripts/calendar.sh todo list --all
```
**Verify:**
- [ ] "Test Todo" appears with correct urgency label
- [ ] Priority grouping is correct
- [ ] `--all` flag works (same output when none are completed)
## 10. Complete a Todo
```bash
$SKILL_DIR/scripts/calendar.sh todo complete --match "Test Todo"
```
**Verify:**
- [ ] .ics file updated with `STATUS:COMPLETED` and `COMPLETED:` timestamp
- [ ] `todo list` — "Test Todo" no longer appears
- [ ] `todo list --all` — "Test Todo" appears as completed (with checkmark)
- [ ] `vdirsyncer sync` ran
## 11. Delete a Todo
Create a second test todo, then delete it.
```bash
# Create
$SKILL_DIR/scripts/calendar.sh todo add \
--summary "Delete Me Todo" \
--due "$TEST_DATE" \
--priority low
# Delete
$SKILL_DIR/scripts/calendar.sh todo delete --match "Delete Me"
```
**Verify:**
- [ ] .ics file removed from tasks dir
- [ ] `todo list` does not show "Delete Me Todo"
- [ ] `vdirsyncer sync` ran
## 12. Todo Check (Cron Output)
```bash
# Create a test todo
$SKILL_DIR/scripts/calendar.sh todo add \
--summary "Check Test Todo" \
--due "$TEST_DATE"
# Run check
$SKILL_DIR/scripts/calendar.sh todo check
```
**Verify:**
- [ ] Output matches daily digest format (priority groups, urgency labels)
- [ ] Complete the todo, run `todo check` again — silent exit (no output)
```bash
$SKILL_DIR/scripts/calendar.sh todo complete --match "Check Test"
$SKILL_DIR/scripts/calendar.sh todo check
# Should produce no output
```
## 13. Regression: Existing Invite Commands
Verify the rename didn't break VEVENT flow.
```bash
$SKILL_DIR/scripts/calendar.sh send \
--to "test@example.com" \
--subject "Regression Test" \
--summary "Regression Test Event" \
--start "${TEST_DATE}T10:00:00" \
--end "${TEST_DATE}T11:00:00" \
--dry-run
```
**Verify:**
- [ ] ICS has `BEGIN:VEVENT`, `METHOD:REQUEST`
- [ ] No errors from the renamed script
---
## Quick Health Checks ## Quick Health Checks
Run these first if any step fails. Run these first if any step fails.
@@ -168,3 +290,4 @@ khal list today 7d
| `ModuleNotFoundError: icalendar` | Run `uv sync --project $SKILL_DIR` to install dependencies | | `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` | | 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/` | | Event not in `khal list` after sync | Check `.ics` file exists in `~/.openclaw/workspace/calendars/home/` |
| Todo not syncing | Check `~/.openclaw/workspace/calendars/tasks/` exists, verify vdirsyncer `cal/tasks` pair |

View File

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

View File

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

View File

@@ -1,27 +1,32 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Calendar Invite Send, accept, and decline calendar invites via himalaya. Calendar Send/reply to calendar invites and manage VTODO tasks via CalDAV.
Uses the icalendar library for proper RFC 5545 ICS generation and parsing. Uses the icalendar library for proper RFC 5545 ICS generation and parsing.
Uses himalaya CLI for email delivery. Syncs to local CalDAV via vdirsyncer. Uses himalaya CLI for email delivery. Syncs to local CalDAV via vdirsyncer.
Subcommands: Subcommands:
python calendar_invite.py send [options] # create and send an invite python calendar.py send [options] # create and send an invite
python calendar_invite.py reply [options] # accept/decline/tentative python calendar.py reply [options] # accept/decline/tentative
python calendar.py todo add [options] # create a VTODO task
python calendar.py todo list [options] # list pending tasks
python calendar.py todo complete [options] # mark task as done
python calendar.py todo delete [options] # remove a task
python calendar.py todo check # daily digest for cron
""" """
import argparse import argparse
import subprocess import subprocess
import sys import sys
import uuid import uuid
from datetime import datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from email.mime.base import MIMEBase from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from icalendar import Alarm, Calendar, Event, vCalAddress, vText from icalendar import Alarm, Calendar, Event, Todo, vCalAddress, vText
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Config # Config
@@ -31,7 +36,12 @@ DEFAULT_TIMEZONE = "America/Los_Angeles"
DEFAULT_FROM = "youlu@luyanxin.com" DEFAULT_FROM = "youlu@luyanxin.com"
DEFAULT_OWNER_EMAIL = "mail@luyx.org" # Always added as attendee DEFAULT_OWNER_EMAIL = "mail@luyx.org" # Always added as attendee
CALENDAR_DIR = Path.home() / ".openclaw" / "workspace" / "calendars" / "home" CALENDAR_DIR = Path.home() / ".openclaw" / "workspace" / "calendars" / "home"
PRODID = "-//OpenClaw//CalendarInvite//EN" TASKS_DIR = Path.home() / ".openclaw" / "workspace" / "calendars" / "tasks"
PRODID = "-//OpenClaw//Calendar//EN"
# RFC 5545 priority mapping
PRIORITY_MAP = {"high": 1, "medium": 5, "low": 9}
PRIORITY_LABELS = {1: "", 5: "", 9: ""}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -96,6 +106,11 @@ def _parse_iso_datetime(dt_str):
raise ValueError(f"Cannot parse datetime: {dt_str}") raise ValueError(f"Cannot parse datetime: {dt_str}")
def _parse_date(date_str):
"""Parse YYYY-MM-DD date string."""
return datetime.strptime(date_str, "%Y-%m-%d").date()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Send invite # Send invite
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -370,12 +385,359 @@ def cmd_reply(args):
cleanup_dir.rmdir() cleanup_dir.rmdir()
# ---------------------------------------------------------------------------
# VTODO: helpers
# ---------------------------------------------------------------------------
def _load_todos():
"""Load all VTODO items from TASKS_DIR. Returns list of (path, vtodo) tuples."""
if not TASKS_DIR.is_dir():
return []
todos = []
for ics_path in TASKS_DIR.glob("*.ics"):
try:
cal = Calendar.from_ical(ics_path.read_bytes())
for component in cal.walk():
if component.name == "VTODO":
todos.append((ics_path, component))
break
except Exception:
continue
return todos
def _get_due_date(vtodo):
"""Extract due date from a VTODO as a date object, or None."""
due = vtodo.get("due")
if due is None:
return None
dt = due.dt
if isinstance(dt, datetime):
return dt.date()
return dt
def _get_priority_int(vtodo):
"""Get priority as int (1=high, 5=medium, 9=low). Default 5."""
p = vtodo.get("priority")
if p is None:
return 5
return int(p)
def _days_until(due_date):
"""Days from today until due_date. Negative means overdue."""
if due_date is None:
return None
return (due_date - date.today()).days
def _urgency_label(days):
"""Urgency label with emoji, matching reminder_check.py style."""
if days is None:
return "❓ 日期未知"
elif days < 0:
return f"🔴 逾期 {-days}"
elif days == 0:
return "🔴 今天"
elif days == 1:
return "🟡 明天"
elif days <= 3:
return f"🟡 {days} 天后"
else:
return f"🟢 {days} 天后"
def _find_todo_by_match(todos, match_str):
"""Find a single todo by fuzzy match on SUMMARY. Exits on 0 or >1 matches."""
matches = []
for path, vtodo in todos:
summary = str(vtodo.get("summary", ""))
if match_str in summary:
matches.append((path, vtodo))
if not matches:
print(f"Error: No todo matching '{match_str}'", file=sys.stderr)
sys.exit(1)
if len(matches) > 1:
print(f"Error: Multiple todos match '{match_str}':", file=sys.stderr)
for _, vt in matches:
print(f" - {vt.get('summary')} (UID: {vt.get('uid')})", file=sys.stderr)
sys.exit(1)
return matches[0]
def _find_todo_by_uid(todos, uid):
"""Find a todo by UID. Exits if not found."""
for path, vtodo in todos:
if str(vtodo.get("uid", "")) == uid:
return path, vtodo
print(f"Error: No todo with UID '{uid}'", file=sys.stderr)
sys.exit(1)
def _resolve_todo(args, todos):
"""Resolve a todo from --uid or --match args."""
if args.uid:
return _find_todo_by_uid(todos, args.uid)
elif args.match:
return _find_todo_by_match(todos, args.match)
else:
print("Error: --uid or --match is required", file=sys.stderr)
sys.exit(1)
# ---------------------------------------------------------------------------
# VTODO: subcommands
# ---------------------------------------------------------------------------
def cmd_todo_add(args):
"""Create a VTODO and save to TASKS_DIR."""
TASKS_DIR.mkdir(parents=True, exist_ok=True)
uid = f"{uuid.uuid4()}@openclaw"
now = datetime.now(timezone.utc)
# Parse due date (default: tomorrow)
if args.due:
due_date = _parse_date(args.due)
else:
due_date = date.today() + timedelta(days=1)
# Parse priority
priority = PRIORITY_MAP.get(args.priority, 5)
# Parse alarm trigger
alarm_trigger = timedelta(days=-1) # default: 1 day before
if args.alarm:
alarm_str = args.alarm
if alarm_str.endswith("d"):
alarm_trigger = timedelta(days=-int(alarm_str[:-1]))
elif alarm_str.endswith("h"):
alarm_trigger = timedelta(hours=-int(alarm_str[:-1]))
elif alarm_str.endswith("m"):
alarm_trigger = timedelta(minutes=-int(alarm_str[:-1]))
# Build VTODO calendar
cal = Calendar()
cal.add("prodid", PRODID)
cal.add("version", "2.0")
cal.add("method", "REQUEST")
todo = Todo()
todo.add("uid", uid)
todo.add("dtstamp", now)
todo.add("created", now)
todo.add("summary", args.summary)
todo.add("due", due_date)
todo.add("priority", priority)
todo.add("status", "NEEDS-ACTION")
if args.description:
todo.add("description", args.description)
# VALARM reminder
alarm = Alarm()
alarm.add("action", "DISPLAY")
alarm.add("description", f"Todo: {args.summary}")
alarm.add("trigger", alarm_trigger)
todo.add_component(alarm)
cal.add_component(todo)
ics_bytes = cal.to_ical()
# Build email body
prio_label = PRIORITY_LABELS.get(priority, "")
body = f"待办事项: {args.summary}\n截止日期: {due_date}\n优先级: {prio_label}"
if args.description:
body += f"\n\n{args.description}"
# Build MIME email
email_str = _build_calendar_email(
DEFAULT_FROM, DEFAULT_OWNER_EMAIL,
f"📋 待办: {args.summary}",
body, ics_bytes, method="REQUEST",
)
if args.dry_run:
print("=== ICS Content ===")
print(ics_bytes.decode())
print("=== Email Message ===")
print(email_str)
return
# Save to TASKS_DIR (without METHOD for CalDAV)
dest = TASKS_DIR / f"{uid}.ics"
dest.write_bytes(_strip_method(ics_bytes))
print(f"Todo created: {args.summary} (due: {due_date}, priority: {prio_label})")
print(f"Saved to: {dest}")
# Sync
_sync_calendar()
# Email the VTODO to owner
try:
_send_email(email_str)
print(f"Emailed todo to {DEFAULT_OWNER_EMAIL}")
except subprocess.CalledProcessError:
print(f"Warning: Failed to email todo to {DEFAULT_OWNER_EMAIL}")
def cmd_todo_list(args):
"""List todos, optionally including completed ones."""
todos = _load_todos()
if not todos:
print("No todos found.")
return
# Filter
if not args.all:
todos = [(p, vt) for p, vt in todos
if str(vt.get("status", "NEEDS-ACTION")) in ("NEEDS-ACTION", "IN-PROCESS")]
if not todos:
print("No pending todos.")
return
# Sort by priority then due date
def sort_key(item):
_, vt = item
prio = _get_priority_int(vt)
due = _get_due_date(vt)
return (prio, due or date.max)
todos.sort(key=sort_key)
# Format output
today_str = date.today().isoformat()
print(f"📋 待办事项 ({today_str})")
print("=" * 50)
# Group by priority
groups = {1: [], 5: [], 9: []}
for path, vt in todos:
prio = _get_priority_int(vt)
# Bucket into nearest standard priority
if prio <= 3:
groups[1].append((path, vt))
elif prio <= 7:
groups[5].append((path, vt))
else:
groups[9].append((path, vt))
for prio, emoji, label in [(1, "🔴", "高优先级"), (5, "🟡", "中优先级"), (9, "🟢", "低优先级")]:
items = groups[prio]
if not items:
continue
print(f"\n{emoji} {label}")
for _, vt in items:
summary = str(vt.get("summary", ""))
due = _get_due_date(vt)
days = _days_until(due)
urgency = _urgency_label(days)
status = str(vt.get("status", ""))
desc = str(vt.get("description", ""))
line = f"{summary} ({urgency})"
if status == "COMPLETED":
line = f" • ✅ {summary} (已完成)"
if desc:
line += f" | {desc}"
print(line)
print("\n" + "=" * 50)
def cmd_todo_complete(args):
"""Mark a todo as COMPLETED."""
todos = _load_todos()
path, vtodo = _resolve_todo(args, todos)
# Read, modify, rewrite
cal = Calendar.from_ical(path.read_bytes())
for component in cal.walk():
if component.name == "VTODO":
component["status"] = "COMPLETED"
component.add("completed", datetime.now(timezone.utc))
break
path.write_bytes(cal.to_ical())
summary = str(vtodo.get("summary", ""))
print(f"Completed: {summary}")
_sync_calendar()
def cmd_todo_delete(args):
"""Delete a todo .ics file."""
todos = _load_todos()
path, vtodo = _resolve_todo(args, todos)
summary = str(vtodo.get("summary", ""))
path.unlink()
print(f"Deleted: {summary}")
_sync_calendar()
def cmd_todo_check(args):
"""Daily digest of pending todos (for cron). Silent when empty."""
todos = _load_todos()
# Filter to NEEDS-ACTION only
pending = [(p, vt) for p, vt in todos
if str(vt.get("status", "NEEDS-ACTION")) in ("NEEDS-ACTION", "IN-PROCESS")]
if not pending:
return # silent exit
# Sort by priority then due date
def sort_key(item):
_, vt = item
prio = _get_priority_int(vt)
due = _get_due_date(vt)
return (prio, due or date.max)
pending.sort(key=sort_key)
# Format output (same style as todo list but without footer)
today_str = date.today().isoformat()
print(f"📋 待办事项 ({today_str})")
print("=" * 50)
groups = {1: [], 5: [], 9: []}
for path, vt in pending:
prio = _get_priority_int(vt)
if prio <= 3:
groups[1].append((path, vt))
elif prio <= 7:
groups[5].append((path, vt))
else:
groups[9].append((path, vt))
for prio, emoji, label in [(1, "🔴", "高优先级"), (5, "🟡", "中优先级"), (9, "🟢", "低优先级")]:
items = groups[prio]
if not items:
continue
print(f"\n{emoji} {label}")
for _, vt in items:
summary = str(vt.get("summary", ""))
due = _get_due_date(vt)
days = _days_until(due)
urgency = _urgency_label(days)
desc = str(vt.get("description", ""))
line = f"{summary} ({urgency})"
if desc:
line += f" | {desc}"
print(line)
print("\n" + "=" * 50)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# CLI # CLI
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def main(): def main():
parser = argparse.ArgumentParser(description="Calendar invite tool") parser = argparse.ArgumentParser(description="Calendar and todo tool")
subparsers = parser.add_subparsers(dest="command", required=True) subparsers = parser.add_subparsers(dest="command", required=True)
# --- send --- # --- send ---
@@ -405,12 +767,53 @@ def main():
reply_p.add_argument("--comment", default="", help="Message to include in reply") 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") reply_p.add_argument("--dry-run", action="store_true", help="Preview without sending")
# --- todo ---
todo_p = subparsers.add_parser("todo", help="Manage VTODO tasks")
todo_sub = todo_p.add_subparsers(dest="todo_command", required=True)
# todo add
add_p = todo_sub.add_parser("add", help="Create a new todo")
add_p.add_argument("--summary", required=True, help="Todo title")
add_p.add_argument("--due", default="", help="Due date (YYYY-MM-DD, default: tomorrow)")
add_p.add_argument("--priority", default="medium", choices=["high", "medium", "low"], help="Priority")
add_p.add_argument("--description", default="", help="Notes / description")
add_p.add_argument("--alarm", default="1d", help="Reminder trigger (e.g. 1d, 2h, 30m)")
add_p.add_argument("--dry-run", action="store_true", help="Preview without saving")
# todo list
list_p = todo_sub.add_parser("list", help="List todos")
list_p.add_argument("--all", action="store_true", help="Include completed todos")
# todo complete
comp_p = todo_sub.add_parser("complete", help="Mark a todo as done")
comp_p.add_argument("--uid", default="", help="Todo UID")
comp_p.add_argument("--match", default="", help="Fuzzy match on summary")
# todo delete
del_p = todo_sub.add_parser("delete", help="Delete a todo")
del_p.add_argument("--uid", default="", help="Todo UID")
del_p.add_argument("--match", default="", help="Fuzzy match on summary")
# todo check
todo_sub.add_parser("check", help="Daily digest (for cron)")
args = parser.parse_args() args = parser.parse_args()
if args.command == "send": if args.command == "send":
cmd_send(args) cmd_send(args)
elif args.command == "reply": elif args.command == "reply":
cmd_reply(args) cmd_reply(args)
elif args.command == "todo":
if args.todo_command == "add":
cmd_todo_add(args)
elif args.todo_command == "list":
cmd_todo_list(args)
elif args.todo_command == "complete":
cmd_todo_complete(args)
elif args.todo_command == "delete":
cmd_todo_delete(args)
elif args.todo_command == "check":
cmd_todo_check(args)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# calendar — wrapper script for the calendar and todo tool.
#
# Usage:
# ./calendar.sh send [options] # send a calendar invite
# ./calendar.sh reply [options] # accept/decline/tentative
# ./calendar.sh todo add [options] # create a todo
# ./calendar.sh todo list [options] # list pending todos
# ./calendar.sh todo complete [options] # mark todo as done
# ./calendar.sh todo delete [options] # remove a todo
# ./calendar.sh todo check # daily digest for cron
#
# 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/cal_tool.py" "$@"

View File

@@ -3,7 +3,7 @@ revision = 3
requires-python = ">=3.10" requires-python = ">=3.10"
[[package]] [[package]]
name = "calendar-invite" name = "calendar"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [