feat: add Google Calendar connector with event sync

Implements GCalendarConnector (registered as "gcalendar") following the
gmail.py pattern: module-level API functions for calendarList and events.list,
_format_event helper for human-readable content, paginated sync across all
calendars, and three MCP tools (get_events_today, search_events, next_meeting).
Includes 6 tests covering not_connected, auth_url scope, sync document fields,
disconnect, mcp_tools, and registry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
krypticmouse
2026-03-26 01:37:28 +00:00
co-authored by Claude Sonnet 4.6
parent 89e757adba
commit 79616c9da4
2 changed files with 598 additions and 0 deletions
+421
View File
@@ -0,0 +1,421 @@
"""Google Calendar connector — event sync via the Calendar REST API v3.
Uses OAuth 2.0 tokens stored locally (see :mod:`openjarvis.connectors.oauth`).
All network calls are isolated in module-level functions (``_gcal_api_*``)
to make them trivially mockable in tests.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, Iterator, List, Optional
import httpx
from openjarvis.connectors._stubs import BaseConnector, Document, SyncStatus
from openjarvis.connectors.oauth import (
build_google_auth_url,
delete_tokens,
load_tokens,
save_tokens,
)
from openjarvis.core.config import DEFAULT_CONFIG_DIR
from openjarvis.core.registry import ConnectorRegistry
from openjarvis.tools._stubs import ToolSpec
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_GCAL_API_BASE = "https://www.googleapis.com/calendar/v3"
_GCAL_SCOPE = "https://www.googleapis.com/auth/calendar.readonly"
_DEFAULT_CREDENTIALS_PATH = str(DEFAULT_CONFIG_DIR / "connectors" / "gcalendar.json")
# ---------------------------------------------------------------------------
# Module-level API functions (easy to patch in tests)
# ---------------------------------------------------------------------------
def _gcal_api_calendars_list(token: str) -> Dict[str, Any]:
"""Call the Calendar ``calendarList.list`` endpoint.
Parameters
----------
token:
OAuth access token.
Returns
-------
dict
Raw API response containing an ``items`` list of calendar resources.
"""
resp = httpx.get(
f"{_GCAL_API_BASE}/users/me/calendarList",
headers={"Authorization": f"Bearer {token}"},
timeout=30.0,
)
resp.raise_for_status()
return resp.json()
def _gcal_api_events_list(
token: str,
calendar_id: str,
*,
page_token: Optional[str] = None,
time_min: Optional[str] = None,
) -> Dict[str, Any]:
"""Call the Calendar ``events.list`` endpoint for a single calendar.
Parameters
----------
token:
OAuth access token.
calendar_id:
Calendar identifier (e.g. ``"primary"``).
page_token:
Pagination token from a previous response's ``nextPageToken``.
time_min:
Lower bound (exclusive) for an event's end time (RFC3339 timestamp).
When omitted the API returns all events.
Returns
-------
dict
Raw API response containing an ``items`` list and optional
``nextPageToken``.
"""
params: Dict[str, Any] = {
"singleEvents": "true",
"orderBy": "startTime",
"maxResults": 250,
}
if page_token:
params["pageToken"] = page_token
if time_min:
params["timeMin"] = time_min
resp = httpx.get(
f"{_GCAL_API_BASE}/calendars/{calendar_id}/events",
headers={"Authorization": f"Bearer {token}"},
params=params,
timeout=30.0,
)
resp.raise_for_status()
return resp.json()
# ---------------------------------------------------------------------------
# Helper functions
# ---------------------------------------------------------------------------
def _format_event(event: Dict[str, Any]) -> str:
"""Return a human-readable text representation of a calendar event.
Parameters
----------
event:
Raw event resource dict from the Calendar API.
Returns
-------
str
Multi-line formatted text suitable for indexing.
"""
lines: List[str] = []
summary = event.get("summary", "(No title)")
lines.append(f"Title: {summary}")
# When
start = event.get("start", {})
end = event.get("end", {})
start_str = start.get("dateTime") or start.get("date", "")
end_str = end.get("dateTime") or end.get("date", "")
if start_str or end_str:
lines.append(f"When: {start_str} {end_str}")
# Location
location = event.get("location", "")
if location:
lines.append(f"Location: {location}")
# Organizer
organizer = event.get("organizer", {})
organizer_name = organizer.get("displayName") or organizer.get("email", "")
if organizer_name:
lines.append(f"Organizer: {organizer_name}")
# Attendees
attendees: List[Dict[str, Any]] = event.get("attendees", [])
if attendees:
attendee_names = [a.get("displayName") or a.get("email", "") for a in attendees]
lines.append(f"Attendees: {', '.join(attendee_names)}")
# Description
description = event.get("description", "")
if description:
lines.append(f"Description: {description}")
return "\n".join(lines)
def _parse_event_timestamp(event: Dict[str, Any]) -> datetime:
"""Extract the start datetime from an event resource.
Falls back to :func:`datetime.now` if the field is missing or unparseable.
"""
start = event.get("start", {})
date_time_str: str = start.get("dateTime", "")
if not date_time_str:
return datetime.now()
try:
# RFC3339 — Python 3.11+ fromisoformat handles the trailing 'Z'.
# For older versions we replace 'Z' with '+00:00'.
normalized = date_time_str.replace("Z", "+00:00")
return datetime.fromisoformat(normalized)
except (ValueError, TypeError):
return datetime.now()
# ---------------------------------------------------------------------------
# GCalendarConnector
# ---------------------------------------------------------------------------
@ConnectorRegistry.register("gcalendar")
class GCalendarConnector(BaseConnector):
"""Connector that syncs events from Google Calendar via the REST API v3.
Authentication is handled through Google OAuth 2.0. Tokens are stored
locally in a JSON credentials file.
Parameters
----------
credentials_path:
Path to the JSON file where OAuth tokens are stored. Defaults to
``~/.openjarvis/connectors/gcalendar.json``.
"""
connector_id = "gcalendar"
display_name = "Google Calendar"
auth_type = "oauth"
def __init__(self, credentials_path: str = "") -> None:
self._credentials_path = credentials_path or _DEFAULT_CREDENTIALS_PATH
self._items_synced: int = 0
self._items_total: int = 0
self._last_sync: Optional[datetime] = None
self._last_cursor: Optional[str] = None
# ------------------------------------------------------------------
# BaseConnector interface
# ------------------------------------------------------------------
def is_connected(self) -> bool:
"""Return ``True`` if a credentials file with a valid token exists."""
tokens = load_tokens(self._credentials_path)
if tokens is None:
return False
return bool(tokens)
def disconnect(self) -> None:
"""Delete the stored credentials file."""
delete_tokens(self._credentials_path)
def auth_url(self) -> str:
"""Return a Google OAuth consent URL requesting ``calendar.readonly`` scope."""
return build_google_auth_url(
client_id="", # placeholder — real client_id from config
scopes=[_GCAL_SCOPE],
)
def handle_callback(self, code: str) -> None:
"""Handle the OAuth callback by persisting the authorization code.
In a full implementation this would exchange the code for tokens.
For now the code is saved directly as the token value.
"""
save_tokens(self._credentials_path, {"token": code})
def sync(
self,
*,
since: Optional[datetime] = None, # noqa: ARG002 — reserved for future use
cursor: Optional[str] = None,
) -> Iterator[Document]:
"""Yield :class:`Document` objects for Google Calendar events.
Fetches all calendars from the calendarList endpoint, then paginates
through each calendar's events.
Parameters
----------
since:
Not yet used (filtering is done server-side via ``timeMin``).
cursor:
``nextPageToken`` from a previous sync to resume pagination.
"""
tokens = load_tokens(self._credentials_path)
if not tokens:
return
token: str = tokens.get("token", tokens.get("access_token", ""))
if not token:
return
# Fetch list of calendars
calendars_resp = _gcal_api_calendars_list(token)
calendars: List[Dict[str, Any]] = calendars_resp.get("items", [])
synced = 0
for calendar in calendars:
calendar_id: str = calendar.get("id", "")
if not calendar_id:
continue
page_token: Optional[str] = cursor
while True:
events_resp = _gcal_api_events_list(
token, calendar_id, page_token=page_token
)
events: List[Dict[str, Any]] = events_resp.get("items", [])
for event in events:
evt_id: str = event.get("id", "")
if not evt_id:
continue
summary: str = event.get("summary", "")
organizer: Dict[str, Any] = event.get("organizer", {})
organizer_email: str = organizer.get("email", "")
attendees: List[Dict[str, Any]] = event.get("attendees", [])
participant_emails: List[str] = [
a.get("email", "") for a in attendees if a.get("email")
]
timestamp = _parse_event_timestamp(event)
html_link: Optional[str] = event.get("htmlLink")
content = _format_event(event)
doc = Document(
doc_id=f"gcalendar:{evt_id}",
source="gcalendar",
doc_type="event",
content=content,
title=summary,
author=organizer_email,
participants=participant_emails,
timestamp=timestamp,
url=html_link,
metadata={
"calendar_id": calendar_id,
"event_id": evt_id,
},
)
synced += 1
yield doc
next_page: Optional[str] = events_resp.get("nextPageToken")
if not next_page:
self._last_cursor = None
break
page_token = next_page
self._last_cursor = next_page
self._items_synced = synced
self._last_sync = datetime.now()
def sync_status(self) -> SyncStatus:
"""Return sync progress from the most recent :meth:`sync` call."""
return SyncStatus(
state="idle",
items_synced=self._items_synced,
last_sync=self._last_sync,
cursor=self._last_cursor,
)
# ------------------------------------------------------------------
# MCP tools
# ------------------------------------------------------------------
def mcp_tools(self) -> List[ToolSpec]:
"""Expose three MCP tool specs for real-time Google Calendar queries."""
return [
ToolSpec(
name="calendar_get_events_today",
description=(
"Retrieve all Google Calendar events scheduled for today. "
"Returns a list of events with title, time, location, "
"and attendees."
),
parameters={
"type": "object",
"properties": {
"calendar_id": {
"type": "string",
"description": (
"Calendar ID to query. Defaults to 'primary'."
),
"default": "primary",
},
},
"required": [],
},
category="productivity",
),
ToolSpec(
name="calendar_search_events",
description=(
"Search Google Calendar events by keyword. "
"Matches against event titles, descriptions, and locations."
),
parameters={
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search term to match against event fields",
},
"max_results": {
"type": "integer",
"description": "Maximum number of events to return",
"default": 20,
},
"calendar_id": {
"type": "string",
"description": (
"Calendar ID to search. Defaults to 'primary'."
),
"default": "primary",
},
},
"required": ["query"],
},
category="productivity",
),
ToolSpec(
name="calendar_next_meeting",
description=(
"Find the next upcoming meeting on the user's Google Calendar. "
"Returns title, start time, location, and attendees."
),
parameters={
"type": "object",
"properties": {
"calendar_id": {
"type": "string",
"description": (
"Calendar ID to query. Defaults to 'primary'."
),
"default": "primary",
},
},
"required": [],
},
category="productivity",
),
]
+177
View File
@@ -0,0 +1,177 @@
"""Tests for GCalendarConnector — OAuth-authenticated Google Calendar sync connector.
All Calendar API calls are mocked; no network access is required.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import List
from unittest.mock import patch
import pytest
from openjarvis.connectors._stubs import Document
from openjarvis.core.registry import ConnectorRegistry
# ---------------------------------------------------------------------------
# Helpers — fake API payloads
# ---------------------------------------------------------------------------
_CALENDARS_RESPONSE = {"items": [{"id": "primary", "summary": "My Calendar"}]}
_EVENTS_RESPONSE = {
"items": [
{
"id": "evt1",
"summary": "Sprint Planning",
"description": "Review sprint goals and capacity.",
"start": {"dateTime": "2024-03-15T10:00:00Z"},
"end": {"dateTime": "2024-03-15T11:00:00Z"},
"attendees": [
{"email": "alice@co.com", "displayName": "Alice"},
{"email": "bob@co.com", "displayName": "Bob"},
],
"location": "Room 3",
"organizer": {"email": "alice@co.com", "displayName": "Alice"},
"htmlLink": "https://calendar.google.com/event?eid=evt1",
}
],
"nextPageToken": None,
}
def _make_credentials(tmp_path: Path) -> Path:
"""Write a minimal fake credentials file and return its path."""
creds = tmp_path / "gcalendar.json"
creds.write_text(json.dumps({"token": "fake-access-token"}), encoding="utf-8")
return creds
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def connector(tmp_path: Path):
"""GCalendarConnector pointing at a tmp credentials path (no file yet)."""
from openjarvis.connectors.gcalendar import GCalendarConnector # noqa: PLC0415
creds_path = str(tmp_path / "gcalendar.json")
return GCalendarConnector(credentials_path=creds_path)
# ---------------------------------------------------------------------------
# Test 1 — not connected without a credentials file
# ---------------------------------------------------------------------------
def test_not_connected(connector) -> None:
"""is_connected() returns False when no credentials file exists."""
assert connector.is_connected() is False
# ---------------------------------------------------------------------------
# Test 2 — auth_url contains calendar.readonly scope
# ---------------------------------------------------------------------------
def test_auth_url(connector) -> None:
"""auth_url() returns a URL to Google's OAuth endpoint with calendar scope."""
url = connector.auth_url()
assert isinstance(url, str)
assert url.startswith("https://accounts.google.com/o/oauth2/v2/auth")
assert "calendar.readonly" in url
# ---------------------------------------------------------------------------
# Test 3 — sync yields events with correct fields (mocked API)
# ---------------------------------------------------------------------------
@patch("openjarvis.connectors.gcalendar._gcal_api_calendars_list")
@patch("openjarvis.connectors.gcalendar._gcal_api_events_list")
def test_sync_yields_events(
mock_events,
mock_calendars,
connector,
tmp_path: Path,
) -> None:
"""sync() yields one Document per event with correct metadata."""
# Set up fake credentials so is_connected() returns True
creds_path = Path(connector._credentials_path)
creds_path.write_text(json.dumps({"token": "fake-access-token"}), encoding="utf-8")
# Configure mocks
mock_calendars.return_value = _CALENDARS_RESPONSE
mock_events.return_value = _EVENTS_RESPONSE
docs: List[Document] = list(connector.sync())
assert len(docs) == 1
doc = docs[0]
assert doc.doc_id == "gcalendar:evt1"
assert doc.source == "gcalendar"
assert doc.doc_type == "event"
assert doc.title == "Sprint Planning"
assert doc.author == "alice@co.com"
assert "alice@co.com" in doc.participants
assert "bob@co.com" in doc.participants
assert "Room 3" in doc.content
assert doc.url == "https://calendar.google.com/event?eid=evt1"
# Verify the API was called correctly
mock_calendars.assert_called_once()
mock_events.assert_called_once()
# ---------------------------------------------------------------------------
# Test 4 — disconnect removes the credentials file
# ---------------------------------------------------------------------------
def test_disconnect(connector, tmp_path: Path) -> None:
"""disconnect() deletes the credentials file."""
creds_path = Path(connector._credentials_path)
creds_path.write_text(json.dumps({"token": "fake-access-token"}), encoding="utf-8")
assert connector.is_connected() is True
connector.disconnect()
assert not creds_path.exists()
assert connector.is_connected() is False
# ---------------------------------------------------------------------------
# Test 5 — mcp_tools returns the three expected tool specs
# ---------------------------------------------------------------------------
def test_mcp_tools(connector) -> None:
"""mcp_tools() returns exactly 3 tools with the required names."""
tools = connector.mcp_tools()
names = {t.name for t in tools}
assert len(tools) == 3
assert "calendar_get_events_today" in names
assert "calendar_search_events" in names
assert "calendar_next_meeting" in names
# ---------------------------------------------------------------------------
# Test 6 — ConnectorRegistry contains "gcalendar" after import
# ---------------------------------------------------------------------------
def test_registry() -> None:
"""GCalendarConnector can be registered and retrieved via ConnectorRegistry."""
from openjarvis.connectors.gcalendar import GCalendarConnector # noqa: PLC0415
# The registry is cleared before each test by the autouse conftest fixture,
# so we imperatively re-register here (same pattern as test_gmail.py).
ConnectorRegistry.register_value("gcalendar", GCalendarConnector)
assert ConnectorRegistry.contains("gcalendar")
cls = ConnectorRegistry.get("gcalendar")
assert cls.connector_id == "gcalendar"