mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-30 19:02:16 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
89e757adba
commit
79616c9da4
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user