Skill: rickhuang28/icloud-cal (version 2.0.1)

--- name: icloud-calendar version: 2.0.0 description: Manage iCloud calendars from natural language. Create, query, update, and delete events on your iPhone calendar via CalDAV. Trigger words: schedule, reminder, meeting, appointment, calendar, event, 日程, 提醒, 会议, 安排, 日历. Supports smart duration inference, auto calendar selection (work/personal), location parsing, keyword-based bulk delete/update, RRULE recurring events. metadata: openclaw: requires: bins: [python] env: [ICLOUD_EMAIL, ICLOUD_APP_PASSWORD] emoji: "📅" # ── Tool Registration (Stage 1: Metadata Only) ────────────────── # Each tool declares: entry point, parameters, safety flags. # OpenClaw currently reads name/description/metadata; tools: is # future-proof for Stage 2 (auto tool calling + param validation). tools: - name: create_event description: "Create a new calendar event with optional recurrence (RRULE), alarm, and location." read_only: false confirm: false entry: "python scripts/add-event.py" parameters: - name: --summary type: string required: true desc: "Event title (e.g., 和张总开会, 聚餐)" - name: --start type: string required: true desc: "Start time (YYYY-MM-DDTHH:mm:ss or YYYY-MM-DD for all-day)" - name: --end type: string required: false desc: "End time. Default: auto-infer from event type (meeting=1h, meal=2h)" - name: --timezone type: string required: false desc: "IANA timezone name. Default: Asia/Shanghai" - name: --location type: string required: false desc: "Event location" - name: --description type: string required: false desc: "Event notes / participants" - name: --calendar type: string required: false desc: "Calendar name (个人/工作). Default: auto-select by keyword" - name: --alarm-minutes type: integer required: false desc: "Alarm before event in minutes. Default: 15" - name: --rrule type: string required: false desc: "Recurrence rule (e.g., FREQ=WEEKLY;BYDAY=MO, FREQ=DAILY;COUNT=10)" - name: --is-all-day type: flag required: false desc: "All-day event (uses VALUE=DATE format to prevent timezone drift)" - name: query_events description: "Query events by date (today, tomorrow, week, nextweek, or specific date/range)." read_only: true confirm: false entry: "python scripts/add-event.py --query" parameters: - name: --query type: string required: true desc: "today | tomorrow | week | nextweek | YYYY-MM-DD | YYYY-MM-DD~YYYY-MM-DD" - name: search_events description: "Search events by keyword across all calendars, optionally within a date range." read_only: true confirm: false entry: "python scripts/add-event.py --search" parameters: - name: --search type: string required: true desc: "Keyword to fuzzy-match against title, location, and description" - name: --search-range type: string required: false desc: "Date range (YYYY-MM-DD~YYYY-MM-DD). Default: ±180 days" - name: update_event description: "Find an event by keyword and modify fields (time, title, location, calendar)." read_only: false confirm: true confirm_hint: "This modifies an existing calendar event. Confirm before proceeding." entry: "python scripts/add-event.py" parameters: - name: --update-find type: string required: true desc: "Keyword to locate the event (fuzzy-match title, case-insensitive)" - name: --update-set-summary type: string required: false desc: "New event title" - name: --update-set-start type: string required: false desc: "New start time (YYYY-MM-DDTHH:mm:ss)" - name: --update-set-end type: string required: false desc: "New end time (YYYY-MM-DDTHH:mm:ss)" - name: --update-set-location type: string required: false desc: "New location. Use __CLEAR__ to remove location" - name: --update-set-calendar type: string required: false desc: "Move event to another calendar (personal/work)" - name: --update-start type: string required: false desc: "Search start date (YYYY-MM-DD). Narrows search scope" - name: --update-end type: string required: false desc: "Search end date (YYYY-MM-DD). Narrows search scope" - name: delete_event description: "Delete events matching a keyword. Supports dry-run preview." read_only: false confirm: true confirm_hint: "This permanently deletes events. Run with DELETE_DRY_RUN=1 first to preview." entry: "python scripts/add-event.py --delete" parameters: - name: --delete type: string required: true desc: "Keyword to fuzzy-match against title and location (case-insensitive)" - name: --delete-start type: string required: false desc: "Search start date (YYYY-MM-DD). Limits scope" - name: --delete-end type: string required: false desc: "Search end date (YYYY-MM-DD). Limits scope" safety_env_vars: - CONFIRM_DELETE: "Set to 1 to allow actual deletion" - DELETE_DRY_RUN: "Set to 1 to preview without deleting" - name: list_calendars description: "List all available iCloud calendars." read_only: true confirm: false entry: "python scripts/add-event.py --list-calendars" parameters: [] author: RickHuang28 license: MIT tags: [calendar, icloud, caldav, schedule, reminder, 日程, 日历] --- # 📅 iCloud Calendar — Full CRUD via Natural Language **Version 2.0.0** | Manage your iCloud calendar through natural language via CalDAV. > ⚠️ 首次使用请先完成配置 → 见 [CONFIG.md](CONFIG.md) > > 你需要：**iCloud 邮箱** + **App-Specific 密码** + **Python 3.8+** + **`pip install caldav`** --- ## 配置清单 使用前确认以下 3 项已完成： 1. ✅ **`pip install "caldav>=1.3.0,<2.0"`** 2. ✅ **`ICLOUD_EMAIL` 环境变量** = 你的 iCloud 邮箱 3. ✅ **`ICLOUD_APP_PASSWORD` 环境变量** = Apple 专用密码 (xxxx-xxxx-xxxx-xxxx) > 如果脚本返回 `"Missing ICLOUD_EMAIL or ICLOUD_APP_PASSWORD environment variables"`，说明环境变量未正确注入。 --- ## 解析规则 从自然语言中提取字段，未提及的按推断规则补全。 ### 1. 标题 (summary) 从消息中提取事件核心，去掉时间词和口癖： - "下周三下午3点和张总开会" → "和张总开会" - "明天记得交报告" → "交报告" - "周五晚上聚餐" → "聚餐" ### 2. 开始时间 (start) 必填。相对日期→绝对日期计算（Asia/Shanghai）： - 今天/明天/后天/大后天 → 对应日期 - 下周一~下周日 → 下周对应星期几 - 时间："下午3点"→15:00 "上午九点半"→09:30 "晚上8点"→20:00 - 无具体时间 → 默认 09:00 ### 3. 结束时间 (end) 永远不问用户，按事件类型推断时长： - 会议/汇报/评审/面试 → 1小时 - 聚餐/饭局/火锅/吃饭 → 2小时 - 运动/健身/游泳/打球 → 1.5小时 - 电影 → 2.5小时 - 培训/课程/讲座 → 2小时 - 飞机/高铁/火车 → 3小时 - 全天 → 00:00~23:59 - 兜底 → 1小时 - 用户说了结束时间 → 用用户的 ### 4. 日历选择 (calendar) 按事件性质自动选： - 含"开会/汇报/评审/客户/项目/出差/面试"关键词 → 工作日历 - 其他 → 个人日历 ### 5. 地点 (location) 提及则提取："在301会议室"→"301会议室" "去北京出差"→"北京" 未提及 → 空 ### 6. 提醒 (alarm) - 用户说"提醒我" → 开始前15分钟 - 指定时间"提前10分钟" → 用指定时间 - 未提及 → 默认 15 分钟 ### 7. 备注 (description) 收集额外信息：参与者、特殊要求等。"和张总一起" → 备注写"参与者：张总" --- ## 执行（创建事件） ```bash export ICLOUD_EMAIL="[REDACTED_SECRET]" export ICLOUD_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx" python scripts/add-event.py \ --summary "标题" \ --start "2026-04-08T15:00:00" \ --end "2026-04-08T16:00:00" \ --timezone "Asia/Shanghai" \ --location "" \ --calendar "个人" \ --description "" \ --alarm-minutes 15 ``` **可选参数：** - `--is-all-day`：纯日期事件，生成 `DTSTART;VALUE=DATE` 格式，跨时区不漂移 - `--rrule "FREQ=WEEKLY;BYDAY=MO"`：重复事件 ### 回复示例 ``` 📅 已记录：和张总开会 🕐 4月8日 周三 15:00-16:00（工作日历） 📍 301会议室 ⏰ 提前15分钟提醒 ``` --- ## 查询（反向查日历） 用户问"我明天有什么安排""这周日程""4月8号有什么"时： ```bash python scripts/add-event.py --query "today|tomorrow|week|nextweek|YYYY-MM-DD|YYYY-MM-DD~YYYY-MM-DD" ``` query 值： - `today` / `tomorrow` / `week` / `nextweek` - 单日：`2026-04-08` - 范围：`2026-04-01~2026-04-30` ### 回复示例 ``` 📅 明天（4月2日）有 3 个安排： 1. 10:00-11:00 日历功能测试（个人） 2. 14:00-15:00 项目评审（工作） 3. 全天 ios退款请加扣扣群...（工作） ``` --- ## 搜索（按关键词） 用户说"我下周有和张总的安排吗""查一下开会的日程"时： ```bash python scripts/add-event.py \ --search "关键词" \ --search-range "2026-04-01~2026-04-30" ``` - `--search`：必填，模糊匹配 title/location/description - `--search-range`：可选日期范围，不传默认搜索 ±180 天 - 返回 `{"events": [...], "count": N, "keyword": "关键词", "errors": [...]}` --- ## 修改事件 用户说"改到4点""换到工作日历""地点改成301"时： ```bash python scripts/add-event.py \ --update-find "关键词" \ [--update-set-summary "新标题"] \ [--update-set-start "2026-04-08T16:00:00"] \ [--update-set-end "2026-04-08T17:00:00"] \ [--update-set-location "新地点"] \ [--update-set-calendar "工作"] \ [--update-start "2026-04-01"] \ [--update-end "2026-04-30"] ``` - 匹配到多个事件时会报错并列出所有匹配项 - 返回 `{"updated": true, "changes": {...}}` --- ## 删除（按关键词） 用户说"删掉xxx相关的""删除垃圾事件"时： ```bash export CONFIRM_DELETE=1 # 可选：预览不执行 export DELETE_DRY_RUN=1 python scripts/add-event.py \ --delete "关键词" \ --delete-start "2026-01-01" \ --delete-end "2026-12-31" ``` - `--delete`：必填，关键词模糊匹配 - `--delete-start` / `--delete-end`：可选搜索范围 - 需要 `CONFIRM_DELETE=1` 环境变量确认 - 可设 `DELETE_DRY_RUN=1` 预览（不真正删除） - 返回三态 `status: "success"/"partial_success"/"error"` + `dry_run: true/false` --- ## 重复事件（RRULE） ```bash python scripts/add-event.py \ --summary "每周例会" \ --start "2026-04-07T09:00:00" \ --rrule "FREQ=WEEKLY;BYDAY=MO" ``` 常用模式： | 频率 | RRULE | |------|-------| | 每天 | `FREQ=DAILY` | | 每周一三五 | `FREQ=WEEKLY;BYDAY=MO,WE,FR` | | 每两周周五 | `FREQ=WEEKLY;INTERVAL=2;BYDAY=FR` | | 每月1号 | `FREQ=MONTHLY;BYMONTHDAY=1` | | 限次10次 | `FREQ=WEEKLY;BYDAY=MO;COUNT=10` | | 限日期 | `FREQ=WEEKLY;BYDAY=MO;UNTIL=20260630T000000Z` | > RRULE 经过白名单校验，仅允许安全字符，拒绝 CRLF 注入。 --- ## 安全特性 | 特性 | 说明 | |------|------| | **凭据零泄露** | 仅环境变量传递，异常消息脱敏，进程列表不可见 | | **操作确认门** | `CONFIRM_DELETE=1` / `CONFIRM_UPDATE=1` 防误操作 | | **Dry Run** | `DELETE_DRY_RUN=1` 预览删除不执行 | | **日志轮转** | 最多 512KB × 5 备份，自动清理 | | **内容截断** | 日志中摘要截断至 30 字符 | | **重试保护** | Dual-Client 架构，4xx 错误立即放弃不盲目重试 | | **幂等创建** | UID 唯一，弱网重试不发重复事件 | | **协议安全** | iCal 字段全转义，RRULE 白名单校验 | --- ## 时区支持 默认 `Asia/Shanghai`，支持任意 IANA 时区名。VTIMEZONE 组件根据 `ZoneInfo` 自动计算，覆盖 DST 夏令时。 --- ## 变更记录 完整变更记录见 [CHANGELOG.md](CHANGELOG.md)（v1.4.0 → v2.0.0，36 项改进）。 ### v2.0.0 亮点 - ⚡ Dual-Client 架构（10s/60s 超时分离） - 🔒 异常消息全面脱敏 - 🛡️ 删除/更新双确认门 + dry-run - 🧹 日志全覆盖 sanitize --- ## 故障排查 | 错误 | 原因 | 解决 | |------|------|------| | `Missing ICLOUD_EMAIL...` | 环境变量未设置 | 检查 openclaw.json 并重启 | | `Authentication failed` | App Password 错误 | 重新生成专用密码 | | `Connection failed` | iCloud 不可达 | 检查网络/防火墙 | | `Delete requires CONFIRM_DELETE` | 未设确认标志 | `export CONFIRM_DELETE=1` | 完整配置指南 → [CONFIG.md](CONFIG.md)

