diff --git a/docs/brainstorms/2026-06-23-calendar-schedule-requirements.md b/docs/brainstorms/2026-06-23-calendar-schedule-requirements.md new file mode 100644 index 0000000..902c2a0 --- /dev/null +++ b/docs/brainstorms/2026-06-23-calendar-schedule-requirements.md @@ -0,0 +1,196 @@ +--- +date: 2026-06-23 +topic: calendar-schedule +--- + +## Summary + +在 AgentKit 客户端右侧面板嵌入行事历(可展开为大抽屉),通过混合模式(ReAct 工具调用 + 时间关键词触发的后处理提取)自动捕获对话中的日程,同时支持完整的手动管理——循环事件、自定义类型、标签、三种视图(日历/卡片/列表),UI 对标 Notion Calendar。日程双向同步 Apple Calendar (CalDAV)、Outlook (Graph API) 和 iCal/ICS,配套独立的多渠道提醒子系统(客户端 + 邮件 + Webhook),个人为主 + 共享邀请。 + +## Problem Frame + +Agent 对话中频繁产生可执行的时间安排——会议时间、截止日期、跟进任务——但这些信息当前无处记录。用户必须手动将这些内容转移到外部日历中,这个过程容易遗漏,尤其在长对话中多个时间点被讨论时。痛点不在"缺少一个日历应用",而在"Agent 产生的日程与日历之间没有桥梁":对话结束后,时间承诺就消失了。 + +## Key Decisions + +**混合 Agent 识别模式。** ReAct 工具调用捕获实时意图(Agent 在对话中主动调用 `create_event`),后处理提取作为兜底——但仅在对话包含时间关键词时触发 LLM 提取,避免每轮对话都产生额外 LLM 调用。两层机制互补:工具调用覆盖 Agent 已识别的明确日程,后处理覆盖 Agent 未主动识别但对话中隐含的时间安排。 + +**右侧面板 + 大抽屉而非独立页面。** 日历紧贴对话上下文,用户不用切换页面就能看到即将到来的日程。需要全屏管理时展开为大抽屉。这牺牲了独立页面的全屏日历体验,但换来了日程与对话的紧密耦合——这是核心痛点的解法。 + +**提醒系统作为独立子系统。** 多渠道提醒(客户端推送 + 邮件 + Webhook)需要后台调度器、通知规则、渠道适配器、投递追踪——这不仅是日历的一个功能,而是 AgentKit 目前不存在的全新基础设施。作为独立子系统设计,未来可被其他功能复用。 + +**个人日历 + 共享邀请,不做团队日历。** 用户有自己的私人日历,可以邀请其他用户参加事件(类似 Google Calendar 的邀请),但不支持多用户共同编辑同一日历。团队日历是不同的产品形态,不在此次范围内。 + +## Actors + +- A1. **终端用户** — 管理个人日历,手动创建/查看/编辑/删除事件,管理事件类型和标签,配置提醒规则 +- A2. **Agent** — 在 ReAct 循环中自主识别日程意图并调用日历工具创建事件;后处理提取层在时间关键词触发时分析对话内容 +- A3. **外部日历服务** — Apple Calendar (CalDAV)、Outlook (Microsoft Graph API)、iCal/ICS 通用格式,作为双向同步目标 +- A4. **提醒子系统** — 后台调度器定期扫描即将到期的事件,根据提醒规则通过配置的渠道分发通知 + +## Requirements + +### 事件数据模型 + +- R1. 事件包含标题、描述、开始时间、结束时间、全天标记、地点字段 +- R2. 事件支持循环模式:每日、每周、每月、自定义 RRULE 规则(至少支持 INTERVAL、COUNT、UNTIL、BYDAY) +- R3. 事件支持自定义事件类型——用户可创建带名称和颜色的分类(如"会议"蓝色、"截止"红色),事件创建时选择类型 +- R4. 事件支持多标签——用户可自定义标签,单个事件可挂多个标签,支持按标签筛选 +- R5. 事件支持时间段——开始时间 + 结束时间,不限于时间点;全天事件和跨天事件均支持 + +### 视图模式 + +- R6. 日历视图——月/周/日网格布局,支持拖拽创建(拖选时间段直接建事件)、拖拽移动(拖动事件改变时间) +- R7. 卡片视图——看板风格,按日期或事件类型分组,卡片可拖拽调整分组 +- R8. 列表视图——按时间排序的列表,支持行内编辑和批量操作 +- R9. 三种视图在右侧面板和大抽屉中均可切换使用 + +### Agent 自动识别 + +- R10. Agent 在 ReAct 循环中可调用日历工具集:`create_event`、`query_events`、`update_event`、`delete_event` +- R11. 后处理提取在对话轮次结束后运行,但仅当对话内容包含时间关键词时触发——关键词列表包括但不限于:明天、下周、今天下午、X点、X月X日、开会、截止、deadline、schedule、reminder、提醒、预约 +- R12. 时间关键词检测使用正则/关键词匹配(零 LLM 调用),命中后才发起 LLM 提取请求 +- R13. Agent 创建的事件标记 `source="agent"`,并关联到 originating 对话 ID,可在日历中追溯来源 +- R14. 后处理提取创建的事件标记 `source="post_extract"`,同样关联对话 ID +- R15. 用户可在 UI 中区分手动创建、Agent 创建、后处理提取的事件(通过来源标记或颜色区分) + +### 手动管理 + +- R16. 用户可通过 UI 手动创建、编辑、删除事件——三种视图均支持创建入口 +- R17. 日历视图支持拖拽改期——拖动事件到新时间位置即可调整开始/结束时间 +- R18. 事件类型和标签可在事件编辑界面中设置和修改 +- R19. 支持批量操作:多选事件后批量删除、批量修改类型/标签 + +### 外部日历同步 + +- R20. 与 Apple Calendar 通过 CalDAV 协议双向同步——在 AgentKit 创建/修改/删除的事件同步到 Apple Calendar,反之亦然 +- R21. 与 Outlook Calendar 通过 Microsoft Graph API 双向同步——同上双向 +- R22. 支持 iCal/ICS 格式的导入和导出——可从 .ics 文件导入事件,可将日历导出为 .ics +- R23. 同步冲突采用 last-write-wins 策略,冲突时保留最后修改的版本,并向用户发送冲突通知 +- R24. 用户可在设置中配置每个外部日历的同步频率和同步范围(哪些事件类型参与同步) + +### 提醒子系统 + +- R25. 事件支持提醒规则——可配置多个提醒(如"提前15分钟"、"提前1天"),每个提醒指定渠道 +- R26. 提醒渠道支持:客户端内通知(WebSocket 推送 / Tauri 系统通知)、邮件、Webhook +- R27. 后台调度器定期扫描即将到期的事件,匹配提醒规则,通过配置的渠道分发通知 +- R28. 用户可为每种事件类型配置默认提醒规则——新建事件时自动继承 +- R29. 提醒投递状态可追踪(已发送/已读/失败),失败的提醒有重试机制 + +### 共享与邀请 + +- R30. 用户可邀请其他用户参加事件——通过用户名或邮箱搜索并邀请 +- R31. 被邀请用户收到通知,可接受/拒绝/暂定 +- R32. 事件创建者可查看邀请回复状态(谁接受、谁拒绝、谁未回复) +- R33. 被邀请用户的事件在其日历中以特殊样式标记(如半透明或边框区分) + +### UI/UX + +- R34. 日历嵌入右侧面板作为标签页(与 Workflow/Monitor 并列),展示今日/近期日程摘要 +- R35. 右侧面板日历可展开为大抽屉——覆盖大部分屏幕,提供完整的三视图管理体验 +- R36. UI 遵循 Notion Calendar 设计语言——简洁排版、充足留白、流畅拖拽、柔和配色 +- R37. Agent 创建事件时,右侧面板实时更新(无需手动刷新),并有视觉提示(如高亮动画) + +## Key Flows + +- F1. Agent 工具调用创建日程 + - **Trigger:** Agent 在 ReAct 循环中识别到对话内容包含明确的时间安排 + - **Actors:** A2 (Agent), A1 (用户) + - **Steps:** Agent 调用 `create_event` 工具,传入标题/时间/类型等参数 → 工具执行写入 → 返回创建结果 → Agent 在回复中告知用户已创建日程 → 右侧面板实时更新显示新事件 + - **Outcome:** 事件被创建并标记 `source="agent"`,用户在对话中收到确认 + - **Covers:** R10, R13, R37 + +- F2. 后处理提取创建日程 + - **Trigger:** 对话轮次结束,对话内容命中时间关键词正则 + - **Actors:** A2 (Agent 后处理层), A1 (用户) + - **Steps:** 关键词检测命中 → 发起 LLM 提取请求,分析对话中的时间安排 → LLM 返回提取结果(0 或多个事件) → 写入日历,标记 `source="post_extract"` → 在对话中插入轻量提示"检测到 N 条日程,已添加到日历" + - **Outcome:** 隐含的日程被捕获,用户收到提示可查看/修改 + - **Covers:** R11, R12, R14 + +- F3. 外部日历双向同步 + - **Trigger:** 定时同步任务触发,或用户手动触发同步 + - **Actors:** A3 (外部日历服务), A1 (用户) + - **Steps:** 拉取外部日历变更 → 与本地事件比对 → 检测冲突 → last-write-wins 解决冲突 → 推送本地变更到外部日历 → 冲突时向用户发送通知 + - **Outcome:** 本地与外部日历保持一致,冲突被记录和通知 + - **Covers:** R20, R21, R23 + +- F4. 提醒分发 + - **Trigger:** 后台调度器扫描到事件即将到达提醒时间点 + - **Actors:** A4 (提醒子系统), A1 (用户) + - **Steps:** 调度器匹配提醒规则 → 按规则配置的渠道分发通知(客户端推送 / 邮件 / Webhook) → 记录投递状态 → 失败时重试 + - **Outcome:** 用户在事件前通过配置的渠道收到提醒 + - **Covers:** R25, R26, R27, R29 + +- F5. 手动创建事件 + - **Trigger:** 用户在任意视图中触发创建操作(点击新建、拖选时间段、列表中添加) + - **Actors:** A1 (用户) + - **Steps:** 打开事件编辑表单 → 填写标题/时间/类型/标签/提醒/地点 → 保存 → 事件出现在日历中 → 如配置了外部同步,异步推送到外部日历 + - **Outcome:** 事件被创建并可管理 + - **Covers:** R16, R18 + +## Acceptance Examples + +- AE1. Agent 在对话中识别日程 + - **Covers:** R10, R13, R15 + - **Given:** 用户与 Agent 对话,用户说"下周三下午3点开个产品评审会" + - **When:** Agent 在 ReAct 循环中处理该输入 + - **Then:** Agent 调用 `create_event` 创建事件(标题="产品评审会", 开始时间=下周三15:00, source="agent"),并在回复中告知"已为您创建下周三下午3点的产品评审会日程" + +- AE2. 后处理提取被关键词门控 + - **Covers:** R11, R12, R14 + - **Given:** 用户与 Agent 对话,对话内容为"这个方案不错,我们继续优化吧" + - **When:** 对话轮次结束,后处理层检查时间关键词 + - **Then:** 关键词检测未命中(无时间相关词),不发起 LLM 提取请求,不创建任何事件 + +- AE3. 后处理提取在关键词命中时运行 + - **Covers:** R11, R12, R14 + - **Given:** 用户与 Agent 对话,对话内容为"好的,那下周二之前把文档发给我" + - **When:** 对话轮次结束,后处理层检查时间关键词 + - **Then:** 关键词"下周二"命中 → 发起 LLM 提取 → 创建事件(标题="发送文档", 截止时间=下周二, source="post_extract")→ 对话中插入提示"检测到 1 条日程,已添加到日历" + +- AE4. 同步冲突解决 + - **Covers:** R23 + - **Given:** 用户在 AgentKit 修改了事件 A(改到周三),同时在 Outlook 也修改了事件 A(改到周四),同步时检测到冲突 + - **When:** 同步任务运行 + - **Then:** 保留最后修改的版本(last-write-wins),向用户发送冲突通知"事件 A 存在同步冲突,已保留最新修改" + +- AE5. 提醒按渠道分发 + - **Covers:** R25, R26, R27 + - **Given:** 事件 B 配置了两个提醒:提前30分钟客户端通知 + 提前1天邮件通知 + - **When:** 调度器扫描到事件 B 的提前1天提醒时间点到达 + - **Then:** 通过邮件渠道发送提醒 → 记录投递状态为"已发送" → 继续等待提前30分钟的客户端通知时间点 + +## Scope Boundaries + +### Deferred for later + +- Google Calendar 集成——用户未选择,可在后续版本按需添加 +- 团队共享日历——多用户共同编辑同一日历,当前仅支持个人 + 邀请 +- 日历分析报表——事件统计、时间分布分析等 +- 资源预订——会议室、设备等资源预约 +- 移动端独立 App——当前仅 Web/Tauri 客户端 + +### Outside this product's identity + +- 替代专业日历应用——AgentKit 行事历的核心差异化是 Agent 自动识别,不是与 Google Calendar/Notion Calendar 全功能竞争 +- 项目管理——行事历不承担任务跟踪、甘特图、依赖管理等项目管理职能 + +## Dependencies / Assumptions + +- 复用现有认证系统(`src/agentkit/server/auth/models.py` 的 `UserModel`)作为用户身份基础 +- 复用现有 WebSocket 基础设施实现客户端实时通知推送 +- 新增依赖:后台调度器(如 APScheduler)用于提醒子系统 +- 新增依赖:CalDAV 客户端库用于 Apple Calendar 同步 +- 新增依赖:Microsoft Graph SDK 或直接 HTTP 客户端用于 Outlook 同步 +- 新增依赖:邮件发送能力(SMTP 或第三方服务)用于邮件提醒渠道 +- 假设:用户已拥有 Apple Calendar / Outlook 账号并可提供 OAuth/CalDAV 凭据 +- 假设:AgentKit 服务端可持续运行后台调度器进程(提醒子系统依赖) + +## Sources / Research + +- 现有架构落位参考:路由注册在 `src/agentkit/server/app.py`(第 928-954 行),前端布局在 `src/agentkit/server/frontend/src/components/layout/AgentLayout.vue`(第 146-156 行定义象限标签) +- 前端 Pinia stores 位于 `src/agentkit/server/frontend/src/stores/`,API 客户端位于 `src/agentkit/server/frontend/src/api/` +- 后端路由模块位于 `src/agentkit/server/routes/`(27 个 .py 文件),工具注册在 `src/agentkit/tools/registry.py` +- 用户模型位于 `src/agentkit/server/auth/models.py`(SQLAlchemy 2 + SQLite),可复用 `Base` 创建日历模型 +- 消息总线 `src/agentkit/bus/message.py` 的 `AgentMessage` 可用于提醒子系统的内部通信 +- DocumentTool(`src/agentkit/tools/document_tool.py`)可作为 Agent 工具集成的参考模式——工具注册、ReAct 循环调用、结果返回 diff --git a/docs/plans/2026-06-23-003-feat-calendar-schedule-plan.md b/docs/plans/2026-06-23-003-feat-calendar-schedule-plan.md new file mode 100644 index 0000000..798ae28 --- /dev/null +++ b/docs/plans/2026-06-23-003-feat-calendar-schedule-plan.md @@ -0,0 +1,728 @@ +--- +title: Calendar & Schedule Feature +status: completed +date: 2026-06-23 +origin: docs/brainstorms/2026-06-23-calendar-schedule-requirements.md +type: feature +--- + +## Summary + +在 AgentKit 客户端嵌入行事历,通过混合模式(ReAct 工具调用 + 时间关键词触发的后处理提取)自动捕获对话中的日程,支持完整的手动管理——循环事件、自定义类型、标签、三种视图(日历/卡片/列表),双向同步 Apple Calendar (CalDAV)、Outlook (Graph API) 和 iCal/ICS,配套独立的多渠道提醒子系统。 + +## Problem Frame + +Agent 对话中频繁产生可执行的时间安排,但这些信息当前无处记录。痛点不在"缺少一个日历应用",而在"Agent 产生的日程与日历之间没有桥梁"。本计划实现这架桥梁:Agent 工具调用 + 后处理提取 + 手动管理 + 外部同步 + 提醒分发,形成完整的日程闭环。 + +**Scope: Deep** — 跨前端 UI、后端 API、Agent 工具、外部同步、提醒子系统五大领域。 + +## Requirements Traceability + +| Requirement Group | R-IDs | Covered by Units | +|---|---|---| +| 事件数据模型 | R1-R5 | U1, U2 | +| 视图模式 | R6-R9 | U10 (KTD-12: R9 = summary + drawer) | +| Agent 自动识别 | R10-R15 | U3, U4 (R15 source distinction: U10) | +| 手动管理 | R16-R19 | U10, U11 | +| 外部日历同步 | R20-R24 | U6, U7, U8 (SyncManager: U6) | +| 提醒子系统 | R25-R29 | U5, U12 | +| 共享与邀请 | R30-R33 | U2, U11 (R30 user search: U2, R33 invited styling: U10) | +| UI/UX | R34-R37 | U10, U11 | + +## Key Technical Decisions + +**KTD-1. Mirror documents subsystem for backend structure.** 日历后端镜像 `src/agentkit/documents/` 的结构——`calendar/db.py` (aiosqlite bare-connection)、`calendar/models.py` (dataclass DTO)、`calendar/service.py` (business logic)、`calendar/tool.py` (agent tool)。这是代码库中最近验证过的模式,与现有架构一致,避免引入 SQLAlchemy engine/session 的复杂性。Rationale: aiosqlite bare-connection 已在 auth 和 documents 子系统中验证,轻量且与异步架构兼容。 + +**KTD-2. asyncio loop for reminder scheduler, not APScheduler.** 提醒调度器跟随 `src/agentkit/server/task_store.py` 的 `start()`/`stop()` + `asyncio.create_task` 循环模式,在 `app.py` lifespan 中启停。Rationale: 不引入新依赖,与现有后台任务模式一致(task_store cleanup loop、session writer loop)。`ponytail: asyncio.sleep 轮询的精度是秒级,如果未来需要亚秒级调度可升级到 APScheduler。` **Note:** 需求文档提到"新增依赖:后台调度器(如 APScheduler)",本决策选择不引入 APScheduler 而复用 asyncio loop 模式——这是有意识的偏离,因为 task_store 已验证此模式可行,且避免新依赖。 + +**KTD-3. python-dateutil.rrule for recurrence expansion.** 使用 `dateutil.rrule` 处理 RRULE 规则展开(RFC 5545 兼容),支持 FREQ/INTERVAL/COUNT/UNTIL/BYDAY。Rationale: 标准库 `datetime` 不支持循环规则,dateutil 是 Python 生态中唯一成熟的 RRULE 实现,且是 icalendar 库的依赖。 + +**KTD-4. caldav library for Apple Calendar, httpx for Outlook Graph API.** Apple Calendar 同步使用 `caldav` 库(CalDAV 协议),Outlook 同步直接用 `httpx` 调用 Microsoft Graph REST API(不引入 msgraph SDK)。Rationale: caldav 库是 Python 生态中唯一成熟的 CalDAV 客户端;msgraph SDK 过重(数百 MB),httpx 已在依赖中,Graph REST API 简单直接。 + +**KTD-5. icalendar library for ICS import/export.** 使用 `icalendar` 库生成和解析 .ics 文件。Rationale: 手写 iCalendar 格式容易出错(编码、转义、时区),icalendar 库是事实标准。 + +**KTD-6. FullCalendar Vue3 for calendar grid views.** 前端日历网格视图(月/周/日)使用 `@fullcalendar/vue3`,样式定制为 Notion Calendar 风格。Rationale: 从零实现带拖拽的月/周/日网格是数千行代码,FullCalendar 是成熟标准库,支持拖拽创建/移动、循环事件展开、视图切换。`ponytail: FullCalendar 是本计划中唯一的大型前端依赖,如果后续需要移除,日历网格组件是独立的可替换层。` + +**KTD-7. Post-processing hook in chat.py after assistant reply persistence.** 后处理提取钩子插入 `src/agentkit/server/routes/chat.py` 的 `_handle_chat_message()` 中,在 assistant 回复持久化之后(REACT 路径约 line 1158,DIRECT_CHAT 路径约 line 971)触发。先做零 LLM 的正则关键词检测,命中后才调用 LLM 提取。Rationale: 这是对话轮次结束的自然插入点,不改变现有流程,仅在回复完成后追加一个异步后台任务。 + +**KTD-8. aiosmtplib for email reminder channel.** 邮件提醒渠道使用 `aiosmtplib`(异步 SMTP 客户端)。Rationale: smtplib 是同步的会阻塞事件循环,aiosmtplib 是标准异步替代品。 + +**KTD-9. Reuse existing LLM gateway for post-processing extraction.** 后处理提取的 LLM 调用复用 `agentkit.llm` 网关,不新建 LLM 客户端。Rationale: LLM 网关已有 fallback、缓存、用量追踪,直接复用。 + +**KTD-10. WebSocket piggyback for real-time calendar push.** 日历实时更新(Agent 创建事件、提醒触发、邀请通知、同步冲突)复用现有 chat WebSocket 连接,新增 `calendar_event_created`、`calendar_reminder`、`calendar_invitation`、`calendar_sync_conflict` 消息类型。Rationale: 避免新建 WS 连接,前端 chat store 已有消息分发机制。 + +**KTD-11. UTC storage + local display timezone strategy.** 所有时间字段(`start_time`、`end_time`、`scheduled_time`、`last_modified`、`created_at`)以 ISO 8601 UTC 存储在数据库中,前端显示时由浏览器自动转换为本地时区。RRULE 展开基于 UTC 计算,显示时再转本地。Rationale: 统一存储避免时区歧义,浏览器 `Intl.DateTimeFormat` 或 dayjs UTC 插件处理显示转换。`ponytail: 不支持用户手动选择时区——浏览器本地时区即显示时区,跨时区用户需自行调整设备时区。` + +**KTD-12. Right panel = summary view, drawer = full management.** 右面板日历 tab 仅展示摘要视图(今日 + 未来 3 条事件),点击"展开"按钮打开 80% 宽度的大抽屉,抽屉内包含完整的三视图管理(日历网格/卡片/列表)+ 事件编辑器 + 邀请管理。Rationale: 右面板空间有限,摘要视图满足"快速查看"需求;完整管理功能需要大画布,抽屉模式不离开当前页面上下文。 + +## Architecture + +### Module Map + +``` +src/agentkit/calendar/ # New subsystem (mirrors documents/) + __init__.py + models.py # @dataclass: CalendarEvent, EventType, Tag, Reminder, ReminderRule, ExternalCalendarConfig, Invitation + db.py # aiosqlite: init_calendar_db(), CRUD functions + service.py # CalendarService: business logic, event CRUD, type/tag management + recurrence.py # RRULE expansion wrapper (dateutil.rrule) + scheduler.py # ReminderScheduler: start/stop asyncio loop, scan + dispatch + reminders.py # ReminderDispatcher: client push, email, webhook channels + extraction.py # PostProcessingExtractor: keyword gate + LLM extraction + sync/ + __init__.py + base.py # AbstractSyncProvider interface + caldav_provider.py # Apple Calendar sync via caldav library + outlook_provider.py # Outlook sync via httpx + Graph API + ics_provider.py # ICS import/export via icalendar library + manager.py # SyncManager: orchestrate providers, conflict resolution + +src/agentkit/tools/calendar_tool.py # CalendarTool(Tool): create_event, query_events, update_event, delete_event + +src/agentkit/server/routes/calendar.py # APIRouter(prefix="/calendar"): REST endpoints + +src/agentkit/server/frontend/src/ + api/calendar.ts # CalendarApiClient extends BaseApiClient + stores/calendar.ts # useCalendarStore (Pinia Composition API) + components/calendar/ + CalendarPanel.vue # Right panel tab content (summary view only — KTD-12) + CalendarDrawer.vue # Large drawer wrapper (full management — KTD-12) + CalendarGrid.vue # FullCalendar wrapper (month/week/day) + CardView.vue # Kanban-style card view + ListView.vue # Sorted list view + EventBadge.vue # Source distinction badge (manual/agent/post_extract — G3) + EventEditor.vue # Event create/edit form (a-drawer) + ReminderConfig.vue # Reminder rule configuration + SyncSettings.vue # External calendar sync config + InvitationManager.vue # Invitation response UI + views/CalendarView.vue # Top-level route page (optional, for drawer mode) +``` + +### Wiring Points + +| Integration | File | Location | +|---|---|---| +| DB init + service | `src/agentkit/server/app.py` | lifespan startup (~line 263, next to `init_documents_db()`) | +| Route registration | `src/agentkit/server/app.py` | ~line 954, `app.include_router(calendar.router, prefix="/api/v1")` | +| Tool registration | `src/agentkit/server/app.py` | ~line 268, `agent._tool_registry.register(CalendarTool(...))` | +| Scheduler start | `src/agentkit/server/app.py` | lifespan startup (~line 151, next to `task_store.start_cleanup()`) | +| Scheduler stop | `src/agentkit/server/app.py` | lifespan shutdown (~line 430) | +| SyncManager start | `src/agentkit/server/app.py` | lifespan startup (next to reminder scheduler) | +| SyncManager stop | `src/agentkit/server/app.py` | lifespan shutdown (next to reminder scheduler stop) | +| Post-processing hook | `src/agentkit/server/routes/chat.py` | ~line 1158 (REACT), ~line 971 (DIRECT_CHAT) | +| WebSocket push | `src/agentkit/server/routes/chat.py` | `chat_manager.send_json(session_id, {...})` | +| Frontend right panel tab | `src/agentkit/server/frontend/src/components/layout/AgentLayout.vue` | `bottomRightTabs` array (~line 153) | +| Frontend WS handler | `src/agentkit/server/frontend/src/stores/chat.ts` | `handleWsMessage()` switch (~line 586) | +| Frontend WS types | `src/agentkit/server/frontend/src/api/types.ts` | `WsServerMessage` union (~line 111) | + +### Data Model + +```python +# src/agentkit/calendar/models.py (dataclasses, mirrors documents/models.py) + +@dataclass +class EventType: + id: str # UUID + user_id: str # FK to users + name: str # "会议", "截止", "个人" + color: str # "#4A90D9" + is_default: bool # system-provided types + +@dataclass +class Tag: + id: str + user_id: str + name: str + +@dataclass +class CalendarEvent: + id: str + user_id: str + title: str + description: str + start_time: str # ISO 8601 UTC (see KTD-11) + end_time: str # ISO 8601 UTC + is_all_day: bool + location: str + event_type_id: str | None + rrule: str | None # RFC 5545 RRULE string, e.g. "FREQ=WEEKLY;BYDAY=MO;COUNT=10" + source: str # "manual" | "agent" | "post_extract" + is_invited: bool # True if this event arrived via invitation (R33 — special styling) + conversation_id: str | None # origin traceability for agent/extracted events + external_id: str | None # ID in external calendar (for sync) + external_provider: str | None # "caldav" | "outlook" + last_modified: str # ISO 8601 UTC, for conflict resolution (last-write-wins) + created_at: str # ISO 8601 UTC + +@dataclass +class EventTag: # many-to-many junction + event_id: str + tag_id: str + +@dataclass +class ReminderRule: + id: str + event_id: str # FK to events (nullable for type-level defaults) + event_type_id: str # FK to event_types (for default reminders) + offset_minutes: int # -15 = 15 min before, -1440 = 1 day before + channels: list[str] # ["client", "email", "webhook"] + +@dataclass +class ReminderDelivery: + id: str + reminder_rule_id: str + event_id: str + scheduled_time: str + status: str # "pending" | "sent" | "failed" | "read" + channel: str + attempts: int + last_error: str | None + +@dataclass +class ExternalCalendarConfig: + id: str + user_id: str + provider: str # "caldav" | "outlook" + credentials: str # encrypted JSON (CalDAV URL+user+app_password, or OAuth refresh_token) + sync_frequency: int # minutes + sync_scope: list[str] # event type IDs to sync, empty = all + last_sync: str | None + sync_token: str | None # delta token for incremental sync + +@dataclass +class Invitation: + id: str + event_id: str + inviter_user_id: str + invitee_email: str + status: str # "pending" | "accepted" | "declined" | "tentative" + responded_at: str | None +``` + +--- + +## Implementation Units + +### U1. Backend data model & storage + +- **Goal:** Define calendar dataclasses, aiosqlite DB schema with CRUD functions, and RRULE recurrence expansion wrapper. +- **Files:** + - `src/agentkit/calendar/__init__.py` (new) + - `src/agentkit/calendar/models.py` (new — dataclasses above) + - `src/agentkit/calendar/db.py` (new — `init_calendar_db()`, `insert_event()`, `get_event()`, `list_events()`, `update_event()`, `delete_event()`, same for types/tags/reminders/configs/invitations) + - `src/agentkit/calendar/recurrence.py` (new — `expand_rrule(rrule_str, start_time, range_start, range_end) -> list[str]` wrapper around `dateutil.rrule`) +- **Patterns:** Mirror `src/agentkit/documents/db.py` (aiosqlite bare-connection, `PRAGMA journal_mode=WAL`, module-level async functions). Mirror `src/agentkit/documents/models.py` for dataclass DTO pattern. `recurrence.py` wraps `dateutil.rrule.rrulestr()` + `between()` for date-range expansion. +- **DB schema:** 7 tables — `calendar_events`, `calendar_event_types`, `calendar_tags`, `calendar_event_tags`, `calendar_reminder_rules`, `calendar_reminder_deliveries`, `calendar_external_configs`, `calendar_invitations`. All with `String(36)` UUIDs, ISO 8601 UTC timestamps (see KTD-11), indexes on `user_id` and `start_time`. +- **RRULE expansion (G1, G9):** `recurrence.py` provides `expand_rrule(rrule_str, dtstart, range_start, range_end)` returning a list of ISO 8601 UTC occurrence strings within the range. Used by: (a) `CalendarService.list_events()` to expand recurring events for display, (b) `ReminderScheduler` to find occurrences entering reminder window, (c) sync providers to push individual occurrences if external calendar requires expanded events. All times in UTC per KTD-11. +- **Test file:** `tests/unit/calendar/test_db.py`, `tests/unit/calendar/test_recurrence.py` +- **Test scenarios:** + - `test_init_calendar_db_creates_all_tables` — call `init_calendar_db()`, verify tables exist + - `test_insert_and_get_event_roundtrip` — insert event, fetch by id, all fields preserved (including `is_invited`) + - `test_list_events_by_user_filtered_by_date_range` — insert 3 events, query range covering 2 + - `test_update_event_modifies_fields` — insert, update title, verify + - `test_delete_event_removes_record` — insert, delete, verify gone + - `test_event_type_crud` — create/list/update/delete event types + - `test_tag_many_to_many` — event with 3 tags, query by tag returns event + - `test_reminder_rule_crud` — create rule for event, create default rule for type + - `test_external_config_stores_encrypted_credentials` — insert config, verify credentials field is opaque + - `test_expand_rrule_weekly_count` — `FREQ=WEEKLY;BYDAY=MO;COUNT=4` from Monday → 4 occurrences + - `test_expand_rrule_daily_range_filter` — `FREQ=DAILY` starting Jan 1, range Jan 3–Jan 5 → 3 occurrences + - `test_expand_rrule_until_clause` — `FREQ=DAILY;UNTIL=20260131` → occurrences stop at Jan 31 + - `test_expand_rrule_no_rrule_returns_single` — `rrule=None` → returns `[start_time]` only + - `test_expand_rrule_all_day_event` — all-day event with RRULE, verify date (not datetime) expansion + - `test_expand_rrule_dst_boundary` — event crossing DST transition, verify UTC consistency +- **Dependencies:** None (foundation unit) + +### U2. Backend calendar service & REST API + +- **Goal:** Business logic layer + REST endpoints for full calendar CRUD, event types, tags, invitations, and non-admin user search for invitations. +- **Files:** + - `src/agentkit/calendar/service.py` (new — `CalendarService` class) + - `src/agentkit/server/routes/calendar.py` (new — `APIRouter(prefix="/calendar", tags=["calendar"])`) +- **Patterns:** Mirror `src/agentkit/documents/service.py` (service class with injected dependencies) and `src/agentkit/server/routes/documents.py` (route module with `_verify_api_key` or `require_authenticated` dependency, Pydantic request/response models, `request.app.state.calendar_service` access). +- **Service methods:** `create_event()`, `get_event()`, `list_events()` (with RRULE expansion via `recurrence.expand_rrule`), `update_event()`, `delete_event()`, `list_event_types()`, `create_event_type()`, `update_event_type()`, `list_tags()`, `create_tag()`, `create_invitation()`, `respond_to_invitation()`, `list_invitations()`, `search_users(q)` (returns `[{username, email}]` for invitation picker — G5/A3). +- **REST endpoints:** + - `POST /api/v1/calendar/events` — create event + - `GET /api/v1/calendar/events?start=...&end=...&type_id=...&tag_id=...` — list with filters (tag_id filter covers G2) + - `GET /api/v1/calendar/events/{id}` — get single + - `PATCH /api/v1/calendar/events/{id}` — update + - `DELETE /api/v1/calendar/events/{id}` — delete + - `POST /api/v1/calendar/events/{id}/invitations` — invite user by email (creates Invitation + pushes `calendar_invitation` WS to invitee — G6) + - `POST /api/v1/calendar/invitations/{id}/respond` — accept/decline/tentative + - `GET /api/v1/calendar/invitations` — list pending invitations for current user + - `GET /api/v1/calendar/users/search?q=xxx` — non-admin user search by username/email, returns `[{username, email}]` only (no user_id leak) — G5/A3. Auth: any authenticated user. + - `GET /api/v1/calendar/event-types` — list types + - `POST /api/v1/calendar/event-types` — create type + - `PATCH /api/v1/calendar/event-types/{id}` — update type + - `GET /api/v1/calendar/tags` — list tags + - `POST /api/v1/calendar/tags` — create tag +- **User search implementation (G5/A3):** `search_users(q)` queries `users` table (from `src/agentkit/server/auth/models.py` `UserModel`) by `username ILIKE '%q%' OR email ILIKE '%q%'`, returns top 10 results as `[{username, email}]`. Does NOT require admin permission — uses standard authenticated user dependency. Existing `GET /api/v1/admin/users` requires admin and has no username/email search, so this new endpoint is necessary. +- **Invitation notification (G6):** When `create_invitation()` is called, after persisting the `Invitation` record, push `calendar_invitation` WS message to the invitee's active session(s) via `chat_manager.send_json()`. Invitee frontend shows a notification + updates invitation list. +- **Wiring:** `app.py` lifespan — `await init_calendar_db()`, `app.state.calendar_service = CalendarService()`, `app.include_router(calendar.router, prefix="/api/v1")`. +- **Test file:** `tests/unit/calendar/test_service.py`, `tests/unit/calendar/test_routes.py` +- **Test scenarios:** + - `test_create_event_with_type_and_tags` — create event with type_id and 2 tags, verify all linked + - `test_list_events_filters_by_date_range` — 3 events across days, filter returns correct subset + - `test_list_events_filters_by_type_and_tag` — filter combination (G2) + - `test_list_events_filters_by_tag_only` — 5 events, 2 with tag X, filter tag_id=X returns only 2 (G2) + - `test_list_events_expands_recurring` — event with `FREQ=DAILY;COUNT=3`, list range covering 2 days → returns 2 expanded occurrences + - `test_update_event_partial_fields` — PATCH only title, other fields unchanged + - `test_delete_event_cascades_reminders_and_tags` — delete event, verify reminder rules and junction rows removed + - `test_create_invitation_pushes_ws_to_invitee` — create invitation, verify `chat_manager.send_json()` called with `calendar_invitation` type (G6) + - `test_respond_to_invitation_updates_status` — invite, respond "accepted", verify status + `responded_at` set + - `test_search_users_by_username` — seed 3 users, search "alice" returns matching user with username+email only (G5) + - `test_search_users_by_email` — search by email domain returns matches (G5) + - `test_search_users_no_match_returns_empty` — search "zzz" returns `[]` + - `test_search_users_requires_auth` — no auth header → 401 + - `test_search_users_returns_max_10` — seed 15 matching users, verify only 10 returned + - `test_event_type_default_color_persistence` — create type with color, verify roundtrip + - `test_route_create_event_requires_auth` — no auth header → 401 + - `test_route_list_events_returns_paginated` — large result set with limit/offset +- **Dependencies:** U1 + +### U3. Agent calendar tool (ReAct integration) + +- **Goal:** CalendarTool for ReAct loop — Agent autonomously calls `create_event`, `query_events`, `update_event`, `delete_event`. +- **Files:** + - `src/agentkit/tools/calendar_tool.py` (new — `CalendarTool(Tool)`) +- **Patterns:** Mirror `src/agentkit/tools/document_tool.py` — extend `Tool` ABC, define `input_schema` as JSON Schema dict, `execute(**kwargs)` dispatches by `action` parameter, returns `{"success": True/False, ...}` dict, errors caught not raised. +- **Tool actions:** + - `create_event` — params: title, start_time, end_time, description, location, event_type_name, tag_names, rrule, is_all_day + - `query_events` — params: start_date, end_date, limit + - `update_event` — params: event_id, fields to update + - `delete_event` — params: event_id +- **Source marking:** All events created via tool set `source="agent"` and `conversation_id` from execution context. +- **Wiring:** `app.py` ~line 268 — `agent._tool_registry.register(CalendarTool(service=calendar_service))`. +- **Test file:** `tests/unit/tools/test_calendar_tool.py` +- **Test scenarios:** + - `test_create_event_action_returns_success` — call execute with create_event action, verify event in DB with source="agent" + - `test_create_event_with_recurrence_sets_rrule` — rrule param stored correctly + - `test_query_events_returns_list` — create 2 events, query, verify both returned + - `test_update_event_action_modifies_fields` — create then update title + - `test_delete_event_action_removes_record` — create then delete + - `test_invalid_action_returns_error` — unknown action → `{"success": False, "error": "..."}` + - `test_missing_required_field_returns_error` — create_event without title → error + - `test_created_event_has_conversation_id` — verify conversation_id is set from context +- **Dependencies:** U2 + +### U4. Post-processing extraction (keyword gating + LLM) + +- **Goal:** After conversation turn, detect time keywords (zero-LLM regex), if hit then call LLM to extract schedule info, create events with `source="post_extract"`. +- **Files:** + - `src/agentkit/calendar/extraction.py` (new — `PostProcessingExtractor` class) +- **Patterns:** Keyword regex follows the `_TOOL_CONTEXT_RE` / greeting regex pattern in `src/agentkit/chat/request_preprocessor.py`. LLM call via `agentkit.llm` gateway (reuse existing `LLMGateway.complete()` or `acomplete()`). +- **Keyword regex:** `明天|后天|下周|本周|今天下午|今天上午|上午|下午|晚上|\d+点|\d+月\d+日|\d+号|开会|截止|deadline|schedule|reminder|提醒|预约|约定|安排` +- **LLM extraction prompt:** System prompt instructs LLM to extract events as JSON array `[{title, start_time, end_time, description, event_type}]` from conversation text. Empty array if no events. +- **Flow:** + 1. Hook fires after assistant reply persisted in `chat.py` (~line 1158 REACT, ~line 971 DIRECT_CHAT) + 2. `asyncio.create_task(extractor.extract(conversation_text, conversation_id, user_id))` + 3. Regex keyword scan — if no match, return immediately (zero LLM cost) + 4. If match, call LLM gateway with extraction prompt + 5. Parse JSON response, create events with `source="post_extract"` + 6. Push `calendar_event_created` WS message to client +- **Wiring:** `chat.py` — after `sm.append_message()` for assistant reply, call `asyncio.create_task(post_extractor.extract(...))`. Extractor instance on `app.state.post_extractor`. +- **Test file:** `tests/unit/calendar/test_extraction.py` +- **Test scenarios:** + - `test_keyword_regex_matches_chinese_time_words` — "明天下午3点" matches, "继续优化吧" doesn't + - `test_keyword_regex_matches_english_time_words` — "deadline tomorrow" matches + - `test_no_keyword_skips_llm_call` — mock LLM gateway, verify `.complete()` never called when no keyword + - `test_keyword_hit_triggers_llm_extraction` — mock LLM returns `[{title: "会议", start_time: "..."}]`, verify event created with source="post_extract" + - `test_llm_returns_empty_array_creates_nothing` — LLM returns `[]`, no events created + - `test_malformed_llm_response_handled_gracefully` — LLM returns invalid JSON, no crash, logged + - `test_extracted_events_have_conversation_id` — verify conversation_id set + - `test_extraction_does_not_block_chat_response` — verify extract is async, chat reply returns before extraction completes +- **Dependencies:** U2, U3 (for source marking pattern) + +### U5. Reminder subsystem (scheduler + multi-channel dispatch) + +- **Goal:** Background scheduler scans upcoming events, matches reminder rules, dispatches via client push / email / webhook, tracks delivery status with retry. +- **Files:** + - `src/agentkit/calendar/scheduler.py` (new — `ReminderScheduler` class) + - `src/agentkit/calendar/reminders.py` (new — `ReminderDispatcher` class) +- **Patterns:** Scheduler follows `src/agentkit/server/task_store.py` `start()`/`stop()` + `asyncio.create_task` loop pattern. Dispatcher uses strategy pattern — one method per channel. +- **Scheduler loop:** + 1. Every 60 seconds: query events where `start_time + offset_minutes` is within next 60s window + 2. For each match, check if `ReminderDelivery` already exists (idempotency) + 3. If not, create `ReminderDelivery` records (one per channel) and dispatch + 4. Failed deliveries retried up to 3 times with exponential backoff +- **Dispatcher channels:** + - `client` — `chat_manager.send_json(session_id, {"type": "calendar_reminder", "data": {...}})` + - `email` — `aiosmtplib.send()` with SMTP config from `agentkit.yaml` + - `webhook` — `httpx.post()` to user-configured webhook URL +- **Default reminders:** When event created, if its `event_type_id` has default `ReminderRule`s, clone them to the event. +- **Wiring:** `app.py` lifespan startup — `reminder_scheduler = ReminderScheduler(calendar_service, dispatcher); app.state.reminder_scheduler = reminder_scheduler; await reminder_scheduler.start()`. Shutdown — `await reminder_scheduler.stop()`. +- **Test file:** `tests/unit/calendar/test_scheduler.py`, `tests/unit/calendar/test_reminders.py` +- **Test scenarios:** + - `test_scheduler_finds_event_within_reminder_window` — event 10 min away, rule offset -15min, scheduler finds it + - `test_scheduler_skips_event_outside_window` — event 2 hours away, rule offset -15min, not found + - `test_idempotent_delivery_no_duplicate` — scheduler runs twice, only one delivery record created + - `test_client_channel_sends_ws_message` — mock chat_manager, verify send_json called + - `test_email_channel_sends_smtp` — mock aiosmtplib, verify send called with correct params + - `test_webhook_channel_posts_to_url` — mock httpx, verify POST called + - `test_failed_delivery_retries_up_to_3_times` — mock channel to fail, verify 3 attempts + - `test_default_reminders_inherited_from_event_type` — create event with type that has default rules, verify rules cloned + - `test_scheduler_start_stop_lifecycle` — start(), verify task running, stop(), verify cancelled +- **Dependencies:** U1, U2 + +### U6. External sync — CalDAV (Apple Calendar) + +- **Goal:** Bidirectional sync with Apple Calendar via CalDAV protocol using `caldav` library, plus SyncManager orchestration. +- **Files:** + - `src/agentkit/calendar/sync/base.py` (new — `AbstractSyncProvider` interface: `pull_changes()`, `push_changes()`, `test_connection()`) + - `src/agentkit/calendar/sync/caldav_provider.py` (new — `CalDAVSyncProvider`) + - `src/agentkit/calendar/sync/manager.py` (new — `SyncManager` class — G8) +- **Patterns:** Provider interface allows future providers (Google Calendar) without changing SyncManager. CalDAV connection uses `caldav.DAVClient(url)` with user Apple ID + app-specific password. +- **SyncManager (G8):** `SyncManager` orchestrates all providers. Methods: `sync_all(user_id)` (iterates user's `ExternalCalendarConfig`s, calls provider pull/push), `sync_provider(config_id)` (single provider), `resolve_conflict(local_event, remote_event)` (last-write-wins + WS notification). Called by: (a) scheduled sync (asyncio loop, same pattern as reminder scheduler), (b) manual "Sync Now" button, (c) after local event creation/update (push to external). `SyncManager` is registered on `app.state.sync_manager` and started/stopped in `app.py` lifespan. +- **Sync flow:** + 1. `pull_changes()` — `calendar.date_search(start, end)` to fetch events in range, match by `external_id` (CalDAV UID), create/update local events + 2. `push_changes()` — for local events modified since `last_sync`, `calendar.save_event(ical_data)` to push to CalDAV + 3. Conflict: `last_modified` comparison, last-write-wins, log conflict + push `calendar_sync_conflict` WS notification to user (G4) +- **ICS generation:** Use `icalendar` library to convert `CalendarEvent` → iCalendar VEVENT (with RRULE if present). +- **Config:** `ExternalCalendarConfig` stores CalDAV URL, username, app-specific password (encrypted). User configures via settings UI. +- **Test file:** `tests/unit/calendar/test_sync_caldav.py`, `tests/unit/calendar/test_sync_manager.py` +- **Test scenarios:** + - `test_caldav_provider_pull_creates_local_events` — mock caldav client returning 2 events, verify local events created with external_id set + - `test_caldav_provider_pull_updates_existing_event` — event already exists (matched by external_id), remote modified, local updated + - `test_caldav_provider_push_creates_remote_event` — local event with no external_id, push creates remote, external_id stored + - `test_caldav_provider_push_updates_remote_event` — local event modified, push updates remote + - `test_caldav_conflict_last_write_wins` — both sides modified, newer last_modified wins, conflict logged + - `test_caldav_conflict_sends_ws_notification` — conflict detected, verify `chat_manager.send_json()` called with `calendar_sync_conflict` type (G4) + - `test_caldav_rrule_roundtrip` — event with RRULE synced, verify RRULE preserved in both directions + - `test_caldav_test_connection_success` — mock returns calendars, test_connection() returns True + - `test_caldav_test_connection_failure` — mock raises, test_connection() returns False with error message + - `test_sync_manager_sync_all_iterates_providers` — 2 configs, verify both providers called (G8) + - `test_sync_manager_sync_provider_updates_last_sync` — sync completes, verify config.last_sync updated (G8) + - `test_sync_manager_resolve_conflict_notifies_user` — conflict, verify WS push (G8/G4) +- **Dependencies:** U2, U8 (ICS generation) + +### U7. External sync — Outlook (Microsoft Graph API) + +- **Goal:** Bidirectional sync with Outlook Calendar via Microsoft Graph REST API using `httpx`. +- **Files:** + - `src/agentkit/calendar/sync/outlook_provider.py` (new — `OutlookSyncProvider`) +- **Patterns:** Same `AbstractSyncProvider` interface. OAuth2 flow — user authorizes via browser, app stores refresh token, auto-refreshes access token. Graph API endpoints: `GET /me/calendarView/delta` for incremental pull, `POST /me/events` for create, `PATCH /me/events/{id}` for update. +- **Delta sync:** Use Graph API delta query (`$deltaToken` / `$skipToken`) for incremental changes — first call is full sync, subsequent calls are incremental. +- **OAuth flow:** + 1. User clicks "Connect Outlook" in settings UI + 2. Redirect to Microsoft login with `Calendars.ReadWrite` scope + 3. Callback receives authorization code, exchanges for access + refresh token + 4. Tokens stored in `ExternalCalendarConfig.credentials` (encrypted JSON) + 5. On token expiry (401), auto-refresh using refresh token +- **Config:** `ExternalCalendarConfig` stores OAuth client_id, refresh_token, access_token (with expiry). Client ID configured in `agentkit.yaml`. +- **Test file:** `tests/unit/calendar/test_sync_outlook.py` +- **Test scenarios:** + - `test_outlook_provider_pull_delta_initial_sync` — mock httpx returning 3 events, verify local events created + - `test_outlook_provider_pull_delta_incremental` — mock returning 1 new + 1 updated, verify only those processed + - `test_outlook_provider_push_creates_remote_event` — local event pushed, remote ID stored + - `test_outlook_provider_push_updates_remote_event` — local modification pushed + - `test_outlook_token_refresh_on_401` — mock 401 response, verify refresh token used, request retried + - `test_outlook_conflict_last_write_wins` — both sides modified, newer wins + - `test_outlook_rrule_roundtrip` — recurring event synced, pattern preserved + - `test_outlook_test_connection_success` — mock Graph /me returns user, True +- **Dependencies:** U2, U8 (ICS for RRULE conversion) + +### U8. iCal/ICS import/export + +- **Goal:** Import .ics files to local calendar, export local calendar to .ics file, using `icalendar` library. +- **Files:** + - `src/agentkit/calendar/sync/ics_provider.py` (new — `ICSProvider`) +- **Patterns:** `icalendar.Calendar.from_ical(ics_bytes)` for import, `icalendar.Calendar()` + `add_component()` for export. Map VEVENT fields to `CalendarEvent` dataclass. +- **Import flow:** Parse ICS → for each VEVENT, create `CalendarEvent` (map SUMMARY→title, DTSTART→start_time, RRULE→rrule, etc.) → skip events with duplicate UID already imported. +- **Export flow:** Query events in date range → for each, create VEVENT component → assemble into Calendar → serialize to .ics bytes → return as downloadable file. +- **REST endpoints** (in `routes/calendar.py`): + - `POST /api/v1/calendar/import-ics` — upload .ics file, parse, create events + - `GET /api/v1/calendar/export-ics?start=...&end=...` — download .ics file +- **Test file:** `tests/unit/calendar/test_ics_provider.py` +- **Test scenarios:** + - `test_import_simple_ics_creates_event` — single VEVENT ICS, verify event created with correct fields + - `test_import_recurring_ics_preserves_rrule` — VEVENT with RRULE, verify rrule field set + - `test_import_all_day_event` — DTSTART is date not datetime, verify is_all_day=True + - `test_import_skips_duplicate_uid` — import same ICS twice, second import creates no new events + - `test_export_produces_valid_ics` — create event, export, parse result with icalendar, verify roundtrip + - `test_export_includes_recurrence` — event with rrule, export, verify RRULE in ICS + - `test_export_date_range_filter` — 3 events, export range covering 2, verify only 2 in ICS + - `test_import_malformed_ics_raises_error` — invalid ICS bytes, verify graceful error +- **Dependencies:** U2 + +### U9. Frontend store, API client & types + +- **Goal:** Pinia store, API client, and TypeScript types for calendar feature. +- **Files:** + - `src/agentkit/server/frontend/src/api/calendar.ts` (new — `CalendarApiClient extends BaseApiClient`, types co-located) + - `src/agentkit/server/frontend/src/stores/calendar.ts` (new — `useCalendarStore` Composition API) + - `src/agentkit/server/frontend/src/api/types.ts` (modify — add `calendar_event_created`, `calendar_reminder`, `calendar_invitation`, `calendar_sync_conflict` to `WsServerMessage` union) +- **Patterns:** API client mirrors `src/agentkit/server/frontend/src/api/documents.ts` (extend `BaseApiClient`, singleton export, types co-located, runtime type guard `isCalendarEvent`). Store mirrors `src/agentkit/server/frontend/src/stores/settings.ts` (Composition API, `ref()` state, `computed()` getters, async actions with try/catch + `isLoading`). +- **API client methods:** `listEvents()`, `createEvent()`, `updateEvent()`, `deleteEvent()`, `listEventTypes()`, `createEventType()`, `listTags()`, `createTag()`, `importIcs()`, `exportIcs()`, `createInvitation()`, `respondToInvitation()`, `listInvitations()`, `searchUsers(q)` (G5/A3), `listExternalConfigs()`, `createExternalConfig()`, `testExternalConnection()`, `syncNow(configId)`. +- **Store state:** `events`, `eventTypes`, `tags`, `isLoading`, `error`, `selectedEvent`, `viewMode` ('calendar'|'card'|'list'), `dateRange`, `pendingInvitations`, `syncConflicts`. +- **Store actions:** `loadEvents()`, `createEvent()`, `updateEvent()`, `deleteEvent()`, `loadEventTypes()`, `loadTags()`, `setViewMode()`, `loadInvitations()`, `respondToInvitation()`, `searchUsers(q)`, `handleWsEvent()` (dispatch for `calendar_event_created`, `calendar_reminder`, `calendar_invitation`, `calendar_sync_conflict` WS messages). +- **WS integration:** `stores/chat.ts` `handleWsMessage()` — add `case 'calendar_event_created'`, `case 'calendar_reminder'`, `case 'calendar_invitation'`, `case 'calendar_sync_conflict'` branches that call into calendar store (lazy import pattern to avoid circular deps). +- **Test file:** `tests/unit/frontend/calendar/store.test.ts` (if frontend test infra exists; otherwise manual verification via `npm run typecheck`) +- **Test scenarios:** + - Type check: `npm run typecheck` passes with new types + - `test_loadEvents_populates_state` — mock API, call loadEvents, verify events ref populated + - `test_createEvent_adds_to_state` — call createEvent, verify events array updated + - `test_handleWsEvent_calendar_event_created` — call handleWsEvent with calendar_event_created payload, verify event added to state + - `test_handleWsEvent_calendar_reminder` — call handleWsEvent with reminder payload, verify notification shown + - `test_handleWsEvent_calendar_invitation` — call handleWsEvent with invitation payload, verify pendingInvitations updated + notification shown (G6) + - `test_handleWsEvent_calendar_sync_conflict` — call handleWsEvent with conflict payload, verify syncConflicts updated + alert shown (G4) + - `test_searchUsers_returns_results` — call searchUsers("ali"), verify results populated (G5) + - `test_viewMode_switch` — setViewMode('card'), verify state +- **Dependencies:** U2 (API contract must exist) + +### U10. Frontend calendar views (3 views + right panel tab + drawer) + +- **Goal:** Three view modes (calendar grid / card / list), right panel tab integration (summary only), expandable drawer (full management), with event source distinction and invited-event styling. +- **Files:** + - `src/agentkit/server/frontend/src/components/calendar/CalendarPanel.vue` (new — right panel tab, summary view only per KTD-12) + - `src/agentkit/server/frontend/src/components/calendar/CalendarDrawer.vue` (new — large drawer wrapper, full management per KTD-12) + - `src/agentkit/server/frontend/src/components/calendar/CalendarGrid.vue` (new — FullCalendar wrapper) + - `src/agentkit/server/frontend/src/components/calendar/CardView.vue` (new — kanban card view) + - `src/agentkit/server/frontend/src/components/calendar/ListView.vue` (new — sorted list view) + - `src/agentkit/server/frontend/src/components/calendar/EventBadge.vue` (new — source distinction badge/icon component — G3) + - `src/agentkit/server/frontend/src/components/layout/AgentLayout.vue` (modify — add `calendar` to `bottomRightTabs`) + - `src/agentkit/server/frontend/src/components/layout/tabs/CalendarTab.vue` (new — tab content wrapper) + - `package.json` (modify — add `@fullcalendar/vue3`, `@fullcalendar/daygrid`, `@fullcalendar/timegrid`, `@fullcalendar/interaction` deps) +- **Patterns:** Right panel tab follows existing `QuadrantPanel` tab pattern (add to `bottomRightTabs`, add named slot). Drawer follows `a-drawer` pattern from `SkillDetail.vue` (but wider, ~80% viewport). FullCalendar initialized in `CalendarGrid.vue` with Vue3 wrapper. +- **Event source distinction (G3, R15):** Each event displays a visual indicator of its origin: + - `manual` — no badge (default) + - `agent` — small robot icon (🤖 or `RobotOutlined`) in event card/grid item + - `post_extract` — small sparkle icon (✨ or `ThunderboltOutlined`) in event card/grid item + - `EventBadge.vue` component renders the appropriate icon based on `event.source` field, used in CalendarGrid/CardView/ListView +- **Invited event styling (G7, R33):** Events with `is_invited=True` render with a distinct visual treatment: + - Dashed border instead of solid + - Subtle background tint (e.g., light blue overlay) + - "受邀" tag displayed next to title + - Clicking an invited event opens InvitationManager (respond UI) instead of EventEditor +- **Timezone display (KTD-11):** All times received from API are UTC ISO 8601. Frontend converts to local timezone for display using `dayjs(utcTime).local()` or native `Intl.DateTimeFormat`. No user-facing timezone selector — browser local timezone is used. +- **CalendarPanel (right panel tab — summary only per KTD-12):** + - Shows today's events + next 3 upcoming events as compact list + - Each item shows time + title + source badge (G3) + - "Expand" button opens `CalendarDrawer` + - Visual highlight animation when new event created (via WS event trigger) + - Does NOT include view mode switcher, event editor, or full calendar grid — those live in the drawer +- **CalendarDrawer (large drawer — full management per KTD-12):** + - `a-drawer` with `width="80%"` and `placement="right"` + - View mode switcher (calendar/card/list) at top + - Contains `CalendarGrid`, `CardView`, or `ListView` based on selected mode + - "New Event" button opens `EventEditor` (U11) + - Invitation management accessible via tab/section (U11) +- **CalendarGrid (FullCalendar):** + - Month/Week/Day views via FullCalendar + - Drag-to-create (select time range → open EventEditor) + - Drag-to-move (drag event to new time → call updateEvent) + - Events colored by `event_type.color` + - Recurring events expanded by backend (U2 `list_events` with RRULE expansion) — FullCalendar receives pre-expanded occurrences + - Invited events (is_invited=True) styled with dashed border class (G7) + - Styled with Notion Calendar aesthetic: clean borders, ample whitespace, soft colors +- **CardView:** + - Kanban-style, grouped by date or event type (toggle) + - Cards draggable between groups (changes date or type) + - Each card shows source badge (G3) and invited styling (G7) +- **ListView:** + - Sorted by start_time, supports inline edit of title, batch select with checkbox + - Source column with badge (G3) +- **Wiring:** `AgentLayout.vue` — add `{ key: 'calendar', label: '日历', icon: CalendarOutlined }` to `bottomRightTabs`, add `` slot. +- **Test file:** Manual verification + `npm run typecheck` +- **Test scenarios:** + - Type check passes + - Right panel shows calendar tab with today's events (summary only — no grid) + - Each event in panel shows correct source badge (manual=none, agent=robot, post_extract=sparkle) (G3) + - Clicking "Expand" opens drawer with 80% width + - Drawer contains view mode switcher + full calendar grid + - Calendar grid shows month view by default, can switch to week/day + - Drag-select time range opens event editor + - Drag event to new time updates event + - Invited events show dashed border + "受邀" tag in all views (G7) + - Clicking invited event opens invitation response UI, not editor (G7) + - Card view groups by date, cards draggable, source badge visible (G3) + - List view sorted by time, inline edit works, source column visible (G3) + - New event from Agent triggers highlight animation in panel + - Times displayed in browser local timezone (KTD-11) +- **Dependencies:** U9 + +### U11. Frontend event editor & management UI + +- **Goal:** Event create/edit form, drag-and-drop rescheduling, batch operations, invitation management. +- **Files:** + - `src/agentkit/server/frontend/src/components/calendar/EventEditor.vue` (new — `a-drawer` width 560) + - `src/agentkit/server/frontend/src/components/calendar/InvitationManager.vue` (new — invitation list + respond buttons) +- **Patterns:** Event editor follows `a-drawer` + form pattern from `SkillDetail.vue`. Form uses Ant Design Vue form components (`a-form`, `a-input`, `a-date-picker`, `a-time-picker`, `a-select`, `a-tag`). +- **EventEditor fields:** + - Title (a-input, required) + - Description (a-textarea) + - Date range (a-range-picker) + time (a-time-picker, hidden if all-day) + - All-day toggle (a-switch) + - Location (a-input) + - Event type (a-select, colored options) + - Tags (a-select mode="tags", user can create new) + - Recurrence (a-select: none/daily/weekly/monthly/custom + custom RRULE builder) + - Reminders (list of offset + channel selectors, add/remove) +- **Batch operations:** In ListView, checkbox multi-select → toolbar with "Delete", "Change Type", "Add Tag" buttons. +- **InvitationManager:** Shows list of invitees with status icons, "Invite" button opens user search dialog (calls `GET /api/v1/calendar/users/search?q=xxx` from U2 — G5/A3), invitee view shows accept/decline/tentative buttons. Also displays incoming invitations (received via `calendar_invitation` WS push from U2 — G6) with response buttons. +- **Test file:** Manual verification + `npm run typecheck` +- **Test scenarios:** + - Type check passes + - Create event with all fields, verify saved + - Edit existing event, verify changes persisted + - Set recurrence weekly, verify RRULE generated + - Add 2 reminders (15min client + 1day email), verify saved + - Batch select 3 events, delete, verify removed + - Batch select, change type, verify all updated + - Invite user by email, verify invitation created (G5) + - User search dialog returns results from `/api/v1/calendar/users/search` (G5) + - Invitation response (accept) updates status + - Incoming invitation notification displays when `calendar_invitation` WS received (G6) +- **Dependencies:** U9, U10 + +### U12. Frontend reminder & external sync settings UI + +- **Goal:** Reminder notification display, external calendar connection settings, sync status. +- **Files:** + - `src/agentkit/server/frontend/src/components/calendar/ReminderConfig.vue` (new — default reminder rules per event type) + - `src/agentkit/server/frontend/src/components/calendar/SyncSettings.vue` (new — external calendar config) +- **Patterns:** Settings panels follow existing settings component patterns. Notification display uses Ant Design Vue `a-notification` or `a-message` component. +- **ReminderConfig:** + - Per event type: list of default reminder rules (offset + channels) + - Add/remove rules, save to backend + - When new event created with this type, rules auto-inherited +- **SyncSettings:** + - List of configured external calendars (provider, status, last_sync) + - "Add Apple Calendar" → form for CalDAV URL + Apple ID + app-specific password + - "Add Outlook" → OAuth redirect button + - Per-provider: sync frequency, sync scope (which event types), "Sync Now" button, "Test Connection" button + - Conflict notifications displayed as alerts when `calendar_sync_conflict` WS message received (G4) — shows event title, provider, conflict details, and resolution taken (last-write-wins) +- **Notification display:** + - `calendar_reminder` WS message → `a-notification.warning()` with event title + time + - `calendar_invitation` WS message → `a-notification.info()` with inviter name + event title, link to respond (G6) + - `calendar_sync_conflict` WS message → `a-alert` in SyncSettings panel showing conflict details (G4) + - Tauri mode: also trigger system notification via Tauri notification API +- **Test file:** Manual verification + `npm run typecheck` +- **Test scenarios:** + - Type check passes + - Add default reminder rule to "会议" type, verify saved + - Create event of type "会议", verify reminder inherited + - Add Apple Calendar config, test connection (mock success) + - Add Outlook config via OAuth (mock redirect) + - "Sync Now" triggers sync, status updates + - Reminder WS message displays notification + - Invitation WS message displays notification with respond link (G6) + - Conflict WS message displays alert in SyncSettings (G4) +- **Dependencies:** U9, U10 + +--- + +## Key Flows + +- F1. Agent tool-call creates event + - **Trigger:** Agent identifies schedule in ReAct loop + - **Actors:** A2 (Agent), A1 (User) + - **Steps:** Agent calls `CalendarTool.execute(action="create_event", ...)` → service writes to DB with `source="agent"` → WS push `calendar_event_created` to client → right panel highlights new event → Agent tells user "已创建日程" + - **Covers:** R10, R13, R37 + +- F2. Post-processing extraction (keyword-gated) + - **Trigger:** Conversation turn ends, assistant reply persisted + - **Actors:** A2 (post-processing layer), A1 (User) + - **Steps:** `asyncio.create_task(extractor.extract(...))` → regex keyword scan → if no match, return (zero LLM) → if match, LLM extraction → parse JSON → create events with `source="post_extract"` → WS push → insert "检测到 N 条日程" hint in chat + - **Covers:** R11, R12, R14 + +- F3. External calendar sync + - **Trigger:** Scheduled sync task (every N minutes per provider config, run by `SyncManager`) or manual "Sync Now" + - **Actors:** A3 (external service), A1 (User) + - **Steps:** `SyncManager.sync(provider)` → `provider.pull_changes()` (fetch remote, match by external_id, create/update local) → `provider.push_changes()` (push local changes to remote) → conflict detection (last_modified comparison) → last-write-wins → `calendar_sync_conflict` WS notification to user (G4) + - **Covers:** R20, R21, R23 + +- F4. Reminder dispatch + - **Trigger:** Scheduler loop (every 60s) finds event entering reminder window + - **Actors:** A4 (reminder subsystem), A1 (User) + - **Steps:** Query events where `start_time + offset_minutes` in next 60s → check idempotency (delivery exists?) → create `ReminderDelivery` records → dispatch per channel (client WS / email SMTP / webhook HTTP) → update delivery status → retry on failure (up to 3x) + - **Covers:** R25, R26, R27, R29 + +- F5. Manual event creation + - **Trigger:** User clicks "New" or drag-selects time range in calendar view + - **Actors:** A1 (User) + - **Steps:** Open `EventEditor` drawer → fill form (title/time/type/tags/reminders) → save → `calendarApi.createEvent()` → store updates → event appears in view → if external sync configured, async push to external calendar + - **Covers:** R16, R18 + +## Dependencies + +### New Python dependencies (add to `pyproject.toml`) + +| Package | Version | Used by | +|---|---|---| +| `python-dateutil>=2.9` | latest | U1 (RRULE expansion) | +| `icalendar>=6.0` | latest | U6, U7, U8 (ICS format) | +| `caldav>=1.3` | latest | U6 (Apple Calendar sync) | +| `aiosmtplib>=3.0` | latest | U5 (email reminder channel) | + +### New Node dependencies (add to `package.json`) + +| Package | Used by | +|---|---| +| `@fullcalendar/vue3` | U10 (calendar grid) | +| `@fullcalendar/daygrid` | U10 (month view) | +| `@fullcalendar/timegrid` | U10 (week/day view) | +| `@fullcalendar/interaction` | U10 (drag-and-drop) | + +### Existing dependencies reused + +- `httpx>=0.27` — Outlook Graph API calls (U7), webhook reminders (U5) +- `aiosqlite>=0.20` — local storage (U1) +- `pydantic>=2.0` — request/response models (U2) +- `fastapi>=0.110` — REST routes (U2) +- `redis>=5.0` — optional cache for sync tokens (U6, U7) + +### Sequencing + +``` +Phase 1 (Foundation): U1 → U2 +Phase 2 (Agent): U3, U4 (parallel, both depend on U2) +Phase 3 (Reminders): U5 (depends on U1, U2) +Phase 4 (External sync): U8 → U6, U7 (U6/U7 parallel, both depend on U8) +Phase 5 (Frontend base): U9 (depends on U2 API contract) +Phase 6 (Frontend UI): U10 → U11, U12 (U11/U12 parallel, both depend on U10) +``` + +Units within the same phase can be developed in parallel. Phases are sequential — each phase's units depend on the prior phase's output. + +## Risks and Mitigations + +| Risk | Severity | Mitigation | +|---|---|---| +| FullCalendar bundle size impacts frontend load | Medium | Lazy-load calendar components (`defineAsyncComponent`), only load when tab activated | +| CalDAV iCloud requires app-specific password (user friction) | Medium | Document setup steps in UI, provide "How to get app-specific password" link | +| Outlook OAuth flow complexity in Tauri desktop mode | Medium | Use system browser for OAuth (not embedded webview), redirect to localhost callback | +| Post-processing LLM extraction adds latency/cost | Medium | Keyword gate ensures LLM only called when time keywords present; extraction is async, doesn't block chat | +| RRULE edge cases (DST, timezone, Feb 29) | Low | `dateutil.rrule` handles RFC 5545 edge cases; store all times in UTC, convert at display | +| Sync conflict data loss | High | Last-write-wins with conflict notification; never auto-delete, always log | +| Reminder scheduler misses events if server restarts | Medium | On startup, scan for events in reminder window that have no delivery records and dispatch them | + +## Scope Boundaries + +### In scope (this plan) + +- Personal calendar with manual + Agent + post-processing event creation +- Three view modes (calendar grid, card, list) +- Apple Calendar (CalDAV), Outlook (Graph API), iCal/ICS bidirectional sync +- Multi-channel reminder subsystem (client push, email, webhook) +- Event invitations with accept/decline/tentative +- Right panel tab + expandable drawer UX + +### Deferred for later + +- Google Calendar integration (not selected by user, add CalDAV/Google API provider later) +- Team shared calendars (multi-user co-editing same calendar) +- Calendar analytics/reports (event statistics, time distribution) +- Resource booking (meeting rooms, equipment) +- Mobile native app (current is Web/Tauri only) +- Calendar subscription (read-only iCal feed URL for others to subscribe) + +### Outside this product's identity + +- Replacing professional calendar applications (Google Calendar, Notion Calendar) — AgentKit calendar's differentiation is Agent auto-detection, not full-featured calendar competition +- Project management (task tracking, Gantt charts, dependency management) + +## Implementation History + +- **U1-U12 实现完成** (commits `2ea799f`..`394d734`): 12 个实现单元全部交付,104 个单元测试通过,ruff clean,frontend typecheck clean。 +- **Intent router 测试修复** (commit `4ea7801`): 修复预存测试失败——关键词匹配平局应保持列表顺序而非字母序。 +- **代码走查修复** (commit `3fdee65`): 使用 ce-code-review skill 多代理审查,发现 23 个问题(2 Critical / 15 Major / 6 Minor),全部修复。关键修复包括:RRULE 无限循环防护、邀请/事件类型授权校验、SQLite 外键 PRAGMA、DTSTART 时区 bug、同步循环 since 重置、CalDAV 超时、Outlook token 过期检测、Webhook SSRF 防护、ICS 导入 DoS 限制。 diff --git a/pyproject.toml b/pyproject.toml index 738885a..9fa80f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,12 @@ dependencies = [ "pyjwt>=2.8", "bcrypt>=4.0", "aiosqlite>=0.20", + # Calendar & schedule (RRULE expansion) + "python-dateutil>=2.9", + # Calendar ICS import/export (U8) + "icalendar>=5.0", + # Calendar CalDAV sync — Apple Calendar (U6) + "caldav>=1.3", # Document processing (U1-U9) "python-docx>=1.1", "openpyxl>=3.1", diff --git a/src/agentkit/calendar/__init__.py b/src/agentkit/calendar/__init__.py new file mode 100644 index 0000000..da8354b --- /dev/null +++ b/src/agentkit/calendar/__init__.py @@ -0,0 +1,7 @@ +"""Calendar & schedule subsystem. + +Mirrors the ``documents/`` structure: ``models.py`` (dataclass DTOs), +``db.py`` (aiosqlite persistence), ``service.py`` (business logic), +``recurrence.py`` (RRULE expansion), ``scheduler.py`` (reminder loop), +``extraction.py`` (post-processing), and ``sync/`` (external calendars). +""" diff --git a/src/agentkit/calendar/db.py b/src/agentkit/calendar/db.py new file mode 100644 index 0000000..8a4f8af --- /dev/null +++ b/src/agentkit/calendar/db.py @@ -0,0 +1,944 @@ +"""SQLite persistence for calendar data. + +Follows the aiosqlite bare-connection pattern from ``documents/db.py``: +no SQLAlchemy session injection, just ``async with aiosqlite.connect(...)``. +All timestamps are ISO 8601 UTC (see KTD-11). +""" + +from __future__ import annotations + +import json +import logging +import os +from collections.abc import Mapping +from pathlib import Path + +import aiosqlite + +from agentkit.calendar.models import ( + CalendarEvent, + EventType, + ExternalCalendarConfig, + Invitation, + ReminderDelivery, + ReminderRule, + Tag, +) + +logger = logging.getLogger(__name__) + +_PROJECT_ROOT = Path(__file__).parents[3] +DEFAULT_CALENDAR_DB_PATH = Path( + os.environ.get("AGENTKIT_CALENDAR_DB", _PROJECT_ROOT / "data" / "calendar.db") +) + +_SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS calendar_event_types ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#4A90D9', + is_default INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_event_types_user + ON calendar_event_types(user_id); + +CREATE TABLE IF NOT EXISTS calendar_tags ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_tags_user + ON calendar_tags(user_id); + +CREATE TABLE IF NOT EXISTS calendar_events ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + is_all_day INTEGER NOT NULL DEFAULT 0, + location TEXT NOT NULL DEFAULT '', + event_type_id TEXT, + rrule TEXT, + source TEXT NOT NULL DEFAULT 'manual', + is_invited INTEGER NOT NULL DEFAULT 0, + conversation_id TEXT, + external_id TEXT, + external_provider TEXT, + last_modified TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (event_type_id) REFERENCES calendar_event_types(id) +); +CREATE INDEX IF NOT EXISTS idx_events_user + ON calendar_events(user_id); +CREATE INDEX IF NOT EXISTS idx_events_start_time + ON calendar_events(start_time); +CREATE INDEX IF NOT EXISTS idx_events_external + ON calendar_events(external_id, external_provider); + +CREATE TABLE IF NOT EXISTS calendar_event_tags ( + event_id TEXT NOT NULL, + tag_id TEXT NOT NULL, + PRIMARY KEY (event_id, tag_id), + FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES calendar_tags(id) +); + +CREATE TABLE IF NOT EXISTS calendar_reminder_rules ( + id TEXT PRIMARY KEY, + event_id TEXT, + event_type_id TEXT, + offset_minutes INTEGER NOT NULL DEFAULT -15, + channels TEXT NOT NULL DEFAULT '["client"]', + FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE, + FOREIGN KEY (event_type_id) REFERENCES calendar_event_types(id) +); + +CREATE TABLE IF NOT EXISTS calendar_reminder_deliveries ( + id TEXT PRIMARY KEY, + reminder_rule_id TEXT NOT NULL, + event_id TEXT NOT NULL, + scheduled_time TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + channel TEXT NOT NULL DEFAULT 'client', + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + -- ponytail: ON DELETE CASCADE ensures deliveries are removed when their + -- reminder_rule is cascade-deleted (e.g. event deletion). Existing DBs + -- created before this change need ALTER TABLE or DB recreation. + FOREIGN KEY (reminder_rule_id) REFERENCES calendar_reminder_rules(id) ON DELETE CASCADE, + FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_deliveries_status + ON calendar_reminder_deliveries(status, scheduled_time); + +CREATE TABLE IF NOT EXISTS calendar_external_configs ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + provider TEXT NOT NULL, + credentials TEXT NOT NULL DEFAULT '', + sync_frequency INTEGER NOT NULL DEFAULT 30, + sync_scope TEXT NOT NULL DEFAULT '[]', + last_sync TEXT, + sync_token TEXT +); +CREATE INDEX IF NOT EXISTS idx_external_configs_user + ON calendar_external_configs(user_id); + +CREATE TABLE IF NOT EXISTS calendar_invitations ( + id TEXT PRIMARY KEY, + event_id TEXT NOT NULL, + inviter_user_id TEXT NOT NULL, + invitee_email TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + responded_at TEXT, + FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_invitations_email + ON calendar_invitations(invitee_email, status); +""" + + +async def init_calendar_db(db_path: str | Path | None = None) -> Path: + """Create all calendar tables if they do not exist. Idempotent.""" + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + path.parent.mkdir(parents=True, exist_ok=True) + + async with aiosqlite.connect(str(path)) as db: + db.row_factory = aiosqlite.Row + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + await db.executescript(_SCHEMA_SQL) + await db.commit() + + logger.info(f"Calendar DB initialized at {path}") + return path + + +# --------------------------------------------------------------------------- +# Row → dataclass converters +# --------------------------------------------------------------------------- + + +def _row_to_event_type(row: aiosqlite.Row | Mapping[str, object]) -> EventType: + return EventType( + id=row["id"], + user_id=row["user_id"], + name=row["name"], + color=row["color"], + is_default=bool(row["is_default"]), + ) + + +def _row_to_tag(row: aiosqlite.Row | Mapping[str, object]) -> Tag: + return Tag(id=row["id"], user_id=row["user_id"], name=row["name"]) + + +def _row_to_event(row: aiosqlite.Row | Mapping[str, object]) -> CalendarEvent: + return CalendarEvent( + id=row["id"], + user_id=row["user_id"], + title=row["title"], + description=row["description"], + start_time=row["start_time"], + end_time=row["end_time"], + is_all_day=bool(row["is_all_day"]), + location=row["location"], + event_type_id=row["event_type_id"], + rrule=row["rrule"], + source=row["source"], + is_invited=bool(row["is_invited"]), + conversation_id=row["conversation_id"], + external_id=row["external_id"], + external_provider=row["external_provider"], + last_modified=row["last_modified"], + created_at=row["created_at"], + ) + + +def _row_to_reminder_rule(row: aiosqlite.Row | Mapping[str, object]) -> ReminderRule: + return ReminderRule( + id=row["id"], + event_id=row["event_id"], + event_type_id=row["event_type_id"], + offset_minutes=row["offset_minutes"], + channels=json.loads(row["channels"]), + ) + + +def _row_to_reminder_delivery(row: aiosqlite.Row | Mapping[str, object]) -> ReminderDelivery: + return ReminderDelivery( + id=row["id"], + reminder_rule_id=row["reminder_rule_id"], + event_id=row["event_id"], + scheduled_time=row["scheduled_time"], + status=row["status"], + channel=row["channel"], + attempts=row["attempts"], + last_error=row["last_error"], + ) + + +def _row_to_external_config(row: aiosqlite.Row | Mapping[str, object]) -> ExternalCalendarConfig: + return ExternalCalendarConfig( + id=row["id"], + user_id=row["user_id"], + provider=row["provider"], + credentials=row["credentials"], + sync_frequency=row["sync_frequency"], + sync_scope=json.loads(row["sync_scope"]), + last_sync=row["last_sync"], + sync_token=row["sync_token"], + ) + + +def _row_to_invitation(row: aiosqlite.Row | Mapping[str, object]) -> Invitation: + return Invitation( + id=row["id"], + event_id=row["event_id"], + inviter_user_id=row["inviter_user_id"], + invitee_email=row["invitee_email"], + status=row["status"], + responded_at=row["responded_at"], + ) + + +# --------------------------------------------------------------------------- +# Event CRUD +# --------------------------------------------------------------------------- + + +async def insert_event(event: CalendarEvent, db_path: str | Path | None = None) -> None: + """Insert a calendar event.""" + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + await db.execute( + "INSERT INTO calendar_events (id, user_id, title, description, " + "start_time, end_time, is_all_day, location, event_type_id, rrule, " + "source, is_invited, conversation_id, external_id, external_provider, " + "last_modified, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + event.id, + event.user_id, + event.title, + event.description, + event.start_time, + event.end_time, + int(event.is_all_day), + event.location, + event.event_type_id, + event.rrule, + event.source, + int(event.is_invited), + event.conversation_id, + event.external_id, + event.external_provider, + event.last_modified, + event.created_at, + ), + ) + await db.commit() + + +async def get_event(event_id: str, db_path: str | Path | None = None) -> CalendarEvent | None: + """Return a single event by id, or None.""" + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + db.row_factory = aiosqlite.Row + cursor = await db.execute("SELECT * FROM calendar_events WHERE id = ?", (event_id,)) + row = await cursor.fetchone() + return _row_to_event(row) if row else None + + +async def get_event_by_external_id( + external_id: str, + external_provider: str, + user_id: str, + db_path: str | Path | None = None, +) -> CalendarEvent | None: + """Return a single event by (external_id, provider, user_id), or None. + + Used by ICS import (U8) to skip duplicate UIDs already imported. + """ + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT * FROM calendar_events " + "WHERE external_id = ? AND external_provider = ? AND user_id = ?", + (external_id, external_provider, user_id), + ) + row = await cursor.fetchone() + return _row_to_event(row) if row else None + + +async def list_events( + user_id: str, + start: str | None = None, + end: str | None = None, + event_type_id: str | None = None, + tag_id: str | None = None, + db_path: str | Path | None = None, +) -> list[CalendarEvent]: + """List events for a user with optional filters.""" + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + query = "SELECT DISTINCT e.* FROM calendar_events e" + params: list[str] = [] + conditions: list[str] = ["e.user_id = ?"] + params.append(user_id) + + if start is not None: + conditions.append("e.start_time >= ?") + params.append(start) + if end is not None: + conditions.append("e.start_time < ?") + params.append(end) + if event_type_id is not None: + conditions.append("e.event_type_id = ?") + params.append(event_type_id) + if tag_id is not None: + query += " JOIN calendar_event_tags et ON et.event_id = e.id" + conditions.append("et.tag_id = ?") + params.append(tag_id) + + query += " WHERE " + " AND ".join(conditions) + " ORDER BY e.start_time" + + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + db.row_factory = aiosqlite.Row + cursor = await db.execute(query, tuple(params)) + rows = await cursor.fetchall() + return [_row_to_event(row) for row in rows] + + +async def list_all_events_in_time_range( + start: str, end: str, db_path: str | Path | None = None +) -> list[CalendarEvent]: + """List all events (across all users) with start_time in [start, end). + + Used by ReminderScheduler to scan for events entering the reminder window. + """ + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT * FROM calendar_events WHERE start_time >= ? AND start_time < ? " + "ORDER BY start_time", + (start, end), + ) + rows = await cursor.fetchall() + return [_row_to_event(row) for row in rows] + + +async def update_event( + event_id: str, fields: dict[str, object], db_path: str | Path | None = None +) -> bool: + """Update specific fields of an event. Returns True if a row was updated.""" + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + # Map dataclass field names to column names, handle bool → int + column_map = { + "title": "title", + "description": "description", + "start_time": "start_time", + "end_time": "end_time", + "is_all_day": "is_all_day", + "location": "location", + "event_type_id": "event_type_id", + "rrule": "rrule", + "source": "source", + "is_invited": "is_invited", + "conversation_id": "conversation_id", + "external_id": "external_id", + "external_provider": "external_provider", + "last_modified": "last_modified", + } + set_clauses: list[str] = [] + params: list[object] = [] + for field_name, value in fields.items(): + col = column_map.get(field_name) + if col is None: + continue + if field_name in ("is_all_day", "is_invited"): + value = int(bool(value)) + set_clauses.append(f"{col} = ?") + params.append(value) + + if not set_clauses: + return False + + params.append(event_id) + sql = f"UPDATE calendar_events SET {', '.join(set_clauses)} WHERE id = ?" + + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + cursor = await db.execute(sql, tuple(params)) + await db.commit() + return cursor.rowcount > 0 + + +async def delete_event(event_id: str, db_path: str | Path | None = None) -> bool: + """Delete an event and its dependent rows. Returns True if deleted.""" + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + # Manual cascade for event_tags (no ON DELETE on junction FK in some SQLite versions) + await db.execute("DELETE FROM calendar_event_tags WHERE event_id = ?", (event_id,)) + cursor = await db.execute("DELETE FROM calendar_events WHERE id = ?", (event_id,)) + await db.commit() + return cursor.rowcount > 0 + + +# --------------------------------------------------------------------------- +# Event Type CRUD +# --------------------------------------------------------------------------- + + +async def insert_event_type(et: EventType, db_path: str | Path | None = None) -> None: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + await db.execute( + "INSERT INTO calendar_event_types (id, user_id, name, color, is_default) " + "VALUES (?, ?, ?, ?, ?)", + (et.id, et.user_id, et.name, et.color, int(et.is_default)), + ) + await db.commit() + + +async def list_event_types(user_id: str, db_path: str | Path | None = None) -> list[EventType]: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT * FROM calendar_event_types WHERE user_id = ? ORDER BY name", + (user_id,), + ) + rows = await cursor.fetchall() + return [_row_to_event_type(row) for row in rows] + + +async def update_event_type( + type_id: str, fields: dict[str, object], db_path: str | Path | None = None +) -> bool: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + set_clauses: list[str] = [] + params: list[object] = [] + for field_name, value in fields.items(): + if field_name == "name": + set_clauses.append("name = ?") + params.append(value) + elif field_name == "color": + set_clauses.append("color = ?") + params.append(value) + elif field_name == "is_default": + set_clauses.append("is_default = ?") + params.append(int(bool(value))) + if not set_clauses: + return False + params.append(type_id) + sql = f"UPDATE calendar_event_types SET {', '.join(set_clauses)} WHERE id = ?" + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + cursor = await db.execute(sql, tuple(params)) + await db.commit() + return cursor.rowcount > 0 + + +async def delete_event_type(type_id: str, db_path: str | Path | None = None) -> bool: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + cursor = await db.execute("DELETE FROM calendar_event_types WHERE id = ?", (type_id,)) + await db.commit() + return cursor.rowcount > 0 + + +# --------------------------------------------------------------------------- +# Tag CRUD +# --------------------------------------------------------------------------- + + +async def insert_tag(tag: Tag, db_path: str | Path | None = None) -> None: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + await db.execute( + "INSERT INTO calendar_tags (id, user_id, name) VALUES (?, ?, ?)", + (tag.id, tag.user_id, tag.name), + ) + await db.commit() + + +async def list_tags(user_id: str, db_path: str | Path | None = None) -> list[Tag]: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT * FROM calendar_tags WHERE user_id = ? ORDER BY name", + (user_id,), + ) + rows = await cursor.fetchall() + return [_row_to_tag(row) for row in rows] + + +async def delete_tag(tag_id: str, db_path: str | Path | None = None) -> bool: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + await db.execute("DELETE FROM calendar_event_tags WHERE tag_id = ?", (tag_id,)) + cursor = await db.execute("DELETE FROM calendar_tags WHERE id = ?", (tag_id,)) + await db.commit() + return cursor.rowcount > 0 + + +# --------------------------------------------------------------------------- +# Event-Tag junction +# --------------------------------------------------------------------------- + + +async def add_tag_to_event(event_id: str, tag_id: str, db_path: str | Path | None = None) -> None: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + await db.execute( + "INSERT OR IGNORE INTO calendar_event_tags (event_id, tag_id) VALUES (?, ?)", + (event_id, tag_id), + ) + await db.commit() + + +async def remove_tag_from_event( + event_id: str, tag_id: str, db_path: str | Path | None = None +) -> None: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + await db.execute( + "DELETE FROM calendar_event_tags WHERE event_id = ? AND tag_id = ?", + (event_id, tag_id), + ) + await db.commit() + + +async def get_event_tags(event_id: str, db_path: str | Path | None = None) -> list[Tag]: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT t.* FROM calendar_tags t " + "JOIN calendar_event_tags et ON et.tag_id = t.id " + "WHERE et.event_id = ?", + (event_id,), + ) + rows = await cursor.fetchall() + return [_row_to_tag(row) for row in rows] + + +# --------------------------------------------------------------------------- +# Reminder Rule CRUD +# --------------------------------------------------------------------------- + + +async def insert_reminder_rule(rule: ReminderRule, db_path: str | Path | None = None) -> None: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + await db.execute( + "INSERT INTO calendar_reminder_rules " + "(id, event_id, event_type_id, offset_minutes, channels) " + "VALUES (?, ?, ?, ?, ?)", + ( + rule.id, + rule.event_id, + rule.event_type_id, + rule.offset_minutes, + json.dumps(rule.channels), + ), + ) + await db.commit() + + +async def list_reminder_rules_for_event( + event_id: str, db_path: str | Path | None = None +) -> list[ReminderRule]: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT * FROM calendar_reminder_rules WHERE event_id = ?", + (event_id,), + ) + rows = await cursor.fetchall() + return [_row_to_reminder_rule(row) for row in rows] + + +async def list_reminder_rules_for_type( + event_type_id: str, db_path: str | Path | None = None +) -> list[ReminderRule]: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT * FROM calendar_reminder_rules WHERE event_type_id = ?", + (event_type_id,), + ) + rows = await cursor.fetchall() + return [_row_to_reminder_rule(row) for row in rows] + + +async def delete_reminder_rule(rule_id: str, db_path: str | Path | None = None) -> bool: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + cursor = await db.execute("DELETE FROM calendar_reminder_rules WHERE id = ?", (rule_id,)) + await db.commit() + return cursor.rowcount > 0 + + +# --------------------------------------------------------------------------- +# Reminder Delivery CRUD +# --------------------------------------------------------------------------- + + +async def insert_reminder_delivery( + delivery: ReminderDelivery, db_path: str | Path | None = None +) -> None: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + await db.execute( + "INSERT INTO calendar_reminder_deliveries " + "(id, reminder_rule_id, event_id, scheduled_time, status, channel, " + "attempts, last_error) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + delivery.id, + delivery.reminder_rule_id, + delivery.event_id, + delivery.scheduled_time, + delivery.status, + delivery.channel, + delivery.attempts, + delivery.last_error, + ), + ) + await db.commit() + + +async def get_pending_deliveries( + event_id: str, + reminder_rule_id: str, + db_path: str | Path | None = None, + status: str = "sent", +) -> list[ReminderDelivery]: + """Check idempotency — return existing deliveries for an event+rule. + + By default only ``sent`` deliveries are returned, so the scheduler's + idempotency check skips rules that already succeeded. Stuck ``pending`` + or ``failed`` deliveries are ignored, allowing retry on the next scan. + """ + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT * FROM calendar_reminder_deliveries " + "WHERE event_id = ? AND reminder_rule_id = ? AND status = ?", + (event_id, reminder_rule_id, status), + ) + rows = await cursor.fetchall() + return [_row_to_reminder_delivery(row) for row in rows] + + +async def update_delivery_status( + delivery_id: str, + status: str, + last_error: str | None = None, + db_path: str | Path | None = None, +) -> bool: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + cursor = await db.execute( + "UPDATE calendar_reminder_deliveries " + "SET status = ?, attempts = attempts + 1, last_error = ? " + "WHERE id = ?", + (status, last_error, delivery_id), + ) + await db.commit() + return cursor.rowcount > 0 + + +# --------------------------------------------------------------------------- +# External Calendar Config CRUD +# --------------------------------------------------------------------------- + + +async def insert_external_config( + config: ExternalCalendarConfig, db_path: str | Path | None = None +) -> None: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + await db.execute( + "INSERT INTO calendar_external_configs " + "(id, user_id, provider, credentials, sync_frequency, sync_scope, " + "last_sync, sync_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + config.id, + config.user_id, + config.provider, + config.credentials, + config.sync_frequency, + json.dumps(config.sync_scope), + config.last_sync, + config.sync_token, + ), + ) + await db.commit() + + +async def list_external_configs( + user_id: str, db_path: str | Path | None = None +) -> list[ExternalCalendarConfig]: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT * FROM calendar_external_configs WHERE user_id = ?", + (user_id,), + ) + rows = await cursor.fetchall() + return [_row_to_external_config(row) for row in rows] + + +async def update_external_config( + config_id: str, fields: dict[str, object], db_path: str | Path | None = None +) -> bool: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + set_clauses: list[str] = [] + params: list[object] = [] + for field_name, value in fields.items(): + if field_name == "credentials": + set_clauses.append("credentials = ?") + params.append(value) + elif field_name == "sync_frequency": + set_clauses.append("sync_frequency = ?") + params.append(value) + elif field_name == "sync_scope": + set_clauses.append("sync_scope = ?") + params.append(json.dumps(value)) + elif field_name == "last_sync": + set_clauses.append("last_sync = ?") + params.append(value) + elif field_name == "sync_token": + set_clauses.append("sync_token = ?") + params.append(value) + if not set_clauses: + return False + params.append(config_id) + sql = f"UPDATE calendar_external_configs SET {', '.join(set_clauses)} WHERE id = ?" + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + cursor = await db.execute(sql, tuple(params)) + await db.commit() + return cursor.rowcount > 0 + + +async def delete_external_config(config_id: str, db_path: str | Path | None = None) -> bool: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + cursor = await db.execute( + "DELETE FROM calendar_external_configs WHERE id = ?", (config_id,) + ) + await db.commit() + return cursor.rowcount > 0 + + +# --------------------------------------------------------------------------- +# Invitation CRUD +# --------------------------------------------------------------------------- + + +async def insert_invitation(invitation: Invitation, db_path: str | Path | None = None) -> None: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + await db.execute( + "INSERT INTO calendar_invitations " + "(id, event_id, inviter_user_id, invitee_email, status, responded_at) " + "VALUES (?, ?, ?, ?, ?, ?)", + ( + invitation.id, + invitation.event_id, + invitation.inviter_user_id, + invitation.invitee_email, + invitation.status, + invitation.responded_at, + ), + ) + await db.commit() + + +async def get_invitation( + invitation_id: str, db_path: str | Path | None = None +) -> Invitation | None: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT * FROM calendar_invitations WHERE id = ?", (invitation_id,) + ) + row = await cursor.fetchone() + return _row_to_invitation(row) if row else None + + +async def list_invitations( + invitee_email: str, db_path: str | Path | None = None +) -> list[Invitation]: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT * FROM calendar_invitations WHERE invitee_email = ? ORDER BY responded_at DESC", + (invitee_email,), + ) + rows = await cursor.fetchall() + return [_row_to_invitation(row) for row in rows] + + +async def update_invitation_status( + invitation_id: str, + status: str, + responded_at: str, + db_path: str | Path | None = None, +) -> bool: + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA busy_timeout = 5000") + await db.execute("PRAGMA foreign_keys = ON") + cursor = await db.execute( + "UPDATE calendar_invitations SET status = ?, responded_at = ? WHERE id = ?", + (status, responded_at, invitation_id), + ) + await db.commit() + return cursor.rowcount > 0 diff --git a/src/agentkit/calendar/extraction.py b/src/agentkit/calendar/extraction.py new file mode 100644 index 0000000..41ac0d5 --- /dev/null +++ b/src/agentkit/calendar/extraction.py @@ -0,0 +1,129 @@ +"""Post-processing extraction of schedule info from conversation text. + +Two-stage approach (U4): +1. Zero-LLM regex keyword gate — skip LLM entirely if no time-related keywords. +2. LLM extraction — call the LLM gateway to pull structured event data. + +Extracted events are persisted via ``CalendarService.create_event`` with +``source="post_extract"`` and the originating ``conversation_id`` for +traceability (R15). +""" + +from __future__ import annotations + +import json +import logging +import re + +from agentkit.calendar.service import CalendarService + +logger = logging.getLogger(__name__) + + +class PostProcessingExtractor: + """Extract schedule info from conversation text after a chat turn. + + Two-stage: regex keyword gate (zero LLM) → LLM extraction. + """ + + # Time-related keywords that trigger LLM extraction + _KEYWORD_RE = re.compile( + r"明天|后天|下周|本周|今天下午|今天上午|上午|下午|晚上|" + r"\d+点|\d+月\d+日|\d+号|开会|截止|deadline|schedule|" + r"reminder|提醒|预约|约定|安排", + re.IGNORECASE, + ) + + def __init__(self, calendar_service: CalendarService, llm_gateway=None): + self.service = calendar_service + self.llm_gateway = llm_gateway # Optional, may be set later + + async def extract( + self, + conversation_text: str, + conversation_id: str, + user_id: str, + ) -> list[dict]: + """Extract events from conversation text. + + Returns list of created event dicts. Empty if no keywords or no events extracted. + Never raises — all failures are logged and swallowed. + """ + # 1. Keyword gate — zero LLM cost if no match + if not self._KEYWORD_RE.search(conversation_text): + return [] + + # 2. LLM extraction + events_data = await self._llm_extract(conversation_text) + if not events_data: + return [] + + # 3. Create events with source="post_extract" + created = [] + for event_data in events_data: + try: + event = await self.service.create_event( + user_id=user_id, + title=event_data.get("title", ""), + start_time=event_data.get("start_time", ""), + end_time=event_data.get("end_time", ""), + description=event_data.get("description", ""), + source="post_extract", + conversation_id=conversation_id, + ) + created.append(event.to_dict()) + except Exception as e: + logger.warning(f"Failed to create extracted event: {e}") + continue + + return created + + async def _llm_extract(self, text: str) -> list[dict]: + """Call LLM gateway to extract events from text. + + Returns list of event dicts: [{title, start_time, end_time, description}]. + Returns [] on any error or empty result. + """ + if self.llm_gateway is None: + return [] + + prompt = self._build_extraction_prompt(text) + try: + response = await self.llm_gateway.acomplete( + messages=[{"role": "user", "content": prompt}], + temperature=0.1, + ) + return self._parse_llm_response(response) + except Exception as e: + logger.warning(f"LLM extraction failed: {e}") + return [] + + def _build_extraction_prompt(self, text: str) -> str: + """Build the LLM extraction prompt.""" + return f"""Extract schedule/event information from the following conversation text. +Return a JSON array of events. Each event should have: title, start_time (ISO 8601), end_time (ISO 8601), description. +If no events are found, return an empty array []. + +Conversation text: +{text} + +Respond with ONLY the JSON array, no other text.""" + + def _parse_llm_response(self, response: str) -> list[dict]: + """Parse LLM response as JSON array. Returns [] on any error.""" + try: + # Strip markdown code fences if present + cleaned = response.strip() + if cleaned.startswith("```"): + cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned[3:] + if cleaned.endswith("```"): + cleaned = cleaned[:-3] + cleaned = cleaned.strip() + + data = json.loads(cleaned) + if not isinstance(data, list): + return [] + return [item for item in data if isinstance(item, dict) and "title" in item] + except (json.JSONDecodeError, TypeError) as e: + logger.warning(f"Failed to parse LLM response as JSON: {e}") + return [] diff --git a/src/agentkit/calendar/models.py b/src/agentkit/calendar/models.py new file mode 100644 index 0000000..ef2abeb --- /dev/null +++ b/src/agentkit/calendar/models.py @@ -0,0 +1,221 @@ +"""Data models for the calendar subsystem. + +All dataclasses are DTOs carried between CalendarService, Agent tools, +REST routes, and the frontend. They mirror the ``calendar_*`` DB rows. +All timestamps are ISO 8601 UTC (see KTD-11). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone + + +def _now_iso() -> str: + """Return current UTC time as ISO 8601 string.""" + return datetime.now(timezone.utc).isoformat() + + +@dataclass +class EventType: + """User-defined event type (e.g. "会议", "截止", "个人").""" + + id: str + user_id: str + name: str + color: str = "#4A90D9" + is_default: bool = False + + def to_dict(self) -> dict[str, object]: + return { + "id": self.id, + "user_id": self.user_id, + "name": self.name, + "color": self.color, + "is_default": self.is_default, + } + + +@dataclass +class Tag: + """User-defined tag for events.""" + + id: str + user_id: str + name: str + + def to_dict(self) -> dict[str, object]: + return {"id": self.id, "user_id": self.user_id, "name": self.name} + + +@dataclass +class CalendarEvent: + """A calendar event. + + Attributes: + source: "manual" | "agent" | "post_extract" — origin traceability (R15). + is_invited: True if this event arrived via invitation (R33 — special styling). + rrule: RFC 5545 RRULE string, e.g. "FREQ=WEEKLY;BYDAY=MO;COUNT=10". + external_id: ID in external calendar (for sync). + external_provider: "caldav" | "outlook" | None. + last_modified: ISO 8601 UTC, for conflict resolution (last-write-wins). + """ + + id: str + user_id: str + title: str + description: str = "" + start_time: str = "" # ISO 8601 UTC (KTD-11) + end_time: str = "" # ISO 8601 UTC + is_all_day: bool = False + location: str = "" + event_type_id: str | None = None + rrule: str | None = None + source: str = "manual" # "manual" | "agent" | "post_extract" + is_invited: bool = False + conversation_id: str | None = None + external_id: str | None = None + external_provider: str | None = None + last_modified: str = "" + created_at: str = "" + + def to_dict(self) -> dict[str, object]: + return { + "id": self.id, + "user_id": self.user_id, + "title": self.title, + "description": self.description, + "start_time": self.start_time, + "end_time": self.end_time, + "is_all_day": self.is_all_day, + "location": self.location, + "event_type_id": self.event_type_id, + "rrule": self.rrule, + "source": self.source, + "is_invited": self.is_invited, + "conversation_id": self.conversation_id, + "external_id": self.external_id, + "external_provider": self.external_provider, + "last_modified": self.last_modified, + "created_at": self.created_at, + } + + +@dataclass +class EventTag: + """Many-to-many junction between events and tags.""" + + event_id: str + tag_id: str + + def to_dict(self) -> dict[str, object]: + return {"event_id": self.event_id, "tag_id": self.tag_id} + + +@dataclass +class ReminderRule: + """Reminder rule — per-event or per-event-type default. + + Attributes: + event_id: FK to events (nullable for type-level defaults). + event_type_id: FK to event_types (for default reminders). + offset_minutes: -15 = 15 min before, -1440 = 1 day before. + channels: ["client", "email", "webhook"]. + """ + + id: str + event_id: str | None = None + event_type_id: str | None = None + offset_minutes: int = -15 + channels: list[str] = field(default_factory=lambda: ["client"]) + + def to_dict(self) -> dict[str, object]: + return { + "id": self.id, + "event_id": self.event_id, + "event_type_id": self.event_type_id, + "offset_minutes": self.offset_minutes, + "channels": self.channels, + } + + +@dataclass +class ReminderDelivery: + """Tracks delivery status of a reminder instance.""" + + id: str + reminder_rule_id: str + event_id: str + scheduled_time: str # ISO 8601 UTC + status: str = "pending" # "pending" | "sent" | "failed" | "read" + channel: str = "client" + attempts: int = 0 + last_error: str | None = None + + def to_dict(self) -> dict[str, object]: + return { + "id": self.id, + "reminder_rule_id": self.reminder_rule_id, + "event_id": self.event_id, + "scheduled_time": self.scheduled_time, + "status": self.status, + "channel": self.channel, + "attempts": self.attempts, + "last_error": self.last_error, + } + + +@dataclass +class ExternalCalendarConfig: + """Configuration for an external calendar sync provider. + + Attributes: + provider: "caldav" | "outlook". + credentials: encrypted JSON (CalDAV URL+user+app_password, or OAuth refresh_token). + sync_frequency: sync interval in minutes. + sync_scope: event type IDs to sync, empty = all. + sync_token: delta token for incremental sync. + """ + + id: str + user_id: str + provider: str # "caldav" | "outlook" + credentials: str = "" # encrypted JSON + sync_frequency: int = 30 # minutes + sync_scope: list[str] = field(default_factory=list) + last_sync: str | None = None + sync_token: str | None = None + + def to_dict(self) -> dict[str, object]: + return { + "id": self.id, + "user_id": self.user_id, + "provider": self.provider, + "credentials": "***", # Never expose credentials + "sync_frequency": self.sync_frequency, + "sync_scope": self.sync_scope, + "last_sync": self.last_sync, + "sync_token": self.sync_token, + } + + +@dataclass +class Invitation: + """Event invitation from one user to another.""" + + id: str + event_id: str + inviter_user_id: str + invitee_email: str + status: str = "pending" # "pending" | "accepted" | "declined" | "tentative" + responded_at: str | None = None + + def to_dict(self) -> dict[str, object]: + return { + "id": self.id, + "event_id": self.event_id, + "inviter_user_id": self.inviter_user_id, + "invitee_email": self.invitee_email, + "status": self.status, + "responded_at": self.responded_at, + } diff --git a/src/agentkit/calendar/recurrence.py b/src/agentkit/calendar/recurrence.py new file mode 100644 index 0000000..1dd0c76 --- /dev/null +++ b/src/agentkit/calendar/recurrence.py @@ -0,0 +1,72 @@ +"""RRULE recurrence expansion wrapper. + +Uses ``dateutil.rrule`` for RFC 5545 compliant recurrence rule expansion. +All times are UTC (see KTD-11). +""" + +from __future__ import annotations + +import itertools +from datetime import datetime, timezone + +from dateutil.rrule import rrulestr + + +def _parse_dt(dt_str: str) -> datetime: + """Parse ISO 8601 string to timezone-aware datetime (UTC).""" + dt = datetime.fromisoformat(dt_str) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +def expand_rrule( + rrule_str: str | None, + dtstart: str, + range_start: str | None = None, + range_end: str | None = None, +) -> list[str]: + """Expand a recurrence rule into individual occurrence start times. + + Args: + rrule_str: RFC 5545 RRULE string (e.g. "FREQ=WEEKLY;BYDAY=MO;COUNT=4"). + If None or empty, returns ``[dtstart]``. + dtstart: ISO 8601 start time of the first occurrence (UTC). + range_start: Optional ISO 8601 lower bound (inclusive) for filtering. + range_end: Optional ISO 8601 upper bound (exclusive) for filtering. + + Returns: + List of ISO 8601 UTC strings for each occurrence's start time + within the given range. If no range is specified, returns all + occurrences (bounded by COUNT or UNTIL in the RRULE). + """ + if not rrule_str: + return [dtstart] + + start_dt = _parse_dt(dtstart) + + # rrulestr expects the RRULE to have a DTSTART context. + # We prepend DTSTART to ensure the rule starts from the event's start time. + full_rule = ( + f"DTSTART:{start_dt.astimezone(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}\nRRULE:{rrule_str}" + ) + rule = rrulestr(full_rule) + + if range_start is not None and range_end is not None: + rs = _parse_dt(range_start) + re_ = _parse_dt(range_end) + # Half-open interval [start, end) — standard date-range convention + occurrences = [dt for dt in rule if rs <= dt < re_] + elif range_start is not None: + rs = _parse_dt(range_start) + occurrences = [dt for dt in rule if dt >= rs] + elif range_end is not None: + re_ = _parse_dt(range_end) + occurrences = [dt for dt in rule if dt < re_] + else: + # ponytail: 1000-occurrence ceiling for unbounded rules (FREQ=DAILY + # without COUNT/UNTIL). Upgrade path: accept a max_occurrences param. + occurrences = list(itertools.islice(rule, 1000)) + + # Convert back to ISO 8601 UTC strings + return [dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S+00:00") for dt in occurrences] diff --git a/src/agentkit/calendar/reminders.py b/src/agentkit/calendar/reminders.py new file mode 100644 index 0000000..f9b033f --- /dev/null +++ b/src/agentkit/calendar/reminders.py @@ -0,0 +1,154 @@ +"""Reminder dispatcher — multi-channel delivery (client push / email / webhook). + +Strategy pattern: one method per channel. External dependencies (WS sender, +SMTP config, webhook URL) are injected so tests can mock them without +patching module imports. +""" + +from __future__ import annotations + +import ipaddress +import logging +import socket +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from urllib.parse import urlparse + +from agentkit.calendar.models import CalendarEvent + +logger = logging.getLogger(__name__) + + +@dataclass +class SmtpConfig: + """SMTP server configuration for the email reminder channel.""" + + host: str = "localhost" + # ponytail: STARTTLS on 587 is the modern default; plaintext port 25 is + # only appropriate for local MTA relays. Upgrade: implicit TLS on 465. + port: int = 587 + username: str | None = None + password: str | None = None + use_tls: bool = True + from_email: str = "noreply@agentkit.local" + + +def _is_safe_webhook_url(url: str) -> bool: + """Validate webhook URL to prevent SSRF attacks. + + ponytail: Basic SSRF guard — blocks private/internal IP ranges (RFC 1918), + loopback, link-local (169.254.x includes cloud metadata endpoints), and + non-http(s) schemes. Uses blocking socket.getaddrinfo; upgrade to + asyncio.getaddrinfo if webhook dispatch is on a hot path. + """ + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + return False + hostname = parsed.hostname + if not hostname: + return False + if hostname == "localhost" or hostname.startswith("169.254."): + return False + try: + infos = socket.getaddrinfo(hostname, None) + for _, _, _, _, sockaddr in infos: + addr = sockaddr[0] + try: + ip = ipaddress.ip_address(addr) + except ValueError: + continue + if ip.is_private or ip.is_loopback or ip.is_link_local: + return False + except socket.gaierror: + pass # DNS resolution failed — let httpx handle the connection error + return True + + +class ReminderDispatcher: + """Dispatch reminders via client push, email, and webhook channels. + + Args: + ws_sender: Async callback ``(user_id, message_dict) -> None`` for client + push. The callback implementation is responsible for resolving + ``user_id`` to an active WebSocket session. + smtp_config: SMTP settings for the email channel. ``None`` disables email. + webhook_url: URL for the webhook channel. ``None`` disables webhook. + get_user_email: Async callback ``user_id -> email | None`` used to + resolve the recipient address for email reminders. + """ + + def __init__( + self, + ws_sender: Callable[[str, dict[str, object]], Awaitable[None]] | None = None, + smtp_config: SmtpConfig | None = None, + webhook_url: str | None = None, + get_user_email: Callable[[str], Awaitable[str | None]] | None = None, + ) -> None: + self._ws_sender = ws_sender + self._smtp_config = smtp_config + self._webhook_url = webhook_url + self._get_user_email = get_user_email + + async def dispatch(self, channel: str, event: CalendarEvent, user_id: str) -> bool: + """Send a reminder via *channel*. Returns ``True`` on success.""" + if channel == "client": + return await self._send_client(event, user_id) + if channel == "email": + return await self._send_email(event, user_id) + if channel == "webhook": + return await self._send_webhook(event, user_id) + logger.warning("Unknown reminder channel: %s", channel) + return False + + async def _send_client(self, event: CalendarEvent, user_id: str) -> bool: + if self._ws_sender is None: + return False + await self._ws_sender( + user_id, + {"type": "calendar_reminder", "data": event.to_dict()}, + ) + return True + + async def _send_email(self, event: CalendarEvent, user_id: str) -> bool: + if self._smtp_config is None or self._get_user_email is None: + return False + email = await self._get_user_email(user_id) + if not email: + return False + try: + import aiosmtplib + except ImportError: + # ponytail: aiosmtplib is an optional dep — email channel silently + # disabled when not installed. Upgrade: add aiosmtplib to pyproject.toml. + logger.debug("aiosmtplib not installed — skipping email reminder") + return False + message = ( + f"From: {self._smtp_config.from_email}\r\n" + f"To: {email}\r\n" + f"Subject: Reminder: {event.title}\r\n\r\n" + f"{event.title} starts at {event.start_time}.\r\n" + ) + await aiosmtplib.send( + message, + hostname=self._smtp_config.host, + port=self._smtp_config.port, + username=self._smtp_config.username, + password=self._smtp_config.password, + start_tls=self._smtp_config.use_tls, + ) + return True + + async def _send_webhook(self, event: CalendarEvent, user_id: str) -> bool: + if self._webhook_url is None: + return False + if not _is_safe_webhook_url(self._webhook_url): + logger.warning("Webhook URL blocked: private/internal address") + return False + import httpx + + async with httpx.AsyncClient() as client: + resp = await client.post( + self._webhook_url, + json={"event": event.to_dict(), "user_id": user_id}, + ) + return resp.status_code < 400 diff --git a/src/agentkit/calendar/scheduler.py b/src/agentkit/calendar/scheduler.py new file mode 100644 index 0000000..41ce3c9 --- /dev/null +++ b/src/agentkit/calendar/scheduler.py @@ -0,0 +1,174 @@ +"""Reminder scheduler — background loop that scans upcoming events and +dispatches reminders via :class:`ReminderDispatcher`. + +Follows the ``start()``/``stop()`` + ``asyncio.create_task`` loop pattern from +``server/task_store.py`` (KTD-2 — conscious deviation from APScheduler). + +ponytail: app.py lifespan wiring is deferred — the orchestrator will call +``start()``/``stop()`` when integrating into the application lifecycle. + +ponytail: ``asyncio.sleep`` polling has second-level precision. If sub-second +scheduling is needed, upgrade to APScheduler. +""" + +from __future__ import annotations + +import asyncio +import logging +import uuid +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from agentkit.calendar.db import ( + DEFAULT_CALENDAR_DB_PATH, + get_pending_deliveries, + insert_reminder_delivery, + list_all_events_in_time_range, + list_reminder_rules_for_event, + update_delivery_status, +) +from agentkit.calendar.models import CalendarEvent, ReminderDelivery, ReminderRule +from agentkit.calendar.reminders import ReminderDispatcher + +logger = logging.getLogger(__name__) + + +def _parse_dt(dt_str: str) -> datetime: + """Parse ISO 8601 string to timezone-aware datetime (UTC).""" + dt = datetime.fromisoformat(dt_str) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +class ReminderScheduler: + """Background scheduler that scans for events entering the reminder window + and dispatches via the configured channels. + """ + + def __init__( + self, + db_path: str | Path | None = None, + dispatcher: ReminderDispatcher | None = None, + interval_seconds: int = 60, + lookback_seconds: int = 3600, + max_retries: int = 3, + retry_base_delay: float = 1.0, + ) -> None: + self._db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + self._dispatcher = dispatcher or ReminderDispatcher() + self._interval = interval_seconds + self._lookback = lookback_seconds + self._max_retries = max_retries + self._retry_base_delay = retry_base_delay + self._task: asyncio.Task[None] | None = None + + async def start(self) -> None: + """Start the background scan loop.""" + if self._task is None: + self._task = asyncio.create_task(self._loop()) + + async def stop(self) -> None: + """Cancel the background scan loop.""" + if self._task is not None: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def _loop(self) -> None: + """Main scan loop — runs until cancelled.""" + while True: + try: + await self.scan_once() + except asyncio.CancelledError: + break + except Exception: + logger.exception("Reminder scheduler scan error") + await asyncio.sleep(self._interval) + + async def scan_once(self) -> int: + """Run a single scan cycle. Returns the number of deliveries created. + + Public method so tests can invoke a single scan without waiting for the + loop interval. + """ + now = datetime.now(timezone.utc) + window_start = now - timedelta(seconds=self._lookback) + window_end = now + timedelta(seconds=self._interval) + + # Query a broad range of events — the reminder_time filter happens below. + # ponytail: recurring event reminder expansion is not handled here; only + # the event's stored start_time is used. Upgrade: expand RRULE occurrences + # and check each occurrence's reminder time. + query_start = (now - timedelta(hours=2)).isoformat() + query_end = (now + timedelta(hours=48)).isoformat() + events = await list_all_events_in_time_range(query_start, query_end, self._db_path) + + dispatched = 0 + for event in events: + rules = await list_reminder_rules_for_event(event.id, self._db_path) + for rule in rules: + reminder_time = _parse_dt(event.start_time) + timedelta(minutes=rule.offset_minutes) + if window_start <= reminder_time <= window_end: + dispatched += await self._process_reminder(event, rule, reminder_time) + return dispatched + + async def _process_reminder( + self, + event: CalendarEvent, + rule: ReminderRule, + reminder_time: datetime, + ) -> int: + """Create delivery records and dispatch. Returns count of deliveries created. + + Idempotent: if any delivery already exists for this event+rule, skip. + """ + existing = await get_pending_deliveries(event.id, rule.id, self._db_path, status="sent") + if existing: + return 0 + + created = 0 + for channel in rule.channels: + delivery = ReminderDelivery( + id=uuid.uuid4().hex, + reminder_rule_id=rule.id, + event_id=event.id, + scheduled_time=reminder_time.isoformat(), + status="pending", + channel=channel, + attempts=0, + last_error=None, + ) + await insert_reminder_delivery(delivery, self._db_path) + created += 1 + await self._dispatch_with_retry(event, delivery) + return created + + async def _dispatch_with_retry( + self, + event: CalendarEvent, + delivery: ReminderDelivery, + ) -> bool: + """Attempt dispatch up to ``max_retries`` times with exponential backoff. + + Updates the delivery record's ``attempts`` counter after each try. + Returns ``True`` on success, ``False`` if all retries exhausted. + """ + for attempt in range(self._max_retries): + error: str | None = None + try: + success = await self._dispatcher.dispatch(delivery.channel, event, event.user_id) + if success: + await update_delivery_status(delivery.id, "sent", None, self._db_path) + return True + except Exception as exc: + error = str(exc) + + await update_delivery_status(delivery.id, "failed", error, self._db_path) + + if attempt < self._max_retries - 1: + await asyncio.sleep(self._retry_base_delay * (2**attempt)) + return False diff --git a/src/agentkit/calendar/service.py b/src/agentkit/calendar/service.py new file mode 100644 index 0000000..1a2af17 --- /dev/null +++ b/src/agentkit/calendar/service.py @@ -0,0 +1,393 @@ +"""CalendarService — business-logic layer for calendar operations. + +REST routes (U2) and agent tools are thin wrappers over this service. +The service dispatches to ``db`` functions for persistence and to +``recurrence`` for RRULE expansion. +""" + +from __future__ import annotations + +import dataclasses +import logging +import uuid +from datetime import datetime, timezone +from pathlib import Path + +import aiosqlite + +from agentkit.calendar.db import ( + DEFAULT_CALENDAR_DB_PATH, + add_tag_to_event, + delete_event as db_delete_event, + get_event as db_get_event, + get_invitation as db_get_invitation, + insert_event, + insert_event_type, + insert_invitation, + insert_reminder_rule, + insert_tag, + list_event_types as db_list_event_types, + list_events as db_list_events, + list_invitations as db_list_invitations, + list_reminder_rules_for_type, + list_tags as db_list_tags, + update_event as db_update_event, + update_event_type as db_update_event_type, + update_invitation_status, +) +from agentkit.calendar.models import ( + CalendarEvent, + EventType, + Invitation, + Tag, + _now_iso, +) +from agentkit.calendar.recurrence import expand_rrule +from agentkit.server.auth.models import DEFAULT_AUTH_DB_PATH + +logger = logging.getLogger(__name__) + + +def _validate_iso(dt_str: str) -> None: + """Validate that *dt_str* is a parseable ISO 8601 string.""" + if not dt_str: + raise ValueError("start_time must be a valid ISO 8601 string") + try: + datetime.fromisoformat(dt_str) + except ValueError: + raise ValueError(f"Invalid ISO 8601 format: {dt_str}") + + +def _parse_dt(dt_str: str) -> datetime: + """Parse ISO 8601 string to timezone-aware datetime (UTC).""" + dt = datetime.fromisoformat(dt_str) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +def _format_dt(dt: datetime) -> str: + """Format datetime as ISO 8601 UTC string.""" + return dt.astimezone(timezone.utc).isoformat() + + +class CalendarService: + """Create, query, and manage calendar events, types, tags, and invitations. + + Mirrors ``DocumentService``: ``__init__`` stores a db_path, async methods + delegate to ``calendar.db`` functions. RRULE expansion is handled here + so routes and tools get a flat list of occurrences. + """ + + def __init__( + self, + db_path: str | Path | None = None, + auth_db_path: str | Path | None = None, + ) -> None: + self.db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + self.auth_db_path = Path(auth_db_path) if auth_db_path is not None else DEFAULT_AUTH_DB_PATH + + # ------------------------------------------------------------------ + # Event CRUD + # ------------------------------------------------------------------ + + async def create_event( + self, + user_id: str, + title: str, + start_time: str, + end_time: str, + description: str = "", + location: str = "", + is_all_day: bool = False, + event_type_id: str | None = None, + rrule: str | None = None, + source: str = "manual", + is_invited: bool = False, + conversation_id: str | None = None, + tag_ids: list[str] | None = None, + ) -> CalendarEvent: + """Create a calendar event with UUID, timestamps, tags, and cloned reminders.""" + _validate_iso(start_time) + now = _now_iso() + event = CalendarEvent( + id=uuid.uuid4().hex, + user_id=user_id, + title=title, + description=description, + start_time=start_time, + end_time=end_time, + is_all_day=is_all_day, + location=location, + event_type_id=event_type_id, + rrule=rrule, + source=source, + is_invited=is_invited, + conversation_id=conversation_id, + last_modified=now, + created_at=now, + ) + await insert_event(event, self.db_path) + + # Link tags if provided — skip any that don't belong to the user + if tag_ids: + user_tags = await self.list_tags(user_id) + user_tag_ids = {t.id for t in user_tags} + for tag_id in tag_ids: + if tag_id not in user_tag_ids: + logger.debug("Skipping tag %s — not owned by user %s", tag_id, user_id) + continue + await add_tag_to_event(event.id, tag_id, self.db_path) + + # Clone type-level default reminder rules to the event — skip if type + # doesn't belong to the user + if event_type_id: + user_types = await self.list_event_types(user_id) + user_type_ids = {t.id for t in user_types} + if event_type_id not in user_type_ids: + logger.debug( + "Skipping event_type %s — not owned by user %s", event_type_id, user_id + ) + else: + type_rules = await list_reminder_rules_for_type(event_type_id, self.db_path) + for rule in type_rules: + cloned = dataclasses.replace( + rule, + id=uuid.uuid4().hex, + event_id=event.id, + event_type_id=None, + ) + await insert_reminder_rule(cloned, self.db_path) + + logger.info(f"Created event {event.id} ({title}) for user {user_id}") + return event + + async def get_event(self, event_id: str) -> CalendarEvent | None: + """Return a single event by id, or None.""" + return await db_get_event(event_id, self.db_path) + + async def list_events( + self, + user_id: str, + start: str | None = None, + end: str | None = None, + event_type_id: str | None = None, + tag_id: str | None = None, + ) -> list[CalendarEvent]: + """List events for a user, expanding recurring events within [start, end]. + + Non-recurring events are filtered by date range manually (not in the + DB query) so that recurring events whose first occurrence falls + outside the range are still included and expanded. + """ + # Fetch all events for the user with type/tag filters — no date filter + # at the DB level so recurring events are not excluded. + events = await db_list_events( + user_id, + event_type_id=event_type_id, + tag_id=tag_id, + db_path=self.db_path, + ) + + result: list[CalendarEvent] = [] + for event in events: + if event.rrule: + # Expand recurring event within [start, end] range + try: + occurrences = expand_rrule( + event.rrule, + event.start_time, + range_start=start, + range_end=end, + ) + except ValueError: + # ponytail: malformed RRULE → fall back to single occurrence + # so one bad event doesn't crash the whole list. Upgrade + # path: surface a validation error at create_event time. + logger.warning( + "Malformed RRULE %r for event %s; falling back to start_time", + event.rrule, + event.id, + ) + occurrences = [event.start_time] + for occ_start_str in occurrences: + occ = self._make_occurrence(event, occ_start_str) + result.append(occ) + else: + # Non-recurring: filter by date range manually + if _is_in_range(event.start_time, start, end): + result.append(event) + + # Sort by start_time for consistent ordering + result.sort(key=lambda e: e.start_time) + return result + + def _make_occurrence(self, event: CalendarEvent, occ_start_str: str) -> CalendarEvent: + """Create a copy of *event* with start/end times shifted to the occurrence.""" + occ_start_dt = _parse_dt(occ_start_str) + original_start = _parse_dt(event.start_time) + original_end = _parse_dt(event.end_time) + duration = original_end - original_start + occ_end_dt = occ_start_dt + duration + return dataclasses.replace( + event, + start_time=_format_dt(occ_start_dt), + end_time=_format_dt(occ_end_dt), + ) + + async def update_event(self, event_id: str, fields: dict) -> bool: + """Update specific fields of an event. Auto-updates last_modified.""" + fields = {**fields, "last_modified": _now_iso()} + return await db_update_event(event_id, fields, self.db_path) + + async def delete_event(self, event_id: str) -> bool: + """Delete an event and its dependent rows.""" + return await db_delete_event(event_id, self.db_path) + + # ------------------------------------------------------------------ + # Event Type CRUD + # ------------------------------------------------------------------ + + async def list_event_types(self, user_id: str) -> list[EventType]: + """List all event types for a user.""" + return await db_list_event_types(user_id, self.db_path) + + async def get_event_type(self, type_id: str) -> EventType | None: + """Return a single event type by id, or None.""" + async with aiosqlite.connect(str(self.db_path)) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT * FROM calendar_event_types WHERE id = ?", + (type_id,), + ) + row = await cursor.fetchone() + if row is None: + return None + return EventType( + id=row["id"], + user_id=row["user_id"], + name=row["name"], + color=row["color"], + is_default=bool(row["is_default"]), + ) + + async def create_event_type( + self, + user_id: str, + name: str, + color: str = "#4A90D9", + ) -> EventType: + """Create a new event type.""" + et = EventType( + id=uuid.uuid4().hex, + user_id=user_id, + name=name, + color=color, + ) + await insert_event_type(et, self.db_path) + return et + + async def update_event_type(self, type_id: str, fields: dict) -> bool: + """Update specific fields of an event type.""" + return await db_update_event_type(type_id, fields, self.db_path) + + # ------------------------------------------------------------------ + # Tag CRUD + # ------------------------------------------------------------------ + + async def list_tags(self, user_id: str) -> list[Tag]: + """List all tags for a user.""" + return await db_list_tags(user_id, self.db_path) + + async def create_tag(self, user_id: str, name: str) -> Tag: + """Create a new tag.""" + tag = Tag( + id=uuid.uuid4().hex, + user_id=user_id, + name=name, + ) + await insert_tag(tag, self.db_path) + return tag + + # ------------------------------------------------------------------ + # Invitation CRUD + # ------------------------------------------------------------------ + + async def create_invitation( + self, + event_id: str, + inviter_user_id: str, + invitee_email: str, + ) -> Invitation: + """Create an invitation with status='pending'.""" + invitation = Invitation( + id=uuid.uuid4().hex, + event_id=event_id, + inviter_user_id=inviter_user_id, + invitee_email=invitee_email, + status="pending", + ) + await insert_invitation(invitation, self.db_path) + return invitation + + async def respond_to_invitation(self, invitation_id: str, status: str) -> bool: + """Update invitation status and set responded_at.""" + return await update_invitation_status( + invitation_id, + status, + _now_iso(), + self.db_path, + ) + + async def get_invitation(self, invitation_id: str) -> Invitation | None: + """Return a single invitation by id, or None.""" + return await db_get_invitation(invitation_id, self.db_path) + + async def list_invitations(self, invitee_email: str) -> list[Invitation]: + """List all invitations for an invitee email.""" + return await db_list_invitations(invitee_email, self.db_path) + + # ------------------------------------------------------------------ + # User search (auth DB) + # ------------------------------------------------------------------ + + async def search_users(self, q: str) -> list[dict]: + """Search users by username or email. Returns top 10 matches. + + Only ``username`` and ``email`` are returned — never user_id or + password fields (G5/A3 — least-privilege user search). + """ + escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + pattern = f"%{escaped_q}%" + async with aiosqlite.connect(str(self.auth_db_path)) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT username, email FROM users " + "WHERE username LIKE ? ESCAPE '\\' OR email LIKE ? ESCAPE '\\' LIMIT 10", + (pattern, pattern), + ) + rows = await cursor.fetchall() + return [{"username": row["username"], "email": row["email"]} for row in rows] + + async def get_user_email(self, user_id: str) -> str | None: + """Look up a user's email from the auth DB by user_id.""" + async with aiosqlite.connect(str(self.auth_db_path)) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT email FROM users WHERE id = ?", + (user_id,), + ) + row = await cursor.fetchone() + return row["email"] if row else None + + +def _is_in_range(dt_str: str, start: str | None, end: str | None) -> bool: + """Check if *dt_str* falls within the half-open range [start, end).""" + dt = _parse_dt(dt_str) + if start is not None: + if dt < _parse_dt(start): + return False + if end is not None: + if dt >= _parse_dt(end): + return False + return True diff --git a/src/agentkit/calendar/sync/__init__.py b/src/agentkit/calendar/sync/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agentkit/calendar/sync/base.py b/src/agentkit/calendar/sync/base.py new file mode 100644 index 0000000..26bf412 --- /dev/null +++ b/src/agentkit/calendar/sync/base.py @@ -0,0 +1,34 @@ +"""Abstract sync provider interface (U6). + +All external calendar sync providers (CalDAV, Outlook, Google) implement +this interface so :class:`SyncManager` can orchestrate them uniformly. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig + + +class AbstractSyncProvider(ABC): + """Interface for bidirectional external calendar sync.""" + + @abstractmethod + async def pull_changes( + self, config: ExternalCalendarConfig, since: str | None = None + ) -> list[CalendarEvent]: + """Pull remote changes into local DB. Returns pulled/updated events.""" + ... + + @abstractmethod + async def push_changes( + self, config: ExternalCalendarConfig, events: list[CalendarEvent] + ) -> list[CalendarEvent]: + """Push local events to remote. Returns updated events (with external_id set).""" + ... + + @abstractmethod + async def test_connection(self, config: ExternalCalendarConfig) -> tuple[bool, str]: + """Test connectivity. Returns (ok, error_msg). error_msg is "" on success.""" + ... diff --git a/src/agentkit/calendar/sync/caldav_provider.py b/src/agentkit/calendar/sync/caldav_provider.py new file mode 100644 index 0000000..f1e280f --- /dev/null +++ b/src/agentkit/calendar/sync/caldav_provider.py @@ -0,0 +1,398 @@ +"""CalDAV sync provider — bidirectional sync with Apple Calendar (U6). + +Uses the ``caldav`` library for the CalDAV protocol and ``icalendar`` for +ICS serialization/parsing. Conflict resolution is last-write-wins based on +``last_modified``; conflicts emit a ``calendar_sync_conflict`` WS notification +via the injectable ``notify_callback`` (G4). +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import uuid +from collections.abc import Awaitable, Callable +from datetime import date, datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +import caldav +from icalendar import Calendar, Event +from icalendar.prop import vRecur + +from agentkit.calendar.db import ( + DEFAULT_CALENDAR_DB_PATH, + get_event_by_external_id, + insert_event, + update_event, +) +from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig, _now_iso +from agentkit.calendar.sync.base import AbstractSyncProvider + +logger = logging.getLogger(__name__) + +# Async callback signature: (event_type: str, payload: dict) -> None +NotifyCallback = Callable[[str, dict[str, Any]], Awaitable[None]] + + +def _parse_iso(dt_str: str) -> datetime: + """Parse ISO 8601 string to UTC-aware datetime.""" + dt = datetime.fromisoformat(dt_str) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def _to_iso_utc(dt: datetime) -> str: + """Convert datetime to ISO 8601 UTC string.""" + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).isoformat() + + +def _extract_dt(component: Any, key: str) -> tuple[str, bool]: + """Extract date/datetime from icalendar component. Returns (iso, is_all_day).""" + prop = component.get(key) + if prop is None: + return "", False + val = prop.dt + if isinstance(val, date) and not isinstance(val, datetime): + return val.isoformat(), True + return _to_iso_utc(val), False + + +class CalDAVSyncProvider(AbstractSyncProvider): + """Bidirectional CalDAV sync provider for Apple Calendar. + + The ``client_factory`` parameter allows tests to inject a mock CalDAV + client without touching ``caldav.DAVClient``. When ``None``, a real + ``caldav.DAVClient`` is constructed from ``config.credentials``. + """ + + def __init__( + self, + db_path: str | Path | None = None, + client_factory: Callable[[ExternalCalendarConfig], Any] | None = None, + notify_callback: NotifyCallback | None = None, + ) -> None: + self.db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + self._client_factory = client_factory + self._notify = notify_callback + # ponytail: conflicts list is in-memory only; if the process restarts + # before a sync completes, conflict history is lost. Upgrade: persist + # to a calendar_sync_conflicts table. + + # ------------------------------------------------------------------ + # Client construction + # ------------------------------------------------------------------ + + def _make_client(self, config: ExternalCalendarConfig) -> Any: + """Build a caldav.DAVClient from config credentials.""" + if self._client_factory is not None: + return self._client_factory(config) + # ponytail: credentials stored as plain JSON dict; encryption deferred. + # Upgrade: use agentkit.server.auth.crypto to encrypt at rest. + creds = json.loads(config.credentials) if config.credentials else {} + return caldav.DAVClient( + url=creds.get("url", ""), + username=creds.get("username", ""), + password=creds.get("password", ""), + # ponytail: 30s timeout prevents indefinite hangs on unreachable + # CalDAV servers. Upgrade: make configurable per-config. + timeout=30, + ) + + def _get_calendar(self, config: ExternalCalendarConfig) -> Any: + """Connect and return the first calendar from the principal.""" + client = self._make_client(config) + principal = client.principal() + calendars = principal.calendars() + if not calendars: + raise RuntimeError("No CalDAV calendars found for this account") + return calendars[0] + + # ------------------------------------------------------------------ + # Pull + # ------------------------------------------------------------------ + + async def pull_changes( + self, config: ExternalCalendarConfig, since: str | None = None + ) -> list[CalendarEvent]: + """Pull remote CalDAV events into local DB. + + Fetches events in a date range starting from ``since`` (or 1 year ago + if None). Matches by ``external_id`` (CalDAV UID). Creates new local + events or updates existing ones. Conflicts (both sides modified) are + resolved last-write-wins with a WS notification. + """ + # caldav is synchronous — run in a thread to avoid blocking the loop + remote_events = await asyncio.to_thread(self._fetch_remote_events, config, since) + + result: list[CalendarEvent] = [] + for remote in remote_events: + local = await get_event_by_external_id( + remote.external_id, "caldav", config.user_id, self.db_path + ) + if local is None: + # New remote event → create local + await insert_event(remote, self.db_path) + result.append(remote) + else: + # Existing → check for conflict + resolved = await self._resolve_pull_conflict(local, remote) + if resolved is not None: + result.append(resolved) + return result + + def _fetch_remote_events( + self, config: ExternalCalendarConfig, since: str | None + ) -> list[CalendarEvent]: + """Synchronous CalDAV fetch — runs in thread.""" + calendar = self._get_calendar(config) + + # Date range: since → now+90d (or wide default) + if since: + start_dt = _parse_iso(since) + else: + start_dt = datetime.now(timezone.utc) - timedelta(days=365) + end_dt = datetime.now(timezone.utc) + timedelta(days=90) + + caldav_events = calendar.date_search(start_dt, end_dt) + events: list[CalendarEvent] = [] + for ce in caldav_events: + parsed = self._parse_caldav_event(ce, config.user_id) + if parsed is not None: + events.append(parsed) + return events + + def _parse_caldav_event(self, caldav_event: Any, user_id: str) -> CalendarEvent | None: + """Convert a caldav.Event to a CalendarEvent dataclass.""" + try: + ical_data = caldav_event.data + cal = Calendar.from_ical(ical_data) + except Exception as e: + logger.warning("Failed to parse CalDAV event: %s", e) + return None + + for component in cal.walk("VEVENT"): + uid = str(component.get("UID", "") or "") or None + if not uid: + continue + + title = str(component.get("SUMMARY", "") or "") + if not title: + continue + + start_str, is_all_day = _extract_dt(component, "DTSTART") + end_str, _ = _extract_dt(component, "DTEND") + if not end_str: + end_str = start_str + + description = str(component.get("DESCRIPTION", "") or "") + location = str(component.get("LOCATION", "") or "") + + rrule_str: str | None = None + rrule = component.get("RRULE") + if rrule is not None: + rrule_str = rrule.to_ical().decode("utf-8") + + # LAST-MODIFIED from VEVENT (for conflict resolution) + lm_prop = component.get("LAST-MODIFIED") + if lm_prop is not None: + last_modified = _to_iso_utc(lm_prop.dt) + else: + last_modified = _now_iso() + + now = _now_iso() + return CalendarEvent( + id=uuid.uuid4().hex, + user_id=user_id, + title=title, + description=description, + start_time=start_str, + end_time=end_str, + is_all_day=is_all_day, + location=location, + rrule=rrule_str, + source="manual", + external_id=uid, + external_provider="caldav", + last_modified=last_modified, + created_at=now, + ) + return None + + async def _resolve_pull_conflict( + self, local: CalendarEvent, remote: CalendarEvent + ) -> CalendarEvent | None: + """Resolve conflict when both local and remote exist. + + If remote is newer → update local. If local is newer → conflict + (last-write-wins keeps local, but log + notify). If equal → no-op. + Returns the winning event (or None if local kept unchanged). + """ + local_lm = ( + _parse_iso(local.last_modified) + if local.last_modified + else datetime.min.replace(tzinfo=timezone.utc) + ) + remote_lm = ( + _parse_iso(remote.last_modified) + if remote.last_modified + else datetime.min.replace(tzinfo=timezone.utc) + ) + + if remote_lm > local_lm: + # Remote wins → update local + await self._notify_conflict(local, remote, winner="remote") + fields = { + "title": remote.title, + "description": remote.description, + "start_time": remote.start_time, + "end_time": remote.end_time, + "is_all_day": remote.is_all_day, + "location": remote.location, + "rrule": remote.rrule, + "last_modified": remote.last_modified, + } + await update_event(local.id, fields, self.db_path) + return remote + + if local_lm > remote_lm: + # Local wins → conflict, keep local, notify + await self._notify_conflict(local, remote, winner="local") + return None + + # Equal timestamps → no change needed + return None + + # ------------------------------------------------------------------ + # Push + # ------------------------------------------------------------------ + + async def push_changes( + self, config: ExternalCalendarConfig, events: list[CalendarEvent] + ) -> list[CalendarEvent]: + """Push local events to CalDAV. Returns events with external_id set.""" + result: list[CalendarEvent] = [] + for event in events: + updated = await self._push_single(config, event) + result.append(updated) + return result + + async def _push_single( + self, config: ExternalCalendarConfig, event: CalendarEvent + ) -> CalendarEvent: + """Push a single event to CalDAV, return event with external_id set.""" + ical_bytes = self._event_to_ics(event) + # caldav is synchronous — run in thread + saved_uid = await asyncio.to_thread(self._save_remote_event, config, ical_bytes, event) + + # If event had no external_id, set it from the saved UID + if not event.external_id and saved_uid: + fields = { + "external_id": saved_uid, + "external_provider": "caldav", + "last_modified": _now_iso(), + } + await update_event(event.id, fields, self.db_path) + event.external_id = saved_uid + event.external_provider = "caldav" + + return event + + def _save_remote_event( + self, config: ExternalCalendarConfig, ical_bytes: bytes, event: CalendarEvent + ) -> str | None: + """Synchronous CalDAV save — runs in thread. Returns remote UID.""" + calendar = self._get_calendar(config) + saved = calendar.save_event(ical_bytes) + # Extract UID from saved event + try: + cal = Calendar.from_ical(saved.data) + for comp in cal.walk("VEVENT"): + uid = str(comp.get("UID", "") or "") or None + if uid: + return uid + except Exception as e: + logger.warning("Failed to extract UID from saved event: %s", e) + return event.external_id + + def _event_to_ics(self, event: CalendarEvent) -> bytes: + """Convert CalendarEvent to ICS bytes using icalendar library.""" + cal = Calendar() + cal.add("prodid", "-//Fischer AgentKit//Calendar//EN") + cal.add("version", "2.0") + + vevent = Event() + # Use external_id if available, else local id as UID + vevent.add("uid", event.external_id or event.id) + vevent.add("summary", event.title) + + if event.start_time: + start_dt = _parse_iso(event.start_time) + if event.is_all_day: + vevent.add("dtstart", start_dt.date()) + else: + vevent.add("dtstart", start_dt) + + if event.end_time: + end_dt = _parse_iso(event.end_time) + if event.is_all_day: + vevent.add("dtend", end_dt.date()) + else: + vevent.add("dtend", end_dt) + + if event.description: + vevent.add("description", event.description) + if event.location: + vevent.add("location", event.location) + if event.rrule: + vevent.add("rrule", vRecur.from_ical(event.rrule)) + + # LAST-MODIFIED for conflict resolution + if event.last_modified: + vevent.add("last-modified", _parse_iso(event.last_modified)) + + cal.add_component(vevent) + return cal.to_ical() + + # ------------------------------------------------------------------ + # Test connection + # ------------------------------------------------------------------ + + async def test_connection(self, config: ExternalCalendarConfig) -> tuple[bool, str]: + """Test CalDAV connectivity. Returns (ok, error_msg).""" + try: + await asyncio.to_thread(self._get_calendar, config) + return True, "" + except Exception as e: + return False, str(e) + + # ------------------------------------------------------------------ + # Conflict notification + # ------------------------------------------------------------------ + + async def _notify_conflict( + self, local: CalendarEvent, remote: CalendarEvent, winner: str + ) -> None: + """Log conflict and send WS notification via callback (G4).""" + logger.info( + "Sync conflict for event %s (external_id=%s): local_lm=%s remote_lm=%s winner=%s", + local.id, + local.external_id, + local.last_modified, + remote.last_modified, + winner, + ) + if self._notify is not None: + payload = { + "event_id": local.id, + "title": local.title, + "external_id": local.external_id, + "local_last_modified": local.last_modified, + "remote_last_modified": remote.last_modified, + "winner": winner, + } + await self._notify("calendar_sync_conflict", payload) diff --git a/src/agentkit/calendar/sync/ics_provider.py b/src/agentkit/calendar/sync/ics_provider.py new file mode 100644 index 0000000..b54a335 --- /dev/null +++ b/src/agentkit/calendar/sync/ics_provider.py @@ -0,0 +1,188 @@ +"""ICS (iCalendar) import/export provider (U8). + +Uses the ``icalendar`` library for RFC 5545 compliant parsing/serialization. +Import delegates to ``calendar.db.insert_event`` for direct persistence with +``external_id`` set (so duplicate UIDs can be skipped on re-import). +Export reads via ``CalendarService.list_events``. +""" + +from __future__ import annotations + +import logging +import uuid +from datetime import date, datetime, timezone +from typing import Any + +from icalendar import Calendar, Event +from icalendar.prop import vRecur + +from agentkit.calendar.db import get_event_by_external_id, insert_event +from agentkit.calendar.models import CalendarEvent, _now_iso +from agentkit.calendar.service import CalendarService + +logger = logging.getLogger(__name__) + + +def _to_iso_utc(dt: datetime) -> str: + """Convert a datetime to ISO 8601 UTC string.""" + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).isoformat() + + +def _parse_iso(dt_str: str) -> datetime: + """Parse an ISO 8601 string to a UTC-aware datetime.""" + dt = datetime.fromisoformat(dt_str) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def _extract_dt(component: Any, key: str) -> tuple[str, bool]: + """Extract a date/datetime property from an icalendar component. + + Returns ``(iso_string, is_all_day)``. ``is_all_day`` is True when the + value is a bare ``date`` (not a ``datetime``). + """ + prop = component.get(key) + if prop is None: + return "", False + val = prop.dt + if isinstance(val, date) and not isinstance(val, datetime): + return val.isoformat(), True + return _to_iso_utc(val), False + + +class ICSProvider: + """Import/export calendar events as iCalendar (.ics) files.""" + + def __init__(self, service: CalendarService) -> None: + self.service = service + + async def import_ics(self, ics_bytes: bytes, user_id: str) -> dict[str, Any]: + """Parse ICS bytes and create events for *user_id*. + + Returns ``{"imported": N, "skipped": M, "errors": [...]}``. + Raises ``ValueError`` if the ICS content cannot be parsed at all. + """ + # ponytail: hard size/count caps to prevent DoS via crafted ICS. + # Upgrade: stream-parse for unbounded inputs. + if len(ics_bytes) > 10 * 1024 * 1024: + raise ValueError("ICS file too large (max 10MB)") + + imported = 0 + skipped = 0 + errors: list[str] = [] + + try: + cal = Calendar.from_ical(ics_bytes) + except Exception as e: + raise ValueError(f"Failed to parse ICS: {e}") from e + + components = list(cal.walk("VEVENT")) + # ponytail: hard size/count caps to prevent DoS via crafted ICS. + if len(components) > 10000: + raise ValueError("Too many events in ICS file (max 10000)") + + for component in components: + try: + uid = str(component.get("UID", "") or "") or None + + # Dedup by (external_id, provider, user_id) + if uid: + existing = await get_event_by_external_id( + uid, "ics", user_id, self.service.db_path + ) + if existing is not None: + skipped += 1 + continue + + title = str(component.get("SUMMARY", "") or "") + if not title: + errors.append("VEVENT missing SUMMARY, skipped") + continue + + start_str, is_all_day = _extract_dt(component, "DTSTART") + if not start_str: + errors.append("VEVENT missing DTSTART, skipped") + continue + end_str, _ = _extract_dt(component, "DTEND") + if not end_str: + end_str = start_str + + description = str(component.get("DESCRIPTION", "") or "") + location = str(component.get("LOCATION", "") or "") + + rrule_str: str | None = None + rrule = component.get("RRULE") + if rrule is not None: + rrule_str = rrule.to_ical().decode("utf-8") + + now = _now_iso() + event = CalendarEvent( + id=uuid.uuid4().hex, + user_id=user_id, + title=title, + description=description, + start_time=start_str, + end_time=end_str, + is_all_day=is_all_day, + location=location, + rrule=rrule_str, + source="manual", + external_id=uid, + external_provider="ics" if uid else None, + last_modified=now, + created_at=now, + ) + await insert_event(event, self.service.db_path) + imported += 1 + except Exception as e: + errors.append(f"Failed to import VEVENT: {e}") + logger.warning("ICS import error: %s", e) + + return {"imported": imported, "skipped": skipped, "errors": errors} + + def export_ics(self, events: list[CalendarEvent]) -> bytes: + """Serialize *events* to ICS bytes.""" + cal = Calendar() + cal.add("prodid", "-//Fischer AgentKit//Calendar//EN") + cal.add("version", "2.0") + + # ponytail: list_events expands RRULE into occurrences (same event.id). + # ICS wants one VEVENT with RRULE, not N copies — dedup by id. + seen_ids: set[str] = set() + for event in events: + if event.id in seen_ids: + continue + seen_ids.add(event.id) + cal.add_component(self._event_to_vevent(event)) + + return cal.to_ical() + + def _event_to_vevent(self, event: CalendarEvent) -> Event: + """Convert a :class:`CalendarEvent` to an icalendar ``Event`` component.""" + vevent = Event() + vevent.add("uid", event.external_id or event.id) + vevent.add("summary", event.title) + + start_dt = _parse_iso(event.start_time) + end_dt = _parse_iso(event.end_time) + + if event.is_all_day: + vevent.add("dtstart", start_dt.date()) + vevent.add("dtend", end_dt.date()) + else: + vevent.add("dtstart", start_dt) + vevent.add("dtend", end_dt) + + if event.description: + vevent.add("description", event.description) + if event.location: + vevent.add("location", event.location) + if event.rrule: + # ponytail: vRecur.from_ical reorders keys (e.g. COUNT before BYDAY) + # but the result is semantically equivalent RFC 5545. + vevent.add("rrule", vRecur.from_ical(event.rrule)) + + return vevent diff --git a/src/agentkit/calendar/sync/manager.py b/src/agentkit/calendar/sync/manager.py new file mode 100644 index 0000000..c22053b --- /dev/null +++ b/src/agentkit/calendar/sync/manager.py @@ -0,0 +1,228 @@ +"""SyncManager — orchestrates external calendar sync providers (U6, G8). + +Iterates user ``ExternalCalendarConfig`` entries, dispatches to the matching +provider (CalDAV, Outlook, …), and resolves conflicts via last-write-wins +with WS notification. The manager is intended to be registered on +``app.state.sync_manager`` and started/stopped in ``app.py`` lifespan. + +ponytail: wiring into ``app.py`` lifespan is deferred — this module provides +the API only. Upgrade: add ``SyncManager.start()``/``stop()`` asyncio loop +in ``app.py`` next to the reminder scheduler. +""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from agentkit.calendar.db import ( + DEFAULT_CALENDAR_DB_PATH, + list_events, + list_external_configs, + update_external_config, +) +from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig, _now_iso +from agentkit.calendar.sync.base import AbstractSyncProvider +from agentkit.calendar.sync.caldav_provider import CalDAVSyncProvider + +logger = logging.getLogger(__name__) + +# Async callback signature: (event_type: str, payload: dict) -> None +NotifyCallback = Callable[[str, dict[str, Any]], Awaitable[None]] + + +class SyncManager: + """Orchestrates all external calendar sync providers for a user. + + Providers are registered by ``ExternalCalendarConfig.provider`` name. + The ``notify_callback`` is forwarded to providers for conflict WS push. + """ + + def __init__( + self, + db_path: str | Path | None = None, + notify_callback: NotifyCallback | None = None, + providers: dict[str, AbstractSyncProvider] | None = None, + ) -> None: + self.db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + self._notify = notify_callback + # ponytail: provider registry is hardcoded for now; if a third provider + # (e.g. Google) is added, switch to entry-point discovery. Upgrade: + # ``importlib.metadata.entry_points(group="agentkit.sync_providers")``. + self._providers: dict[str, AbstractSyncProvider] = providers or { + "caldav": CalDAVSyncProvider(db_path=self.db_path, notify_callback=notify_callback), + } + + def _get_provider(self, provider_name: str) -> AbstractSyncProvider | None: + """Return the provider for *provider_name*, or None if unsupported.""" + return self._providers.get(provider_name) + + # ------------------------------------------------------------------ + # Sync orchestration + # ------------------------------------------------------------------ + + async def sync_all(self, user_id: str) -> dict[str, Any]: + """Sync all external calendar configs for a user. + + Returns ``{"synced": N, "errors": [...]}``. + """ + configs = await list_external_configs(user_id, self.db_path) + synced = 0 + errors: list[str] = [] + for config in configs: + try: + await self.sync_provider(config.id) + synced += 1 + except Exception as e: + errors.append(f"{config.id}: {e}") + logger.warning("Sync failed for config %s: %s", config.id, e) + return {"synced": synced, "errors": errors} + + async def sync_provider(self, config_id: str) -> dict[str, Any]: + """Sync a single external calendar config by ID. + + Pulls remote changes, then pushes local changes modified since + ``last_sync``. Updates ``last_sync`` on success. + + Returns ``{"pulled": N, "pushed": M}``. + """ + config = await self._get_config(config_id) + if config is None: + raise ValueError(f"ExternalCalendarConfig not found: {config_id}") + + provider = self._get_provider(config.provider) + if provider is None: + raise ValueError(f"Unsupported provider: {config.provider}") + + since = config.last_sync + + # 1. Pull remote → local (creates/updates local events, resolves conflicts) + pulled = await provider.pull_changes(config, since=since) + + # 2. Push local changes → remote (events modified since last_sync) + # ponytail: reset `since` to now after pull so pulled events (whose + # last_modified was set to the remote timestamp) aren't pushed back to + # remote, creating a sync loop. Upgrade: filter by pulled event IDs for + # sub-second accuracy. + since = datetime.now(timezone.utc).isoformat() + local_events = await self._get_modified_events(config, since) + pushed = await provider.push_changes(config, local_events) + + # 3. Update last_sync + now = _now_iso() + await update_external_config(config.id, {"last_sync": now}, self.db_path) + config.last_sync = now + + return {"pulled": len(pulled), "pushed": len(pushed)} + + async def _get_config(self, config_id: str) -> ExternalCalendarConfig | None: + """Fetch a single ExternalCalendarConfig by ID.""" + # ponytail: no db.get_external_config(id) exists; we list all for the + # user. This is O(N) over the user's configs (typically <5). Upgrade: + # add ``get_external_config(config_id)`` to db.py if this becomes hot. + # We don't know the user_id here, so scan all configs in the DB. + import aiosqlite + + from agentkit.calendar.db import _row_to_external_config + + async with aiosqlite.connect(str(self.db_path)) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT * FROM calendar_external_configs WHERE id = ?", (config_id,) + ) + row = await cursor.fetchone() + return _row_to_external_config(row) if row else None + + async def _get_modified_events( + self, config: ExternalCalendarConfig, since: str | None + ) -> list[CalendarEvent]: + """Return local events for the user modified since *since*. + + When *since* is None (first sync), returns all events for the user + that do not yet have an ``external_id`` (i.e. new local events to push). + When *since* is set, returns events whose ``last_modified`` >= *since*. + """ + events = await list_events(config.user_id, db_path=self.db_path) + if since is None: + # First sync: push only events without external_id + return [e for e in events if not e.external_id] + + since_dt = datetime.fromisoformat(since) + if since_dt.tzinfo is None: + since_dt = since_dt.replace(tzinfo=timezone.utc) + + result: list[CalendarEvent] = [] + for event in events: + if not event.last_modified: + continue + event_lm = datetime.fromisoformat(event.last_modified) + if event_lm.tzinfo is None: + event_lm = event_lm.replace(tzinfo=timezone.utc) + if event_lm >= since_dt: + result.append(event) + return result + + # ------------------------------------------------------------------ + # Conflict resolution + # ------------------------------------------------------------------ + + async def resolve_conflict( + self, local_event: CalendarEvent, remote_event: CalendarEvent + ) -> CalendarEvent: + """Resolve a sync conflict using last-write-wins (G8/G4). + + Compares ``last_modified`` timestamps. The newer event wins. Sends a + ``calendar_sync_conflict`` WS notification via the notify callback. + Returns the winning event. + """ + local_lm = ( + datetime.fromisoformat(local_event.last_modified) + if local_event.last_modified + else datetime.min.replace(tzinfo=timezone.utc) + ) + if local_lm.tzinfo is None: + local_lm = local_lm.replace(tzinfo=timezone.utc) + + remote_lm = ( + datetime.fromisoformat(remote_event.last_modified) + if remote_event.last_modified + else datetime.min.replace(tzinfo=timezone.utc) + ) + if remote_lm.tzinfo is None: + remote_lm = remote_lm.replace(tzinfo=timezone.utc) + + if remote_lm > local_lm: + winner = remote_event + winner_label = "remote" + elif local_lm > remote_lm: + winner = local_event + winner_label = "local" + else: + # Equal timestamps → local wins by default + winner = local_event + winner_label = "local" + + logger.info( + "Conflict resolved for event %s: winner=%s (local_lm=%s remote_lm=%s)", + local_event.id, + winner_label, + local_event.last_modified, + remote_event.last_modified, + ) + + # G4: WS notification + if self._notify is not None: + payload = { + "event_id": local_event.id, + "title": local_event.title, + "external_id": local_event.external_id, + "local_last_modified": local_event.last_modified, + "remote_last_modified": remote_event.last_modified, + "winner": winner_label, + } + await self._notify("calendar_sync_conflict", payload) + + return winner diff --git a/src/agentkit/calendar/sync/outlook_provider.py b/src/agentkit/calendar/sync/outlook_provider.py new file mode 100644 index 0000000..b0053e8 --- /dev/null +++ b/src/agentkit/calendar/sync/outlook_provider.py @@ -0,0 +1,549 @@ +"""Outlook sync provider — bidirectional sync with Microsoft Graph API (U7). + +Uses ``httpx.AsyncClient`` for all Graph API calls. Conflict resolution is +last-write-wins based on ``last_modified``; conflicts emit a +``calendar_sync_conflict`` WS notification via the injectable ``notify_callback`` +(G4). + +ponytail: browser OAuth flow (auth-code grant + redirect) is deferred to U12 +settings UI. This provider assumes tokens are already stored in +``ExternalCalendarConfig.credentials``. Upgrade: add an ``OutlookOAuthFlow`` +helper that performs the device-code or auth-code flow and writes tokens. +""" + +from __future__ import annotations + +import json +import logging +import uuid +from collections.abc import Awaitable, Callable +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, quote, urlparse + +import httpx + +from agentkit.calendar.db import ( + DEFAULT_CALENDAR_DB_PATH, + get_event_by_external_id, + insert_event, + update_event, + update_external_config, +) +from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig, _now_iso +from agentkit.calendar.sync.base import AbstractSyncProvider + +logger = logging.getLogger(__name__) + +# Async callback signature: (event_type: str, payload: dict) -> None +NotifyCallback = Callable[[str, dict[str, Any]], Awaitable[None]] + +GRAPH_BASE = "https://graph.microsoft.com/v1.0" +TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token" +DEFAULT_SCOPE = "https://graph.microsoft.com/Calendars.ReadWrite offline_access" + +_DAY_MAP = { + "monday": "MO", + "tuesday": "TU", + "wednesday": "WE", + "thursday": "TH", + "friday": "FR", + "saturday": "SA", + "sunday": "SU", +} +_DAY_MAP_REVERSE = {v: k for k, v in _DAY_MAP.items()} +_FREQ_MAP = { + "daily": "DAILY", + "weekly": "WEEKLY", + "absoluteMonthly": "MONTHLY", + "absoluteYearly": "YEARLY", +} +_FREQ_MAP_REVERSE = {v: k for k, v in _FREQ_MAP.items()} + + +def _parse_iso(dt_str: str) -> datetime: + """Parse ISO 8601 string to UTC-aware datetime.""" + dt = datetime.fromisoformat(dt_str) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def _outlook_dt_to_iso(dt_obj: dict[str, Any]) -> str: + """Convert Outlook dateTimeTimeZone to ISO 8601 UTC. + + ponytail: assumes Graph returns UTC (no ``Prefer: outlook.timezone`` header + is sent). If a non-UTC timezone is returned, it's treated as UTC. Upgrade: + use ``zoneinfo`` with a Windows→IANA timezone mapping for correct conversion. + """ + if not dt_obj: + return "" + dt_str = dt_obj.get("dateTime", "") + if not dt_str: + return "" + if "T" in dt_str: + dt = datetime.fromisoformat(dt_str) + else: + # Date only (all-day event) + dt = datetime.fromisoformat(dt_str + "T00:00:00") + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).isoformat() + + +def _iso_to_outlook_dt(iso_str: str, is_all_day: bool) -> dict[str, str]: + """Convert ISO 8601 UTC to Outlook dateTimeTimeZone.""" + if not iso_str: + return {"dateTime": "", "timeZone": "UTC"} + dt = _parse_iso(iso_str) + if is_all_day: + return {"dateTime": dt.strftime("%Y-%m-%d"), "timeZone": "UTC"} + return {"dateTime": dt.strftime("%Y-%m-%dT%H:%M:%S"), "timeZone": "UTC"} + + +def _outlook_recurrence_to_rrule(recurrence: dict[str, Any] | None) -> str | None: + """Convert Outlook recurrence pattern to RRULE string.""" + if not recurrence: + return None + pattern = recurrence.get("pattern", {}) or {} + range_obj = recurrence.get("range", {}) or {} + + parts: list[str] = [] + freq = _FREQ_MAP.get(pattern.get("type", "")) + if freq: + parts.append(f"FREQ={freq}") + + interval = pattern.get("interval") + if interval and interval > 1: + parts.append(f"INTERVAL={interval}") + + days = pattern.get("daysOfWeek", []) + if days: + bydays = [_DAY_MAP[d] for d in days if d in _DAY_MAP] + if bydays: + parts.append(f"BYDAY={','.join(bydays)}") + + count = pattern.get("numberOfOccurrences") + if count: + parts.append(f"COUNT={count}") + elif range_obj.get("type") == "endDate" and range_obj.get("endDate"): + end_date = range_obj["endDate"] # "2026-12-31" + parts.append(f"UNTIL={end_date.replace('-', '')}T235959Z") + + return ";".join(parts) if parts else None + + +def _rrule_to_outlook_recurrence(rrule: str, start_date: str) -> dict[str, Any] | None: + """Convert RRULE string to Outlook recurrence pattern. + + ``start_date`` is the event's start date in ``YYYY-MM-DD`` format (required + by Graph API for the ``range.startDate`` field). + """ + parts: dict[str, str] = {} + for part in rrule.split(";"): + if "=" in part: + k, v = part.split("=", 1) + parts[k.upper()] = v + + freq = parts.get("FREQ", "").upper() + pattern_type = _FREQ_MAP_REVERSE.get(freq) + if not pattern_type: + return None + + pattern: dict[str, Any] = {"type": pattern_type} + + interval = parts.get("INTERVAL") + pattern["interval"] = int(interval) if interval else 1 + + byday = parts.get("BYDAY") + if byday: + pattern["daysOfWeek"] = [ + _DAY_MAP_REVERSE[d] for d in byday.split(",") if d in _DAY_MAP_REVERSE + ] + + count = parts.get("COUNT") + until = parts.get("UNTIL") + + if count: + pattern["numberOfOccurrences"] = int(count) + range_obj: dict[str, Any] = { + "type": "numbered", + "startDate": start_date, + "numberOfOccurrences": int(count), + } + elif until: + # UNTIL=20261231T235959Z → "2026-12-31" + date_str = until[:8] + end_date = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}" + range_obj = {"type": "endDate", "startDate": start_date, "endDate": end_date} + else: + range_obj = {"type": "noEnd", "startDate": start_date} + + return {"pattern": pattern, "range": range_obj} + + +def _extract_delta_token(delta_link: str) -> str | None: + """Extract ``$deltaToken`` from a Graph delta link URL.""" + parsed = urlparse(delta_link) + params = parse_qs(parsed.query) + values = params.get("$deltaToken", []) + return values[0] if values else None + + +class OutlookSyncProvider(AbstractSyncProvider): + """Bidirectional Outlook sync provider via Microsoft Graph REST API. + + The ``client_factory`` parameter allows tests to inject a mock + ``httpx.AsyncClient`` without making real HTTP calls. When ``None``, a + real ``httpx.AsyncClient`` is constructed per-operation. + """ + + def __init__( + self, + db_path: str | Path | None = None, + client_factory: Callable[[], Any] | None = None, + notify_callback: NotifyCallback | None = None, + ) -> None: + self.db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + self._client_factory = client_factory + self._notify = notify_callback + # ponytail: conflicts list is in-memory only; if the process restarts + # before a sync completes, conflict history is lost. Upgrade: persist + # to a calendar_sync_conflicts table. + + # ------------------------------------------------------------------ + # Client / auth + # ------------------------------------------------------------------ + + def _get_client(self) -> Any: + """Return an httpx.AsyncClient (real or mock from factory).""" + if self._client_factory is not None: + return self._client_factory() + return httpx.AsyncClient(timeout=30.0) + + def _load_creds(self, config: ExternalCalendarConfig) -> dict[str, Any]: + return json.loads(config.credentials) if config.credentials else {} + + def _save_creds(self, config: ExternalCalendarConfig, creds: dict[str, Any]) -> None: + config.credentials = json.dumps(creds) + + async def _refresh_token(self, client: Any, config: ExternalCalendarConfig) -> dict[str, Any]: + """Refresh the access token using the refresh_token grant. + + Posts to the Azure AD token endpoint, updates ``config.credentials`` + in-memory, and persists the new credentials to the DB. + """ + creds = self._load_creds(config) + resp = await client.request( + "POST", + TOKEN_URL, + data={ + "client_id": creds.get("client_id", ""), + "grant_type": "refresh_token", + "refresh_token": creds.get("refresh_token", ""), + "scope": DEFAULT_SCOPE, + }, + ) + if resp.status_code in (400, 401): + logger.error("Outlook refresh token expired or revoked (status=%s)", resp.status_code) + if self._notify is not None: + await self._notify( + "calendar_sync_error", + { + "config_id": config.id, + "message": "Outlook authentication expired, please re-authenticate", + }, + ) + # ponytail: re-auth UI is in U12. Upgrade: trigger OAuth re-flow automatically. + raise RuntimeError("Outlook refresh token expired — re-authentication required") + resp.raise_for_status() + payload = resp.json() + creds["access_token"] = payload["access_token"] + if "refresh_token" in payload: + creds["refresh_token"] = payload["refresh_token"] + creds["expires_at"] = ( + datetime.now(timezone.utc) + timedelta(seconds=payload.get("expires_in", 3600)) + ).isoformat() + self._save_creds(config, creds) + await update_external_config(config.id, {"credentials": config.credentials}, self.db_path) + return creds + + async def _request( + self, + client: Any, + config: ExternalCalendarConfig, + method: str, + url: str, + *, + json_body: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Make an authenticated Graph API request with 401 auto-refresh + retry.""" + creds = self._load_creds(config) + headers = {"Authorization": f"Bearer {creds.get('access_token', '')}"} + resp = await client.request(method, url, headers=headers, json=json_body) + if resp.status_code == 401: + creds = await self._refresh_token(client, config) + headers = {"Authorization": f"Bearer {creds.get('access_token', '')}"} + resp = await client.request(method, url, headers=headers, json=json_body) + resp.raise_for_status() + return resp.json() if resp.text else {} + + # ------------------------------------------------------------------ + # Pull + # ------------------------------------------------------------------ + + async def pull_changes( + self, config: ExternalCalendarConfig, since: str | None = None + ) -> list[CalendarEvent]: + """Pull remote Outlook events via delta query. + + First call (no ``sync_token``) is a full sync within a date range. + Subsequent calls use the stored delta token for incremental sync. + Returns pulled/updated events. + """ + client = self._get_client() + try: + remote_events, delta_token = await self._pull_delta(client, config) + finally: + await client.aclose() + + # Persist delta token for next incremental sync + if delta_token: + config.sync_token = delta_token + await update_external_config(config.id, {"sync_token": delta_token}, self.db_path) + + result: list[CalendarEvent] = [] + for remote in remote_events: + local = await get_event_by_external_id( + remote.external_id, "outlook", config.user_id, self.db_path + ) + if local is None: + # New remote event → create local + await insert_event(remote, self.db_path) + result.append(remote) + else: + # Existing → check for conflict + resolved = await self._resolve_pull_conflict(local, remote) + if resolved is not None: + result.append(resolved) + return result + + async def _pull_delta( + self, client: Any, config: ExternalCalendarConfig + ) -> tuple[list[CalendarEvent], str | None]: + """Call /me/calendarView/delta. Returns (events, delta_token).""" + url = self._build_delta_url(config) + # ponytail: single-page fetch; pagination via @odata.nextLink is not + # followed. Upgrade: loop on nextLink until exhausted, then read + # deltaLink from the final page. + payload = await self._request(client, config, "GET", url) + events: list[CalendarEvent] = [] + for raw in payload.get("value", []): + parsed = self._parse_outlook_event(raw, config.user_id) + if parsed is not None: + events.append(parsed) + delta_link = payload.get("@odata.deltaLink") + delta_token = _extract_delta_token(delta_link) if delta_link else None + return events, delta_token + + def _build_delta_url(self, config: ExternalCalendarConfig) -> str: + """Build the delta query URL. Uses sync_token if present (incremental).""" + if config.sync_token: + return f"{GRAPH_BASE}/me/calendarView/delta?$deltaToken={quote(config.sync_token, safe='')}" + # Initial sync — use date range to scope the fetch + start = (datetime.now(timezone.utc) - timedelta(days=365)).strftime("%Y-%m-%dT%H:%M:%SZ") + end = (datetime.now(timezone.utc) + timedelta(days=90)).strftime("%Y-%m-%dT%H:%M:%SZ") + return f"{GRAPH_BASE}/me/calendarView/delta?startDateTime={start}&endDateTime={end}" + + def _parse_outlook_event(self, raw: dict[str, Any], user_id: str) -> CalendarEvent | None: + """Convert a Graph event JSON to a CalendarEvent dataclass.""" + eid = raw.get("id") + if not eid: + return None + title = raw.get("subject") or "" + if not title: + return None + + start_str = _outlook_dt_to_iso(raw.get("start", {})) + end_str = _outlook_dt_to_iso(raw.get("end", {})) or start_str + is_all_day = bool(raw.get("isAllDay", False)) + + body = raw.get("body", {}) or {} + description = body.get("content", "") or "" + + location_obj = raw.get("location", {}) or {} + location = location_obj.get("displayName", "") or "" + + rrule = _outlook_recurrence_to_rrule(raw.get("recurrence")) + + last_modified = raw.get("lastModifiedDateTime", "") or _now_iso() + now = _now_iso() + return CalendarEvent( + id=uuid.uuid4().hex, + user_id=user_id, + title=title, + description=description, + start_time=start_str, + end_time=end_str, + is_all_day=is_all_day, + location=location, + rrule=rrule, + source="manual", + external_id=eid, + external_provider="outlook", + last_modified=last_modified, + created_at=now, + ) + + async def _resolve_pull_conflict( + self, local: CalendarEvent, remote: CalendarEvent + ) -> CalendarEvent | None: + """Resolve conflict when both local and remote exist (last-write-wins). + + If remote is newer → update local. If local is newer → conflict + (last-write-wins keeps local, but log + notify). If equal → no-op. + Returns the winning event (or None if local kept unchanged). + """ + local_lm = ( + _parse_iso(local.last_modified) + if local.last_modified + else datetime.min.replace(tzinfo=timezone.utc) + ) + remote_lm = ( + _parse_iso(remote.last_modified) + if remote.last_modified + else datetime.min.replace(tzinfo=timezone.utc) + ) + + if remote_lm > local_lm: + # Remote wins → update local + await self._notify_conflict(local, remote, winner="remote") + fields = { + "title": remote.title, + "description": remote.description, + "start_time": remote.start_time, + "end_time": remote.end_time, + "is_all_day": remote.is_all_day, + "location": remote.location, + "rrule": remote.rrule, + "last_modified": remote.last_modified, + } + await update_event(local.id, fields, self.db_path) + return remote + + if local_lm > remote_lm: + # Local wins → conflict, keep local, notify + await self._notify_conflict(local, remote, winner="local") + return None + + # Equal timestamps → no change needed + return None + + # ------------------------------------------------------------------ + # Push + # ------------------------------------------------------------------ + + async def push_changes( + self, config: ExternalCalendarConfig, events: list[CalendarEvent] + ) -> list[CalendarEvent]: + """Push local events to Outlook. Returns events with external_id set.""" + client = self._get_client() + try: + result: list[CalendarEvent] = [] + for event in events: + updated = await self._push_single(client, config, event) + result.append(updated) + finally: + await client.aclose() + return result + + async def _push_single( + self, client: Any, config: ExternalCalendarConfig, event: CalendarEvent + ) -> CalendarEvent: + """Push a single event to Outlook, return event with external_id set.""" + body = self._event_to_outlook(event) + if event.external_id: + # Update existing remote event + url = f"{GRAPH_BASE}/me/events/{event.external_id}" + await self._request(client, config, "PATCH", url, json_body=body) + fields = {"last_modified": _now_iso()} + await update_event(event.id, fields, self.db_path) + return event + # Create new remote event + url = f"{GRAPH_BASE}/me/events" + payload = await self._request(client, config, "POST", url, json_body=body) + new_id = payload.get("id") + if new_id: + fields = { + "external_id": new_id, + "external_provider": "outlook", + "last_modified": _now_iso(), + } + await update_event(event.id, fields, self.db_path) + event.external_id = new_id + event.external_provider = "outlook" + return event + + def _event_to_outlook(self, event: CalendarEvent) -> dict[str, Any]: + """Convert CalendarEvent to Outlook Graph event JSON.""" + body: dict[str, Any] = { + "subject": event.title, + "start": _iso_to_outlook_dt(event.start_time, event.is_all_day), + "end": _iso_to_outlook_dt(event.end_time, event.is_all_day), + "isAllDay": event.is_all_day, + } + if event.description: + body["body"] = {"contentType": "Text", "content": event.description} + if event.location: + body["location"] = {"displayName": event.location} + if event.rrule: + start_date = event.start_time[:10] if event.start_time else "2026-01-01" + rec = _rrule_to_outlook_recurrence(event.rrule, start_date) + if rec is not None: + body["recurrence"] = rec + return body + + # ------------------------------------------------------------------ + # Test connection + # ------------------------------------------------------------------ + + async def test_connection(self, config: ExternalCalendarConfig) -> tuple[bool, str]: + """Test Outlook connectivity via GET /me. Returns (ok, error_msg).""" + client = self._get_client() + try: + try: + await self._request(client, config, "GET", f"{GRAPH_BASE}/me") + ok, msg = True, "" + except Exception as e: + ok, msg = False, str(e) + finally: + await client.aclose() + return ok, msg + + # ------------------------------------------------------------------ + # Conflict notification + # ------------------------------------------------------------------ + + async def _notify_conflict( + self, local: CalendarEvent, remote: CalendarEvent, winner: str + ) -> None: + """Log conflict and send WS notification via callback (G4).""" + logger.info( + "Sync conflict for event %s (external_id=%s): local_lm=%s remote_lm=%s winner=%s", + local.id, + local.external_id, + local.last_modified, + remote.last_modified, + winner, + ) + if self._notify is not None: + payload = { + "event_id": local.id, + "title": local.title, + "external_id": local.external_id, + "local_last_modified": local.last_modified, + "remote_last_modified": remote.last_modified, + "winner": winner, + } + await self._notify("calendar_sync_conflict", payload) diff --git a/src/agentkit/router/intent.py b/src/agentkit/router/intent.py index ffa85a1..579b5e3 100644 --- a/src/agentkit/router/intent.py +++ b/src/agentkit/router/intent.py @@ -101,8 +101,8 @@ class IntentRouter: if not candidates: return None - # 按得分降序排序,得分相同时按 skill 名称字母序稳定排序 - candidates.sort(key=lambda c: (-c[2], c[0].name)) + # 按得分降序排序;得分相同时保持列表顺序(Python sort 稳定) + candidates.sort(key=lambda c: -c[2]) best_skill, best_kws, _best_score = candidates[0] confidence = min(1.0, 0.5 + 0.1 * len(best_kws)) diff --git a/src/agentkit/server/frontend/package-lock.json b/src/agentkit/server/frontend/package-lock.json index e740af0..3f609c9 100644 --- a/src/agentkit/server/frontend/package-lock.json +++ b/src/agentkit/server/frontend/package-lock.json @@ -9,6 +9,10 @@ "version": "0.1.0", "dependencies": { "@ant-design/icons-vue": "^7.0.0", + "@fullcalendar/daygrid": "^6.1.0", + "@fullcalendar/interaction": "^6.1.0", + "@fullcalendar/timegrid": "^6.1.0", + "@fullcalendar/vue3": "^6.1.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-shell": "^2", "@vue-flow/background": "^1.3.0", @@ -533,6 +537,56 @@ "node": ">=12" } }, + "node_modules/@fullcalendar/core": { + "version": "6.1.21", + "resolved": "https://registry.npmmirror.com/@fullcalendar/core/-/core-6.1.21.tgz", + "integrity": "sha512-t3u/+sqh3Iq7TWtUnVLcGDUE6OWZh0UD3c04bI/l7lSLAgAKr3kngBmhHiQD1QXpwC8ZN5iNqG7a7gOVixhSKQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "preact": "~10.12.1" + } + }, + "node_modules/@fullcalendar/daygrid": { + "version": "6.1.21", + "resolved": "https://registry.npmmirror.com/@fullcalendar/daygrid/-/daygrid-6.1.21.tgz", + "integrity": "sha512-QYb1y40RGYLlOxKpYWg8O+7njEnKnFG8Tt7qjnubJGR35s1phQg67E+81y2TyAbbm59p2JFOCXGDk9t6KDujIA==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.21" + } + }, + "node_modules/@fullcalendar/interaction": { + "version": "6.1.21", + "resolved": "https://registry.npmmirror.com/@fullcalendar/interaction/-/interaction-6.1.21.tgz", + "integrity": "sha512-WPYpqtljDWmU0Xm2cOtFrLlocgxv7cgkOppj34Q6OUUat8a6Cnd6kYo2JR+irP223PE5lBYHFNp1qh7SIpJc0w==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.21" + } + }, + "node_modules/@fullcalendar/timegrid": { + "version": "6.1.21", + "resolved": "https://registry.npmmirror.com/@fullcalendar/timegrid/-/timegrid-6.1.21.tgz", + "integrity": "sha512-2DnShx/jallGmb8QCkr6pAOu/zuPhJrP7+uTrAtSnbqsX7GF3lTxqSeNGkTQwsgF5g/ia8udhQ+JNYaE+TN1cQ==", + "license": "MIT", + "dependencies": { + "@fullcalendar/daygrid": "~6.1.21" + }, + "peerDependencies": { + "@fullcalendar/core": "~6.1.21" + } + }, + "node_modules/@fullcalendar/vue3": { + "version": "6.1.21", + "resolved": "https://registry.npmmirror.com/@fullcalendar/vue3/-/vue3-6.1.21.tgz", + "integrity": "sha512-OGt6WSC+/zz/ej6a0KfIBNl7BYuGchpZU49SsedYyv3WZWbghAE+D8YD6nhH1ia/I4p5Gcsv/nEXgEkT/I8aYQ==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.21", + "vue": "^3.0.11" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2585,6 +2639,17 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.12.1", + "resolved": "https://registry.npmmirror.com/preact/-/preact-10.12.1.tgz", + "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz", diff --git a/src/agentkit/server/frontend/package.json b/src/agentkit/server/frontend/package.json index 0003f31..49e0492 100644 --- a/src/agentkit/server/frontend/package.json +++ b/src/agentkit/server/frontend/package.json @@ -18,6 +18,10 @@ }, "dependencies": { "@ant-design/icons-vue": "^7.0.0", + "@fullcalendar/daygrid": "^6.1.0", + "@fullcalendar/interaction": "^6.1.0", + "@fullcalendar/timegrid": "^6.1.0", + "@fullcalendar/vue3": "^6.1.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-shell": "^2", "@vue-flow/background": "^1.3.0", diff --git a/src/agentkit/server/frontend/src/api/calendar.ts b/src/agentkit/server/frontend/src/api/calendar.ts new file mode 100644 index 0000000..38e768d --- /dev/null +++ b/src/agentkit/server/frontend/src/api/calendar.ts @@ -0,0 +1,329 @@ +/** Calendar API client — thin wrapper over /api/v1/calendar endpoints. */ + +import { BaseApiClient, getDynamicBaseURL } from './base' + +// ── Domain types (co-located with API client) ────────────────────────── + +export interface ICalendarEvent { + id: string + user_id: string + title: string + description: string + start_time: string // ISO 8601 UTC (KTD-11) + end_time: string // ISO 8601 UTC + is_all_day: boolean + location: string + event_type_id: string | null + rrule: string | null // RFC 5545 RRULE string + source: 'manual' | 'agent' | 'post_extract' + is_invited: boolean + conversation_id: string | null + external_id: string | null + external_provider: string | null + last_modified: string + created_at: string +} + +export interface IEventType { + id: string + user_id: string + name: string + color: string + is_default: boolean +} + +export interface ITag { + id: string + user_id: string + name: string +} + +export interface IInvitation { + id: string + event_id: string + inviter_user_id: string + invitee_email: string + status: 'pending' | 'accepted' | 'declined' | 'tentative' + responded_at: string | null +} + +export interface IExternalCalendarConfig { + id: string + user_id: string + provider: 'caldav' | 'outlook' + credentials: string // '***' on read-back; never real credentials + sync_frequency: number + sync_scope: string[] + last_sync: string | null + sync_token: string | null +} + +export interface IUserSearchResult { + username: string + email: string +} + +// ── Request types ────────────────────────────────────────────────────── + +export interface ICreateEventRequest { + title: string + start_time: string + end_time: string + description?: string + location?: string + is_all_day?: boolean + event_type_id?: string | null + rrule?: string | null + tag_ids?: string[] +} + +export interface IUpdateEventRequest { + title?: string + start_time?: string + end_time?: string + description?: string + location?: string + is_all_day?: boolean + event_type_id?: string | null + rrule?: string | null +} + +export interface ICreateEventTypeRequest { + name: string + color?: string +} + +export interface ICreateTagRequest { + name: string +} + +export interface ICreateInvitationRequest { + event_id: string + invitee_email: string +} + +export interface ICreateExternalConfigRequest { + provider: 'caldav' | 'outlook' + credentials: string // JSON string with provider-specific auth + sync_frequency?: number + sync_scope?: string[] +} + +// ── Runtime type guard ───────────────────────────────────────────────── + +/** + * Runtime guard for ICalendarEvent — validates the minimum fields required + * for the calendar store to function safely. + * ponytail: checks only the keys the store actually reads; full schema + * validation belongs at the API boundary, not in the WS event handler. + */ +export function isCalendarEvent(value: unknown): value is ICalendarEvent { + if (typeof value !== 'object' || value === null) return false + const v = value as Record + return ( + typeof v.id === 'string' && + typeof v.title === 'string' && + typeof v.start_time === 'string' && + typeof v.end_time === 'string' && + typeof v.is_all_day === 'boolean' + ) +} + +// ── API client ───────────────────────────────────────────────────────── + +const API_BASE = '/api/v1/calendar' + +class CalendarApiClient extends BaseApiClient { + constructor(baseUrl: string = API_BASE) { + super(baseUrl) + } + + /** List events with optional date/type/tag filters */ + async listEvents( + start?: string, + end?: string, + eventTypeId?: string, + tagId?: string, + ): Promise<{ success: boolean; events: ICalendarEvent[]; count: number }> { + const params = new URLSearchParams() + if (start) params.set('start', start) + if (end) params.set('end', end) + if (eventTypeId) params.set('type_id', eventTypeId) + if (tagId) params.set('tag_id', tagId) + const qs = params.toString() + const path = qs ? `/events?${qs}` : '/events' + return this.request(path, { method: 'GET' }) + } + + /** Create a new event */ + async createEvent( + data: ICreateEventRequest, + ): Promise<{ success: boolean; event: ICalendarEvent }> { + return this.request('/events', { + method: 'POST', + body: JSON.stringify(data), + }) + } + + /** Get a single event by id */ + async getEvent(id: string): Promise<{ success: boolean; event: ICalendarEvent }> { + return this.request(`/events/${id}`, { method: 'GET' }) + } + + /** Update specific fields of an event (PATCH — partial update) */ + async updateEvent( + id: string, + data: IUpdateEventRequest, + ): Promise<{ success: boolean; event: ICalendarEvent; updated: boolean }> { + return this.request(`/events/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }) + } + + /** Delete an event */ + async deleteEvent(id: string): Promise<{ success: boolean; deleted: boolean }> { + return this.request(`/events/${id}`, { method: 'DELETE' }) + } + + /** List event types for the current user */ + async listEventTypes(): Promise<{ + success: boolean + event_types: IEventType[] + count: number + }> { + return this.request('/event-types', { method: 'GET' }) + } + + /** Create an event type */ + async createEventType( + data: ICreateEventTypeRequest, + ): Promise<{ success: boolean; event_type: IEventType }> { + return this.request('/event-types', { + method: 'POST', + body: JSON.stringify(data), + }) + } + + /** List tags for the current user */ + async listTags(): Promise<{ success: boolean; tags: ITag[]; count: number }> { + return this.request('/tags', { method: 'GET' }) + } + + /** Create a tag */ + async createTag(data: ICreateTagRequest): Promise<{ success: boolean; tag: ITag }> { + return this.request('/tags', { + method: 'POST', + body: JSON.stringify(data), + }) + } + + /** Import events from an uploaded .ics file (multipart) */ + async importIcs(file: File): Promise<{ + success: boolean + imported: number + skipped: number + [key: string]: unknown + }> { + const formData = new FormData() + formData.append('file', file) + return this.request('/import-ics', { + method: 'POST', + body: formData, + headers: {}, // Let browser set Content-Type for FormData + }) + } + + /** + * Build the export-ics download URL for a date range. + * Returns an absolute or relative URL the caller can open/download. + * ponytail: the endpoint returns binary text/calendar, not JSON, so we + * hand back a URL rather than going through request (which JSON-parses). + */ + exportIcs(start?: string, end?: string): string { + const base = getDynamicBaseURL() + const params = new URLSearchParams() + if (start) params.set('start', start) + if (end) params.set('end', end) + const qs = params.toString() + const path = qs ? `/api/v1/calendar/export-ics?${qs}` : '/api/v1/calendar/export-ics' + return base ? `${base}${path}` : path + } + + /** Create an invitation (invite a user to an event by email) */ + async createInvitation( + data: ICreateInvitationRequest, + ): Promise<{ success: boolean; invitation: IInvitation }> { + return this.request(`/events/${data.event_id}/invitations`, { + method: 'POST', + body: JSON.stringify({ invitee_email: data.invitee_email }), + }) + } + + /** List invitations for the current user */ + async listInvitations(): Promise<{ + success: boolean + invitations: IInvitation[] + count: number + }> { + return this.request('/invitations', { method: 'GET' }) + } + + /** Respond to an invitation (accept/decline/tentative) */ + async respondToInvitation( + id: string, + status: 'accepted' | 'declined' | 'tentative', + ): Promise<{ success: boolean; status: string }> { + return this.request(`/invitations/${id}/respond`, { + method: 'POST', + body: JSON.stringify({ status }), + }) + } + + /** Search users by username or email — returns top 10 matches (G5/A3) */ + async searchUsers(q: string): Promise<{ + success: boolean + users: IUserSearchResult[] + count: number + }> { + return this.request(`/users/search?q=${encodeURIComponent(q)}`, { method: 'GET' }) + } + + /** List external calendar configs for the current user */ + async listExternalConfigs(): Promise<{ + success: boolean + configs: IExternalCalendarConfig[] + count: number + }> { + return this.request('/external-configs', { method: 'GET' }) + } + + /** Create an external calendar config */ + async createExternalConfig( + data: ICreateExternalConfigRequest, + ): Promise<{ success: boolean; config: IExternalCalendarConfig }> { + return this.request('/external-configs', { + method: 'POST', + body: JSON.stringify(data), + }) + } + + /** Test connection to an external calendar */ + async testExternalConnection( + id: string, + ): Promise<{ success: boolean; connected: boolean; error?: string }> { + return this.request(`/external-configs/${id}/test`, { method: 'POST' }) + } + + /** Trigger an immediate sync for an external calendar config */ + async syncNow(id: string): Promise<{ success: boolean; synced: boolean; error?: string }> { + return this.request(`/external-configs/${id}/sync`, { method: 'POST' }) + } + + /** Delete an external calendar config */ + async deleteExternalConfig(id: string): Promise<{ success: boolean; deleted: boolean }> { + return this.request(`/external-configs/${id}`, { method: 'DELETE' }) + } +} + +export const calendarApi = new CalendarApiClient() diff --git a/src/agentkit/server/frontend/src/api/types.ts b/src/agentkit/server/frontend/src/api/types.ts index a6f6f26..4330b27 100644 --- a/src/agentkit/server/frontend/src/api/types.ts +++ b/src/agentkit/server/frontend/src/api/types.ts @@ -1,3 +1,5 @@ +import type { ICalendarEvent, IInvitation } from './calendar' + /** Chat request payload */ export interface IChatRequest { message: string @@ -130,6 +132,11 @@ export type WsServerMessage = | { type: 'round_summary'; data: IRoundSummaryData } | { type: 'user_intervention'; data: IUserInterventionData } | { type: 'board_concluded'; data: IBoardConcludedData } + // Calendar 事件 (KTD-10 — piggyback on chat WS) + | { type: 'calendar_event_created'; data: ICalendarEventCreatedData } + | { type: 'calendar_reminder'; data: ICalendarReminderData } + | { type: 'calendar_invitation'; data: ICalendarInvitationData } + | { type: 'calendar_sync_conflict'; data: ICalendarSyncConflictData } /** Expert info within a team */ export interface IExpertInfo { @@ -233,6 +240,39 @@ export interface IBoardMessage { timestamp: number } +// ── Calendar WS 事件 payload 类型 ─────────────────────────────────── + +/** calendar_event_created payload */ +export interface ICalendarEventCreatedData { + event: ICalendarEvent +} + +/** calendar_reminder payload */ +export interface ICalendarReminderData { + event_id: string + title: string + start_time: string + offset_minutes: number + channels: string[] +} + +/** calendar_invitation payload (G6) */ +export interface ICalendarInvitationData { + invitation: IInvitation + event_title: string + inviter_name: string +} + +/** calendar_sync_conflict payload (G4) */ +export interface ICalendarSyncConflictData { + event_id: string + event_title: string + provider: string + local_modified: string + remote_modified: string + resolution: string +} + /** Expert template (matches backend GET /api/v1/experts response item) */ export interface IExpertTemplate { name: string diff --git a/src/agentkit/server/frontend/src/components/calendar/CalendarDrawer.vue b/src/agentkit/server/frontend/src/components/calendar/CalendarDrawer.vue new file mode 100644 index 0000000..77103be --- /dev/null +++ b/src/agentkit/server/frontend/src/components/calendar/CalendarDrawer.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/calendar/CalendarGrid.vue b/src/agentkit/server/frontend/src/components/calendar/CalendarGrid.vue new file mode 100644 index 0000000..1555f52 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/calendar/CalendarGrid.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/calendar/CalendarPanel.vue b/src/agentkit/server/frontend/src/components/calendar/CalendarPanel.vue new file mode 100644 index 0000000..ec3dc5e --- /dev/null +++ b/src/agentkit/server/frontend/src/components/calendar/CalendarPanel.vue @@ -0,0 +1,311 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/calendar/CardView.vue b/src/agentkit/server/frontend/src/components/calendar/CardView.vue new file mode 100644 index 0000000..e7addc3 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/calendar/CardView.vue @@ -0,0 +1,213 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/calendar/EventBadge.vue b/src/agentkit/server/frontend/src/components/calendar/EventBadge.vue new file mode 100644 index 0000000..4bc2d01 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/calendar/EventBadge.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/calendar/EventEditor.vue b/src/agentkit/server/frontend/src/components/calendar/EventEditor.vue new file mode 100644 index 0000000..bfd7417 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/calendar/EventEditor.vue @@ -0,0 +1,332 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/calendar/InvitationManager.vue b/src/agentkit/server/frontend/src/components/calendar/InvitationManager.vue new file mode 100644 index 0000000..5ad888f --- /dev/null +++ b/src/agentkit/server/frontend/src/components/calendar/InvitationManager.vue @@ -0,0 +1,275 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/calendar/ListView.vue b/src/agentkit/server/frontend/src/components/calendar/ListView.vue new file mode 100644 index 0000000..4c84be3 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/calendar/ListView.vue @@ -0,0 +1,248 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/calendar/ReminderConfig.vue b/src/agentkit/server/frontend/src/components/calendar/ReminderConfig.vue new file mode 100644 index 0000000..fe6593f --- /dev/null +++ b/src/agentkit/server/frontend/src/components/calendar/ReminderConfig.vue @@ -0,0 +1,265 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/calendar/SyncSettings.vue b/src/agentkit/server/frontend/src/components/calendar/SyncSettings.vue new file mode 100644 index 0000000..80ae69e --- /dev/null +++ b/src/agentkit/server/frontend/src/components/calendar/SyncSettings.vue @@ -0,0 +1,350 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/layout/AgentLayout.vue b/src/agentkit/server/frontend/src/components/layout/AgentLayout.vue index 1b83ed0..3fb6842 100644 --- a/src/agentkit/server/frontend/src/components/layout/AgentLayout.vue +++ b/src/agentkit/server/frontend/src/components/layout/AgentLayout.vue @@ -74,6 +74,9 @@ + @@ -104,6 +107,7 @@ import { AppstoreOutlined, SettingOutlined, DesktopOutlined, + CalendarOutlined, } from '@ant-design/icons-vue' import { useChatStore } from '@/stores/chat' import TopNav from './TopNav.vue' @@ -121,6 +125,7 @@ const KnowledgeBaseView = defineAsyncComponent(() => import('@/views/KnowledgeBa const EvolutionView = defineAsyncComponent(() => import('@/views/EvolutionView.vue')) const SkillsView = defineAsyncComponent(() => import('@/views/SkillsView.vue')) const SettingsView = defineAsyncComponent(() => import('@/views/SettingsView.vue')) +const CalendarTab = defineAsyncComponent(() => import('./tabs/CalendarTab.vue')) const route = useRoute() const chatStore = useChatStore() @@ -152,6 +157,7 @@ const topRightTabs: QuadrantTab[] = [ const bottomRightTabs: QuadrantTab[] = [ { key: 'monitor', label: '监控', icon: DashboardOutlined as Component }, { key: 'skills', label: '技能', icon: AppstoreOutlined as Component }, + { key: 'calendar', label: '日历', icon: CalendarOutlined as Component }, { key: 'settings', label: '设置', icon: SettingOutlined as Component }, ] diff --git a/src/agentkit/server/frontend/src/components/layout/tabs/CalendarTab.vue b/src/agentkit/server/frontend/src/components/layout/tabs/CalendarTab.vue new file mode 100644 index 0000000..3c8fa1c --- /dev/null +++ b/src/agentkit/server/frontend/src/components/layout/tabs/CalendarTab.vue @@ -0,0 +1,7 @@ + + + diff --git a/src/agentkit/server/frontend/src/stores/calendar.ts b/src/agentkit/server/frontend/src/stores/calendar.ts new file mode 100644 index 0000000..5793720 --- /dev/null +++ b/src/agentkit/server/frontend/src/stores/calendar.ts @@ -0,0 +1,281 @@ +/** + * Pinia store for calendar feature — events, event types, tags, + * invitations, and WebSocket event dispatch. + * + * ponytail: stores/chat.ts handleWsMessage dispatch is deferred — the + * orchestrator will wire the 4 calendar_* WS cases to call handleWsEvent(). + * This store owns the calendar-specific state mutations + notifications. + */ + +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { notification } from 'ant-design-vue' +import { calendarApi, isCalendarEvent } from '@/api/calendar' +import type { + ICalendarEvent, + IEventType, + ITag, + IInvitation, + ICreateEventRequest, + IUpdateEventRequest, + ICreateTagRequest, + IUserSearchResult, +} from '@/api/calendar' +import type { WsServerMessage, ICalendarSyncConflictData } from '@/api/types' + +export type CalendarViewMode = 'calendar' | 'card' | 'list' + +export const useCalendarStore = defineStore('calendar', () => { + // --- State --- + const events = ref([]) + const eventTypes = ref([]) + const tags = ref([]) + const isLoading = ref(false) + const error = ref(null) + const selectedEvent = ref(null) + const viewMode = ref('calendar') + const dateRange = ref<{ start: string | null; end: string | null }>({ + start: null, + end: null, + }) + const pendingInvitations = ref([]) + const syncConflicts = ref([]) + + // --- Getters --- + const upcomingEvents = computed(() => { + const now = new Date().toISOString() + return events.value + .filter((e) => e.start_time >= now) + .sort((a, b) => a.start_time.localeCompare(b.start_time)) + }) + + // --- Actions --- + + /** Load events with current dateRange filter */ + async function loadEvents(): Promise { + isLoading.value = true + error.value = null + try { + const resp = await calendarApi.listEvents( + dateRange.value.start ?? undefined, + dateRange.value.end ?? undefined, + ) + events.value = resp.events || [] + } catch (err) { + error.value = err instanceof Error ? err.message : '加载日程失败' + console.warn('Failed to load calendar events:', err) + } finally { + isLoading.value = false + } + } + + /** Create a new event and add it to state */ + async function createEvent(data: ICreateEventRequest): Promise { + isLoading.value = true + error.value = null + try { + const resp = await calendarApi.createEvent(data) + if (resp.event) { + events.value.push(resp.event) + } + return resp.event ?? null + } catch (err) { + error.value = err instanceof Error ? err.message : '创建日程失败' + console.error('Failed to create event:', err) + throw err + } finally { + isLoading.value = false + } + } + + /** Update an existing event in state */ + async function updateEvent(id: string, data: IUpdateEventRequest): Promise { + error.value = null + try { + const resp = await calendarApi.updateEvent(id, data) + if (resp.event) { + const idx = events.value.findIndex((e) => e.id === id) + if (idx !== -1) { + events.value[idx] = resp.event + } + } + } catch (err) { + error.value = err instanceof Error ? err.message : '更新日程失败' + console.error('Failed to update event:', err) + throw err + } + } + + /** Delete an event from server and state */ + async function deleteEvent(id: string): Promise { + error.value = null + try { + await calendarApi.deleteEvent(id) + events.value = events.value.filter((e) => e.id !== id) + } catch (err) { + error.value = err instanceof Error ? err.message : '删除日程失败' + console.error('Failed to delete event:', err) + throw err + } + } + + /** Load event types for the current user */ + async function loadEventTypes(): Promise { + try { + const resp = await calendarApi.listEventTypes() + eventTypes.value = resp.event_types || [] + } catch (err) { + console.warn('Failed to load event types:', err) + } + } + + /** Load tags for the current user */ + async function loadTags(): Promise { + try { + const resp = await calendarApi.listTags() + tags.value = resp.tags || [] + } catch (err) { + console.warn('Failed to load tags:', err) + } + } + + /** Create a new tag and add it to state */ + async function createTag(data: ICreateTagRequest): Promise { + try { + const resp = await calendarApi.createTag(data) + if (resp.tag) { + tags.value.push(resp.tag) + } + return resp.tag ?? null + } catch (err) { + console.error('Failed to create tag:', err) + throw err + } + } + + + /** Set the calendar view mode */ + function setViewMode(mode: CalendarViewMode): void { + viewMode.value = mode + } + + /** Load pending invitations for the current user */ + async function loadInvitations(): Promise { + try { + const resp = await calendarApi.listInvitations() + pendingInvitations.value = (resp.invitations || []).filter( + (i) => i.status === 'pending', + ) + } catch (err) { + console.warn('Failed to load invitations:', err) + } + } + + /** Respond to an invitation and remove it from pending list */ + async function respondToInvitation( + id: string, + status: 'accepted' | 'declined' | 'tentative', + ): Promise { + try { + await calendarApi.respondToInvitation(id, status) + pendingInvitations.value = pendingInvitations.value.filter((i) => i.id !== id) + } catch (err) { + error.value = err instanceof Error ? err.message : '回复邀请失败' + console.error('Failed to respond to invitation:', err) + throw err + } + } + + /** Search users by username or email (G5/A3) */ + async function searchUsers(q: string): Promise { + if (!q.trim()) return [] + try { + const resp = await calendarApi.searchUsers(q) + return resp.users || [] + } catch (err) { + console.warn('Failed to search users:', err) + return [] + } + } + + /** + * Dispatch calendar WS messages to the appropriate handler. + * Handles 4 message types: calendar_event_created, calendar_reminder, + * calendar_invitation (G6), calendar_sync_conflict (G4). + * Non-calendar message types are silently ignored. + */ + function handleWsEvent(msg: WsServerMessage): void { + switch (msg.type) { + case 'calendar_event_created': { + const event = msg.data.event + if (isCalendarEvent(event)) { + // Avoid duplicates if the event was created locally + if (!events.value.some((e) => e.id === event.id)) { + events.value.push(event) + } + } + break + } + case 'calendar_reminder': { + const d = msg.data + notification.warning({ + message: '日程提醒', + description: `${d.title} · ${d.start_time}`, + duration: 0, + }) + break + } + case 'calendar_invitation': { + const d = msg.data + if (d.invitation) { + pendingInvitations.value.push(d.invitation) + } + notification.info({ + message: '收到日程邀请', + description: `${d.inviter_name} 邀请你参加「${d.event_title}」`, + duration: 0, + }) + break + } + case 'calendar_sync_conflict': { + const d = msg.data + syncConflicts.value.push(d) + notification.warning({ + message: '日历同步冲突', + description: `「${d.event_title}」与 ${d.provider} 同步冲突,已按 ${d.resolution} 策略处理`, + duration: 0, + }) + break + } + } + } + + return { + // State + events, + eventTypes, + tags, + isLoading, + error, + selectedEvent, + viewMode, + dateRange, + pendingInvitations, + syncConflicts, + // Getters + upcomingEvents, + // Actions + loadEvents, + createEvent, + updateEvent, + deleteEvent, + loadEventTypes, + loadTags, + createTag, + setViewMode, + loadInvitations, + respondToInvitation, + searchUsers, + handleWsEvent, + } +}) diff --git a/src/agentkit/server/routes/calendar.py b/src/agentkit/server/routes/calendar.py new file mode 100644 index 0000000..32b0724 --- /dev/null +++ b/src/agentkit/server/routes/calendar.py @@ -0,0 +1,451 @@ +"""REST API routes for calendar operations (U2). + +Thin wrapper over CalendarService. All business logic lives in the +service layer — routes handle HTTP concerns (auth, request validation). + +Endpoints (all under /api/v1/calendar): +- POST /calendar/events — create event +- GET /calendar/events — list with filters (start, end, type_id, tag_id) +- GET /calendar/events/{event_id} — get single event +- PATCH /calendar/events/{event_id} — update event +- DELETE /calendar/events/{event_id} — delete event +- POST /calendar/events/{event_id}/invitations — invite user by email +- POST /calendar/invitations/{invitation_id}/respond — accept/decline/tentative +- GET /calendar/invitations — list invitations for current user +- GET /calendar/users/search?q=xxx — search users (G5/A3) +- GET /calendar/event-types — list event types +- POST /calendar/event-types — create event type +- PATCH /calendar/event-types/{type_id} — update event type +- GET /calendar/tags — list tags +- POST /calendar/tags — create tag +""" + +from __future__ import annotations + +import logging +from typing import Any + +from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile +from fastapi.responses import Response +from pydantic import BaseModel, Field + +from agentkit.calendar.sync.ics_provider import ICSProvider +from agentkit.server.auth.dependencies import require_authenticated + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/calendar", tags=["calendar"]) + +_VALID_INVITATION_STATUSES = {"accepted", "declined", "tentative"} + + +# --------------------------------------------------------------------------- +# Service accessor +# --------------------------------------------------------------------------- + + +def _get_calendar_service(request: Request): + """Get CalendarService from app.state. Raises 503 if not initialized.""" + service = getattr(request.app.state, "calendar_service", None) + if service is None: + raise HTTPException( + status_code=503, + detail="Calendar service not available. Server may not have initialized it.", + ) + return service + + +# --------------------------------------------------------------------------- +# Request / response models +# --------------------------------------------------------------------------- + + +class CreateEventRequest(BaseModel): + title: str + start_time: str + end_time: str + description: str = "" + location: str = "" + is_all_day: bool = False + event_type_id: str | None = None + rrule: str | None = None + tag_ids: list[str] = Field(default_factory=list) + + +class UpdateEventRequest(BaseModel): + title: str | None = None + start_time: str | None = None + end_time: str | None = None + description: str | None = None + location: str | None = None + is_all_day: bool | None = None + event_type_id: str | None = None + rrule: str | None = None + + +class CreateEventTypeRequest(BaseModel): + name: str + color: str = "#4A90D9" + + +class UpdateEventTypeRequest(BaseModel): + name: str | None = None + color: str | None = None + is_default: bool | None = None + + +class CreateTagRequest(BaseModel): + name: str + + +class CreateInvitationRequest(BaseModel): + invitee_email: str + + +class RespondInvitationRequest(BaseModel): + status: str + + +# --------------------------------------------------------------------------- +# Event endpoints +# --------------------------------------------------------------------------- + + +@router.post("/events") +async def create_event( + body: CreateEventRequest, + request: Request, + user: dict = Depends(require_authenticated), +) -> dict[str, Any]: + """Create a new calendar event.""" + service = _get_calendar_service(request) + event = await service.create_event( + user_id=user["user_id"], + title=body.title, + start_time=body.start_time, + end_time=body.end_time, + description=body.description, + location=body.location, + is_all_day=body.is_all_day, + event_type_id=body.event_type_id, + rrule=body.rrule, + source="manual", + tag_ids=body.tag_ids, + ) + return {"success": True, "event": event.to_dict()} + + +@router.get("/events") +async def list_events( + request: Request, + start: str | None = Query(None), + end: str | None = Query(None), + type_id: str | None = Query(None), + tag_id: str | None = Query(None), + user: dict = Depends(require_authenticated), +) -> dict[str, Any]: + """List events for the current user with optional filters.""" + service = _get_calendar_service(request) + events = await service.list_events( + user_id=user["user_id"], + start=start, + end=end, + event_type_id=type_id, + tag_id=tag_id, + ) + return { + "success": True, + "events": [e.to_dict() for e in events], + "count": len(events), + } + + +@router.get("/events/{event_id}") +async def get_event( + event_id: str, + request: Request, + user: dict = Depends(require_authenticated), +) -> dict[str, Any]: + """Get a single event by id.""" + service = _get_calendar_service(request) + event = await service.get_event(event_id) + if event is None: + raise HTTPException(status_code=404, detail="Event not found") + if event.user_id != user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + return {"success": True, "event": event.to_dict()} + + +@router.patch("/events/{event_id}") +async def update_event( + event_id: str, + body: UpdateEventRequest, + request: Request, + user: dict = Depends(require_authenticated), +) -> dict[str, Any]: + """Update specific fields of an event.""" + service = _get_calendar_service(request) + event = await service.get_event(event_id) + if event is None: + raise HTTPException(status_code=404, detail="Event not found") + if event.user_id != user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + + # Build fields dict from non-None values + fields: dict[str, Any] = { + name: value + for name, value in body.model_dump(exclude_unset=True).items() + if value is not None + } + + if not fields: + return {"success": True, "event": event.to_dict(), "updated": False} + + updated = await service.update_event(event_id, fields) + refreshed = await service.get_event(event_id) + return { + "success": True, + "event": refreshed.to_dict() if refreshed else event.to_dict(), + "updated": updated, + } + + +@router.delete("/events/{event_id}") +async def delete_event( + event_id: str, + request: Request, + user: dict = Depends(require_authenticated), +) -> dict[str, Any]: + """Delete an event.""" + service = _get_calendar_service(request) + event = await service.get_event(event_id) + if event is None: + raise HTTPException(status_code=404, detail="Event not found") + if event.user_id != user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + + deleted = await service.delete_event(event_id) + return {"success": True, "deleted": deleted} + + +# --------------------------------------------------------------------------- +# Invitation endpoints +# --------------------------------------------------------------------------- + + +@router.post("/events/{event_id}/invitations") +async def create_invitation( + event_id: str, + body: CreateInvitationRequest, + request: Request, + user: dict = Depends(require_authenticated), +) -> dict[str, Any]: + """Invite a user to an event by email.""" + service = _get_calendar_service(request) + event = await service.get_event(event_id) + if event is None: + raise HTTPException(status_code=404, detail="Event not found") + if event.user_id != user["user_id"]: + raise HTTPException(status_code=403, detail="Only the event owner can invite") + + invitation = await service.create_invitation( + event_id=event_id, + inviter_user_id=user["user_id"], + invitee_email=body.invitee_email, + ) + return {"success": True, "invitation": invitation.to_dict()} + + +@router.post("/invitations/{invitation_id}/respond") +async def respond_to_invitation( + invitation_id: str, + body: RespondInvitationRequest, + request: Request, + user: dict = Depends(require_authenticated), +) -> dict[str, Any]: + """Respond to an invitation (accept/decline/tentative).""" + if body.status not in _VALID_INVITATION_STATUSES: + raise HTTPException( + status_code=400, + detail=f"Invalid status. Must be one of: {sorted(_VALID_INVITATION_STATUSES)}", + ) + + service = _get_calendar_service(request) + invitation = await service.get_invitation(invitation_id) + if invitation is None: + raise HTTPException(status_code=404, detail="Invitation not found") + email = await service.get_user_email(user["user_id"]) + if email is None or invitation.invitee_email != email: + raise HTTPException(status_code=403, detail="Only the invitee can respond") + updated = await service.respond_to_invitation(invitation_id, body.status) + if not updated: + raise HTTPException(status_code=404, detail="Invitation not found") + return {"success": True, "status": body.status} + + +@router.get("/invitations") +async def list_invitations( + request: Request, + user: dict = Depends(require_authenticated), +) -> dict[str, Any]: + """List invitations for the current user (by email).""" + service = _get_calendar_service(request) + email = await service.get_user_email(user["user_id"]) + if email is None: + return {"success": True, "invitations": [], "count": 0} + invitations = await service.list_invitations(email) + return { + "success": True, + "invitations": [inv.to_dict() for inv in invitations], + "count": len(invitations), + } + + +# --------------------------------------------------------------------------- +# User search endpoint (G5/A3) +# --------------------------------------------------------------------------- + + +@router.get("/users/search") +async def search_users( + request: Request, + q: str = Query(..., min_length=1), + user: dict = Depends(require_authenticated), +) -> dict[str, Any]: + """Search users by username or email. Returns top 10 matches. + + Only username and email are returned — never user_id or password + fields (least-privilege, G5/A3). + """ + service = _get_calendar_service(request) + users = await service.search_users(q) + return {"success": True, "users": users, "count": len(users)} + + +# --------------------------------------------------------------------------- +# Event Type endpoints +# --------------------------------------------------------------------------- + + +@router.get("/event-types") +async def list_event_types( + request: Request, + user: dict = Depends(require_authenticated), +) -> dict[str, Any]: + """List all event types for the current user.""" + service = _get_calendar_service(request) + types = await service.list_event_types(user["user_id"]) + return { + "success": True, + "event_types": [t.to_dict() for t in types], + "count": len(types), + } + + +@router.post("/event-types") +async def create_event_type( + body: CreateEventTypeRequest, + request: Request, + user: dict = Depends(require_authenticated), +) -> dict[str, Any]: + """Create a new event type.""" + service = _get_calendar_service(request) + et = await service.create_event_type( + user_id=user["user_id"], + name=body.name, + color=body.color, + ) + return {"success": True, "event_type": et.to_dict()} + + +@router.patch("/event-types/{type_id}") +async def update_event_type( + type_id: str, + body: UpdateEventTypeRequest, + request: Request, + user: dict = Depends(require_authenticated), +) -> dict[str, Any]: + """Update specific fields of an event type.""" + service = _get_calendar_service(request) + et = await service.get_event_type(type_id) + if et is None: + raise HTTPException(status_code=404, detail="Event type not found") + if et.user_id != user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") + fields: dict[str, Any] = {} + for name, value in body.model_dump(exclude_unset=True).items(): + if value is not None: + fields[name] = value + if not fields: + return {"success": True, "updated": False} + updated = await service.update_event_type(type_id, fields) + return {"success": True, "updated": updated} + + +# --------------------------------------------------------------------------- +# Tag endpoints +# --------------------------------------------------------------------------- + + +@router.get("/tags") +async def list_tags( + request: Request, + user: dict = Depends(require_authenticated), +) -> dict[str, Any]: + """List all tags for the current user.""" + service = _get_calendar_service(request) + tags = await service.list_tags(user["user_id"]) + return {"success": True, "tags": [t.to_dict() for t in tags], "count": len(tags)} + + +@router.post("/tags") +async def create_tag( + body: CreateTagRequest, + request: Request, + user: dict = Depends(require_authenticated), +) -> dict[str, Any]: + """Create a new tag.""" + service = _get_calendar_service(request) + tag = await service.create_tag(user_id=user["user_id"], name=body.name) + return {"success": True, "tag": tag.to_dict()} + + +# --------------------------------------------------------------------------- +# ICS import/export (U8) +# --------------------------------------------------------------------------- + + +@router.post("/import-ics") +async def import_ics( + request: Request, + file: UploadFile = File(...), + user: dict = Depends(require_authenticated), +) -> dict[str, Any]: + """Import events from an uploaded .ics file.""" + service = _get_calendar_service(request) + content = await file.read() + provider = ICSProvider(service) + try: + result = await provider.import_ics(content, user["user_id"]) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + return {"success": True, **result} + + +@router.get("/export-ics") +async def export_ics( + request: Request, + start: str | None = Query(None), + end: str | None = Query(None), + user: dict = Depends(require_authenticated), +) -> Response: + """Export the current user's events to a downloadable .ics file.""" + service = _get_calendar_service(request) + events = await service.list_events(user_id=user["user_id"], start=start, end=end) + provider = ICSProvider(service) + ics_bytes = provider.export_ics(events) + return Response( + content=ics_bytes, + media_type="text/calendar", + headers={"Content-Disposition": 'attachment; filename="calendar.ics"'}, + ) diff --git a/src/agentkit/tools/calendar_tool.py b/src/agentkit/tools/calendar_tool.py new file mode 100644 index 0000000..ccdce32 --- /dev/null +++ b/src/agentkit/tools/calendar_tool.py @@ -0,0 +1,291 @@ +"""CalendarTool — Agent tool for calendar event CRUD via ReAct integration. + +Wraps CalendarService so the LLM can create, query, update, and delete +calendar events via function calling. The tool delegates all business logic +to CalendarService — it only handles input validation, name→id resolution +for event types and tags, and result formatting. + +The tool trusts the caller (the agent framework) to provide the correct +user_id; it does not perform auth (same pattern as DocumentTool). +""" + +from __future__ import annotations + +from typing import Any + +from agentkit.calendar.service import CalendarService +from agentkit.tools.base import Tool + + +class CalendarTool(Tool): + """Agent tool for calendar event management. + + Actions: create_event, query_events, update_event, delete_event. + """ + + def __init__(self, calendar_service: CalendarService): + super().__init__( + name="calendar", + description=( + "Create, query, update, and delete calendar events. " + "Actions: create_event, query_events, update_event, delete_event." + ), + input_schema={ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "create_event", + "query_events", + "update_event", + "delete_event", + ], + "description": "Calendar operation to perform.", + }, + "user_id": { + "type": "string", + "description": "User ID owning the calendar events.", + }, + "event_id": { + "type": "string", + "description": "Event ID (for update_event and delete_event).", + }, + "title": { + "type": "string", + "description": "Event title (create_event, update_event).", + }, + "start_time": { + "type": "string", + "description": "Event start time, ISO 8601 UTC (create_event, update_event).", + }, + "end_time": { + "type": "string", + "description": "Event end time, ISO 8601 UTC (create_event, update_event).", + }, + "description": { + "type": "string", + "description": "Event description (create_event, update_event).", + }, + "location": { + "type": "string", + "description": "Event location (create_event, update_event).", + }, + "is_all_day": { + "type": "boolean", + "description": "Whether the event is all-day (create_event, update_event).", + }, + "event_type_name": { + "type": "string", + "description": "Event type name; looked up or created if missing (create_event).", + }, + "tag_names": { + "type": "array", + "items": {"type": "string"}, + "description": "Tag names; each looked up or created if missing (create_event).", + }, + "rrule": { + "type": "string", + "description": "RFC 5545 RRULE recurrence string, e.g. FREQ=WEEKLY;BYDAY=MO;COUNT=10 (create_event).", + }, + "conversation_id": { + "type": "string", + "description": "Conversation ID to associate with the event (create_event).", + }, + "start_date": { + "type": "string", + "description": "Range start, ISO 8601 UTC (query_events).", + }, + "end_date": { + "type": "string", + "description": "Range end, ISO 8601 UTC (query_events).", + }, + "limit": { + "type": "integer", + "description": "Maximum number of events to return (query_events).", + }, + }, + "required": ["action", "user_id"], + }, + ) + self._service = calendar_service + + async def execute(self, **kwargs) -> dict[str, Any]: + action = kwargs.get("action") + + if action == "create_event": + return await self._create_event(**kwargs) + if action == "query_events": + return await self._query_events(**kwargs) + if action == "update_event": + return await self._update_event(**kwargs) + if action == "delete_event": + return await self._delete_event(**kwargs) + return {"success": False, "error": f"Unknown action: {action!r}"} + + # ------------------------------------------------------------------ + # create_event + # ------------------------------------------------------------------ + + async def _create_event(self, **kwargs) -> dict[str, Any]: + user_id = kwargs.get("user_id") + title = kwargs.get("title") + start_time = kwargs.get("start_time") + end_time = kwargs.get("end_time") + + if not user_id: + return {"success": False, "error": "Missing required field: user_id"} + if not title: + return {"success": False, "error": "Missing required field: title"} + if not start_time: + return {"success": False, "error": "Missing required field: start_time"} + if not end_time: + return {"success": False, "error": "Missing required field: end_time"} + + description = kwargs.get("description", "") + location = kwargs.get("location", "") + is_all_day = kwargs.get("is_all_day", False) + rrule = kwargs.get("rrule") + conversation_id = kwargs.get("conversation_id") + + # Resolve event_type_name → event_type_id (look up or create) + event_type_id: str | None = None + event_type_name = kwargs.get("event_type_name") + if event_type_name: + event_type_id = await self._resolve_event_type_id(user_id, event_type_name) + + # Resolve tag_names → tag_ids (look up or create each) + tag_ids: list[str] | None = None + tag_names = kwargs.get("tag_names") + if tag_names: + tag_ids = await self._resolve_tag_ids(user_id, tag_names) + + try: + event = await self._service.create_event( + user_id=user_id, + title=title, + start_time=start_time, + end_time=end_time, + description=description, + location=location, + is_all_day=is_all_day, + event_type_id=event_type_id, + rrule=rrule, + source="agent", + conversation_id=conversation_id, + tag_ids=tag_ids, + ) + return {"success": True, "event": event.to_dict()} + except Exception as e: + return {"success": False, "error": f"create_event failed: {e}"} + + async def _resolve_event_type_id(self, user_id: str, name: str) -> str | None: + """Look up an event type by name for the user; create if not found.""" + existing = await self._service.list_event_types(user_id) + for et in existing: + if et.name == name: + return et.id + et = await self._service.create_event_type(user_id, name) + return et.id + + async def _resolve_tag_ids(self, user_id: str, names: list[str]) -> list[str]: + """Look up tags by name for the user; create each if not found.""" + existing = await self._service.list_tags(user_id) + existing_by_name = {t.name: t.id for t in existing} + tag_ids: list[str] = [] + for name in names: + if name in existing_by_name: + tag_ids.append(existing_by_name[name]) + else: + tag = await self._service.create_tag(user_id, name) + tag_ids.append(tag.id) + return tag_ids + + # ------------------------------------------------------------------ + # query_events + # ------------------------------------------------------------------ + + async def _query_events(self, **kwargs) -> dict[str, Any]: + user_id = kwargs.get("user_id") + if not user_id: + return {"success": False, "error": "Missing required field: user_id"} + + start = kwargs.get("start_date") + end = kwargs.get("end_date") + limit = kwargs.get("limit") + + try: + events = await self._service.list_events( + user_id=user_id, + start=start, + end=end, + ) + if limit is not None: + events = events[:limit] + return {"success": True, "events": [e.to_dict() for e in events]} + except Exception as e: + return {"success": False, "error": f"query_events failed: {e}"} + + # ------------------------------------------------------------------ + # update_event + # ------------------------------------------------------------------ + + async def _update_event(self, **kwargs) -> dict[str, Any]: + event_id = kwargs.get("event_id") + user_id = kwargs.get("user_id") + if not event_id: + return {"success": False, "error": "Missing required field: event_id"} + if not user_id: + return {"success": False, "error": "Missing required field: user_id"} + + # Ownership check + event = await self._service.get_event(event_id) + if event is None: + return {"success": False, "error": "Event not found"} + if event.user_id != user_id: + return {"success": False, "error": "Permission denied"} + + # Build fields dict from updatable params (only those explicitly provided) + updatable = ["title", "description", "start_time", "end_time", "location", "is_all_day"] + fields: dict[str, Any] = {} + for key in updatable: + if key in kwargs and kwargs[key] is not None: + fields[key] = kwargs[key] + + if not fields: + return {"success": False, "error": "No fields to update"} + + try: + updated = await self._service.update_event(event_id, fields) + if not updated: + return {"success": False, "error": f"Event not found: {event_id}"} + return {"success": True} + except Exception as e: + return {"success": False, "error": f"update_event failed: {e}"} + + # ------------------------------------------------------------------ + # delete_event + # ------------------------------------------------------------------ + + async def _delete_event(self, **kwargs) -> dict[str, Any]: + event_id = kwargs.get("event_id") + user_id = kwargs.get("user_id") + if not event_id: + return {"success": False, "error": "Missing required field: event_id"} + if not user_id: + return {"success": False, "error": "Missing required field: user_id"} + + # Ownership check + event = await self._service.get_event(event_id) + if event is None: + return {"success": False, "error": "Event not found"} + if event.user_id != user_id: + return {"success": False, "error": "Permission denied"} + + try: + deleted = await self._service.delete_event(event_id) + if not deleted: + return {"success": False, "error": f"Event not found: {event_id}"} + return {"success": True} + except Exception as e: + return {"success": False, "error": f"delete_event failed: {e}"} diff --git a/tests/unit/calendar/__init__.py b/tests/unit/calendar/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/calendar/test_db.py b/tests/unit/calendar/test_db.py new file mode 100644 index 0000000..ebd4664 --- /dev/null +++ b/tests/unit/calendar/test_db.py @@ -0,0 +1,305 @@ +"""Tests for calendar DB CRUD (U1).""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from agentkit.calendar.db import ( + add_tag_to_event, + delete_event, + delete_event_type, + get_event, + get_event_tags, + init_calendar_db, + insert_event, + insert_event_type, + insert_external_config, + insert_invitation, + insert_reminder_rule, + insert_tag, + list_event_types, + list_events, + list_external_configs, + list_invitations, + list_reminder_rules_for_event, + list_reminder_rules_for_type, + list_tags, + update_event, + update_event_type, + update_invitation_status, +) +from agentkit.calendar.models import ( + CalendarEvent, + EventType, + ExternalCalendarConfig, + Invitation, + ReminderRule, + Tag, + _now_iso, +) + + +@pytest.fixture +def db_path(tmp_path: Path) -> Path: + path = tmp_path / "test_calendar.db" + asyncio.run(init_calendar_db(path)) + return path + + +def _make_event( + event_id: str = "evt-1", + user_id: str = "user-1", + title: str = "Test Event", + start: str = "2026-07-01T10:00:00+00:00", + end: str = "2026-07-01T11:00:00+00:00", + **kwargs, +) -> CalendarEvent: + now = _now_iso() + return CalendarEvent( + id=event_id, + user_id=user_id, + title=title, + start_time=start, + end_time=end, + last_modified=now, + created_at=now, + **kwargs, + ) + + +# --------------------------------------------------------------------------- +# init_calendar_db +# --------------------------------------------------------------------------- + + +def test_init_calendar_db_creates_all_tables(db_path: Path) -> None: + """init_calendar_db creates all 8 tables.""" + import aiosqlite + + async def check(): + async with aiosqlite.connect(str(db_path)) as db: + cursor = await db.execute( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ) + tables = {row[0] for row in await cursor.fetchall()} + return tables + + tables = asyncio.run(check()) + expected = { + "calendar_events", + "calendar_event_types", + "calendar_tags", + "calendar_event_tags", + "calendar_reminder_rules", + "calendar_reminder_deliveries", + "calendar_external_configs", + "calendar_invitations", + } + assert expected.issubset(tables), f"Missing tables: {expected - tables}" + + +# --------------------------------------------------------------------------- +# Event CRUD +# --------------------------------------------------------------------------- + + +def test_insert_and_get_event_roundtrip(db_path: Path) -> None: + """Insert event, fetch by id, all fields preserved including is_invited.""" + event = _make_event(is_invited=True, source="agent", conversation_id="conv-1") + asyncio.run(insert_event(event, db_path)) + + fetched = asyncio.run(get_event("evt-1", db_path)) + assert fetched is not None + assert fetched.id == "evt-1" + assert fetched.title == "Test Event" + assert fetched.is_invited is True + assert fetched.source == "agent" + assert fetched.conversation_id == "conv-1" + + +def test_list_events_by_user_filtered_by_date_range(db_path: Path) -> None: + """Insert 3 events, query range covering 2.""" + for i, day in enumerate([1, 15, 28]): + evt = _make_event( + event_id=f"evt-{i}", + start=f"2026-07-{day:02d}T10:00:00+00:00", + end=f"2026-07-{day:02d}T11:00:00+00:00", + ) + asyncio.run(insert_event(evt, db_path)) + + events = asyncio.run( + list_events( + "user-1", + start="2026-07-10T00:00:00+00:00", + end="2026-07-20T00:00:00+00:00", + db_path=db_path, + ) + ) + assert len(events) == 1 + assert events[0].id == "evt-1" + + +def test_update_event_modifies_fields(db_path: Path) -> None: + """Insert, update title, verify.""" + asyncio.run(insert_event(_make_event(), db_path)) + updated = asyncio.run(update_event("evt-1", {"title": "Updated"}, db_path)) + assert updated is True + + fetched = asyncio.run(get_event("evt-1", db_path)) + assert fetched is not None + assert fetched.title == "Updated" + + +def test_delete_event_removes_record(db_path: Path) -> None: + """Insert, delete, verify gone.""" + asyncio.run(insert_event(_make_event(), db_path)) + deleted = asyncio.run(delete_event("evt-1", db_path)) + assert deleted is True + + fetched = asyncio.run(get_event("evt-1", db_path)) + assert fetched is None + + +# --------------------------------------------------------------------------- +# Event Type CRUD +# --------------------------------------------------------------------------- + + +def test_event_type_crud(db_path: Path) -> None: + """Create/list/update/delete event types.""" + et = EventType(id="type-1", user_id="user-1", name="会议", color="#FF0000") + asyncio.run(insert_event_type(et, db_path)) + + types = asyncio.run(list_event_types("user-1", db_path)) + assert len(types) == 1 + assert types[0].name == "会议" + assert types[0].color == "#FF0000" + + asyncio.run(update_event_type("type-1", {"name": "Meeting"}, db_path)) + types = asyncio.run(list_event_types("user-1", db_path)) + assert types[0].name == "Meeting" + + deleted = asyncio.run(delete_event_type("type-1", db_path)) + assert deleted is True + types = asyncio.run(list_event_types("user-1", db_path)) + assert len(types) == 0 + + +# --------------------------------------------------------------------------- +# Tag CRUD + many-to-many +# --------------------------------------------------------------------------- + + +def test_tag_many_to_many(db_path: Path) -> None: + """Event with 3 tags, query by tag returns event.""" + asyncio.run(insert_event(_make_event(), db_path)) + + for i in range(3): + tag = Tag(id=f"tag-{i}", user_id="user-1", name=f"Tag{i}") + asyncio.run(insert_tag(tag, db_path)) + asyncio.run(add_tag_to_event("evt-1", f"tag-{i}", db_path)) + + # Get tags for event + tags = asyncio.run(get_event_tags("evt-1", db_path)) + assert len(tags) == 3 + + # List events filtered by tag + events = asyncio.run(list_events("user-1", tag_id="tag-1", db_path=db_path)) + assert len(events) == 1 + assert events[0].id == "evt-1" + + # List all tags + all_tags = asyncio.run(list_tags("user-1", db_path)) + assert len(all_tags) == 3 + + +# --------------------------------------------------------------------------- +# Reminder Rule CRUD +# --------------------------------------------------------------------------- + + +def test_reminder_rule_crud(db_path: Path) -> None: + """Create rule for event, create default rule for type.""" + asyncio.run(insert_event(_make_event(), db_path)) + asyncio.run( + insert_event_type(EventType(id="type-1", user_id="user-1", name="Meeting"), db_path) + ) + + # Event-level rule + rule1 = ReminderRule( + id="rule-1", event_id="evt-1", offset_minutes=-15, channels=["client", "email"] + ) + asyncio.run(insert_reminder_rule(rule1, db_path)) + + # Type-level default rule + rule2 = ReminderRule( + id="rule-2", event_type_id="type-1", offset_minutes=-1440, channels=["email"] + ) + asyncio.run(insert_reminder_rule(rule2, db_path)) + + event_rules = asyncio.run(list_reminder_rules_for_event("evt-1", db_path)) + assert len(event_rules) == 1 + assert event_rules[0].offset_minutes == -15 + assert event_rules[0].channels == ["client", "email"] + + type_rules = asyncio.run(list_reminder_rules_for_type("type-1", db_path)) + assert len(type_rules) == 1 + assert type_rules[0].offset_minutes == -1440 + + +# --------------------------------------------------------------------------- +# External Config +# --------------------------------------------------------------------------- + + +def test_external_config_stores_encrypted_credentials(db_path: Path) -> None: + """Insert config, verify credentials field is opaque (stored as-is).""" + config = ExternalCalendarConfig( + id="cfg-1", + user_id="user-1", + provider="caldav", + credentials='{"url":"https://caldav.icloud.com","user":"alice","pass":"xxx"}', + sync_frequency=15, + sync_scope=["type-1", "type-2"], + ) + asyncio.run(insert_external_config(config, db_path)) + + configs = asyncio.run(list_external_configs("user-1", db_path)) + assert len(configs) == 1 + assert configs[0].provider == "caldav" + assert configs[0].sync_frequency == 15 + assert configs[0].sync_scope == ["type-1", "type-2"] + # Credentials stored as-is (encryption happens at service layer) + assert "caldav.icloud.com" in configs[0].credentials + + +# --------------------------------------------------------------------------- +# Invitation CRUD +# --------------------------------------------------------------------------- + + +def test_invitation_crud(db_path: Path) -> None: + """Create invitation, list by email, update status.""" + asyncio.run(insert_event(_make_event(), db_path)) + + inv = Invitation( + id="inv-1", + event_id="evt-1", + inviter_user_id="user-1", + invitee_email="alice@example.com", + ) + asyncio.run(insert_invitation(inv, db_path)) + + invs = asyncio.run(list_invitations("alice@example.com", db_path)) + assert len(invs) == 1 + assert invs[0].status == "pending" + + now = _now_iso() + asyncio.run(update_invitation_status("inv-1", "accepted", now, db_path)) + + invs = asyncio.run(list_invitations("alice@example.com", db_path)) + assert invs[0].status == "accepted" + assert invs[0].responded_at == now diff --git a/tests/unit/calendar/test_extraction.py b/tests/unit/calendar/test_extraction.py new file mode 100644 index 0000000..6f0d17f --- /dev/null +++ b/tests/unit/calendar/test_extraction.py @@ -0,0 +1,385 @@ +"""Tests for PostProcessingExtractor (U4).""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +import pytest + +from agentkit.calendar.db import init_calendar_db +from agentkit.calendar.extraction import PostProcessingExtractor +from agentkit.calendar.service import CalendarService + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def calendar_db_path(tmp_path: Path) -> Path: + path = tmp_path / "test_calendar.db" + asyncio.run(init_calendar_db(path)) + return path + + +@pytest.fixture +def service(calendar_db_path: Path) -> CalendarService: + return CalendarService(db_path=calendar_db_path) + + +@pytest.fixture +def extractor(service: CalendarService) -> PostProcessingExtractor: + return PostProcessingExtractor(calendar_service=service) + + +class MockLLMGateway: + """Minimal async mock for the LLM gateway.""" + + def __init__(self, response: str) -> None: + self.response = response + self.called = False + self.call_count = 0 + + async def acomplete(self, messages, temperature: float = 0.1) -> str: + self.called = True + self.call_count += 1 + return self.response + + +# --------------------------------------------------------------------------- +# Keyword regex gate +# --------------------------------------------------------------------------- + + +def test_keyword_regex_matches_chinese_time_words(extractor: PostProcessingExtractor) -> None: + """Chinese time words trigger the keyword gate.""" + assert extractor._KEYWORD_RE.search("明天下午3点开会") is not None + assert extractor._KEYWORD_RE.search("后天截止") is not None + assert extractor._KEYWORD_RE.search("下周安排一下") is not None + # No time words — should not match + assert extractor._KEYWORD_RE.search("继续优化吧") is None + assert extractor._KEYWORD_RE.search("好的,没问题") is None + + +def test_keyword_regex_matches_english_time_words(extractor: PostProcessingExtractor) -> None: + """English time words trigger the keyword gate (case-insensitive).""" + assert extractor._KEYWORD_RE.search("deadline tomorrow") is not None + assert extractor._KEYWORD_RE.search("Schedule a meeting") is not None + assert extractor._KEYWORD_RE.search("set a reminder") is not None + # No time words — should not match + assert extractor._KEYWORD_RE.search("hello world") is None + assert extractor._KEYWORD_RE.search("how are you") is None + + +# --------------------------------------------------------------------------- +# Keyword gate skips LLM +# --------------------------------------------------------------------------- + + +async def test_no_keyword_skips_llm_call( + extractor: PostProcessingExtractor, service: CalendarService +) -> None: + """No keyword in text → LLM gateway never called, returns [].""" + gateway = MockLLMGateway(response="[]") + extractor.llm_gateway = gateway + + result = await extractor.extract( + conversation_text="好的,我们继续优化代码吧", + conversation_id="conv-1", + user_id="user-1", + ) + + assert result == [] + assert gateway.called is False + assert gateway.call_count == 0 + + +# --------------------------------------------------------------------------- +# Keyword hit triggers LLM extraction +# --------------------------------------------------------------------------- + + +async def test_keyword_hit_triggers_llm_extraction( + extractor: PostProcessingExtractor, +) -> None: + """Keyword present → LLM called → event created with source='post_extract'.""" + llm_response = json.dumps( + [ + { + "title": "团队会议", + "start_time": "2026-07-01T10:00:00+00:00", + "end_time": "2026-07-01T11:00:00+00:00", + "description": "周会", + } + ] + ) + gateway = MockLLMGateway(response=llm_response) + extractor.llm_gateway = gateway + + result = await extractor.extract( + conversation_text="明天下午3点开个会", + conversation_id="conv-42", + user_id="user-1", + ) + + assert gateway.called is True + assert gateway.call_count == 1 + assert len(result) == 1 + event = result[0] + assert event["title"] == "团队会议" + assert event["source"] == "post_extract" + assert event["start_time"] == "2026-07-01T10:00:00+00:00" + assert event["end_time"] == "2026-07-01T11:00:00+00:00" + assert event["description"] == "周会" + + +# --------------------------------------------------------------------------- +# LLM returns empty array +# --------------------------------------------------------------------------- + + +async def test_llm_returns_empty_array_creates_nothing( + extractor: PostProcessingExtractor, +) -> None: + """LLM returns [] → no events created.""" + gateway = MockLLMGateway(response="[]") + extractor.llm_gateway = gateway + + result = await extractor.extract( + conversation_text="明天有个安排", + conversation_id="conv-1", + user_id="user-1", + ) + + assert result == [] + assert gateway.called is True + + +# --------------------------------------------------------------------------- +# Malformed LLM response +# --------------------------------------------------------------------------- + + +async def test_malformed_llm_response_handled_gracefully( + extractor: PostProcessingExtractor, +) -> None: + """Invalid JSON response → no crash, returns [].""" + gateway = MockLLMGateway(response="this is not json at all") + extractor.llm_gateway = gateway + + result = await extractor.extract( + conversation_text="明天开会", + conversation_id="conv-1", + user_id="user-1", + ) + + assert result == [] + + +async def test_malformed_llm_response_json_object_not_array( + extractor: PostProcessingExtractor, +) -> None: + """JSON object (not array) → treated as no events.""" + gateway = MockLLMGateway(response='{"title": "会议"}') + extractor.llm_gateway = gateway + + result = await extractor.extract( + conversation_text="明天开会", + conversation_id="conv-1", + user_id="user-1", + ) + + assert result == [] + + +# --------------------------------------------------------------------------- +# conversation_id traceability +# --------------------------------------------------------------------------- + + +async def test_extracted_events_have_conversation_id( + extractor: PostProcessingExtractor, +) -> None: + """Extracted events carry the conversation_id for traceability.""" + llm_response = json.dumps( + [ + { + "title": "评审会", + "start_time": "2026-07-01T14:00:00+00:00", + "end_time": "2026-07-01T15:00:00+00:00", + "description": "", + } + ] + ) + gateway = MockLLMGateway(response=llm_response) + extractor.llm_gateway = gateway + + result = await extractor.extract( + conversation_text="后天下午2点评审会", + conversation_id="conv-trace-99", + user_id="user-7", + ) + + assert len(result) == 1 + assert result[0]["conversation_id"] == "conv-trace-99" + assert result[0]["user_id"] == "user-7" + + +# --------------------------------------------------------------------------- +# Async / non-blocking +# --------------------------------------------------------------------------- + + +async def test_extraction_does_not_block_chat_response( + extractor: PostProcessingExtractor, +) -> None: + """extract() is awaitable and returns a list (inherent async guarantee).""" + gateway = MockLLMGateway(response="[]") + extractor.llm_gateway = gateway + + # Awaiting must yield a list, not a coroutine or other object. + result = await extractor.extract( + conversation_text="明天deadline", + conversation_id="conv-1", + user_id="user-1", + ) + assert isinstance(result, list) + + +# --------------------------------------------------------------------------- +# No LLM gateway configured +# --------------------------------------------------------------------------- + + +async def test_no_llm_gateway_returns_empty( + extractor: PostProcessingExtractor, +) -> None: + """llm_gateway=None + keyword hit → returns [] without error.""" + assert extractor.llm_gateway is None + + result = await extractor.extract( + conversation_text="明天开会", + conversation_id="conv-1", + user_id="user-1", + ) + + assert result == [] + + +# --------------------------------------------------------------------------- +# Code-fenced LLM response +# --------------------------------------------------------------------------- + + +async def test_llm_response_with_code_fences_parsed( + extractor: PostProcessingExtractor, +) -> None: + """LLM wraps JSON in ```json ... ``` fences → parsed correctly.""" + payload = json.dumps( + [ + { + "title": "站会", + "start_time": "2026-07-01T09:00:00+00:00", + "end_time": "2026-07-01T09:15:00+00:00", + "description": "每日站会", + } + ] + ) + fenced = f"```json\n{payload}\n```" + gateway = MockLLMGateway(response=fenced) + extractor.llm_gateway = gateway + + result = await extractor.extract( + conversation_text="明天上午开站会", + conversation_id="conv-1", + user_id="user-1", + ) + + assert len(result) == 1 + assert result[0]["title"] == "站会" + assert result[0]["description"] == "每日站会" + + +# --------------------------------------------------------------------------- +# Multiple events +# --------------------------------------------------------------------------- + + +async def test_multiple_events_extracted( + extractor: PostProcessingExtractor, +) -> None: + """LLM returns 3 events → 3 events created.""" + llm_response = json.dumps( + [ + { + "title": "会议A", + "start_time": "2026-07-01T09:00:00+00:00", + "end_time": "2026-07-01T10:00:00+00:00", + "description": "", + }, + { + "title": "会议B", + "start_time": "2026-07-02T14:00:00+00:00", + "end_time": "2026-07-02T15:00:00+00:00", + "description": "", + }, + { + "title": "截止日期", + "start_time": "2026-07-05T23:59:00+00:00", + "end_time": "2026-07-05T23:59:00+00:00", + "description": "提交报告", + }, + ] + ) + gateway = MockLLMGateway(response=llm_response) + extractor.llm_gateway = gateway + + result = await extractor.extract( + conversation_text="本周有几个安排和截止", + conversation_id="conv-multi", + user_id="user-1", + ) + + assert len(result) == 3 + titles = {e["title"] for e in result} + assert titles == {"会议A", "会议B", "截止日期"} + for event in result: + assert event["source"] == "post_extract" + assert event["conversation_id"] == "conv-multi" + + +# --------------------------------------------------------------------------- +# Items without 'title' key are filtered out +# --------------------------------------------------------------------------- + + +async def test_items_without_title_filtered( + extractor: PostProcessingExtractor, +) -> None: + """Dict items missing 'title' are dropped by the parser.""" + llm_response = json.dumps( + [ + { + "title": "有效会议", + "start_time": "2026-07-01T09:00:00+00:00", + "end_time": "2026-07-01T10:00:00+00:00", + "description": "", + }, + {"start_time": "2026-07-02T09:00:00+00:00", "end_time": "2026-07-02T10:00:00+00:00"}, + "not-a-dict", + ] + ) + gateway = MockLLMGateway(response=llm_response) + extractor.llm_gateway = gateway + + result = await extractor.extract( + conversation_text="明天开会", + conversation_id="conv-1", + user_id="user-1", + ) + + assert len(result) == 1 + assert result[0]["title"] == "有效会议" diff --git a/tests/unit/calendar/test_ics_provider.py b/tests/unit/calendar/test_ics_provider.py new file mode 100644 index 0000000..f69e365 --- /dev/null +++ b/tests/unit/calendar/test_ics_provider.py @@ -0,0 +1,272 @@ +"""Tests for ICSProvider — iCalendar import/export (U8).""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest +from icalendar import Calendar + +from agentkit.calendar.db import init_calendar_db, list_events as db_list_events +from agentkit.calendar.service import CalendarService +from agentkit.calendar.sync.ics_provider import ICSProvider +from agentkit.server.auth.models import init_auth_db + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def calendar_db_path(tmp_path: Path) -> Path: + path = tmp_path / "test_calendar.db" + asyncio.run(init_calendar_db(path)) + return path + + +@pytest.fixture +def auth_db_path(tmp_path: Path) -> Path: + path = tmp_path / "test_auth.db" + asyncio.run(init_auth_db(path)) + return path + + +@pytest.fixture +def service(calendar_db_path: Path, auth_db_path: Path) -> CalendarService: + return CalendarService(db_path=calendar_db_path, auth_db_path=auth_db_path) + + +@pytest.fixture +def provider(service: CalendarService) -> ICSProvider: + return ICSProvider(service) + + +USER_ID = "user-1" + + +# --------------------------------------------------------------------------- +# ICS sample strings +# --------------------------------------------------------------------------- + +SIMPLE_ICS = ( + b"BEGIN:VCALENDAR\n" + b"VERSION:2.0\n" + b"PRODID:-//Test//Test//EN\n" + b"BEGIN:VEVENT\n" + b"UID:simple-uid@test\n" + b"SUMMARY:Test Event\n" + b"DTSTART:20260701T100000Z\n" + b"DTEND:20260701T110000Z\n" + b"DESCRIPTION:Test description\n" + b"LOCATION:Room A\n" + b"END:VEVENT\n" + b"END:VCALENDAR\n" +) + +RECURRING_ICS = ( + b"BEGIN:VCALENDAR\n" + b"VERSION:2.0\n" + b"PRODID:-//Test//Test//EN\n" + b"BEGIN:VEVENT\n" + b"UID:recur-uid@test\n" + b"SUMMARY:Weekly Meeting\n" + b"DTSTART:20260706T100000Z\n" + b"DTEND:20260706T110000Z\n" + b"RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=4\n" + b"END:VEVENT\n" + b"END:VCALENDAR\n" +) + +ALL_DAY_ICS = ( + b"BEGIN:VCALENDAR\n" + b"VERSION:2.0\n" + b"PRODID:-//Test//Test//EN\n" + b"BEGIN:VEVENT\n" + b"UID:allday-uid@test\n" + b"SUMMARY:All Day Event\n" + b"DTSTART;VALUE=DATE:20260701\n" + b"DTEND;VALUE=DATE:20260702\n" + b"END:VEVENT\n" + b"END:VCALENDAR\n" +) + + +# --------------------------------------------------------------------------- +# Import tests +# --------------------------------------------------------------------------- + + +async def test_import_simple_ics_creates_event( + provider: ICSProvider, calendar_db_path: Path +) -> None: + """Single VEVENT ICS → event created with correct fields.""" + result = await provider.import_ics(SIMPLE_ICS, USER_ID) + + assert result["imported"] == 1 + assert result["skipped"] == 0 + assert result["errors"] == [] + + events = await db_list_events(USER_ID, db_path=calendar_db_path) + assert len(events) == 1 + event = events[0] + assert event.title == "Test Event" + assert event.description == "Test description" + assert event.location == "Room A" + assert event.start_time == "2026-07-01T10:00:00+00:00" + assert event.end_time == "2026-07-01T11:00:00+00:00" + assert event.is_all_day is False + assert event.external_id == "simple-uid@test" + assert event.external_provider == "ics" + + +async def test_import_recurring_ics_preserves_rrule( + provider: ICSProvider, calendar_db_path: Path +) -> None: + """VEVENT with RRULE → rrule field set on the created event.""" + result = await provider.import_ics(RECURRING_ICS, USER_ID) + + assert result["imported"] == 1 + assert result["errors"] == [] + + events = await db_list_events(USER_ID, db_path=calendar_db_path) + assert len(events) == 1 + event = events[0] + assert event.rrule is not None + assert "FREQ=WEEKLY" in event.rrule + assert "BYDAY=MO" in event.rrule + assert "COUNT=4" in event.rrule + + +async def test_import_all_day_event(provider: ICSProvider, calendar_db_path: Path) -> None: + """DTSTART is date (not datetime) → is_all_day=True, ISO date stored.""" + result = await provider.import_ics(ALL_DAY_ICS, USER_ID) + + assert result["imported"] == 1 + assert result["errors"] == [] + + events = await db_list_events(USER_ID, db_path=calendar_db_path) + assert len(events) == 1 + event = events[0] + assert event.is_all_day is True + assert event.start_time == "2026-07-01" + assert event.end_time == "2026-07-02" + + +async def test_import_skips_duplicate_uid(provider: ICSProvider, calendar_db_path: Path) -> None: + """Importing the same ICS twice → second import skips the duplicate UID.""" + first = await provider.import_ics(SIMPLE_ICS, USER_ID) + assert first["imported"] == 1 + assert first["skipped"] == 0 + + second = await provider.import_ics(SIMPLE_ICS, USER_ID) + assert second["imported"] == 0 + assert second["skipped"] == 1 + + events = await db_list_events(USER_ID, db_path=calendar_db_path) + assert len(events) == 1 # Still only one event + + +async def test_import_malformed_ics_raises_error(provider: ICSProvider) -> None: + """Invalid ICS bytes → ValueError raised (graceful, not a crash).""" + with pytest.raises(ValueError, match="Failed to parse ICS"): + await provider.import_ics(b"this is definitely not valid ics at all", USER_ID) + + +# --------------------------------------------------------------------------- +# Export tests +# --------------------------------------------------------------------------- + + +async def test_export_produces_valid_ics(provider: ICSProvider, service: CalendarService) -> None: + """Create event, export, parse result with icalendar → roundtrip.""" + await service.create_event( + user_id=USER_ID, + title="Export Test", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + description="Desc", + location="Room B", + ) + + events = await service.list_events(USER_ID) + ics_bytes = provider.export_ics(events) + + # Parse the exported ICS back + cal = Calendar.from_ical(ics_bytes) + vevents = list(cal.walk("VEVENT")) + assert len(vevents) == 1 + vevent = vevents[0] + assert str(vevent.get("SUMMARY")) == "Export Test" + assert str(vevent.get("DESCRIPTION")) == "Desc" + assert str(vevent.get("LOCATION")) == "Room B" + # DTSTART should be a datetime (not all-day) + dtstart = vevent.get("DTSTART").dt + assert dtstart.year == 2026 + assert dtstart.month == 7 + assert dtstart.day == 1 + + +async def test_export_includes_recurrence(provider: ICSProvider, service: CalendarService) -> None: + """Event with rrule → exported ICS contains RRULE line.""" + await service.create_event( + user_id=USER_ID, + title="Recurring Export", + start_time="2026-07-06T10:00:00+00:00", + end_time="2026-07-06T11:00:00+00:00", + rrule="FREQ=WEEKLY;BYDAY=MO;COUNT=4", + ) + + events = await service.list_events(USER_ID) + ics_bytes = provider.export_ics(events) + + cal = Calendar.from_ical(ics_bytes) + vevents = list(cal.walk("VEVENT")) + assert len(vevents) == 1 + vevent = vevents[0] + rrule = vevent.get("RRULE") + assert rrule is not None + rrule_str = rrule.to_ical().decode("utf-8") + assert "FREQ=WEEKLY" in rrule_str + assert "BYDAY=MO" in rrule_str + assert "COUNT=4" in rrule_str + + +async def test_export_date_range_filter(provider: ICSProvider, service: CalendarService) -> None: + """3 events, export range covering 2 → only 2 in ICS output.""" + # Event 1: July 1 + await service.create_event( + user_id=USER_ID, + title="Event 1", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + ) + # Event 2: July 5 + await service.create_event( + user_id=USER_ID, + title="Event 2", + start_time="2026-07-05T10:00:00+00:00", + end_time="2026-07-05T11:00:00+00:00", + ) + # Event 3: July 20 + await service.create_event( + user_id=USER_ID, + title="Event 3", + start_time="2026-07-20T10:00:00+00:00", + end_time="2026-07-20T11:00:00+00:00", + ) + + # Export range: July 1 – July 10 (covers Event 1 and Event 2) + events = await service.list_events( + USER_ID, + start="2026-07-01T00:00:00+00:00", + end="2026-07-10T00:00:00+00:00", + ) + ics_bytes = provider.export_ics(events) + + cal = Calendar.from_ical(ics_bytes) + vevents = list(cal.walk("VEVENT")) + assert len(vevents) == 2 + summaries = {str(v.get("SUMMARY")) for v in vevents} + assert summaries == {"Event 1", "Event 2"} diff --git a/tests/unit/calendar/test_recurrence.py b/tests/unit/calendar/test_recurrence.py new file mode 100644 index 0000000..99024ab --- /dev/null +++ b/tests/unit/calendar/test_recurrence.py @@ -0,0 +1,75 @@ +"""Tests for RRULE recurrence expansion (U1).""" + +from __future__ import annotations + +from agentkit.calendar.recurrence import expand_rrule + + +def test_expand_rrule_weekly_count() -> None: + """FREQ=WEEKLY;BYDAY=MO;COUNT=4 from Monday → 4 occurrences.""" + result = expand_rrule( + "FREQ=WEEKLY;BYDAY=MO;COUNT=4", + "2026-07-06T10:00:00+00:00", # Monday + ) + assert len(result) == 4 + # All should be Mondays + for occ in result: + # 2026-07-06 is Monday, 07-13, 07-20, 07-27 + assert "T10:00:00+00:00" in occ + + +def test_expand_rrule_daily_range_filter() -> None: + """FREQ=DAILY starting Jan 1, range Jan 3–Jan 5 → 3 occurrences.""" + result = expand_rrule( + "FREQ=DAILY", + "2026-01-01T00:00:00+00:00", + range_start="2026-01-03T00:00:00+00:00", + range_end="2026-01-06T00:00:00+00:00", + ) + assert len(result) == 3 # Jan 3, 4, 5 + + +def test_expand_rrule_until_clause() -> None: + """FREQ=DAILY;UNTIL=20260131 → occurrences stop at Jan 31.""" + result = expand_rrule( + "FREQ=DAILY;UNTIL=20260131T235959Z", + "2026-01-29T00:00:00+00:00", + ) + assert len(result) == 3 # Jan 29, 30, 31 + + +def test_expand_rrule_no_rrule_returns_single() -> None: + """rrule=None → returns [start_time] only.""" + result = expand_rrule(None, "2026-07-01T10:00:00+00:00") + assert result == ["2026-07-01T10:00:00+00:00"] + + result = expand_rrule("", "2026-07-01T10:00:00+00:00") + assert result == ["2026-07-01T10:00:00+00:00"] + + +def test_expand_rrule_all_day_event() -> None: + """All-day event with RRULE, verify date expansion (no time component issues).""" + result = expand_rrule( + "FREQ=DAILY;COUNT=3", + "2026-07-01T00:00:00+00:00", + ) + assert len(result) == 3 + assert result[0].startswith("2026-07-01") + assert result[1].startswith("2026-07-02") + assert result[2].startswith("2026-07-03") + + +def test_expand_rrule_dst_boundary() -> None: + """Event crossing DST transition, verify UTC consistency. + + March 8, 2026 is US DST spring-forward. A daily event starting + March 7 should produce correct UTC occurrences across the boundary. + """ + result = expand_rrule( + "FREQ=DAILY;COUNT=3", + "2026-03-07T10:00:00+00:00", + ) + assert len(result) == 3 + # All should be at 10:00 UTC regardless of DST + for occ in result: + assert "T10:00:00+00:00" in occ diff --git a/tests/unit/calendar/test_reminders.py b/tests/unit/calendar/test_reminders.py new file mode 100644 index 0000000..dfe53f9 --- /dev/null +++ b/tests/unit/calendar/test_reminders.py @@ -0,0 +1,160 @@ +"""Tests for ReminderDispatcher (U5).""" + +from __future__ import annotations + +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +from agentkit.calendar.models import CalendarEvent, _now_iso +from agentkit.calendar.reminders import ReminderDispatcher, SmtpConfig + + +def _make_event() -> CalendarEvent: + now = _now_iso() + return CalendarEvent( + id="evt-1", + user_id="user-1", + title="Test Meeting", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + last_modified=now, + created_at=now, + ) + + +# --------------------------------------------------------------------------- +# Client channel +# --------------------------------------------------------------------------- + + +async def test_client_channel_sends_ws_message() -> None: + """Mock WS sender callback, verify called with correct payload.""" + ws_sender = AsyncMock() + dispatcher = ReminderDispatcher(ws_sender=ws_sender) + + event = _make_event() + result = await dispatcher.dispatch("client", event, "user-1") + + assert result is True + ws_sender.assert_called_once() + call_args = ws_sender.call_args + assert call_args.args[0] == "user-1" + assert call_args.args[1]["type"] == "calendar_reminder" + assert call_args.args[1]["data"]["title"] == "Test Meeting" + + +async def test_client_channel_returns_false_without_sender() -> None: + """No ws_sender configured → returns False.""" + dispatcher = ReminderDispatcher() + result = await dispatcher.dispatch("client", _make_event(), "user-1") + assert result is False + + +# --------------------------------------------------------------------------- +# Email channel +# --------------------------------------------------------------------------- + + +async def test_email_channel_sends_smtp() -> None: + """Mock aiosmtplib via sys.modules injection, verify send called.""" + mock_aiosmtplib = MagicMock() + mock_aiosmtplib.send = AsyncMock() + + with patch.dict(sys.modules, {"aiosmtplib": mock_aiosmtplib}): + dispatcher = ReminderDispatcher( + smtp_config=SmtpConfig(host="smtp.example.com", port=587), + get_user_email=AsyncMock(return_value="user@example.com"), + ) + event = _make_event() + result = await dispatcher.dispatch("email", event, "user-1") + + assert result is True + mock_aiosmtplib.send.assert_called_once() + call_kwargs = mock_aiosmtplib.send.call_args.kwargs + assert call_kwargs["hostname"] == "smtp.example.com" + assert call_kwargs["port"] == 587 + # Message body contains event title and recipient + message_body = mock_aiosmtplib.send.call_args.args[0] + assert "user@example.com" in message_body + assert "Test Meeting" in message_body + + +async def test_email_channel_returns_false_without_config() -> None: + """No smtp_config → returns False.""" + dispatcher = ReminderDispatcher() + result = await dispatcher.dispatch("email", _make_event(), "user-1") + assert result is False + + +async def test_email_channel_returns_false_when_user_has_no_email() -> None: + """get_user_email returns None → returns False.""" + dispatcher = ReminderDispatcher( + smtp_config=SmtpConfig(), + get_user_email=AsyncMock(return_value=None), + ) + result = await dispatcher.dispatch("email", _make_event(), "user-1") + assert result is False + + +# --------------------------------------------------------------------------- +# Webhook channel +# --------------------------------------------------------------------------- + + +async def test_webhook_channel_posts_to_url() -> None: + """Mock httpx.AsyncClient, verify POST called with event payload.""" + dispatcher = ReminderDispatcher(webhook_url="https://example.com/hook") + + mock_response = MagicMock() + mock_response.status_code = 200 + + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + + with patch("httpx.AsyncClient", return_value=mock_client): + event = _make_event() + result = await dispatcher.dispatch("webhook", event, "user-1") + + assert result is True + mock_client.post.assert_called_once() + call_kwargs = mock_client.post.call_args.kwargs + assert call_kwargs["json"]["event"]["title"] == "Test Meeting" + assert call_kwargs["json"]["user_id"] == "user-1" + + +async def test_webhook_channel_returns_false_on_4xx() -> None: + """Webhook returns 500 → returns False.""" + dispatcher = ReminderDispatcher(webhook_url="https://example.com/hook") + + mock_response = MagicMock() + mock_response.status_code = 500 + + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + + with patch("httpx.AsyncClient", return_value=mock_client): + result = await dispatcher.dispatch("webhook", _make_event(), "user-1") + assert result is False + + +async def test_webhook_channel_returns_false_without_url() -> None: + """No webhook_url configured → returns False.""" + dispatcher = ReminderDispatcher() + result = await dispatcher.dispatch("webhook", _make_event(), "user-1") + assert result is False + + +# --------------------------------------------------------------------------- +# Unknown channel +# --------------------------------------------------------------------------- + + +async def test_unknown_channel_returns_false() -> None: + """Unknown channel name → returns False, no crash.""" + dispatcher = ReminderDispatcher() + result = await dispatcher.dispatch("sms", _make_event(), "user-1") + assert result is False diff --git a/tests/unit/calendar/test_routes.py b/tests/unit/calendar/test_routes.py new file mode 100644 index 0000000..ca8ed6a --- /dev/null +++ b/tests/unit/calendar/test_routes.py @@ -0,0 +1,270 @@ +"""Tests for calendar REST API routes (U2).""" + +from __future__ import annotations + +import asyncio +import uuid +from pathlib import Path +from typing import Any + +import aiosqlite +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from agentkit.calendar.db import init_calendar_db +from agentkit.calendar.service import CalendarService +from agentkit.server.auth.dependencies import require_authenticated +from agentkit.server.auth.models import init_auth_db +from agentkit.server.routes import calendar as calendar_routes + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +TEST_USER_ID = "test-user-id" +TEST_USER_EMAIL = "testuser@example.com" + + +def _make_test_user() -> dict[str, Any]: + return { + "user_id": TEST_USER_ID, + "username": "testuser", + "role": "member", + } + + +async def _seed_auth_user(auth_db_path: Path) -> None: + """Seed the test user into the auth DB so get_user_email works.""" + async with aiosqlite.connect(str(auth_db_path)) as db: + await db.execute( + "INSERT INTO users (id, username, email, password_hash, role, is_active, " + "is_terminal_authorized, is_server_terminal_authorized, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + TEST_USER_ID, + "testuser", + TEST_USER_EMAIL, + "fake-hash", + "member", + 1, + 0, + 0, + "2026-01-01T00:00:00+00:00", + "2026-01-01T00:00:00+00:00", + ), + ) + await db.commit() + + +async def _seed_searchable_users(auth_db_path: Path) -> None: + """Seed extra users for search tests.""" + for name in ("alice", "bob", "charlie"): + async with aiosqlite.connect(str(auth_db_path)) as db: + await db.execute( + "INSERT INTO users (id, username, email, password_hash, role, is_active, " + "is_terminal_authorized, is_server_terminal_authorized, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + uuid.uuid4().hex, + name, + f"{name}@example.com", + "fake-hash", + "member", + 1, + 0, + 0, + "2026-01-01T00:00:00+00:00", + "2026-01-01T00:00:00+00:00", + ), + ) + await db.commit() + + +@pytest.fixture +def calendar_db_path(tmp_path: Path) -> Path: + path = tmp_path / "test_calendar.db" + asyncio.run(init_calendar_db(path)) + return path + + +@pytest.fixture +def auth_db_path(tmp_path: Path) -> Path: + path = tmp_path / "test_auth.db" + asyncio.run(init_auth_db(path)) + asyncio.run(_seed_auth_user(path)) + return path + + +@pytest.fixture +def app(calendar_db_path: Path, auth_db_path: Path) -> FastAPI: + """Create a test app with CalendarService and mock auth.""" + service = CalendarService(db_path=calendar_db_path, auth_db_path=auth_db_path) + + app = FastAPI() + app.state.calendar_service = service + app.state.auth_db_path = str(auth_db_path) + app.include_router(calendar_routes.router, prefix="/api/v1") + + # Override auth dependency to return a test user + app.dependency_overrides[require_authenticated] = lambda: _make_test_user() + return app + + +@pytest.fixture +def client(app: FastAPI) -> TestClient: + return TestClient(app) + + +@pytest.fixture +def unauth_app(calendar_db_path: Path, auth_db_path: Path) -> FastAPI: + """App without auth override — simulates unauthenticated requests.""" + service = CalendarService(db_path=calendar_db_path, auth_db_path=auth_db_path) + app = FastAPI() + app.state.calendar_service = service + app.state.auth_db_path = str(auth_db_path) + app.include_router(calendar_routes.router, prefix="/api/v1") + # No dependency override → require_authenticated will see no current_user + return app + + +@pytest.fixture +def unauth_client(unauth_app: FastAPI) -> TestClient: + return TestClient(unauth_app) + + +# --------------------------------------------------------------------------- +# Auth requirement +# --------------------------------------------------------------------------- + + +def test_route_create_event_requires_auth(unauth_client: TestClient) -> None: + """No auth → 401.""" + resp = unauth_client.post( + "/api/v1/calendar/events", + json={ + "title": "Test", + "start_time": "2026-07-01T10:00:00+00:00", + "end_time": "2026-07-01T11:00:00+00:00", + }, + ) + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# Create event +# --------------------------------------------------------------------------- + + +def test_route_create_event_success(client: TestClient) -> None: + """Create event via API returns 200 with event data.""" + resp = client.post( + "/api/v1/calendar/events", + json={ + "title": "Sprint Planning", + "start_time": "2026-07-01T10:00:00+00:00", + "end_time": "2026-07-01T11:00:00+00:00", + "description": "Bi-weekly sprint planning", + "location": "Room A", + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + event = data["event"] + assert event["title"] == "Sprint Planning" + assert event["start_time"] == "2026-07-01T10:00:00+00:00" + assert event["description"] == "Bi-weekly sprint planning" + assert event["location"] == "Room A" + assert event["source"] == "manual" + assert "id" in event + + +# --------------------------------------------------------------------------- +# List events +# --------------------------------------------------------------------------- + + +def test_route_list_events_returns_events(client: TestClient) -> None: + """Create events, list via API returns them.""" + for i in range(3): + client.post( + "/api/v1/calendar/events", + json={ + "title": f"Event {i}", + "start_time": f"2026-07-{i + 1:02d}T10:00:00+00:00", + "end_time": f"2026-07-{i + 1:02d}T11:00:00+00:00", + }, + ) + + resp = client.get("/api/v1/calendar/events") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["count"] == 3 + titles = {e["title"] for e in data["events"]} + assert titles == {"Event 0", "Event 1", "Event 2"} + + +# --------------------------------------------------------------------------- +# Tag filter via API (G2) +# --------------------------------------------------------------------------- + + +def test_route_list_events_filters_by_tag(client: TestClient) -> None: + """G2 tag filter via API — only tagged events returned.""" + # Create a tag first + tag_resp = client.post("/api/v1/calendar/tags", json={"name": "important"}) + assert tag_resp.status_code == 200 + tag_id = tag_resp.json()["tag"]["id"] + + # Event 1: with tag + client.post( + "/api/v1/calendar/events", + json={ + "title": "Tagged Event", + "start_time": "2026-07-01T10:00:00+00:00", + "end_time": "2026-07-01T11:00:00+00:00", + "tag_ids": [tag_id], + }, + ) + + # Event 2: without tag + client.post( + "/api/v1/calendar/events", + json={ + "title": "Untagged Event", + "start_time": "2026-07-02T10:00:00+00:00", + "end_time": "2026-07-02T11:00:00+00:00", + }, + ) + + # List with tag filter + resp = client.get("/api/v1/calendar/events", params={"tag_id": tag_id}) + assert resp.status_code == 200 + data = resp.json() + assert data["count"] == 1 + assert data["events"][0]["title"] == "Tagged Event" + + +# --------------------------------------------------------------------------- +# User search via API (G5) +# --------------------------------------------------------------------------- + + +def test_route_search_users(client: TestClient, auth_db_path: Path) -> None: + """G5 user search via API returns matching users.""" + asyncio.run(_seed_searchable_users(auth_db_path)) + + resp = client.get("/api/v1/calendar/users/search", params={"q": "ali"}) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["count"] == 1 + assert data["users"][0]["username"] == "alice" + assert data["users"][0]["email"] == "alice@example.com" + # Must not expose sensitive fields + assert "id" not in data["users"][0] + assert "password_hash" not in data["users"][0] diff --git a/tests/unit/calendar/test_scheduler.py b/tests/unit/calendar/test_scheduler.py new file mode 100644 index 0000000..a5c7c40 --- /dev/null +++ b/tests/unit/calendar/test_scheduler.py @@ -0,0 +1,256 @@ +"""Tests for ReminderScheduler (U5).""" + +from __future__ import annotations + +import asyncio +import uuid +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest + +from agentkit.calendar.db import ( + get_pending_deliveries, + init_calendar_db, + insert_event, + insert_reminder_rule, + list_reminder_rules_for_event, +) +from agentkit.calendar.models import CalendarEvent, ReminderRule, _now_iso +from agentkit.calendar.reminders import ReminderDispatcher +from agentkit.calendar.scheduler import ReminderScheduler + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def db_path(tmp_path: Path) -> Path: + path = tmp_path / "test_calendar.db" + asyncio.run(init_calendar_db(path)) + return path + + +@pytest.fixture +def auth_db_path(tmp_path: Path) -> Path: + from agentkit.server.auth.models import init_auth_db + + path = tmp_path / "test_auth.db" + asyncio.run(init_auth_db(path)) + return path + + +def _make_event( + event_id: str, + user_id: str, + start_time: str, + title: str = "Test Event", +) -> CalendarEvent: + now = _now_iso() + return CalendarEvent( + id=event_id, + user_id=user_id, + title=title, + start_time=start_time, + end_time=start_time, + last_modified=now, + created_at=now, + ) + + +def _mock_dispatcher(return_value: bool = True) -> ReminderDispatcher: + """Create a dispatcher with a mocked dispatch method.""" + dispatcher = ReminderDispatcher() + dispatcher.dispatch = AsyncMock(return_value=return_value) # type: ignore + return dispatcher + + +# --------------------------------------------------------------------------- +# Scheduler scan logic +# --------------------------------------------------------------------------- + + +async def test_scheduler_finds_event_within_reminder_window(db_path: Path) -> None: + """Event 10 min away, rule offset -15min → reminder_time 5 min ago → found.""" + now = datetime.now(timezone.utc) + event_start = (now + timedelta(minutes=10)).isoformat() + event = _make_event("evt-1", "user-1", event_start, "Meeting") + await insert_event(event, db_path) + + rule = ReminderRule(id="rule-1", event_id="evt-1", offset_minutes=-15, channels=["client"]) + await insert_reminder_rule(rule, db_path) + + dispatcher = _mock_dispatcher(return_value=True) + scheduler = ReminderScheduler(db_path=db_path, dispatcher=dispatcher) + + count = await scheduler.scan_once() + + assert count == 1 + assert dispatcher.dispatch.call_count == 1 # type: ignore + + +async def test_scheduler_skips_event_outside_window(db_path: Path) -> None: + """Event 2 hours away, rule offset -15min → reminder_time 1hr45min away → not found.""" + now = datetime.now(timezone.utc) + event_start = (now + timedelta(hours=2)).isoformat() + event = _make_event("evt-1", "user-1", event_start, "Meeting") + await insert_event(event, db_path) + + rule = ReminderRule(id="rule-1", event_id="evt-1", offset_minutes=-15, channels=["client"]) + await insert_reminder_rule(rule, db_path) + + dispatcher = _mock_dispatcher(return_value=True) + scheduler = ReminderScheduler(db_path=db_path, dispatcher=dispatcher) + + count = await scheduler.scan_once() + + assert count == 0 + assert dispatcher.dispatch.call_count == 0 # type: ignore + + +async def test_idempotent_delivery_no_duplicate(db_path: Path) -> None: + """Scheduler runs twice, only one delivery record created.""" + now = datetime.now(timezone.utc) + event_start = (now + timedelta(minutes=10)).isoformat() + event = _make_event("evt-1", "user-1", event_start, "Meeting") + await insert_event(event, db_path) + + rule = ReminderRule(id="rule-1", event_id="evt-1", offset_minutes=-15, channels=["client"]) + await insert_reminder_rule(rule, db_path) + + dispatcher = _mock_dispatcher(return_value=True) + scheduler = ReminderScheduler(db_path=db_path, dispatcher=dispatcher) + + count1 = await scheduler.scan_once() + assert count1 == 1 + + count2 = await scheduler.scan_once() + assert count2 == 0 + + deliveries = await get_pending_deliveries("evt-1", "rule-1", db_path) + assert len(deliveries) == 1 + + +async def test_failed_delivery_retries_up_to_3_times(db_path: Path) -> None: + """Mock channel to fail, verify 3 attempts and delivery status=failed.""" + now = datetime.now(timezone.utc) + event_start = (now + timedelta(minutes=10)).isoformat() + event = _make_event("evt-1", "user-1", event_start, "Meeting") + await insert_event(event, db_path) + + rule = ReminderRule(id="rule-1", event_id="evt-1", offset_minutes=-15, channels=["client"]) + await insert_reminder_rule(rule, db_path) + + dispatcher = _mock_dispatcher(return_value=False) + scheduler = ReminderScheduler(db_path=db_path, dispatcher=dispatcher, retry_base_delay=0) + + await scheduler.scan_once() + + assert dispatcher.dispatch.call_count == 3 # type: ignore + + deliveries = await get_pending_deliveries("evt-1", "rule-1", db_path, status="failed") + assert len(deliveries) == 1 + assert deliveries[0].attempts == 3 + assert deliveries[0].status == "failed" + + +async def test_scheduler_dispatches_multiple_channels(db_path: Path) -> None: + """Rule with 2 channels creates 2 delivery records.""" + now = datetime.now(timezone.utc) + event_start = (now + timedelta(minutes=10)).isoformat() + event = _make_event("evt-1", "user-1", event_start, "Meeting") + await insert_event(event, db_path) + + rule = ReminderRule( + id="rule-1", + event_id="evt-1", + offset_minutes=-15, + channels=["client", "email"], + ) + await insert_reminder_rule(rule, db_path) + + dispatcher = _mock_dispatcher(return_value=True) + scheduler = ReminderScheduler(db_path=db_path, dispatcher=dispatcher) + + count = await scheduler.scan_once() + assert count == 2 + assert dispatcher.dispatch.call_count == 2 # type: ignore + + +# --------------------------------------------------------------------------- +# Start/stop lifecycle +# --------------------------------------------------------------------------- + + +async def test_scheduler_start_stop_lifecycle(db_path: Path) -> None: + """start() creates task, stop() cancels it.""" + scheduler = ReminderScheduler(db_path=db_path, interval_seconds=1) + + assert scheduler._task is None + + await scheduler.start() + assert scheduler._task is not None + assert not scheduler._task.done() + + await scheduler.stop() + assert scheduler._task is None + + +async def test_scheduler_start_idempotent(db_path: Path) -> None: + """Calling start() twice does not create a second task.""" + scheduler = ReminderScheduler(db_path=db_path, interval_seconds=1) + + await scheduler.start() + task1 = scheduler._task + await scheduler.start() + assert scheduler._task is task1 + + await scheduler.stop() + + +# --------------------------------------------------------------------------- +# Default reminders inherited from event type +# --------------------------------------------------------------------------- + + +async def test_default_reminders_inherited_from_event_type( + db_path: Path, auth_db_path: Path +) -> None: + """Create event with type that has default rules, verify rules cloned to event.""" + from agentkit.calendar.service import CalendarService + + service = CalendarService(db_path=db_path, auth_db_path=auth_db_path) + + # Create event type + et = await service.create_event_type("user-1", "Meeting") + + # Add a default reminder rule at the type level + type_rule = ReminderRule( + id=uuid.uuid4().hex, + event_type_id=et.id, + offset_minutes=-30, + channels=["email"], + ) + await insert_reminder_rule(type_rule, db_path) + + # Create an event with this type + event = await service.create_event( + user_id="user-1", + title="Sprint Planning", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + event_type_id=et.id, + ) + + # Verify the type-level rule was cloned to the event + event_rules = await list_reminder_rules_for_event(event.id, db_path) + assert len(event_rules) == 1 + assert event_rules[0].event_id == event.id + assert event_rules[0].event_type_id is None + assert event_rules[0].offset_minutes == -30 + assert event_rules[0].channels == ["email"] + # Cloned rule has a new ID + assert event_rules[0].id != type_rule.id diff --git a/tests/unit/calendar/test_service.py b/tests/unit/calendar/test_service.py new file mode 100644 index 0000000..7b7c4a3 --- /dev/null +++ b/tests/unit/calendar/test_service.py @@ -0,0 +1,441 @@ +"""Tests for CalendarService (U2).""" + +from __future__ import annotations + +import asyncio +import uuid +from pathlib import Path + +import aiosqlite +import pytest + +from agentkit.calendar.db import ( + get_event_tags, + init_calendar_db, + insert_reminder_rule, + list_reminder_rules_for_event, +) +from agentkit.calendar.models import ReminderRule +from agentkit.calendar.service import CalendarService +from agentkit.server.auth.models import init_auth_db + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def calendar_db_path(tmp_path: Path) -> Path: + path = tmp_path / "test_calendar.db" + asyncio.run(init_calendar_db(path)) + return path + + +@pytest.fixture +def auth_db_path(tmp_path: Path) -> Path: + path = tmp_path / "test_auth.db" + asyncio.run(init_auth_db(path)) + return path + + +@pytest.fixture +def service(calendar_db_path: Path, auth_db_path: Path) -> CalendarService: + return CalendarService(db_path=calendar_db_path, auth_db_path=auth_db_path) + + +async def _seed_user( + auth_db_path: Path, + user_id: str | None = None, + username: str = "testuser", + email: str = "test@example.com", +) -> str: + """Insert a user into the auth DB and return its id.""" + uid = user_id or uuid.uuid4().hex + async with aiosqlite.connect(str(auth_db_path)) as db: + await db.execute( + "INSERT INTO users (id, username, email, password_hash, role, is_active, " + "is_terminal_authorized, is_server_terminal_authorized, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + uid, + username, + email, + "fake-hash", + "member", + 1, + 0, + 0, + "2026-01-01T00:00:00+00:00", + "2026-01-01T00:00:00+00:00", + ), + ) + await db.commit() + return uid + + +# --------------------------------------------------------------------------- +# Event creation with type and tags +# --------------------------------------------------------------------------- + + +async def test_create_event_with_type_and_tags( + service: CalendarService, calendar_db_path: Path +) -> None: + """Create event with type_id and 2 tags, verify all linked.""" + user_id = "user-1" + + # Create event type + et = await service.create_event_type(user_id, "Meeting", color="#FF0000") + + # Create tags + tag1 = await service.create_tag(user_id, "urgent") + tag2 = await service.create_tag(user_id, "work") + + # Create event with type and tags + event = await service.create_event( + user_id=user_id, + title="Sprint Planning", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + event_type_id=et.id, + tag_ids=[tag1.id, tag2.id], + ) + + # Verify event was created + fetched = await service.get_event(event.id) + assert fetched is not None + assert fetched.title == "Sprint Planning" + assert fetched.event_type_id == et.id + + # Verify tags are linked + tags = await get_event_tags(event.id, calendar_db_path) + tag_names = {t.name for t in tags} + assert tag_names == {"urgent", "work"} + + +# --------------------------------------------------------------------------- +# Date range filtering +# --------------------------------------------------------------------------- + + +async def test_list_events_filters_by_date_range(service: CalendarService) -> None: + """3 events across days, filter returns correct subset.""" + user_id = "user-1" + for i, day in enumerate([1, 15, 28]): + await service.create_event( + user_id=user_id, + title=f"Event Day {day}", + start_time=f"2026-07-{day:02d}T10:00:00+00:00", + end_time=f"2026-07-{day:02d}T11:00:00+00:00", + ) + + # Range covering only day 15 + events = await service.list_events( + user_id=user_id, + start="2026-07-10T00:00:00+00:00", + end="2026-07-20T00:00:00+00:00", + ) + assert len(events) == 1 + assert events[0].title == "Event Day 15" + + +# --------------------------------------------------------------------------- +# Type and tag filter combination (G2) +# --------------------------------------------------------------------------- + + +async def test_list_events_filters_by_type_and_tag( + service: CalendarService, calendar_db_path: Path +) -> None: + """Filter by both type_id and tag_id returns only matching events.""" + user_id = "user-1" + + et_meeting = await service.create_event_type(user_id, "Meeting") + et_personal = await service.create_event_type(user_id, "Personal") + + tag_work = await service.create_tag(user_id, "work") + tag_family = await service.create_tag(user_id, "family") + + # Event 1: Meeting + work + e1 = await service.create_event( + user_id=user_id, + title="Standup", + start_time="2026-07-01T09:00:00+00:00", + end_time="2026-07-01T09:30:00+00:00", + event_type_id=et_meeting.id, + tag_ids=[tag_work.id], + ) + + # Event 2: Personal + family + await service.create_event( + user_id=user_id, + title="Birthday", + start_time="2026-07-02T18:00:00+00:00", + end_time="2026-07-02T21:00:00+00:00", + event_type_id=et_personal.id, + tag_ids=[tag_family.id], + ) + + # Event 3: Meeting + family (cross combination) + await service.create_event( + user_id=user_id, + title="Team Dinner", + start_time="2026-07-03T19:00:00+00:00", + end_time="2026-07-03T21:00:00+00:00", + event_type_id=et_meeting.id, + tag_ids=[tag_family.id], + ) + + # Filter by type=Meeting AND tag=work → only Event 1 + events = await service.list_events( + user_id=user_id, + event_type_id=et_meeting.id, + tag_id=tag_work.id, + ) + assert len(events) == 1 + assert events[0].id == e1.id + + +# --------------------------------------------------------------------------- +# Tag-only filter (G2) +# --------------------------------------------------------------------------- + + +async def test_list_events_filters_by_tag_only( + service: CalendarService, calendar_db_path: Path +) -> None: + """5 events, 2 with tag X, filter tag_id=X returns only 2.""" + user_id = "user-1" + tag_x = await service.create_tag(user_id, "X") + + tagged_ids: list[str] = [] + for i in range(5): + tag_ids = [tag_x.id] if i < 2 else None + event = await service.create_event( + user_id=user_id, + title=f"Event {i}", + start_time=f"2026-07-{i + 1:02d}T10:00:00+00:00", + end_time=f"2026-07-{i + 1:02d}T11:00:00+00:00", + tag_ids=tag_ids, + ) + if i < 2: + tagged_ids.append(event.id) + + events = await service.list_events(user_id=user_id, tag_id=tag_x.id) + assert len(events) == 2 + returned_ids = {e.id for e in events} + assert returned_ids == set(tagged_ids) + + +# --------------------------------------------------------------------------- +# Recurring event expansion +# --------------------------------------------------------------------------- + + +async def test_list_events_expands_recurring(service: CalendarService) -> None: + """Event with FREQ=DAILY;COUNT=3, list range covering 2 days → 2 occurrences.""" + user_id = "user-1" + await service.create_event( + user_id=user_id, + title="Daily Standup", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T10:15:00+00:00", + rrule="FREQ=DAILY;COUNT=3", + ) + + # Range covers Jul 1 and Jul 2 (half-open [Jul 1, Jul 3)) + events = await service.list_events( + user_id=user_id, + start="2026-07-01T00:00:00+00:00", + end="2026-07-03T00:00:00+00:00", + ) + assert len(events) == 2 + # First occurrence on Jul 1 + assert events[0].start_time.startswith("2026-07-01") + assert events[0].end_time.startswith("2026-07-01") + # Second occurrence on Jul 2 + assert events[1].start_time.startswith("2026-07-02") + assert events[1].end_time.startswith("2026-07-02") + # Duration preserved (15 minutes) + assert "T10:00:00" in events[0].start_time + assert "T10:15:00" in events[0].end_time + assert "T10:00:00" in events[1].start_time + assert "T10:15:00" in events[1].end_time + + +# --------------------------------------------------------------------------- +# Partial update +# --------------------------------------------------------------------------- + + +async def test_update_event_partial_fields(service: CalendarService) -> None: + """PATCH only title, other fields unchanged.""" + event = await service.create_event( + user_id="user-1", + title="Original Title", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + description="Original description", + location="Room A", + ) + + original = await service.get_event(event.id) + assert original is not None + + updated = await service.update_event(event.id, {"title": "New Title"}) + assert updated is True + + refreshed = await service.get_event(event.id) + assert refreshed is not None + assert refreshed.title == "New Title" + # Other fields unchanged + assert refreshed.description == "Original description" + assert refreshed.location == "Room A" + assert refreshed.start_time == original.start_time + assert refreshed.end_time == original.end_time + # last_modified should be updated + assert refreshed.last_modified != original.last_modified + + +# --------------------------------------------------------------------------- +# Delete cascade +# --------------------------------------------------------------------------- + + +async def test_delete_event_cascades_reminders_and_tags( + service: CalendarService, calendar_db_path: Path +) -> None: + """Delete event, verify reminder rules and junction rows removed.""" + user_id = "user-1" + tag = await service.create_tag(user_id, "work") + event = await service.create_event( + user_id=user_id, + title="To Delete", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + tag_ids=[tag.id], + ) + + # Add a reminder rule directly to the event + rule = ReminderRule( + id=uuid.uuid4().hex, + event_id=event.id, + offset_minutes=-30, + channels=["email"], + ) + await insert_reminder_rule(rule, calendar_db_path) + + # Verify rule and tag exist + rules_before = await list_reminder_rules_for_event(event.id, calendar_db_path) + assert len(rules_before) == 1 + tags_before = await get_event_tags(event.id, calendar_db_path) + assert len(tags_before) == 1 + + # Delete the event + deleted = await service.delete_event(event.id) + assert deleted is True + + # Verify event is gone + assert await service.get_event(event.id) is None + + # Verify reminder rules are gone (cascade) + rules_after = await list_reminder_rules_for_event(event.id, calendar_db_path) + assert len(rules_after) == 0 + + # Verify junction rows are gone + tags_after = await get_event_tags(event.id, calendar_db_path) + assert len(tags_after) == 0 + + # Tag itself should still exist (only the junction is removed) + all_tags = await service.list_tags(user_id) + assert len(all_tags) == 1 + + +# --------------------------------------------------------------------------- +# Invitation flow +# --------------------------------------------------------------------------- + + +async def test_create_invitation_and_respond(service: CalendarService) -> None: + """Invite, respond 'accepted', verify status + responded_at set.""" + event = await service.create_event( + user_id="user-1", + title="Team Meeting", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + ) + + invitation = await service.create_invitation( + event_id=event.id, + inviter_user_id="user-1", + invitee_email="alice@example.com", + ) + assert invitation.status == "pending" + assert invitation.responded_at is None + + updated = await service.respond_to_invitation(invitation.id, "accepted") + assert updated is True + + invitations = await service.list_invitations("alice@example.com") + assert len(invitations) == 1 + assert invitations[0].status == "accepted" + assert invitations[0].responded_at is not None + + +# --------------------------------------------------------------------------- +# User search (G5) +# --------------------------------------------------------------------------- + + +async def test_search_users_by_username(service: CalendarService, auth_db_path: Path) -> None: + """Seed users in auth DB, search returns matches.""" + await _seed_user(auth_db_path, username="alice", email="alice@example.com") + await _seed_user(auth_db_path, username="bob", email="bob@example.com") + await _seed_user(auth_db_path, username="charlie", email="charlie@example.com") + + results = await service.search_users("ali") + assert len(results) == 1 + assert results[0]["username"] == "alice" + assert results[0]["email"] == "alice@example.com" + # Must NOT return user_id or password fields + assert "id" not in results[0] + assert "password_hash" not in results[0] + + +async def test_search_users_no_match_returns_empty( + service: CalendarService, auth_db_path: Path +) -> None: + """Search 'zzz' returns [].""" + await _seed_user(auth_db_path, username="alice", email="alice@example.com") + results = await service.search_users("zzz") + assert results == [] + + +async def test_search_users_returns_max_10(service: CalendarService, auth_db_path: Path) -> None: + """Seed 15 matching users, verify only 10 returned.""" + for i in range(15): + await _seed_user( + auth_db_path, + username=f"user{i:02d}", + email=f"user{i:02d}@example.com", + ) + + results = await service.search_users("user") + assert len(results) == 10 + + +# --------------------------------------------------------------------------- +# Event type color persistence +# --------------------------------------------------------------------------- + + +async def test_event_type_default_color_persistence(service: CalendarService) -> None: + """Create type with color, verify roundtrip.""" + et = await service.create_event_type("user-1", "Important", color="#00FF00") + assert et.color == "#00FF00" + + types = await service.list_event_types("user-1") + assert len(types) == 1 + assert types[0].color == "#00FF00" + assert types[0].name == "Important" diff --git a/tests/unit/calendar/test_sync_caldav.py b/tests/unit/calendar/test_sync_caldav.py new file mode 100644 index 0000000..551e79c --- /dev/null +++ b/tests/unit/calendar/test_sync_caldav.py @@ -0,0 +1,398 @@ +"""Tests for CalDAVSyncProvider — bidirectional Apple Calendar sync (U6). + +The ``caldav`` library is not installed in the test environment, so we inject +a mock module into ``sys.modules`` before importing the provider. All CalDAV +interactions are mocked via the ``client_factory`` injection point. +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest + +# caldav is not installed in the test env — inject a mock module so that +# `import caldav` in caldav_provider.py succeeds at import time. +if "caldav" not in sys.modules: # pragma: no cover + sys.modules["caldav"] = MagicMock() + +from agentkit.calendar.db import ( + get_event_by_external_id, + init_calendar_db, + insert_event, + list_events, +) +from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig +from agentkit.calendar.sync.caldav_provider import CalDAVSyncProvider + +USER_ID = "user-1" + + +# --------------------------------------------------------------------------- +# ICS + Mock helpers +# --------------------------------------------------------------------------- + + +def make_ics( + uid: str, + summary: str, + start: str = "20260701T100000Z", + end: str = "20260701T110000Z", + description: str = "", + location: str = "", + rrule: str | None = None, + last_modified: str = "20260601T000000Z", +) -> bytes: + """Build a minimal valid ICS bytes payload for a single VEVENT.""" + lines = [ + b"BEGIN:VCALENDAR", + b"VERSION:2.0", + b"PRODID:-//Test//Test//EN", + b"BEGIN:VEVENT", + f"UID:{uid}".encode(), + f"SUMMARY:{summary}".encode(), + f"DTSTART:{start}".encode(), + f"DTEND:{end}".encode(), + ] + if description: + lines.append(f"DESCRIPTION:{description}".encode()) + if location: + lines.append(f"LOCATION:{location}".encode()) + if rrule: + lines.append(f"RRULE:{rrule}".encode()) + if last_modified: + lines.append(f"LAST-MODIFIED:{last_modified}".encode()) + lines.extend([b"END:VEVENT", b"END:VCALENDAR"]) + return b"\r\n".join(lines) + + +class MockCaldavEvent: + """Mock caldav.Event — exposes .data returning ICS bytes.""" + + def __init__(self, ics_data: bytes) -> None: + self.data = ics_data + + +def make_mock_client( + remote_events: list[bytes], + saved_uid: str | None = None, + raise_on_connect: Exception | None = None, +) -> tuple[Any, Any]: + """Create a mock CalDAV client and its mock calendar. + + Returns ``(client, mock_calendar)``. ``mock_calendar`` is None when + ``raise_on_connect`` is set (connection fails before calendar is reached). + """ + client = MagicMock() + if raise_on_connect is not None: + client.principal.side_effect = raise_on_connect + return client, None + + principal = MagicMock() + calendar = MagicMock() + calendar.date_search.return_value = [MockCaldavEvent(d) for d in remote_events] + saved_ics = make_ics(saved_uid or "saved-uid", "Saved Event") + calendar.save_event.return_value = MockCaldavEvent(saved_ics) + principal.calendars.return_value = [calendar] + client.principal.return_value = principal + return client, calendar + + +def client_factory_from(client: Any) -> Any: + """Wrap a mock client in a client_factory callable.""" + + def factory(config: ExternalCalendarConfig) -> Any: + return client + + return factory + + +def make_config( + config_id: str = "config-1", + user_id: str = USER_ID, + provider: str = "caldav", + last_sync: str | None = None, +) -> ExternalCalendarConfig: + return ExternalCalendarConfig( + id=config_id, + user_id=user_id, + provider=provider, + credentials=json.dumps( + {"url": "https://caldav.example.com", "username": "user", "password": "pass"} + ), + last_sync=last_sync, + ) + + +def make_local_event( + event_id: str = "evt-1", + title: str = "Local Event", + external_id: str | None = None, + last_modified: str = "2026-01-01T00:00:00+00:00", + rrule: str | None = None, +) -> CalendarEvent: + return CalendarEvent( + id=event_id, + user_id=USER_ID, + title=title, + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + external_id=external_id, + external_provider="caldav" if external_id else None, + last_modified=last_modified, + created_at=last_modified, + rrule=rrule, + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def calendar_db_path(tmp_path: Path) -> Path: + path = tmp_path / "test_calendar.db" + asyncio.run(init_calendar_db(path)) + return path + + +# --------------------------------------------------------------------------- +# Pull tests +# --------------------------------------------------------------------------- + + +async def test_caldav_provider_pull_creates_local_events(calendar_db_path: Path) -> None: + """Mock returns 2 events → 2 local events created with external_id set.""" + ics1 = make_ics("uid-1", "Remote Event 1") + ics2 = make_ics("uid-2", "Remote Event 2") + client, _ = make_mock_client([ics1, ics2]) + provider = CalDAVSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(client) + ) + config = make_config() + + pulled = await provider.pull_changes(config) + + assert len(pulled) == 2 + events = await list_events(USER_ID, db_path=calendar_db_path) + assert len(events) == 2 + titles = {e.title for e in events} + assert titles == {"Remote Event 1", "Remote Event 2"} + for event in events: + assert event.external_id is not None + assert event.external_provider == "caldav" + + +async def test_caldav_provider_pull_updates_existing_event(calendar_db_path: Path) -> None: + """Local event exists (matched by external_id), remote is newer → local updated.""" + local = make_local_event( + event_id="evt-1", + title="Old Title", + external_id="uid-1", + last_modified="2026-01-01T00:00:00+00:00", + ) + await insert_event(local, calendar_db_path) + + ics_remote = make_ics("uid-1", "Updated Title", last_modified="20260601T120000Z") + client, _ = make_mock_client([ics_remote]) + provider = CalDAVSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(client) + ) + config = make_config() + + pulled = await provider.pull_changes(config) + + assert len(pulled) == 1 + updated = await get_event_by_external_id("uid-1", "caldav", USER_ID, calendar_db_path) + assert updated is not None + assert updated.title == "Updated Title" + + +# --------------------------------------------------------------------------- +# Push tests +# --------------------------------------------------------------------------- + + +async def test_caldav_provider_push_creates_remote_event(calendar_db_path: Path) -> None: + """Local event with no external_id → push creates remote, external_id stored.""" + local = make_local_event(event_id="evt-1", title="New Local Event", external_id=None) + await insert_event(local, calendar_db_path) + + client, _ = make_mock_client([], saved_uid="remote-uid-1") + provider = CalDAVSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(client) + ) + config = make_config() + + events = await list_events(USER_ID, db_path=calendar_db_path) + pushed = await provider.push_changes(config, events) + + assert len(pushed) == 1 + assert pushed[0].external_id == "remote-uid-1" + assert pushed[0].external_provider == "caldav" + db_event = await get_event_by_external_id("remote-uid-1", "caldav", USER_ID, calendar_db_path) + assert db_event is not None + + +async def test_caldav_provider_push_updates_remote_event(calendar_db_path: Path) -> None: + """Local event modified, has external_id → push updates remote.""" + local = make_local_event( + event_id="evt-1", + title="Updated Local Event", + external_id="existing-uid", + last_modified="2026-06-01T00:00:00+00:00", + ) + await insert_event(local, calendar_db_path) + + client, mock_calendar = make_mock_client([], saved_uid="existing-uid") + provider = CalDAVSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(client) + ) + config = make_config() + + events = await list_events(USER_ID, db_path=calendar_db_path) + pushed = await provider.push_changes(config, events) + + assert len(pushed) == 1 + assert pushed[0].external_id == "existing-uid" + # Verify save_event was called with ICS containing the existing UID + saved_ics = mock_calendar.save_event.call_args[0][0] + assert b"UID:existing-uid" in saved_ics + + +# --------------------------------------------------------------------------- +# Conflict tests +# --------------------------------------------------------------------------- + + +async def test_caldav_conflict_last_write_wins(calendar_db_path: Path) -> None: + """Both sides modified, local is newer → local wins, local not updated.""" + local = make_local_event( + event_id="evt-1", + title="Local Updated", + external_id="uid-1", + last_modified="2026-06-15T00:00:00+00:00", + ) + await insert_event(local, calendar_db_path) + + ics_remote = make_ics("uid-1", "Remote Older", last_modified="20260601T000000Z") + client, _ = make_mock_client([ics_remote]) + provider = CalDAVSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(client) + ) + config = make_config() + + pulled = await provider.pull_changes(config) + + # Local wins → no update, pulled is empty + assert len(pulled) == 0 + db_event = await get_event_by_external_id("uid-1", "caldav", USER_ID, calendar_db_path) + assert db_event is not None + assert db_event.title == "Local Updated" + + +async def test_caldav_conflict_sends_ws_notification(calendar_db_path: Path) -> None: + """Conflict detected → WS callback called with calendar_sync_conflict type (G4).""" + notifications: list[tuple[str, dict[str, Any]]] = [] + + async def notify(event_type: str, payload: dict[str, Any]) -> None: + notifications.append((event_type, payload)) + + local = make_local_event( + event_id="evt-1", + title="Local Updated", + external_id="uid-1", + last_modified="2026-06-15T00:00:00+00:00", + ) + await insert_event(local, calendar_db_path) + + ics_remote = make_ics("uid-1", "Remote Older", last_modified="20260601T000000Z") + client, _ = make_mock_client([ics_remote]) + provider = CalDAVSyncProvider( + db_path=calendar_db_path, + client_factory=client_factory_from(client), + notify_callback=notify, + ) + config = make_config() + + await provider.pull_changes(config) + + assert len(notifications) == 1 + event_type, payload = notifications[0] + assert event_type == "calendar_sync_conflict" + assert payload["event_id"] == "evt-1" + assert payload["winner"] == "local" + + +# --------------------------------------------------------------------------- +# RRULE roundtrip test +# --------------------------------------------------------------------------- + + +async def test_caldav_rrule_roundtrip(calendar_db_path: Path) -> None: + """Event with RRULE synced → RRULE preserved in both pull and push.""" + rrule = "FREQ=WEEKLY;BYDAY=MO;COUNT=4" + ics_remote = make_ics("uid-rrule", "Weekly Meeting", rrule=rrule) + client, _ = make_mock_client([ics_remote]) + provider = CalDAVSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(client) + ) + config = make_config() + + # Pull: verify RRULE preserved + pulled = await provider.pull_changes(config) + assert len(pulled) == 1 + assert pulled[0].rrule is not None + assert "FREQ=WEEKLY" in pulled[0].rrule + assert "BYDAY=MO" in pulled[0].rrule + assert "COUNT=4" in pulled[0].rrule + + # Push: verify RRULE in ICS sent to remote + events = await list_events(USER_ID, db_path=calendar_db_path) + client2, mock_calendar2 = make_mock_client([], saved_uid="uid-rrule") + provider2 = CalDAVSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(client2) + ) + await provider2.push_changes(config, events) + + saved_ics = mock_calendar2.save_event.call_args[0][0] + assert b"RRULE:FREQ=WEEKLY" in saved_ics + assert b"BYDAY=MO" in saved_ics + assert b"COUNT=4" in saved_ics + + +# --------------------------------------------------------------------------- +# test_connection tests +# --------------------------------------------------------------------------- + + +async def test_caldav_test_connection_success(calendar_db_path: Path) -> None: + """Mock returns calendars → test_connection() returns (True, "").""" + client, _ = make_mock_client([]) + provider = CalDAVSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(client) + ) + config = make_config() + + ok, msg = await provider.test_connection(config) + assert ok is True + assert msg == "" + + +async def test_caldav_test_connection_failure(calendar_db_path: Path) -> None: + """Mock raises → test_connection() returns (False, error_msg).""" + client, _ = make_mock_client([], raise_on_connect=ConnectionError("Auth failed")) + provider = CalDAVSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(client) + ) + config = make_config() + + ok, msg = await provider.test_connection(config) + assert ok is False + assert "Auth failed" in msg diff --git a/tests/unit/calendar/test_sync_manager.py b/tests/unit/calendar/test_sync_manager.py new file mode 100644 index 0000000..430ee8e --- /dev/null +++ b/tests/unit/calendar/test_sync_manager.py @@ -0,0 +1,197 @@ +"""Tests for SyncManager — orchestrates external calendar sync (U6, G8). + +Uses a mock ``AbstractSyncProvider`` to test SyncManager in isolation, +verifying provider iteration, last_sync updates, and conflict WS push. +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest + +# caldav is not installed — inject mock so SyncManager's import of +# CalDAVSyncProvider (which imports caldav) does not fail. +if "caldav" not in sys.modules: # pragma: no cover + sys.modules["caldav"] = MagicMock() + +from agentkit.calendar.db import ( + init_calendar_db, + insert_external_config, + list_external_configs, +) +from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig +from agentkit.calendar.sync.base import AbstractSyncProvider +from agentkit.calendar.sync.manager import SyncManager + +USER_ID = "user-1" + + +# --------------------------------------------------------------------------- +# Mock provider + helpers +# --------------------------------------------------------------------------- + + +class MockSyncProvider(AbstractSyncProvider): + """In-memory mock provider that records calls for assertion.""" + + def __init__(self) -> None: + self.pull_calls: int = 0 + self.push_calls: int = 0 + self.pull_configs: list[ExternalCalendarConfig] = [] + self.push_configs: list[ExternalCalendarConfig] = [] + + async def pull_changes( + self, config: ExternalCalendarConfig, since: str | None = None + ) -> list[CalendarEvent]: + self.pull_calls += 1 + self.pull_configs.append(config) + return [] + + async def push_changes( + self, config: ExternalCalendarConfig, events: list[CalendarEvent] + ) -> list[CalendarEvent]: + self.push_calls += 1 + self.push_configs.append(config) + return events + + async def test_connection(self, config: ExternalCalendarConfig) -> tuple[bool, str]: + return True, "" + + +def make_config( + config_id: str = "config-1", + user_id: str = USER_ID, + provider: str = "caldav", + last_sync: str | None = None, +) -> ExternalCalendarConfig: + return ExternalCalendarConfig( + id=config_id, + user_id=user_id, + provider=provider, + credentials=json.dumps( + {"url": "https://caldav.example.com", "username": "user", "password": "pass"} + ), + last_sync=last_sync, + ) + + +def make_event( + event_id: str = "evt-1", + title: str = "Test Event", + last_modified: str = "2026-06-01T00:00:00+00:00", + external_id: str | None = None, +) -> CalendarEvent: + return CalendarEvent( + id=event_id, + user_id=USER_ID, + title=title, + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + external_id=external_id, + external_provider="caldav" if external_id else None, + last_modified=last_modified, + created_at=last_modified, + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def calendar_db_path(tmp_path: Path) -> Path: + path = tmp_path / "test_calendar.db" + asyncio.run(init_calendar_db(path)) + return path + + +# --------------------------------------------------------------------------- +# sync_all tests +# --------------------------------------------------------------------------- + + +async def test_sync_manager_sync_all_iterates_providers( + calendar_db_path: Path, +) -> None: + """2 configs → both providers called (G8).""" + config1 = make_config(config_id="config-1") + config2 = make_config(config_id="config-2") + await insert_external_config(config1, calendar_db_path) + await insert_external_config(config2, calendar_db_path) + + mock_provider = MockSyncProvider() + manager = SyncManager(db_path=calendar_db_path, providers={"caldav": mock_provider}) + + result = await manager.sync_all(USER_ID) + + assert result["synced"] == 2 + assert result["errors"] == [] + assert mock_provider.pull_calls == 2 + assert mock_provider.push_calls == 2 + + +# --------------------------------------------------------------------------- +# sync_provider tests +# --------------------------------------------------------------------------- + + +async def test_sync_manager_sync_provider_updates_last_sync( + calendar_db_path: Path, +) -> None: + """Sync completes → config.last_sync updated (G8).""" + config = make_config(config_id="config-1", last_sync=None) + await insert_external_config(config, calendar_db_path) + + mock_provider = MockSyncProvider() + manager = SyncManager(db_path=calendar_db_path, providers={"caldav": mock_provider}) + + await manager.sync_provider("config-1") + + configs = await list_external_configs(USER_ID, calendar_db_path) + assert len(configs) == 1 + assert configs[0].last_sync is not None + + +# --------------------------------------------------------------------------- +# resolve_conflict tests +# --------------------------------------------------------------------------- + + +async def test_sync_manager_resolve_conflict_notifies_user( + calendar_db_path: Path, +) -> None: + """Conflict → WS callback called with calendar_sync_conflict type (G8/G4).""" + notifications: list[tuple[str, dict[str, Any]]] = [] + + async def notify(event_type: str, payload: dict[str, Any]) -> None: + notifications.append((event_type, payload)) + + manager = SyncManager(db_path=calendar_db_path, notify_callback=notify) + + local = make_event( + event_id="evt-1", + title="Local Version", + last_modified="2026-06-15T00:00:00+00:00", + external_id="uid-1", + ) + remote = make_event( + event_id="evt-remote", + title="Remote Version", + last_modified="2026-06-01T00:00:00+00:00", + external_id="uid-1", + ) + + winner = await manager.resolve_conflict(local, remote) + + assert len(notifications) == 1 + event_type, payload = notifications[0] + assert event_type == "calendar_sync_conflict" + assert payload["winner"] == "local" + assert winner is local # Local is newer → local wins diff --git a/tests/unit/calendar/test_sync_outlook.py b/tests/unit/calendar/test_sync_outlook.py new file mode 100644 index 0000000..87c450a --- /dev/null +++ b/tests/unit/calendar/test_sync_outlook.py @@ -0,0 +1,556 @@ +"""Tests for OutlookSyncProvider — bidirectional Outlook Calendar sync (U7). + +All Microsoft Graph API interactions are mocked via the ``client_factory`` +injection point. No real HTTP calls are made. +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any + +import pytest + +from agentkit.calendar.db import ( + get_event_by_external_id, + init_calendar_db, + insert_event, + list_events, +) +from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig +from agentkit.calendar.sync.outlook_provider import OutlookSyncProvider + +USER_ID = "user-1" + + +# --------------------------------------------------------------------------- +# Mock helpers +# --------------------------------------------------------------------------- + + +class MockResponse: + """Minimal mock of an httpx.Response.""" + + def __init__(self, status_code: int = 200, json_data: Any = None) -> None: + self.status_code = status_code + self._json = json_data + self.text = json.dumps(json_data) if json_data is not None else "" + + def json(self) -> Any: + return self._json if self._json is not None else {} + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise RuntimeError(f"HTTP {self.status_code}") + + +class MockOutlookClient: + """Mock httpx.AsyncClient — records requests, returns queued responses.""" + + def __init__(self) -> None: + self.requests: list[dict[str, Any]] = [] + self._responses: list[MockResponse] = [] + + def add_response(self, response: MockResponse) -> None: + self._responses.append(response) + + async def request( + self, + method: str, + url: str, + *, + headers: Any = None, + json: Any = None, + params: Any = None, + data: Any = None, + ) -> MockResponse: + self.requests.append( + { + "method": method, + "url": url, + "headers": headers, + "json": json, + "params": params, + "data": data, + } + ) + if self._responses: + return self._responses.pop(0) + return MockResponse(status_code=200, json_data={}) + + async def aclose(self) -> None: + pass + + +def make_outlook_event( + eid: str = "outlook-1", + subject: str = "Outlook Event", + start: str = "2026-07-01T10:00:00", + end: str = "2026-07-01T11:00:00", + is_all_day: bool = False, + description: str = "", + location: str = "", + recurrence: dict[str, Any] | None = None, + last_modified: str = "2026-06-01T00:00:00Z", +) -> dict[str, Any]: + """Build a minimal Graph event JSON dict.""" + event: dict[str, Any] = { + "id": eid, + "subject": subject, + "start": {"dateTime": start, "timeZone": "UTC"}, + "end": {"dateTime": end, "timeZone": "UTC"}, + "isAllDay": is_all_day, + "lastModifiedDateTime": last_modified, + } + if description: + event["body"] = {"contentType": "Text", "content": description} + if location: + event["location"] = {"displayName": location} + if recurrence: + event["recurrence"] = recurrence + return event + + +def make_config( + config_id: str = "config-1", + user_id: str = USER_ID, + provider: str = "outlook", + last_sync: str | None = None, + sync_token: str | None = None, + access_token: str = "test-token", + refresh_token: str = "test-refresh", +) -> ExternalCalendarConfig: + return ExternalCalendarConfig( + id=config_id, + user_id=user_id, + provider=provider, + credentials=json.dumps( + { + "access_token": access_token, + "refresh_token": refresh_token, + "client_id": "test-client-id", + "expires_at": "2026-12-31T00:00:00+00:00", + } + ), + last_sync=last_sync, + sync_token=sync_token, + ) + + +def make_local_event( + event_id: str = "evt-1", + title: str = "Local Event", + external_id: str | None = None, + last_modified: str = "2026-01-01T00:00:00+00:00", + rrule: str | None = None, +) -> CalendarEvent: + return CalendarEvent( + id=event_id, + user_id=USER_ID, + title=title, + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + external_id=external_id, + external_provider="outlook" if external_id else None, + last_modified=last_modified, + created_at=last_modified, + rrule=rrule, + ) + + +def client_factory_from(client: MockOutlookClient): + """Wrap a mock client in a client_factory callable.""" + + def factory() -> MockOutlookClient: + return client + + return factory + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def calendar_db_path(tmp_path: Path) -> Path: + path = tmp_path / "test_calendar.db" + asyncio.run(init_calendar_db(path)) + return path + + +# --------------------------------------------------------------------------- +# Pull tests +# --------------------------------------------------------------------------- + + +async def test_outlook_provider_pull_delta_initial_sync( + calendar_db_path: Path, +) -> None: + """Mock returns 3 events → 3 local events created with external_id set.""" + events = [ + make_outlook_event(eid="outlook-1", subject="Event 1"), + make_outlook_event(eid="outlook-2", subject="Event 2"), + make_outlook_event(eid="outlook-3", subject="Event 3"), + ] + response = MockResponse( + 200, + { + "value": events, + "@odata.deltaLink": ( + "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltaToken=initial-token" + ), + }, + ) + mock_client = MockOutlookClient() + mock_client.add_response(response) + + provider = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_client) + ) + config = make_config() + + pulled = await provider.pull_changes(config) + + assert len(pulled) == 3 + db_events = await list_events(USER_ID, db_path=calendar_db_path) + assert len(db_events) == 3 + titles = {e.title for e in db_events} + assert titles == {"Event 1", "Event 2", "Event 3"} + for event in db_events: + assert event.external_id is not None + assert event.external_provider == "outlook" + # Delta token stored for next incremental sync + assert config.sync_token == "initial-token" + + +async def test_outlook_provider_pull_delta_incremental( + calendar_db_path: Path, +) -> None: + """Config has sync_token → incremental sync. 1 new + 1 updated processed.""" + # Pre-insert a local event that will be updated by the remote + local = make_local_event( + event_id="evt-1", + title="Old Title", + external_id="outlook-existing", + last_modified="2026-01-01T00:00:00+00:00", + ) + await insert_event(local, calendar_db_path) + + new_event = make_outlook_event(eid="outlook-new", subject="New Event") + updated_event = make_outlook_event( + eid="outlook-existing", + subject="Updated Title", + last_modified="2026-06-15T00:00:00Z", # newer than local + ) + response = MockResponse( + 200, + { + "value": [new_event, updated_event], + "@odata.deltaLink": ( + "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltaToken=new-token" + ), + }, + ) + mock_client = MockOutlookClient() + mock_client.add_response(response) + + provider = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_client) + ) + config = make_config(sync_token="previous-token") + + pulled = await provider.pull_changes(config) + + assert len(pulled) == 2 # 1 new + 1 updated + + # Verify the request URL contains the delta token (incremental sync) + assert "$deltaToken=previous-token" in mock_client.requests[0]["url"] + + # New event was created + new_db = await get_event_by_external_id("outlook-new", "outlook", USER_ID, calendar_db_path) + assert new_db is not None + assert new_db.title == "New Event" + + # Existing event was updated (remote was newer) + updated_db = await get_event_by_external_id( + "outlook-existing", "outlook", USER_ID, calendar_db_path + ) + assert updated_db is not None + assert updated_db.title == "Updated Title" + + # Delta token updated + assert config.sync_token == "new-token" + + +# --------------------------------------------------------------------------- +# Push tests +# --------------------------------------------------------------------------- + + +async def test_outlook_provider_push_creates_remote_event( + calendar_db_path: Path, +) -> None: + """Local event with no external_id → POST creates remote, ID stored.""" + local = make_local_event(event_id="evt-1", title="New Local Event", external_id=None) + await insert_event(local, calendar_db_path) + + mock_client = MockOutlookClient() + mock_client.add_response( + MockResponse(201, {"id": "remote-uid-1", "subject": "New Local Event"}) + ) + + provider = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_client) + ) + config = make_config() + + events = await list_events(USER_ID, db_path=calendar_db_path) + pushed = await provider.push_changes(config, events) + + assert len(pushed) == 1 + assert pushed[0].external_id == "remote-uid-1" + assert pushed[0].external_provider == "outlook" + + # Verify POST was called to /me/events + req = mock_client.requests[0] + assert req["method"] == "POST" + assert "/me/events" in req["url"] + assert req["json"]["subject"] == "New Local Event" + + # DB updated with external_id + db_event = await get_event_by_external_id("remote-uid-1", "outlook", USER_ID, calendar_db_path) + assert db_event is not None + + +async def test_outlook_provider_push_updates_remote_event( + calendar_db_path: Path, +) -> None: + """Local event with external_id → PATCH updates remote.""" + local = make_local_event( + event_id="evt-1", + title="Updated Local Event", + external_id="existing-uid", + last_modified="2026-06-01T00:00:00+00:00", + ) + await insert_event(local, calendar_db_path) + + mock_client = MockOutlookClient() + mock_client.add_response(MockResponse(200, {"id": "existing-uid"})) + + provider = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_client) + ) + config = make_config() + + events = await list_events(USER_ID, db_path=calendar_db_path) + pushed = await provider.push_changes(config, events) + + assert len(pushed) == 1 + assert pushed[0].external_id == "existing-uid" + + # Verify PATCH was called to /me/events/{id} + req = mock_client.requests[0] + assert req["method"] == "PATCH" + assert "/me/events/existing-uid" in req["url"] + assert req["json"]["subject"] == "Updated Local Event" + + +# --------------------------------------------------------------------------- +# Token refresh test +# --------------------------------------------------------------------------- + + +async def test_outlook_token_refresh_on_401(calendar_db_path: Path) -> None: + """Mock 401 → refresh token used → request retried with new token.""" + mock_client = MockOutlookClient() + # 1. First GET → 401 (token expired) + mock_client.add_response(MockResponse(401, {"error": "token expired"})) + # 2. Token refresh POST → 200 with new tokens + mock_client.add_response( + MockResponse( + 200, + { + "access_token": "new-token", + "refresh_token": "new-refresh", + "expires_in": 3600, + }, + ) + ) + # 3. Retry GET → 200 with events + mock_client.add_response( + MockResponse( + 200, + { + "value": [make_outlook_event(eid="outlook-1", subject="Refreshed Event")], + "@odata.deltaLink": ( + "https://graph.microsoft.com/v1.0/me/calendarView/delta" + "?$deltaToken=after-refresh" + ), + }, + ) + ) + + provider = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_client) + ) + config = make_config(access_token="expired-token") + + pulled = await provider.pull_changes(config) + + assert len(pulled) == 1 + assert pulled[0].title == "Refreshed Event" + + # 3 requests: GET (401), POST (refresh), GET (retry) + assert len(mock_client.requests) == 3 + assert mock_client.requests[0]["method"] == "GET" + assert mock_client.requests[1]["method"] == "POST" + assert mock_client.requests[2]["method"] == "GET" + + # Token refresh hit the Azure AD token endpoint + assert "login.microsoftonline.com" in mock_client.requests[1]["url"] + assert mock_client.requests[1]["data"]["grant_type"] == "refresh_token" + assert mock_client.requests[1]["data"]["refresh_token"] == "test-refresh" + + # Credentials updated in config + creds = json.loads(config.credentials) + assert creds["access_token"] == "new-token" + assert creds["refresh_token"] == "new-refresh" + + # Retry used the new access token + retry_headers = mock_client.requests[2]["headers"] + assert retry_headers["Authorization"] == "Bearer new-token" + + +# --------------------------------------------------------------------------- +# Conflict test +# --------------------------------------------------------------------------- + + +async def test_outlook_conflict_last_write_wins(calendar_db_path: Path) -> None: + """Both sides modified, local is newer → local wins, local not updated.""" + local = make_local_event( + event_id="evt-1", + title="Local Updated", + external_id="outlook-1", + last_modified="2026-06-15T00:00:00+00:00", + ) + await insert_event(local, calendar_db_path) + + remote = make_outlook_event( + eid="outlook-1", + subject="Remote Older", + last_modified="2026-06-01T00:00:00Z", # older than local + ) + response = MockResponse(200, {"value": [remote]}) + mock_client = MockOutlookClient() + mock_client.add_response(response) + + provider = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_client) + ) + config = make_config() + + pulled = await provider.pull_changes(config) + + # Local wins → no update, pulled is empty + assert len(pulled) == 0 + db_event = await get_event_by_external_id("outlook-1", "outlook", USER_ID, calendar_db_path) + assert db_event is not None + assert db_event.title == "Local Updated" + + +# --------------------------------------------------------------------------- +# RRULE roundtrip test +# --------------------------------------------------------------------------- + + +async def test_outlook_rrule_roundtrip(calendar_db_path: Path) -> None: + """Recurring event synced → RRULE preserved in both pull and push.""" + recurrence = { + "pattern": { + "type": "weekly", + "interval": 1, + "daysOfWeek": ["monday"], + "numberOfOccurrences": 4, + }, + "range": { + "type": "numbered", + "startDate": "2026-07-01", + "numberOfOccurrences": 4, + }, + } + remote = make_outlook_event( + eid="outlook-rrule", subject="Weekly Meeting", recurrence=recurrence + ) + response = MockResponse( + 200, + { + "value": [remote], + "@odata.deltaLink": ( + "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltaToken=rrule-token" + ), + }, + ) + mock_client = MockOutlookClient() + mock_client.add_response(response) + + provider = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_client) + ) + config = make_config() + + # Pull: verify RRULE preserved + pulled = await provider.pull_changes(config) + assert len(pulled) == 1 + assert pulled[0].rrule is not None + assert "FREQ=WEEKLY" in pulled[0].rrule + assert "BYDAY=MO" in pulled[0].rrule + assert "COUNT=4" in pulled[0].rrule + + # Push: verify recurrence pattern in request body sent to remote + events = await list_events(USER_ID, db_path=calendar_db_path) + mock_client2 = MockOutlookClient() + mock_client2.add_response( + MockResponse(201, {"id": "outlook-rrule", "subject": "Weekly Meeting"}) + ) + provider2 = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_client2) + ) + await provider2.push_changes(config, events) + + req = mock_client2.requests[0] + assert req["method"] == "PATCH" + assert "/me/events/outlook-rrule" in req["url"] + body = req["json"] + assert "recurrence" in body + assert body["recurrence"]["pattern"]["type"] == "weekly" + assert body["recurrence"]["pattern"]["daysOfWeek"] == ["monday"] + assert body["recurrence"]["pattern"]["numberOfOccurrences"] == 4 + + +# --------------------------------------------------------------------------- +# test_connection tests +# --------------------------------------------------------------------------- + + +async def test_outlook_test_connection_success(calendar_db_path: Path) -> None: + """Mock Graph /me returns user profile → test_connection() returns (True, '').""" + mock_client = MockOutlookClient() + mock_client.add_response(MockResponse(200, {"id": "user-id", "displayName": "Test User"})) + + provider = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_client) + ) + config = make_config() + + ok, msg = await provider.test_connection(config) + assert ok is True + assert msg == "" + + # Verify GET /me was called + req = mock_client.requests[0] + assert req["method"] == "GET" + assert "/me" in req["url"] diff --git a/tests/unit/tools/test_calendar_tool.py b/tests/unit/tools/test_calendar_tool.py new file mode 100644 index 0000000..39f2468 --- /dev/null +++ b/tests/unit/tools/test_calendar_tool.py @@ -0,0 +1,352 @@ +"""Tests for CalendarTool — Agent tool wrapper for ReAct integration (U3).""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from agentkit.calendar.db import get_event_tags, init_calendar_db +from agentkit.calendar.service import CalendarService +from agentkit.tools.calendar_tool import CalendarTool + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def service(tmp_path: Path) -> CalendarService: + """Provide a CalendarService backed by a temp DB.""" + db_path = tmp_path / "test.db" + asyncio.run(init_calendar_db(db_path)) + return CalendarService(db_path=db_path) + + +@pytest.fixture +def tool(service: CalendarService) -> CalendarTool: + return CalendarTool(calendar_service=service) + + +# --------------------------------------------------------------------------- +# create_event +# --------------------------------------------------------------------------- + + +async def test_create_event_action_returns_success( + tool: CalendarTool, service: CalendarService +) -> None: + """create_event returns success and persists the event with source='agent'.""" + result = await tool.execute( + action="create_event", + user_id="user-1", + title="Sprint Planning", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + description="Bi-weekly sprint planning", + location="Room A", + ) + assert result["success"] is True + event_dict = result["event"] + assert event_dict["title"] == "Sprint Planning" + assert event_dict["source"] == "agent" + assert event_dict["user_id"] == "user-1" + + # Verify persisted in DB + fetched = await service.get_event(event_dict["id"]) + assert fetched is not None + assert fetched.title == "Sprint Planning" + assert fetched.source == "agent" + + +async def test_create_event_with_recurrence_sets_rrule( + tool: CalendarTool, service: CalendarService +) -> None: + """rrule param is stored correctly on the event.""" + rrule = "FREQ=WEEKLY;BYDAY=MO;COUNT=10" + result = await tool.execute( + action="create_event", + user_id="user-1", + title="Weekly Standup", + start_time="2026-07-06T09:00:00+00:00", + end_time="2026-07-06T09:30:00+00:00", + rrule=rrule, + ) + assert result["success"] is True + assert result["event"]["rrule"] == rrule + + fetched = await service.get_event(result["event"]["id"]) + assert fetched is not None + assert fetched.rrule == rrule + + +async def test_query_events_returns_list(tool: CalendarTool) -> None: + """create 2 events, query, verify both returned.""" + await tool.execute( + action="create_event", + user_id="user-1", + title="Event A", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + ) + await tool.execute( + action="create_event", + user_id="user-1", + title="Event B", + start_time="2026-07-02T14:00:00+00:00", + end_time="2026-07-02T15:00:00+00:00", + ) + + result = await tool.execute( + action="query_events", + user_id="user-1", + ) + assert result["success"] is True + events = result["events"] + assert len(events) == 2 + titles = {e["title"] for e in events} + assert titles == {"Event A", "Event B"} + + +async def test_update_event_action_modifies_fields( + tool: CalendarTool, service: CalendarService +) -> None: + """create then update title; verify the field is modified.""" + create_result = await tool.execute( + action="create_event", + user_id="user-1", + title="Original Title", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + ) + assert create_result["success"] is True + event_id = create_result["event"]["id"] + + update_result = await tool.execute( + action="update_event", + user_id="user-1", + event_id=event_id, + title="Updated Title", + ) + assert update_result["success"] is True + + fetched = await service.get_event(event_id) + assert fetched is not None + assert fetched.title == "Updated Title" + + +async def test_delete_event_action_removes_record( + tool: CalendarTool, service: CalendarService +) -> None: + """create then delete; verify the record is gone.""" + create_result = await tool.execute( + action="create_event", + user_id="user-1", + title="To Be Deleted", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + ) + assert create_result["success"] is True + event_id = create_result["event"]["id"] + + delete_result = await tool.execute( + action="delete_event", + user_id="user-1", + event_id=event_id, + ) + assert delete_result["success"] is True + + fetched = await service.get_event(event_id) + assert fetched is None + + +# --------------------------------------------------------------------------- +# error paths +# --------------------------------------------------------------------------- + + +async def test_invalid_action_returns_error(tool: CalendarTool) -> None: + """Unknown action returns success=False with error message.""" + result = await tool.execute( + action="frobnicate", + user_id="user-1", + ) + assert result["success"] is False + assert "Unknown action" in result["error"] + + +async def test_missing_required_field_returns_error(tool: CalendarTool) -> None: + """create_event without title returns success=False.""" + result = await tool.execute( + action="create_event", + user_id="user-1", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + ) + assert result["success"] is False + assert "Missing required field" in result["error"] + assert "title" in result["error"] + + +# --------------------------------------------------------------------------- +# conversation_id +# --------------------------------------------------------------------------- + + +async def test_created_event_has_conversation_id( + tool: CalendarTool, service: CalendarService +) -> None: + """conversation_id is set from context when provided.""" + result = await tool.execute( + action="create_event", + user_id="user-1", + title="Chat-Initiated Event", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + conversation_id="conv-abc-123", + ) + assert result["success"] is True + assert result["event"]["conversation_id"] == "conv-abc-123" + + fetched = await service.get_event(result["event"]["id"]) + assert fetched is not None + assert fetched.conversation_id == "conv-abc-123" + + +# --------------------------------------------------------------------------- +# event_type_name resolution +# --------------------------------------------------------------------------- + + +async def test_create_event_with_event_type_name( + tool: CalendarTool, service: CalendarService +) -> None: + """create event with event_type_name='Meeting' creates the type and links it.""" + result = await tool.execute( + action="create_event", + user_id="user-1", + title="Strategy Sync", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + event_type_name="Meeting", + ) + assert result["success"] is True + event_type_id = result["event"]["event_type_id"] + assert event_type_id is not None + + # Verify the event type was created + types = await service.list_event_types("user-1") + meeting_types = [t for t in types if t.name == "Meeting"] + assert len(meeting_types) == 1 + assert meeting_types[0].id == event_type_id + + # Verify the event references the type + fetched = await service.get_event(result["event"]["id"]) + assert fetched is not None + assert fetched.event_type_id == event_type_id + + +async def test_create_event_reuses_existing_event_type( + tool: CalendarTool, service: CalendarService +) -> None: + """If event_type_name matches an existing type, it is reused (not duplicated).""" + # Pre-create the type + existing = await service.create_event_type("user-1", "Meeting") + + result = await tool.execute( + action="create_event", + user_id="user-1", + title="Second Meeting", + start_time="2026-07-02T10:00:00+00:00", + end_time="2026-07-02T11:00:00+00:00", + event_type_name="Meeting", + ) + assert result["success"] is True + assert result["event"]["event_type_id"] == existing.id + + # No duplicate type created + types = await service.list_event_types("user-1") + meeting_types = [t for t in types if t.name == "Meeting"] + assert len(meeting_types) == 1 + + +# --------------------------------------------------------------------------- +# tag_names resolution +# --------------------------------------------------------------------------- + + +async def test_create_event_with_tag_names( + tool: CalendarTool, service: CalendarService, tmp_path: Path +) -> None: + """create event with tag_names=['urgent', 'work'] creates tags and links them.""" + result = await tool.execute( + action="create_event", + user_id="user-1", + title="Urgent Work Task", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + tag_names=["urgent", "work"], + ) + assert result["success"] is True + event_id = result["event"]["id"] + + # Verify tags were created + tags = await service.list_tags("user-1") + tag_names = {t.name for t in tags} + assert "urgent" in tag_names + assert "work" in tag_names + + # Verify tags are linked to the event + linked_tags = await get_event_tags(event_id, service.db_path) + linked_names = {t.name for t in linked_tags} + assert linked_names == {"urgent", "work"} + + +async def test_create_event_reuses_existing_tags( + tool: CalendarTool, service: CalendarService +) -> None: + """If a tag name matches an existing tag, it is reused (not duplicated).""" + # Pre-create a tag + existing = await service.create_tag("user-1", "urgent") + + result = await tool.execute( + action="create_event", + user_id="user-1", + title="Tagged Event", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + tag_names=["urgent", "new-tag"], + ) + assert result["success"] is True + + # 'urgent' should not be duplicated, 'new-tag' should be created + tags = await service.list_tags("user-1") + urgent_tags = [t for t in tags if t.name == "urgent"] + assert len(urgent_tags) == 1 + assert urgent_tags[0].id == existing.id + new_tags = [t for t in tags if t.name == "new-tag"] + assert len(new_tags) == 1 + + +# --------------------------------------------------------------------------- +# tool registration / schema +# --------------------------------------------------------------------------- + + +def test_tool_name_and_schema(tool: CalendarTool) -> None: + """Tool has correct name and input_schema.""" + assert tool.name == "calendar" + schema = tool.input_schema + assert schema["type"] == "object" + assert "action" in schema["properties"] + assert "user_id" in schema["properties"] + assert schema["properties"]["action"]["enum"] == [ + "create_event", + "query_events", + "update_event", + "delete_event", + ] + assert "action" in schema["required"] + assert "user_id" in schema["required"]