Merge feat/calendar-schedule: calendar & schedule feature (U1-U12 + code review fixes)
Deploy to Production / deploy (push) Waiting to run Details

12 implementation units, 104 tests, 23 code review fixes (2 critical, 15 major, 6 minor).
See docs/plans/2026-06-23-003-feat-calendar-schedule-plan.md for details.
This commit is contained in:
chiguyong 2026-06-24 11:36:30 +08:00
commit 91352d910e
50 changed files with 11831 additions and 2 deletions

View File

@ -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 循环调用、结果返回

View File

@ -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 过重(数百 MBhttpx 已在依赖中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 1158DIRECT_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 3Jan 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 `<template #calendar><CalendarTab /></template>` 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 cleanfrontend 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 限制。

View File

@ -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",

View File

@ -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).
"""

944
src/agentkit/calendar/db.py Normal file
View File

@ -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

View File

@ -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 []

View File

@ -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,
}

View File

@ -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]

View File

@ -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

View File

@ -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

View File

@ -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

View File

View File

@ -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."""
...

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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 WindowsIANA 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)

View File

@ -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))

View File

@ -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",

View File

@ -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",

View File

@ -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<string, unknown>
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<T> (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()

View File

@ -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

View File

@ -0,0 +1,123 @@
<template>
<a-drawer
:open="open"
@close="emit('update:open', false)"
placement="right"
width="80%"
title="日历管理"
:bodyStyle="{ padding: '0 24px 24px', display: 'flex', flexDirection: 'column', overflow: 'hidden' }"
>
<a-tabs v-model:activeKey="activeTab" class="calendar-drawer__tabs">
<a-tab-pane key="calendar" tab="日历">
<div class="calendar-drawer__toolbar">
<a-radio-group :value="store.viewMode" @change="onViewChange">
<a-radio-button value="calendar">日历</a-radio-button>
<a-radio-button value="card">卡片</a-radio-button>
<a-radio-button value="list">列表</a-radio-button>
</a-radio-group>
<a-space>
<a-badge :count="store.pendingInvitations.length" :offset="[6, 0]">
<a-button @click="emit('manage-invitations')">邀请管理</a-button>
</a-badge>
<a-button type="primary" @click="emit('create')">
<template #icon><PlusOutlined /></template>
新建事件
</a-button>
</a-space>
</div>
<div class="calendar-drawer__content">
<CalendarGrid
v-if="store.viewMode === 'calendar'"
@create="onCreate"
@edit="onEdit"
/>
<CardView
v-else-if="store.viewMode === 'card'"
@edit="onEdit"
/>
<ListView
v-else
@edit="onEdit"
/>
</div>
</a-tab-pane>
<a-tab-pane key="reminder" tab="提醒设置" force-render>
<ReminderConfig />
</a-tab-pane>
<a-tab-pane key="sync" tab="同步设置" force-render>
<SyncSettings />
</a-tab-pane>
</a-tabs>
</a-drawer>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { PlusOutlined } from '@ant-design/icons-vue'
import { useCalendarStore } from '@/stores/calendar'
import type { ICalendarEvent } from '@/api/calendar'
import CalendarGrid from './CalendarGrid.vue'
import CardView from './CardView.vue'
import ListView from './ListView.vue'
import ReminderConfig from './ReminderConfig.vue'
import SyncSettings from './SyncSettings.vue'
const store = useCalendarStore()
defineProps<{
open: boolean
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
(e: 'create', start?: Date, end?: Date): void
(e: 'edit', event: ICalendarEvent): void
(e: 'manage-invitations'): void
}>()
const activeTab = ref<string>('calendar')
function onViewChange(e: { target: { value: string } }): void {
store.setViewMode(e.target.value as 'calendar' | 'card' | 'list')
}
function onCreate(start?: Date, end?: Date): void {
emit('create', start, end)
}
function onEdit(event: ICalendarEvent): void {
emit('edit', event)
}
</script>
<style scoped>
.calendar-drawer__tabs {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.calendar-drawer__tabs :deep(.ant-tabs-content-holder) {
flex: 1;
overflow: auto;
}
.calendar-drawer__toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3) 0;
flex-shrink: 0;
}
.calendar-drawer__content {
flex: 1;
overflow: hidden;
border-top: 1px solid var(--border-color-split);
padding-top: var(--space-3);
}
</style>

View File

@ -0,0 +1,96 @@
<template>
<div class="calendar-grid">
<FullCalendar :options="calendarOptions" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid'
import interactionPlugin from '@fullcalendar/interaction'
import type {
CalendarOptions,
EventInput,
DateSelectArg,
EventDropArg,
EventClickArg,
} from '@fullcalendar/core'
import { useCalendarStore } from '@/stores/calendar'
import type { ICalendarEvent } from '@/api/calendar'
const store = useCalendarStore()
const emit = defineEmits<{
(e: 'create', start?: Date, end?: Date): void
(e: 'edit', event: ICalendarEvent): void
}>()
function toFcEvent(ev: ICalendarEvent): EventInput {
const eventType = store.eventTypes.find((t) => t.id === ev.event_type_id)
const color = eventType?.color || '#1677ff'
return {
id: ev.id,
title: ev.title,
start: ev.start_time,
end: ev.end_time,
allDay: ev.is_all_day,
backgroundColor: color,
borderColor: color,
classNames: ev.is_invited ? ['fc-event-invited'] : [],
extendedProps: { event: ev },
}
}
function handleSelect(arg: DateSelectArg): void {
emit('create', arg.start, arg.end)
}
async function handleEventDrop(arg: EventDropArg): Promise<void> {
const ev = arg.event.extendedProps.event as ICalendarEvent
await store.updateEvent(ev.id, {
start_time: arg.event.start?.toISOString() ?? ev.start_time,
end_time: arg.event.end?.toISOString() ?? ev.end_time,
})
}
function handleEventClick(arg: EventClickArg): void {
const ev = arg.event.extendedProps.event as ICalendarEvent
emit('edit', ev)
}
const calendarOptions = computed<CalendarOptions>(() => ({
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
initialView: 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay',
},
selectable: true,
editable: true,
height: '100%',
events: store.events.map(toFcEvent),
select: handleSelect,
eventDrop: handleEventDrop,
eventClick: handleEventClick,
}))
</script>
<style scoped>
.calendar-grid {
height: 100%;
overflow: hidden;
}
.calendar-grid :deep(.fc) {
height: 100%;
}
.calendar-grid :deep(.fc-event-invited) {
border-style: dashed !important;
border-width: 2px !important;
opacity: 0.75;
}
</style>

View File