path: CHANGELOG.md
content: # Changelog — iCloud Calendar Skill

## v1.4.0 → v2.0.0 全量变更

从 v1.4.0（基础创建功能）升级到 v2.0.0（全功能生产就绪）。以下是所有改进的整合列表，按类别分组。

---

### 🔐 安全加固（8 项）

| # | 变更 | 详情 |
|---|------|------|
| 1 | **凭据仅环境变量** | 彻底移除 `--email`/`--password` CLI 参数，不再通过命令行传递任何凭据 |
| 2 | **异常消息全面脱敏** | AuthError、连接失败、搜索错误等输出不再泄露 token/headers/bearer/internal details |
| 3 | **iCal 字段自动转义** | `escape_ical()` 处理 `\` `;` `,` `\n` `\r`，防止协议级注入攻击 |
| 4 | **RRULE 白名单校验** | 仅允许 `[A-Z0-9=;,\-_]` 字符，非法字符直接拒绝 |
| 5 | **创建路径 RRULE 验证** | `build_ical()` 前调用 `_validate_rrule()`，堵住了更新路径有但创建路径缺的漏洞 |
| 6 | **日志隐私保护** | `sanitize_for_log()` 截断事件标题/摘要/错误至 30 字符 |
| 7 | **删除操作安全门** | 强制 `CONFIRM_DELETE=1`，支持 `DELETE_DRY_RUN=1` 预览模式 |
| 8 | **更新操作安全门** | 新增 `CONFIRM_UPDATE=1` 环境变量确认 |

### 🌐 时区与协议（4 项）

| # | 变更 | 详情 |
|---|------|------|
| 9 | **DST 动态计算** | 用 `ZoneInfo.utcoffset()` 替代硬编码偏移，夏令时区域（美/欧）时间正确 |
| 10 | **全天事件修复** | 生成 `DTSTART;VALUE=DATE` 格式，跨时区不漂移 |
| 11 | **VTIMEZONE 自动生成** | 按输入时区动态生成完整 VTIMEZONE 组件，覆盖 35+ 城市 |
| 12 | **iCal 行尾标准化** | 统一使用 `\r\n`，符合 RFC 5545 Section 3.1 |

### 🔄 网络韧性（5 项）

| # | 变更 | 详情 |
|---|------|------|
| 13 | **Dual-Client 架构** | `fast_client`(10s) 处理 auth/CRUD（Fail-Fast），`slow_client`(60s) 专用于 `date_search(expand=True)` |
| 14 | **重试全覆盖** | 所有 8 个 CalDAV 网络调用点包裹 `_retry_on_caldav_error()` |
| 15 | **智能跳过重试** | 4xx 错误 (400/401/403/404/405/409/412/422) 立即放弃，不浪费 14 秒 |
| 16 | **幂等创建** | 预生成 UID 传 `build_ical()`，弱网重试发相同 UID，iCloud 自动去重 |
| 17 | **批量操作异常隔离** | 批量删除逐事件 try/catch，单个失败不影响其他，返回三态 `partial_success` |

### 🏗️ 架构改进（7 项）

| # | 变更 | 详情 |
|---|------|------|
| 18 | **跨日历移动安全** | 先复制（新 UID）后删除原事件，网络中断不丢数据 |
| 19 | **查询范围截断** | `MAX_EXPAND_DAYS=1095`（3 年），防无限 RRULE expand 撑爆内存 |
| 20 | **日志轮转** | `RotatingFileHandler` 512KB × 5 备份，防止磁盘打满 |
| 21 | **异常分类** | 区分 `AuthorizationError` / `NotFound` / 通用异常，返回结构化 `errors[]` |
| 22 | **裸 `except` 替换** | 全部改为 `except Exception:`，防止吞 `KeyboardInterrupt`/`SystemExit` |
| 23 | **User-Agent 注入** | 自定义 requests.Session 带 `OpenClaw-Calendar/2.0.0` 标识 |
| 24 | **依赖版本锁定** | `caldav>=1.3.0,<2.0`，避免大版本升级导致兼容性崩溃 |

### 🔧 功能增强（6 项）

| # | 变更 | 详情 |
|---|------|------|
| 25 | **搜索返回 errors[]** | 部分日历查询失败不阻断其他日历，错误信息完整返回 |
| 26 | **CALSCALE:GREGORIAN** | iCal 合规，部分客户端依赖此项 |
| 27 | **SSL 证书验证** | 启用默认验证，移除 `verify=False` 中间人风险 |
| 28 | **日历自动选择** | 按事件性质关键词自动选工作/个人日历 |
| 29 | **智能结束时间推断** | 会议 1h / 聚餐 2h / 航班 3h 等事件类型推断 |
| 30 | **搜索默认范围缩小** | 默认 180 天（原 365 天），减少 iCloud 响应时间和内存 |

### 🧹 代码清理（6 项）

| # | 变更 | 详情 |
|---|------|------|
| 31 | **`find_calendar` 空列表检查** | 无日历可访问时抛明确异常而非崩溃 |
| 32 | **`build_ical` 拆分** | 拆为 `build_vtimezone()` + `build_vevent()` 独立函数 |
| 33 | **pathlib 迁移** | LOG_DIR 使用 `Path(__file__).resolve().parent.parent` |
| 34 | **`parse_known_args` 替代 `parse_args`** | 忽略未知参数，兼容 Agent 传递额外参数 |
| 35 | **VALARM 描述含标题** | 提醒内容 `Reminder: {summary}` 而非空提醒 |
| 36 | **删除重复函数调用** | 避免同一次请求中的冗余网络调用 |

---

## 统计

- **总计：** 36 项变更
- **安全相关：** 8 项
- **时区/DST：** 4 项
- **网络韧性：** 5 项
- **架构改进：** 7 项
- **功能增强：** 6 项
- **代码清理：** 6 项
- **测试覆盖：** 95 个 case，全部通过


path: CONFIG.md
content: # 📅 iCloud Calendar Skill — 安装与配置指南

完整从 0 到 1 的配置流程。大约需要 5 分钟。

---

## 📋 你需要准备什么

| 项目 | 说明 | 状态 |
|------|------|:----:|
| **iCloud 账号** | Apple ID 邮箱 ([REDACTED_SECRET]) | ✅ 必须有 |
| **App-Specific Password** | Apple 生成的专用应用密码 (非登录密码!) | ⬜ 需要生成 |
| **Python 3.8+** | 运行脚本的解释器 | ⬜ 需要安装 |
| **OpenClaw** | 宿主框架 | ✅ 已安装 |

---

## 🔑 第一步：生成 iCloud 专用密码（App-Specific Password）

> ⚠️ 这不是你的 Apple ID 登录密码！Apple 要求第三方应用使用"专用密码"。

1. 打开浏览器访问 [appleid.apple.com](https://appleid.apple.com)
2. 登录你的 Apple ID
3. 进入 **Sign-In and Security**（登录与安全）→ **App-Specific Passwords**（专用密码）
4. 点击 **Generate an app-specific password**（生成专用密码）或 `+` 按钮
5. 输入密码标签（如 "OpenClaw Calendar"），点击 **Create**（创建）
6. 系统显示一个 **`xxxx-xxxx-xxxx-xxxx`** 格式的密码 → **立即复制保存**，关闭页面后就看不到了

### ❓ 常见问题

**Q：找不到 App-Specific Passwords 选项？**  
A：确认你已开启双重认证（Sign-In and Security → Two-Factor Authentication）。

**Q：密码格式是什么？**  
A：`xxxx-xxxx-xxxx-xxxx`，16 位，4 组用 `--` 连接。

**Q：可以重复使用已生成的密码吗？**  
A：可以。但如果忘记密码，只能重新生成一个新的，旧密码无法查看。

---

## 🐍 第二步：安装 Python 依赖

打开终端运行：

```bash
# 检查 Python 版本（需要 3.8+）
python --version

