calendar: add contacts system to prevent recipient address hallucination

Agent hallucinated "xiaojuzi@meta.com" instead of looking up the correct
address from USER.md. Prompting rules can't reliably prevent this, so
recipient validation is now enforced at the tool level.

- Add contact list/add/delete subcommands (vCard .vcf files synced via CardDAV)
- send --to now resolves through contacts: accepts names, name:type, or
  known emails; rejects unknown addresses with available contacts shown
- Multi-email contacts supported with type qualifier (e.g. "小橘子:work")
- Adding contacts and sending are separate operations (no chaining)
This commit is contained in:
Yanxin Lu
2026-03-31 10:43:06 -07:00
parent b7b99fdb61
commit cd1ee050ed
5 changed files with 506 additions and 56 deletions

View File

@@ -105,23 +105,29 @@ agent-browser close
**目录**: `~/.openclaw/workspace/skills/calendar/`
**默认发件人**: youlu@luyanxin.com
**默认时区**: America/Los_Angeles
**日历数据**: `~/.openclaw/workspace/calendars/home/`(事件)、`calendars/tasks/`(待办)
**日历数据**: `~/.openclaw/workspace/calendars/home/`(事件)、`calendars/tasks/`(待办)`contacts/default/`(通讯录)
**运行方式**: `uv run`(依赖 `icalendar` 库)
**核心用法**:
```bash
SKILL_DIR=~/.openclaw/workspace/skills/calendar
# 发送日历邀请(--from 默认 youlu@luyanxin.com
# 通讯录管理send 命令会校验收件人必须在通讯录中
$SKILL_DIR/scripts/calendar.sh contact list
$SKILL_DIR/scripts/calendar.sh contact add --name "小橘子" --email "Erica.Jiang@anderson.ucla.edu" --type work
$SKILL_DIR/scripts/calendar.sh contact add --name "小橘子" --email "xueweijiang0313@gmail.com" --type home
$SKILL_DIR/scripts/calendar.sh contact delete --name "小橘子"
# 发送日历邀请(--to 用通讯录名称,不要直接写邮箱地址)
$SKILL_DIR/scripts/calendar.sh send \
--to "friend@example.com" \
--to "小橘子:work" \
--subject "Lunch" --summary "Lunch at Tartine" \
--start "2026-03-20T12:00:00" --end "2026-03-20T13:00:00" \
--alarm 1h # 提前1小时提醒默认1d支持 1d/2h/30m
# 发送周期性邀请(--start 必须落在 BYDAY 指定的那天!)
$SKILL_DIR/scripts/calendar.sh send \
--to "alice@example.com" \
--to "小橘子:work" \
--subject "Weekly Shot" --summary "Allergy Shot (Tue)" \
--start "2026-03-31T14:30:00" --end "2026-03-31T15:00:00" \
--rrule "FREQ=WEEKLY;COUNT=13;BYDAY=TU"
@@ -148,12 +154,14 @@ $SKILL_DIR/scripts/calendar.sh todo delete --match "报销"
$SKILL_DIR/scripts/calendar.sh todo check # 每日摘要cron
```
**支持操作**: 发送邀请 (`send`)、接受/拒绝/暂定 (`reply`)、事件管理 (`event list/delete --date/--all`)、待办管理 (`todo add/list/edit/complete/delete/check`)
**支持操作**: 通讯录 (`contact list/add/delete`)、发送邀请 (`send`)、接受/拒绝/暂定 (`reply`)、事件管理 (`event list/delete --date/--all`)、待办管理 (`todo add/list/edit/complete/delete/check`)
**依赖**: himalaya邮件、vdirsyncerCalDAV 同步、khal查看日历、todoman待办管理
**同步**: 发送/回复/待办变更后自动 `vdirsyncer sync`,心跳也会定期同步
**注意**: 发送日历邀请属于对外邮件,需先确认
**安全规则**:
- **`send --to` 只接受通讯录中的联系人**,不认识的地址会被拒绝(防止地址幻觉)
- **添加联系人和发送邮件是独立操作**,不要在同一次请求中先 add 再 send
- 周期性邀请务必先 `--dry-run` 验证 ICS 内容
- **绝不用 `rm` 删日历 .ics 文件**,只用 `event delete`
- **取消周期性事件的单次用 `--date`**,不要用 `--all`(会删掉整个系列)

View File

