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:
18
TOOLS.md
18
TOOLS.md
@@ -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(邮件)、vdirsyncer(CalDAV 同步)、khal(查看日历)、todoman(待办管理)
|
||||
**同步**: 发送/回复/待办变更后自动 `vdirsyncer sync`,心跳也会定期同步
|
||||
**注意**: 发送日历邀请属于对外邮件,需先确认
|
||||
|
||||
**安全规则**:
|
||||
- **`send --to` 只接受通讯录中的联系人**,不认识的地址会被拒绝(防止地址幻觉)
|
||||
- **添加联系人和发送邮件是独立操作**,不要在同一次请求中先 add 再 send
|
||||
- 周期性邀请务必先 `--dry-run` 验证 ICS 内容
|
||||
- **绝不用 `rm` 删日历 .ics 文件**,只用 `event delete`
|
||||
- **取消周期性事件的单次用 `--date`**,不要用 `--all`(会删掉整个系列)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/` |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user