# 安装 caldav 库（版本锁定 >=1.3.0,<2.0）
pip install "caldav>=1.3.0,<2.0"
```

### ❓ 常见问题

**Q：`pip` 不是内部命令？**  
A：尝试 `python -m pip install "caldav>=1.3.0,<2.0"`

**Q：Windows PowerShell 执行策略报错？**  
A：以管理员身份运行：`Set-ExecutionPolicy RemoteSigned -Scope CurrentUser`

---

## ⚙️ 第三步：配置 OpenClaw 环境变量

编辑 OpenClaw 配置文件 `~/.openclaw/openclaw.json`（或你的配置路径），在 `skills.entries` 中添加 `calendar-add` 条目：

```json
{
  "skills": {
    "entries": {
      "calendar-add": {
        "env": {
          "ICLOUD_EMAIL": "[REDACTED_SECRET]",
          "ICLOUD_APP_PASSWORD": "xxxx-xxxx-xxxx-xxxx"
        }
      }
    }
  }
}
```

替换：
- `"[REDACTED_SECRET]"` → 你的 iCloud 账号邮箱
- `"xxxx-xxxx-xxxx-xxxx"` → 第一步生成的专用密码

> ⚠️ **安全提醒：**
> - 凭据**只能**通过环境变量传递，脚本不接受 `--email` 或 `--password` 命令行参数
> - 确保 `openclaw.json` 文件权限为 `600`（仅你的用户可读）
> - 如果配置文件被其他人读取，你的 iCloud 日历就会被访问

---

## 🔄 第四步：重启 OpenClaw 使配置生效

```bash
openclaw gateway restart
```

重启后环境变量 `ICLOUD_EMAIL` 和 `ICLOUD_APP_PASSWORD` 会被注入到脚本的运行环境中。

---

## ✅ 第五步：验证安装

### 测试 1：列出你的日历

```bash
python skills/calendar-add/scripts/add-event.py --list-calendars
```

预期输出（JSON 数组，包含你 iCloud 中的所有日历）：
```json
[{"index": 0, "name": "个人"}, {"index": 1, "name": "工作"}]
```

如果看到错误：
- `"Authentication failed"` → 检查邮箱和密码是否正确
- `"Connection failed"` → 检查网络连接，确认 iCloud 可以访问

### 测试 2：查询今天的日程

```bash
python skills/calendar-add/scripts/add-event.py --query today
```

预期：返回今天的日程列表（如果没有则返回空数组）。

### 测试 3：创建一个测试事件

```bash
python skills/calendar-add/scripts/add-event.py \
    --summary "测试日程" \
    --start "2026-12-25T10:00:00" \
    --end "2026-12-25T11:00:00" \
    --calendar "个人"
