feat: Auto-link conversations to Google Calendar events#7101
feat: Auto-link conversations to Google Calendar events#7101
Conversation
Rebased from #3747. - Auto-links conversations to overlapping Google Calendar events - Calendar event card on conversation detail (view, unlink, add summary, share) - Append conversation summaries to calendar event descriptions - One-tap email sharing to event attendees - Calendar integrations settings page
Greptile SummaryThis PR adds end-to-end Google Calendar integration: OAuth connection, automatic conversation→event linking during post-processing, manual event picker, and a detail sheet — across both the FastAPI backend and Flutter frontend. Many issues flagged in earlier review rounds (missing
Confidence Score: 3/5Not safe to merge as-is: one confirmed P1 (manual calendar link silently overwritten on reprocess) needs a one-line fix before merging. Multiple P1s from previous rounds have been addressed, leaving one new P1 (auto-link clobbers manual link on reprocess) plus several P2s. The single P1 is a narrow one-line fix, but it causes silent data loss in a user-visible feature. backend/utils/conversations/process_conversation.py — the auto-link guard; backend/routers/google_calendar.py — duplicate model definition. Important Files Changed
Sequence DiagramsequenceDiagram
participant App as Flutter App
participant Backend as FastAPI Backend
participant GCal as Google Calendar API
participant DB as Firestore
Note over App,DB: Auto-link flow (conversation end)
App->>Backend: POST /v1/conversations (or reprocess)
Backend->>DB: get_integration(uid, 'google_calendar')
DB-->>Backend: {access_token, connected}
Backend->>GCal: get_google_calendar_events(time_min, time_max)
GCal-->>Backend: [events]
Backend->>Backend: find best overlap (>=10s AND >=50%)
Backend->>DB: upsert_conversation(as_dict_cleaned_dates)
Note over App,DB: Manual link flow (user picks event)
App->>Backend: GET /v1/calendar/google/events?time_min=...
Backend->>GCal: get_google_calendar_events(...)
GCal-->>Backend: [events]
Backend-->>App: List[CalendarEventLink]
App->>Backend: POST /v1/conversations/{id}/calendar-event {event_id}
Backend->>GCal: get_google_calendar_event(event_id)
GCal-->>Backend: event
Backend->>DB: update_conversation({calendar_event: ...})
Backend-->>App: CalendarEventLink
Note over App,DB: Unlink flow
App->>Backend: DELETE /v1/conversations/{id}/calendar-event
Backend->>DB: update_conversation({calendar_event: None})
Backend-->>App: {status: Ok}
Reviews (8): Last reviewed commit: "fix: lower auto-link overlap threshold +..." | Re-trigger Greptile |
| raise HTTPException(status_code=400, detail="Could not parse calendar event times") | ||
|
|
||
| # Persist to Firestore | ||
| conversations_db.update_conversation(uid, conversation_id, {'calendar_event': calendar_event.dict()}) |
There was a problem hiding this comment.
.dict() is deprecated in Pydantic v2
.dict() was deprecated in Pydantic v2 in favour of .model_dump(). If this project runs on Pydantic v2 (which is now the default for new FastAPI installs), using .dict() raises a deprecation warning and may break in a future release. The same pattern appears on the auto-link endpoint (line 358) and on link_calendar_event (line 308).
| conversations_db.update_conversation(uid, conversation_id, {'calendar_event': calendar_event.dict()}) | |
| conversations_db.update_conversation(uid, conversation_id, {'calendar_event': calendar_event.model_dump()}) |
| def _get_google_calendar_token(uid: str) -> str: | ||
| """ | ||
| Get and validate Google Calendar access token for a user. | ||
| Raises HTTPException if not connected or token invalid. | ||
| """ | ||
| integration = users_db.get_integration(uid, 'google_calendar') | ||
| if not integration or not integration.get('connected'): | ||
| raise HTTPException(status_code=400, detail="Google Calendar not connected") | ||
|
|
||
| access_token = integration.get('access_token') | ||
| if not access_token: | ||
| raise HTTPException(status_code=400, detail="No access token found") | ||
|
|
||
| return access_token |
There was a problem hiding this comment.
_get_google_calendar_token is defined but never called
The helper on lines 113–126 is declared to centralise token retrieval, but the only endpoint in the file (list_google_calendar_events) re-implements the same two-step check inline (lines 146–152). The helper is dead code. Either use it in the endpoint or remove it to avoid confusion.
| def _extract_attendees(event: dict) -> tuple[list[str], list[str]]: | ||
| """Extract attendee names and emails from a Google Calendar event.""" | ||
| names = [] | ||
| emails = [] | ||
| for attendee in event.get('attendees', []): | ||
| if attendee.get('self', False): | ||
| continue | ||
| email = attendee.get('email', '') | ||
| name = attendee.get('displayName') or email | ||
| if name: | ||
| names.append(name) | ||
| if email: | ||
| emails.append(email) | ||
| return names, emails | ||
|
|
||
|
|
||
| def _parse_event_times(event: dict) -> tuple[Optional[datetime], Optional[datetime]]: | ||
| """Parse start and end times from a Google Calendar event.""" | ||
| start = event.get('start', {}) | ||
| end = event.get('end', {}) | ||
| try: | ||
| if 'dateTime' in start: | ||
| start_dt = datetime.fromisoformat(start['dateTime'].replace('Z', '+00:00')) | ||
| elif 'date' in start: | ||
| start_dt = datetime.fromisoformat(start['date'] + 'T00:00:00+00:00') | ||
| else: | ||
| return None, None | ||
|
|
||
| if 'dateTime' in end: | ||
| end_dt = datetime.fromisoformat(end['dateTime'].replace('Z', '+00:00')) | ||
| elif 'date' in end: | ||
| end_dt = datetime.fromisoformat(end['date'] + 'T23:59:59+00:00') | ||
| else: | ||
| return None, None | ||
|
|
||
| return start_dt, end_dt | ||
| except (ValueError, KeyError): | ||
| return None, None |
There was a problem hiding this comment.
Triplicated helper functions across three modules
_extract_attendees and _parse_event_times are copy-pasted identically in backend/routers/conversations.py (lines 202–239), backend/routers/google_calendar.py, and backend/utils/conversations/calendar_linking.py. Any bug fix or edge-case handling (e.g. timezone offsets, organisers with no email) must be applied in three places. These should be extracted into a single shared utility — e.g., backend/utils/conversations/calendar_utils.py — and imported where needed.
- P1: Fix unlinkCalendarEvent data loss — was reconstructing ServerConversation without audioFiles/starred fields; now delegates to _updateLocalConversationWithCalendarEvent(null) - P2: Extract _extract_attendees and _parse_event_times into shared calendar_utils.py; remove three-way duplication across conversations.py, google_calendar.py, and calendar_linking.py - P2: Replace deprecated .dict() with .model_dump() on CalendarEventLink in conversations.py (two call sites) - P2: Wire up the dead _get_google_calendar_token helper in google_calendar.py so the list endpoint uses it instead of re-implementing inline Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@greptileai review |
| context: context, | ||
| backgroundColor: Colors.transparent, | ||
| isScrollControlled: true, | ||
| builder: (context) => CalendarEventDetailsSheet(calendarEvent: calendarEvent), |
There was a problem hiding this comment.
CalendarEventDetailsSheet is undefined — app will not compile
CalendarEventDetailsSheet is instantiated here but is never defined anywhere in the repository. A grep of the entire codebase finds only this single reference with no corresponding class CalendarEventDetailsSheet declaration. The Flutter app will fail to build with a compilation error until the widget is implemented or the call is replaced.
| ) | ||
| if calendar_event: | ||
| # Override the conversation title with calendar event title | ||
| conversation.structured.title = calendar_event.title |
There was a problem hiding this comment.
AI-generated title is silently and permanently overwritten
conversation.structured.title = calendar_event.title replaces the AI-generated, content-specific title with the raw calendar event name for every conversation that overlaps any meeting. The AI title (e.g., "Debugging the payment flow") gets discarded in favour of a generic meeting name (e.g., "Weekly Standup"), and the overwrite is persisted to Firestore immediately after via upsert_conversation. There is no flag to opt out, and the folder-assignment step that follows already sees the replaced title. Consider storing the calendar link without touching the structured title, or making the title override opt-in.
- Add missing flutter/foundation.dart import for debugPrint in conversations.dart - Add missing CalendarEventDetailsSheet widget referenced in widgets.dart Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- P0: Implement CalendarEventDetailsSheet widget in widgets.dart — was referenced in _showCalendarEventDetails but never defined, causing a Flutter build failure. Shows event title, date/time, attendees, and an "Open in Google Calendar" button via url_launcher. - P1: Remove unconditional title override in process_conversation.py — AI-generated conversation title was being permanently replaced by the calendar event name on every auto-linked conversation. Now only calendar_event is attached; structured.title is left untouched. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove duplicate CalendarEventDetailsSheet and duplicate imports introduced by concurrent fix from Krushna — keep his cleaner version with safe-area padding and AM/PM formatting - P1: Don't overwrite AI-generated conversation title with calendar event name in process_conversation.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@greptileai review |
| void _updateLocalConversationWithCalendarEvent(CalendarEventLink? calendarEvent) { | ||
| if (_cachedConversation != null) { | ||
| final updatedConversation = ServerConversation( | ||
| id: _cachedConversation!.id, | ||
| createdAt: _cachedConversation!.createdAt, | ||
| structured: _cachedConversation!.structured, | ||
| startedAt: _cachedConversation!.startedAt, | ||
| finishedAt: _cachedConversation!.finishedAt, | ||
| transcriptSegments: _cachedConversation!.transcriptSegments, | ||
| appResults: _cachedConversation!.appResults, | ||
| suggestedSummarizationApps: _cachedConversation!.suggestedSummarizationApps, | ||
| geolocation: _cachedConversation!.geolocation, | ||
| photos: _cachedConversation!.photos, | ||
| audioFiles: _cachedConversation!.audioFiles, | ||
| discarded: _cachedConversation!.discarded, | ||
| deleted: _cachedConversation!.deleted, | ||
| source: _cachedConversation!.source, | ||
| language: _cachedConversation!.language, | ||
| externalIntegration: _cachedConversation!.externalIntegration, | ||
| calendarEvent: calendarEvent, | ||
| status: _cachedConversation!.status, | ||
| isLocked: _cachedConversation!.isLocked, | ||
| starred: _cachedConversation!.starred, | ||
| ); | ||
| _cachedConversation = updatedConversation; | ||
| conversationProvider?.updateConversation(updatedConversation); | ||
| } | ||
| notifyListeners(); | ||
| } |
There was a problem hiding this comment.
folderId and visibility silently reset on every calendar mutation
_updateLocalConversationWithCalendarEvent rebuilds ServerConversation from scratch but omits folderId and visibility. Both fields default to null / ConversationVisibility.private_ respectively, so every call to linkCalendarEvent, unlinkCalendarEvent, or autoLinkCalendarEvent will silently clear the conversation's folder assignment and reset its visibility to private in the local cache. The user won't see the correct state again until the view is refreshed from the server.
- P1: _updateLocalConversationWithCalendarEvent now forwards folderId and visibility — they were silently reset to null/private in the local Flutter cache on every calendar link/unlink/auto-link operation - P2: Replace print() with logger.error() for calendar linking errors in process_conversation.py - P2: Harden 401 detection across conversations.py, calendar_linking.py, and google_calendar.py — use "error 401" substring on lowercased message instead of bare "401" to avoid false positives Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@greptileai review |
| if (success) { | ||
| if (mounted) { | ||
| scaffoldMessenger.showSnackBar( | ||
| const SnackBar( | ||
| content: Text('Please complete authentication in your browser. Once done, return to the app.'), | ||
| duration: Duration(seconds: 5), | ||
| ), | ||
| ); | ||
| } | ||
| await _loadFromBackend(); | ||
| debugPrint('✓ Calendar integration enabled: ${app.displayName} (${app.key}) - authentication in progress'); |
There was a problem hiding this comment.
Missing
mounted guard before _loadFromBackend() after async gap
After await authenticate() returns, the widget may have been disposed if the user navigated away during the OAuth flow. The mounted check on line 125 only guards the SnackBar, but _loadFromBackend() (which calls context.read<IntegrationProvider>()) runs unconditionally. In debug mode this triggers a FlutterError; in release mode the behaviour is undefined.
| if (success) { | |
| if (mounted) { | |
| scaffoldMessenger.showSnackBar( | |
| const SnackBar( | |
| content: Text('Please complete authentication in your browser. Once done, return to the app.'), | |
| duration: Duration(seconds: 5), | |
| ), | |
| ); | |
| } | |
| await _loadFromBackend(); | |
| debugPrint('✓ Calendar integration enabled: ${app.displayName} (${app.key}) - authentication in progress'); | |
| if (success) { | |
| if (mounted) { | |
| scaffoldMessenger.showSnackBar( | |
| const SnackBar( | |
| content: Text('Please complete authentication in your browser. Once done, return to the app.'), | |
| duration: Duration(seconds: 5), | |
| ), | |
| ); | |
| await _loadFromBackend(); | |
| } | |
| debugPrint('✓ Calendar integration enabled: ${app.displayName} (${app.key}) - authentication in progress'); |
- P1: Guard _loadFromBackend() with mounted check in _handleAuthFlow — if user navigates away during OAuth the widget could be disposed, causing context.read to throw on an unmounted widget - P2: Narrow auth-error match in add_summary_to_calendar_event from "authentication" to "authentication failed" — consistent with all other endpoints in the PR, avoids spurious token refresh attempts - P2: Tighten calendar overlap criteria from OR to AND+OR — now requires the overlap to exceed 5 min AND cover ≥50% of either the event or the conversation duration, preventing a 5-min recording from attaching to a multi-hour all-day block via the absolute-seconds threshold alone Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@greptileai review |
| updates, | ||
| calendar_meetings, | ||
| google_calendar, | ||
| calendar_onboarding, |
There was a problem hiding this comment.
Missing
calendar_onboarding router file — backend will not start
calendar_onboarding is imported here and its router is mounted at line 105, but backend/routers/calendar_onboarding.py does not exist anywhere in the repository. Python will raise ModuleNotFoundError: No module named 'routers.calendar_onboarding' the moment the process starts, taking the entire backend down before it can serve any request.
Either create backend/routers/calendar_onboarding.py with a valid router = APIRouter(), or remove both the import and the app.include_router(calendar_onboarding.router) line until the file is ready.
- P0: Create backend/routers/calendar_onboarding.py — imported and mounted in main.py but never created, causing ModuleNotFoundError on every backend startup. Adds a minimal /v1/calendar/onboarding/status endpoint that returns whether the user has completed calendar setup. - P1: Use conversation.as_dict_cleaned_dates() instead of conversation.dict() for the Firestore upsert in process_conversation.py — ensures CalendarEventLink datetime fields (timezone-aware) are serialised to ISO strings consistently with all other datetime fields in the conversation document. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@greptileai review |
| end_dt = datetime.fromisoformat(end['dateTime'].replace('Z', '+00:00')) | ||
| elif 'date' in end: |
There was a problem hiding this comment.
All-day event end date is off by one day
Google Calendar API all-day event end.date is the exclusive end (e.g., a one-day event on June 1 has end.date = 2024-06-02). Using T23:59:59+00:00 on the exclusive end date makes every all-day event appear to end ~24 hours later than it actually does. This inflates the overlap window and can spuriously link a conversation to a meeting that has not started yet.
Correct approach: subtract one second from midnight of the exclusive end date:
end_dt = datetime.fromisoformat(end['date'] + 'T00:00:00+00:00') - timedelta(seconds=1)(requires importing timedelta from datetime)
…' into atlas/calendar-conversation-link
…sistence - calendar_utils.py: fix all-day event end.date handling — Google Calendar end.date is exclusive, so subtract 1 second from midnight of that day - conversations.py: add mode='json' to model_dump() in both link and auto-link endpoints to serialize datetimes as ISO strings for Firestore - calendar_onboarding.py: persist skip state via set_integration instead of returning a no-op response; status endpoint reflects skipped state - conversation_detail_provider.dart: remove redundant aliased import of conversations.dart
|
@greptileai review |
|
Want your agent to iterate on Greptile's feedback? Try greploops. |
| events = get_google_calendar_events( | ||
| access_token=access_token, | ||
| time_min=search_start, | ||
| time_max=search_end, | ||
| max_results=20, | ||
| ) | ||
| except Exception as e: | ||
| error_msg = str(e) | ||
| if "error 401" in error_msg.lower() or "authentication failed" in error_msg.lower(): | ||
| new_token = refresh_google_token(uid, integration) | ||
| if new_token: | ||
| try: | ||
| events = get_google_calendar_events( | ||
| access_token=new_token, | ||
| time_min=search_start, | ||
| time_max=search_end, | ||
| max_results=20, | ||
| ) | ||
| except Exception: | ||
| return None | ||
| else: | ||
| return None | ||
| else: | ||
| return None |
There was a problem hiding this comment.
Async functions called without
await — auto-linking silently fails
get_google_calendar_events and refresh_google_token are both declared as async def in calendar_tools.py and google_utils.py respectively. Calling them without await inside the synchronous get_overlapping_calendar_event returns a coroutine object, not the actual result.
The coroutine is truthy, so if not events: passes. The subsequent for event in events: then raises TypeError: 'coroutine' object is not iterable. This is caught by the outer except Exception as e: block, and since "error 401" is not in the TypeError message, it falls through to else: return None. The result is that calendar auto-linking silently returns None for every conversation, regardless of whether a matching event exists.
The function must be made async (and have callers updated accordingly), or the API calls need to be run via asyncio.run() if the call site is truly synchronous.
| /// Unlinks the calendar event from the current conversation | ||
| Future<bool> unlinkCalendarEvent() async { | ||
| try { | ||
| final success = await unlinkCalendarEvent(conversation.id); | ||
| if (success) { | ||
| _updateLocalConversationWithCalendarEvent(null); | ||
| notifyListeners(); | ||
| return true; | ||
| } | ||
| return false; | ||
| } catch (e) { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| /// Adds conversation summary to the linked calendar event and returns the event link | ||
| Future<String?> addSummaryToCalendarEvent() async { | ||
| try { | ||
| final htmlLink = await addSummaryToCalendarEvent(conversation.id); | ||
| return htmlLink; | ||
| } catch (e) { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /// Helper method to update the local conversation state with a calendar event | ||
| void _updateLocalConversationWithCalendarEvent(CalendarEventLink? calendarEvent) { | ||
| if (_cachedConversation != null) { | ||
| final updatedConversation = ServerConversation( | ||
| id: _cachedConversation!.id, | ||
| createdAt: _cachedConversation!.createdAt, | ||
| structured: _cachedConversation!.structured, | ||
| startedAt: _cachedConversation!.startedAt, | ||
| finishedAt: _cachedConversation!.finishedAt, | ||
| transcriptSegments: _cachedConversation!.transcriptSegments, | ||
| appResults: _cachedConversation!.appResults, | ||
| suggestedSummarizationApps: _cachedConversation!.suggestedSummarizationApps, | ||
| geolocation: _cachedConversation!.geolocation, | ||
| photos: _cachedConversation!.photos, | ||
| audioFiles: _cachedConversation!.audioFiles, | ||
| discarded: _cachedConversation!.discarded, | ||
| deleted: _cachedConversation!.deleted, | ||
| source: _cachedConversation!.source, | ||
| language: _cachedConversation!.language, | ||
| externalIntegration: _cachedConversation!.externalIntegration, | ||
| calendarEvent: calendarEvent, | ||
| status: _cachedConversation!.status, | ||
| isLocked: _cachedConversation!.isLocked, | ||
| starred: _cachedConversation!.starred, | ||
| folderId: _cachedConversation!.folderId, | ||
| visibility: _cachedConversation!.visibility, | ||
| ); | ||
| _cachedConversation = updatedConversation; | ||
| conversationProvider?.updateConversation(updatedConversation); | ||
| } | ||
| notifyListeners(); | ||
| } | ||
|
|
||
| /// Auto-links the conversation to the best overlapping calendar event | ||
| Future<CalendarEventLink?> autoLinkCalendarEvent() async { | ||
| try { | ||
| final calendarEvent = await autoLinkCalendarEvent(conversation.id); | ||
| if (calendarEvent != null) { | ||
| _updateLocalConversationWithCalendarEvent(calendarEvent); | ||
| } | ||
| return calendarEvent; | ||
| } catch (e) { | ||
| debugPrint('Error auto-linking calendar event: $e'); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /// Links the conversation to a specific calendar event by event ID | ||
| Future<CalendarEventLink?> linkCalendarEvent(String eventId) async { | ||
| try { | ||
| final calendarEvent = await linkCalendarEvent(conversation.id, eventId); | ||
| if (calendarEvent != null) { | ||
| _updateLocalConversationWithCalendarEvent(calendarEvent); | ||
| } | ||
| return calendarEvent; | ||
| } catch (e) { | ||
| debugPrint('Error linking calendar event: $e'); | ||
| return null; | ||
| } | ||
| } |
There was a problem hiding this comment.
Instance methods shadow imported functions — compile error
Four methods in ConversationDetailProvider share names with imported top-level functions from conversations.dart, but have different parameter counts. In Dart, bare identifiers inside a class resolve to instance members first. Each call inside the method body tries to invoke the instance method with the wrong arity, which is a compile-time error:
unlinkCalendarEvent()(0 params) callsunlinkCalendarEvent(conversation.id)(1 arg)addSummaryToCalendarEvent()(0 params) callsaddSummaryToCalendarEvent(conversation.id)(1 arg)autoLinkCalendarEvent()(0 params) callsautoLinkCalendarEvent(conversation.id)(1 arg)linkCalendarEvent(String eventId)(1 param) callslinkCalendarEvent(conversation.id, eventId)(2 args)
The Flutter app will not build until these methods are renamed (e.g. prefixed with local or provider), or the imported functions are called via a qualified import alias.
…k using SharedPreferences fallback
|
@greptileai review |
| # Check for overlapping calendar events | ||
| if not discarded and conversation.started_at and conversation.finished_at: | ||
| try: | ||
| calendar_event = asyncio.run(get_overlapping_calendar_event( | ||
| uid, | ||
| conversation.started_at, | ||
| conversation.finished_at, | ||
| )) | ||
| if calendar_event: | ||
| conversation.calendar_event = calendar_event | ||
| except Exception as e: | ||
| logger.error(f"Error during calendar event linking: {e}") | ||
| pass |
There was a problem hiding this comment.
Auto-link overwrites manual calendar link on reprocess
This block unconditionally replaces conversation.calendar_event whenever an overlapping event is found. If a user has already manually linked a specific calendar event (e.g. changed the auto-linked event to a different one), reprocessing the conversation will silently overwrite their choice with whatever get_overlapping_calendar_event returns — likely the same originally auto-linked event. There is no check for a pre-existing link.
Add an early-exit guard:
# Only auto-link if no calendar event is already linked
if not discarded and conversation.started_at and conversation.finished_at and conversation.calendar_event is None:
Summary
Closes #3379
This PR implements automatic linking of conversations to Google Calendar events, enabling meeting detection and speaker identification from calendar context.
/v1/google-calendar/*): OAuth flow, event fetching, and conversation-calendar linkingHow it works
calendar_linking.pyqueries the calendar for events overlapping the conversation time windowCalendarEventLink(event ID, title, attendees, times, deep link)Test plan
🤖 Generated with Claude Code