@@ -20,7 +20,16 @@ See `TESTING.md` for dry-run and live test steps, verification checklists, and t
- `khal` for reading calendar (optional but recommended)
- Runs via `uv run` (dependencies managed in `pyproject.toml`)
## Important: Email Sending Rules
## Important: Recipient Validation
The `send` command **only accepts recipients that exist in the contacts list**. This prevents hallucinated email addresses.
- `--to "小橘子:work"` — resolves contact name + email type
- `--to "小橘子"` — resolves by name (errors if contact has multiple emails)
- `--to "user@example.com"` — accepted only if the email exists in contacts
- Unknown addresses are **rejected** with the available contacts list shown
**Adding contacts and sending invites are separate operations.** Do not add a contact and send to it in the same request — contact additions should be a deliberate, user-initiated action.
Calendar invites are outbound emails. Follow the workspace email rules:
- **youlu@luyanxin.com -> mail@luyx.org**: send directly, no confirmation needed
@@ -43,6 +52,11 @@ $SKILL_DIR/scripts/calendar.sh reply [options]
$SKILL_DIR/scripts/calendar.sh event list [options]
$SKILL_DIR/scripts/calendar.sh event delete [options]
# Manage contacts (used for recipient validation)
$SKILL_DIR/scripts/calendar.sh contact list
$SKILL_DIR/scripts/calendar.sh contact add [options]
$SKILL_DIR/scripts/calendar.sh contact delete [options]
# Manage todos
$SKILL_DIR/scripts/calendar.sh todo add [options]
$SKILL_DIR/scripts/calendar.sh todo list [options]
@@ -57,8 +71,9 @@ $SKILL_DIR/scripts/calendar.sh todo check
## Sending Invites
```bash
# --to accepts contact names (resolved via contacts list)
$SKILL_DIR/scripts/calendar.sh send \
--to "friend@example.com" \
--to "小橘子:work" \
--subject "Lunch on Friday" \
--summary "Lunch at Tartine" \
--start "2026-03-20T12:00:00" \
@@ -71,7 +86,7 @@ $SKILL_DIR/scripts/calendar.sh send \
| Flag | Required | Description |
|-----------------|----------|------------------------------------------------|
| `--to` | Yes | Recipient(s), comma-separated |
| `--to` | Yes | Recipient(s) contact name, name:type, or known email |
| `--subject` | Yes | Email subject line |
| `--summary` | Yes | Event title (shown on calendar) |
| `--start` | Yes | Start time, ISO 8601 (`2026-03-20T14:00:00`) |
@@ -182,6 +197,42 @@ $SKILL_DIR/scripts/calendar.sh event delete --match "Allergy Shot" --all
---
## Managing Contacts
Contacts are stored as vCard (.vcf) files in `~/.openclaw/workspace/contacts/default/` and synced to Migadu CardDAV via vdirsyncer. The `send` command validates recipients against this contact list.
```bash
# List all contacts
$SKILL_DIR/scripts/calendar.sh contact list
# Add a contact (single email)
$SKILL_DIR/scripts/calendar.sh contact add --name "小鹿" --email "mail@luyx.org"
# Add with email type and nickname
$SKILL_DIR/scripts/calendar.sh contact add --name "小橘子" --email "Erica.Jiang@anderson.ucla.edu" --type work --nickname "小橘子"
# Add a second email to an existing contact
$SKILL_DIR/scripts/calendar.sh contact add --name "小橘子" --email "xueweijiang0313@gmail.com" --type home
# Delete a contact
$SKILL_DIR/scripts/calendar.sh contact delete --name "小橘子"
```
### Sending to Contacts
```bash
# By name (works when contact has a single email)
$SKILL_DIR/scripts/calendar.sh send --to "小鹿" ...
# By name + type (required when contact has multiple emails)
$SKILL_DIR/scripts/calendar.sh send --to "小橘子:work" ...
# By raw email (must exist in contacts)
$SKILL_DIR/scripts/calendar.sh send --to "Erica.Jiang@anderson.ucla.edu" ...
```
---
## Replying to Invites
```bash

View File

