diff --git a/python/tests/tools/test_google_calendar.py b/python/tests/tools/test_google_calendar.py new file mode 100644 index 00000000..3d961908 --- /dev/null +++ b/python/tests/tools/test_google_calendar.py @@ -0,0 +1,229 @@ +"""Unit tests for Google Calendar tools (httpx mocked).""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from pydantic import SecretStr +from timbal.tools.google_calendar import ( + GoogleCalendarCreateEvent, + GoogleCalendarUpdateEvent, + _meet_create_request, +) + + +def _mock_httpx_context(mock_client: MagicMock) -> MagicMock: + cm = MagicMock() + cm.__aenter__ = AsyncMock(return_value=mock_client) + cm.__aexit__ = AsyncMock(return_value=False) + return cm + + +def _response(payload: dict) -> MagicMock: + response = MagicMock() + response.raise_for_status = MagicMock() + response.json.return_value = payload + return response + + +def test_meet_create_request_is_unique_per_call(): + first = _meet_create_request()["createRequest"] + second = _meet_create_request()["createRequest"] + + assert first["conferenceSolutionKey"] == {"type": "hangoutsMeet"} + # A repeated requestId makes Google hand back the conference it already + # created, which is how every event ends up sharing one room. + assert first["requestId"] != second["requestId"] + + +@pytest.mark.asyncio +async def test_create_event_without_meet_sends_no_conference_data(): + mock_client = MagicMock() + mock_client.post = AsyncMock(return_value=_response({"id": "evt1"})) + + with patch("httpx.AsyncClient", return_value=_mock_httpx_context(mock_client)): + tool = GoogleCalendarCreateEvent(token=SecretStr("token")) + out = await tool.handler( + summary="Sync", + start="2026-01-01T10:00:00Z", + end="2026-01-01T10:30:00Z", + calendar_id="primary", + description=None, + location=None, + attendees=None, + timezone="UTC", + add_google_meet=False, + ) + + assert out["id"] == "evt1" + call = mock_client.post.await_args + assert "conferenceData" not in call.kwargs["json"] + assert call.kwargs["params"] == {} + + +@pytest.mark.asyncio +async def test_create_event_with_meet_requests_a_new_conference(): + mock_client = MagicMock() + mock_client.post = AsyncMock( + return_value=_response( + { + "id": "evt1", + "hangoutLink": "https://meet.google.com/abc-defg-hij", + "conferenceData": {"status": {"statusCode": "success"}}, + } + ) + ) + mock_client.get = AsyncMock() + + with patch("httpx.AsyncClient", return_value=_mock_httpx_context(mock_client)): + tool = GoogleCalendarCreateEvent(token=SecretStr("token")) + out = await tool.handler( + summary="Acme <> Timbal AI", + start="2026-01-01T10:00:00Z", + end="2026-01-01T10:30:00Z", + calendar_id="primary", + description=None, + location=None, + attendees=["prospect@acme.com"], + timezone="Europe/Madrid", + add_google_meet=True, + ) + + assert out["hangoutLink"] == "https://meet.google.com/abc-defg-hij" + + call = mock_client.post.await_args + # The opt-in is a query parameter; without it Google drops conferenceData + # from the body and the event comes back with no link at all. + assert call.kwargs["params"]["conferenceDataVersion"] == 1 + create_request = call.kwargs["json"]["conferenceData"]["createRequest"] + assert create_request["conferenceSolutionKey"] == {"type": "hangoutsMeet"} + assert create_request["requestId"] + + # The link was already there, so no re-read. + mock_client.get.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_create_event_polls_until_the_conference_is_minted(): + pending = _response({"id": "evt1", "conferenceData": {"status": {"statusCode": "pending"}}}) + ready = _response( + { + "id": "evt1", + "hangoutLink": "https://meet.google.com/xyz-uvwx-yz", + "conferenceData": {"status": {"statusCode": "success"}}, + } + ) + + mock_client = MagicMock() + mock_client.post = AsyncMock(return_value=pending) + mock_client.get = AsyncMock(return_value=ready) + + with ( + patch("httpx.AsyncClient", return_value=_mock_httpx_context(mock_client)), + patch("asyncio.sleep", new=AsyncMock()), + ): + tool = GoogleCalendarCreateEvent(token=SecretStr("token")) + out = await tool.handler( + summary="Acme <> Timbal AI", + start="2026-01-01T10:00:00Z", + end="2026-01-01T10:30:00Z", + calendar_id="primary", + description=None, + location=None, + attendees=None, + timezone="UTC", + add_google_meet=True, + ) + + assert out["hangoutLink"] == "https://meet.google.com/xyz-uvwx-yz" + assert mock_client.get.await_count == 1 + assert mock_client.get.await_args.kwargs["params"]["conferenceDataVersion"] == 1 + + +@pytest.mark.asyncio +async def test_create_event_stops_polling_when_the_conference_failed(): + failed = _response({"id": "evt1", "conferenceData": {"status": {"statusCode": "failure"}}}) + + mock_client = MagicMock() + mock_client.post = AsyncMock(return_value=failed) + mock_client.get = AsyncMock() + + with ( + patch("httpx.AsyncClient", return_value=_mock_httpx_context(mock_client)), + patch("asyncio.sleep", new=AsyncMock()), + ): + tool = GoogleCalendarCreateEvent(token=SecretStr("token")) + out = await tool.handler( + summary="Sync", + start="2026-01-01T10:00:00Z", + end="2026-01-01T10:30:00Z", + calendar_id="primary", + description=None, + location=None, + attendees=None, + timezone="UTC", + add_google_meet=True, + ) + + # The event still exists; it simply has no link. Callers decide what to do. + assert out["id"] == "evt1" + assert "hangoutLink" not in out + mock_client.get.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_update_event_can_attach_a_conference(): + mock_client = MagicMock() + mock_client.patch = AsyncMock( + return_value=_response( + { + "id": "evt1", + "hangoutLink": "https://meet.google.com/abc-defg-hij", + "conferenceData": {"status": {"statusCode": "success"}}, + } + ) + ) + + with patch("httpx.AsyncClient", return_value=_mock_httpx_context(mock_client)): + tool = GoogleCalendarUpdateEvent(token=SecretStr("token")) + out = await tool.handler( + event_id="evt1", + calendar_id="primary", + summary=None, + start="2026-01-02T10:00:00Z", + end="2026-01-02T10:30:00Z", + description=None, + location=None, + timezone="Europe/Madrid", + add_google_meet=True, + ) + + assert out["hangoutLink"] == "https://meet.google.com/abc-defg-hij" + call = mock_client.patch.await_args + assert call.kwargs["params"]["conferenceDataVersion"] == 1 + assert call.kwargs["json"]["conferenceData"]["createRequest"]["requestId"] + + +@pytest.mark.asyncio +async def test_update_event_leaves_an_existing_conference_alone(): + mock_client = MagicMock() + mock_client.patch = AsyncMock(return_value=_response({"id": "evt1"})) + + with patch("httpx.AsyncClient", return_value=_mock_httpx_context(mock_client)): + tool = GoogleCalendarUpdateEvent(token=SecretStr("token")) + await tool.handler( + event_id="evt1", + calendar_id="primary", + summary="Moved", + start=None, + end=None, + description=None, + location=None, + timezone=None, + add_google_meet=False, + ) + + call = mock_client.patch.await_args + # PATCH leaves unspecified fields untouched, so a plain reschedule must not + # send conferenceData — the event keeps whatever link it already had. + assert "conferenceData" not in call.kwargs["json"] + assert call.kwargs["params"] == {} diff --git a/python/timbal/tools/google_calendar.py b/python/timbal/tools/google_calendar.py index 702847ed..89476655 100644 --- a/python/timbal/tools/google_calendar.py +++ b/python/timbal/tools/google_calendar.py @@ -1,3 +1,5 @@ +import asyncio +import uuid from typing import Annotated, Any from pydantic import Field, SecretStr @@ -7,6 +9,61 @@ _CALENDAR_BASE = "https://www.googleapis.com/calendar/v3" +# How long to keep re-reading an event whose conference is still being minted. +_CONFERENCE_POLL_DELAYS = (0.5, 1.0, 2.0) + + +def _meet_create_request() -> dict[str, Any]: + """Ask Google for a brand new Meet room. + + `requestId` has to differ per call: Google treats a repeated id as a retry of + the same request and hands back the conference it already made, so a constant + value would quietly share one room across every event. + """ + return { + "createRequest": { + "requestId": str(uuid.uuid4()), + "conferenceSolutionKey": {"type": "hangoutsMeet"}, + } + } + + +async def _await_conference( + client: Any, + token: str, + calendar_id: str, + event: dict[str, Any], +) -> dict[str, Any]: + """Re-read an event until Google has finished attaching its conference. + + Conferences are created asynchronously, so `events.insert` can answer with + `conferenceData.status.statusCode == "pending"` and no `hangoutLink` yet. The + link is the whole point of asking for a conference — it goes in front of a + human — so it is worth a few cheap reads rather than returning an event that + nobody can join. + """ + event_id = event.get("id") + if not event_id: + return event + + for delay in _CONFERENCE_POLL_DELAYS: + status = ((event.get("conferenceData") or {}).get("status") or {}).get("statusCode") + # Only `pending` will change. No status at all means Google never took the + # request — polling a calendar that cannot host Meet just wastes seconds. + if event.get("hangoutLink") or status != "pending": + break + + await asyncio.sleep(delay) + response = await client.get( + f"{_CALENDAR_BASE}/calendars/{calendar_id}/events/{event_id}", + headers={"Authorization": f"Bearer {token}"}, + params={"conferenceDataVersion": 1}, + ) + response.raise_for_status() + event = response.json() + + return event + async def _resolve_token(tool: Any) -> str: if isinstance(tool.integration, Integration): @@ -69,7 +126,7 @@ async def _list_events( class GoogleCalendarCreateEvent(Tool): name: str = "google_calendar_create_event" - description: str | None = "Create a new event in Google Calendar." + description: str | None = "Create a new event in Google Calendar, optionally with its own Google Meet link." integration: Annotated[str, Integration("google_calendar")] | None = None token: SecretStr | None = None @@ -89,6 +146,13 @@ async def _create_event( location: str | None = Field(None, description="Event location or venue."), attendees: list[str] | None = Field(None, description="List of attendee email addresses."), timezone: str = Field("UTC", description="Timezone for the event, e.g. 'America/New_York'"), + add_google_meet: bool = Field( + False, + description=( + "If true, create a Google Meet conference for this event and return its join " + "link in 'hangoutLink'. Each event gets its own room." + ), + ), ) -> Any: token = await _resolve_token(self) import httpx @@ -105,21 +169,34 @@ async def _create_event( if attendees: body["attendees"] = [{"email": email} for email in attendees] + # Conference data is ignored unless the request opts into it, and the + # opt-in is a query parameter rather than a body field. Both or neither. + params: dict[str, Any] = {} + if add_google_meet: + body["conferenceData"] = _meet_create_request() + params["conferenceDataVersion"] = 1 + async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=10.0)) as client: response = await client.post( f"{_CALENDAR_BASE}/calendars/{calendar_id}/events", headers={"Authorization": f"Bearer {token}"}, + params=params, json=body, ) response.raise_for_status() - return response.json() + event = response.json() + + if add_google_meet: + event = await _await_conference(client, token, calendar_id, event) + + return event super().__init__(handler=_create_event, **kwargs) class GoogleCalendarUpdateEvent(Tool): name: str = "google_calendar_update_event" - description: str | None = "Update an existing event in Google Calendar." + description: str | None = "Update an existing event in Google Calendar, optionally attaching a Google Meet link." integration: Annotated[str, Integration("google_calendar")] | None = None token: SecretStr | None = None @@ -141,6 +218,13 @@ async def _update_event( description: str | None = Field(None, description="Updated event description or notes."), location: str | None = Field(None, description="Updated event location or venue."), timezone: str | None = Field(None, description="Updated timezone for the event, e.g. 'America/New_York'"), + add_google_meet: bool = Field( + False, + description=( + "If true, attach a Google Meet conference to this event and return its join " + "link in 'hangoutLink'. Events that already have one keep it." + ), + ), ) -> Any: token = await _resolve_token(self) import httpx @@ -157,14 +241,25 @@ async def _update_event( if end: body["end"] = {"dateTime": end, "timeZone": timezone or "UTC"} + params: dict[str, Any] = {} + if add_google_meet: + body["conferenceData"] = _meet_create_request() + params["conferenceDataVersion"] = 1 + async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=10.0)) as client: response = await client.patch( f"{_CALENDAR_BASE}/calendars/{calendar_id}/events/{event_id}", headers={"Authorization": f"Bearer {token}"}, + params=params, json=body, ) response.raise_for_status() - return response.json() + event = response.json() + + if add_google_meet: + event = await _await_conference(client, token, calendar_id, event) + + return event super().__init__(handler=_update_event, **kwargs)