From 2ea799f6c4097c799478eb3c560e3882b0683471 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Tue, 23 Jun 2026 21:30:39 +0800 Subject: [PATCH 01/16] feat(calendar): U1 backend data model, storage & RRULE expansion Add calendar subsystem foundation mirroring documents/ pattern: - models.py: 8 dataclasses (CalendarEvent with is_invited, EventType, Tag, EventTag, ReminderRule, ReminderDelivery, ExternalCalendarConfig, Invitation) - db.py: aiosqlite bare-connection CRUD for all 8 tables with WAL mode - recurrence.py: RRULE expansion via dateutil.rrule (RFC 5545) - 16 unit tests covering DB CRUD and RRULE edge cases (DST, UNTIL, range) - Add python-dateutil>=2.9 to pyproject.toml --- pyproject.toml | 2 + src/agentkit/calendar/__init__.py | 7 + src/agentkit/calendar/db.py | 798 +++++++++++++++++++++++++ src/agentkit/calendar/models.py | 221 +++++++ src/agentkit/calendar/recurrence.py | 67 +++ tests/unit/calendar/__init__.py | 0 tests/unit/calendar/test_db.py | 305 ++++++++++ tests/unit/calendar/test_recurrence.py | 75 +++ 8 files changed, 1475 insertions(+) create mode 100644 src/agentkit/calendar/__init__.py create mode 100644 src/agentkit/calendar/db.py create mode 100644 src/agentkit/calendar/models.py create mode 100644 src/agentkit/calendar/recurrence.py create mode 100644 tests/unit/calendar/__init__.py create mode 100644 tests/unit/calendar/test_db.py create mode 100644 tests/unit/calendar/test_recurrence.py diff --git a/pyproject.toml b/pyproject.toml index 738885a..56f2e95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,8 @@ dependencies = [ "pyjwt>=2.8", "bcrypt>=4.0", "aiosqlite>=0.20", + # Calendar & schedule (RRULE expansion) + "python-dateutil>=2.9", # Document processing (U1-U9) "python-docx>=1.1", "openpyxl>=3.1", diff --git a/src/agentkit/calendar/__init__.py b/src/agentkit/calendar/__init__.py new file mode 100644 index 0000000..da8354b --- /dev/null +++ b/src/agentkit/calendar/__init__.py @@ -0,0 +1,7 @@ +"""Calendar & schedule subsystem. + +Mirrors the ``documents/`` structure: ``models.py`` (dataclass DTOs), +``db.py`` (aiosqlite persistence), ``service.py`` (business logic), +``recurrence.py`` (RRULE expansion), ``scheduler.py`` (reminder loop), +``extraction.py`` (post-processing), and ``sync/`` (external calendars). +""" diff --git a/src/agentkit/calendar/db.py b/src/agentkit/calendar/db.py new file mode 100644 index 0000000..0d66420 --- /dev/null +++ b/src/agentkit/calendar/db.py @@ -0,0 +1,798 @@ +"""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, + FOREIGN KEY (reminder_rule_id) REFERENCES calendar_reminder_rules(id), + 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 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: + 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 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: + 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 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: + 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 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( + "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: + 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: + 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: + 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( + "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: + 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("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( + "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( + "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: + 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( + "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: + 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: + 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: + 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( + "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 +) -> list[ReminderDelivery]: + """Check idempotency — return existing deliveries for an event+rule.""" + path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + async with aiosqlite.connect(str(path)) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT * FROM calendar_reminder_deliveries " + "WHERE event_id = ? AND reminder_rule_id = ?", + (event_id, reminder_rule_id), + ) + 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: + 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( + "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: + 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: + 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: + 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( + "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: + 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: + 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: + cursor = await db.execute( + "UPDATE calendar_invitations SET status = ?, responded_at = ? WHERE id = ?", + (status, responded_at, invitation_id), + ) + await db.commit() + return cursor.rowcount > 0 diff --git a/src/agentkit/calendar/models.py b/src/agentkit/calendar/models.py new file mode 100644 index 0000000..ef2abeb --- /dev/null +++ b/src/agentkit/calendar/models.py @@ -0,0 +1,221 @@ +"""Data models for the calendar subsystem. + +All dataclasses are DTOs carried between CalendarService, Agent tools, +REST routes, and the frontend. They mirror the ``calendar_*`` DB rows. +All timestamps are ISO 8601 UTC (see KTD-11). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone + + +def _now_iso() -> str: + """Return current UTC time as ISO 8601 string.""" + return datetime.now(timezone.utc).isoformat() + + +@dataclass +class EventType: + """User-defined event type (e.g. "会议", "截止", "个人").""" + + id: str + user_id: str + name: str + color: str = "#4A90D9" + is_default: bool = False + + def to_dict(self) -> dict[str, object]: + return { + "id": self.id, + "user_id": self.user_id, + "name": self.name, + "color": self.color, + "is_default": self.is_default, + } + + +@dataclass +class Tag: + """User-defined tag for events.""" + + id: str + user_id: str + name: str + + def to_dict(self) -> dict[str, object]: + return {"id": self.id, "user_id": self.user_id, "name": self.name} + + +@dataclass +class CalendarEvent: + """A calendar event. + + Attributes: + source: "manual" | "agent" | "post_extract" — origin traceability (R15). + is_invited: True if this event arrived via invitation (R33 — special styling). + rrule: RFC 5545 RRULE string, e.g. "FREQ=WEEKLY;BYDAY=MO;COUNT=10". + external_id: ID in external calendar (for sync). + external_provider: "caldav" | "outlook" | None. + last_modified: ISO 8601 UTC, for conflict resolution (last-write-wins). + """ + + id: str + user_id: str + title: str + description: str = "" + start_time: str = "" # ISO 8601 UTC (KTD-11) + end_time: str = "" # ISO 8601 UTC + is_all_day: bool = False + location: str = "" + event_type_id: str | None = None + rrule: str | None = None + source: str = "manual" # "manual" | "agent" | "post_extract" + is_invited: bool = False + conversation_id: str | None = None + external_id: str | None = None + external_provider: str | None = None + last_modified: str = "" + created_at: str = "" + + def to_dict(self) -> dict[str, object]: + return { + "id": self.id, + "user_id": self.user_id, + "title": self.title, + "description": self.description, + "start_time": self.start_time, + "end_time": self.end_time, + "is_all_day": self.is_all_day, + "location": self.location, + "event_type_id": self.event_type_id, + "rrule": self.rrule, + "source": self.source, + "is_invited": self.is_invited, + "conversation_id": self.conversation_id, + "external_id": self.external_id, + "external_provider": self.external_provider, + "last_modified": self.last_modified, + "created_at": self.created_at, + } + + +@dataclass +class EventTag: + """Many-to-many junction between events and tags.""" + + event_id: str + tag_id: str + + def to_dict(self) -> dict[str, object]: + return {"event_id": self.event_id, "tag_id": self.tag_id} + + +@dataclass +class ReminderRule: + """Reminder rule — per-event or per-event-type default. + + Attributes: + event_id: FK to events (nullable for type-level defaults). + event_type_id: FK to event_types (for default reminders). + offset_minutes: -15 = 15 min before, -1440 = 1 day before. + channels: ["client", "email", "webhook"]. + """ + + id: str + event_id: str | None = None + event_type_id: str | None = None + offset_minutes: int = -15 + channels: list[str] = field(default_factory=lambda: ["client"]) + + def to_dict(self) -> dict[str, object]: + return { + "id": self.id, + "event_id": self.event_id, + "event_type_id": self.event_type_id, + "offset_minutes": self.offset_minutes, + "channels": self.channels, + } + + +@dataclass +class ReminderDelivery: + """Tracks delivery status of a reminder instance.""" + + id: str + reminder_rule_id: str + event_id: str + scheduled_time: str # ISO 8601 UTC + status: str = "pending" # "pending" | "sent" | "failed" | "read" + channel: str = "client" + attempts: int = 0 + last_error: str | None = None + + def to_dict(self) -> dict[str, object]: + return { + "id": self.id, + "reminder_rule_id": self.reminder_rule_id, + "event_id": self.event_id, + "scheduled_time": self.scheduled_time, + "status": self.status, + "channel": self.channel, + "attempts": self.attempts, + "last_error": self.last_error, + } + + +@dataclass +class ExternalCalendarConfig: + """Configuration for an external calendar sync provider. + + Attributes: + provider: "caldav" | "outlook". + credentials: encrypted JSON (CalDAV URL+user+app_password, or OAuth refresh_token). + sync_frequency: sync interval in minutes. + sync_scope: event type IDs to sync, empty = all. + sync_token: delta token for incremental sync. + """ + + id: str + user_id: str + provider: str # "caldav" | "outlook" + credentials: str = "" # encrypted JSON + sync_frequency: int = 30 # minutes + sync_scope: list[str] = field(default_factory=list) + last_sync: str | None = None + sync_token: str | None = None + + def to_dict(self) -> dict[str, object]: + return { + "id": self.id, + "user_id": self.user_id, + "provider": self.provider, + "credentials": "***", # Never expose credentials + "sync_frequency": self.sync_frequency, + "sync_scope": self.sync_scope, + "last_sync": self.last_sync, + "sync_token": self.sync_token, + } + + +@dataclass +class Invitation: + """Event invitation from one user to another.""" + + id: str + event_id: str + inviter_user_id: str + invitee_email: str + status: str = "pending" # "pending" | "accepted" | "declined" | "tentative" + responded_at: str | None = None + + def to_dict(self) -> dict[str, object]: + return { + "id": self.id, + "event_id": self.event_id, + "inviter_user_id": self.inviter_user_id, + "invitee_email": self.invitee_email, + "status": self.status, + "responded_at": self.responded_at, + } diff --git a/src/agentkit/calendar/recurrence.py b/src/agentkit/calendar/recurrence.py new file mode 100644 index 0000000..98d6bb6 --- /dev/null +++ b/src/agentkit/calendar/recurrence.py @@ -0,0 +1,67 @@ +"""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 + +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.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: + occurrences = list(rule) + + # Convert back to ISO 8601 UTC strings + return [dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S+00:00") for dt in occurrences] diff --git a/tests/unit/calendar/__init__.py b/tests/unit/calendar/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/calendar/test_db.py b/tests/unit/calendar/test_db.py new file mode 100644 index 0000000..ebd4664 --- /dev/null +++ b/tests/unit/calendar/test_db.py @@ -0,0 +1,305 @@ +"""Tests for calendar DB CRUD (U1).""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from agentkit.calendar.db import ( + add_tag_to_event, + delete_event, + delete_event_type, + get_event, + get_event_tags, + init_calendar_db, + insert_event, + insert_event_type, + insert_external_config, + insert_invitation, + insert_reminder_rule, + insert_tag, + list_event_types, + list_events, + list_external_configs, + list_invitations, + list_reminder_rules_for_event, + list_reminder_rules_for_type, + list_tags, + update_event, + update_event_type, + update_invitation_status, +) +from agentkit.calendar.models import ( + CalendarEvent, + EventType, + ExternalCalendarConfig, + Invitation, + ReminderRule, + Tag, + _now_iso, +) + + +@pytest.fixture +def db_path(tmp_path: Path) -> Path: + path = tmp_path / "test_calendar.db" + asyncio.run(init_calendar_db(path)) + return path + + +def _make_event( + event_id: str = "evt-1", + user_id: str = "user-1", + title: str = "Test Event", + start: str = "2026-07-01T10:00:00+00:00", + end: str = "2026-07-01T11:00:00+00:00", + **kwargs, +) -> CalendarEvent: + now = _now_iso() + return CalendarEvent( + id=event_id, + user_id=user_id, + title=title, + start_time=start, + end_time=end, + last_modified=now, + created_at=now, + **kwargs, + ) + + +# --------------------------------------------------------------------------- +# init_calendar_db +# --------------------------------------------------------------------------- + + +def test_init_calendar_db_creates_all_tables(db_path: Path) -> None: + """init_calendar_db creates all 8 tables.""" + import aiosqlite + + async def check(): + async with aiosqlite.connect(str(db_path)) as db: + cursor = await db.execute( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ) + tables = {row[0] for row in await cursor.fetchall()} + return tables + + tables = asyncio.run(check()) + expected = { + "calendar_events", + "calendar_event_types", + "calendar_tags", + "calendar_event_tags", + "calendar_reminder_rules", + "calendar_reminder_deliveries", + "calendar_external_configs", + "calendar_invitations", + } + assert expected.issubset(tables), f"Missing tables: {expected - tables}" + + +# --------------------------------------------------------------------------- +# Event CRUD +# --------------------------------------------------------------------------- + + +def test_insert_and_get_event_roundtrip(db_path: Path) -> None: + """Insert event, fetch by id, all fields preserved including is_invited.""" + event = _make_event(is_invited=True, source="agent", conversation_id="conv-1") + asyncio.run(insert_event(event, db_path)) + + fetched = asyncio.run(get_event("evt-1", db_path)) + assert fetched is not None + assert fetched.id == "evt-1" + assert fetched.title == "Test Event" + assert fetched.is_invited is True + assert fetched.source == "agent" + assert fetched.conversation_id == "conv-1" + + +def test_list_events_by_user_filtered_by_date_range(db_path: Path) -> None: + """Insert 3 events, query range covering 2.""" + for i, day in enumerate([1, 15, 28]): + evt = _make_event( + event_id=f"evt-{i}", + start=f"2026-07-{day:02d}T10:00:00+00:00", + end=f"2026-07-{day:02d}T11:00:00+00:00", + ) + asyncio.run(insert_event(evt, db_path)) + + events = asyncio.run( + list_events( + "user-1", + start="2026-07-10T00:00:00+00:00", + end="2026-07-20T00:00:00+00:00", + db_path=db_path, + ) + ) + assert len(events) == 1 + assert events[0].id == "evt-1" + + +def test_update_event_modifies_fields(db_path: Path) -> None: + """Insert, update title, verify.""" + asyncio.run(insert_event(_make_event(), db_path)) + updated = asyncio.run(update_event("evt-1", {"title": "Updated"}, db_path)) + assert updated is True + + fetched = asyncio.run(get_event("evt-1", db_path)) + assert fetched is not None + assert fetched.title == "Updated" + + +def test_delete_event_removes_record(db_path: Path) -> None: + """Insert, delete, verify gone.""" + asyncio.run(insert_event(_make_event(), db_path)) + deleted = asyncio.run(delete_event("evt-1", db_path)) + assert deleted is True + + fetched = asyncio.run(get_event("evt-1", db_path)) + assert fetched is None + + +# --------------------------------------------------------------------------- +# Event Type CRUD +# --------------------------------------------------------------------------- + + +def test_event_type_crud(db_path: Path) -> None: + """Create/list/update/delete event types.""" + et = EventType(id="type-1", user_id="user-1", name="会议", color="#FF0000") + asyncio.run(insert_event_type(et, db_path)) + + types = asyncio.run(list_event_types("user-1", db_path)) + assert len(types) == 1 + assert types[0].name == "会议" + assert types[0].color == "#FF0000" + + asyncio.run(update_event_type("type-1", {"name": "Meeting"}, db_path)) + types = asyncio.run(list_event_types("user-1", db_path)) + assert types[0].name == "Meeting" + + deleted = asyncio.run(delete_event_type("type-1", db_path)) + assert deleted is True + types = asyncio.run(list_event_types("user-1", db_path)) + assert len(types) == 0 + + +# --------------------------------------------------------------------------- +# Tag CRUD + many-to-many +# --------------------------------------------------------------------------- + + +def test_tag_many_to_many(db_path: Path) -> None: + """Event with 3 tags, query by tag returns event.""" + asyncio.run(insert_event(_make_event(), db_path)) + + for i in range(3): + tag = Tag(id=f"tag-{i}", user_id="user-1", name=f"Tag{i}") + asyncio.run(insert_tag(tag, db_path)) + asyncio.run(add_tag_to_event("evt-1", f"tag-{i}", db_path)) + + # Get tags for event + tags = asyncio.run(get_event_tags("evt-1", db_path)) + assert len(tags) == 3 + + # List events filtered by tag + events = asyncio.run(list_events("user-1", tag_id="tag-1", db_path=db_path)) + assert len(events) == 1 + assert events[0].id == "evt-1" + + # List all tags + all_tags = asyncio.run(list_tags("user-1", db_path)) + assert len(all_tags) == 3 + + +# --------------------------------------------------------------------------- +# Reminder Rule CRUD +# --------------------------------------------------------------------------- + + +def test_reminder_rule_crud(db_path: Path) -> None: + """Create rule for event, create default rule for type.""" + asyncio.run(insert_event(_make_event(), db_path)) + asyncio.run( + insert_event_type(EventType(id="type-1", user_id="user-1", name="Meeting"), db_path) + ) + + # Event-level rule + rule1 = ReminderRule( + id="rule-1", event_id="evt-1", offset_minutes=-15, channels=["client", "email"] + ) + asyncio.run(insert_reminder_rule(rule1, db_path)) + + # Type-level default rule + rule2 = ReminderRule( + id="rule-2", event_type_id="type-1", offset_minutes=-1440, channels=["email"] + ) + asyncio.run(insert_reminder_rule(rule2, db_path)) + + event_rules = asyncio.run(list_reminder_rules_for_event("evt-1", db_path)) + assert len(event_rules) == 1 + assert event_rules[0].offset_minutes == -15 + assert event_rules[0].channels == ["client", "email"] + + type_rules = asyncio.run(list_reminder_rules_for_type("type-1", db_path)) + assert len(type_rules) == 1 + assert type_rules[0].offset_minutes == -1440 + + +# --------------------------------------------------------------------------- +# External Config +# --------------------------------------------------------------------------- + + +def test_external_config_stores_encrypted_credentials(db_path: Path) -> None: + """Insert config, verify credentials field is opaque (stored as-is).""" + config = ExternalCalendarConfig( + id="cfg-1", + user_id="user-1", + provider="caldav", + credentials='{"url":"https://caldav.icloud.com","user":"alice","pass":"xxx"}', + sync_frequency=15, + sync_scope=["type-1", "type-2"], + ) + asyncio.run(insert_external_config(config, db_path)) + + configs = asyncio.run(list_external_configs("user-1", db_path)) + assert len(configs) == 1 + assert configs[0].provider == "caldav" + assert configs[0].sync_frequency == 15 + assert configs[0].sync_scope == ["type-1", "type-2"] + # Credentials stored as-is (encryption happens at service layer) + assert "caldav.icloud.com" in configs[0].credentials + + +# --------------------------------------------------------------------------- +# Invitation CRUD +# --------------------------------------------------------------------------- + + +def test_invitation_crud(db_path: Path) -> None: + """Create invitation, list by email, update status.""" + asyncio.run(insert_event(_make_event(), db_path)) + + inv = Invitation( + id="inv-1", + event_id="evt-1", + inviter_user_id="user-1", + invitee_email="alice@example.com", + ) + asyncio.run(insert_invitation(inv, db_path)) + + invs = asyncio.run(list_invitations("alice@example.com", db_path)) + assert len(invs) == 1 + assert invs[0].status == "pending" + + now = _now_iso() + asyncio.run(update_invitation_status("inv-1", "accepted", now, db_path)) + + invs = asyncio.run(list_invitations("alice@example.com", db_path)) + assert invs[0].status == "accepted" + assert invs[0].responded_at == now diff --git a/tests/unit/calendar/test_recurrence.py b/tests/unit/calendar/test_recurrence.py new file mode 100644 index 0000000..99024ab --- /dev/null +++ b/tests/unit/calendar/test_recurrence.py @@ -0,0 +1,75 @@ +"""Tests for RRULE recurrence expansion (U1).""" + +from __future__ import annotations + +from agentkit.calendar.recurrence import expand_rrule + + +def test_expand_rrule_weekly_count() -> None: + """FREQ=WEEKLY;BYDAY=MO;COUNT=4 from Monday → 4 occurrences.""" + result = expand_rrule( + "FREQ=WEEKLY;BYDAY=MO;COUNT=4", + "2026-07-06T10:00:00+00:00", # Monday + ) + assert len(result) == 4 + # All should be Mondays + for occ in result: + # 2026-07-06 is Monday, 07-13, 07-20, 07-27 + assert "T10:00:00+00:00" in occ + + +def test_expand_rrule_daily_range_filter() -> None: + """FREQ=DAILY starting Jan 1, range Jan 3–Jan 5 → 3 occurrences.""" + result = expand_rrule( + "FREQ=DAILY", + "2026-01-01T00:00:00+00:00", + range_start="2026-01-03T00:00:00+00:00", + range_end="2026-01-06T00:00:00+00:00", + ) + assert len(result) == 3 # Jan 3, 4, 5 + + +def test_expand_rrule_until_clause() -> None: + """FREQ=DAILY;UNTIL=20260131 → occurrences stop at Jan 31.""" + result = expand_rrule( + "FREQ=DAILY;UNTIL=20260131T235959Z", + "2026-01-29T00:00:00+00:00", + ) + assert len(result) == 3 # Jan 29, 30, 31 + + +def test_expand_rrule_no_rrule_returns_single() -> None: + """rrule=None → returns [start_time] only.""" + result = expand_rrule(None, "2026-07-01T10:00:00+00:00") + assert result == ["2026-07-01T10:00:00+00:00"] + + result = expand_rrule("", "2026-07-01T10:00:00+00:00") + assert result == ["2026-07-01T10:00:00+00:00"] + + +def test_expand_rrule_all_day_event() -> None: + """All-day event with RRULE, verify date expansion (no time component issues).""" + result = expand_rrule( + "FREQ=DAILY;COUNT=3", + "2026-07-01T00:00:00+00:00", + ) + assert len(result) == 3 + assert result[0].startswith("2026-07-01") + assert result[1].startswith("2026-07-02") + assert result[2].startswith("2026-07-03") + + +def test_expand_rrule_dst_boundary() -> None: + """Event crossing DST transition, verify UTC consistency. + + March 8, 2026 is US DST spring-forward. A daily event starting + March 7 should produce correct UTC occurrences across the boundary. + """ + result = expand_rrule( + "FREQ=DAILY;COUNT=3", + "2026-03-07T10:00:00+00:00", + ) + assert len(result) == 3 + # All should be at 10:00 UTC regardless of DST + for occ in result: + assert "T10:00:00+00:00" in occ From d36e45bbe73bd108258a6fc46e4c54c3153cd81d Mon Sep 17 00:00:00 2001 From: chiguyong Date: Tue, 23 Jun 2026 21:43:39 +0800 Subject: [PATCH 02/16] feat(calendar): U2 backend service & REST API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CalendarService business logic layer and 14 REST endpoints: - service.py: event CRUD with RRULE expansion, event types, tags, invitations, non-admin user search (G5/A3), type-level default reminder rule cloning - routes/calendar.py: JWT-authenticated endpoints for events, types, tags, invitations, user search — with ownership checks - 17 new tests (12 service + 5 routes), 33 total calendar tests passing --- src/agentkit/calendar/service.py | 332 +++++++++++++++++++ src/agentkit/server/routes/calendar.py | 399 ++++++++++++++++++++++ tests/unit/calendar/test_routes.py | 270 +++++++++++++++ tests/unit/calendar/test_service.py | 441 +++++++++++++++++++++++++ 4 files changed, 1442 insertions(+) create mode 100644 src/agentkit/calendar/service.py create mode 100644 src/agentkit/server/routes/calendar.py create mode 100644 tests/unit/calendar/test_routes.py create mode 100644 tests/unit/calendar/test_service.py diff --git a/src/agentkit/calendar/service.py b/src/agentkit/calendar/service.py new file mode 100644 index 0000000..b6fc6d5 --- /dev/null +++ b/src/agentkit/calendar/service.py @@ -0,0 +1,332 @@ +"""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, + 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 _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.""" + 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 + if tag_ids: + for tag_id in tag_ids: + await add_tag_to_event(event.id, tag_id, self.db_path) + + # Clone type-level default reminder rules to the event + if event_type_id: + 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 + occurrences = expand_rrule( + event.rrule, + event.start_time, + range_start=start, + range_end=end, + ) + 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 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 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). + """ + pattern = f"%{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 ? OR email LIKE ? LIMIT 10", + (pattern, pattern), + ) + rows = await cursor.fetchall() + return [{"username": row["username"], "email": row["email"]} for row in rows] + + async def get_user_email(self, user_id: str) -> str | None: + """Look up a user's email from the auth DB by user_id.""" + async with aiosqlite.connect(str(self.auth_db_path)) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT email FROM users WHERE id = ?", + (user_id,), + ) + row = await cursor.fetchone() + return row["email"] if row else None + + +def _is_in_range(dt_str: str, start: str | None, end: str | None) -> bool: + """Check if *dt_str* falls within the half-open range [start, end).""" + dt = _parse_dt(dt_str) + if start is not None: + if dt < _parse_dt(start): + return False + if end is not None: + if dt >= _parse_dt(end): + return False + return True diff --git a/src/agentkit/server/routes/calendar.py b/src/agentkit/server/routes/calendar.py new file mode 100644 index 0000000..1b0b017 --- /dev/null +++ b/src/agentkit/server/routes/calendar.py @@ -0,0 +1,399 @@ +"""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, HTTPException, Query, Request +from pydantic import BaseModel, Field + +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 + + model_config = {"extra": "allow"} + + +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 (extra fields allowed by model_config) + 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) + 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) + 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()} diff --git a/tests/unit/calendar/test_routes.py b/tests/unit/calendar/test_routes.py new file mode 100644 index 0000000..ca8ed6a --- /dev/null +++ b/tests/unit/calendar/test_routes.py @@ -0,0 +1,270 @@ +"""Tests for calendar REST API routes (U2).""" + +from __future__ import annotations + +import asyncio +import uuid +from pathlib import Path +from typing import Any + +import aiosqlite +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from agentkit.calendar.db import init_calendar_db +from agentkit.calendar.service import CalendarService +from agentkit.server.auth.dependencies import require_authenticated +from agentkit.server.auth.models import init_auth_db +from agentkit.server.routes import calendar as calendar_routes + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +TEST_USER_ID = "test-user-id" +TEST_USER_EMAIL = "testuser@example.com" + + +def _make_test_user() -> dict[str, Any]: + return { + "user_id": TEST_USER_ID, + "username": "testuser", + "role": "member", + } + + +async def _seed_auth_user(auth_db_path: Path) -> None: + """Seed the test user into the auth DB so get_user_email works.""" + async with aiosqlite.connect(str(auth_db_path)) as db: + await db.execute( + "INSERT INTO users (id, username, email, password_hash, role, is_active, " + "is_terminal_authorized, is_server_terminal_authorized, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + TEST_USER_ID, + "testuser", + TEST_USER_EMAIL, + "fake-hash", + "member", + 1, + 0, + 0, + "2026-01-01T00:00:00+00:00", + "2026-01-01T00:00:00+00:00", + ), + ) + await db.commit() + + +async def _seed_searchable_users(auth_db_path: Path) -> None: + """Seed extra users for search tests.""" + for name in ("alice", "bob", "charlie"): + async with aiosqlite.connect(str(auth_db_path)) as db: + await db.execute( + "INSERT INTO users (id, username, email, password_hash, role, is_active, " + "is_terminal_authorized, is_server_terminal_authorized, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + uuid.uuid4().hex, + name, + f"{name}@example.com", + "fake-hash", + "member", + 1, + 0, + 0, + "2026-01-01T00:00:00+00:00", + "2026-01-01T00:00:00+00:00", + ), + ) + await db.commit() + + +@pytest.fixture +def calendar_db_path(tmp_path: Path) -> Path: + path = tmp_path / "test_calendar.db" + asyncio.run(init_calendar_db(path)) + return path + + +@pytest.fixture +def auth_db_path(tmp_path: Path) -> Path: + path = tmp_path / "test_auth.db" + asyncio.run(init_auth_db(path)) + asyncio.run(_seed_auth_user(path)) + return path + + +@pytest.fixture +def app(calendar_db_path: Path, auth_db_path: Path) -> FastAPI: + """Create a test app with CalendarService and mock auth.""" + service = CalendarService(db_path=calendar_db_path, auth_db_path=auth_db_path) + + app = FastAPI() + app.state.calendar_service = service + app.state.auth_db_path = str(auth_db_path) + app.include_router(calendar_routes.router, prefix="/api/v1") + + # Override auth dependency to return a test user + app.dependency_overrides[require_authenticated] = lambda: _make_test_user() + return app + + +@pytest.fixture +def client(app: FastAPI) -> TestClient: + return TestClient(app) + + +@pytest.fixture +def unauth_app(calendar_db_path: Path, auth_db_path: Path) -> FastAPI: + """App without auth override — simulates unauthenticated requests.""" + service = CalendarService(db_path=calendar_db_path, auth_db_path=auth_db_path) + app = FastAPI() + app.state.calendar_service = service + app.state.auth_db_path = str(auth_db_path) + app.include_router(calendar_routes.router, prefix="/api/v1") + # No dependency override → require_authenticated will see no current_user + return app + + +@pytest.fixture +def unauth_client(unauth_app: FastAPI) -> TestClient: + return TestClient(unauth_app) + + +# --------------------------------------------------------------------------- +# Auth requirement +# --------------------------------------------------------------------------- + + +def test_route_create_event_requires_auth(unauth_client: TestClient) -> None: + """No auth → 401.""" + resp = unauth_client.post( + "/api/v1/calendar/events", + json={ + "title": "Test", + "start_time": "2026-07-01T10:00:00+00:00", + "end_time": "2026-07-01T11:00:00+00:00", + }, + ) + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# Create event +# --------------------------------------------------------------------------- + + +def test_route_create_event_success(client: TestClient) -> None: + """Create event via API returns 200 with event data.""" + resp = client.post( + "/api/v1/calendar/events", + json={ + "title": "Sprint Planning", + "start_time": "2026-07-01T10:00:00+00:00", + "end_time": "2026-07-01T11:00:00+00:00", + "description": "Bi-weekly sprint planning", + "location": "Room A", + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + event = data["event"] + assert event["title"] == "Sprint Planning" + assert event["start_time"] == "2026-07-01T10:00:00+00:00" + assert event["description"] == "Bi-weekly sprint planning" + assert event["location"] == "Room A" + assert event["source"] == "manual" + assert "id" in event + + +# --------------------------------------------------------------------------- +# List events +# --------------------------------------------------------------------------- + + +def test_route_list_events_returns_events(client: TestClient) -> None: + """Create events, list via API returns them.""" + for i in range(3): + client.post( + "/api/v1/calendar/events", + json={ + "title": f"Event {i}", + "start_time": f"2026-07-{i + 1:02d}T10:00:00+00:00", + "end_time": f"2026-07-{i + 1:02d}T11:00:00+00:00", + }, + ) + + resp = client.get("/api/v1/calendar/events") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["count"] == 3 + titles = {e["title"] for e in data["events"]} + assert titles == {"Event 0", "Event 1", "Event 2"} + + +# --------------------------------------------------------------------------- +# Tag filter via API (G2) +# --------------------------------------------------------------------------- + + +def test_route_list_events_filters_by_tag(client: TestClient) -> None: + """G2 tag filter via API — only tagged events returned.""" + # Create a tag first + tag_resp = client.post("/api/v1/calendar/tags", json={"name": "important"}) + assert tag_resp.status_code == 200 + tag_id = tag_resp.json()["tag"]["id"] + + # Event 1: with tag + client.post( + "/api/v1/calendar/events", + json={ + "title": "Tagged Event", + "start_time": "2026-07-01T10:00:00+00:00", + "end_time": "2026-07-01T11:00:00+00:00", + "tag_ids": [tag_id], + }, + ) + + # Event 2: without tag + client.post( + "/api/v1/calendar/events", + json={ + "title": "Untagged Event", + "start_time": "2026-07-02T10:00:00+00:00", + "end_time": "2026-07-02T11:00:00+00:00", + }, + ) + + # List with tag filter + resp = client.get("/api/v1/calendar/events", params={"tag_id": tag_id}) + assert resp.status_code == 200 + data = resp.json() + assert data["count"] == 1 + assert data["events"][0]["title"] == "Tagged Event" + + +# --------------------------------------------------------------------------- +# User search via API (G5) +# --------------------------------------------------------------------------- + + +def test_route_search_users(client: TestClient, auth_db_path: Path) -> None: + """G5 user search via API returns matching users.""" + asyncio.run(_seed_searchable_users(auth_db_path)) + + resp = client.get("/api/v1/calendar/users/search", params={"q": "ali"}) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["count"] == 1 + assert data["users"][0]["username"] == "alice" + assert data["users"][0]["email"] == "alice@example.com" + # Must not expose sensitive fields + assert "id" not in data["users"][0] + assert "password_hash" not in data["users"][0] diff --git a/tests/unit/calendar/test_service.py b/tests/unit/calendar/test_service.py new file mode 100644 index 0000000..7b7c4a3 --- /dev/null +++ b/tests/unit/calendar/test_service.py @@ -0,0 +1,441 @@ +"""Tests for CalendarService (U2).""" + +from __future__ import annotations + +import asyncio +import uuid +from pathlib import Path + +import aiosqlite +import pytest + +from agentkit.calendar.db import ( + get_event_tags, + init_calendar_db, + insert_reminder_rule, + list_reminder_rules_for_event, +) +from agentkit.calendar.models import ReminderRule +from agentkit.calendar.service import CalendarService +from agentkit.server.auth.models import init_auth_db + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def calendar_db_path(tmp_path: Path) -> Path: + path = tmp_path / "test_calendar.db" + asyncio.run(init_calendar_db(path)) + return path + + +@pytest.fixture +def auth_db_path(tmp_path: Path) -> Path: + path = tmp_path / "test_auth.db" + asyncio.run(init_auth_db(path)) + return path + + +@pytest.fixture +def service(calendar_db_path: Path, auth_db_path: Path) -> CalendarService: + return CalendarService(db_path=calendar_db_path, auth_db_path=auth_db_path) + + +async def _seed_user( + auth_db_path: Path, + user_id: str | None = None, + username: str = "testuser", + email: str = "test@example.com", +) -> str: + """Insert a user into the auth DB and return its id.""" + uid = user_id or uuid.uuid4().hex + async with aiosqlite.connect(str(auth_db_path)) as db: + await db.execute( + "INSERT INTO users (id, username, email, password_hash, role, is_active, " + "is_terminal_authorized, is_server_terminal_authorized, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + uid, + username, + email, + "fake-hash", + "member", + 1, + 0, + 0, + "2026-01-01T00:00:00+00:00", + "2026-01-01T00:00:00+00:00", + ), + ) + await db.commit() + return uid + + +# --------------------------------------------------------------------------- +# Event creation with type and tags +# --------------------------------------------------------------------------- + + +async def test_create_event_with_type_and_tags( + service: CalendarService, calendar_db_path: Path +) -> None: + """Create event with type_id and 2 tags, verify all linked.""" + user_id = "user-1" + + # Create event type + et = await service.create_event_type(user_id, "Meeting", color="#FF0000") + + # Create tags + tag1 = await service.create_tag(user_id, "urgent") + tag2 = await service.create_tag(user_id, "work") + + # Create event with type and tags + event = await service.create_event( + user_id=user_id, + title="Sprint Planning", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + event_type_id=et.id, + tag_ids=[tag1.id, tag2.id], + ) + + # Verify event was created + fetched = await service.get_event(event.id) + assert fetched is not None + assert fetched.title == "Sprint Planning" + assert fetched.event_type_id == et.id + + # Verify tags are linked + tags = await get_event_tags(event.id, calendar_db_path) + tag_names = {t.name for t in tags} + assert tag_names == {"urgent", "work"} + + +# --------------------------------------------------------------------------- +# Date range filtering +# --------------------------------------------------------------------------- + + +async def test_list_events_filters_by_date_range(service: CalendarService) -> None: + """3 events across days, filter returns correct subset.""" + user_id = "user-1" + for i, day in enumerate([1, 15, 28]): + await service.create_event( + user_id=user_id, + title=f"Event Day {day}", + start_time=f"2026-07-{day:02d}T10:00:00+00:00", + end_time=f"2026-07-{day:02d}T11:00:00+00:00", + ) + + # Range covering only day 15 + events = await service.list_events( + user_id=user_id, + start="2026-07-10T00:00:00+00:00", + end="2026-07-20T00:00:00+00:00", + ) + assert len(events) == 1 + assert events[0].title == "Event Day 15" + + +# --------------------------------------------------------------------------- +# Type and tag filter combination (G2) +# --------------------------------------------------------------------------- + + +async def test_list_events_filters_by_type_and_tag( + service: CalendarService, calendar_db_path: Path +) -> None: + """Filter by both type_id and tag_id returns only matching events.""" + user_id = "user-1" + + et_meeting = await service.create_event_type(user_id, "Meeting") + et_personal = await service.create_event_type(user_id, "Personal") + + tag_work = await service.create_tag(user_id, "work") + tag_family = await service.create_tag(user_id, "family") + + # Event 1: Meeting + work + e1 = await service.create_event( + user_id=user_id, + title="Standup", + start_time="2026-07-01T09:00:00+00:00", + end_time="2026-07-01T09:30:00+00:00", + event_type_id=et_meeting.id, + tag_ids=[tag_work.id], + ) + + # Event 2: Personal + family + await service.create_event( + user_id=user_id, + title="Birthday", + start_time="2026-07-02T18:00:00+00:00", + end_time="2026-07-02T21:00:00+00:00", + event_type_id=et_personal.id, + tag_ids=[tag_family.id], + ) + + # Event 3: Meeting + family (cross combination) + await service.create_event( + user_id=user_id, + title="Team Dinner", + start_time="2026-07-03T19:00:00+00:00", + end_time="2026-07-03T21:00:00+00:00", + event_type_id=et_meeting.id, + tag_ids=[tag_family.id], + ) + + # Filter by type=Meeting AND tag=work → only Event 1 + events = await service.list_events( + user_id=user_id, + event_type_id=et_meeting.id, + tag_id=tag_work.id, + ) + assert len(events) == 1 + assert events[0].id == e1.id + + +# --------------------------------------------------------------------------- +# Tag-only filter (G2) +# --------------------------------------------------------------------------- + + +async def test_list_events_filters_by_tag_only( + service: CalendarService, calendar_db_path: Path +) -> None: + """5 events, 2 with tag X, filter tag_id=X returns only 2.""" + user_id = "user-1" + tag_x = await service.create_tag(user_id, "X") + + tagged_ids: list[str] = [] + for i in range(5): + tag_ids = [tag_x.id] if i < 2 else None + event = await service.create_event( + user_id=user_id, + title=f"Event {i}", + start_time=f"2026-07-{i + 1:02d}T10:00:00+00:00", + end_time=f"2026-07-{i + 1:02d}T11:00:00+00:00", + tag_ids=tag_ids, + ) + if i < 2: + tagged_ids.append(event.id) + + events = await service.list_events(user_id=user_id, tag_id=tag_x.id) + assert len(events) == 2 + returned_ids = {e.id for e in events} + assert returned_ids == set(tagged_ids) + + +# --------------------------------------------------------------------------- +# Recurring event expansion +# --------------------------------------------------------------------------- + + +async def test_list_events_expands_recurring(service: CalendarService) -> None: + """Event with FREQ=DAILY;COUNT=3, list range covering 2 days → 2 occurrences.""" + user_id = "user-1" + await service.create_event( + user_id=user_id, + title="Daily Standup", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T10:15:00+00:00", + rrule="FREQ=DAILY;COUNT=3", + ) + + # Range covers Jul 1 and Jul 2 (half-open [Jul 1, Jul 3)) + events = await service.list_events( + user_id=user_id, + start="2026-07-01T00:00:00+00:00", + end="2026-07-03T00:00:00+00:00", + ) + assert len(events) == 2 + # First occurrence on Jul 1 + assert events[0].start_time.startswith("2026-07-01") + assert events[0].end_time.startswith("2026-07-01") + # Second occurrence on Jul 2 + assert events[1].start_time.startswith("2026-07-02") + assert events[1].end_time.startswith("2026-07-02") + # Duration preserved (15 minutes) + assert "T10:00:00" in events[0].start_time + assert "T10:15:00" in events[0].end_time + assert "T10:00:00" in events[1].start_time + assert "T10:15:00" in events[1].end_time + + +# --------------------------------------------------------------------------- +# Partial update +# --------------------------------------------------------------------------- + + +async def test_update_event_partial_fields(service: CalendarService) -> None: + """PATCH only title, other fields unchanged.""" + event = await service.create_event( + user_id="user-1", + title="Original Title", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + description="Original description", + location="Room A", + ) + + original = await service.get_event(event.id) + assert original is not None + + updated = await service.update_event(event.id, {"title": "New Title"}) + assert updated is True + + refreshed = await service.get_event(event.id) + assert refreshed is not None + assert refreshed.title == "New Title" + # Other fields unchanged + assert refreshed.description == "Original description" + assert refreshed.location == "Room A" + assert refreshed.start_time == original.start_time + assert refreshed.end_time == original.end_time + # last_modified should be updated + assert refreshed.last_modified != original.last_modified + + +# --------------------------------------------------------------------------- +# Delete cascade +# --------------------------------------------------------------------------- + + +async def test_delete_event_cascades_reminders_and_tags( + service: CalendarService, calendar_db_path: Path +) -> None: + """Delete event, verify reminder rules and junction rows removed.""" + user_id = "user-1" + tag = await service.create_tag(user_id, "work") + event = await service.create_event( + user_id=user_id, + title="To Delete", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + tag_ids=[tag.id], + ) + + # Add a reminder rule directly to the event + rule = ReminderRule( + id=uuid.uuid4().hex, + event_id=event.id, + offset_minutes=-30, + channels=["email"], + ) + await insert_reminder_rule(rule, calendar_db_path) + + # Verify rule and tag exist + rules_before = await list_reminder_rules_for_event(event.id, calendar_db_path) + assert len(rules_before) == 1 + tags_before = await get_event_tags(event.id, calendar_db_path) + assert len(tags_before) == 1 + + # Delete the event + deleted = await service.delete_event(event.id) + assert deleted is True + + # Verify event is gone + assert await service.get_event(event.id) is None + + # Verify reminder rules are gone (cascade) + rules_after = await list_reminder_rules_for_event(event.id, calendar_db_path) + assert len(rules_after) == 0 + + # Verify junction rows are gone + tags_after = await get_event_tags(event.id, calendar_db_path) + assert len(tags_after) == 0 + + # Tag itself should still exist (only the junction is removed) + all_tags = await service.list_tags(user_id) + assert len(all_tags) == 1 + + +# --------------------------------------------------------------------------- +# Invitation flow +# --------------------------------------------------------------------------- + + +async def test_create_invitation_and_respond(service: CalendarService) -> None: + """Invite, respond 'accepted', verify status + responded_at set.""" + event = await service.create_event( + user_id="user-1", + title="Team Meeting", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + ) + + invitation = await service.create_invitation( + event_id=event.id, + inviter_user_id="user-1", + invitee_email="alice@example.com", + ) + assert invitation.status == "pending" + assert invitation.responded_at is None + + updated = await service.respond_to_invitation(invitation.id, "accepted") + assert updated is True + + invitations = await service.list_invitations("alice@example.com") + assert len(invitations) == 1 + assert invitations[0].status == "accepted" + assert invitations[0].responded_at is not None + + +# --------------------------------------------------------------------------- +# User search (G5) +# --------------------------------------------------------------------------- + + +async def test_search_users_by_username(service: CalendarService, auth_db_path: Path) -> None: + """Seed users in auth DB, search returns matches.""" + await _seed_user(auth_db_path, username="alice", email="alice@example.com") + await _seed_user(auth_db_path, username="bob", email="bob@example.com") + await _seed_user(auth_db_path, username="charlie", email="charlie@example.com") + + results = await service.search_users("ali") + assert len(results) == 1 + assert results[0]["username"] == "alice" + assert results[0]["email"] == "alice@example.com" + # Must NOT return user_id or password fields + assert "id" not in results[0] + assert "password_hash" not in results[0] + + +async def test_search_users_no_match_returns_empty( + service: CalendarService, auth_db_path: Path +) -> None: + """Search 'zzz' returns [].""" + await _seed_user(auth_db_path, username="alice", email="alice@example.com") + results = await service.search_users("zzz") + assert results == [] + + +async def test_search_users_returns_max_10(service: CalendarService, auth_db_path: Path) -> None: + """Seed 15 matching users, verify only 10 returned.""" + for i in range(15): + await _seed_user( + auth_db_path, + username=f"user{i:02d}", + email=f"user{i:02d}@example.com", + ) + + results = await service.search_users("user") + assert len(results) == 10 + + +# --------------------------------------------------------------------------- +# Event type color persistence +# --------------------------------------------------------------------------- + + +async def test_event_type_default_color_persistence(service: CalendarService) -> None: + """Create type with color, verify roundtrip.""" + et = await service.create_event_type("user-1", "Important", color="#00FF00") + assert et.color == "#00FF00" + + types = await service.list_event_types("user-1") + assert len(types) == 1 + assert types[0].color == "#00FF00" + assert types[0].name == "Important" From 42fe7bcbc9b32850982499b5f649dde4cf429642 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Tue, 23 Jun 2026 21:56:08 +0800 Subject: [PATCH 03/16] feat(calendar): U3 agent calendar tool for ReAct integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds CalendarTool implementing the Tool ABC so the ReAct engine can create, query, update, and delete events autonomously. Resolves event_type_name and tag_names (look up or create), sets source="agent" to distinguish agent-created events from manual ones. - src/agentkit/tools/calendar_tool.py — CalendarTool(Tool) - tests/unit/tools/test_calendar_tool.py — 13 tests covering all actions --- src/agentkit/tools/calendar_tool.py | 277 +++++++++++++++++++ tests/unit/tools/test_calendar_tool.py | 352 +++++++++++++++++++++++++ 2 files changed, 629 insertions(+) create mode 100644 src/agentkit/tools/calendar_tool.py create mode 100644 tests/unit/tools/test_calendar_tool.py diff --git a/src/agentkit/tools/calendar_tool.py b/src/agentkit/tools/calendar_tool.py new file mode 100644 index 0000000..cf81a8e --- /dev/null +++ b/src/agentkit/tools/calendar_tool.py @@ -0,0 +1,277 @@ +"""CalendarTool — Agent tool for calendar event CRUD via ReAct integration. + +Wraps CalendarService so the LLM can create, query, update, and delete +calendar events via function calling. The tool delegates all business logic +to CalendarService — it only handles input validation, name→id resolution +for event types and tags, and result formatting. + +The tool trusts the caller (the agent framework) to provide the correct +user_id; it does not perform auth (same pattern as DocumentTool). +""" + +from __future__ import annotations + +from typing import Any + +from agentkit.calendar.service import CalendarService +from agentkit.tools.base import Tool + + +class CalendarTool(Tool): + """Agent tool for calendar event management. + + Actions: create_event, query_events, update_event, delete_event. + """ + + def __init__(self, calendar_service: CalendarService): + super().__init__( + name="calendar", + description=( + "Create, query, update, and delete calendar events. " + "Actions: create_event, query_events, update_event, delete_event." + ), + input_schema={ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "create_event", + "query_events", + "update_event", + "delete_event", + ], + "description": "Calendar operation to perform.", + }, + "user_id": { + "type": "string", + "description": "User ID owning the calendar events.", + }, + "event_id": { + "type": "string", + "description": "Event ID (for update_event and delete_event).", + }, + "title": { + "type": "string", + "description": "Event title (create_event, update_event).", + }, + "start_time": { + "type": "string", + "description": "Event start time, ISO 8601 UTC (create_event, update_event).", + }, + "end_time": { + "type": "string", + "description": "Event end time, ISO 8601 UTC (create_event, update_event).", + }, + "description": { + "type": "string", + "description": "Event description (create_event, update_event).", + }, + "location": { + "type": "string", + "description": "Event location (create_event, update_event).", + }, + "is_all_day": { + "type": "boolean", + "description": "Whether the event is all-day (create_event, update_event).", + }, + "event_type_name": { + "type": "string", + "description": "Event type name; looked up or created if missing (create_event).", + }, + "tag_names": { + "type": "array", + "items": {"type": "string"}, + "description": "Tag names; each looked up or created if missing (create_event).", + }, + "rrule": { + "type": "string", + "description": "RFC 5545 RRULE recurrence string, e.g. FREQ=WEEKLY;BYDAY=MO;COUNT=10 (create_event).", + }, + "conversation_id": { + "type": "string", + "description": "Conversation ID to associate with the event (create_event).", + }, + "start_date": { + "type": "string", + "description": "Range start, ISO 8601 UTC (query_events).", + }, + "end_date": { + "type": "string", + "description": "Range end, ISO 8601 UTC (query_events).", + }, + "limit": { + "type": "integer", + "description": "Maximum number of events to return (query_events).", + }, + }, + "required": ["action", "user_id"], + }, + ) + self._service = calendar_service + + async def execute(self, **kwargs) -> dict[str, Any]: + action = kwargs.get("action") + + if action == "create_event": + return await self._create_event(**kwargs) + if action == "query_events": + return await self._query_events(**kwargs) + if action == "update_event": + return await self._update_event(**kwargs) + if action == "delete_event": + return await self._delete_event(**kwargs) + return {"success": False, "error": f"Unknown action: {action!r}"} + + # ------------------------------------------------------------------ + # create_event + # ------------------------------------------------------------------ + + async def _create_event(self, **kwargs) -> dict[str, Any]: + user_id = kwargs.get("user_id") + title = kwargs.get("title") + start_time = kwargs.get("start_time") + end_time = kwargs.get("end_time") + + if not user_id: + return {"success": False, "error": "Missing required field: user_id"} + if not title: + return {"success": False, "error": "Missing required field: title"} + if not start_time: + return {"success": False, "error": "Missing required field: start_time"} + if not end_time: + return {"success": False, "error": "Missing required field: end_time"} + + description = kwargs.get("description", "") + location = kwargs.get("location", "") + is_all_day = kwargs.get("is_all_day", False) + rrule = kwargs.get("rrule") + conversation_id = kwargs.get("conversation_id") + + # Resolve event_type_name → event_type_id (look up or create) + event_type_id: str | None = None + event_type_name = kwargs.get("event_type_name") + if event_type_name: + event_type_id = await self._resolve_event_type_id(user_id, event_type_name) + + # Resolve tag_names → tag_ids (look up or create each) + tag_ids: list[str] | None = None + tag_names = kwargs.get("tag_names") + if tag_names: + tag_ids = await self._resolve_tag_ids(user_id, tag_names) + + try: + event = await self._service.create_event( + user_id=user_id, + title=title, + start_time=start_time, + end_time=end_time, + description=description, + location=location, + is_all_day=is_all_day, + event_type_id=event_type_id, + rrule=rrule, + source="agent", + conversation_id=conversation_id, + tag_ids=tag_ids, + ) + return {"success": True, "event": event.to_dict()} + except Exception as e: + return {"success": False, "error": f"create_event failed: {e}"} + + async def _resolve_event_type_id(self, user_id: str, name: str) -> str | None: + """Look up an event type by name for the user; create if not found.""" + existing = await self._service.list_event_types(user_id) + for et in existing: + if et.name == name: + return et.id + et = await self._service.create_event_type(user_id, name) + return et.id + + async def _resolve_tag_ids(self, user_id: str, names: list[str]) -> list[str]: + """Look up tags by name for the user; create each if not found.""" + existing = await self._service.list_tags(user_id) + existing_by_name = {t.name: t.id for t in existing} + tag_ids: list[str] = [] + for name in names: + if name in existing_by_name: + tag_ids.append(existing_by_name[name]) + else: + tag = await self._service.create_tag(user_id, name) + tag_ids.append(tag.id) + return tag_ids + + # ------------------------------------------------------------------ + # query_events + # ------------------------------------------------------------------ + + async def _query_events(self, **kwargs) -> dict[str, Any]: + user_id = kwargs.get("user_id") + if not user_id: + return {"success": False, "error": "Missing required field: user_id"} + + start = kwargs.get("start_date") + end = kwargs.get("end_date") + limit = kwargs.get("limit") + + try: + events = await self._service.list_events( + user_id=user_id, + start=start, + end=end, + ) + if limit is not None: + events = events[:limit] + return {"success": True, "events": [e.to_dict() for e in events]} + except Exception as e: + return {"success": False, "error": f"query_events failed: {e}"} + + # ------------------------------------------------------------------ + # update_event + # ------------------------------------------------------------------ + + async def _update_event(self, **kwargs) -> dict[str, Any]: + event_id = kwargs.get("event_id") + user_id = kwargs.get("user_id") + if not event_id: + return {"success": False, "error": "Missing required field: event_id"} + if not user_id: + return {"success": False, "error": "Missing required field: user_id"} + + # 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"} + + try: + deleted = await self._service.delete_event(event_id) + if not deleted: + return {"success": False, "error": f"Event not found: {event_id}"} + return {"success": True} + except Exception as e: + return {"success": False, "error": f"delete_event failed: {e}"} diff --git a/tests/unit/tools/test_calendar_tool.py b/tests/unit/tools/test_calendar_tool.py new file mode 100644 index 0000000..39f2468 --- /dev/null +++ b/tests/unit/tools/test_calendar_tool.py @@ -0,0 +1,352 @@ +"""Tests for CalendarTool — Agent tool wrapper for ReAct integration (U3).""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from agentkit.calendar.db import get_event_tags, init_calendar_db +from agentkit.calendar.service import CalendarService +from agentkit.tools.calendar_tool import CalendarTool + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def service(tmp_path: Path) -> CalendarService: + """Provide a CalendarService backed by a temp DB.""" + db_path = tmp_path / "test.db" + asyncio.run(init_calendar_db(db_path)) + return CalendarService(db_path=db_path) + + +@pytest.fixture +def tool(service: CalendarService) -> CalendarTool: + return CalendarTool(calendar_service=service) + + +# --------------------------------------------------------------------------- +# create_event +# --------------------------------------------------------------------------- + + +async def test_create_event_action_returns_success( + tool: CalendarTool, service: CalendarService +) -> None: + """create_event returns success and persists the event with source='agent'.""" + result = await tool.execute( + action="create_event", + user_id="user-1", + title="Sprint Planning", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + description="Bi-weekly sprint planning", + location="Room A", + ) + assert result["success"] is True + event_dict = result["event"] + assert event_dict["title"] == "Sprint Planning" + assert event_dict["source"] == "agent" + assert event_dict["user_id"] == "user-1" + + # Verify persisted in DB + fetched = await service.get_event(event_dict["id"]) + assert fetched is not None + assert fetched.title == "Sprint Planning" + assert fetched.source == "agent" + + +async def test_create_event_with_recurrence_sets_rrule( + tool: CalendarTool, service: CalendarService +) -> None: + """rrule param is stored correctly on the event.""" + rrule = "FREQ=WEEKLY;BYDAY=MO;COUNT=10" + result = await tool.execute( + action="create_event", + user_id="user-1", + title="Weekly Standup", + start_time="2026-07-06T09:00:00+00:00", + end_time="2026-07-06T09:30:00+00:00", + rrule=rrule, + ) + assert result["success"] is True + assert result["event"]["rrule"] == rrule + + fetched = await service.get_event(result["event"]["id"]) + assert fetched is not None + assert fetched.rrule == rrule + + +async def test_query_events_returns_list(tool: CalendarTool) -> None: + """create 2 events, query, verify both returned.""" + await tool.execute( + action="create_event", + user_id="user-1", + title="Event A", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + ) + await tool.execute( + action="create_event", + user_id="user-1", + title="Event B", + start_time="2026-07-02T14:00:00+00:00", + end_time="2026-07-02T15:00:00+00:00", + ) + + result = await tool.execute( + action="query_events", + user_id="user-1", + ) + assert result["success"] is True + events = result["events"] + assert len(events) == 2 + titles = {e["title"] for e in events} + assert titles == {"Event A", "Event B"} + + +async def test_update_event_action_modifies_fields( + tool: CalendarTool, service: CalendarService +) -> None: + """create then update title; verify the field is modified.""" + create_result = await tool.execute( + action="create_event", + user_id="user-1", + title="Original Title", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + ) + assert create_result["success"] is True + event_id = create_result["event"]["id"] + + update_result = await tool.execute( + action="update_event", + user_id="user-1", + event_id=event_id, + title="Updated Title", + ) + assert update_result["success"] is True + + fetched = await service.get_event(event_id) + assert fetched is not None + assert fetched.title == "Updated Title" + + +async def test_delete_event_action_removes_record( + tool: CalendarTool, service: CalendarService +) -> None: + """create then delete; verify the record is gone.""" + create_result = await tool.execute( + action="create_event", + user_id="user-1", + title="To Be Deleted", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + ) + assert create_result["success"] is True + event_id = create_result["event"]["id"] + + delete_result = await tool.execute( + action="delete_event", + user_id="user-1", + event_id=event_id, + ) + assert delete_result["success"] is True + + fetched = await service.get_event(event_id) + assert fetched is None + + +# --------------------------------------------------------------------------- +# error paths +# --------------------------------------------------------------------------- + + +async def test_invalid_action_returns_error(tool: CalendarTool) -> None: + """Unknown action returns success=False with error message.""" + result = await tool.execute( + action="frobnicate", + user_id="user-1", + ) + assert result["success"] is False + assert "Unknown action" in result["error"] + + +async def test_missing_required_field_returns_error(tool: CalendarTool) -> None: + """create_event without title returns success=False.""" + result = await tool.execute( + action="create_event", + user_id="user-1", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + ) + assert result["success"] is False + assert "Missing required field" in result["error"] + assert "title" in result["error"] + + +# --------------------------------------------------------------------------- +# conversation_id +# --------------------------------------------------------------------------- + + +async def test_created_event_has_conversation_id( + tool: CalendarTool, service: CalendarService +) -> None: + """conversation_id is set from context when provided.""" + result = await tool.execute( + action="create_event", + user_id="user-1", + title="Chat-Initiated Event", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + conversation_id="conv-abc-123", + ) + assert result["success"] is True + assert result["event"]["conversation_id"] == "conv-abc-123" + + fetched = await service.get_event(result["event"]["id"]) + assert fetched is not None + assert fetched.conversation_id == "conv-abc-123" + + +# --------------------------------------------------------------------------- +# event_type_name resolution +# --------------------------------------------------------------------------- + + +async def test_create_event_with_event_type_name( + tool: CalendarTool, service: CalendarService +) -> None: + """create event with event_type_name='Meeting' creates the type and links it.""" + result = await tool.execute( + action="create_event", + user_id="user-1", + title="Strategy Sync", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + event_type_name="Meeting", + ) + assert result["success"] is True + event_type_id = result["event"]["event_type_id"] + assert event_type_id is not None + + # Verify the event type was created + types = await service.list_event_types("user-1") + meeting_types = [t for t in types if t.name == "Meeting"] + assert len(meeting_types) == 1 + assert meeting_types[0].id == event_type_id + + # Verify the event references the type + fetched = await service.get_event(result["event"]["id"]) + assert fetched is not None + assert fetched.event_type_id == event_type_id + + +async def test_create_event_reuses_existing_event_type( + tool: CalendarTool, service: CalendarService +) -> None: + """If event_type_name matches an existing type, it is reused (not duplicated).""" + # Pre-create the type + existing = await service.create_event_type("user-1", "Meeting") + + result = await tool.execute( + action="create_event", + user_id="user-1", + title="Second Meeting", + start_time="2026-07-02T10:00:00+00:00", + end_time="2026-07-02T11:00:00+00:00", + event_type_name="Meeting", + ) + assert result["success"] is True + assert result["event"]["event_type_id"] == existing.id + + # No duplicate type created + types = await service.list_event_types("user-1") + meeting_types = [t for t in types if t.name == "Meeting"] + assert len(meeting_types) == 1 + + +# --------------------------------------------------------------------------- +# tag_names resolution +# --------------------------------------------------------------------------- + + +async def test_create_event_with_tag_names( + tool: CalendarTool, service: CalendarService, tmp_path: Path +) -> None: + """create event with tag_names=['urgent', 'work'] creates tags and links them.""" + result = await tool.execute( + action="create_event", + user_id="user-1", + title="Urgent Work Task", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + tag_names=["urgent", "work"], + ) + assert result["success"] is True + event_id = result["event"]["id"] + + # Verify tags were created + tags = await service.list_tags("user-1") + tag_names = {t.name for t in tags} + assert "urgent" in tag_names + assert "work" in tag_names + + # Verify tags are linked to the event + linked_tags = await get_event_tags(event_id, service.db_path) + linked_names = {t.name for t in linked_tags} + assert linked_names == {"urgent", "work"} + + +async def test_create_event_reuses_existing_tags( + tool: CalendarTool, service: CalendarService +) -> None: + """If a tag name matches an existing tag, it is reused (not duplicated).""" + # Pre-create a tag + existing = await service.create_tag("user-1", "urgent") + + result = await tool.execute( + action="create_event", + user_id="user-1", + title="Tagged Event", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + tag_names=["urgent", "new-tag"], + ) + assert result["success"] is True + + # 'urgent' should not be duplicated, 'new-tag' should be created + tags = await service.list_tags("user-1") + urgent_tags = [t for t in tags if t.name == "urgent"] + assert len(urgent_tags) == 1 + assert urgent_tags[0].id == existing.id + new_tags = [t for t in tags if t.name == "new-tag"] + assert len(new_tags) == 1 + + +# --------------------------------------------------------------------------- +# tool registration / schema +# --------------------------------------------------------------------------- + + +def test_tool_name_and_schema(tool: CalendarTool) -> None: + """Tool has correct name and input_schema.""" + assert tool.name == "calendar" + schema = tool.input_schema + assert schema["type"] == "object" + assert "action" in schema["properties"] + assert "user_id" in schema["properties"] + assert schema["properties"]["action"]["enum"] == [ + "create_event", + "query_events", + "update_event", + "delete_event", + ] + assert "action" in schema["required"] + assert "user_id" in schema["required"] From ddcedb57b29d228cfd4e2f57f3f361af38c84f14 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Tue, 23 Jun 2026 21:56:20 +0800 Subject: [PATCH 04/16] feat(calendar): U4 post-processing extractor with keyword gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds PostProcessingExtractor — a zero-LLM keyword gate (Chinese + English time words) followed by LLM extraction for ambiguous cases. Events created from extraction carry source="post_extract" so the UI can style them distinctly (R33). LLM gateway is optional to keep the constructor testable without a live provider. - src/agentkit/calendar/extraction.py — PostProcessingExtractor - tests/unit/calendar/test_extraction.py — 13 tests with MockLLMGateway --- src/agentkit/calendar/extraction.py | 129 +++++++++ tests/unit/calendar/test_extraction.py | 385 +++++++++++++++++++++++++ 2 files changed, 514 insertions(+) create mode 100644 src/agentkit/calendar/extraction.py create mode 100644 tests/unit/calendar/test_extraction.py diff --git a/src/agentkit/calendar/extraction.py b/src/agentkit/calendar/extraction.py new file mode 100644 index 0000000..41ac0d5 --- /dev/null +++ b/src/agentkit/calendar/extraction.py @@ -0,0 +1,129 @@ +"""Post-processing extraction of schedule info from conversation text. + +Two-stage approach (U4): +1. Zero-LLM regex keyword gate — skip LLM entirely if no time-related keywords. +2. LLM extraction — call the LLM gateway to pull structured event data. + +Extracted events are persisted via ``CalendarService.create_event`` with +``source="post_extract"`` and the originating ``conversation_id`` for +traceability (R15). +""" + +from __future__ import annotations + +import json +import logging +import re + +from agentkit.calendar.service import CalendarService + +logger = logging.getLogger(__name__) + + +class PostProcessingExtractor: + """Extract schedule info from conversation text after a chat turn. + + Two-stage: regex keyword gate (zero LLM) → LLM extraction. + """ + + # Time-related keywords that trigger LLM extraction + _KEYWORD_RE = re.compile( + r"明天|后天|下周|本周|今天下午|今天上午|上午|下午|晚上|" + r"\d+点|\d+月\d+日|\d+号|开会|截止|deadline|schedule|" + r"reminder|提醒|预约|约定|安排", + re.IGNORECASE, + ) + + def __init__(self, calendar_service: CalendarService, llm_gateway=None): + self.service = calendar_service + self.llm_gateway = llm_gateway # Optional, may be set later + + async def extract( + self, + conversation_text: str, + conversation_id: str, + user_id: str, + ) -> list[dict]: + """Extract events from conversation text. + + Returns list of created event dicts. Empty if no keywords or no events extracted. + Never raises — all failures are logged and swallowed. + """ + # 1. Keyword gate — zero LLM cost if no match + if not self._KEYWORD_RE.search(conversation_text): + return [] + + # 2. LLM extraction + events_data = await self._llm_extract(conversation_text) + if not events_data: + return [] + + # 3. Create events with source="post_extract" + created = [] + for event_data in events_data: + try: + event = await self.service.create_event( + user_id=user_id, + title=event_data.get("title", ""), + start_time=event_data.get("start_time", ""), + end_time=event_data.get("end_time", ""), + description=event_data.get("description", ""), + source="post_extract", + conversation_id=conversation_id, + ) + created.append(event.to_dict()) + except Exception as e: + logger.warning(f"Failed to create extracted event: {e}") + continue + + return created + + async def _llm_extract(self, text: str) -> list[dict]: + """Call LLM gateway to extract events from text. + + Returns list of event dicts: [{title, start_time, end_time, description}]. + Returns [] on any error or empty result. + """ + if self.llm_gateway is None: + return [] + + prompt = self._build_extraction_prompt(text) + try: + response = await self.llm_gateway.acomplete( + messages=[{"role": "user", "content": prompt}], + temperature=0.1, + ) + return self._parse_llm_response(response) + except Exception as e: + logger.warning(f"LLM extraction failed: {e}") + return [] + + def _build_extraction_prompt(self, text: str) -> str: + """Build the LLM extraction prompt.""" + return f"""Extract schedule/event information from the following conversation text. +Return a JSON array of events. Each event should have: title, start_time (ISO 8601), end_time (ISO 8601), description. +If no events are found, return an empty array []. + +Conversation text: +{text} + +Respond with ONLY the JSON array, no other text.""" + + def _parse_llm_response(self, response: str) -> list[dict]: + """Parse LLM response as JSON array. Returns [] on any error.""" + try: + # Strip markdown code fences if present + cleaned = response.strip() + if cleaned.startswith("```"): + cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned[3:] + if cleaned.endswith("```"): + cleaned = cleaned[:-3] + cleaned = cleaned.strip() + + data = json.loads(cleaned) + if not isinstance(data, list): + return [] + return [item for item in data if isinstance(item, dict) and "title" in item] + except (json.JSONDecodeError, TypeError) as e: + logger.warning(f"Failed to parse LLM response as JSON: {e}") + return [] diff --git a/tests/unit/calendar/test_extraction.py b/tests/unit/calendar/test_extraction.py new file mode 100644 index 0000000..6f0d17f --- /dev/null +++ b/tests/unit/calendar/test_extraction.py @@ -0,0 +1,385 @@ +"""Tests for PostProcessingExtractor (U4).""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +import pytest + +from agentkit.calendar.db import init_calendar_db +from agentkit.calendar.extraction import PostProcessingExtractor +from agentkit.calendar.service import CalendarService + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def calendar_db_path(tmp_path: Path) -> Path: + path = tmp_path / "test_calendar.db" + asyncio.run(init_calendar_db(path)) + return path + + +@pytest.fixture +def service(calendar_db_path: Path) -> CalendarService: + return CalendarService(db_path=calendar_db_path) + + +@pytest.fixture +def extractor(service: CalendarService) -> PostProcessingExtractor: + return PostProcessingExtractor(calendar_service=service) + + +class MockLLMGateway: + """Minimal async mock for the LLM gateway.""" + + def __init__(self, response: str) -> None: + self.response = response + self.called = False + self.call_count = 0 + + async def acomplete(self, messages, temperature: float = 0.1) -> str: + self.called = True + self.call_count += 1 + return self.response + + +# --------------------------------------------------------------------------- +# Keyword regex gate +# --------------------------------------------------------------------------- + + +def test_keyword_regex_matches_chinese_time_words(extractor: PostProcessingExtractor) -> None: + """Chinese time words trigger the keyword gate.""" + assert extractor._KEYWORD_RE.search("明天下午3点开会") is not None + assert extractor._KEYWORD_RE.search("后天截止") is not None + assert extractor._KEYWORD_RE.search("下周安排一下") is not None + # No time words — should not match + assert extractor._KEYWORD_RE.search("继续优化吧") is None + assert extractor._KEYWORD_RE.search("好的,没问题") is None + + +def test_keyword_regex_matches_english_time_words(extractor: PostProcessingExtractor) -> None: + """English time words trigger the keyword gate (case-insensitive).""" + assert extractor._KEYWORD_RE.search("deadline tomorrow") is not None + assert extractor._KEYWORD_RE.search("Schedule a meeting") is not None + assert extractor._KEYWORD_RE.search("set a reminder") is not None + # No time words — should not match + assert extractor._KEYWORD_RE.search("hello world") is None + assert extractor._KEYWORD_RE.search("how are you") is None + + +# --------------------------------------------------------------------------- +# Keyword gate skips LLM +# --------------------------------------------------------------------------- + + +async def test_no_keyword_skips_llm_call( + extractor: PostProcessingExtractor, service: CalendarService +) -> None: + """No keyword in text → LLM gateway never called, returns [].""" + gateway = MockLLMGateway(response="[]") + extractor.llm_gateway = gateway + + result = await extractor.extract( + conversation_text="好的,我们继续优化代码吧", + conversation_id="conv-1", + user_id="user-1", + ) + + assert result == [] + assert gateway.called is False + assert gateway.call_count == 0 + + +# --------------------------------------------------------------------------- +# Keyword hit triggers LLM extraction +# --------------------------------------------------------------------------- + + +async def test_keyword_hit_triggers_llm_extraction( + extractor: PostProcessingExtractor, +) -> None: + """Keyword present → LLM called → event created with source='post_extract'.""" + llm_response = json.dumps( + [ + { + "title": "团队会议", + "start_time": "2026-07-01T10:00:00+00:00", + "end_time": "2026-07-01T11:00:00+00:00", + "description": "周会", + } + ] + ) + gateway = MockLLMGateway(response=llm_response) + extractor.llm_gateway = gateway + + result = await extractor.extract( + conversation_text="明天下午3点开个会", + conversation_id="conv-42", + user_id="user-1", + ) + + assert gateway.called is True + assert gateway.call_count == 1 + assert len(result) == 1 + event = result[0] + assert event["title"] == "团队会议" + assert event["source"] == "post_extract" + assert event["start_time"] == "2026-07-01T10:00:00+00:00" + assert event["end_time"] == "2026-07-01T11:00:00+00:00" + assert event["description"] == "周会" + + +# --------------------------------------------------------------------------- +# LLM returns empty array +# --------------------------------------------------------------------------- + + +async def test_llm_returns_empty_array_creates_nothing( + extractor: PostProcessingExtractor, +) -> None: + """LLM returns [] → no events created.""" + gateway = MockLLMGateway(response="[]") + extractor.llm_gateway = gateway + + result = await extractor.extract( + conversation_text="明天有个安排", + conversation_id="conv-1", + user_id="user-1", + ) + + assert result == [] + assert gateway.called is True + + +# --------------------------------------------------------------------------- +# Malformed LLM response +# --------------------------------------------------------------------------- + + +async def test_malformed_llm_response_handled_gracefully( + extractor: PostProcessingExtractor, +) -> None: + """Invalid JSON response → no crash, returns [].""" + gateway = MockLLMGateway(response="this is not json at all") + extractor.llm_gateway = gateway + + result = await extractor.extract( + conversation_text="明天开会", + conversation_id="conv-1", + user_id="user-1", + ) + + assert result == [] + + +async def test_malformed_llm_response_json_object_not_array( + extractor: PostProcessingExtractor, +) -> None: + """JSON object (not array) → treated as no events.""" + gateway = MockLLMGateway(response='{"title": "会议"}') + extractor.llm_gateway = gateway + + result = await extractor.extract( + conversation_text="明天开会", + conversation_id="conv-1", + user_id="user-1", + ) + + assert result == [] + + +# --------------------------------------------------------------------------- +# conversation_id traceability +# --------------------------------------------------------------------------- + + +async def test_extracted_events_have_conversation_id( + extractor: PostProcessingExtractor, +) -> None: + """Extracted events carry the conversation_id for traceability.""" + llm_response = json.dumps( + [ + { + "title": "评审会", + "start_time": "2026-07-01T14:00:00+00:00", + "end_time": "2026-07-01T15:00:00+00:00", + "description": "", + } + ] + ) + gateway = MockLLMGateway(response=llm_response) + extractor.llm_gateway = gateway + + result = await extractor.extract( + conversation_text="后天下午2点评审会", + conversation_id="conv-trace-99", + user_id="user-7", + ) + + assert len(result) == 1 + assert result[0]["conversation_id"] == "conv-trace-99" + assert result[0]["user_id"] == "user-7" + + +# --------------------------------------------------------------------------- +# Async / non-blocking +# --------------------------------------------------------------------------- + + +async def test_extraction_does_not_block_chat_response( + extractor: PostProcessingExtractor, +) -> None: + """extract() is awaitable and returns a list (inherent async guarantee).""" + gateway = MockLLMGateway(response="[]") + extractor.llm_gateway = gateway + + # Awaiting must yield a list, not a coroutine or other object. + result = await extractor.extract( + conversation_text="明天deadline", + conversation_id="conv-1", + user_id="user-1", + ) + assert isinstance(result, list) + + +# --------------------------------------------------------------------------- +# No LLM gateway configured +# --------------------------------------------------------------------------- + + +async def test_no_llm_gateway_returns_empty( + extractor: PostProcessingExtractor, +) -> None: + """llm_gateway=None + keyword hit → returns [] without error.""" + assert extractor.llm_gateway is None + + result = await extractor.extract( + conversation_text="明天开会", + conversation_id="conv-1", + user_id="user-1", + ) + + assert result == [] + + +# --------------------------------------------------------------------------- +# Code-fenced LLM response +# --------------------------------------------------------------------------- + + +async def test_llm_response_with_code_fences_parsed( + extractor: PostProcessingExtractor, +) -> None: + """LLM wraps JSON in ```json ... ``` fences → parsed correctly.""" + payload = json.dumps( + [ + { + "title": "站会", + "start_time": "2026-07-01T09:00:00+00:00", + "end_time": "2026-07-01T09:15:00+00:00", + "description": "每日站会", + } + ] + ) + fenced = f"```json\n{payload}\n```" + gateway = MockLLMGateway(response=fenced) + extractor.llm_gateway = gateway + + result = await extractor.extract( + conversation_text="明天上午开站会", + conversation_id="conv-1", + user_id="user-1", + ) + + assert len(result) == 1 + assert result[0]["title"] == "站会" + assert result[0]["description"] == "每日站会" + + +# --------------------------------------------------------------------------- +# Multiple events +# --------------------------------------------------------------------------- + + +async def test_multiple_events_extracted( + extractor: PostProcessingExtractor, +) -> None: + """LLM returns 3 events → 3 events created.""" + llm_response = json.dumps( + [ + { + "title": "会议A", + "start_time": "2026-07-01T09:00:00+00:00", + "end_time": "2026-07-01T10:00:00+00:00", + "description": "", + }, + { + "title": "会议B", + "start_time": "2026-07-02T14:00:00+00:00", + "end_time": "2026-07-02T15:00:00+00:00", + "description": "", + }, + { + "title": "截止日期", + "start_time": "2026-07-05T23:59:00+00:00", + "end_time": "2026-07-05T23:59:00+00:00", + "description": "提交报告", + }, + ] + ) + gateway = MockLLMGateway(response=llm_response) + extractor.llm_gateway = gateway + + result = await extractor.extract( + conversation_text="本周有几个安排和截止", + conversation_id="conv-multi", + user_id="user-1", + ) + + assert len(result) == 3 + titles = {e["title"] for e in result} + assert titles == {"会议A", "会议B", "截止日期"} + for event in result: + assert event["source"] == "post_extract" + assert event["conversation_id"] == "conv-multi" + + +# --------------------------------------------------------------------------- +# Items without 'title' key are filtered out +# --------------------------------------------------------------------------- + + +async def test_items_without_title_filtered( + extractor: PostProcessingExtractor, +) -> None: + """Dict items missing 'title' are dropped by the parser.""" + llm_response = json.dumps( + [ + { + "title": "有效会议", + "start_time": "2026-07-01T09:00:00+00:00", + "end_time": "2026-07-01T10:00:00+00:00", + "description": "", + }, + {"start_time": "2026-07-02T09:00:00+00:00", "end_time": "2026-07-02T10:00:00+00:00"}, + "not-a-dict", + ] + ) + gateway = MockLLMGateway(response=llm_response) + extractor.llm_gateway = gateway + + result = await extractor.extract( + conversation_text="明天开会", + conversation_id="conv-1", + user_id="user-1", + ) + + assert len(result) == 1 + assert result[0]["title"] == "有效会议" From 26efbb51dbbde80678eb884206d86834b1d24c89 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Tue, 23 Jun 2026 22:19:57 +0800 Subject: [PATCH 05/16] feat(calendar): U5 reminder subsystem with scheduler and multi-channel dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReminderScheduler scans upcoming events every 60s, matches reminder rules, and dispatches via client (WS), email (SMTP), or webhook channels. Idempotent delivery (no duplicates on re-scan), retry with exponential backoff (up to 3 attempts). Follows task_store.py start/stop asyncio loop pattern (KTD-2 — conscious deviation from APScheduler). - src/agentkit/calendar/scheduler.py — ReminderScheduler (start/stop/scan_once) - src/agentkit/calendar/reminders.py — ReminderDispatcher (strategy per channel) - src/agentkit/calendar/db.py — added list_all_events_in_time_range() for scheduler - tests/unit/calendar/test_scheduler.py — 8 tests - tests/unit/calendar/test_reminders.py — 9 tests --- src/agentkit/calendar/db.py | 41 +++++ src/agentkit/calendar/reminders.py | 115 ++++++++++++ src/agentkit/calendar/scheduler.py | 174 +++++++++++++++++ tests/unit/calendar/test_reminders.py | 160 ++++++++++++++++ tests/unit/calendar/test_scheduler.py | 256 ++++++++++++++++++++++++++ 5 files changed, 746 insertions(+) create mode 100644 src/agentkit/calendar/reminders.py create mode 100644 src/agentkit/calendar/scheduler.py create mode 100644 tests/unit/calendar/test_reminders.py create mode 100644 tests/unit/calendar/test_scheduler.py diff --git a/src/agentkit/calendar/db.py b/src/agentkit/calendar/db.py index 0d66420..0064e3d 100644 --- a/src/agentkit/calendar/db.py +++ b/src/agentkit/calendar/db.py @@ -292,6 +292,28 @@ async def get_event(event_id: str, db_path: str | Path | None = None) -> Calenda 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: + 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, @@ -330,6 +352,25 @@ async def list_events( 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: + 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: diff --git a/src/agentkit/calendar/reminders.py b/src/agentkit/calendar/reminders.py new file mode 100644 index 0000000..672aeec --- /dev/null +++ b/src/agentkit/calendar/reminders.py @@ -0,0 +1,115 @@ +"""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 logging +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from agentkit.calendar.models import CalendarEvent + +logger = logging.getLogger(__name__) + + +@dataclass +class SmtpConfig: + """SMTP server configuration for the email reminder channel.""" + + host: str = "localhost" + port: int = 25 + username: str | None = None + password: str | None = None + use_tls: bool = False + from_email: str = "noreply@agentkit.local" + + +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 + import httpx + + async with httpx.AsyncClient() as client: + resp = await client.post( + self._webhook_url, + json={"event": event.to_dict(), "user_id": user_id}, + ) + return resp.status_code < 400 diff --git a/src/agentkit/calendar/scheduler.py b/src/agentkit/calendar/scheduler.py new file mode 100644 index 0000000..beded6b --- /dev/null +++ b/src/agentkit/calendar/scheduler.py @@ -0,0 +1,174 @@ +"""Reminder scheduler — background loop that scans upcoming events and +dispatches reminders via :class:`ReminderDispatcher`. + +Follows the ``start()``/``stop()`` + ``asyncio.create_task`` loop pattern from +``server/task_store.py`` (KTD-2 — conscious deviation from APScheduler). + +ponytail: app.py lifespan wiring is deferred — the orchestrator will call +``start()``/``stop()`` when integrating into the application lifecycle. + +ponytail: ``asyncio.sleep`` polling has second-level precision. If sub-second +scheduling is needed, upgrade to APScheduler. +""" + +from __future__ import annotations + +import asyncio +import logging +import uuid +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from agentkit.calendar.db import ( + DEFAULT_CALENDAR_DB_PATH, + get_pending_deliveries, + insert_reminder_delivery, + list_all_events_in_time_range, + list_reminder_rules_for_event, + update_delivery_status, +) +from agentkit.calendar.models import CalendarEvent, ReminderDelivery, ReminderRule +from agentkit.calendar.reminders import ReminderDispatcher + +logger = logging.getLogger(__name__) + + +def _parse_dt(dt_str: str) -> datetime: + """Parse ISO 8601 string to timezone-aware datetime (UTC).""" + dt = datetime.fromisoformat(dt_str) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +class ReminderScheduler: + """Background scheduler that scans for events entering the reminder window + and dispatches via the configured channels. + """ + + def __init__( + self, + db_path: str | Path | None = None, + dispatcher: ReminderDispatcher | None = None, + interval_seconds: int = 60, + lookback_seconds: int = 3600, + max_retries: int = 3, + retry_base_delay: float = 1.0, + ) -> None: + self._db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + self._dispatcher = dispatcher or ReminderDispatcher() + self._interval = interval_seconds + self._lookback = lookback_seconds + self._max_retries = max_retries + self._retry_base_delay = retry_base_delay + self._task: asyncio.Task[None] | None = None + + async def start(self) -> None: + """Start the background scan loop.""" + if self._task is None: + self._task = asyncio.create_task(self._loop()) + + async def stop(self) -> None: + """Cancel the background scan loop.""" + if self._task is not None: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def _loop(self) -> None: + """Main scan loop — runs until cancelled.""" + while True: + try: + await self.scan_once() + except asyncio.CancelledError: + break + except Exception: + logger.exception("Reminder scheduler scan error") + await asyncio.sleep(self._interval) + + async def scan_once(self) -> int: + """Run a single scan cycle. Returns the number of deliveries created. + + Public method so tests can invoke a single scan without waiting for the + loop interval. + """ + now = datetime.now(timezone.utc) + window_start = now - timedelta(seconds=self._lookback) + window_end = now + timedelta(seconds=self._interval) + + # Query a broad range of events — the reminder_time filter happens below. + # ponytail: recurring event reminder expansion is not handled here; only + # the event's stored start_time is used. Upgrade: expand RRULE occurrences + # and check each occurrence's reminder time. + query_start = (now - timedelta(hours=2)).isoformat() + query_end = (now + timedelta(hours=48)).isoformat() + events = await list_all_events_in_time_range(query_start, query_end, self._db_path) + + dispatched = 0 + for event in events: + rules = await list_reminder_rules_for_event(event.id, self._db_path) + for rule in rules: + reminder_time = _parse_dt(event.start_time) + timedelta(minutes=rule.offset_minutes) + if window_start <= reminder_time <= window_end: + dispatched += await self._process_reminder(event, rule, reminder_time) + return dispatched + + async def _process_reminder( + self, + event: CalendarEvent, + rule: ReminderRule, + reminder_time: datetime, + ) -> int: + """Create delivery records and dispatch. Returns count of deliveries created. + + Idempotent: if any delivery already exists for this event+rule, skip. + """ + existing = await get_pending_deliveries(event.id, rule.id, self._db_path) + if existing: + return 0 + + created = 0 + for channel in rule.channels: + delivery = ReminderDelivery( + id=uuid.uuid4().hex, + reminder_rule_id=rule.id, + event_id=event.id, + scheduled_time=reminder_time.isoformat(), + status="pending", + channel=channel, + attempts=0, + last_error=None, + ) + await insert_reminder_delivery(delivery, self._db_path) + created += 1 + await self._dispatch_with_retry(event, delivery) + return created + + async def _dispatch_with_retry( + self, + event: CalendarEvent, + delivery: ReminderDelivery, + ) -> bool: + """Attempt dispatch up to ``max_retries`` times with exponential backoff. + + Updates the delivery record's ``attempts`` counter after each try. + Returns ``True`` on success, ``False`` if all retries exhausted. + """ + for attempt in range(self._max_retries): + error: str | None = None + try: + success = await self._dispatcher.dispatch(delivery.channel, event, event.user_id) + if success: + await update_delivery_status(delivery.id, "sent", None, self._db_path) + return True + except Exception as exc: + error = str(exc) + + await update_delivery_status(delivery.id, "failed", error, self._db_path) + + if attempt < self._max_retries - 1: + await asyncio.sleep(self._retry_base_delay * (2**attempt)) + return False diff --git a/tests/unit/calendar/test_reminders.py b/tests/unit/calendar/test_reminders.py new file mode 100644 index 0000000..dfe53f9 --- /dev/null +++ b/tests/unit/calendar/test_reminders.py @@ -0,0 +1,160 @@ +"""Tests for ReminderDispatcher (U5).""" + +from __future__ import annotations + +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +from agentkit.calendar.models import CalendarEvent, _now_iso +from agentkit.calendar.reminders import ReminderDispatcher, SmtpConfig + + +def _make_event() -> CalendarEvent: + now = _now_iso() + return CalendarEvent( + id="evt-1", + user_id="user-1", + title="Test Meeting", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + last_modified=now, + created_at=now, + ) + + +# --------------------------------------------------------------------------- +# Client channel +# --------------------------------------------------------------------------- + + +async def test_client_channel_sends_ws_message() -> None: + """Mock WS sender callback, verify called with correct payload.""" + ws_sender = AsyncMock() + dispatcher = ReminderDispatcher(ws_sender=ws_sender) + + event = _make_event() + result = await dispatcher.dispatch("client", event, "user-1") + + assert result is True + ws_sender.assert_called_once() + call_args = ws_sender.call_args + assert call_args.args[0] == "user-1" + assert call_args.args[1]["type"] == "calendar_reminder" + assert call_args.args[1]["data"]["title"] == "Test Meeting" + + +async def test_client_channel_returns_false_without_sender() -> None: + """No ws_sender configured → returns False.""" + dispatcher = ReminderDispatcher() + result = await dispatcher.dispatch("client", _make_event(), "user-1") + assert result is False + + +# --------------------------------------------------------------------------- +# Email channel +# --------------------------------------------------------------------------- + + +async def test_email_channel_sends_smtp() -> None: + """Mock aiosmtplib via sys.modules injection, verify send called.""" + mock_aiosmtplib = MagicMock() + mock_aiosmtplib.send = AsyncMock() + + with patch.dict(sys.modules, {"aiosmtplib": mock_aiosmtplib}): + dispatcher = ReminderDispatcher( + smtp_config=SmtpConfig(host="smtp.example.com", port=587), + get_user_email=AsyncMock(return_value="user@example.com"), + ) + event = _make_event() + result = await dispatcher.dispatch("email", event, "user-1") + + assert result is True + mock_aiosmtplib.send.assert_called_once() + call_kwargs = mock_aiosmtplib.send.call_args.kwargs + assert call_kwargs["hostname"] == "smtp.example.com" + assert call_kwargs["port"] == 587 + # Message body contains event title and recipient + message_body = mock_aiosmtplib.send.call_args.args[0] + assert "user@example.com" in message_body + assert "Test Meeting" in message_body + + +async def test_email_channel_returns_false_without_config() -> None: + """No smtp_config → returns False.""" + dispatcher = ReminderDispatcher() + result = await dispatcher.dispatch("email", _make_event(), "user-1") + assert result is False + + +async def test_email_channel_returns_false_when_user_has_no_email() -> None: + """get_user_email returns None → returns False.""" + dispatcher = ReminderDispatcher( + smtp_config=SmtpConfig(), + get_user_email=AsyncMock(return_value=None), + ) + result = await dispatcher.dispatch("email", _make_event(), "user-1") + assert result is False + + +# --------------------------------------------------------------------------- +# Webhook channel +# --------------------------------------------------------------------------- + + +async def test_webhook_channel_posts_to_url() -> None: + """Mock httpx.AsyncClient, verify POST called with event payload.""" + dispatcher = ReminderDispatcher(webhook_url="https://example.com/hook") + + mock_response = MagicMock() + mock_response.status_code = 200 + + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + + with patch("httpx.AsyncClient", return_value=mock_client): + event = _make_event() + result = await dispatcher.dispatch("webhook", event, "user-1") + + assert result is True + mock_client.post.assert_called_once() + call_kwargs = mock_client.post.call_args.kwargs + assert call_kwargs["json"]["event"]["title"] == "Test Meeting" + assert call_kwargs["json"]["user_id"] == "user-1" + + +async def test_webhook_channel_returns_false_on_4xx() -> None: + """Webhook returns 500 → returns False.""" + dispatcher = ReminderDispatcher(webhook_url="https://example.com/hook") + + mock_response = MagicMock() + mock_response.status_code = 500 + + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + + with patch("httpx.AsyncClient", return_value=mock_client): + result = await dispatcher.dispatch("webhook", _make_event(), "user-1") + assert result is False + + +async def test_webhook_channel_returns_false_without_url() -> None: + """No webhook_url configured → returns False.""" + dispatcher = ReminderDispatcher() + result = await dispatcher.dispatch("webhook", _make_event(), "user-1") + assert result is False + + +# --------------------------------------------------------------------------- +# Unknown channel +# --------------------------------------------------------------------------- + + +async def test_unknown_channel_returns_false() -> None: + """Unknown channel name → returns False, no crash.""" + dispatcher = ReminderDispatcher() + result = await dispatcher.dispatch("sms", _make_event(), "user-1") + assert result is False diff --git a/tests/unit/calendar/test_scheduler.py b/tests/unit/calendar/test_scheduler.py new file mode 100644 index 0000000..27cdb41 --- /dev/null +++ b/tests/unit/calendar/test_scheduler.py @@ -0,0 +1,256 @@ +"""Tests for ReminderScheduler (U5).""" + +from __future__ import annotations + +import asyncio +import uuid +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest + +from agentkit.calendar.db import ( + get_pending_deliveries, + init_calendar_db, + insert_event, + insert_reminder_rule, + list_reminder_rules_for_event, +) +from agentkit.calendar.models import CalendarEvent, ReminderRule, _now_iso +from agentkit.calendar.reminders import ReminderDispatcher +from agentkit.calendar.scheduler import ReminderScheduler + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def db_path(tmp_path: Path) -> Path: + path = tmp_path / "test_calendar.db" + asyncio.run(init_calendar_db(path)) + return path + + +@pytest.fixture +def auth_db_path(tmp_path: Path) -> Path: + from agentkit.server.auth.models import init_auth_db + + path = tmp_path / "test_auth.db" + asyncio.run(init_auth_db(path)) + return path + + +def _make_event( + event_id: str, + user_id: str, + start_time: str, + title: str = "Test Event", +) -> CalendarEvent: + now = _now_iso() + return CalendarEvent( + id=event_id, + user_id=user_id, + title=title, + start_time=start_time, + end_time=start_time, + last_modified=now, + created_at=now, + ) + + +def _mock_dispatcher(return_value: bool = True) -> ReminderDispatcher: + """Create a dispatcher with a mocked dispatch method.""" + dispatcher = ReminderDispatcher() + dispatcher.dispatch = AsyncMock(return_value=return_value) # type: ignore + return dispatcher + + +# --------------------------------------------------------------------------- +# Scheduler scan logic +# --------------------------------------------------------------------------- + + +async def test_scheduler_finds_event_within_reminder_window(db_path: Path) -> None: + """Event 10 min away, rule offset -15min → reminder_time 5 min ago → found.""" + now = datetime.now(timezone.utc) + event_start = (now + timedelta(minutes=10)).isoformat() + event = _make_event("evt-1", "user-1", event_start, "Meeting") + await insert_event(event, db_path) + + rule = ReminderRule(id="rule-1", event_id="evt-1", offset_minutes=-15, channels=["client"]) + await insert_reminder_rule(rule, db_path) + + dispatcher = _mock_dispatcher(return_value=True) + scheduler = ReminderScheduler(db_path=db_path, dispatcher=dispatcher) + + count = await scheduler.scan_once() + + assert count == 1 + assert dispatcher.dispatch.call_count == 1 # type: ignore + + +async def test_scheduler_skips_event_outside_window(db_path: Path) -> None: + """Event 2 hours away, rule offset -15min → reminder_time 1hr45min away → not found.""" + now = datetime.now(timezone.utc) + event_start = (now + timedelta(hours=2)).isoformat() + event = _make_event("evt-1", "user-1", event_start, "Meeting") + await insert_event(event, db_path) + + rule = ReminderRule(id="rule-1", event_id="evt-1", offset_minutes=-15, channels=["client"]) + await insert_reminder_rule(rule, db_path) + + dispatcher = _mock_dispatcher(return_value=True) + scheduler = ReminderScheduler(db_path=db_path, dispatcher=dispatcher) + + count = await scheduler.scan_once() + + assert count == 0 + assert dispatcher.dispatch.call_count == 0 # type: ignore + + +async def test_idempotent_delivery_no_duplicate(db_path: Path) -> None: + """Scheduler runs twice, only one delivery record created.""" + now = datetime.now(timezone.utc) + event_start = (now + timedelta(minutes=10)).isoformat() + event = _make_event("evt-1", "user-1", event_start, "Meeting") + await insert_event(event, db_path) + + rule = ReminderRule(id="rule-1", event_id="evt-1", offset_minutes=-15, channels=["client"]) + await insert_reminder_rule(rule, db_path) + + dispatcher = _mock_dispatcher(return_value=True) + scheduler = ReminderScheduler(db_path=db_path, dispatcher=dispatcher) + + count1 = await scheduler.scan_once() + assert count1 == 1 + + count2 = await scheduler.scan_once() + assert count2 == 0 + + deliveries = await get_pending_deliveries("evt-1", "rule-1", db_path) + assert len(deliveries) == 1 + + +async def test_failed_delivery_retries_up_to_3_times(db_path: Path) -> None: + """Mock channel to fail, verify 3 attempts and delivery status=failed.""" + now = datetime.now(timezone.utc) + event_start = (now + timedelta(minutes=10)).isoformat() + event = _make_event("evt-1", "user-1", event_start, "Meeting") + await insert_event(event, db_path) + + rule = ReminderRule(id="rule-1", event_id="evt-1", offset_minutes=-15, channels=["client"]) + await insert_reminder_rule(rule, db_path) + + dispatcher = _mock_dispatcher(return_value=False) + scheduler = ReminderScheduler(db_path=db_path, dispatcher=dispatcher, retry_base_delay=0) + + await scheduler.scan_once() + + assert dispatcher.dispatch.call_count == 3 # type: ignore + + deliveries = await get_pending_deliveries("evt-1", "rule-1", db_path) + 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 From ffb184acc7a1c924d237ba76654b24717d53a96b Mon Sep 17 00:00:00 2001 From: chiguyong Date: Tue, 23 Jun 2026 22:20:07 +0800 Subject: [PATCH 06/16] feat(calendar): U8 iCal/ICS import and export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ICSProvider parses .ics files (icalendar library) and creates local CalendarEvents, skipping duplicate UIDs. Export builds an iCalendar from events in a date range, deduplicating recurring event occurrences back to a single VEVENT with RRULE. REST endpoints: POST /import-ics (multipart upload), GET /export-ics (download). - src/agentkit/calendar/sync/__init__.py — sync subpackage init - src/agentkit/calendar/sync/ics_provider.py — ICSProvider (import/export) - src/agentkit/calendar/db.py — added get_event_by_external_id() for dedup - src/agentkit/server/routes/calendar.py — import-ics and export-ics endpoints - pyproject.toml — added icalendar>=5.0 dependency - tests/unit/calendar/test_ics_provider.py — 8 tests --- pyproject.toml | 2 + src/agentkit/calendar/sync/__init__.py | 0 src/agentkit/calendar/sync/ics_provider.py | 175 +++++++++++++ src/agentkit/server/routes/calendar.py | 45 +++- tests/unit/calendar/test_ics_provider.py | 272 +++++++++++++++++++++ 5 files changed, 493 insertions(+), 1 deletion(-) create mode 100644 src/agentkit/calendar/sync/__init__.py create mode 100644 src/agentkit/calendar/sync/ics_provider.py create mode 100644 tests/unit/calendar/test_ics_provider.py diff --git a/pyproject.toml b/pyproject.toml index 56f2e95..b734b63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,8 @@ dependencies = [ "aiosqlite>=0.20", # Calendar & schedule (RRULE expansion) "python-dateutil>=2.9", + # Calendar ICS import/export (U8) + "icalendar>=5.0", # Document processing (U1-U9) "python-docx>=1.1", "openpyxl>=3.1", diff --git a/src/agentkit/calendar/sync/__init__.py b/src/agentkit/calendar/sync/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agentkit/calendar/sync/ics_provider.py b/src/agentkit/calendar/sync/ics_provider.py new file mode 100644 index 0000000..17ed738 --- /dev/null +++ b/src/agentkit/calendar/sync/ics_provider.py @@ -0,0 +1,175 @@ +"""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. + """ + 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 + + for component in cal.walk("VEVENT"): + 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") + 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.id) + vevent.add("summary", event.title) + + start_dt = _parse_iso(event.start_time) + end_dt = _parse_iso(event.end_time) + + if event.is_all_day: + vevent.add("dtstart", start_dt.date()) + vevent.add("dtend", end_dt.date()) + else: + vevent.add("dtstart", start_dt) + vevent.add("dtend", end_dt) + + if event.description: + vevent.add("description", event.description) + if event.location: + vevent.add("location", event.location) + if event.rrule: + # ponytail: vRecur.from_ical reorders keys (e.g. COUNT before BYDAY) + # but the result is semantically equivalent RFC 5545. + vevent.add("rrule", vRecur.from_ical(event.rrule)) + + return vevent diff --git a/src/agentkit/server/routes/calendar.py b/src/agentkit/server/routes/calendar.py index 1b0b017..932f668 100644 --- a/src/agentkit/server/routes/calendar.py +++ b/src/agentkit/server/routes/calendar.py @@ -25,9 +25,11 @@ from __future__ import annotations import logging from typing import Any -from fastapi import APIRouter, Depends, HTTPException, Query, Request +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__) @@ -397,3 +399,44 @@ async def create_tag( service = _get_calendar_service(request) tag = await service.create_tag(user_id=user["user_id"], name=body.name) return {"success": True, "tag": tag.to_dict()} + + +# --------------------------------------------------------------------------- +# ICS import/export (U8) +# --------------------------------------------------------------------------- + + +@router.post("/import-ics") +async def import_ics( + request: Request, + file: UploadFile = File(...), + user: dict = Depends(require_authenticated), +) -> dict[str, Any]: + """Import events from an uploaded .ics file.""" + service = _get_calendar_service(request) + content = await file.read() + provider = ICSProvider(service) + try: + result = await provider.import_ics(content, user["user_id"]) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + return {"success": True, **result} + + +@router.get("/export-ics") +async def export_ics( + request: Request, + start: str | None = Query(None), + end: str | None = Query(None), + user: dict = Depends(require_authenticated), +) -> Response: + """Export the current user's events to a downloadable .ics file.""" + service = _get_calendar_service(request) + events = await service.list_events(user_id=user["user_id"], start=start, end=end) + provider = ICSProvider(service) + ics_bytes = provider.export_ics(events) + return Response( + content=ics_bytes, + media_type="text/calendar", + headers={"Content-Disposition": 'attachment; filename="calendar.ics"'}, + ) diff --git a/tests/unit/calendar/test_ics_provider.py b/tests/unit/calendar/test_ics_provider.py new file mode 100644 index 0000000..f69e365 --- /dev/null +++ b/tests/unit/calendar/test_ics_provider.py @@ -0,0 +1,272 @@ +"""Tests for ICSProvider — iCalendar import/export (U8).""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest +from icalendar import Calendar + +from agentkit.calendar.db import init_calendar_db, list_events as db_list_events +from agentkit.calendar.service import CalendarService +from agentkit.calendar.sync.ics_provider import ICSProvider +from agentkit.server.auth.models import init_auth_db + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def calendar_db_path(tmp_path: Path) -> Path: + path = tmp_path / "test_calendar.db" + asyncio.run(init_calendar_db(path)) + return path + + +@pytest.fixture +def auth_db_path(tmp_path: Path) -> Path: + path = tmp_path / "test_auth.db" + asyncio.run(init_auth_db(path)) + return path + + +@pytest.fixture +def service(calendar_db_path: Path, auth_db_path: Path) -> CalendarService: + return CalendarService(db_path=calendar_db_path, auth_db_path=auth_db_path) + + +@pytest.fixture +def provider(service: CalendarService) -> ICSProvider: + return ICSProvider(service) + + +USER_ID = "user-1" + + +# --------------------------------------------------------------------------- +# ICS sample strings +# --------------------------------------------------------------------------- + +SIMPLE_ICS = ( + b"BEGIN:VCALENDAR\n" + b"VERSION:2.0\n" + b"PRODID:-//Test//Test//EN\n" + b"BEGIN:VEVENT\n" + b"UID:simple-uid@test\n" + b"SUMMARY:Test Event\n" + b"DTSTART:20260701T100000Z\n" + b"DTEND:20260701T110000Z\n" + b"DESCRIPTION:Test description\n" + b"LOCATION:Room A\n" + b"END:VEVENT\n" + b"END:VCALENDAR\n" +) + +RECURRING_ICS = ( + b"BEGIN:VCALENDAR\n" + b"VERSION:2.0\n" + b"PRODID:-//Test//Test//EN\n" + b"BEGIN:VEVENT\n" + b"UID:recur-uid@test\n" + b"SUMMARY:Weekly Meeting\n" + b"DTSTART:20260706T100000Z\n" + b"DTEND:20260706T110000Z\n" + b"RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=4\n" + b"END:VEVENT\n" + b"END:VCALENDAR\n" +) + +ALL_DAY_ICS = ( + b"BEGIN:VCALENDAR\n" + b"VERSION:2.0\n" + b"PRODID:-//Test//Test//EN\n" + b"BEGIN:VEVENT\n" + b"UID:allday-uid@test\n" + b"SUMMARY:All Day Event\n" + b"DTSTART;VALUE=DATE:20260701\n" + b"DTEND;VALUE=DATE:20260702\n" + b"END:VEVENT\n" + b"END:VCALENDAR\n" +) + + +# --------------------------------------------------------------------------- +# Import tests +# --------------------------------------------------------------------------- + + +async def test_import_simple_ics_creates_event( + provider: ICSProvider, calendar_db_path: Path +) -> None: + """Single VEVENT ICS → event created with correct fields.""" + result = await provider.import_ics(SIMPLE_ICS, USER_ID) + + assert result["imported"] == 1 + assert result["skipped"] == 0 + assert result["errors"] == [] + + events = await db_list_events(USER_ID, db_path=calendar_db_path) + assert len(events) == 1 + event = events[0] + assert event.title == "Test Event" + assert event.description == "Test description" + assert event.location == "Room A" + assert event.start_time == "2026-07-01T10:00:00+00:00" + assert event.end_time == "2026-07-01T11:00:00+00:00" + assert event.is_all_day is False + assert event.external_id == "simple-uid@test" + assert event.external_provider == "ics" + + +async def test_import_recurring_ics_preserves_rrule( + provider: ICSProvider, calendar_db_path: Path +) -> None: + """VEVENT with RRULE → rrule field set on the created event.""" + result = await provider.import_ics(RECURRING_ICS, USER_ID) + + assert result["imported"] == 1 + assert result["errors"] == [] + + events = await db_list_events(USER_ID, db_path=calendar_db_path) + assert len(events) == 1 + event = events[0] + assert event.rrule is not None + assert "FREQ=WEEKLY" in event.rrule + assert "BYDAY=MO" in event.rrule + assert "COUNT=4" in event.rrule + + +async def test_import_all_day_event(provider: ICSProvider, calendar_db_path: Path) -> None: + """DTSTART is date (not datetime) → is_all_day=True, ISO date stored.""" + result = await provider.import_ics(ALL_DAY_ICS, USER_ID) + + assert result["imported"] == 1 + assert result["errors"] == [] + + events = await db_list_events(USER_ID, db_path=calendar_db_path) + assert len(events) == 1 + event = events[0] + assert event.is_all_day is True + assert event.start_time == "2026-07-01" + assert event.end_time == "2026-07-02" + + +async def test_import_skips_duplicate_uid(provider: ICSProvider, calendar_db_path: Path) -> None: + """Importing the same ICS twice → second import skips the duplicate UID.""" + first = await provider.import_ics(SIMPLE_ICS, USER_ID) + assert first["imported"] == 1 + assert first["skipped"] == 0 + + second = await provider.import_ics(SIMPLE_ICS, USER_ID) + assert second["imported"] == 0 + assert second["skipped"] == 1 + + events = await db_list_events(USER_ID, db_path=calendar_db_path) + assert len(events) == 1 # Still only one event + + +async def test_import_malformed_ics_raises_error(provider: ICSProvider) -> None: + """Invalid ICS bytes → ValueError raised (graceful, not a crash).""" + with pytest.raises(ValueError, match="Failed to parse ICS"): + await provider.import_ics(b"this is definitely not valid ics at all", USER_ID) + + +# --------------------------------------------------------------------------- +# Export tests +# --------------------------------------------------------------------------- + + +async def test_export_produces_valid_ics(provider: ICSProvider, service: CalendarService) -> None: + """Create event, export, parse result with icalendar → roundtrip.""" + await service.create_event( + user_id=USER_ID, + title="Export Test", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + description="Desc", + location="Room B", + ) + + events = await service.list_events(USER_ID) + ics_bytes = provider.export_ics(events) + + # Parse the exported ICS back + cal = Calendar.from_ical(ics_bytes) + vevents = list(cal.walk("VEVENT")) + assert len(vevents) == 1 + vevent = vevents[0] + assert str(vevent.get("SUMMARY")) == "Export Test" + assert str(vevent.get("DESCRIPTION")) == "Desc" + assert str(vevent.get("LOCATION")) == "Room B" + # DTSTART should be a datetime (not all-day) + dtstart = vevent.get("DTSTART").dt + assert dtstart.year == 2026 + assert dtstart.month == 7 + assert dtstart.day == 1 + + +async def test_export_includes_recurrence(provider: ICSProvider, service: CalendarService) -> None: + """Event with rrule → exported ICS contains RRULE line.""" + await service.create_event( + user_id=USER_ID, + title="Recurring Export", + start_time="2026-07-06T10:00:00+00:00", + end_time="2026-07-06T11:00:00+00:00", + rrule="FREQ=WEEKLY;BYDAY=MO;COUNT=4", + ) + + events = await service.list_events(USER_ID) + ics_bytes = provider.export_ics(events) + + cal = Calendar.from_ical(ics_bytes) + vevents = list(cal.walk("VEVENT")) + assert len(vevents) == 1 + vevent = vevents[0] + rrule = vevent.get("RRULE") + assert rrule is not None + rrule_str = rrule.to_ical().decode("utf-8") + assert "FREQ=WEEKLY" in rrule_str + assert "BYDAY=MO" in rrule_str + assert "COUNT=4" in rrule_str + + +async def test_export_date_range_filter(provider: ICSProvider, service: CalendarService) -> None: + """3 events, export range covering 2 → only 2 in ICS output.""" + # Event 1: July 1 + await service.create_event( + user_id=USER_ID, + title="Event 1", + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + ) + # Event 2: July 5 + await service.create_event( + user_id=USER_ID, + title="Event 2", + start_time="2026-07-05T10:00:00+00:00", + end_time="2026-07-05T11:00:00+00:00", + ) + # Event 3: July 20 + await service.create_event( + user_id=USER_ID, + title="Event 3", + start_time="2026-07-20T10:00:00+00:00", + end_time="2026-07-20T11:00:00+00:00", + ) + + # Export range: July 1 – July 10 (covers Event 1 and Event 2) + events = await service.list_events( + USER_ID, + start="2026-07-01T00:00:00+00:00", + end="2026-07-10T00:00:00+00:00", + ) + ics_bytes = provider.export_ics(events) + + cal = Calendar.from_ical(ics_bytes) + vevents = list(cal.walk("VEVENT")) + assert len(vevents) == 2 + summaries = {str(v.get("SUMMARY")) for v in vevents} + assert summaries == {"Event 1", "Event 2"} From 40d326cd3f73e928771ffa2fa00fa39bf346b359 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Tue, 23 Jun 2026 22:52:29 +0800 Subject: [PATCH 07/16] feat(calendar): U6 CalDAV sync provider and SyncManager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AbstractSyncProvider interface with CalDAVSyncProvider implementation for bidirectional Apple Calendar sync. SyncManager orchestrates all providers (G8) — sync_all/sync_provider/resolve_conflict with last-write-wins + WS notification on conflicts (G4). caldav library calls wrapped in asyncio.to_thread for non-blocking operation. - src/agentkit/calendar/sync/base.py — AbstractSyncProvider ABC - src/agentkit/calendar/sync/caldav_provider.py — CalDAVSyncProvider - src/agentkit/calendar/sync/manager.py — SyncManager (G8) - pyproject.toml — added caldav>=1.3 dependency - tests — 12 tests (9 CalDAV + 3 SyncManager) --- pyproject.toml | 2 + src/agentkit/calendar/sync/base.py | 34 ++ src/agentkit/calendar/sync/caldav_provider.py | 394 +++++++++++++++++ src/agentkit/calendar/sync/manager.py | 223 ++++++++++ tests/unit/calendar/test_sync_caldav.py | 398 ++++++++++++++++++ tests/unit/calendar/test_sync_manager.py | 197 +++++++++ 6 files changed, 1248 insertions(+) create mode 100644 src/agentkit/calendar/sync/base.py create mode 100644 src/agentkit/calendar/sync/caldav_provider.py create mode 100644 src/agentkit/calendar/sync/manager.py create mode 100644 tests/unit/calendar/test_sync_caldav.py create mode 100644 tests/unit/calendar/test_sync_manager.py diff --git a/pyproject.toml b/pyproject.toml index b734b63..9fa80f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,8 @@ dependencies = [ "python-dateutil>=2.9", # Calendar ICS import/export (U8) "icalendar>=5.0", + # Calendar CalDAV sync — Apple Calendar (U6) + "caldav>=1.3", # Document processing (U1-U9) "python-docx>=1.1", "openpyxl>=3.1", diff --git a/src/agentkit/calendar/sync/base.py b/src/agentkit/calendar/sync/base.py new file mode 100644 index 0000000..26bf412 --- /dev/null +++ b/src/agentkit/calendar/sync/base.py @@ -0,0 +1,34 @@ +"""Abstract sync provider interface (U6). + +All external calendar sync providers (CalDAV, Outlook, Google) implement +this interface so :class:`SyncManager` can orchestrate them uniformly. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig + + +class AbstractSyncProvider(ABC): + """Interface for bidirectional external calendar sync.""" + + @abstractmethod + async def pull_changes( + self, config: ExternalCalendarConfig, since: str | None = None + ) -> list[CalendarEvent]: + """Pull remote changes into local DB. Returns pulled/updated events.""" + ... + + @abstractmethod + async def push_changes( + self, config: ExternalCalendarConfig, events: list[CalendarEvent] + ) -> list[CalendarEvent]: + """Push local events to remote. Returns updated events (with external_id set).""" + ... + + @abstractmethod + async def test_connection(self, config: ExternalCalendarConfig) -> tuple[bool, str]: + """Test connectivity. Returns (ok, error_msg). error_msg is "" on success.""" + ... diff --git a/src/agentkit/calendar/sync/caldav_provider.py b/src/agentkit/calendar/sync/caldav_provider.py new file mode 100644 index 0000000..34b4c38 --- /dev/null +++ b/src/agentkit/calendar/sync/caldav_provider.py @@ -0,0 +1,394 @@ +"""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", ""), + ) + + 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 + fields = { + "title": remote.title, + "description": remote.description, + "start_time": remote.start_time, + "end_time": remote.end_time, + "is_all_day": remote.is_all_day, + "location": remote.location, + "rrule": remote.rrule, + "last_modified": remote.last_modified, + } + await update_event(local.id, fields, self.db_path) + return remote + + if local_lm > remote_lm: + # Local wins → conflict, keep local, notify + await self._notify_conflict(local, remote, winner="local") + return None + + # Equal timestamps → no change needed + return None + + # ------------------------------------------------------------------ + # Push + # ------------------------------------------------------------------ + + async def push_changes( + self, config: ExternalCalendarConfig, events: list[CalendarEvent] + ) -> list[CalendarEvent]: + """Push local events to CalDAV. Returns events with external_id set.""" + result: list[CalendarEvent] = [] + for event in events: + updated = await self._push_single(config, event) + result.append(updated) + return result + + async def _push_single( + self, config: ExternalCalendarConfig, event: CalendarEvent + ) -> CalendarEvent: + """Push a single event to CalDAV, return event with external_id set.""" + ical_bytes = self._event_to_ics(event) + # caldav is synchronous — run in thread + saved_uid = await asyncio.to_thread(self._save_remote_event, config, ical_bytes, event) + + # If event had no external_id, set it from the saved UID + if not event.external_id and saved_uid: + fields = { + "external_id": saved_uid, + "external_provider": "caldav", + "last_modified": _now_iso(), + } + await update_event(event.id, fields, self.db_path) + event.external_id = saved_uid + event.external_provider = "caldav" + + return event + + def _save_remote_event( + self, config: ExternalCalendarConfig, ical_bytes: bytes, event: CalendarEvent + ) -> str | None: + """Synchronous CalDAV save — runs in thread. Returns remote UID.""" + calendar = self._get_calendar(config) + saved = calendar.save_event(ical_bytes) + # Extract UID from saved event + try: + cal = Calendar.from_ical(saved.data) + for comp in cal.walk("VEVENT"): + uid = str(comp.get("UID", "") or "") or None + if uid: + return uid + except Exception as e: + logger.warning("Failed to extract UID from saved event: %s", e) + return event.external_id + + def _event_to_ics(self, event: CalendarEvent) -> bytes: + """Convert CalendarEvent to ICS bytes using icalendar library.""" + cal = Calendar() + cal.add("prodid", "-//Fischer AgentKit//Calendar//EN") + cal.add("version", "2.0") + + vevent = Event() + # Use external_id if available, else local id as UID + vevent.add("uid", event.external_id or event.id) + vevent.add("summary", event.title) + + if event.start_time: + start_dt = _parse_iso(event.start_time) + if event.is_all_day: + vevent.add("dtstart", start_dt.date()) + else: + vevent.add("dtstart", start_dt) + + if event.end_time: + end_dt = _parse_iso(event.end_time) + if event.is_all_day: + vevent.add("dtend", end_dt.date()) + else: + vevent.add("dtend", end_dt) + + if event.description: + vevent.add("description", event.description) + if event.location: + vevent.add("location", event.location) + if event.rrule: + vevent.add("rrule", vRecur.from_ical(event.rrule)) + + # LAST-MODIFIED for conflict resolution + if event.last_modified: + vevent.add("last-modified", _parse_iso(event.last_modified)) + + cal.add_component(vevent) + return cal.to_ical() + + # ------------------------------------------------------------------ + # Test connection + # ------------------------------------------------------------------ + + async def test_connection(self, config: ExternalCalendarConfig) -> tuple[bool, str]: + """Test CalDAV connectivity. Returns (ok, error_msg).""" + try: + await asyncio.to_thread(self._get_calendar, config) + return True, "" + except Exception as e: + return False, str(e) + + # ------------------------------------------------------------------ + # Conflict notification + # ------------------------------------------------------------------ + + async def _notify_conflict( + self, local: CalendarEvent, remote: CalendarEvent, winner: str + ) -> None: + """Log conflict and send WS notification via callback (G4).""" + logger.info( + "Sync conflict for event %s (external_id=%s): local_lm=%s remote_lm=%s winner=%s", + local.id, + local.external_id, + local.last_modified, + remote.last_modified, + winner, + ) + if self._notify is not None: + payload = { + "event_id": local.id, + "title": local.title, + "external_id": local.external_id, + "local_last_modified": local.last_modified, + "remote_last_modified": remote.last_modified, + "winner": winner, + } + await self._notify("calendar_sync_conflict", payload) diff --git a/src/agentkit/calendar/sync/manager.py b/src/agentkit/calendar/sync/manager.py new file mode 100644 index 0000000..29a5ea1 --- /dev/null +++ b/src/agentkit/calendar/sync/manager.py @@ -0,0 +1,223 @@ +"""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) + local_events = await self._get_modified_events(config, since) + pushed = await provider.push_changes(config, local_events) + + # 3. Update last_sync + now = _now_iso() + await update_external_config(config.id, {"last_sync": now}, self.db_path) + config.last_sync = now + + return {"pulled": len(pulled), "pushed": len(pushed)} + + async def _get_config(self, config_id: str) -> ExternalCalendarConfig | None: + """Fetch a single ExternalCalendarConfig by ID.""" + # ponytail: no db.get_external_config(id) exists; we list all for the + # user. This is O(N) over the user's configs (typically <5). Upgrade: + # add ``get_external_config(config_id)`` to db.py if this becomes hot. + # We don't know the user_id here, so scan all configs in the DB. + import aiosqlite + + from agentkit.calendar.db import _row_to_external_config + + async with aiosqlite.connect(str(self.db_path)) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT * FROM calendar_external_configs WHERE id = ?", (config_id,) + ) + row = await cursor.fetchone() + return _row_to_external_config(row) if row else None + + async def _get_modified_events( + self, config: ExternalCalendarConfig, since: str | None + ) -> list[CalendarEvent]: + """Return local events for the user modified since *since*. + + When *since* is None (first sync), returns all events for the user + that do not yet have an ``external_id`` (i.e. new local events to push). + When *since* is set, returns events whose ``last_modified`` >= *since*. + """ + events = await list_events(config.user_id, db_path=self.db_path) + if since is None: + # First sync: push only events without external_id + return [e for e in events if not e.external_id] + + since_dt = datetime.fromisoformat(since) + if since_dt.tzinfo is None: + since_dt = since_dt.replace(tzinfo=timezone.utc) + + result: list[CalendarEvent] = [] + for event in events: + if not event.last_modified: + continue + event_lm = datetime.fromisoformat(event.last_modified) + if event_lm.tzinfo is None: + event_lm = event_lm.replace(tzinfo=timezone.utc) + if event_lm >= since_dt: + result.append(event) + return result + + # ------------------------------------------------------------------ + # Conflict resolution + # ------------------------------------------------------------------ + + async def resolve_conflict( + self, local_event: CalendarEvent, remote_event: CalendarEvent + ) -> CalendarEvent: + """Resolve a sync conflict using last-write-wins (G8/G4). + + Compares ``last_modified`` timestamps. The newer event wins. Sends a + ``calendar_sync_conflict`` WS notification via the notify callback. + Returns the winning event. + """ + local_lm = ( + datetime.fromisoformat(local_event.last_modified) + if local_event.last_modified + else datetime.min.replace(tzinfo=timezone.utc) + ) + if local_lm.tzinfo is None: + local_lm = local_lm.replace(tzinfo=timezone.utc) + + remote_lm = ( + datetime.fromisoformat(remote_event.last_modified) + if remote_event.last_modified + else datetime.min.replace(tzinfo=timezone.utc) + ) + if remote_lm.tzinfo is None: + remote_lm = remote_lm.replace(tzinfo=timezone.utc) + + if remote_lm > local_lm: + winner = remote_event + winner_label = "remote" + elif local_lm > remote_lm: + winner = local_event + winner_label = "local" + else: + # Equal timestamps → local wins by default + winner = local_event + winner_label = "local" + + logger.info( + "Conflict resolved for event %s: winner=%s (local_lm=%s remote_lm=%s)", + local_event.id, + winner_label, + local_event.last_modified, + remote_event.last_modified, + ) + + # G4: WS notification + if self._notify is not None: + payload = { + "event_id": local_event.id, + "title": local_event.title, + "external_id": local_event.external_id, + "local_last_modified": local_event.last_modified, + "remote_last_modified": remote_event.last_modified, + "winner": winner_label, + } + await self._notify("calendar_sync_conflict", payload) + + return winner diff --git a/tests/unit/calendar/test_sync_caldav.py b/tests/unit/calendar/test_sync_caldav.py new file mode 100644 index 0000000..551e79c --- /dev/null +++ b/tests/unit/calendar/test_sync_caldav.py @@ -0,0 +1,398 @@ +"""Tests for CalDAVSyncProvider — bidirectional Apple Calendar sync (U6). + +The ``caldav`` library is not installed in the test environment, so we inject +a mock module into ``sys.modules`` before importing the provider. All CalDAV +interactions are mocked via the ``client_factory`` injection point. +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest + +# caldav is not installed in the test env — inject a mock module so that +# `import caldav` in caldav_provider.py succeeds at import time. +if "caldav" not in sys.modules: # pragma: no cover + sys.modules["caldav"] = MagicMock() + +from agentkit.calendar.db import ( + get_event_by_external_id, + init_calendar_db, + insert_event, + list_events, +) +from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig +from agentkit.calendar.sync.caldav_provider import CalDAVSyncProvider + +USER_ID = "user-1" + + +# --------------------------------------------------------------------------- +# ICS + Mock helpers +# --------------------------------------------------------------------------- + + +def make_ics( + uid: str, + summary: str, + start: str = "20260701T100000Z", + end: str = "20260701T110000Z", + description: str = "", + location: str = "", + rrule: str | None = None, + last_modified: str = "20260601T000000Z", +) -> bytes: + """Build a minimal valid ICS bytes payload for a single VEVENT.""" + lines = [ + b"BEGIN:VCALENDAR", + b"VERSION:2.0", + b"PRODID:-//Test//Test//EN", + b"BEGIN:VEVENT", + f"UID:{uid}".encode(), + f"SUMMARY:{summary}".encode(), + f"DTSTART:{start}".encode(), + f"DTEND:{end}".encode(), + ] + if description: + lines.append(f"DESCRIPTION:{description}".encode()) + if location: + lines.append(f"LOCATION:{location}".encode()) + if rrule: + lines.append(f"RRULE:{rrule}".encode()) + if last_modified: + lines.append(f"LAST-MODIFIED:{last_modified}".encode()) + lines.extend([b"END:VEVENT", b"END:VCALENDAR"]) + return b"\r\n".join(lines) + + +class MockCaldavEvent: + """Mock caldav.Event — exposes .data returning ICS bytes.""" + + def __init__(self, ics_data: bytes) -> None: + self.data = ics_data + + +def make_mock_client( + remote_events: list[bytes], + saved_uid: str | None = None, + raise_on_connect: Exception | None = None, +) -> tuple[Any, Any]: + """Create a mock CalDAV client and its mock calendar. + + Returns ``(client, mock_calendar)``. ``mock_calendar`` is None when + ``raise_on_connect`` is set (connection fails before calendar is reached). + """ + client = MagicMock() + if raise_on_connect is not None: + client.principal.side_effect = raise_on_connect + return client, None + + principal = MagicMock() + calendar = MagicMock() + calendar.date_search.return_value = [MockCaldavEvent(d) for d in remote_events] + saved_ics = make_ics(saved_uid or "saved-uid", "Saved Event") + calendar.save_event.return_value = MockCaldavEvent(saved_ics) + principal.calendars.return_value = [calendar] + client.principal.return_value = principal + return client, calendar + + +def client_factory_from(client: Any) -> Any: + """Wrap a mock client in a client_factory callable.""" + + def factory(config: ExternalCalendarConfig) -> Any: + return client + + return factory + + +def make_config( + config_id: str = "config-1", + user_id: str = USER_ID, + provider: str = "caldav", + last_sync: str | None = None, +) -> ExternalCalendarConfig: + return ExternalCalendarConfig( + id=config_id, + user_id=user_id, + provider=provider, + credentials=json.dumps( + {"url": "https://caldav.example.com", "username": "user", "password": "pass"} + ), + last_sync=last_sync, + ) + + +def make_local_event( + event_id: str = "evt-1", + title: str = "Local Event", + external_id: str | None = None, + last_modified: str = "2026-01-01T00:00:00+00:00", + rrule: str | None = None, +) -> CalendarEvent: + return CalendarEvent( + id=event_id, + user_id=USER_ID, + title=title, + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + external_id=external_id, + external_provider="caldav" if external_id else None, + last_modified=last_modified, + created_at=last_modified, + rrule=rrule, + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def calendar_db_path(tmp_path: Path) -> Path: + path = tmp_path / "test_calendar.db" + asyncio.run(init_calendar_db(path)) + return path + + +# --------------------------------------------------------------------------- +# Pull tests +# --------------------------------------------------------------------------- + + +async def test_caldav_provider_pull_creates_local_events(calendar_db_path: Path) -> None: + """Mock returns 2 events → 2 local events created with external_id set.""" + ics1 = make_ics("uid-1", "Remote Event 1") + ics2 = make_ics("uid-2", "Remote Event 2") + client, _ = make_mock_client([ics1, ics2]) + provider = CalDAVSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(client) + ) + config = make_config() + + pulled = await provider.pull_changes(config) + + assert len(pulled) == 2 + events = await list_events(USER_ID, db_path=calendar_db_path) + assert len(events) == 2 + titles = {e.title for e in events} + assert titles == {"Remote Event 1", "Remote Event 2"} + for event in events: + assert event.external_id is not None + assert event.external_provider == "caldav" + + +async def test_caldav_provider_pull_updates_existing_event(calendar_db_path: Path) -> None: + """Local event exists (matched by external_id), remote is newer → local updated.""" + local = make_local_event( + event_id="evt-1", + title="Old Title", + external_id="uid-1", + last_modified="2026-01-01T00:00:00+00:00", + ) + await insert_event(local, calendar_db_path) + + ics_remote = make_ics("uid-1", "Updated Title", last_modified="20260601T120000Z") + client, _ = make_mock_client([ics_remote]) + provider = CalDAVSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(client) + ) + config = make_config() + + pulled = await provider.pull_changes(config) + + assert len(pulled) == 1 + updated = await get_event_by_external_id("uid-1", "caldav", USER_ID, calendar_db_path) + assert updated is not None + assert updated.title == "Updated Title" + + +# --------------------------------------------------------------------------- +# Push tests +# --------------------------------------------------------------------------- + + +async def test_caldav_provider_push_creates_remote_event(calendar_db_path: Path) -> None: + """Local event with no external_id → push creates remote, external_id stored.""" + local = make_local_event(event_id="evt-1", title="New Local Event", external_id=None) + await insert_event(local, calendar_db_path) + + client, _ = make_mock_client([], saved_uid="remote-uid-1") + provider = CalDAVSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(client) + ) + config = make_config() + + events = await list_events(USER_ID, db_path=calendar_db_path) + pushed = await provider.push_changes(config, events) + + assert len(pushed) == 1 + assert pushed[0].external_id == "remote-uid-1" + assert pushed[0].external_provider == "caldav" + db_event = await get_event_by_external_id("remote-uid-1", "caldav", USER_ID, calendar_db_path) + assert db_event is not None + + +async def test_caldav_provider_push_updates_remote_event(calendar_db_path: Path) -> None: + """Local event modified, has external_id → push updates remote.""" + local = make_local_event( + event_id="evt-1", + title="Updated Local Event", + external_id="existing-uid", + last_modified="2026-06-01T00:00:00+00:00", + ) + await insert_event(local, calendar_db_path) + + client, mock_calendar = make_mock_client([], saved_uid="existing-uid") + provider = CalDAVSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(client) + ) + config = make_config() + + events = await list_events(USER_ID, db_path=calendar_db_path) + pushed = await provider.push_changes(config, events) + + assert len(pushed) == 1 + assert pushed[0].external_id == "existing-uid" + # Verify save_event was called with ICS containing the existing UID + saved_ics = mock_calendar.save_event.call_args[0][0] + assert b"UID:existing-uid" in saved_ics + + +# --------------------------------------------------------------------------- +# Conflict tests +# --------------------------------------------------------------------------- + + +async def test_caldav_conflict_last_write_wins(calendar_db_path: Path) -> None: + """Both sides modified, local is newer → local wins, local not updated.""" + local = make_local_event( + event_id="evt-1", + title="Local Updated", + external_id="uid-1", + last_modified="2026-06-15T00:00:00+00:00", + ) + await insert_event(local, calendar_db_path) + + ics_remote = make_ics("uid-1", "Remote Older", last_modified="20260601T000000Z") + client, _ = make_mock_client([ics_remote]) + provider = CalDAVSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(client) + ) + config = make_config() + + pulled = await provider.pull_changes(config) + + # Local wins → no update, pulled is empty + assert len(pulled) == 0 + db_event = await get_event_by_external_id("uid-1", "caldav", USER_ID, calendar_db_path) + assert db_event is not None + assert db_event.title == "Local Updated" + + +async def test_caldav_conflict_sends_ws_notification(calendar_db_path: Path) -> None: + """Conflict detected → WS callback called with calendar_sync_conflict type (G4).""" + notifications: list[tuple[str, dict[str, Any]]] = [] + + async def notify(event_type: str, payload: dict[str, Any]) -> None: + notifications.append((event_type, payload)) + + local = make_local_event( + event_id="evt-1", + title="Local Updated", + external_id="uid-1", + last_modified="2026-06-15T00:00:00+00:00", + ) + await insert_event(local, calendar_db_path) + + ics_remote = make_ics("uid-1", "Remote Older", last_modified="20260601T000000Z") + client, _ = make_mock_client([ics_remote]) + provider = CalDAVSyncProvider( + db_path=calendar_db_path, + client_factory=client_factory_from(client), + notify_callback=notify, + ) + config = make_config() + + await provider.pull_changes(config) + + assert len(notifications) == 1 + event_type, payload = notifications[0] + assert event_type == "calendar_sync_conflict" + assert payload["event_id"] == "evt-1" + assert payload["winner"] == "local" + + +# --------------------------------------------------------------------------- +# RRULE roundtrip test +# --------------------------------------------------------------------------- + + +async def test_caldav_rrule_roundtrip(calendar_db_path: Path) -> None: + """Event with RRULE synced → RRULE preserved in both pull and push.""" + rrule = "FREQ=WEEKLY;BYDAY=MO;COUNT=4" + ics_remote = make_ics("uid-rrule", "Weekly Meeting", rrule=rrule) + client, _ = make_mock_client([ics_remote]) + provider = CalDAVSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(client) + ) + config = make_config() + + # Pull: verify RRULE preserved + pulled = await provider.pull_changes(config) + assert len(pulled) == 1 + assert pulled[0].rrule is not None + assert "FREQ=WEEKLY" in pulled[0].rrule + assert "BYDAY=MO" in pulled[0].rrule + assert "COUNT=4" in pulled[0].rrule + + # Push: verify RRULE in ICS sent to remote + events = await list_events(USER_ID, db_path=calendar_db_path) + client2, mock_calendar2 = make_mock_client([], saved_uid="uid-rrule") + provider2 = CalDAVSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(client2) + ) + await provider2.push_changes(config, events) + + saved_ics = mock_calendar2.save_event.call_args[0][0] + assert b"RRULE:FREQ=WEEKLY" in saved_ics + assert b"BYDAY=MO" in saved_ics + assert b"COUNT=4" in saved_ics + + +# --------------------------------------------------------------------------- +# test_connection tests +# --------------------------------------------------------------------------- + + +async def test_caldav_test_connection_success(calendar_db_path: Path) -> None: + """Mock returns calendars → test_connection() returns (True, "").""" + client, _ = make_mock_client([]) + provider = CalDAVSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(client) + ) + config = make_config() + + ok, msg = await provider.test_connection(config) + assert ok is True + assert msg == "" + + +async def test_caldav_test_connection_failure(calendar_db_path: Path) -> None: + """Mock raises → test_connection() returns (False, error_msg).""" + client, _ = make_mock_client([], raise_on_connect=ConnectionError("Auth failed")) + provider = CalDAVSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(client) + ) + config = make_config() + + ok, msg = await provider.test_connection(config) + assert ok is False + assert "Auth failed" in msg diff --git a/tests/unit/calendar/test_sync_manager.py b/tests/unit/calendar/test_sync_manager.py new file mode 100644 index 0000000..430ee8e --- /dev/null +++ b/tests/unit/calendar/test_sync_manager.py @@ -0,0 +1,197 @@ +"""Tests for SyncManager — orchestrates external calendar sync (U6, G8). + +Uses a mock ``AbstractSyncProvider`` to test SyncManager in isolation, +verifying provider iteration, last_sync updates, and conflict WS push. +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest + +# caldav is not installed — inject mock so SyncManager's import of +# CalDAVSyncProvider (which imports caldav) does not fail. +if "caldav" not in sys.modules: # pragma: no cover + sys.modules["caldav"] = MagicMock() + +from agentkit.calendar.db import ( + init_calendar_db, + insert_external_config, + list_external_configs, +) +from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig +from agentkit.calendar.sync.base import AbstractSyncProvider +from agentkit.calendar.sync.manager import SyncManager + +USER_ID = "user-1" + + +# --------------------------------------------------------------------------- +# Mock provider + helpers +# --------------------------------------------------------------------------- + + +class MockSyncProvider(AbstractSyncProvider): + """In-memory mock provider that records calls for assertion.""" + + def __init__(self) -> None: + self.pull_calls: int = 0 + self.push_calls: int = 0 + self.pull_configs: list[ExternalCalendarConfig] = [] + self.push_configs: list[ExternalCalendarConfig] = [] + + async def pull_changes( + self, config: ExternalCalendarConfig, since: str | None = None + ) -> list[CalendarEvent]: + self.pull_calls += 1 + self.pull_configs.append(config) + return [] + + async def push_changes( + self, config: ExternalCalendarConfig, events: list[CalendarEvent] + ) -> list[CalendarEvent]: + self.push_calls += 1 + self.push_configs.append(config) + return events + + async def test_connection(self, config: ExternalCalendarConfig) -> tuple[bool, str]: + return True, "" + + +def make_config( + config_id: str = "config-1", + user_id: str = USER_ID, + provider: str = "caldav", + last_sync: str | None = None, +) -> ExternalCalendarConfig: + return ExternalCalendarConfig( + id=config_id, + user_id=user_id, + provider=provider, + credentials=json.dumps( + {"url": "https://caldav.example.com", "username": "user", "password": "pass"} + ), + last_sync=last_sync, + ) + + +def make_event( + event_id: str = "evt-1", + title: str = "Test Event", + last_modified: str = "2026-06-01T00:00:00+00:00", + external_id: str | None = None, +) -> CalendarEvent: + return CalendarEvent( + id=event_id, + user_id=USER_ID, + title=title, + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + external_id=external_id, + external_provider="caldav" if external_id else None, + last_modified=last_modified, + created_at=last_modified, + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def calendar_db_path(tmp_path: Path) -> Path: + path = tmp_path / "test_calendar.db" + asyncio.run(init_calendar_db(path)) + return path + + +# --------------------------------------------------------------------------- +# sync_all tests +# --------------------------------------------------------------------------- + + +async def test_sync_manager_sync_all_iterates_providers( + calendar_db_path: Path, +) -> None: + """2 configs → both providers called (G8).""" + config1 = make_config(config_id="config-1") + config2 = make_config(config_id="config-2") + await insert_external_config(config1, calendar_db_path) + await insert_external_config(config2, calendar_db_path) + + mock_provider = MockSyncProvider() + manager = SyncManager(db_path=calendar_db_path, providers={"caldav": mock_provider}) + + result = await manager.sync_all(USER_ID) + + assert result["synced"] == 2 + assert result["errors"] == [] + assert mock_provider.pull_calls == 2 + assert mock_provider.push_calls == 2 + + +# --------------------------------------------------------------------------- +# sync_provider tests +# --------------------------------------------------------------------------- + + +async def test_sync_manager_sync_provider_updates_last_sync( + calendar_db_path: Path, +) -> None: + """Sync completes → config.last_sync updated (G8).""" + config = make_config(config_id="config-1", last_sync=None) + await insert_external_config(config, calendar_db_path) + + mock_provider = MockSyncProvider() + manager = SyncManager(db_path=calendar_db_path, providers={"caldav": mock_provider}) + + await manager.sync_provider("config-1") + + configs = await list_external_configs(USER_ID, calendar_db_path) + assert len(configs) == 1 + assert configs[0].last_sync is not None + + +# --------------------------------------------------------------------------- +# resolve_conflict tests +# --------------------------------------------------------------------------- + + +async def test_sync_manager_resolve_conflict_notifies_user( + calendar_db_path: Path, +) -> None: + """Conflict → WS callback called with calendar_sync_conflict type (G8/G4).""" + notifications: list[tuple[str, dict[str, Any]]] = [] + + async def notify(event_type: str, payload: dict[str, Any]) -> None: + notifications.append((event_type, payload)) + + manager = SyncManager(db_path=calendar_db_path, notify_callback=notify) + + local = make_event( + event_id="evt-1", + title="Local Version", + last_modified="2026-06-15T00:00:00+00:00", + external_id="uid-1", + ) + remote = make_event( + event_id="evt-remote", + title="Remote Version", + last_modified="2026-06-01T00:00:00+00:00", + external_id="uid-1", + ) + + winner = await manager.resolve_conflict(local, remote) + + assert len(notifications) == 1 + event_type, payload = notifications[0] + assert event_type == "calendar_sync_conflict" + assert payload["winner"] == "local" + assert winner is local # Local is newer → local wins From 40bc27822ff848334d7c45d71aafdf7812cac0d5 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Tue, 23 Jun 2026 22:52:40 +0800 Subject: [PATCH 08/16] feat(calendar): U9 frontend store, API client and types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CalendarApiClient with 20 methods covering all backend endpoints (listEvents, createEvent, importIcs, searchUsers, syncNow, etc.). useCalendarStore Pinia store with events/eventTypes/tags state, view mode switching, and handleWsEvent dispatch for 4 calendar WS message types. WsServerMessage union extended with calendar variants. - src/agentkit/server/frontend/src/api/calendar.ts — API client + types - src/agentkit/server/frontend/src/stores/calendar.ts — Pinia store - src/agentkit/server/frontend/src/api/types.ts — WS message types --- .../server/frontend/src/api/calendar.ts | 324 ++++++++++++++++++ src/agentkit/server/frontend/src/api/types.ts | 40 +++ .../server/frontend/src/stores/calendar.ts | 264 ++++++++++++++ 3 files changed, 628 insertions(+) create mode 100644 src/agentkit/server/frontend/src/api/calendar.ts create mode 100644 src/agentkit/server/frontend/src/stores/calendar.ts diff --git a/src/agentkit/server/frontend/src/api/calendar.ts b/src/agentkit/server/frontend/src/api/calendar.ts new file mode 100644 index 0000000..b6ed927 --- /dev/null +++ b/src/agentkit/server/frontend/src/api/calendar.ts @@ -0,0 +1,324 @@ +/** Calendar API client — thin wrapper over /api/v1/calendar endpoints. */ + +import { BaseApiClient, getDynamicBaseURL } from './base' + +// ── Domain types (co-located with API client) ────────────────────────── + +export interface ICalendarEvent { + id: string + user_id: string + title: string + description: string + start_time: string // ISO 8601 UTC (KTD-11) + end_time: string // ISO 8601 UTC + is_all_day: boolean + location: string + event_type_id: string | null + rrule: string | null // RFC 5545 RRULE string + source: 'manual' | 'agent' | 'post_extract' + is_invited: boolean + conversation_id: string | null + external_id: string | null + external_provider: string | null + last_modified: string + created_at: string +} + +export interface IEventType { + id: string + user_id: string + name: string + color: string + is_default: boolean +} + +export interface ITag { + id: string + user_id: string + name: string +} + +export interface IInvitation { + id: string + event_id: string + inviter_user_id: string + invitee_email: string + status: 'pending' | 'accepted' | 'declined' | 'tentative' + responded_at: string | null +} + +export interface IExternalCalendarConfig { + id: string + user_id: string + provider: 'caldav' | 'outlook' + credentials: string // '***' on read-back; never real credentials + sync_frequency: number + sync_scope: string[] + last_sync: string | null + sync_token: string | null +} + +export interface IUserSearchResult { + username: string + email: string +} + +// ── Request types ────────────────────────────────────────────────────── + +export interface ICreateEventRequest { + title: string + start_time: string + end_time: string + description?: string + location?: string + is_all_day?: boolean + event_type_id?: string | null + rrule?: string | null + tag_ids?: string[] +} + +export interface IUpdateEventRequest { + title?: string + start_time?: string + end_time?: string + description?: string + location?: string + is_all_day?: boolean + event_type_id?: string | null + rrule?: string | null +} + +export interface ICreateEventTypeRequest { + name: string + color?: string +} + +export interface ICreateTagRequest { + name: string +} + +export interface ICreateInvitationRequest { + event_id: string + invitee_email: string +} + +export interface ICreateExternalConfigRequest { + provider: 'caldav' | 'outlook' + credentials: string // JSON string with provider-specific auth + sync_frequency?: number + sync_scope?: string[] +} + +// ── Runtime type guard ───────────────────────────────────────────────── + +/** + * Runtime guard for ICalendarEvent — validates the minimum fields required + * for the calendar store to function safely. + * ponytail: checks only the keys the store actually reads; full schema + * validation belongs at the API boundary, not in the WS event handler. + */ +export function isCalendarEvent(value: unknown): value is ICalendarEvent { + if (typeof value !== 'object' || value === null) return false + const v = value as Record + return ( + typeof v.id === 'string' && + typeof v.title === 'string' && + typeof v.start_time === 'string' && + typeof v.end_time === 'string' && + typeof v.is_all_day === 'boolean' + ) +} + +// ── API client ───────────────────────────────────────────────────────── + +const API_BASE = '/api/v1/calendar' + +class CalendarApiClient extends BaseApiClient { + constructor(baseUrl: string = API_BASE) { + super(baseUrl) + } + + /** List events with optional date/type/tag filters */ + async listEvents( + start?: string, + end?: string, + eventTypeId?: string, + tagId?: string, + ): Promise<{ success: boolean; events: ICalendarEvent[]; count: number }> { + const params = new URLSearchParams() + if (start) params.set('start', start) + if (end) params.set('end', end) + if (eventTypeId) params.set('type_id', eventTypeId) + if (tagId) params.set('tag_id', tagId) + const qs = params.toString() + const path = qs ? `/events?${qs}` : '/events' + return this.request(path, { method: 'GET' }) + } + + /** Create a new event */ + async createEvent( + data: ICreateEventRequest, + ): Promise<{ success: boolean; event: ICalendarEvent }> { + return this.request('/events', { + method: 'POST', + body: JSON.stringify(data), + }) + } + + /** Get a single event by id */ + async getEvent(id: string): Promise<{ success: boolean; event: ICalendarEvent }> { + return this.request(`/events/${id}`, { method: 'GET' }) + } + + /** Update specific fields of an event (PATCH — partial update) */ + async updateEvent( + id: string, + data: IUpdateEventRequest, + ): Promise<{ success: boolean; event: ICalendarEvent; updated: boolean }> { + return this.request(`/events/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }) + } + + /** Delete an event */ + async deleteEvent(id: string): Promise<{ success: boolean; deleted: boolean }> { + return this.request(`/events/${id}`, { method: 'DELETE' }) + } + + /** List event types for the current user */ + async listEventTypes(): Promise<{ + success: boolean + event_types: IEventType[] + count: number + }> { + return this.request('/event-types', { method: 'GET' }) + } + + /** Create an event type */ + async createEventType( + data: ICreateEventTypeRequest, + ): Promise<{ success: boolean; event_type: IEventType }> { + return this.request('/event-types', { + method: 'POST', + body: JSON.stringify(data), + }) + } + + /** List tags for the current user */ + async listTags(): Promise<{ success: boolean; tags: ITag[]; count: number }> { + return this.request('/tags', { method: 'GET' }) + } + + /** Create a tag */ + async createTag(data: ICreateTagRequest): Promise<{ success: boolean; tag: ITag }> { + return this.request('/tags', { + method: 'POST', + body: JSON.stringify(data), + }) + } + + /** Import events from an uploaded .ics file (multipart) */ + async importIcs(file: File): Promise<{ + success: boolean + imported: number + skipped: number + [key: string]: unknown + }> { + const formData = new FormData() + formData.append('file', file) + return this.request('/import-ics', { + method: 'POST', + body: formData, + headers: {}, // Let browser set Content-Type for FormData + }) + } + + /** + * Build the export-ics download URL for a date range. + * Returns an absolute or relative URL the caller can open/download. + * ponytail: the endpoint returns binary text/calendar, not JSON, so we + * hand back a URL rather than going through request (which JSON-parses). + */ + exportIcs(start?: string, end?: string): string { + const base = getDynamicBaseURL() + const params = new URLSearchParams() + if (start) params.set('start', start) + if (end) params.set('end', end) + const qs = params.toString() + const path = qs ? `/api/v1/calendar/export-ics?${qs}` : '/api/v1/calendar/export-ics' + return base ? `${base}${path}` : path + } + + /** Create an invitation (invite a user to an event by email) */ + async createInvitation( + data: ICreateInvitationRequest, + ): Promise<{ success: boolean; invitation: IInvitation }> { + return this.request(`/events/${data.event_id}/invitations`, { + method: 'POST', + body: JSON.stringify({ invitee_email: data.invitee_email }), + }) + } + + /** List invitations for the current user */ + async listInvitations(): Promise<{ + success: boolean + invitations: IInvitation[] + count: number + }> { + return this.request('/invitations', { method: 'GET' }) + } + + /** Respond to an invitation (accept/decline/tentative) */ + async respondToInvitation( + id: string, + status: 'accepted' | 'declined' | 'tentative', + ): Promise<{ success: boolean; status: string }> { + return this.request(`/invitations/${id}/respond`, { + method: 'POST', + body: JSON.stringify({ status }), + }) + } + + /** Search users by username or email — returns top 10 matches (G5/A3) */ + async searchUsers(q: string): Promise<{ + success: boolean + users: IUserSearchResult[] + count: number + }> { + return this.request(`/users/search?q=${encodeURIComponent(q)}`, { method: 'GET' }) + } + + /** List external calendar configs for the current user */ + async listExternalConfigs(): Promise<{ + success: boolean + configs: IExternalCalendarConfig[] + count: number + }> { + return this.request('/external-configs', { method: 'GET' }) + } + + /** Create an external calendar config */ + async createExternalConfig( + data: ICreateExternalConfigRequest, + ): Promise<{ success: boolean; config: IExternalCalendarConfig }> { + return this.request('/external-configs', { + method: 'POST', + body: JSON.stringify(data), + }) + } + + /** Test connection to an external calendar */ + async testExternalConnection( + id: string, + ): Promise<{ success: boolean; connected: boolean; error?: string }> { + return this.request(`/external-configs/${id}/test`, { method: 'POST' }) + } + + /** Trigger an immediate sync for an external calendar config */ + async syncNow(id: string): Promise<{ success: boolean; synced: boolean; error?: string }> { + return this.request(`/external-configs/${id}/sync`, { method: 'POST' }) + } +} + +export const calendarApi = new CalendarApiClient() diff --git a/src/agentkit/server/frontend/src/api/types.ts b/src/agentkit/server/frontend/src/api/types.ts index a6f6f26..4330b27 100644 --- a/src/agentkit/server/frontend/src/api/types.ts +++ b/src/agentkit/server/frontend/src/api/types.ts @@ -1,3 +1,5 @@ +import type { ICalendarEvent, IInvitation } from './calendar' + /** Chat request payload */ export interface IChatRequest { message: string @@ -130,6 +132,11 @@ export type WsServerMessage = | { type: 'round_summary'; data: IRoundSummaryData } | { type: 'user_intervention'; data: IUserInterventionData } | { type: 'board_concluded'; data: IBoardConcludedData } + // Calendar 事件 (KTD-10 — piggyback on chat WS) + | { type: 'calendar_event_created'; data: ICalendarEventCreatedData } + | { type: 'calendar_reminder'; data: ICalendarReminderData } + | { type: 'calendar_invitation'; data: ICalendarInvitationData } + | { type: 'calendar_sync_conflict'; data: ICalendarSyncConflictData } /** Expert info within a team */ export interface IExpertInfo { @@ -233,6 +240,39 @@ export interface IBoardMessage { timestamp: number } +// ── Calendar WS 事件 payload 类型 ─────────────────────────────────── + +/** calendar_event_created payload */ +export interface ICalendarEventCreatedData { + event: ICalendarEvent +} + +/** calendar_reminder payload */ +export interface ICalendarReminderData { + event_id: string + title: string + start_time: string + offset_minutes: number + channels: string[] +} + +/** calendar_invitation payload (G6) */ +export interface ICalendarInvitationData { + invitation: IInvitation + event_title: string + inviter_name: string +} + +/** calendar_sync_conflict payload (G4) */ +export interface ICalendarSyncConflictData { + event_id: string + event_title: string + provider: string + local_modified: string + remote_modified: string + resolution: string +} + /** Expert template (matches backend GET /api/v1/experts response item) */ export interface IExpertTemplate { name: string diff --git a/src/agentkit/server/frontend/src/stores/calendar.ts b/src/agentkit/server/frontend/src/stores/calendar.ts new file mode 100644 index 0000000..3d9dcf1 --- /dev/null +++ b/src/agentkit/server/frontend/src/stores/calendar.ts @@ -0,0 +1,264 @@ +/** + * 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, + IUserSearchResult, +} from '@/api/calendar' +import type { WsServerMessage, ICalendarSyncConflictData } from '@/api/types' + +export type CalendarViewMode = 'calendar' | 'card' | 'list' + +export const useCalendarStore = defineStore('calendar', () => { + // --- State --- + const events = ref([]) + const eventTypes = ref([]) + const tags = ref([]) + const isLoading = ref(false) + const error = ref(null) + const selectedEvent = ref(null) + const viewMode = ref('calendar') + const dateRange = ref<{ start: string | null; end: string | null }>({ + start: null, + end: null, + }) + const pendingInvitations = ref([]) + const syncConflicts = ref([]) + + // --- Getters --- + const upcomingEvents = computed(() => { + const now = new Date().toISOString() + return events.value + .filter((e) => e.start_time >= now) + .sort((a, b) => a.start_time.localeCompare(b.start_time)) + }) + + // --- Actions --- + + /** Load events with current dateRange filter */ + async function loadEvents(): Promise { + isLoading.value = true + error.value = null + try { + const resp = await calendarApi.listEvents( + dateRange.value.start ?? undefined, + dateRange.value.end ?? undefined, + ) + events.value = resp.events || [] + } catch (err) { + error.value = err instanceof Error ? err.message : '加载日程失败' + console.warn('Failed to load calendar events:', err) + } finally { + isLoading.value = false + } + } + + /** Create a new event and add it to state */ + async function createEvent(data: ICreateEventRequest): Promise { + isLoading.value = true + error.value = null + try { + const resp = await calendarApi.createEvent(data) + if (resp.event) { + events.value.push(resp.event) + } + return resp.event ?? null + } catch (err) { + error.value = err instanceof Error ? err.message : '创建日程失败' + console.error('Failed to create event:', err) + throw err + } finally { + isLoading.value = false + } + } + + /** Update an existing event in state */ + async function updateEvent(id: string, data: IUpdateEventRequest): Promise { + error.value = null + try { + const resp = await calendarApi.updateEvent(id, data) + if (resp.event) { + const idx = events.value.findIndex((e) => e.id === id) + if (idx !== -1) { + events.value[idx] = resp.event + } + } + } catch (err) { + error.value = err instanceof Error ? err.message : '更新日程失败' + console.error('Failed to update event:', err) + throw err + } + } + + /** Delete an event from server and state */ + async function deleteEvent(id: string): Promise { + error.value = null + try { + await calendarApi.deleteEvent(id) + events.value = events.value.filter((e) => e.id !== id) + } catch (err) { + error.value = err instanceof Error ? err.message : '删除日程失败' + console.error('Failed to delete event:', err) + throw err + } + } + + /** Load event types for the current user */ + async function loadEventTypes(): Promise { + try { + const resp = await calendarApi.listEventTypes() + eventTypes.value = resp.event_types || [] + } catch (err) { + console.warn('Failed to load event types:', err) + } + } + + /** Load tags for the current user */ + async function loadTags(): Promise { + try { + const resp = await calendarApi.listTags() + tags.value = resp.tags || [] + } catch (err) { + console.warn('Failed to load tags:', err) + } + } + + /** Set the calendar view mode */ + function setViewMode(mode: CalendarViewMode): void { + viewMode.value = mode + } + + /** Load pending invitations for the current user */ + async function loadInvitations(): Promise { + try { + const resp = await calendarApi.listInvitations() + pendingInvitations.value = (resp.invitations || []).filter( + (i) => i.status === 'pending', + ) + } catch (err) { + console.warn('Failed to load invitations:', err) + } + } + + /** Respond to an invitation and remove it from pending list */ + async function respondToInvitation( + id: string, + status: 'accepted' | 'declined' | 'tentative', + ): Promise { + try { + await calendarApi.respondToInvitation(id, status) + pendingInvitations.value = pendingInvitations.value.filter((i) => i.id !== id) + } catch (err) { + error.value = err instanceof Error ? err.message : '回复邀请失败' + console.error('Failed to respond to invitation:', err) + throw err + } + } + + /** Search users by username or email (G5/A3) */ + async function searchUsers(q: string): Promise { + if (!q.trim()) return [] + try { + const resp = await calendarApi.searchUsers(q) + return resp.users || [] + } catch (err) { + console.warn('Failed to search users:', err) + return [] + } + } + + /** + * Dispatch calendar WS messages to the appropriate handler. + * Handles 4 message types: calendar_event_created, calendar_reminder, + * calendar_invitation (G6), calendar_sync_conflict (G4). + * Non-calendar message types are silently ignored. + */ + function handleWsEvent(msg: WsServerMessage): void { + switch (msg.type) { + case 'calendar_event_created': { + const event = msg.data.event + if (isCalendarEvent(event)) { + // Avoid duplicates if the event was created locally + if (!events.value.some((e) => e.id === event.id)) { + events.value.push(event) + } + } + break + } + case 'calendar_reminder': { + const d = msg.data + notification.warning({ + message: '日程提醒', + description: `${d.title} · ${d.start_time}`, + duration: 0, + }) + break + } + case 'calendar_invitation': { + const d = msg.data + if (d.invitation) { + pendingInvitations.value.push(d.invitation) + } + notification.info({ + message: '收到日程邀请', + description: `${d.inviter_name} 邀请你参加「${d.event_title}」`, + duration: 0, + }) + break + } + case 'calendar_sync_conflict': { + const d = msg.data + syncConflicts.value.push(d) + notification.warning({ + message: '日历同步冲突', + description: `「${d.event_title}」与 ${d.provider} 同步冲突,已按 ${d.resolution} 策略处理`, + duration: 0, + }) + break + } + } + } + + return { + // State + events, + eventTypes, + tags, + isLoading, + error, + selectedEvent, + viewMode, + dateRange, + pendingInvitations, + syncConflicts, + // Getters + upcomingEvents, + // Actions + loadEvents, + createEvent, + updateEvent, + deleteEvent, + loadEventTypes, + loadTags, + setViewMode, + loadInvitations, + respondToInvitation, + searchUsers, + handleWsEvent, + } +}) From 8d4145ddf9ce2897f0a1bb764dc9f062e6553479 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Tue, 23 Jun 2026 23:49:24 +0800 Subject: [PATCH 09/16] feat(calendar): U7 Outlook sync via Microsoft Graph API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OutlookSyncProvider implementing AbstractSyncProvider for bidirectional Outlook Calendar sync. Uses Graph API delta query for incremental pull, auto-refreshes OAuth tokens on 401, and converts Outlook recurrence patterns to RRULE. Same conflict resolution as CalDAV (last-write-wins + WS notification). - src/agentkit/calendar/sync/outlook_provider.py — OutlookSyncProvider - tests/unit/calendar/test_sync_outlook.py — 8 tests --- .../calendar/sync/outlook_provider.py | 536 +++++++++++++++++ tests/unit/calendar/test_sync_outlook.py | 556 ++++++++++++++++++ 2 files changed, 1092 insertions(+) create mode 100644 src/agentkit/calendar/sync/outlook_provider.py create mode 100644 tests/unit/calendar/test_sync_outlook.py diff --git a/src/agentkit/calendar/sync/outlook_provider.py b/src/agentkit/calendar/sync/outlook_provider.py new file mode 100644 index 0000000..1c4974f --- /dev/null +++ b/src/agentkit/calendar/sync/outlook_provider.py @@ -0,0 +1,536 @@ +"""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, urlparse + +import httpx + +from agentkit.calendar.db import ( + DEFAULT_CALENDAR_DB_PATH, + get_event_by_external_id, + insert_event, + update_event, + update_external_config, +) +from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig, _now_iso +from agentkit.calendar.sync.base import AbstractSyncProvider + +logger = logging.getLogger(__name__) + +# Async callback signature: (event_type: str, payload: dict) -> None +NotifyCallback = Callable[[str, dict[str, Any]], Awaitable[None]] + +GRAPH_BASE = "https://graph.microsoft.com/v1.0" +TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token" +DEFAULT_SCOPE = "https://graph.microsoft.com/Calendars.ReadWrite offline_access" + +_DAY_MAP = { + "monday": "MO", + "tuesday": "TU", + "wednesday": "WE", + "thursday": "TH", + "friday": "FR", + "saturday": "SA", + "sunday": "SU", +} +_DAY_MAP_REVERSE = {v: k for k, v in _DAY_MAP.items()} +_FREQ_MAP = { + "daily": "DAILY", + "weekly": "WEEKLY", + "absoluteMonthly": "MONTHLY", + "absoluteYearly": "YEARLY", +} +_FREQ_MAP_REVERSE = {v: k for k, v in _FREQ_MAP.items()} + + +def _parse_iso(dt_str: str) -> datetime: + """Parse ISO 8601 string to UTC-aware datetime.""" + dt = datetime.fromisoformat(dt_str) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def _outlook_dt_to_iso(dt_obj: dict[str, Any]) -> str: + """Convert Outlook dateTimeTimeZone to ISO 8601 UTC. + + ponytail: assumes Graph returns UTC (no ``Prefer: outlook.timezone`` header + is sent). If a non-UTC timezone is returned, it's treated as UTC. Upgrade: + use ``zoneinfo`` with a Windows→IANA timezone mapping for correct conversion. + """ + if not dt_obj: + return "" + dt_str = dt_obj.get("dateTime", "") + if not dt_str: + return "" + if "T" in dt_str: + dt = datetime.fromisoformat(dt_str) + else: + # Date only (all-day event) + dt = datetime.fromisoformat(dt_str + "T00:00:00") + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).isoformat() + + +def _iso_to_outlook_dt(iso_str: str, is_all_day: bool) -> dict[str, str]: + """Convert ISO 8601 UTC to Outlook dateTimeTimeZone.""" + if not iso_str: + return {"dateTime": "", "timeZone": "UTC"} + dt = _parse_iso(iso_str) + if is_all_day: + return {"dateTime": dt.strftime("%Y-%m-%d"), "timeZone": "UTC"} + return {"dateTime": dt.strftime("%Y-%m-%dT%H:%M:%S"), "timeZone": "UTC"} + + +def _outlook_recurrence_to_rrule(recurrence: dict[str, Any] | None) -> str | None: + """Convert Outlook recurrence pattern to RRULE string.""" + if not recurrence: + return None + pattern = recurrence.get("pattern", {}) or {} + range_obj = recurrence.get("range", {}) or {} + + parts: list[str] = [] + freq = _FREQ_MAP.get(pattern.get("type", "")) + if freq: + parts.append(f"FREQ={freq}") + + interval = pattern.get("interval") + if interval and interval > 1: + parts.append(f"INTERVAL={interval}") + + days = pattern.get("daysOfWeek", []) + if days: + bydays = [_DAY_MAP[d] for d in days if d in _DAY_MAP] + if bydays: + parts.append(f"BYDAY={','.join(bydays)}") + + count = pattern.get("numberOfOccurrences") + if count: + parts.append(f"COUNT={count}") + elif range_obj.get("type") == "endDate" and range_obj.get("endDate"): + end_date = range_obj["endDate"] # "2026-12-31" + parts.append(f"UNTIL={end_date.replace('-', '')}T235959Z") + + return ";".join(parts) if parts else None + + +def _rrule_to_outlook_recurrence(rrule: str, start_date: str) -> dict[str, Any] | None: + """Convert RRULE string to Outlook recurrence pattern. + + ``start_date`` is the event's start date in ``YYYY-MM-DD`` format (required + by Graph API for the ``range.startDate`` field). + """ + parts: dict[str, str] = {} + for part in rrule.split(";"): + if "=" in part: + k, v = part.split("=", 1) + parts[k.upper()] = v + + freq = parts.get("FREQ", "").upper() + pattern_type = _FREQ_MAP_REVERSE.get(freq) + if not pattern_type: + return None + + pattern: dict[str, Any] = {"type": pattern_type} + + interval = parts.get("INTERVAL") + pattern["interval"] = int(interval) if interval else 1 + + byday = parts.get("BYDAY") + if byday: + pattern["daysOfWeek"] = [ + _DAY_MAP_REVERSE[d] for d in byday.split(",") if d in _DAY_MAP_REVERSE + ] + + count = parts.get("COUNT") + until = parts.get("UNTIL") + + if count: + pattern["numberOfOccurrences"] = int(count) + range_obj: dict[str, Any] = { + "type": "numbered", + "startDate": start_date, + "numberOfOccurrences": int(count), + } + elif until: + # UNTIL=20261231T235959Z → "2026-12-31" + date_str = until[:8] + end_date = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}" + range_obj = {"type": "endDate", "startDate": start_date, "endDate": end_date} + else: + range_obj = {"type": "noEnd", "startDate": start_date} + + return {"pattern": pattern, "range": range_obj} + + +def _extract_delta_token(delta_link: str) -> str | None: + """Extract ``$deltaToken`` from a Graph delta link URL.""" + parsed = urlparse(delta_link) + params = parse_qs(parsed.query) + values = params.get("$deltaToken", []) + return values[0] if values else None + + +class OutlookSyncProvider(AbstractSyncProvider): + """Bidirectional Outlook sync provider via Microsoft Graph REST API. + + The ``client_factory`` parameter allows tests to inject a mock + ``httpx.AsyncClient`` without making real HTTP calls. When ``None``, a + real ``httpx.AsyncClient`` is constructed per-operation. + """ + + def __init__( + self, + db_path: str | Path | None = None, + client_factory: Callable[[], Any] | None = None, + notify_callback: NotifyCallback | None = None, + ) -> None: + self.db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + self._client_factory = client_factory + self._notify = notify_callback + # ponytail: conflicts list is in-memory only; if the process restarts + # before a sync completes, conflict history is lost. Upgrade: persist + # to a calendar_sync_conflicts table. + + # ------------------------------------------------------------------ + # Client / auth + # ------------------------------------------------------------------ + + def _get_client(self) -> Any: + """Return an httpx.AsyncClient (real or mock from factory).""" + if self._client_factory is not None: + return self._client_factory() + return httpx.AsyncClient(timeout=30.0) + + def _load_creds(self, config: ExternalCalendarConfig) -> dict[str, Any]: + return json.loads(config.credentials) if config.credentials else {} + + def _save_creds(self, config: ExternalCalendarConfig, creds: dict[str, Any]) -> None: + config.credentials = json.dumps(creds) + + async def _refresh_token(self, client: Any, config: ExternalCalendarConfig) -> dict[str, Any]: + """Refresh the access token using the refresh_token grant. + + Posts to the Azure AD token endpoint, updates ``config.credentials`` + in-memory, and persists the new credentials to the DB. + """ + creds = self._load_creds(config) + resp = await client.request( + "POST", + TOKEN_URL, + data={ + "client_id": creds.get("client_id", ""), + "grant_type": "refresh_token", + "refresh_token": creds.get("refresh_token", ""), + "scope": DEFAULT_SCOPE, + }, + ) + 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={config.sync_token}" + # 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 + fields = { + "title": remote.title, + "description": remote.description, + "start_time": remote.start_time, + "end_time": remote.end_time, + "is_all_day": remote.is_all_day, + "location": remote.location, + "rrule": remote.rrule, + "last_modified": remote.last_modified, + } + await update_event(local.id, fields, self.db_path) + return remote + + if local_lm > remote_lm: + # Local wins → conflict, keep local, notify + await self._notify_conflict(local, remote, winner="local") + return None + + # Equal timestamps → no change needed + return None + + # ------------------------------------------------------------------ + # Push + # ------------------------------------------------------------------ + + async def push_changes( + self, config: ExternalCalendarConfig, events: list[CalendarEvent] + ) -> list[CalendarEvent]: + """Push local events to Outlook. Returns events with external_id set.""" + client = self._get_client() + try: + result: list[CalendarEvent] = [] + for event in events: + updated = await self._push_single(client, config, event) + result.append(updated) + finally: + await client.aclose() + return result + + async def _push_single( + self, client: Any, config: ExternalCalendarConfig, event: CalendarEvent + ) -> CalendarEvent: + """Push a single event to Outlook, return event with external_id set.""" + body = self._event_to_outlook(event) + if event.external_id: + # Update existing remote event + url = f"{GRAPH_BASE}/me/events/{event.external_id}" + await self._request(client, config, "PATCH", url, json_body=body) + fields = {"last_modified": _now_iso()} + await update_event(event.id, fields, self.db_path) + return event + # Create new remote event + url = f"{GRAPH_BASE}/me/events" + payload = await self._request(client, config, "POST", url, json_body=body) + new_id = payload.get("id") + if new_id: + fields = { + "external_id": new_id, + "external_provider": "outlook", + "last_modified": _now_iso(), + } + await update_event(event.id, fields, self.db_path) + event.external_id = new_id + event.external_provider = "outlook" + return event + + def _event_to_outlook(self, event: CalendarEvent) -> dict[str, Any]: + """Convert CalendarEvent to Outlook Graph event JSON.""" + body: dict[str, Any] = { + "subject": event.title, + "start": _iso_to_outlook_dt(event.start_time, event.is_all_day), + "end": _iso_to_outlook_dt(event.end_time, event.is_all_day), + "isAllDay": event.is_all_day, + } + if event.description: + body["body"] = {"contentType": "Text", "content": event.description} + if event.location: + body["location"] = {"displayName": event.location} + if event.rrule: + start_date = event.start_time[:10] if event.start_time else "2026-01-01" + rec = _rrule_to_outlook_recurrence(event.rrule, start_date) + if rec is not None: + body["recurrence"] = rec + return body + + # ------------------------------------------------------------------ + # Test connection + # ------------------------------------------------------------------ + + async def test_connection(self, config: ExternalCalendarConfig) -> tuple[bool, str]: + """Test Outlook connectivity via GET /me. Returns (ok, error_msg).""" + client = self._get_client() + try: + try: + await self._request(client, config, "GET", f"{GRAPH_BASE}/me") + ok, msg = True, "" + except Exception as e: + ok, msg = False, str(e) + finally: + await client.aclose() + return ok, msg + + # ------------------------------------------------------------------ + # Conflict notification + # ------------------------------------------------------------------ + + async def _notify_conflict( + self, local: CalendarEvent, remote: CalendarEvent, winner: str + ) -> None: + """Log conflict and send WS notification via callback (G4).""" + logger.info( + "Sync conflict for event %s (external_id=%s): local_lm=%s remote_lm=%s winner=%s", + local.id, + local.external_id, + local.last_modified, + remote.last_modified, + winner, + ) + if self._notify is not None: + payload = { + "event_id": local.id, + "title": local.title, + "external_id": local.external_id, + "local_last_modified": local.last_modified, + "remote_last_modified": remote.last_modified, + "winner": winner, + } + await self._notify("calendar_sync_conflict", payload) diff --git a/tests/unit/calendar/test_sync_outlook.py b/tests/unit/calendar/test_sync_outlook.py new file mode 100644 index 0000000..87c450a --- /dev/null +++ b/tests/unit/calendar/test_sync_outlook.py @@ -0,0 +1,556 @@ +"""Tests for OutlookSyncProvider — bidirectional Outlook Calendar sync (U7). + +All Microsoft Graph API interactions are mocked via the ``client_factory`` +injection point. No real HTTP calls are made. +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any + +import pytest + +from agentkit.calendar.db import ( + get_event_by_external_id, + init_calendar_db, + insert_event, + list_events, +) +from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig +from agentkit.calendar.sync.outlook_provider import OutlookSyncProvider + +USER_ID = "user-1" + + +# --------------------------------------------------------------------------- +# Mock helpers +# --------------------------------------------------------------------------- + + +class MockResponse: + """Minimal mock of an httpx.Response.""" + + def __init__(self, status_code: int = 200, json_data: Any = None) -> None: + self.status_code = status_code + self._json = json_data + self.text = json.dumps(json_data) if json_data is not None else "" + + def json(self) -> Any: + return self._json if self._json is not None else {} + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise RuntimeError(f"HTTP {self.status_code}") + + +class MockOutlookClient: + """Mock httpx.AsyncClient — records requests, returns queued responses.""" + + def __init__(self) -> None: + self.requests: list[dict[str, Any]] = [] + self._responses: list[MockResponse] = [] + + def add_response(self, response: MockResponse) -> None: + self._responses.append(response) + + async def request( + self, + method: str, + url: str, + *, + headers: Any = None, + json: Any = None, + params: Any = None, + data: Any = None, + ) -> MockResponse: + self.requests.append( + { + "method": method, + "url": url, + "headers": headers, + "json": json, + "params": params, + "data": data, + } + ) + if self._responses: + return self._responses.pop(0) + return MockResponse(status_code=200, json_data={}) + + async def aclose(self) -> None: + pass + + +def make_outlook_event( + eid: str = "outlook-1", + subject: str = "Outlook Event", + start: str = "2026-07-01T10:00:00", + end: str = "2026-07-01T11:00:00", + is_all_day: bool = False, + description: str = "", + location: str = "", + recurrence: dict[str, Any] | None = None, + last_modified: str = "2026-06-01T00:00:00Z", +) -> dict[str, Any]: + """Build a minimal Graph event JSON dict.""" + event: dict[str, Any] = { + "id": eid, + "subject": subject, + "start": {"dateTime": start, "timeZone": "UTC"}, + "end": {"dateTime": end, "timeZone": "UTC"}, + "isAllDay": is_all_day, + "lastModifiedDateTime": last_modified, + } + if description: + event["body"] = {"contentType": "Text", "content": description} + if location: + event["location"] = {"displayName": location} + if recurrence: + event["recurrence"] = recurrence + return event + + +def make_config( + config_id: str = "config-1", + user_id: str = USER_ID, + provider: str = "outlook", + last_sync: str | None = None, + sync_token: str | None = None, + access_token: str = "test-token", + refresh_token: str = "test-refresh", +) -> ExternalCalendarConfig: + return ExternalCalendarConfig( + id=config_id, + user_id=user_id, + provider=provider, + credentials=json.dumps( + { + "access_token": access_token, + "refresh_token": refresh_token, + "client_id": "test-client-id", + "expires_at": "2026-12-31T00:00:00+00:00", + } + ), + last_sync=last_sync, + sync_token=sync_token, + ) + + +def make_local_event( + event_id: str = "evt-1", + title: str = "Local Event", + external_id: str | None = None, + last_modified: str = "2026-01-01T00:00:00+00:00", + rrule: str | None = None, +) -> CalendarEvent: + return CalendarEvent( + id=event_id, + user_id=USER_ID, + title=title, + start_time="2026-07-01T10:00:00+00:00", + end_time="2026-07-01T11:00:00+00:00", + external_id=external_id, + external_provider="outlook" if external_id else None, + last_modified=last_modified, + created_at=last_modified, + rrule=rrule, + ) + + +def client_factory_from(client: MockOutlookClient): + """Wrap a mock client in a client_factory callable.""" + + def factory() -> MockOutlookClient: + return client + + return factory + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def calendar_db_path(tmp_path: Path) -> Path: + path = tmp_path / "test_calendar.db" + asyncio.run(init_calendar_db(path)) + return path + + +# --------------------------------------------------------------------------- +# Pull tests +# --------------------------------------------------------------------------- + + +async def test_outlook_provider_pull_delta_initial_sync( + calendar_db_path: Path, +) -> None: + """Mock returns 3 events → 3 local events created with external_id set.""" + events = [ + make_outlook_event(eid="outlook-1", subject="Event 1"), + make_outlook_event(eid="outlook-2", subject="Event 2"), + make_outlook_event(eid="outlook-3", subject="Event 3"), + ] + response = MockResponse( + 200, + { + "value": events, + "@odata.deltaLink": ( + "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltaToken=initial-token" + ), + }, + ) + mock_client = MockOutlookClient() + mock_client.add_response(response) + + provider = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_client) + ) + config = make_config() + + pulled = await provider.pull_changes(config) + + assert len(pulled) == 3 + db_events = await list_events(USER_ID, db_path=calendar_db_path) + assert len(db_events) == 3 + titles = {e.title for e in db_events} + assert titles == {"Event 1", "Event 2", "Event 3"} + for event in db_events: + assert event.external_id is not None + assert event.external_provider == "outlook" + # Delta token stored for next incremental sync + assert config.sync_token == "initial-token" + + +async def test_outlook_provider_pull_delta_incremental( + calendar_db_path: Path, +) -> None: + """Config has sync_token → incremental sync. 1 new + 1 updated processed.""" + # Pre-insert a local event that will be updated by the remote + local = make_local_event( + event_id="evt-1", + title="Old Title", + external_id="outlook-existing", + last_modified="2026-01-01T00:00:00+00:00", + ) + await insert_event(local, calendar_db_path) + + new_event = make_outlook_event(eid="outlook-new", subject="New Event") + updated_event = make_outlook_event( + eid="outlook-existing", + subject="Updated Title", + last_modified="2026-06-15T00:00:00Z", # newer than local + ) + response = MockResponse( + 200, + { + "value": [new_event, updated_event], + "@odata.deltaLink": ( + "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltaToken=new-token" + ), + }, + ) + mock_client = MockOutlookClient() + mock_client.add_response(response) + + provider = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_client) + ) + config = make_config(sync_token="previous-token") + + pulled = await provider.pull_changes(config) + + assert len(pulled) == 2 # 1 new + 1 updated + + # Verify the request URL contains the delta token (incremental sync) + assert "$deltaToken=previous-token" in mock_client.requests[0]["url"] + + # New event was created + new_db = await get_event_by_external_id("outlook-new", "outlook", USER_ID, calendar_db_path) + assert new_db is not None + assert new_db.title == "New Event" + + # Existing event was updated (remote was newer) + updated_db = await get_event_by_external_id( + "outlook-existing", "outlook", USER_ID, calendar_db_path + ) + assert updated_db is not None + assert updated_db.title == "Updated Title" + + # Delta token updated + assert config.sync_token == "new-token" + + +# --------------------------------------------------------------------------- +# Push tests +# --------------------------------------------------------------------------- + + +async def test_outlook_provider_push_creates_remote_event( + calendar_db_path: Path, +) -> None: + """Local event with no external_id → POST creates remote, ID stored.""" + local = make_local_event(event_id="evt-1", title="New Local Event", external_id=None) + await insert_event(local, calendar_db_path) + + mock_client = MockOutlookClient() + mock_client.add_response( + MockResponse(201, {"id": "remote-uid-1", "subject": "New Local Event"}) + ) + + provider = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_client) + ) + config = make_config() + + events = await list_events(USER_ID, db_path=calendar_db_path) + pushed = await provider.push_changes(config, events) + + assert len(pushed) == 1 + assert pushed[0].external_id == "remote-uid-1" + assert pushed[0].external_provider == "outlook" + + # Verify POST was called to /me/events + req = mock_client.requests[0] + assert req["method"] == "POST" + assert "/me/events" in req["url"] + assert req["json"]["subject"] == "New Local Event" + + # DB updated with external_id + db_event = await get_event_by_external_id("remote-uid-1", "outlook", USER_ID, calendar_db_path) + assert db_event is not None + + +async def test_outlook_provider_push_updates_remote_event( + calendar_db_path: Path, +) -> None: + """Local event with external_id → PATCH updates remote.""" + local = make_local_event( + event_id="evt-1", + title="Updated Local Event", + external_id="existing-uid", + last_modified="2026-06-01T00:00:00+00:00", + ) + await insert_event(local, calendar_db_path) + + mock_client = MockOutlookClient() + mock_client.add_response(MockResponse(200, {"id": "existing-uid"})) + + provider = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_client) + ) + config = make_config() + + events = await list_events(USER_ID, db_path=calendar_db_path) + pushed = await provider.push_changes(config, events) + + assert len(pushed) == 1 + assert pushed[0].external_id == "existing-uid" + + # Verify PATCH was called to /me/events/{id} + req = mock_client.requests[0] + assert req["method"] == "PATCH" + assert "/me/events/existing-uid" in req["url"] + assert req["json"]["subject"] == "Updated Local Event" + + +# --------------------------------------------------------------------------- +# Token refresh test +# --------------------------------------------------------------------------- + + +async def test_outlook_token_refresh_on_401(calendar_db_path: Path) -> None: + """Mock 401 → refresh token used → request retried with new token.""" + mock_client = MockOutlookClient() + # 1. First GET → 401 (token expired) + mock_client.add_response(MockResponse(401, {"error": "token expired"})) + # 2. Token refresh POST → 200 with new tokens + mock_client.add_response( + MockResponse( + 200, + { + "access_token": "new-token", + "refresh_token": "new-refresh", + "expires_in": 3600, + }, + ) + ) + # 3. Retry GET → 200 with events + mock_client.add_response( + MockResponse( + 200, + { + "value": [make_outlook_event(eid="outlook-1", subject="Refreshed Event")], + "@odata.deltaLink": ( + "https://graph.microsoft.com/v1.0/me/calendarView/delta" + "?$deltaToken=after-refresh" + ), + }, + ) + ) + + provider = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_client) + ) + config = make_config(access_token="expired-token") + + pulled = await provider.pull_changes(config) + + assert len(pulled) == 1 + assert pulled[0].title == "Refreshed Event" + + # 3 requests: GET (401), POST (refresh), GET (retry) + assert len(mock_client.requests) == 3 + assert mock_client.requests[0]["method"] == "GET" + assert mock_client.requests[1]["method"] == "POST" + assert mock_client.requests[2]["method"] == "GET" + + # Token refresh hit the Azure AD token endpoint + assert "login.microsoftonline.com" in mock_client.requests[1]["url"] + assert mock_client.requests[1]["data"]["grant_type"] == "refresh_token" + assert mock_client.requests[1]["data"]["refresh_token"] == "test-refresh" + + # Credentials updated in config + creds = json.loads(config.credentials) + assert creds["access_token"] == "new-token" + assert creds["refresh_token"] == "new-refresh" + + # Retry used the new access token + retry_headers = mock_client.requests[2]["headers"] + assert retry_headers["Authorization"] == "Bearer new-token" + + +# --------------------------------------------------------------------------- +# Conflict test +# --------------------------------------------------------------------------- + + +async def test_outlook_conflict_last_write_wins(calendar_db_path: Path) -> None: + """Both sides modified, local is newer → local wins, local not updated.""" + local = make_local_event( + event_id="evt-1", + title="Local Updated", + external_id="outlook-1", + last_modified="2026-06-15T00:00:00+00:00", + ) + await insert_event(local, calendar_db_path) + + remote = make_outlook_event( + eid="outlook-1", + subject="Remote Older", + last_modified="2026-06-01T00:00:00Z", # older than local + ) + response = MockResponse(200, {"value": [remote]}) + mock_client = MockOutlookClient() + mock_client.add_response(response) + + provider = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_client) + ) + config = make_config() + + pulled = await provider.pull_changes(config) + + # Local wins → no update, pulled is empty + assert len(pulled) == 0 + db_event = await get_event_by_external_id("outlook-1", "outlook", USER_ID, calendar_db_path) + assert db_event is not None + assert db_event.title == "Local Updated" + + +# --------------------------------------------------------------------------- +# RRULE roundtrip test +# --------------------------------------------------------------------------- + + +async def test_outlook_rrule_roundtrip(calendar_db_path: Path) -> None: + """Recurring event synced → RRULE preserved in both pull and push.""" + recurrence = { + "pattern": { + "type": "weekly", + "interval": 1, + "daysOfWeek": ["monday"], + "numberOfOccurrences": 4, + }, + "range": { + "type": "numbered", + "startDate": "2026-07-01", + "numberOfOccurrences": 4, + }, + } + remote = make_outlook_event( + eid="outlook-rrule", subject="Weekly Meeting", recurrence=recurrence + ) + response = MockResponse( + 200, + { + "value": [remote], + "@odata.deltaLink": ( + "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltaToken=rrule-token" + ), + }, + ) + mock_client = MockOutlookClient() + mock_client.add_response(response) + + provider = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_client) + ) + config = make_config() + + # Pull: verify RRULE preserved + pulled = await provider.pull_changes(config) + assert len(pulled) == 1 + assert pulled[0].rrule is not None + assert "FREQ=WEEKLY" in pulled[0].rrule + assert "BYDAY=MO" in pulled[0].rrule + assert "COUNT=4" in pulled[0].rrule + + # Push: verify recurrence pattern in request body sent to remote + events = await list_events(USER_ID, db_path=calendar_db_path) + mock_client2 = MockOutlookClient() + mock_client2.add_response( + MockResponse(201, {"id": "outlook-rrule", "subject": "Weekly Meeting"}) + ) + provider2 = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_client2) + ) + await provider2.push_changes(config, events) + + req = mock_client2.requests[0] + assert req["method"] == "PATCH" + assert "/me/events/outlook-rrule" in req["url"] + body = req["json"] + assert "recurrence" in body + assert body["recurrence"]["pattern"]["type"] == "weekly" + assert body["recurrence"]["pattern"]["daysOfWeek"] == ["monday"] + assert body["recurrence"]["pattern"]["numberOfOccurrences"] == 4 + + +# --------------------------------------------------------------------------- +# test_connection tests +# --------------------------------------------------------------------------- + + +async def test_outlook_test_connection_success(calendar_db_path: Path) -> None: + """Mock Graph /me returns user profile → test_connection() returns (True, '').""" + mock_client = MockOutlookClient() + mock_client.add_response(MockResponse(200, {"id": "user-id", "displayName": "Test User"})) + + provider = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_client) + ) + config = make_config() + + ok, msg = await provider.test_connection(config) + assert ok is True + assert msg == "" + + # Verify GET /me was called + req = mock_client.requests[0] + assert req["method"] == "GET" + assert "/me" in req["url"] From 8350b02d751e1e77b608748c344ff6120d40f17f Mon Sep 17 00:00:00 2001 From: chiguyong Date: Tue, 23 Jun 2026 23:50:28 +0800 Subject: [PATCH 10/16] feat(calendar): U10 frontend calendar views with 3 view modes and drawer --- .../server/frontend/package-lock.json | 65 ++++ src/agentkit/server/frontend/package.json | 4 + .../components/calendar/CalendarDrawer.vue | 94 ++++++ .../src/components/calendar/CalendarGrid.vue | 96 ++++++ .../src/components/calendar/CalendarPanel.vue | 299 ++++++++++++++++++ .../src/components/calendar/CardView.vue | 213 +++++++++++++ .../src/components/calendar/EventBadge.vue | 54 ++++ .../src/components/calendar/ListView.vue | 144 +++++++++ .../src/components/layout/AgentLayout.vue | 6 + .../components/layout/tabs/CalendarTab.vue | 7 + 10 files changed, 982 insertions(+) create mode 100644 src/agentkit/server/frontend/src/components/calendar/CalendarDrawer.vue create mode 100644 src/agentkit/server/frontend/src/components/calendar/CalendarGrid.vue create mode 100644 src/agentkit/server/frontend/src/components/calendar/CalendarPanel.vue create mode 100644 src/agentkit/server/frontend/src/components/calendar/CardView.vue create mode 100644 src/agentkit/server/frontend/src/components/calendar/EventBadge.vue create mode 100644 src/agentkit/server/frontend/src/components/calendar/ListView.vue create mode 100644 src/agentkit/server/frontend/src/components/layout/tabs/CalendarTab.vue diff --git a/src/agentkit/server/frontend/package-lock.json b/src/agentkit/server/frontend/package-lock.json index e740af0..3f609c9 100644 --- a/src/agentkit/server/frontend/package-lock.json +++ b/src/agentkit/server/frontend/package-lock.json @@ -9,6 +9,10 @@ "version": "0.1.0", "dependencies": { "@ant-design/icons-vue": "^7.0.0", + "@fullcalendar/daygrid": "^6.1.0", + "@fullcalendar/interaction": "^6.1.0", + "@fullcalendar/timegrid": "^6.1.0", + "@fullcalendar/vue3": "^6.1.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-shell": "^2", "@vue-flow/background": "^1.3.0", @@ -533,6 +537,56 @@ "node": ">=12" } }, + "node_modules/@fullcalendar/core": { + "version": "6.1.21", + "resolved": "https://registry.npmmirror.com/@fullcalendar/core/-/core-6.1.21.tgz", + "integrity": "sha512-t3u/+sqh3Iq7TWtUnVLcGDUE6OWZh0UD3c04bI/l7lSLAgAKr3kngBmhHiQD1QXpwC8ZN5iNqG7a7gOVixhSKQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "preact": "~10.12.1" + } + }, + "node_modules/@fullcalendar/daygrid": { + "version": "6.1.21", + "resolved": "https://registry.npmmirror.com/@fullcalendar/daygrid/-/daygrid-6.1.21.tgz", + "integrity": "sha512-QYb1y40RGYLlOxKpYWg8O+7njEnKnFG8Tt7qjnubJGR35s1phQg67E+81y2TyAbbm59p2JFOCXGDk9t6KDujIA==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.21" + } + }, + "node_modules/@fullcalendar/interaction": { + "version": "6.1.21", + "resolved": "https://registry.npmmirror.com/@fullcalendar/interaction/-/interaction-6.1.21.tgz", + "integrity": "sha512-WPYpqtljDWmU0Xm2cOtFrLlocgxv7cgkOppj34Q6OUUat8a6Cnd6kYo2JR+irP223PE5lBYHFNp1qh7SIpJc0w==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.21" + } + }, + "node_modules/@fullcalendar/timegrid": { + "version": "6.1.21", + "resolved": "https://registry.npmmirror.com/@fullcalendar/timegrid/-/timegrid-6.1.21.tgz", + "integrity": "sha512-2DnShx/jallGmb8QCkr6pAOu/zuPhJrP7+uTrAtSnbqsX7GF3lTxqSeNGkTQwsgF5g/ia8udhQ+JNYaE+TN1cQ==", + "license": "MIT", + "dependencies": { + "@fullcalendar/daygrid": "~6.1.21" + }, + "peerDependencies": { + "@fullcalendar/core": "~6.1.21" + } + }, + "node_modules/@fullcalendar/vue3": { + "version": "6.1.21", + "resolved": "https://registry.npmmirror.com/@fullcalendar/vue3/-/vue3-6.1.21.tgz", + "integrity": "sha512-OGt6WSC+/zz/ej6a0KfIBNl7BYuGchpZU49SsedYyv3WZWbghAE+D8YD6nhH1ia/I4p5Gcsv/nEXgEkT/I8aYQ==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.21", + "vue": "^3.0.11" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2585,6 +2639,17 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.12.1", + "resolved": "https://registry.npmmirror.com/preact/-/preact-10.12.1.tgz", + "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz", diff --git a/src/agentkit/server/frontend/package.json b/src/agentkit/server/frontend/package.json index 0003f31..49e0492 100644 --- a/src/agentkit/server/frontend/package.json +++ b/src/agentkit/server/frontend/package.json @@ -18,6 +18,10 @@ }, "dependencies": { "@ant-design/icons-vue": "^7.0.0", + "@fullcalendar/daygrid": "^6.1.0", + "@fullcalendar/interaction": "^6.1.0", + "@fullcalendar/timegrid": "^6.1.0", + "@fullcalendar/vue3": "^6.1.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-shell": "^2", "@vue-flow/background": "^1.3.0", diff --git a/src/agentkit/server/frontend/src/components/calendar/CalendarDrawer.vue b/src/agentkit/server/frontend/src/components/calendar/CalendarDrawer.vue new file mode 100644 index 0000000..10f0033 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/calendar/CalendarDrawer.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/calendar/CalendarGrid.vue b/src/agentkit/server/frontend/src/components/calendar/CalendarGrid.vue new file mode 100644 index 0000000..1555f52 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/calendar/CalendarGrid.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/calendar/CalendarPanel.vue b/src/agentkit/server/frontend/src/components/calendar/CalendarPanel.vue new file mode 100644 index 0000000..e2cf048 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/calendar/CalendarPanel.vue @@ -0,0 +1,299 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/calendar/CardView.vue b/src/agentkit/server/frontend/src/components/calendar/CardView.vue new file mode 100644 index 0000000..e7addc3 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/calendar/CardView.vue @@ -0,0 +1,213 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/calendar/EventBadge.vue b/src/agentkit/server/frontend/src/components/calendar/EventBadge.vue new file mode 100644 index 0000000..4bc2d01 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/calendar/EventBadge.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/calendar/ListView.vue b/src/agentkit/server/frontend/src/components/calendar/ListView.vue new file mode 100644 index 0000000..f4e4ce8 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/calendar/ListView.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/layout/AgentLayout.vue b/src/agentkit/server/frontend/src/components/layout/AgentLayout.vue index 1b83ed0..3fb6842 100644 --- a/src/agentkit/server/frontend/src/components/layout/AgentLayout.vue +++ b/src/agentkit/server/frontend/src/components/layout/AgentLayout.vue @@ -74,6 +74,9 @@ + @@ -104,6 +107,7 @@ import { AppstoreOutlined, SettingOutlined, DesktopOutlined, + CalendarOutlined, } from '@ant-design/icons-vue' import { useChatStore } from '@/stores/chat' import TopNav from './TopNav.vue' @@ -121,6 +125,7 @@ const KnowledgeBaseView = defineAsyncComponent(() => import('@/views/KnowledgeBa const EvolutionView = defineAsyncComponent(() => import('@/views/EvolutionView.vue')) const SkillsView = defineAsyncComponent(() => import('@/views/SkillsView.vue')) const SettingsView = defineAsyncComponent(() => import('@/views/SettingsView.vue')) +const CalendarTab = defineAsyncComponent(() => import('./tabs/CalendarTab.vue')) const route = useRoute() const chatStore = useChatStore() @@ -152,6 +157,7 @@ const topRightTabs: QuadrantTab[] = [ const bottomRightTabs: QuadrantTab[] = [ { key: 'monitor', label: '监控', icon: DashboardOutlined as Component }, { key: 'skills', label: '技能', icon: AppstoreOutlined as Component }, + { key: 'calendar', label: '日历', icon: CalendarOutlined as Component }, { key: 'settings', label: '设置', icon: SettingOutlined as Component }, ] diff --git a/src/agentkit/server/frontend/src/components/layout/tabs/CalendarTab.vue b/src/agentkit/server/frontend/src/components/layout/tabs/CalendarTab.vue new file mode 100644 index 0000000..3c8fa1c --- /dev/null +++ b/src/agentkit/server/frontend/src/components/layout/tabs/CalendarTab.vue @@ -0,0 +1,7 @@ + + + From 3131769aed62362a6ac8b52c439cfc0d0ebbdd94 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Wed, 24 Jun 2026 05:02:12 +0800 Subject: [PATCH 11/16] feat(calendar): U11 event editor, invitation manager and batch operations --- .../src/components/calendar/CalendarPanel.vue | 62 ++-- .../src/components/calendar/EventEditor.vue | 332 ++++++++++++++++++ .../components/calendar/InvitationManager.vue | 275 +++++++++++++++ .../src/components/calendar/ListView.vue | 106 +++++- .../server/frontend/src/stores/calendar.ts | 17 + 5 files changed, 766 insertions(+), 26 deletions(-) create mode 100644 src/agentkit/server/frontend/src/components/calendar/EventEditor.vue create mode 100644 src/agentkit/server/frontend/src/components/calendar/InvitationManager.vue diff --git a/src/agentkit/server/frontend/src/components/calendar/CalendarPanel.vue b/src/agentkit/server/frontend/src/components/calendar/CalendarPanel.vue index e2cf048..ec3dc5e 100644 --- a/src/agentkit/server/frontend/src/components/calendar/CalendarPanel.vue +++ b/src/agentkit/server/frontend/src/components/calendar/CalendarPanel.vue @@ -68,17 +68,31 @@ @edit="onEdit" @manage-invitations="onManageInvitations" /> + + + + + + diff --git a/src/agentkit/server/frontend/src/components/calendar/InvitationManager.vue b/src/agentkit/server/frontend/src/components/calendar/InvitationManager.vue new file mode 100644 index 0000000..5ad888f --- /dev/null +++ b/src/agentkit/server/frontend/src/components/calendar/InvitationManager.vue @@ -0,0 +1,275 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/calendar/ListView.vue b/src/agentkit/server/frontend/src/components/calendar/ListView.vue index f4e4ce8..4c84be3 100644 --- a/src/agentkit/server/frontend/src/components/calendar/ListView.vue +++ b/src/agentkit/server/frontend/src/components/calendar/ListView.vue @@ -1,5 +1,39 @@