```

预期：返回成功 JSON，然后在 iPhone 日历 App 中可以看到该事件。

---

## 🔒 安全机制说明

| 机制 | 说明 |
|------|------|
| **凭据零泄露** | 所有认证信息仅通过环境变量传递，进程列表看不到 |
| **异常脱敏** | 错误消息不包含 token/headers/bearer 等敏感信息 |
| **删除防护** | `--delete` 需要 `CONFIRM_DELETE=1` 才能执行 |
| **更新防护** | `--update-find` 需要 `CONFIRM_UPDATE=1` 才能执行 |
| **Dry Run** | 删除前可设 `DELETE_DRY_RUN=1` 预览不执行 |
| **日志轮转** | 日志最多 512KB × 5 备份 = 2.5MB，自动清理旧日志 |
| **内容截断** | 日志中事件标题/摘要截断至 30 字符 |

---

## 📂 文件结构

```
skills/calendar-add/
├── SKILL.md          # 技能说明（Agent 如何调用）
├── CONFIG.md         # 本文档（安装配置指南）
├── CHANGELOG.md      # v1.4.0 → v2.0.0 全量变更日志
├── scripts/
│   └── add-event.py  # 主脚本（v2.0.0）
├── logs/
│   └── calendar.log  # 操作日志（自动轮转）
└── test_add_event.py # 测试套件（95 个 case）
```

---

## ❓ 常见故障排查

| 错误 | 原因 | 解决方法 |
|------|------|----------|
| `Missing ICLOUD_EMAIL or ICLOUD_APP_PASSWORD` | 环境变量未设置 | 检查 openclaw.json 是否配置并重启 |
| `Authentication failed` | App Password 错误或账号不对 | 重新生成 App-Specific Password |
| `Connection failed` | iCloud CalDAV 不可达 | 检查网络/防火墙，确认 `caldav.icloud.com` 可达 |
| `No calendars found` | 日历服务未启用 | 登录 iCloud 网页版 → 日历 → 确认有日历 |
| `Unknown timezone` | 时区名拼写错误 | 使用标准 IANA 时区名，如 `Asia/Shanghai` |
| `Delete requires CONFIRM_DELETE` | 未设确认标志 | 运行前设 `CONFIRM_DELETE=1` 环境变量 |

---

## 📞 需要帮助？

- 技术文档：<https://docs.openclaw.ai>
- 社区讨论：<https://discord.com/invite/clawd>


path: README.md
content: # iCloud Calendar — Full CRUD

[![Version](https://img.shields.io/badge/version-2.0.0-blue)](https://github.com/RickHuang28/icloud-cal)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)

> 通过自然语言管理 iCloud 日历（同步到 iPhone），支持 CalDAV 创建、查询、更新、删除、搜索、RRULE 重复事件。

## ✨ 特性

- 📝 **自然语言创建** — "下周三下午3点和张总开会" 自动解析
- 🔍 **智能查询** — 按日期/周/关键词搜索
- ⚡ **批量更新** — 按关键词定位并修改事件
- 🗑️ **安全删除** — 关键词匹配 + dry-run 预览
- 🔄 **重复事件** — RRULE 完整支持（周/月/年/限次/限期）
- 🌍 **时区正确** — ZoneInfo 动态 DST，35+ 城市覆盖
- 🔒 **安全** — 凭据零泄露、异常脱敏、操作确认门
- 📝 **操作日志** — 自动轮转 512KB×5 备份

## ⚙️ 快速开始

### 1. 安装依赖

```bash
pip install "caldav>=1.3.0,<2.0"
```

### 2. 生成 App-Specific Password

访问 [appleid.apple.com](https://appleid.apple.com) → Sign-In and Security → App-Specific Passwords → 生成新密码。

### 3. 配置

```bash
export ICLOUD_EMAIL="[REDACTED_SECRET]"
export ICLOUD_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx"
```

### 4. 使用

```bash
# 创建事件
python scripts/add-event.py \
    --summary "每周例会" \
    --start "2026-04-08T09:00:00" \
    --end "2026-04-08T10:00:00" \
    --calendar "工作" \
    --rrule "FREQ=WEEKLY;BYDAY=MO"