@ -0,0 +1,311 @@
<template>
<div class="calendar-panel" :class="{ 'calendar-panel--highlight': highlight }">
<div class="calendar-panel__header">
<div class="calendar-panel__title">
<CalendarOutlined />
<span>日历</span>
<span class="calendar-panel__date">{{ todayLabel }}</span>
</div>
<a-button size="small" type="link" @click="openDrawer">
<template #icon><ExpandOutlined /></template>
展开
</a-button>
</div>
<div v-if="store.isLoading && store.events.length === 0" class="calendar-panel__loading">
<a-spin size="small" />
<span>加载日程...</span>
</div>
<div v-else-if="store.error" class="calendar-panel__error">
<WarningOutlined />
<span>{{ store.error }}</span>
</div>
<div v-else-if="todayEvents.length === 0 && upcomingEvents.length === 0" class="calendar-panel__empty">
<CalendarOutlined />
<span>暂无日程</span>
</div>
<div v-else class="calendar-panel__body">
<div v-if="todayEvents.length > 0" class="calendar-panel__section">
<div class="calendar-panel__section-title">今日</div>
<div
v-for="ev in todayEvents"
:key="ev.id"
class="calendar-panel__item"
:class="{ 'calendar-panel__item--invited': ev.is_invited }"
@click="onEventClick(ev)"
>
<span class="calendar-panel__item-time">{{ formatTime(ev.start_time) }}</span>
<span class="calendar-panel__item-title">{{ ev.title }}</span>
<EventBadge :event="ev" />
<a-tag v-if="ev.is_invited" color="purple" size="small">受邀</a-tag>
</div>
</div>
<div v-if="upcomingEvents.length > 0" class="calendar-panel__section">
<div class="calendar-panel__section-title">即将到来</div>
<div
v-for="ev in upcomingEvents"
:key="ev.id"
class="calendar-panel__item"
:class="{ 'calendar-panel__item--invited': ev.is_invited }"
@click="onEventClick(ev)"
>
<span class="calendar-panel__item-time">{{ formatUpcomingTime(ev.start_time) }}</span>
<span class="calendar-panel__item-title">{{ ev.title }}</span>
<EventBadge :event="ev" />
<a-tag v-if="ev.is_invited" color="purple" size="small">受邀</a-tag>
</div>
</div>
</div>
<CalendarDrawer
:open="drawerOpen"
@update:open="drawerOpen = $event"
@create="onCreate"
@edit="onEdit"
@manage-invitations="onManageInvitations"
/>
<EventEditor
v-model:open="editorOpen"
:event="editorEvent"
:prefill-start="editorPrefillStart"
:prefill-end="editorPrefillEnd"
@saved="onSaved"
/>
<InvitationManager
v-model:open="invitationOpen"
:event="editorEvent"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { CalendarOutlined, ExpandOutlined, WarningOutlined } from '@ant-design/icons-vue'
import { useCalendarStore } from '@/stores/calendar'
import type { ICalendarEvent } from '@/api/calendar'
import EventBadge from './EventBadge.vue'
import CalendarDrawer from './CalendarDrawer.vue'
import EventEditor from './EventEditor.vue'
import InvitationManager from './InvitationManager.vue'
const store = useCalendarStore()
const drawerOpen = ref(false)
const highlight = ref(false)
let highlightTimer: ReturnType<typeof setTimeout> | null = null
// EventEditor state
const editorOpen = ref(false)
const editorEvent = ref<ICalendarEvent | null>(null)
const editorPrefillStart = ref<Date | null>(null)
const editorPrefillEnd = ref<Date | null>(null)
// InvitationManager state
const invitationOpen = ref(false)
const todayLabel = new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
weekday: 'short',
}).format(new Date())
const todayEvents = computed(() => {
const now = new Date()
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString()
const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999).toISOString()
return store.events
.filter((e) => e.start_time >= startOfDay && e.start_time <= endOfDay)
.sort((a, b) => a.start_time.localeCompare(b.start_time))
})
const upcomingEvents = computed(() => {
const now = new Date()
const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999).toISOString()
return store.events
.filter((e) => e.start_time > endOfDay)
.sort((a, b) => a.start_time.localeCompare(b.start_time))
.slice(0, 3)
})
// Highlight animation when new event arrives via WS
watch(
() => store.events.length,
(newLen, oldLen) => {
if (newLen > (oldLen ?? 0)) {
highlight.value = true
if (highlightTimer) clearTimeout(highlightTimer)
highlightTimer = setTimeout(() => {
highlight.value = false
}, 2000)
}
},
)
function openDrawer(): void {
drawerOpen.value = true
}
function onEventClick(ev: ICalendarEvent): void {
onEdit(ev)
}
function onCreate(start?: Date, end?: Date): void {
editorEvent.value = null
editorPrefillStart.value = start ?? null
editorPrefillEnd.value = end ?? null
editorOpen.value = true
}
function onEdit(ev: ICalendarEvent): void {
editorEvent.value = ev
editorPrefillStart.value = null
editorPrefillEnd.value = null
editorOpen.value = true
}
function onManageInvitations(): void {
invitationOpen.value = true
}
function onSaved(_event: ICalendarEvent): void {
// Event already added/updated in store by EventEditor; nothing else needed.
}
function formatTime(iso: string): string {
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(new Date(iso))
}
function formatUpcomingTime(iso: string): string {
const date = new Date(iso)
const now = new Date()
const tomorrow = new Date(now)
tomorrow.setDate(tomorrow.getDate() + 1)
if (date.toDateString() === tomorrow.toDateString()) {
return `明天 ${formatTime(iso)}`
}
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date)
}
onMounted(() => {
store.loadEvents()
store.loadEventTypes()
store.loadTags()
store.loadInvitations()
})
</script>
<style scoped>
.calendar-panel {
height: 100%;
overflow-y: auto;
padding: var(--space-3) var(--space-4);
transition: background var(--transition-normal);
}
.calendar-panel--highlight {
background: var(--color-primary-light, rgba(22, 119, 255, 0.06));
}
.calendar-panel__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-3);
}
.calendar-panel__title {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-sm);
font-weight: var(--font-weight-medium);
color: var(--text-primary);
}
.calendar-panel__date {
font-size: var(--font-xs);
color: var(--text-tertiary);
font-weight: normal;
}
.calendar-panel__loading,
.calendar-panel__error,
.calendar-panel__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-8) 0;
color: var(--text-tertiary);
font-size: var(--font-sm);
}
.calendar-panel__error {
color: var(--color-error);
}
.calendar-panel__section {
margin-bottom: var(--space-4);
}
.calendar-panel__section-title {
font-size: var(--font-xs);
color: var(--text-tertiary);
margin-bottom: var(--space-2);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.calendar-panel__item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--transition-fast);
margin-bottom: 2px;
}
.calendar-panel__item:hover {
background: var(--bg-tertiary);
}
.calendar-panel__item--invited {
border: 1px dashed var(--border-color-hover);
background: var(--color-primary-light, rgba(22, 119, 255, 0.04));
}
.calendar-panel__item-time {
font-size: var(--font-xs);
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
min-width: 48px;
}
.calendar-panel__item-title {
flex: 1;
font-size: var(--font-sm);
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -0,0 +1,213 @@
<template>
<div class="card-view">
<div class="card-view__toolbar">
<a-radio-group :value="groupBy" @change="onGroupChange">
<a-radio-button value="date">按日期</a-radio-button>
<a-radio-button value="type">按类型</a-radio-button>
</a-radio-group>
</div>
<a-empty v-if="sortedEvents.length === 0" description="暂无日程" class="card-view__empty" />
<div v-else class="card-view__groups">
<div v-for="group in groups" :key="group.key" class="card-view__group">
<div class="card-view__group-header">
<span class="card-view__group-label">{{ group.label }}</span>
<a-tag size="small">{{ group.events.length }}</a-tag>
</div>
<div class="card-view__cards">
<div
v-for="ev in group.events"
:key="ev.id"
class="card-view__card"
:class="{ 'card-view__card--invited': ev.is_invited }"
:style="cardStyle(ev)"
@click="emit('edit', ev)"
>
<div class="card-view__card-header">
<span class="card-view__card-time">{{ formatTime(ev.start_time) }}</span>
<EventBadge :event="ev" />
<a-tag v-if="ev.is_invited" color="purple" size="small">受邀</a-tag>
</div>
<div class="card-view__card-title">{{ ev.title }}</div>
<div v-if="ev.location" class="card-view__card-location">
<EnvironmentOutlined />
<span>{{ ev.location }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { EnvironmentOutlined } from '@ant-design/icons-vue'
import { useCalendarStore } from '@/stores/calendar'
import type { ICalendarEvent } from '@/api/calendar'
import EventBadge from './EventBadge.vue'
const store = useCalendarStore()
const emit = defineEmits<{
(e: 'edit', event: ICalendarEvent): void
}>()
const groupBy = ref<'date' | 'type'>('date')
function onGroupChange(e: { target: { value: string } }): void {
groupBy.value = e.target.value as 'date' | 'type'
}
const sortedEvents = computed(() =>
[...store.events].sort((a, b) => a.start_time.localeCompare(b.start_time)),
)
interface CardGroup {
key: string
label: string
events: ICalendarEvent[]
}
const groups = computed<CardGroup[]>(() => {
const map = new Map<string, ICalendarEvent[]>()
if (groupBy.value === 'date') {
for (const ev of sortedEvents.value) {
const key = ev.start_time.slice(0, 10)
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(ev)
}
return Array.from(map.entries()).map(([key, events]) => ({
key,
label: formatDateLabel(key),
events,
}))
}
for (const ev of sortedEvents.value) {
const key = ev.event_type_id ?? 'none'
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(ev)
}
return Array.from(map.entries()).map(([key, events]) => ({
key,
label:
key === 'none'
? '未分类'
: store.eventTypes.find((t) => t.id === key)?.name ?? '未分类',
events,
}))
})
function cardStyle(ev: ICalendarEvent): { borderLeftColor: string } {
const eventType = store.eventTypes.find((t) => t.id === ev.event_type_id)
return { borderLeftColor: eventType?.color || '#1677ff' }
}
function formatTime(iso: string): string {
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(new Date(iso))
}
function formatDateLabel(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00')
const today = new Date()
if (date.toDateString() === today.toDateString()) return '今天'
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
if (date.toDateString() === tomorrow.toDateString()) return '明天'
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
weekday: 'short',
}).format(date)
}
</script>
<style scoped>
.card-view {
height: 100%;
overflow-y: auto;
padding: var(--space-3) 0;
}
.card-view__toolbar {
padding: 0 var(--space-3) var(--space-3);
}
.card-view__empty {
padding: var(--space-8) 0;
}
.card-view__groups {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: 0 var(--space-3);
}
.card-view__group-header {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-2);
font-size: var(--font-sm);
font-weight: var(--font-weight-medium);
color: var(--text-secondary);
}
.card-view__cards {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.card-view__card {
padding: var(--space-3);
border: 1px solid var(--border-color);
border-left: 3px solid #1677ff;
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.card-view__card:hover {
background: var(--bg-tertiary);
border-color: var(--border-color-hover);
}
.card-view__card--invited {
border-style: dashed;
background: var(--color-primary-light, rgba(22, 119, 255, 0.06));
}
.card-view__card-header {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-1);
}
.card-view__card-time {
font-size: var(--font-xs);
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
}
.card-view__card-title {
font-weight: var(--font-weight-medium);
color: var(--text-primary);
margin-bottom: var(--space-1);
}
.card-view__card-location {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--font-xs);
color: var(--text-tertiary);
}
</style>