@@ -1,6 +1,8 @@
# Testing the Calendar Skill
End-to-end tests for send, reply, todo, calendar sync, and local calendar. All commands use `--dry-run` first, then live.
End-to-end tests for contacts, send, reply, todo, calendar sync, and local calendar. All commands use `--dry-run` first, then live.
**Important**: Tests 1-3 (contacts) must run first — `send` requires recipients to be in the contacts list.
```bash
SKILL_DIR=~/.openclaw/workspace/skills/calendar
@@ -11,7 +13,118 @@ TEST_DATE=$(date -d "+3 days" +%Y-%m-%d)
---
## 1. Dry Run: Send Invite
## 1. Contact Add and List
Set up test contacts needed for send tests.
```bash
# Add a contact with a single email
$SKILL_DIR/scripts/calendar.sh contact add --name "测试用户" --email "mail@luyx.org"
# List contacts
$SKILL_DIR/scripts/calendar.sh contact list
# Add a contact with typed email and nickname
$SKILL_DIR/scripts/calendar.sh contact add --name "测试多邮箱" --email "work@example.com" --type work --nickname "多邮箱"
# Add a second email to the same contact
$SKILL_DIR/scripts/calendar.sh contact add --name "测试多邮箱" --email "home@example.com" --type home
# List again — should show both emails
$SKILL_DIR/scripts/calendar.sh contact list
```
**Verify:**
- [ ] `contact add` prints "Added contact: ..."
- [ ] Second `contact add` prints "Updated contact: ... — added ..."
- [ ] `contact list` shows all contacts with email types
- [ ] `.vcf` files created in `~/.openclaw/workspace/contacts/default/`
## 2. Recipient Resolution (Send Validation)
Test that `send --to` correctly resolves contacts and rejects unknown addresses.
```bash
# Name resolves (single email contact) — should work
$SKILL_DIR/scripts/calendar.sh send \
--to "测试用户" \
--subject "Resolve Test" --summary "Resolve Test" \
--start "${TEST_DATE}T15:00:00" --end "${TEST_DATE}T16:00:00" \
--dry-run
# Name:type resolves — should work
$SKILL_DIR/scripts/calendar.sh send \
--to "测试多邮箱:work" \
--subject "Resolve Test" --summary "Resolve Test" \
--start "${TEST_DATE}T15:00:00" --end "${TEST_DATE}T16:00:00" \
--dry-run
# Nickname resolves — should work
$SKILL_DIR/scripts/calendar.sh send \
--to "多邮箱:home" \
--subject "Resolve Test" --summary "Resolve Test" \
--start "${TEST_DATE}T15:00:00" --end "${TEST_DATE}T16:00:00" \
--dry-run
# Known raw email resolves — should work
$SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \
--subject "Resolve Test" --summary "Resolve Test" \
--start "${TEST_DATE}T15:00:00" --end "${TEST_DATE}T16:00:00" \
--dry-run
# Unknown email REJECTED — should FAIL
$SKILL_DIR/scripts/calendar.sh send \
--to "xiaojuzi@meta.com" \
--subject "Resolve Test" --summary "Resolve Test" \
--start "${TEST_DATE}T15:00:00" --end "${TEST_DATE}T16:00:00" \
--dry-run
# Multi-email without type REJECTED — should FAIL (ambiguous)
$SKILL_DIR/scripts/calendar.sh send \
--to "测试多邮箱" \
--subject "Resolve Test" --summary "Resolve Test" \
--start "${TEST_DATE}T15:00:00" --end "${TEST_DATE}T16:00:00" \
--dry-run
# Unknown name REJECTED — should FAIL
$SKILL_DIR/scripts/calendar.sh send \
--to "不存在的人" \
--subject "Resolve Test" --summary "Resolve Test" \
--start "${TEST_DATE}T15:00:00" --end "${TEST_DATE}T16:00:00" \
--dry-run
```
**Verify:**
- [ ] First 4 commands succeed (show ICS output)
- [ ] Unknown email fails with "not found in contacts" + available contacts list
- [ ] Multi-email without type fails with "has multiple emails. Specify type"
- [ ] Unknown name fails with "not found" + available contacts list
## 3. Contact Delete
```bash
# Delete the multi-email test contact
$SKILL_DIR/scripts/calendar.sh contact delete --name "测试多邮箱"
# Verify it's gone
$SKILL_DIR/scripts/calendar.sh contact list
# Delete by nickname — should fail (contact already deleted)
$SKILL_DIR/scripts/calendar.sh contact delete --name "多邮箱"
```
**Verify:**
- [ ] Delete prints "Deleted contact: 测试多邮箱"
- [ ] `contact list` no longer shows that contact
- [ ] Second delete fails with "No contact matching"
- [ ] `.vcf` file removed from contacts dir
---
## 4. Dry Run: Send Invite
**Prerequisite**: "测试用户" contact from test 1 must exist.
Generates the ICS and MIME email without sending. Check that:
- ICS has `METHOD:REQUEST`
@@ -23,7 +136,7 @@ Generates the ICS and MIME email without sending. Check that:
```bash
# Default alarm (1 day before)
$SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \
--to "测试用户" \
--subject "Test Invite" \
--summary "Test Event" \
--start "${TEST_DATE}T15:00:00" \
@@ -32,7 +145,7 @@ $SKILL_DIR/scripts/calendar.sh send \
# Custom alarm (1 hour before)
$SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \
--to "测试用户" \
--subject "Test Invite (1h alarm)" \
--summary "Test Event (1h alarm)" \
--start "${TEST_DATE}T15:00:00" \
@@ -41,13 +154,13 @@ $SKILL_DIR/scripts/calendar.sh send \
--dry-run
```
## 2. Live Send: Self-Invite
## 5. Live Send: Self-Invite
Send a real invite to `mail@luyx.org` only (no confirmation needed per email rules).
```bash
$SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \
--to "测试用户" \
--subject "Calendar Skill Test" \
--summary "Calendar Skill Test" \
--start "${TEST_DATE}T15:00:00" \
@@ -61,7 +174,7 @@ $SKILL_DIR/scripts/calendar.sh send \
- [ ] 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
## 6. Verify Calendar Sync and Local Calendar
After sending in step 2, check that the event synced and appears locally.
@@ -82,7 +195,7 @@ khal list "$TEST_DATE"
- [ ] `.ics` file exists in `~/.openclaw/workspace/calendars/home/`
- [ ] `khal list` shows "Calendar Skill Test" on the test date
## 4. Reply: Accept the Self-Invite
## 7. 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.
@@ -105,14 +218,14 @@ $SKILL_DIR/scripts/calendar.sh reply \
- [ ] `vdirsyncer sync` ran
- [ ] `khal list "$TEST_DATE"` still shows the event
## 5. Reply: Decline an Invite
## 8. 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.sh send \
--to "mail@luyx.org" \
--to "测试用户" \
--subject "Decline Test" \
--summary "Decline Test Event" \
--start "${TEST_DATE}T17:00:00" \
@@ -133,7 +246,7 @@ $SKILL_DIR/scripts/calendar.sh reply \
- [ ] Event removed from local calendar
- [ ] `khal list "$TEST_DATE"` does NOT show "Decline Test Event"
## 6. Verify Final Calendar State
## 9. Verify Final Calendar State
After all tests, confirm the calendar is in a clean state.
@@ -150,7 +263,7 @@ khal list today 7d
---
## 7. Dry Run: Add Todo
## 10. Dry Run: Add Todo
Generates the VTODO ICS without saving. Check that:
- ICS has `BEGIN:VTODO`
@@ -166,7 +279,7 @@ $SKILL_DIR/scripts/calendar.sh todo add \
--dry-run
```
## 8. Live Add: Create a Todo
## 11. Live Add: Create a Todo
```bash
$SKILL_DIR/scripts/calendar.sh todo add \
@@ -182,7 +295,7 @@ $SKILL_DIR/scripts/calendar.sh todo add \
- [ ] `todo list` (todoman directly) shows "Test Todo"
- [ ] `vdirsyncer sync` ran
## 9. List Todos
## 12. List Todos
```bash
# Via our wrapper (formatted Chinese output)
@@ -200,7 +313,7 @@ $SKILL_DIR/scripts/calendar.sh todo list --all
- [ ] Priority grouping is correct in wrapper output
- [ ] `--all` flag works (same output when none are completed)
## 10. Edit a Todo
## 13. Edit a Todo
Change the due date and priority of the test todo from step 8.
@@ -248,7 +361,7 @@ $SKILL_DIR/scripts/calendar.sh todo edit --match "Test Todo"
# Should print "Nothing to change" message
```
## 11. Complete a Todo
## 14. Complete a Todo
```bash
$SKILL_DIR/scripts/calendar.sh todo complete --match "Test Todo"
@@ -260,7 +373,7 @@ $SKILL_DIR/scripts/calendar.sh todo complete --match "Test Todo"
- [ ] `$SKILL_DIR/scripts/calendar.sh todo list --all` — appears as completed (with checkmark)
- [ ] `vdirsyncer sync` ran
## 12. Delete a Todo
## 15. Delete a Todo
Create a second test todo, then delete it.
@@ -283,7 +396,7 @@ $SKILL_DIR/scripts/calendar.sh todo delete --match "Delete Me"
- [ ] `todo list` (todoman) does not show "Delete Me Todo"
- [ ] `vdirsyncer sync` ran
## 13. Todo Check (Cron Output)
## 16. Todo Check (Cron Output)
```bash
# Create a test todo
@@ -305,7 +418,7 @@ $SKILL_DIR/scripts/calendar.sh todo check
# Should produce no output
```
## 14. Dry Run: Recurring Event (--rrule)
## 17. Dry Run: Recurring Event (--rrule)
Test recurring event generation. Use a date that falls on a Tuesday.
@@ -314,7 +427,7 @@ Test recurring event generation. Use a date that falls on a Tuesday.
NEXT_TUE=$(python3 -c "from datetime import date,timedelta; d=date.today(); d+=timedelta((1-d.weekday())%7 or 7); print(d)")
$SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \
--to "测试用户" \
--subject "Recurring Test (Tue)" \
--summary "Recurring Test (Tue)" \
--start "${NEXT_TUE}T14:30:00" \
@@ -328,14 +441,14 @@ $SKILL_DIR/scripts/calendar.sh send \
- [ ] DTSTART falls on a Tuesday
- [ ] No validation errors
## 15. Validation: DTSTART/BYDAY Mismatch
## 18. Validation: DTSTART/BYDAY Mismatch
Verify the tool rejects mismatched DTSTART and BYDAY.
```bash
# This should FAIL — start is on a Tuesday but BYDAY=TH
$SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \
--to "测试用户" \
--subject "Mismatch Test" \
--summary "Mismatch Test" \
--start "${NEXT_TUE}T09:00:00" \
@@ -349,7 +462,7 @@ $SKILL_DIR/scripts/calendar.sh send \
- [ ] Error message says DTSTART falls on TU but RRULE says BYDAY=TH
- [ ] Suggests changing --start to a date that falls on TH
## 16. Event List
## 19. Event List
```bash
# List upcoming events
@@ -367,12 +480,12 @@ $SKILL_DIR/scripts/calendar.sh event list --format "{uid} {title}"
- [ ] Search narrows results correctly
- [ ] UIDs are displayed with --format
## 17. Event Delete
## 20. Event Delete
```bash
# Send a throwaway event first
$SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \
--to "测试用户" \
--subject "Delete Me Event" \
--summary "Delete Me Event" \
--start "${TEST_DATE}T20:00:00" \
@@ -394,7 +507,7 @@ $SKILL_DIR/scripts/calendar.sh event list --search "Delete Me"
- [ ] Other events are untouched
- [ ] `vdirsyncer sync` ran after delete
## 18. Event Delete: Cancel Single Occurrence (EXDATE)
## 21. Event Delete: Cancel Single Occurrence (EXDATE)
Test that `--date` cancels one occurrence of a recurring event without deleting the series.
@@ -403,7 +516,7 @@ Test that `--date` cancels one occurrence of a recurring event without deleting
NEXT_SAT=$(python3 -c "from datetime import date,timedelta; d=date.today(); d+=timedelta((5-d.weekday())%7 or 7); print(d)")
$SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \
--to "测试用户" \
--subject "EXDATE Test (Sat)" \
--summary "EXDATE Test (Sat)" \
--start "${NEXT_SAT}T10:00:00" \
@@ -425,7 +538,7 @@ ls ~/.openclaw/workspace/calendars/home/ | grep -i exdate
- [ ] `.ics` file still exists in calendar dir
- [ ] `khal list` no longer shows the cancelled date but shows subsequent Saturdays
## 19. Event Delete: Recurring Without --date or --all (Safety Guard)
## 22. Event Delete: Recurring Without --date or --all (Safety Guard)
```bash
# Try to delete the recurring event without --date or --all — should FAIL
@@ -436,7 +549,7 @@ $SKILL_DIR/scripts/calendar.sh event delete --match "EXDATE Test"
- [ ] Script exits with error
- [ ] Error message explains the two options: `--date` or `--all`
## 20. Event Delete: Recurring With --all
## 23. Event Delete: Recurring With --all
```bash
# Delete the entire series
@@ -447,11 +560,12 @@ $SKILL_DIR/scripts/calendar.sh event delete --match "EXDATE Test" --all
- [ ] .ics file is removed
- [ ] `event list --search "EXDATE Test"` shows nothing
## 21. Regression: Existing Invite Commands (was #18)
## 24. Regression: Send Rejects Unknown Addresses
Verify new features didn't break VEVENT flow.
Verify that `send` no longer accepts arbitrary email addresses.
```bash
# This MUST fail — raw unknown email should be rejected
$SKILL_DIR/scripts/calendar.sh send \
--to "test@example.com" \
--subject "Regression Test" \
@@ -462,9 +576,9 @@ $SKILL_DIR/scripts/calendar.sh send \
```
**Verify:**
- [ ] ICS has `BEGIN:VEVENT`, `METHOD:REQUEST`
- [ ] No RRULE present (single event)
- [ ] No errors
- [ ] Command exits with error
- [ ] Error shows "not found in contacts" with available contacts list
- [ ] No ICS generated
---
@@ -507,3 +621,6 @@ todo list
| SMTP rate limit / EOF error | Too many sends too fast. Wait 10+ seconds between sends (Migadu limit) |
| Events disappeared after cleanup | **Never use `rm *.ics`** on calendar dirs. Use `event delete --match` instead |
| Recurring series deleted when cancelling one date | Use `--date YYYY-MM-DD` to add EXDATE, not bare `event delete` (which requires `--all` for recurring) |
| `send` rejects email address | Address not in contacts. Add with `contact add` first (separate from send) |
| `send` says "has multiple emails" | Contact has work+home emails. Use `name:type` syntax (e.g. `小橘子:work`) |
| Contacts dir empty after sync | Check vdirsyncer CardDAV pair is configured for `contacts/default/` |