# 查询今天
python scripts/add-event.py --query today

# 搜索
python scripts/add-event.py --search "开会" --search-range "2026-04-01~2026-04-30"

# 列出所有日历
python scripts/add-event.py --list-calendars
```

## 📁 文件结构

```
calendar-add/
├── scripts/
│   └── add-event.py    # 主脚本 (v2.0.0)
├── logs/               # 操作日志 (自动轮转)
├── SKILL.md            # 技能说明文档
├── CONFIG.md           # 完整安装配置指南
├── CHANGELOG.md        # v1.4.0 → v2.0.0 全量变更
└── .gitignore
```

## 🔐 安全特性

| 特性 | 说明 |
|------|------|
| 凭据零泄露 | 仅环境变量传递，异常消息脱敏 |
| 操作确认门 | `CONFIRM_DELETE=1` / `CONFIRM_UPDATE=1` |
| Dry Run | `DELETE_DRY_RUN=1` 预览不执行 |
| 日志轮转 | 512KB × 5 备份自动清理 |
| 内容截断 | 日志摘要截断至 30 字符 |
| 幂等创建 | UID 唯一，弱网不发重复事件 |
| 协议安全 | iCal 字段全转义，RRULE 白名单 |

## 📊 v2.0.0 变更亮点

- ⚡ **Dual-Client 架构** — fast(10s) auth/CRUD + slow(60s) 高负载 expand
- 🔒 **凭据安全** — CLI 参数彻底删除，零 token/headers 泄露
- 🛡️ **安全门** — 删除/更新双确认 + dry-run 预览
- 🔄 **重试全覆盖** — 8 个网络调用点，4xx 智能跳过
- 🌍 **DST 正确** — ZoneInfo 动态计算

完整变更见 [CHANGELOG.md](CHANGELOG.md)

## 📝 License

MIT


path: test_add_event.py
content: #!/usr/bin/env python3
"""
Test suite for add-event.py - Pure logic & boundary testing.
Run: python test_add_event.py
"""
import sys

# Force UTF-8 output on Windows
if hasattr(sys.stdout, 'reconfigure'):
    sys.stdout.reconfigure(encoding='utf-8')
if hasattr(sys.stderr, 'reconfigure'):
    sys.stderr.reconfigure(encoding='utf-8')

import json, os, tempfile
import importlib.util
from datetime import timedelta, datetime as _dt
from pathlib import Path
from unittest.mock import patch, MagicMock
from zoneinfo import ZoneInfo

# Load add-event.py (hyphenated filename cannot be normal import)
script_path = Path(__file__).parent / 'scripts' / 'add-event.py'
spec = importlib.util.spec_from_file_location('add_event', script_path)
add_event = importlib.util.module_from_spec(spec)
spec.loader.exec_module(add_event)

# Convenience aliases
parse_dt                = add_event.parse_dt
parse_date_range_for_query = add_event.parse_date_range_for_query
escape_ical             = add_event.escape_ical
sanitize_for_log        = add_event.sanitize_for_log
build_ical              = add_event.build_ical
find_calendar           = add_event.find_calendar
_new_uid                = add_event._new_uid
_validate_rrule         = add_event._validate_rrule
build_vevent            = add_event.build_vevent
build_vtimezone         = add_event.build_vtimezone
_get_tz_abbr            = add_event._get_tz_abbr
_format_offset          = add_event._format_offset
extract_event           = add_event.extract_event
search_events           = add_event.search_events

ELLIPSIS = chr(8230)  # Unicode ellipsis …

pass_count = 0
fail_count = 0

def eq(got, expected, label=''):
    global pass_count, fail_count
    if got == expected:
        pass_count += 1
    else:
        fail_count += 1
        print(f'  FAIL {label}: got={repr(got)}, expected={repr(expected)}')

def check(cond, label=''):
    global pass_count, fail_count
    if cond:
        pass_count += 1
    else:
        fail_count += 1
        print(f'  FAIL {label}: assertion failed')

def section(name):
    print(f'\n{"="*60}')
    print(f'  {name}')
    print(f'{"="*60}')

# ==================================================================
# 1. parse_dt
# ==================================================================
section('1. parse_dt -- time parsing boundaries')

tz = ZoneInfo('Asia/Shanghai')

dt, ad = parse_dt('2026-04-08T15:30:00', tz)
check(dt is not None, 'datetime with seconds')
eq(ad, False, 'not all-day')

dt, ad = parse_dt('2026-04-08T15:30', tz)
check(dt is not None, 'datetime without seconds')
eq(ad, False)

dt, ad = parse_dt('2026-04-08 15:30:00', tz)
check(dt is not None, 'space-separated datetime')
eq(ad, False)

dt, ad = parse_dt('2026-04-08', tz)
check(dt is not None, 'date only')
eq(ad, True, 'all-day detected for pure date')
eq(dt.hour, 0)
eq(dt.minute, 0)

dt, ad = parse_dt('2026\xe5\xb9\xb44\xe6\x9c\x888\xe6\x97\xa5 15:30', tz)
if dt is None:
    # The above is raw bytes; let's use the actual string
    dt, ad = parse_dt('2026\u5e744\u67088\u65e5 15:30', tz)
check(dt is not None, 'Chinese date format')
eq(dt.month, 4)
eq(dt.day, 8)

eq(parse_dt('', tz)[0], None, 'empty string')
eq(parse_dt(None, tz)[0], None, 'None input')

dt, _ = parse_dt('not-a-date', tz)
eq(dt, None, 'invalid date string')

dt, _ = parse_dt('2024-02-29T10:00:00', tz)
check(dt is not None, 'leap year date accepted')

dt, _ = parse_dt('2026-02-29T10:00:00', tz)
eq(dt, None, 'non-leap Feb 29 rejected')

# ==================================================================
# 2. parse_date_range_for_query
# ==================================================================
section('2. parse_date_range_for_query -- relative date boundaries')

tz = ZoneInfo('Asia/Shanghai')

s, e = parse_date_range_for_query('today', tz)
check(s is not None, 'today parses')
check(e == s + timedelta(days=1), 'today +1 day')

s, e = parse_date_range_for_query('tomorrow', tz)
check(s is not None, 'tomorrow parses')

s, e = parse_date_range_for_query('week', tz)
check(s is not None, 'week parses')
eq(s.weekday(), 0, 'week starts on Monday')

s, e = parse_date_range_for_query('nextweek', tz)
check(s is not None, 'nextweek parses')

s, e = parse_date_range_for_query('month', tz)
check(s is not None, 'month parses')
eq(s.day, 1, 'month starts on 1st')

s, e = parse_date_range_for_query('nextmonth', tz)
check(s is not None, 'nextmonth parses')

s, e = parse_date_range_for_query('2026-04-01~2026-04-30', tz)
check(s is not None, 'range parses')
eq(s.day, 1)
check(e.day == 1 and e.month == 5, 'range end is May 1')

s, e = parse_date_range_for_query('invalid', tz)
eq(s, None, 'invalid query returns None')

# Single date
s, e = parse_date_range_for_query('2026-12-31', tz)
check(s is not None, 'single date parses')
eq(s.month, 12)
eq(s.day, 31)

# Year boundary: nextmonth from December should not produce month 13
now_mock = _dt(2026, 12, 31, 12, 0, tzinfo=tz)
with patch.object(add_event, 'datetime', wraps=_dt) as mock_dt:
    mock_dt.now.return_value = now_mock
    s, e = parse_date_range_for_query('thismonth', tz)
    check(s is not None, 'thismonth on Dec 31')
    eq(s.month, 12)
    eq(e.month, 1, 'next month is January')
    eq(e.year, 2027, 'year wraps to 2027')

# ==================================================================
# 3. _validate_rrule
# ==================================================================
section('3. _validate_rrule -- RRULE validation')

eq(_validate_rrule('FREQ=WEEKLY;BYDAY=MO'), 'FREQ=WEEKLY;BYDAY=MO')
eq(_validate_rrule('FREQ=DAILY'), 'FREQ=DAILY')
eq(_validate_rrule('FREQ=MONTHLY;BYMONTHDAY=1'), 'FREQ=MONTHLY;BYMONTHDAY=1')
eq(_validate_rrule(''), '')
eq(_validate_rrule('  FREQ=DAILY  '), 'FREQ=DAILY', 'strips whitespace')
eq(_validate_rrule('freq=daily'), 'freq=daily', 'case preserved')

try:
    _validate_rrule('INVALID')
    fail_count += 1
    print('  FAIL INVALID should raise ValueError')
except ValueError:
    pass_count += 1

try:
    _validate_rrule('FREQ=DAILY;DROP!TABLE')
    fail_count += 1
    print('  FAIL special chars should raise ValueError')
except ValueError:
    pass_count += 1

# ==================================================================
# 4. escape_ical
# ==================================================================
section('4. escape_ical -- RFC 5545 escaping')

eq(escape_ical('Hello; World'), 'Hello\\; World')
eq(escape_ical('Line1\nLine2'), 'Line1\\nLine2')
eq(escape_ical('Cats, Dogs'), 'Cats\\, Dogs')
eq(escape_ical('Back\\slash'), 'Back\\\\slash')
eq(escape_ical(''), '')
eq(escape_ical('a;b,c\nd'), 'a\\;b\\,c\\nd', 'multiple escapes')

# ==================================================================
# 5. sanitize_for_log
# ==================================================================
section('5. sanitize_for_log -- log sanitization')

eq(sanitize_for_log('short'), 'short')
eq(sanitize_for_log('a' * 30), 'a' * 30)
eq(sanitize_for_log('a' * 50, max_len=30), 'a' * 30 + ELLIPSIS)
eq(sanitize_for_log(''), '')

sensitive = [REDACTED_SECRET]
eq(sanitize_for_log(sensitive), 'SecretMeetingWithPassword12345' + ELLIPSIS, 'sensitive text truncated at 30 chars')

# ==================================================================
# 6. _new_uid
# ==================================================================
section('6. _new_uid -- UID uniqueness')

u1 = _new_uid()
u2 = _new_uid()
check(u1 != u2, 'UIDs are unique')
check(u1.endswith('@openclaw.icloud'), 'UID suffix correct')

# ==================================================================
# 7. build_vtimezone -- DST-safe dynamic offset
# ==================================================================
section('7. build_vtimezone -- DST-safe dynamic offset')

# America/New_York summer (EDT = -0400)
ny_summer = _dt(2026, 7, 15, 12, 0, tzinfo=ZoneInfo('America/New_York'))
off = _format_offset(ny_summer)
eq(off, '-0400', 'NY summer EDT = -0400')

# America/New_York winter (EST = -0500)
ny_winter = _dt(2026, 1, 15, 12, 0, tzinfo=ZoneInfo('America/New_York'))
off = _format_offset(ny_winter)
eq(off, '-0500', 'NY winter EST = -0500')

# Europe/London summer (BST = +0100)
lon_summer = _dt(2026, 7, 15, 12, 0, tzinfo=ZoneInfo('Europe/London'))
off = _format_offset(lon_summer)
eq(off, '+0100', 'London summer BST = +0100')

# Europe/London winter (GMT = +0000)
lon_winter = _dt(2026, 1, 15, 12, 0, tzinfo=ZoneInfo('Europe/London'))
off = _format_offset(lon_winter)
eq(off, '+0000', 'London winter GMT = +0000')

# Shanghai no DST
sh_summer = _dt(2026, 7, 15, 12, 0, tzinfo=ZoneInfo('Asia/Shanghai'))
off = _format_offset(sh_summer)
eq(off, '+0800', 'Shanghai always +0800')

lines = build_vtimezone('America/New_York', ny_summer)
ical_str = '\r\n'.join(lines)
check('TZID:America/New_York' in ical_str, 'VTIMEZONE has TZID')

# Unknown zone fallback
abbr = _get_tz_abbr('Antarctica/McMurdo')
eq(abbr, 'McMurdo', 'unknown zone abbr fallback')

# ==================================================================
# 8. build_ical -- timed vs all-day
# ==================================================================
section('8. build_ical -- timed vs all-day events')

tz_sh = ZoneInfo('Asia/Shanghai')
dt_s = _dt(2026, 4, 8, 15, 0, tzinfo=tz_sh)
dt_e = _dt(2026, 4, 8, 16, 0, tzinfo=tz_sh)

ical = build_ical('Meeting', dt_s, dt_e)
check('DTSTART;TZID=Asia/Shanghai:20260408T150000' in ical, 'timed DTSTART')
check('VTIMEZONE' in ical, 'has VTIMEZONE for timed')

# All-day
ical_allday = build_ical('Birthday', dt_s, dt_e, is_all_day=True)
check('DTSTART;VALUE=DATE:20260408' in ical_allday, 'all-day VALUE=DATE')
check('DTEND;VALUE=DATE:20260408' in ical_allday, 'all-day DTEND VALUE=DATE')

# ==================================================================
# 9. find_calendar
# ==================================================================
section('9. find_calendar -- calendar lookup')

cal_p = MagicMock(); cal_p.get_display_name.return_value = 'Personal'
cal_w = MagicMock(); cal_w.get_display_name.return_value = 'Work Calendar'

check(find_calendar([cal_p, cal_w], 'primary') is cal_p, 'primary -> first')
check(find_calendar([cal_p, cal_w], 'work') is cal_w, 'partial match "work"')
check(find_calendar([cal_p, cal_w], 'WORK') is cal_w, 'case insensitive')
check(find_calendar([cal_p, cal_w], '') is cal_p, 'empty -> first')

try:
    find_calendar([], 'x')
    fail_count += 1
    print('  FAIL empty list should raise ValueError')
except ValueError:
    pass_count += 1

# ==================================================================
# 10. Long text
# ==================================================================
section('10. Long text stress')

long_s = 'A' * 10000
long_d = 'B' * 10000
ical = build_ical(long_s, dt_s, dt_e, description=long_d)
check(f'SUMMARY:{long_s}' in ical, 'long summary in iCal')
check(f'DESCRIPTION:{long_d}' in ical, 'long description in iCal')

# But sanitized in logs
check(sanitize_for_log(long_s) == 'A' * 30 + ELLIPSIS, 'log sanitization truncates')

# ==================================================================
# 11. Alarm boundaries
# ==================================================================
section('11. Alarm parameter boundaries')

check('VALARM' not in build_ical('T', dt_s, dt_e, alarm_minutes=0), 'alarm=0 -> no VALARM')
check('VALARM' not in build_ical('T', dt_s, dt_e, alarm_minutes=-15), 'alarm=-15 -> no VALARM')
check('VALARM' not in build_ical('T', dt_s, dt_e, alarm_minutes=None), 'alarm=None -> no VALARM')
ical1 = build_ical('T', dt_s, dt_e, alarm_minutes=1)
check('VALARM' in ical1, 'alarm=1 -> has VALARM')
check('TRIGGER:-PT1M' in ical1, '1-minute trigger')

# ==================================================================
# 12. RFC 5545 compliance
# ==================================================================
section('12. RFC 5545 compliance')

ical2 = build_ical('M', dt_s, dt_e)
check('\r\n' in ical2, 'CRLF line endings')
bare_lf = ical2.replace('\r\n', '')
check('\n' not in bare_lf, 'no bare LF')
check('CALSCALE:GREGORIAN' in ical2)
check('VERSION:2.0' in ical2)
check('PRODID:' in ical2)
check('STATUS:CONFIRMED' in ical2)
check('DTSTAMP:' in ical2)

# ==================================================================
# 13. search_events error propagation
# ==================================================================
section('13. search_events -- error propagation')

cal_ok = MagicMock()
cal_ok.get_display_name.return_value = 'GoodCal'
cal_ok.date_search.return_value = []

cal_err = MagicMock()
cal_err.get_display_name.return_value = 'BadCal'
cal_err.date_search.side_effect = Exception('timeout')

tz_sh = ZoneInfo('Asia/Shanghai')
start = _dt(2026, 4, 1, tzinfo=tz_sh)
end = _dt(2026, 5, 1, tzinfo=tz_sh)

result = search_events([cal_ok, cal_err], tz_sh, start, end)
check('events' in result, "result has 'events'")
check('errors' in result, "result has 'errors'")
check(len(result['errors']) > 0, 'errors captured from failed calendar')
check(len(result['events']) == 0, 'events list is empty')

# ==================================================================
# 14. Security -- log sanitization with sensitive RRULE
# ==================================================================
section('14. Security -- injection prevention')

try:
    _validate_rrule('DROP TABLE; FREQ=DAILY')
    fail_count += 1
    print('  FAIL SQL injection in RRULE should raise')
except ValueError:
    pass_count += 1

# ==================================================================
# Summary
# ==================================================================
print(f'\n{"="*60}')
print(f'  Results')
print(f'{"="*60}')
total = pass_count + fail_count
print(f'  PASS: {pass_count}/{total}')
if fail_count > 0:
    print(f'  FAIL: {fail_count}/{total}')
else:
    print(f'  ALL PASS!')
print(f'{"="*60}')


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)