View File

@ -0,0 +1,54 @@
<template>
<span v-if="icon" class="event-badge" :class="`event-badge--${event.source}`" :title="title">
<component :is="icon" />
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { RobotOutlined, ThunderboltOutlined } from '@ant-design/icons-vue'
import type { ICalendarEvent } from '@/api/calendar'
const props = defineProps<{
event: ICalendarEvent
}>()
const icon = computed(() => {
switch (props.event.source) {
case 'agent':
return RobotOutlined
case 'post_extract':
return ThunderboltOutlined
default:
return null
}
})
const title = computed(() => {
switch (props.event.source) {
case 'agent':
return 'Agent 创建'
case 'post_extract':
return '对话提取'
default:
return ''
}
})
</script>
<style scoped>
.event-badge {
display: inline-flex;
align-items: center;
font-size: var(--font-xs);
color: var(--text-tertiary);
}
.event-badge--agent {
color: var(--accent-team, #722ed1);
}
.event-badge--post_extract {
color: var(--accent-board, #fa8c16);
}
</style>

View File

@ -0,0 +1,332 @@
<template>
<a-drawer
:open="open"
@close="close"
placement="right"
:width="560"
:title="isEdit ? '编辑事件' : '新建事件'"
:destroyOnClose="true"
>
<a-form layout="vertical">
<a-form-item label="标题" required>
<a-input v-model:value="form.title" placeholder="事件标题" />
</a-form-item>
<a-form-item label="描述">
<a-textarea v-model:value="form.description" :rows="3" placeholder="事件描述" />
</a-form-item>
<a-form-item label="全天">
<a-switch v-model:checked="form.is_all_day" />
</a-form-item>
<a-form-item label="日期范围" required>
<a-range-picker v-model:value="dateRange" style="width: 100%" />
</a-form-item>
<a-form-item v-if="!form.is_all_day" label="时间">
<a-space>
<a-time-picker v-model:value="startTime" format="HH:mm" :allowClear="false" />
<span></span>
<a-time-picker v-model:value="endTime" format="HH:mm" :allowClear="false" />
</a-space>
</a-form-item>
<a-form-item label="地点">
<a-input v-model:value="form.location" placeholder="事件地点" />
</a-form-item>
<a-form-item label="事件类型">
<a-select v-model:value="form.event_type_id" allowClear placeholder="选择类型">
<a-select-option v-for="t in store.eventTypes" :key="t.id" :value="t.id">
<a-tag :color="t.color" size="small">{{ t.name }}</a-tag>
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="标签">
<a-select
v-model:value="selectedTagIds"
mode="tags"
placeholder="选择或输入标签"
:token-separators="[',']"
>
<a-select-option v-for="t in store.tags" :key="t.id" :value="t.id">
{{ t.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="重复">
<a-space>
<a-select v-model:value="rruleFreq" style="width: 120px">
<a-select-option value="none">不重复</a-select-option>
<a-select-option value="DAILY">每天</a-select-option>
<a-select-option value="WEEKLY">每周</a-select-option>
<a-select-option value="MONTHLY">每月</a-select-option>
</a-select>
<template v-if="rruleFreq !== 'none'">
<span></span>
<a-input-number v-model:value="rruleInterval" :min="1" :max="99" />
<span>{{ freqUnitLabel }}</span>
</template>
</a-space>
</a-form-item>
<a-form-item label="提醒">
<!-- ponytail: reminders UI is local-only; ICreateEventRequest has no reminders field yet.
Upgrade path: add reminders array to ICreateEventRequest/IUpdateEventRequest when backend supports it. -->
<div v-for="(r, i) in reminders" :key="i" class="event-editor__reminder">
<a-input-number v-model:value="r.offset" :min="0" addon-before="提前" addon-after="分钟" />
<a-select v-model:value="r.channel" style="width: 100px">
<a-select-option value="in_app">应用内</a-select-option>
<a-select-option value="email">邮件</a-select-option>
</a-select>
<a-button danger size="small" @click="reminders.splice(i, 1)">
<template #icon><DeleteOutlined /></template>
</a-button>
</div>
<a-button size="small" @click="reminders.push({ offset: 15, channel: 'in_app' })">
<template #icon><PlusOutlined /></template>
添加提醒
</a-button>
</a-form-item>
</a-form>
<template #footer>
<a-space>
<a-button @click="close">取消</a-button>
<a-button type="primary" :loading="store.isLoading" @click="onSave">保存</a-button>
</a-space>
</template>
</a-drawer>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue'
import dayjs, { type Dayjs } from 'dayjs'
import { message } from 'ant-design-vue'
import { useCalendarStore } from '@/stores/calendar'
import type { ICalendarEvent, ICreateEventRequest, IUpdateEventRequest } from '@/api/calendar'
const props = defineProps<{
open: boolean
event: ICalendarEvent | null
prefillStart?: Date | null
prefillEnd?: Date | null
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
(e: 'saved', event: ICalendarEvent): void
}>()
const store = useCalendarStore()
const isEdit = computed(() => !!props.event)
interface ReminderRule {
offset: number
channel: 'in_app' | 'email'
}
const form = reactive({
title: '',
description: '',
is_all_day: false,
location: '',
event_type_id: null as string | null,
})
const dateRange = ref<[Dayjs, Dayjs] | null>(null)
const startTime = ref<Dayjs>(dayjs().hour(9).minute(0).second(0))
const endTime = ref<Dayjs>(dayjs().hour(10).minute(0).second(0))
const selectedTagIds = ref<string[]>([])
const rruleFreq = ref<'none' | 'DAILY' | 'WEEKLY' | 'MONTHLY'>('none')
const rruleInterval = ref(1)
const reminders = ref<ReminderRule[]>([])
const freqUnitLabel = computed(() => {
switch (rruleFreq.value) {
case 'DAILY':
return '天'
case 'WEEKLY':
return '周'
case 'MONTHLY':
return '月'
default:
return ''
}
})
// Initialize form when drawer opens or event changes
watch(
() => [props.open, props.event],
() => {
if (!props.open) return
if (props.event) {
// Edit mode pre-fill from event
form.title = props.event.title
form.description = props.event.description
form.is_all_day = props.event.is_all_day
form.location = props.event.location
form.event_type_id = props.event.event_type_id
const start = dayjs(props.event.start_time)
const end = dayjs(props.event.end_time)
dateRange.value = [start.startOf('day'), end.startOf('day')]
startTime.value = start
endTime.value = end
parseRrule(props.event.rrule)
// ponytail: ICalendarEvent has no tags field; can't pre-fill tag selection.
selectedTagIds.value = []
reminders.value = []
} else {
// Create mode
form.title = ''
form.description = ''
form.is_all_day = false
form.location = ''
form.event_type_id = null
const start = props.prefillStart ? dayjs(props.prefillStart) : dayjs().hour(9).minute(0)
const end = props.prefillEnd ? dayjs(props.prefillEnd) : start.add(1, 'hour')
dateRange.value = [start.startOf('day'), end.startOf('day')]
startTime.value = start
endTime.value = end
rruleFreq.value = 'none'
rruleInterval.value = 1
selectedTagIds.value = []
reminders.value = []
}
},
{ immediate: true },
)
function parseRrule(rrule: string | null): void {
if (!rrule) {
rruleFreq.value = 'none'
rruleInterval.value = 1
return
}
const freqMatch = rrule.match(/FREQ=(\w+)/)
const intervalMatch = rrule.match(/INTERVAL=(\d+)/)
if (freqMatch && ['DAILY', 'WEEKLY', 'MONTHLY'].includes(freqMatch[1])) {
rruleFreq.value = freqMatch[1] as 'DAILY' | 'WEEKLY' | 'MONTHLY'
rruleInterval.value = intervalMatch ? parseInt(intervalMatch[1], 10) : 1
} else {
rruleFreq.value = 'none'
rruleInterval.value = 1
}
}
function buildRrule(): string | null {
if (rruleFreq.value === 'none') return null
return `FREQ=${rruleFreq.value};INTERVAL=${rruleInterval.value}`
}
function close(): void {
emit('update:open', false)
}
/** Resolve tag selections to IDs, creating new tags as needed */
async function resolveTagIds(): Promise<string[]> {
const ids: string[] = []
for (const val of selectedTagIds.value) {
const existing = store.tags.find((t) => t.id === val)
if (existing) {
ids.push(existing.id)
} else {
// New tag name typed by user create it
const tag = await store.createTag({ name: val })
if (tag) ids.push(tag.id)
}
}
return ids
}
async function onSave(): Promise<void> {
if (!form.title.trim()) {
message.warning('请输入标题')
return
}
if (!dateRange.value) {
message.warning('请选择日期范围')
return
}
const [startDate, endDate] = dateRange.value
let startIso: string
let endIso: string
if (form.is_all_day) {
startIso = startDate.startOf('day').toISOString()
endIso = endDate.startOf('day').toISOString()
} else {
// KTD-11: combine local date + time, convert to UTC ISO 8601
startIso = startDate
.hour(startTime.value.hour())
.minute(startTime.value.minute())
.second(0)
.millisecond(0)
.toISOString()
endIso = endDate
.hour(endTime.value.hour())
.minute(endTime.value.minute())
.second(0)
.millisecond(0)
.toISOString()
}
const rrule = buildRrule()
try {
if (props.event) {
const updateData: IUpdateEventRequest = {
title: form.title,
description: form.description,
start_time: startIso,
end_time: endIso,
is_all_day: form.is_all_day,
location: form.location,
event_type_id: form.event_type_id,
rrule,
}
await store.updateEvent(props.event.id, updateData)
message.success('事件已更新')
emit('saved', props.event)
} else {
const tagIds = await resolveTagIds()
const createData: ICreateEventRequest = {
title: form.title,
description: form.description,
start_time: startIso,
end_time: endIso,
is_all_day: form.is_all_day,
location: form.location,
event_type_id: form.event_type_id,
rrule,
tag_ids: tagIds,
}
const created = await store.createEvent(createData)
message.success('事件已创建')
if (created) emit('saved', created)
}
close()
} catch {
message.error(props.event ? '更新失败' : '创建失败')
}
}
</script>
<style scoped>
.event-editor__reminder {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
</style>

View File

@ -0,0 +1,275 @@
<template>
<a-drawer
:open="open"
@close="close"
placement="right"
:width="480"
title="邀请管理"
:destroyOnClose="true"
>
<!-- Event selector + invitees for selected event -->
<div class="invitation-manager__section">
<div class="invitation-manager__section-header">
<span class="invitation-manager__section-title">事件邀请</span>
<a-button
v-if="selectedEventId"
size="small"
type="primary"
@click="inviteModalOpen = true"
>
<template #icon><PlusOutlined /></template>
邀请
</a-button>
</div>
<a-select
v-model:value="selectedEventId"
allowClear
placeholder="选择事件以管理邀请"
style="width: 100%; margin-bottom: 12px"
@change="loadEventInvitations"
>
<a-select-option v-for="ev in store.events" :key="ev.id" :value="ev.id">
{{ ev.title }}
</a-select-option>
</a-select>
<template v-if="selectedEventId">
<a-empty v-if="eventInvitations.length === 0" description="暂无邀请" />
<a-list v-else :dataSource="eventInvitations" size="small">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>{{ item.invitee_email }}</template>
<template #description>
<a-tag :color="statusColor(item.status)">{{ statusLabel(item.status) }}</a-tag>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</template>
</div>
<a-divider />
<!-- Incoming invitations (G6) -->
<div class="invitation-manager__section">
<div class="invitation-manager__section-header">
<span class="invitation-manager__section-title">收到的邀请</span>
<a-badge :count="store.pendingInvitations.length" />
</div>
<a-empty v-if="store.pendingInvitations.length === 0" description="暂无待处理邀请" />
<a-list v-else :dataSource="store.pendingInvitations" size="small">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>{{ invitationEventTitle(item.event_id) }}</template>
<template #description>{{ item.invitee_email }}</template>
</a-list-item-meta>
<template #actions>
<a-space>
<a-button size="small" type="primary" @click="respond(item.id, 'accepted')">
接受
</a-button>
<a-button size="small" @click="respond(item.id, 'tentative')">待定</a-button>
<a-button size="small" danger @click="respond(item.id, 'declined')">
拒绝
</a-button>
</a-space>
</template>
</a-list-item>
</template>
</a-list>
</div>
<!-- User search modal for inviting (G5/A3) -->
<a-modal v-model:open="inviteModalOpen" title="邀请用户" :footer="null" :width="420">
<a-input-search
v-model:value="searchQuery"
placeholder="搜索用户名或邮箱"
@search="doSearch"
:loading="searching"
/>
<a-list
v-if="searchResults.length > 0"
:dataSource="searchResults"
size="small"
class="invitation-manager__search-results"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>{{ item.username }}</template>
<template #description>{{ item.email }}</template>
</a-list-item-meta>
<template #actions>
<a-button
size="small"
type="primary"
:loading="invitingEmail === item.email"
@click="inviteUser(item.email)"
>
邀请
</a-button>
</template>
</a-list-item>
</template>
</a-list>
</a-modal>
</a-drawer>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { PlusOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { useCalendarStore } from '@/stores/calendar'
import { calendarApi } from '@/api/calendar'
import type { ICalendarEvent, IInvitation, IUserSearchResult } from '@/api/calendar'
const props = defineProps<{
open: boolean
event: ICalendarEvent | null
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
}>()
const store = useCalendarStore()
const selectedEventId = ref<string | null>(null)
const eventInvitations = ref<IInvitation[]>([])
const inviteModalOpen = ref(false)
const searchQuery = ref('')
const searchResults = ref<IUserSearchResult[]>([])
const searching = ref(false)
const invitingEmail = ref<string | null>(null)
// Sync event prop to internal selection
watch(
() => [props.open, props.event],
() => {
if (!props.open) return
selectedEventId.value = props.event?.id ?? null
if (selectedEventId.value) {
loadEventInvitations()
} else {
eventInvitations.value = []
}
},
{ immediate: true },
)
function close(): void {
emit('update:open', false)
}
async function loadEventInvitations(): Promise<void> {
if (!selectedEventId.value) {
eventInvitations.value = []
return
}
try {
const resp = await calendarApi.listInvitations()
eventInvitations.value = (resp.invitations || []).filter(
(i) => i.event_id === selectedEventId.value,
)
} catch (err) {
console.warn('Failed to load event invitations:', err)
}
}
async function doSearch(): Promise<void> {
if (!searchQuery.value.trim()) return
searching.value = true
try {
searchResults.value = await store.searchUsers(searchQuery.value)
} finally {
searching.value = false
}
}
async function inviteUser(email: string): Promise<void> {
if (!selectedEventId.value) return
invitingEmail.value = email
try {
await calendarApi.createInvitation({
event_id: selectedEventId.value,
invitee_email: email,
})
message.success(`已邀请 ${email}`)
await loadEventInvitations()
} catch {
message.error('邀请失败')
} finally {
invitingEmail.value = null
}
}
async function respond(
id: string,
status: 'accepted' | 'declined' | 'tentative',
): Promise<void> {
try {
await store.respondToInvitation(id, status)
message.success(status === 'accepted' ? '已接受' : status === 'declined' ? '已拒绝' : '已标记待定')
} catch {
message.error('操作失败')
}
}
function invitationEventTitle(eventId: string): string {
return store.events.find((e) => e.id === eventId)?.title ?? '未知事件'
}
function statusColor(status: IInvitation['status']): string {
switch (status) {
case 'accepted':
return 'green'
case 'declined':
return 'red'
case 'tentative':
return 'orange'
default:
return 'default'
}
}
function statusLabel(status: IInvitation['status']): string {
switch (status) {
case 'accepted':
return '已接受'
case 'declined':
return '已拒绝'
case 'tentative':
return '待定'
default:
return '待回复'
}
}
</script>
<style scoped>
.invitation-manager__section {
margin-bottom: var(--space-3);
}
.invitation-manager__section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-2);
}
.invitation-manager__section-title {
font-size: var(--font-sm);
font-weight: var(--font-weight-medium);
color: var(--text-primary);
}
.invitation-manager__search-results {
margin-top: var(--space-3);
}
</style>

View File

@ -0,0 +1,248 @@
<template>
<div class="list-view">
<!-- Batch operations toolbar -->
<div v-if="selectedRowKeys.length > 0" class="list-view__toolbar">
<span class="list-view__toolbar-count">已选 {{ selectedRowKeys.length }} </span>
<a-space>
<a-popconfirm title="确认删除选中事件?" @confirm="batchDelete">
<a-button size="small" danger>
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</a-popconfirm>
<a-select
v-model:value="batchTypeId"
size="small"
placeholder="更改类型"
allowClear
style="width: 140px"
@change="batchChangeType"
>
<a-select-option v-for="t in store.eventTypes" :key="t.id" :value="t.id">
{{ t.name }}
</a-select-option>
</a-select>
<!-- ponytail: IUpdateEventRequest has no tag_ids field; batch add-tag is UI-only until backend supports it. -->
<a-input-search
v-model:value="batchTagName"
size="small"
placeholder="添加标签"
style="width: 140px"
@search="batchAddTag"
/>
<a-button size="small" @click="clearSelection">取消选择</a-button>
</a-space>
</div>
<a-empty v-if="sortedEvents.length === 0" description="暂无日程" class="list-view__empty" />
<a-table
v-else
:dataSource="sortedEvents"
:columns="columns"
rowKey="id"
size="small"
:pagination="{ pageSize: 50, showSizeChanger: false }"
:customRow="customRow"
:rowSelection="rowSelection"
:scroll="{ y: 'calc(100% - 64px)' }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'start_time'">
<span class="list-view__time">
{{ formatDateTime(record.start_time) }}
<span class="list-view__time-sep"></span>
{{ formatTime(record.end_time) }}
</span>
</template>
<template v-else-if="column.key === 'title'">
<span class="list-view__title" :class="{ 'list-view__title--invited': record.is_invited }">
{{ record.title }}
<a-tag v-if="record.is_invited" color="purple" size="small">受邀</a-tag>
</span>
</template>
<template v-else-if="column.key === 'source'">
<EventBadge :event="record" />
</template>
<template v-else-if="column.key === 'event_type_id'">
<a-tag
v-if="record.event_type_id"
:color="getTypeColor(record.event_type_id)"
size="small"
>
{{ getTypeName(record.event_type_id) }}
</a-tag>
<span v-else class="list-view__muted"></span>
</template>
<template v-else-if="column.key === 'location'">
<span v-if="record.location">{{ record.location }}</span>
<span v-else class="list-view__muted"></span>
</template>
</template>
</a-table>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { DeleteOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { useCalendarStore } from '@/stores/calendar'
import type { ICalendarEvent } from '@/api/calendar'
import EventBadge from './EventBadge.vue'
const store = useCalendarStore()
const emit = defineEmits<{
(e: 'edit', event: ICalendarEvent): void
}>()
const columns = [
{ title: '时间', dataIndex: 'start_time', key: 'start_time', width: 200 },
{ title: '标题', dataIndex: 'title', key: 'title' },
{ title: '来源', dataIndex: 'source', key: 'source', width: 60 },
{ title: '类型', dataIndex: 'event_type_id', key: 'event_type_id', width: 100 },
{ title: '地点', dataIndex: 'location', key: 'location', width: 140 },
]
const sortedEvents = computed(() =>
[...store.events].sort((a, b) => a.start_time.localeCompare(b.start_time)),
)
// --- Batch selection ---
const selectedRowKeys = ref<(string | number)[]>([])
const batchTypeId = ref<string | null>(null)
const batchTagName = ref('')
const rowSelection = {
selectedRowKeys,
onChange: (keys: (string | number)[]): void => {
selectedRowKeys.value = keys
},
}
function clearSelection(): void {
selectedRowKeys.value = []
batchTypeId.value = null
batchTagName.value = ''
}
async function batchDelete(): Promise<void> {
const count = selectedRowKeys.value.length
try {
for (const id of selectedRowKeys.value) {
await store.deleteEvent(id as string)
}
message.success(`已删除 ${count} 个事件`)
clearSelection()
} catch {
message.error('批量删除失败')
}
}
async function batchChangeType(typeId: string | null): Promise<void> {
if (!typeId) return
const count = selectedRowKeys.value.length
try {
for (const id of selectedRowKeys.value) {
await store.updateEvent(id as string, { event_type_id: typeId })
}
message.success(`已更新 ${count} 个事件的类型`)
batchTypeId.value = null
clearSelection()
} catch {
message.error('批量更新类型失败')
}
}
function batchAddTag(): void {
// ponytail: IUpdateEventRequest has no tag_ids; API doesn't support batch tag assignment yet.
message.info('批量添加标签功能暂未支持API 待扩展)')
batchTagName.value = ''
}
function customRow(record: ICalendarEvent): Record<string, () => void> {
return {
onClick: () => emit('edit', record),
}
}
function getTypeName(id: string): string {
return store.eventTypes.find((t) => t.id === id)?.name ?? '未知'
}
function getTypeColor(id: string): string {
return store.eventTypes.find((t) => t.id === id)?.color ?? 'blue'
}
function formatTime(iso: string): string {
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(new Date(iso))
}
function formatDateTime(iso: string): string {
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(new Date(iso))
}
</script>
<style scoped>
.list-view {
height: 100%;
overflow: hidden;
padding: var(--space-3) 0;
}
.list-view__toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-2) var(--space-3);
margin-bottom: var(--space-2);
background: var(--color-primary-light, rgba(22, 119, 255, 0.06));
border-radius: var(--radius-md);
}
.list-view__toolbar-count {
font-size: var(--font-sm);
color: var(--text-secondary);
}
.list-view__empty {
padding: var(--space-8) 0;
}
.list-view__time {
font-size: var(--font-xs);
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
}
.list-view__time-sep {
margin: 0 var(--space-1);
color: var(--text-placeholder);
}
.list-view__title {
font-weight: var(--font-weight-medium);
color: var(--text-primary);
display: flex;
align-items: center;
gap: var(--space-1);
}
.list-view__title--invited {
font-style: italic;
}
.list-view__muted {
color: var(--text-placeholder);
}
</style>

View File

@ -0,0 +1,265 @@
<template>
<div class="reminder-config">
<a-alert
type="info"
show-icon
message="为每种事件类型配置默认提醒规则。新建事件时将由后端U5自动继承对应规则。"
style="margin-bottom: var(--space-3)"
/>
<a-form layout="inline" style="margin-bottom: var(--space-3)">
<a-form-item label="事件类型">
<a-select
:value="selectedTypeId"
style="width: 220px"
placeholder="选择事件类型"
:options="typeOptions"
allow-clear
@change="onTypeChange"
/>
</a-form-item>
</a-form>
<div v-if="!selectedTypeId" class="reminder-config__empty">
<BellOutlined />
<span>请选择事件类型以配置提醒规则</span>
</div>
<div v-else class="reminder-config__rules">
<div v-if="draftRules.length === 0" class="reminder-config__empty">
<BellOutlined />
<span>暂无提醒规则</span>
</div>
<div v-else class="reminder-config__rule-list">
<div
v-for="(rule, index) in draftRules"
:key="index"
class="reminder-config__rule"
>
<span class="reminder-config__rule-offset">
<ClockCircleOutlined />
{{ rule.offset_minutes }} 分钟前
</span>
<div class="reminder-config__rule-channels">
<a-tag
v-for="ch in rule.channels"
:key="ch"
:color="channelColor(ch)"
>
{{ channelLabel(ch) }}
</a-tag>
</div>
<a-button type="link" danger size="small" @click="removeRule(index)">
<template #icon><DeleteOutlined /></template>
移除
</a-button>
</div>
</div>
<a-divider style="margin: var(--space-3) 0" />
<div class="reminder-config__add">
<a-input-number
v-model:value="newOffset"
:min="0"
:step="5"
addon-before="提前"
addon-after="分钟"
style="width: 200px"
/>
<a-checkbox-group v-model:value="newChannels" :options="channelOptions" />
<a-button type="primary" :disabled="!canAdd" @click="addRule">
<template #icon><PlusOutlined /></template>
添加规则
</a-button>
</div>
<div style="margin-top: var(--space-4)">
<a-button type="primary" @click="save">
<template #icon><CheckCircleOutlined /></template>
保存规则
</a-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { notification } from 'ant-design-vue'
import {
BellOutlined,
PlusOutlined,
DeleteOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
} from '@ant-design/icons-vue'
import { useCalendarStore } from '@/stores/calendar'
type ReminderChannel = 'client' | 'email' | 'webhook'
interface IReminderRule {
offset_minutes: number
channels: ReminderChannel[]
}
const STORAGE_KEY = 'calendar:reminder-rules'
const channelOptions: Array<{ label: string; value: ReminderChannel }> = [
{ label: '客户端', value: 'client' },
{ label: '邮件', value: 'email' },
{ label: 'Webhook', value: 'webhook' },
]
// ponytail: backend endpoint for per-event-type reminder rule CRUD is
// deferred U5 inherits rules at event creation time. Until the CRUD
// route lands, rules are persisted locally in localStorage (native
// platform feature, no new deps). Functional but per-device; the upgrade
// path is a GET/PUT /event-types/{id}/reminder-rules endpoint.
function loadRules(): Record<string, IReminderRule[]> {
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? (JSON.parse(raw) as Record<string, IReminderRule[]>) : {}
} catch {
return {}
}
}
function persistRules(rules: Record<string, IReminderRule[]>): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(rules))
} catch {
/* quota / private mode — non-fatal, rules stay in-memory for the session */
}
}
const store = useCalendarStore()
const allRules = ref<Record<string, IReminderRule[]>>(loadRules())
const selectedTypeId = ref<string | null>(null)
const draftRules = ref<IReminderRule[]>([])
const newOffset = ref(15)
const newChannels = ref<ReminderChannel[]>(['client'])
const typeOptions = computed(() =>
store.eventTypes.map((t) => ({ label: t.name, value: t.id })),
)
const canAdd = computed(() => newChannels.value.length > 0 && newOffset.value >= 0)
// When the selected type changes, load its rules into the working draft.
watch(
selectedTypeId,
(id) => {
if (!id) {
draftRules.value = []
return
}
draftRules.value = (allRules.value[id] ?? []).map((r) => ({
offset_minutes: r.offset_minutes,
channels: [...r.channels],
}))
},
{ immediate: true },
)
function onTypeChange(id: string | undefined): void {
selectedTypeId.value = id ?? null
}
function addRule(): void {
if (!canAdd.value) return
draftRules.value.push({
offset_minutes: newOffset.value,
channels: [...newChannels.value],
})
}
function removeRule(index: number): void {
draftRules.value.splice(index, 1)
}
function save(): void {
if (!selectedTypeId.value) return
allRules.value[selectedTypeId.value] = draftRules.value.map((r) => ({
offset_minutes: r.offset_minutes,
channels: [...r.channels],
}))
persistRules(allRules.value)
notification.success({ message: '提醒规则已保存' })
}
function channelLabel(ch: ReminderChannel): string {
return channelOptions.find((o) => o.value === ch)?.label ?? ch
}
function channelColor(ch: ReminderChannel): string {
return ch === 'client' ? 'blue' : ch === 'email' ? 'green' : 'orange'
}
onMounted(() => {
// ponytail: Tauri system-notification integration (showing native OS
// notifications for calendar_reminder WS events) is deferred the
// store currently surfaces reminders via ant-design notification only.
if (store.eventTypes.length === 0) {
store.loadEventTypes()
}
})
</script>
<style scoped>
.reminder-config {
padding: var(--space-2) 0;
}
.reminder-config__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-6) 0;
color: var(--text-tertiary);
font-size: var(--font-sm);
}
.reminder-config__rule-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.reminder-config__rule {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-color-split);
border-radius: var(--radius-md);
}
.reminder-config__rule-offset {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: var(--font-sm);
color: var(--text-primary);
font-variant-numeric: tabular-nums;
min-width: 120px;
}
.reminder-config__rule-channels {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.reminder-config__add {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
</style>

View File

@ -0,0 +1,350 @@
<template>
<div class="sync-settings">
<!-- G4: persistent conflict alerts sourced from store.syncConflicts
(populated by the store's handleWsEvent on calendar_sync_conflict). -->
<a-alert
v-for="(c, i) in store.syncConflicts"
:key="`${c.event_id}-${i}`"
type="warning"
show-icon
:message="`同步冲突:${c.event_title}`"
style="margin-bottom: var(--space-3)"
>
<template #description>
<div class="sync-settings__conflict">
<div><span class="sync-settings__label">提供商</span>{{ c.provider }}</div>
<div><span class="sync-settings__label">本地修改</span>{{ formatTime(c.local_modified) }}</div>
<div><span class="sync-settings__label">远端修改</span>{{ formatTime(c.remote_modified) }}</div>
<div><span class="sync-settings__label">处理策略</span>{{ c.resolution }}</div>
</div>
</template>
</a-alert>
<div class="sync-settings__actions">
<a-button @click="showAppleForm = true">
<template #icon><AppleOutlined /></template>
添加 Apple 日历
</a-button>
<!-- ponytail: OAuth callback route (/api/v1/calendar/auth/outlook/callback)
is deferred this button only starts the redirect flow; the backend
will redirect to Microsoft's consent page and back. -->
<a-button @click="gotoOutlookAuth">
<template #icon><WindowsOutlined /></template>
添加 Outlook
</a-button>
<a-button :loading="loading" @click="loadConfigs">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</div>
<a-spin :spinning="loading">
<a-empty
v-if="configs.length === 0 && !loading"
description="尚未配置外部日历"
style="padding: var(--space-6) 0"
/>
<div v-else class="sync-settings__list">
<a-card
v-for="cfg in configs"
:key="cfg.id"
size="small"
class="sync-settings__card"
>
<template #title>
<div class="sync-settings__card-title">
<AppleOutlined v-if="cfg.provider === 'caldav'" />
<WindowsOutlined v-else />
<span>{{ providerLabel(cfg.provider) }}</span>
<a-badge status="success" text="已连接" />
</div>
</template>
<template #extra>
<a-space>
<a-button size="small" :loading="syncingId === cfg.id" @click="onSync(cfg.id)">
<template #icon><SyncOutlined /></template>
立即同步
</a-button>
<a-button size="small" :loading="testingId === cfg.id" @click="onTest(cfg.id)">
<template #icon><ApiOutlined /></template>
测试连接
</a-button>
<a-popconfirm title="确认移除该外部日历?" @confirm="onRemove(cfg.id)">
<a-button size="small" danger>
<template #icon><DeleteOutlined /></template>
移除
</a-button>
</a-popconfirm>
</a-space>
</template>
<div class="sync-settings__card-body">
<div class="sync-settings__row">
<span class="sync-settings__label">上次同步</span>
<span>{{ cfg.last_sync ? formatTime(cfg.last_sync) : '从未同步' }}</span>
</div>
<div class="sync-settings__row">
<span class="sync-settings__label">同步范围</span>
<!-- ponytail: no PATCH endpoint for sync_scope yet edits are
local-only until the backend adds an update route. -->
<a-select
v-model:value="cfg.sync_scope"
mode="multiple"
style="flex: 1; min-width: 200px"
placeholder="选择要同步的事件类型(留空同步全部)"
:options="scopeOptions"
allow-clear
/>
</div>
</div>
</a-card>
</div>
</a-spin>
<!-- Apple Calendar (CalDAV) add form -->
<a-modal
:open="showAppleForm"
title="添加 Apple 日历 (CalDAV)"
:confirm-loading="submitting"
ok-text="添加"
cancel-text="取消"
@ok="submitApple"
@cancel="showAppleForm = false"
>
<a-form layout="vertical">
<a-form-item label="CalDAV URL" required>
<a-input v-model:value="appleForm.url" placeholder="https://caldav.icloud.com" />
</a-form-item>
<a-form-item label="Apple ID" required>
<a-input v-model:value="appleForm.username" placeholder="apple@example.com" />
</a-form-item>
<a-form-item label="App 专用密码" required>
<a-input-password v-model:value="appleForm.password" placeholder="应用专用密码" />
</a-form-item>
<a-form-item label="同步范围">
<a-select
v-model:value="appleForm.scope"
mode="multiple"
placeholder="留空则同步全部"
:options="scopeOptions"
allow-clear
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { notification } from 'ant-design-vue'
import {
AppleOutlined,
WindowsOutlined,
SyncOutlined,
ReloadOutlined,
ApiOutlined,
DeleteOutlined,
} from '@ant-design/icons-vue'
import { calendarApi } from '@/api/calendar'
import { useCalendarStore } from '@/stores/calendar'
import type { IExternalCalendarConfig } from '@/api/calendar'
const store = useCalendarStore()
const configs = ref<IExternalCalendarConfig[]>([])
const loading = ref(false)
const syncingId = ref<string | null>(null)
const testingId = ref<string | null>(null)
const submitting = ref(false)
const showAppleForm = ref(false)
const appleForm = ref({
url: '',
username: '',
password: '',
scope: [] as string[],
})
// ponytail: OAuth callback route is deferred this URL starts the redirect
// flow only; the backend redirects to Microsoft's consent page.
const OUTLOOK_AUTH_URL = '/api/v1/calendar/auth/outlook'
const scopeOptions = computed(() =>
store.eventTypes.map((t) => ({ label: t.name, value: t.name })),
)
async function loadConfigs(): Promise<void> {
loading.value = true
try {
const resp = await calendarApi.listExternalConfigs()
configs.value = resp.configs || []
} catch (err) {
notification.error({ message: '加载外部日历配置失败' })
console.warn('listExternalConfigs failed:', err)
} finally {
loading.value = false
}
}
async function onSync(id: string): Promise<void> {
syncingId.value = id
try {
const resp = await calendarApi.syncNow(id)
if (resp.synced) {
notification.success({ message: '同步已触发' })
await loadConfigs()
} else {
notification.warning({ message: '同步失败', description: resp.error })
}
} catch (err) {
notification.error({ message: '同步失败' })
console.warn('syncNow failed:', err)
} finally {
syncingId.value = null
}
}
async function onTest(id: string): Promise<void> {
testingId.value = id
try {
const resp = await calendarApi.testExternalConnection(id)
if (resp.connected) {
notification.success({ message: '连接正常' })
} else {
notification.warning({ message: '连接失败', description: resp.error })
}
} catch (err) {
notification.error({ message: '测试连接失败' })
console.warn('testExternalConnection failed:', err)
} finally {
testingId.value = null
}
}
async function onRemove(id: string): Promise<void> {
try {
await calendarApi.deleteExternalConfig(id)
configs.value = configs.value.filter((c) => c.id !== id)
notification.success({ message: '已移除外部日历' })
} catch (err) {
notification.error({ message: '移除失败' })
console.warn('deleteExternalConfig failed:', err)
}
}
async function submitApple(): Promise<void> {
const f = appleForm.value
if (!f.url || !f.username || !f.password) {
notification.warning({ message: '请填写完整的 CalDAV 信息' })
return
}
submitting.value = true
try {
const credentials = JSON.stringify({
url: f.url,
username: f.username,
password: f.password,
})
const resp = await calendarApi.createExternalConfig({
provider: 'caldav',
credentials,
sync_scope: f.scope,
})
configs.value.push(resp.config)
showAppleForm.value = false
appleForm.value = { url: '', username: '', password: '', scope: [] }
notification.success({ message: 'Apple 日历已添加' })
} catch (err) {
notification.error({ message: '添加失败' })
console.warn('createExternalConfig failed:', err)
} finally {
submitting.value = false
}
}
function gotoOutlookAuth(): void {
window.location.href = OUTLOOK_AUTH_URL
}
function providerLabel(p: IExternalCalendarConfig['provider']): string {
return p === 'caldav' ? 'Apple 日历 (CalDAV)' : 'Outlook'
}
// KTD-11: format ISO timestamps in the user's local timezone.
const dtf = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
function formatTime(iso: string): string {
try {
return dtf.format(new Date(iso))
} catch {
return iso
}
}
onMounted(() => {
loadConfigs()
if (store.eventTypes.length === 0) {
store.loadEventTypes()
}
})
</script>
<style scoped>
.sync-settings {
padding: var(--space-2) 0;
}
.sync-settings__actions {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-3);
flex-wrap: wrap;
}
.sync-settings__conflict {
display: flex;
flex-direction: column;
gap: 2px;
font-size: var(--font-sm);
}
.sync-settings__list {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.sync-settings__card-title {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-sm);
}
.sync-settings__card-body {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.sync-settings__row {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-sm);
}
.sync-settings__label {
color: var(--text-tertiary);
min-width: 72px;
flex-shrink: 0;
}
</style>

View File

@ -74,6 +74,9 @@
<template #skills>
<SkillsView />
</template>
<template #calendar>
<CalendarTab />
</template>
<template #settings>
<SettingsView />
</template>
@ -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 },
]

View File

@ -0,0 +1,7 @@
<template>
<CalendarPanel />
</template>
<script setup lang="ts">
import CalendarPanel from '@/components/calendar/CalendarPanel.vue'
</script>

View File

@ -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<ICalendarEvent[]>([])
const eventTypes = ref<IEventType[]>([])
const tags = ref<ITag[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const selectedEvent = ref<ICalendarEvent | null>(null)
const viewMode = ref<CalendarViewMode>('calendar')
const dateRange = ref<{ start: string | null; end: string | null }>({
start: null,
end: null,
})
const pendingInvitations = ref<IInvitation[]>([])
const syncConflicts = ref<ICalendarSyncConflictData[]>([])
// --- 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<void> {
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<ICalendarEvent | null> {
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<void> {
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<void> {
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<void> {
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<void> {
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<ITag | null> {
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<void> {
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<void> {
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<IUserSearchResult[]> {
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,
}
})

View File

@ -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"'},
)

View File

@ -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, nameid 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}"}

View File

View File

@ -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

View File

@ -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"] == "有效会议"

View File

@ -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"}

View File

@ -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 3Jan 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

View File

@ -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

View File

@ -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]

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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"]

View File

@ -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"]