diff --git a/TOOLS.md b/TOOLS.md index 4a036dd..9143865 100644 --- a/TOOLS.md +++ b/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`(会删掉整个系列) diff --git a/skills/calendar/SKILL.md b/skills/calendar/SKILL.md index 559498e..81fea0d 100644 --- a/skills/calendar/SKILL.md +++ b/skills/calendar/SKILL.md @@ -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 diff --git a/skills/calendar/TESTING.md b/skills/calendar/TESTING.md index a21af78..de25e0a 100644 --- a/skills/calendar/TESTING.md +++ b/skills/calendar/TESTING.md @@ -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/` | diff --git a/skills/calendar/scripts/cal_tool.py b/skills/calendar/scripts/cal_tool.py index 6826e6b..d362d25 100644 --- a/skills/calendar/scripts/cal_tool.py +++ b/skills/calendar/scripts/cal_tool.py @@ -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}:'.\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) diff --git a/skills/calendar/scripts/calendar.sh b/skills/calendar/scripts/calendar.sh index 88ff26a..1c216d5 100755 --- a/skills/calendar/scripts/calendar.sh +++ b/skills/calendar/scripts/calendar.sh @@ -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