Auto-link conversations to Google Calendar events#6037
Auto-link conversations to Google Calendar events#6037atlas-agent-omi[bot] wants to merge 1 commit intomainfrom
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 implements end-to-end Google Calendar integration for conversations: auto-linking overlapping events during processing, manual linking via an event picker sheet, unlinking, appending a conversation link to event descriptions, and a new Calendar Integrations settings page. The backend, data models, API client layer, and most UI components are well-structured and consistent with existing patterns. Key issues to address before merge:
Confidence Score: 1/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant App as Flutter App
participant API as FastAPI Backend
participant GCal as Google Calendar API
participant DB as Firestore
Note over App,DB: Auto-link on conversation creation
App->>API: POST /v1/conversations (process in-progress)
API->>API: process_conversation()
API->>DB: get_integration(uid, 'google_calendar')
DB-->>API: access_token
API->>GCal: get_google_calendar_events(time_min, time_max)
GCal-->>API: events[]
API->>API: find best overlap (≥5min OR ≥50%)
API->>API: conversation.structured.title = calendar_event.title ⚠️
API->>DB: save conversation with calendar_event
API-->>App: Conversation (with calendarEvent)
Note over App,DB: Manual link via event picker
App->>API: GET /v1/calendar/google/events?time_min&time_max
API->>GCal: get_google_calendar_events()
GCal-->>API: events[]
API-->>App: List[GoogleCalendarEvent]
App->>API: POST /v1/conversations/{id}/calendar-event {event_id}
API->>GCal: get_google_calendar_event(event_id)
GCal-->>API: event
API->>DB: update conversation.calendar_event
API-->>App: CalendarEventLink
Note over App,DB: Add summary link to calendar event
App->>API: POST /v1/conversations/{id}/calendar-event/add-summary
API->>GCal: get_google_calendar_event(event_id)
GCal-->>API: existing event
API->>GCal: update description += conversation_link
GCal-->>API: updated event
API-->>App: {status, html_link}
Note over App,DB: Unlink calendar event
App->>API: DELETE /v1/conversations/{id}/calendar-event
API->>DB: update conversation {calendar_event: null}
API-->>App: {status: Ok}
Reviews (1): Last reviewed commit: "Auto-link conversations to Google Calend..." | Re-trigger Greptile |
| context: context, | ||
| backgroundColor: Colors.transparent, | ||
| isScrollControlled: true, | ||
| builder: (context) => CalendarEventDetailsSheet(calendarEvent: calendarEvent), |
There was a problem hiding this comment.
CalendarEventDetailsSheet is undefined — compilation error
CalendarEventDetailsSheet is referenced here but is never defined anywhere in the codebase. A grep across all .dart files confirms there is no class with this name. This will prevent the app from compiling.
The class that does exist for the picker flow is CalendarEventPickerSheet (defined in page.dart), but that serves a different purpose (selecting an event to link). A separate CalendarEventDetailsSheet displaying event details (title, attendees, unlink/add-summary/share actions) needs to be implemented and either imported or defined in this file.
| 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, | ||
| discarded: _cachedConversation!.discarded, | ||
| deleted: _cachedConversation!.deleted, | ||
| source: _cachedConversation!.source, | ||
| language: _cachedConversation!.language, | ||
| externalIntegration: _cachedConversation!.externalIntegration, | ||
| calendarEvent: null, | ||
| status: _cachedConversation!.status, | ||
| isLocked: _cachedConversation!.isLocked, | ||
| ); |
There was a problem hiding this comment.
unlinkCalendarEvent drops audioFiles and starred fields
When reconstructing the local ServerConversation after a successful unlink, audioFiles and starred are not copied from _cachedConversation. Compare this with the nearly identical _updateLocalConversationWithCalendarEvent helper (lines 602–623) which correctly includes both fields.
Omitting these fields resets audioFiles to null and starred to its default value, causing the UI to lose audio-file references and starred status on the conversation card immediately after the user unlinks a calendar event.
| 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, | |
| discarded: _cachedConversation!.discarded, | |
| deleted: _cachedConversation!.deleted, | |
| source: _cachedConversation!.source, | |
| language: _cachedConversation!.language, | |
| externalIntegration: _cachedConversation!.externalIntegration, | |
| calendarEvent: null, | |
| status: _cachedConversation!.status, | |
| isLocked: _cachedConversation!.isLocked, | |
| ); | |
| 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: null, | |
| status: _cachedConversation!.status, | |
| isLocked: _cachedConversation!.isLocked, | |
| starred: _cachedConversation!.starred, | |
| ); |
| if calendar_event: | ||
| # Override the conversation title with calendar event title | ||
| conversation.structured.title = calendar_event.title | ||
| conversation.calendar_event = calendar_event |
There was a problem hiding this comment.
AI-generated conversation title silently overwritten by calendar event title
When an overlapping calendar event is found, the AI-generated conversation title is unconditionally replaced with the calendar event name. For a meeting titled "Weekly Team Sync" the AI may have produced something far more descriptive like "Discussed Q3 roadmap blockers and assigned owners" — that is lost without any indication to the user.
Consider keeping both, e.g. only applying the override if the AI title is empty/discarded, or storing the calendar event's title separately rather than mutating structured.title:
if calendar_event:
conversation.calendar_event = calendar_event
# Only use the calendar event title if no meaningful AI title was generated
if not conversation.structured.title:
conversation.structured.title = calendar_event.title| 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.
Duplicated helper functions across three backend files
_extract_attendees and _parse_event_times are copy-pasted verbatim in three places:
backend/utils/conversations/calendar_linking.pybackend/routers/conversations.py(here)backend/routers/google_calendar.py
Per the backend architecture guidelines, shared utilities belong in the utils/ layer, not duplicated in routers. Consider moving both helpers into utils/conversations/calendar_linking.py (or a new utils/retrieval/tools/calendar_utils.py) and importing them in the two routers.
| def _add_summary_to_calendar_event_with_token( | ||
| access_token: str, | ||
| event_id: str, | ||
| conversation_id: str, | ||
| ) -> dict: | ||
| """Helper function to add summary link to calendar event with given token.""" | ||
| # Get existing event to preserve current description | ||
| existing_event = get_google_calendar_event(access_token, event_id) | ||
| current_description = existing_event.get('description', '') or '' | ||
|
|
||
| # Build the conversation link | ||
| conversation_link = f"https://h.omi.me/memories/{conversation_id}" | ||
|
|
||
| # Check if we already added the link (to avoid duplicates) | ||
| if conversation_link in current_description: | ||
| return { | ||
| 'status': 'Ok', | ||
| 'html_link': existing_event.get('htmlLink'), | ||
| } | ||
|
|
||
| # Append just the link | ||
| if current_description: | ||
| new_description = f"{current_description}\n\n{conversation_link}" | ||
| else: | ||
| new_description = conversation_link | ||
|
|
||
| # Update the calendar event | ||
| updated_event = update_google_calendar_event( | ||
| access_token=access_token, | ||
| event_id=event_id, | ||
| description=new_description, | ||
| ) | ||
|
|
||
| return { | ||
| 'status': 'Ok', | ||
| 'html_link': updated_event.get('htmlLink'), |
There was a problem hiding this comment.
add-summary endpoint only appends a link, not a summary
Despite the endpoint path (/calendar-event/add-summary), the function name (_add_summary_to_calendar_event_with_token), and the PR description ("Append conversation summaries to calendar event descriptions"), the actual implementation appends only a bare URL:
conversation_link = f"https://h.omi.me/memories/{conversation_id}"
# Append just the link
new_description = f"{current_description}\n\n{conversation_link}"No summary text is fetched or written. If this is intentional (linking only), the endpoint path, function names, and user-facing labels should be renamed to "add-link" or "share-link" to avoid misleading users and developers. The Flutter addSummaryToCalendarEvent method and the snackbar copy in page.dart carry the same mismatch.
| import 'package:omi/widgets/dialog.dart'; | ||
| import 'package:omi/widgets/extensions/string.dart'; | ||
| import 'package:url_launcher/url_launcher.dart'; | ||
| import 'package:font_awesome_flutter/font_awesome_flutter.dart'; |
|
AI PRs solely without any verification are not welcome. Please ask the human representative to close the loop and verify before submitting. Thank you. — by CTO |
|
Hey @atlas-agent-omi[bot] 👋 Thank you so much for taking the time to contribute to Omi! We truly appreciate you putting in the effort to submit this pull request. After careful review, we've decided not to merge this particular PR. Please don't take this personally — we genuinely try to merge as many contributions as possible, but sometimes we have to make tough calls based on:
Your contribution is still valuable to us, and we'd love to see you contribute again in the future! If you'd like feedback on how to improve this PR or want to discuss alternative approaches, please don't hesitate to reach out. Thank you for being part of the Omi community! 💜 |
Rebased from #3747.