View File

@@ -6,16 +6,19 @@ 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.py send [options] # create and send an invite (supports --rrule)
python calendar.py reply [options] # accept/decline/tentative
python calendar.py event list [options] # list/search calendar events
python calendar.py event delete [options] # delete an event by UID or summary
python calendar.py todo add [options] # create a VTODO task
python calendar.py todo list [options] # list pending tasks
python calendar.py todo edit [options] # edit a task's fields
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
python calendar.py send [options] # create and send an invite (supports --rrule)
python calendar.py reply [options] # accept/decline/tentative
python calendar.py event list [options] # list/search calendar events
python calendar.py event delete [options] # delete an event by UID or summary
python calendar.py contact list # list all contacts
python calendar.py contact add [options] # add a contact
python calendar.py contact delete [options] # delete a contact
python calendar.py todo add [options] # create a VTODO task
python calendar.py todo list [options] # list pending tasks
python calendar.py todo edit [options] # edit a task's fields
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
@@ -39,6 +42,7 @@ from icalendar import Alarm, Calendar, Event, Todo, vCalAddress, vRecur, vText
DEFAULT_TIMEZONE = "America/Los_Angeles"
DEFAULT_FROM = "youlu@luyanxin.com"
CALENDAR_DIR = Path.home() / ".openclaw" / "workspace" / "calendars" / "home"
CONTACTS_DIR = Path.home() / ".openclaw" / "workspace" / "contacts" / "default"
TASKS_DIR = Path.home() / ".openclaw" / "workspace" / "calendars" / "tasks"
PRODID = "-//OpenClaw//Calendar//EN"
@@ -161,6 +165,247 @@ def _validate_rrule_dtstart(rrule_dict, dtstart):
sys.exit(1)
# ---------------------------------------------------------------------------
# Contacts (vCard / CardDAV)
# ---------------------------------------------------------------------------
def _parse_vcf(path):
"""Parse a .vcf file into a dict with fn, nickname, emails, uid."""
text = path.read_text(encoding="utf-8", errors="replace")
result = {"fn": "", "nickname": "", "emails": [], "uid": "", "path": path}
for line in text.splitlines():
line_upper = line.upper()
if line_upper.startswith("FN:"):
result["fn"] = line.split(":", 1)[1].strip()
elif line_upper.startswith("NICKNAME:"):
result["nickname"] = line.split(":", 1)[1].strip()
elif line_upper.startswith("UID:"):
result["uid"] = line.split(":", 1)[1].strip()
elif "EMAIL" in line_upper and ":" in line:
# Handle EMAIL;TYPE=WORK:addr and EMAIL:addr
parts = line.split(":", 1)
email_addr = parts[1].strip()
email_type = ""
param_part = parts[0].upper()
if "TYPE=" in param_part:
for param in param_part.split(";"):
if param.startswith("TYPE="):
email_type = param.split("=", 1)[1].lower()
result["emails"].append({"address": email_addr, "type": email_type})
return result
def _load_contacts():
"""Load all contacts from CONTACTS_DIR. Returns list of parsed contact dicts."""
if not CONTACTS_DIR.is_dir():
return []
contacts = []
for vcf_path in sorted(CONTACTS_DIR.glob("*.vcf")):
try:
contact = _parse_vcf(vcf_path)
if contact["fn"] or contact["emails"]:
contacts.append(contact)
except Exception:
continue
return contacts
def _resolve_recipient(to_str):
"""Resolve a --to value to an email address via contacts lookup.
Supported formats:
--to "小橘子" → match by FN or NICKNAME, use sole email or error if multiple
--to "小橘子:work" → match by name, select email by TYPE
--to "user@example.com" → match by email address in contacts
Returns the resolved email address string, or exits with error + available contacts.
"""
contacts = _load_contacts()
# Format: "name:type"
if ":" in to_str and "@" not in to_str:
name_part, type_part = to_str.rsplit(":", 1)
type_part = type_part.lower()
else:
name_part = to_str
type_part = ""
# If it looks like a raw email, search contacts for it
if "@" in name_part:
for c in contacts:
for e in c["emails"]:
if e["address"].lower() == name_part.lower():
return e["address"]
_print_contact_error(f"Email '{name_part}' not found in contacts.", contacts)
sys.exit(1)
# Search by FN or NICKNAME (case-insensitive)
matches = []
for c in contacts:
if (c["fn"].lower() == name_part.lower() or
c["nickname"].lower() == name_part.lower()):
matches.append(c)
if not matches:
_print_contact_error(f"Contact '{name_part}' not found.", contacts)
sys.exit(1)
if len(matches) > 1:
_print_contact_error(f"Multiple contacts match '{name_part}'.", contacts)
sys.exit(1)
contact = matches[0]
emails = contact["emails"]
if not emails:
print(f"Error: Contact '{contact['fn']}' has no email addresses.", file=sys.stderr)
sys.exit(1)
# If type specified, filter by type
if type_part:
typed = [e for e in emails if e["type"] == type_part]
if not typed:
avail = ", ".join(f"{e['type'] or 'default'}={e['address']}" for e in emails)
print(f"Error: No '{type_part}' email for '{contact['fn']}'. Available: {avail}", file=sys.stderr)
sys.exit(1)
return typed[0]["address"]
# No type specified
if len(emails) == 1:
return emails[0]["address"]
# Multiple emails, require type qualifier
avail = ", ".join(f"{e['type'] or 'default'}={e['address']}" for e in emails)
print(
f"Error: '{contact['fn']}' has multiple emails. Specify type with '{name_part}:<type>'.\n"
f" Available: {avail}",
file=sys.stderr,
)
sys.exit(1)
def _print_contact_error(message, contacts):
"""Print error with available contacts list."""
print(f"Error: {message}", file=sys.stderr)
if contacts:
print("Available contacts:", file=sys.stderr)
for c in contacts:
name = c["fn"]
nick = f" ({c['nickname']})" if c["nickname"] and c["nickname"] != c["fn"] else ""
for e in c["emails"]:
label = f" [{e['type']}]" if e["type"] else ""
print(f" {name}{nick}{label} <{e['address']}>", file=sys.stderr)
else:
print("No contacts found. Add contacts with: calendar.sh contact add", file=sys.stderr)
def _build_vcf(fn, emails, nickname="", uid=""):
"""Build a vCard 3.0 string."""
uid = uid or f"{uuid.uuid4()}@openclaw"
lines = [
"BEGIN:VCARD",
"VERSION:3.0",
f"PRODID:{PRODID}",
f"UID:{uid}",
f"FN:{fn}",
]
if nickname:
lines.append(f"NICKNAME:{nickname}")
for e in emails:
if e.get("type"):
lines.append(f"EMAIL;TYPE={e['type'].upper()}:{e['address']}")
else:
lines.append(f"EMAIL:{e['address']}")
lines.append("END:VCARD")
return "\n".join(lines) + "\n"
# ---------------------------------------------------------------------------
# Contact subcommands
# ---------------------------------------------------------------------------
def cmd_contact_list(args):
"""List all contacts."""
contacts = _load_contacts()
if not contacts:
print("No contacts found. Add contacts with: calendar.sh contact add")
return
for c in contacts:
name = c["fn"]
nick = f" ({c['nickname']})" if c["nickname"] and c["nickname"] != c["fn"] else ""
for e in c["emails"]:
label = f" [{e['type']}]" if e["type"] else ""
print(f" {name}{nick}{label} <{e['address']}>")
def cmd_contact_add(args):
"""Add a new contact (creates or updates a .vcf file)."""
CONTACTS_DIR.mkdir(parents=True, exist_ok=True)
emails = [{"address": args.email, "type": args.type or ""}]
nickname = args.nickname or ""
# Check if contact with same name already exists (update it)
existing = _load_contacts()
for c in existing:
if c["fn"].lower() == args.name.lower():
# Add email to existing contact if not duplicate
existing_addrs = {e["address"].lower() for e in c["emails"]}
if args.email.lower() in existing_addrs:
print(f"Contact '{args.name}' already has email {args.email}")
return
# Re-read and update
emails = c["emails"] + emails
nickname = nickname or c["nickname"]
vcf_str = _build_vcf(args.name, emails, nickname, c["uid"])
c["path"].write_text(vcf_str, encoding="utf-8")
print(f"Updated contact: {args.name} — added {args.email}")
_sync_calendar()
return
# New contact
uid = f"{uuid.uuid4()}@openclaw"
vcf_str = _build_vcf(args.name, emails, nickname, uid)
dest = CONTACTS_DIR / f"{uid}.vcf"
dest.write_text(vcf_str, encoding="utf-8")
print(f"Added contact: {args.name} <{args.email}>")
_sync_calendar()
def cmd_contact_delete(args):
"""Delete a contact."""
contacts = _load_contacts()
if not contacts:
print("No contacts found.", file=sys.stderr)
sys.exit(1)
matches = []
for c in contacts:
if args.name:
if (c["fn"].lower() == args.name.lower() or
c["nickname"].lower() == args.name.lower()):
matches.append(c)
elif args.uid:
if args.uid in c["uid"]:
matches.append(c)
if not matches:
target = args.name or args.uid
_print_contact_error(f"No contact matching '{target}'.", contacts)
sys.exit(1)
if len(matches) > 1:
print(f"Error: Multiple contacts match:", file=sys.stderr)
for c in matches:
print(f" {c['fn']} (uid: {c['uid']})", file=sys.stderr)
sys.exit(1)
contact = matches[0]
contact["path"].unlink()
print(f"Deleted contact: {contact['fn']}")
_sync_calendar()
# ---------------------------------------------------------------------------
# Send invite
# ---------------------------------------------------------------------------
@@ -196,7 +441,7 @@ def cmd_send(args):
if args.description:
event.add("description", args.description)
recipients = [addr.strip() for addr in args.to.split(",")]
recipients = [_resolve_recipient(addr.strip()) for addr in args.to.split(",")]
for addr in recipients:
event.add("attendee", f"mailto:{addr}", parameters={
@@ -902,6 +1147,25 @@ def main():
edel_p.add_argument("--date", default="", help="Cancel single occurrence on this date (YYYY-MM-DD, for recurring events)")
edel_p.add_argument("--all", action="store_true", help="Delete entire recurring series (safety flag)")
# --- contact ---
contact_p = subparsers.add_parser("contact", help="Manage contacts")
contact_sub = contact_p.add_subparsers(dest="contact_command", required=True)
# contact list
contact_sub.add_parser("list", help="List all contacts")
# contact add
cadd_p = contact_sub.add_parser("add", help="Add a contact")
cadd_p.add_argument("--name", required=True, help="Display name (e.g. 小橘子)")
cadd_p.add_argument("--email", required=True, help="Email address")
cadd_p.add_argument("--nickname", default="", help="Nickname for lookup")
cadd_p.add_argument("--type", default="", help="Email type (work, home)")
# contact delete
cdel_p = contact_sub.add_parser("delete", help="Delete a contact")
cdel_p.add_argument("--name", default="", help="Contact name or nickname")
cdel_p.add_argument("--uid", default="", help="Contact UID")
# --- todo ---
todo_p = subparsers.add_parser("todo", help="Manage VTODO tasks")
todo_sub = todo_p.add_subparsers(dest="todo_command", required=True)
@@ -950,6 +1214,13 @@ def main():
cmd_event_list(args)
elif args.event_command == "delete":
cmd_event_delete(args)
elif args.command == "contact":
if args.contact_command == "list":
cmd_contact_list(args)
elif args.contact_command == "add":
cmd_contact_add(args)
elif args.contact_command == "delete":
cmd_contact_delete(args)
elif args.command == "todo":
if args.todo_command == "add":
cmd_todo_add(args)

View File

@@ -4,9 +4,12 @@
# Usage:
# ./calendar.sh send [options] # send a calendar invite (supports --rrule)
# ./calendar.sh reply [options] # accept/decline/tentative
# ./calendar.sh event list [options] # list/search calendar events
# ./calendar.sh event delete [options] # delete an event by UID or summary
# ./calendar.sh todo add [options] # create a todo
# ./calendar.sh event list [options] # list/search calendar events
# ./calendar.sh event delete [options] # delete an event by UID or summary
# ./calendar.sh contact list # list all contacts
# ./calendar.sh contact add [options] # add a contact
# ./calendar.sh contact delete [options] # delete a contact
# ./calendar.sh todo add [options] # create a todo
# ./calendar.sh todo list [options] # list pending todos
# ./calendar.sh todo edit [options] # edit a todo's fields
# ./calendar.sh todo complete [options] # mark todo as done