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/` **目录**: `~/.openclaw/workspace/skills/calendar/`
**默认发件人**: youlu@luyanxin.com **默认发件人**: youlu@luyanxin.com
**默认时区**: America/Los_Angeles **默认时区**: America/Los_Angeles
**日历数据**: `~/.openclaw/workspace/calendars/home/`(事件)、`calendars/tasks/`(待办) **日历数据**: `~/.openclaw/workspace/calendars/home/`(事件)、`calendars/tasks/`(待办)`contacts/default/`(通讯录)
**运行方式**: `uv run`(依赖 `icalendar` 库) **运行方式**: `uv run`(依赖 `icalendar` 库)
**核心用法**: **核心用法**:
```bash ```bash
SKILL_DIR=~/.openclaw/workspace/skills/calendar 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 \ $SKILL_DIR/scripts/calendar.sh send \
--to "friend@example.com" \ --to "小橘子:work" \
--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" \
--alarm 1h # 提前1小时提醒默认1d支持 1d/2h/30m --alarm 1h # 提前1小时提醒默认1d支持 1d/2h/30m
# 发送周期性邀请(--start 必须落在 BYDAY 指定的那天!) # 发送周期性邀请(--start 必须落在 BYDAY 指定的那天!)
$SKILL_DIR/scripts/calendar.sh send \ $SKILL_DIR/scripts/calendar.sh send \
--to "alice@example.com" \ --to "小橘子:work" \
--subject "Weekly Shot" --summary "Allergy Shot (Tue)" \ --subject "Weekly Shot" --summary "Allergy Shot (Tue)" \
--start "2026-03-31T14:30:00" --end "2026-03-31T15:00:00" \ --start "2026-03-31T14:30:00" --end "2026-03-31T15:00:00" \
--rrule "FREQ=WEEKLY;COUNT=13;BYDAY=TU" --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 $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待办管理 **依赖**: himalaya邮件、vdirsyncerCalDAV 同步、khal查看日历、todoman待办管理
**同步**: 发送/回复/待办变更后自动 `vdirsyncer sync`,心跳也会定期同步 **同步**: 发送/回复/待办变更后自动 `vdirsyncer sync`,心跳也会定期同步
**注意**: 发送日历邀请属于对外邮件,需先确认 **注意**: 发送日历邀请属于对外邮件,需先确认
**安全规则**: **安全规则**:
- **`send --to` 只接受通讯录中的联系人**,不认识的地址会被拒绝(防止地址幻觉)
- **添加联系人和发送邮件是独立操作**,不要在同一次请求中先 add 再 send
- 周期性邀请务必先 `--dry-run` 验证 ICS 内容 - 周期性邀请务必先 `--dry-run` 验证 ICS 内容
- **绝不用 `rm` 删日历 .ics 文件**,只用 `event delete` - **绝不用 `rm` 删日历 .ics 文件**,只用 `event delete`
- **取消周期性事件的单次用 `--date`**,不要用 `--all`(会删掉整个系列) - **取消周期性事件的单次用 `--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) - `khal` for reading calendar (optional but recommended)
- Runs via `uv run` (dependencies managed in `pyproject.toml`) - 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: Calendar invites are outbound emails. Follow the workspace email rules:
- **youlu@luyanxin.com -> mail@luyx.org**: send directly, no confirmation needed - **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 list [options]
$SKILL_DIR/scripts/calendar.sh event delete [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 # Manage todos
$SKILL_DIR/scripts/calendar.sh todo add [options] $SKILL_DIR/scripts/calendar.sh todo add [options]
$SKILL_DIR/scripts/calendar.sh todo list [options] $SKILL_DIR/scripts/calendar.sh todo list [options]
@@ -57,8 +71,9 @@ $SKILL_DIR/scripts/calendar.sh todo check
## Sending Invites ## Sending Invites
```bash ```bash
# --to accepts contact names (resolved via contacts list)
$SKILL_DIR/scripts/calendar.sh send \ $SKILL_DIR/scripts/calendar.sh send \
--to "friend@example.com" \ --to "小橘子:work" \
--subject "Lunch on Friday" \ --subject "Lunch on Friday" \
--summary "Lunch at Tartine" \ --summary "Lunch at Tartine" \
--start "2026-03-20T12:00:00" \ --start "2026-03-20T12:00:00" \
@@ -71,7 +86,7 @@ $SKILL_DIR/scripts/calendar.sh send \
| Flag | Required | Description | | 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 | | `--subject` | Yes | Email subject line |
| `--summary` | Yes | Event title (shown on calendar) | | `--summary` | Yes | Event title (shown on calendar) |
| `--start` | Yes | Start time, ISO 8601 (`2026-03-20T14:00:00`) | | `--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 ## Replying to Invites
```bash ```bash

View File

@@ -1,6 +1,8 @@
# Testing the Calendar Skill # 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 ```bash
SKILL_DIR=~/.openclaw/workspace/skills/calendar 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: Generates the ICS and MIME email without sending. Check that:
- ICS has `METHOD:REQUEST` - ICS has `METHOD:REQUEST`
@@ -23,7 +136,7 @@ Generates the ICS and MIME email without sending. Check that:
```bash ```bash
# Default alarm (1 day before) # Default alarm (1 day before)
$SKILL_DIR/scripts/calendar.sh send \ $SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \ --to "测试用户" \
--subject "Test Invite" \ --subject "Test Invite" \
--summary "Test Event" \ --summary "Test Event" \
--start "${TEST_DATE}T15:00:00" \ --start "${TEST_DATE}T15:00:00" \
@@ -32,7 +145,7 @@ $SKILL_DIR/scripts/calendar.sh send \
# Custom alarm (1 hour before) # Custom alarm (1 hour before)
$SKILL_DIR/scripts/calendar.sh send \ $SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \ --to "测试用户" \
--subject "Test Invite (1h alarm)" \ --subject "Test Invite (1h alarm)" \
--summary "Test Event (1h alarm)" \ --summary "Test Event (1h alarm)" \
--start "${TEST_DATE}T15:00:00" \ --start "${TEST_DATE}T15:00:00" \
@@ -41,13 +154,13 @@ $SKILL_DIR/scripts/calendar.sh send \
--dry-run --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). Send a real invite to `mail@luyx.org` only (no confirmation needed per email rules).
```bash ```bash
$SKILL_DIR/scripts/calendar.sh send \ $SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \ --to "测试用户" \
--subject "Calendar Skill Test" \ --subject "Calendar Skill Test" \
--summary "Calendar Skill Test" \ --summary "Calendar Skill Test" \
--start "${TEST_DATE}T15:00:00" \ --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) - [ ] Email shows Accept/Decline/Tentative buttons (not just an attachment)
- [ ] `.ics` file saved to `~/.openclaw/workspace/calendars/home/` - [ ] `.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. 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/` - [ ] `.ics` file exists in `~/.openclaw/workspace/calendars/home/`
- [ ] `khal list` shows "Calendar Skill Test" on the test date - [ ] `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. 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 - [ ] `vdirsyncer sync` ran
- [ ] `khal list "$TEST_DATE"` still shows the event - [ ] `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. Send another self-invite, then decline it. This verifies decline removes the event from local calendar.
```bash ```bash
# Send a second test invite # Send a second test invite
$SKILL_DIR/scripts/calendar.sh send \ $SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \ --to "测试用户" \
--subject "Decline Test" \ --subject "Decline Test" \
--summary "Decline Test Event" \ --summary "Decline Test Event" \
--start "${TEST_DATE}T17:00:00" \ --start "${TEST_DATE}T17:00:00" \
@@ -133,7 +246,7 @@ $SKILL_DIR/scripts/calendar.sh reply \
- [ ] Event removed from local calendar - [ ] Event removed from local calendar
- [ ] `khal list "$TEST_DATE"` does NOT show "Decline Test Event" - [ ] `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. 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: Generates the VTODO ICS without saving. Check that:
- ICS has `BEGIN:VTODO` - ICS has `BEGIN:VTODO`
@@ -166,7 +279,7 @@ $SKILL_DIR/scripts/calendar.sh todo add \
--dry-run --dry-run
``` ```
## 8. Live Add: Create a Todo ## 11. Live Add: Create a Todo
```bash ```bash
$SKILL_DIR/scripts/calendar.sh todo add \ $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" - [ ] `todo list` (todoman directly) shows "Test Todo"
- [ ] `vdirsyncer sync` ran - [ ] `vdirsyncer sync` ran
## 9. List Todos ## 12. List Todos
```bash ```bash
# Via our wrapper (formatted Chinese output) # 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 - [ ] Priority grouping is correct in wrapper output
- [ ] `--all` flag works (same output when none are completed) - [ ] `--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. 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 # Should print "Nothing to change" message
``` ```
## 11. Complete a Todo ## 14. Complete a Todo
```bash ```bash
$SKILL_DIR/scripts/calendar.sh todo complete --match "Test Todo" $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) - [ ] `$SKILL_DIR/scripts/calendar.sh todo list --all` — appears as completed (with checkmark)
- [ ] `vdirsyncer sync` ran - [ ] `vdirsyncer sync` ran
## 12. Delete a Todo ## 15. Delete a Todo
Create a second test todo, then delete it. 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" - [ ] `todo list` (todoman) does not show "Delete Me Todo"
- [ ] `vdirsyncer sync` ran - [ ] `vdirsyncer sync` ran
## 13. Todo Check (Cron Output) ## 16. Todo Check (Cron Output)
```bash ```bash
# Create a test todo # Create a test todo
@@ -305,7 +418,7 @@ $SKILL_DIR/scripts/calendar.sh todo check
# Should produce no output # 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. 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)") 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 \ $SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \ --to "测试用户" \
--subject "Recurring Test (Tue)" \ --subject "Recurring Test (Tue)" \
--summary "Recurring Test (Tue)" \ --summary "Recurring Test (Tue)" \
--start "${NEXT_TUE}T14:30:00" \ --start "${NEXT_TUE}T14:30:00" \
@@ -328,14 +441,14 @@ $SKILL_DIR/scripts/calendar.sh send \
- [ ] DTSTART falls on a Tuesday - [ ] DTSTART falls on a Tuesday
- [ ] No validation errors - [ ] No validation errors
## 15. Validation: DTSTART/BYDAY Mismatch ## 18. Validation: DTSTART/BYDAY Mismatch
Verify the tool rejects mismatched DTSTART and BYDAY. Verify the tool rejects mismatched DTSTART and BYDAY.
```bash ```bash
# This should FAIL — start is on a Tuesday but BYDAY=TH # This should FAIL — start is on a Tuesday but BYDAY=TH
$SKILL_DIR/scripts/calendar.sh send \ $SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \ --to "测试用户" \
--subject "Mismatch Test" \ --subject "Mismatch Test" \
--summary "Mismatch Test" \ --summary "Mismatch Test" \
--start "${NEXT_TUE}T09:00:00" \ --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 - [ ] Error message says DTSTART falls on TU but RRULE says BYDAY=TH
- [ ] Suggests changing --start to a date that falls on TH - [ ] Suggests changing --start to a date that falls on TH
## 16. Event List ## 19. Event List
```bash ```bash
# List upcoming events # List upcoming events
@@ -367,12 +480,12 @@ $SKILL_DIR/scripts/calendar.sh event list --format "{uid} {title}"
- [ ] Search narrows results correctly - [ ] Search narrows results correctly
- [ ] UIDs are displayed with --format - [ ] UIDs are displayed with --format
## 17. Event Delete ## 20. Event Delete
```bash ```bash
# Send a throwaway event first # Send a throwaway event first
$SKILL_DIR/scripts/calendar.sh send \ $SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \ --to "测试用户" \
--subject "Delete Me Event" \ --subject "Delete Me Event" \
--summary "Delete Me Event" \ --summary "Delete Me Event" \
--start "${TEST_DATE}T20:00:00" \ --start "${TEST_DATE}T20:00:00" \
@@ -394,7 +507,7 @@ $SKILL_DIR/scripts/calendar.sh event list --search "Delete Me"
- [ ] Other events are untouched - [ ] Other events are untouched
- [ ] `vdirsyncer sync` ran after delete - [ ] `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. 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)") 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 \ $SKILL_DIR/scripts/calendar.sh send \
--to "mail@luyx.org" \ --to "测试用户" \
--subject "EXDATE Test (Sat)" \ --subject "EXDATE Test (Sat)" \
--summary "EXDATE Test (Sat)" \ --summary "EXDATE Test (Sat)" \
--start "${NEXT_SAT}T10:00:00" \ --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 - [ ] `.ics` file still exists in calendar dir
- [ ] `khal list` no longer shows the cancelled date but shows subsequent Saturdays - [ ] `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 ```bash
# Try to delete the recurring event without --date or --all — should FAIL # 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 - [ ] Script exits with error
- [ ] Error message explains the two options: `--date` or `--all` - [ ] Error message explains the two options: `--date` or `--all`
## 20. Event Delete: Recurring With --all ## 23. Event Delete: Recurring With --all
```bash ```bash
# Delete the entire series # Delete the entire series
@@ -447,11 +560,12 @@ $SKILL_DIR/scripts/calendar.sh event delete --match "EXDATE Test" --all
- [ ] .ics file is removed - [ ] .ics file is removed
- [ ] `event list --search "EXDATE Test"` shows nothing - [ ] `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 ```bash
# This MUST fail — raw unknown email should be rejected
$SKILL_DIR/scripts/calendar.sh send \ $SKILL_DIR/scripts/calendar.sh send \
--to "test@example.com" \ --to "test@example.com" \
--subject "Regression Test" \ --subject "Regression Test" \
@@ -462,9 +576,9 @@ $SKILL_DIR/scripts/calendar.sh send \
``` ```
**Verify:** **Verify:**
- [ ] ICS has `BEGIN:VEVENT`, `METHOD:REQUEST` - [ ] Command exits with error
- [ ] No RRULE present (single event) - [ ] Error shows "not found in contacts" with available contacts list
- [ ] No errors - [ ] 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) | | 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 | | 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) | | 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

@@ -10,6 +10,9 @@ Subcommands:
python calendar.py reply [options] # accept/decline/tentative python calendar.py reply [options] # accept/decline/tentative
python calendar.py event list [options] # list/search calendar events 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 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 add [options] # create a VTODO task
python calendar.py todo list [options] # list pending tasks python calendar.py todo list [options] # list pending tasks
python calendar.py todo edit [options] # edit a task's fields python calendar.py todo edit [options] # edit a task's fields
@@ -39,6 +42,7 @@ from icalendar import Alarm, Calendar, Event, Todo, vCalAddress, vRecur, vText
DEFAULT_TIMEZONE = "America/Los_Angeles" DEFAULT_TIMEZONE = "America/Los_Angeles"
DEFAULT_FROM = "youlu@luyanxin.com" DEFAULT_FROM = "youlu@luyanxin.com"
CALENDAR_DIR = Path.home() / ".openclaw" / "workspace" / "calendars" / "home" CALENDAR_DIR = Path.home() / ".openclaw" / "workspace" / "calendars" / "home"
CONTACTS_DIR = Path.home() / ".openclaw" / "workspace" / "contacts" / "default"
TASKS_DIR = Path.home() / ".openclaw" / "workspace" / "calendars" / "tasks" TASKS_DIR = Path.home() / ".openclaw" / "workspace" / "calendars" / "tasks"
PRODID = "-//OpenClaw//Calendar//EN" PRODID = "-//OpenClaw//Calendar//EN"
@@ -161,6 +165,247 @@ def _validate_rrule_dtstart(rrule_dict, dtstart):
sys.exit(1) 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 # Send invite
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -196,7 +441,7 @@ def cmd_send(args):
if args.description: if args.description:
event.add("description", 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: for addr in recipients:
event.add("attendee", f"mailto:{addr}", parameters={ 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("--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)") 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 ---
todo_p = subparsers.add_parser("todo", help="Manage VTODO tasks") todo_p = subparsers.add_parser("todo", help="Manage VTODO tasks")
todo_sub = todo_p.add_subparsers(dest="todo_command", required=True) todo_sub = todo_p.add_subparsers(dest="todo_command", required=True)
@@ -950,6 +1214,13 @@ def main():
cmd_event_list(args) cmd_event_list(args)
elif args.event_command == "delete": elif args.event_command == "delete":
cmd_event_delete(args) 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": elif args.command == "todo":
if args.todo_command == "add": if args.todo_command == "add":
cmd_todo_add(args) cmd_todo_add(args)

View File

@@ -6,6 +6,9 @@
# ./calendar.sh reply [options] # accept/decline/tentative # ./calendar.sh reply [options] # accept/decline/tentative
# ./calendar.sh event list [options] # list/search calendar events # ./calendar.sh event list [options] # list/search calendar events
# ./calendar.sh event delete [options] # delete an event by UID or summary # ./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 add [options] # create a todo
# ./calendar.sh todo list [options] # list pending todos # ./calendar.sh todo list [options] # list pending todos
# ./calendar.sh todo edit [options] # edit a todo's fields # ./calendar.sh todo edit [options] # edit a todo's fields