Compare commits
2 Commits
da26f84947
...
44fbbea29b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44fbbea29b | ||
|
|
ceb7af543b |
@@ -96,7 +96,7 @@ _这份文件记录持续性项目和重要状态,跨会话保留。_
|
||||
**状态**: 运行中
|
||||
**创建**: 2026-03-18
|
||||
**配置**:
|
||||
- 技能: `~/.openclaw/workspace/skills/calendar-invite/`
|
||||
- 技能: `~/.openclaw/workspace/skills/calendar/`
|
||||
- 日历数据: `~/.openclaw/workspace/calendars/` (home/work/tasks/journals)
|
||||
- CalDAV: Migadu (`cdav.migadu.com`),通过 vdirsyncer 同步
|
||||
- 查看日历: khal
|
||||
@@ -105,7 +105,8 @@ _这份文件记录持续性项目和重要状态,跨会话保留。_
|
||||
**功能**:
|
||||
- 发送日历邀请(自动添加 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/email_processor/` |
|
||||
| 待办列表 | `~/.openclaw/workspace/reminders/active.md` |
|
||||
| 日历邀请 | `~/.openclaw/workspace/skills/calendar-invite/` |
|
||||
| 日历/待办 | `~/.openclaw/workspace/skills/calendar/` |
|
||||
| 日历数据 | `~/.openclaw/workspace/calendars/` |
|
||||
|
||||
---
|
||||
|
||||
24
TOOLS.md
24
TOOLS.md
@@ -115,37 +115,43 @@ agent-browser close
|
||||
- `data/pending_emails.json` — 待处理队列
|
||||
- `logs/` — 处理日志
|
||||
|
||||
### Calendar Invite 日历邀请
|
||||
### Calendar 日历 + 待办
|
||||
|
||||
**文档**: `~/.openclaw/workspace/skills/calendar-invite/SKILL.md`
|
||||
**目录**: `~/.openclaw/workspace/skills/calendar-invite/`
|
||||
**文档**: `~/.openclaw/workspace/skills/calendar/SKILL.md`
|
||||
**目录**: `~/.openclaw/workspace/skills/calendar/`
|
||||
**默认发件人**: youlu@luyanxin.com
|
||||
**默认时区**: America/Los_Angeles
|
||||
**日历数据**: `~/.openclaw/workspace/calendars/home/`
|
||||
**日历数据**: `~/.openclaw/workspace/calendars/home/`(事件)、`calendars/tasks/`(待办)
|
||||
**运行方式**: `uv run`(依赖 `icalendar` 库)
|
||||
|
||||
**核心用法**:
|
||||
```bash
|
||||
SKILL_DIR=~/.openclaw/workspace/skills/calendar-invite
|
||||
SKILL_DIR=~/.openclaw/workspace/skills/calendar
|
||||
|
||||
# 发送日历邀请(--from 默认 youlu@luyanxin.com)
|
||||
$SKILL_DIR/scripts/calendar-invite.sh send \
|
||||
$SKILL_DIR/scripts/calendar.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.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."
|
||||
|
||||
# 待办管理
|
||||
$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
|
||||
```
|
||||
|
||||
**支持操作**: 发送邀请 (`send`)、接受/拒绝/暂定 (`reply`)
|
||||
**支持操作**: 发送邀请 (`send`)、接受/拒绝/暂定 (`reply`)、待办管理 (`todo add/list/complete/delete/check`)
|
||||
**依赖**: himalaya(邮件)、vdirsyncer(CalDAV 同步)、khal(查看日历)
|
||||
**同步**: 发送/回复后自动 `vdirsyncer sync`,心跳也会定期同步
|
||||
**自动抄送**: mail@luyx.org(用户别名)自动加入所有邀请
|
||||
|
||||
@@ -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,12 +1,12 @@
|
||||
---
|
||||
name: calendar-invite
|
||||
description: "Send, accept, and decline calendar invite emails (ICS/iCalendar) via himalaya. Syncs events to CalDAV (Migadu) via vdirsyncer."
|
||||
name: calendar
|
||||
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"]}}}
|
||||
---
|
||||
|
||||
# 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
|
||||
|
||||
@@ -36,13 +36,20 @@ When accepting or tentatively accepting a received invite, the original invite i
|
||||
All commands go through the wrapper script:
|
||||
|
||||
```bash
|
||||
SKILL_DIR=~/.openclaw/workspace/skills/calendar-invite
|
||||
SKILL_DIR=~/.openclaw/workspace/skills/calendar
|
||||
|
||||
# Send an invite
|
||||
$SKILL_DIR/scripts/calendar-invite.sh send [options]
|
||||
$SKILL_DIR/scripts/calendar.sh send [options]
|
||||
|
||||
# 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
|
||||
|
||||
```bash
|
||||
$SKILL_DIR/scripts/calendar-invite.sh send \
|
||||
$SKILL_DIR/scripts/calendar.sh send \
|
||||
--to "friend@example.com" \
|
||||
--subject "Lunch on Friday" \
|
||||
--summary "Lunch at Tartine" \
|
||||
@@ -81,7 +88,7 @@ $SKILL_DIR/scripts/calendar-invite.sh send \
|
||||
|
||||
```bash
|
||||
# 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" \
|
||||
--subject "Coffee Chat" \
|
||||
--summary "Coffee Chat" \
|
||||
@@ -89,7 +96,7 @@ $SKILL_DIR/scripts/calendar-invite.sh send \
|
||||
--end "2026-03-25T10:30:00"
|
||||
|
||||
# Multiple attendees with details
|
||||
$SKILL_DIR/scripts/calendar-invite.sh send \
|
||||
$SKILL_DIR/scripts/calendar.sh send \
|
||||
--to "alice@example.com, bob@example.com" \
|
||||
--subject "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."
|
||||
|
||||
# Dry run
|
||||
$SKILL_DIR/scripts/calendar-invite.sh send \
|
||||
$SKILL_DIR/scripts/calendar.sh send \
|
||||
--to "test@example.com" \
|
||||
--subject "Test" \
|
||||
--summary "Test Event" \
|
||||
@@ -114,18 +121,18 @@ $SKILL_DIR/scripts/calendar-invite.sh send \
|
||||
|
||||
```bash
|
||||
# Accept by himalaya envelope ID
|
||||
$SKILL_DIR/scripts/calendar-invite.sh reply \
|
||||
$SKILL_DIR/scripts/calendar.sh reply \
|
||||
--envelope-id 42 \
|
||||
--action accept
|
||||
|
||||
# Decline with a comment
|
||||
$SKILL_DIR/scripts/calendar-invite.sh reply \
|
||||
$SKILL_DIR/scripts/calendar.sh reply \
|
||||
--envelope-id 42 \
|
||||
--action decline \
|
||||
--comment "Sorry, I have a conflict."
|
||||
|
||||
# From an .ics file
|
||||
$SKILL_DIR/scripts/calendar-invite.sh reply \
|
||||
$SKILL_DIR/scripts/calendar.sh reply \
|
||||
--ics-file ~/Downloads/meeting.ics \
|
||||
--action tentative
|
||||
```
|
||||
@@ -149,7 +156,74 @@ $SKILL_DIR/scripts/calendar-invite.sh reply \
|
||||
|
||||
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`
|
||||
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
|
||||
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:**
|
||||
- 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
|
||||
- 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
|
||||
- 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?**
|
||||
- Gmail, Outlook, Apple Mail all support `text/calendar` method=REQUEST
|
||||
- Some webmail clients may vary
|
||||
@@ -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
|
||||
SKILL_DIR=~/.openclaw/workspace/skills/calendar-invite
|
||||
SKILL_DIR=~/.openclaw/workspace/skills/calendar
|
||||
|
||||
# Use a date 3 days from now for test events
|
||||
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
|
||||
|
||||
```bash
|
||||
$SKILL_DIR/scripts/calendar-invite.sh send \
|
||||
$SKILL_DIR/scripts/calendar.sh send \
|
||||
--to "mail@luyx.org" \
|
||||
--subject "Test Invite" \
|
||||
--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).
|
||||
|
||||
```bash
|
||||
$SKILL_DIR/scripts/calendar-invite.sh send \
|
||||
$SKILL_DIR/scripts/calendar.sh send \
|
||||
--to "mail@luyx.org" \
|
||||
--subject "Calendar Skill Test" \
|
||||
--summary "Calendar Skill Test" \
|
||||
@@ -82,7 +82,7 @@ himalaya envelope list
|
||||
himalaya message read <envelope-id>
|
||||
|
||||
# Accept it
|
||||
$SKILL_DIR/scripts/calendar-invite.sh reply \
|
||||
$SKILL_DIR/scripts/calendar.sh reply \
|
||||
--envelope-id <envelope-id> \
|
||||
--action accept
|
||||
```
|
||||
@@ -100,7 +100,7 @@ Send another self-invite, then decline it. This verifies decline removes the eve
|
||||
|
||||
```bash
|
||||
# Send a second test invite
|
||||
$SKILL_DIR/scripts/calendar-invite.sh send \
|
||||
$SKILL_DIR/scripts/calendar.sh send \
|
||||
--to "mail@luyx.org" \
|
||||
--subject "Decline Test" \
|
||||
--summary "Decline Test Event" \
|
||||
@@ -111,7 +111,7 @@ $SKILL_DIR/scripts/calendar-invite.sh send \
|
||||
himalaya envelope list
|
||||
|
||||
# Decline it
|
||||
$SKILL_DIR/scripts/calendar-invite.sh reply \
|
||||
$SKILL_DIR/scripts/calendar.sh reply \
|
||||
--envelope-id <envelope-id> \
|
||||
--action decline \
|
||||
--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
|
||||
|
||||
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 |
|
||||
| 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/` |
|
||||
| Todo not syncing | Check `~/.openclaw/workspace/calendars/tasks/` exists, verify vdirsyncer `cal/tasks` pair |
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"ownerId": "kn7anq2d7gcch060anc2j9cg89800dyv",
|
||||
"slug": "calendar-invite",
|
||||
"slug": "calendar",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
[project]
|
||||
name = "calendar-invite"
|
||||
name = "calendar"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["icalendar"]
|
||||
@@ -1,27 +1,32 @@
|
||||
#!/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 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
|
||||
python calendar.py send [options] # create and send an invite
|
||||
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 subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import date, 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
|
||||
from icalendar import Alarm, Calendar, Event, Todo, vCalAddress, vText
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
@@ -31,7 +36,12 @@ 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"
|
||||
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}")
|
||||
|
||||
|
||||
def _parse_date(date_str):
|
||||
"""Parse YYYY-MM-DD date string."""
|
||||
return datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Send invite
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -370,12 +385,359 @@ def cmd_reply(args):
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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)
|
||||
|
||||
# --- send ---
|
||||
@@ -405,12 +767,53 @@ def main():
|
||||
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")
|
||||
|
||||
# --- 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()
|
||||
|
||||
if args.command == "send":
|
||||
cmd_send(args)
|
||||
elif args.command == "reply":
|
||||
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__":
|
||||
20
skills/calendar/scripts/calendar.sh
Executable file
20
skills/calendar/scripts/calendar.sh
Executable 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" "$@"
|
||||
@@ -3,7 +3,7 @@ revision = 3
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[[package]]
|
||||
name = "calendar-invite"
|
||||
name = "calendar"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
Reference in New Issue
Block a user