cortexdb-connectors 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1033 @@
1
+ """
2
+ Google Workspace connector for CortexDB.
3
+
4
+ A compound connector that ingests data from three Google Workspace services
5
+ — Gmail, Google Drive, and Google Calendar — into CortexDB as Episodes.
6
+ Each sub-connector can be independently enabled or disabled.
7
+
8
+ Uses ``google-api-python-client`` and ``google-auth`` for API access.
9
+ Authenticates via a Google Service Account with domain-wide delegation.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import base64
16
+ import json
17
+ import logging
18
+ from datetime import datetime, timedelta, timezone
19
+ from email.utils import parseaddr
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from cortexdb_connectors.base import (
24
+ Actor,
25
+ CortexConnector,
26
+ EntityRef,
27
+ Episode,
28
+ EpisodeType,
29
+ RawEvent,
30
+ Source,
31
+ Visibility,
32
+ VisibilityLevel,
33
+ )
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ _SOURCE = Source(system="google_workspace")
38
+
39
+ # Rate-limit: Google APIs enforce per-user and per-project quotas.
40
+ # Default conservative delay between requests.
41
+ _RATE_LIMIT_DELAY = 0.25
42
+ _PAGE_SIZE = 100
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Sub-connector helpers (stateless — invoked by the main class)
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ class _GmailSubConnector:
50
+ """Fetches email threads from Gmail via the Gmail API v1."""
51
+
52
+ def __init__(
53
+ self,
54
+ delegated_user: str,
55
+ label_filter: list[str] | None = None,
56
+ backfill_days: int = 30,
57
+ ) -> None:
58
+ self.delegated_user = delegated_user
59
+ self.label_filter = label_filter or ["INBOX"]
60
+ self.backfill_days = backfill_days
61
+
62
+ async def fetch(
63
+ self,
64
+ credentials: Any,
65
+ since: datetime | None = None,
66
+ ) -> list[RawEvent]:
67
+ """Fetch email messages from Gmail."""
68
+ from googleapiclient.discovery import build
69
+
70
+ service = build("gmail", "v1", credentials=credentials)
71
+ events: list[RawEvent] = []
72
+
73
+ if since is None:
74
+ since = datetime.now(timezone.utc) - timedelta(days=self.backfill_days)
75
+
76
+ # Gmail query: after:YYYY/MM/DD
77
+ after_str = since.strftime("%Y/%m/%d")
78
+ query = f"after:{after_str}"
79
+ if self.label_filter:
80
+ label_query = " OR ".join(
81
+ f"label:{lbl}" for lbl in self.label_filter
82
+ )
83
+ query = f"({label_query}) {query}"
84
+
85
+ page_token: str | None = None
86
+ while True:
87
+ try:
88
+ kwargs: dict[str, Any] = {
89
+ "userId": "me",
90
+ "q": query,
91
+ "maxResults": _PAGE_SIZE,
92
+ }
93
+ if page_token:
94
+ kwargs["pageToken"] = page_token
95
+
96
+ result = await asyncio.to_thread(
97
+ service.users().messages().list(**kwargs).execute
98
+ )
99
+ except Exception as exc:
100
+ logger.warning("Gmail list failed: %s", exc)
101
+ break
102
+
103
+ for msg_stub in result.get("messages", []):
104
+ msg_id = msg_stub["id"]
105
+ try:
106
+ msg = await asyncio.to_thread(
107
+ service.users()
108
+ .messages()
109
+ .get(userId="me", id=msg_id, format="metadata")
110
+ .execute
111
+ )
112
+ except Exception:
113
+ logger.debug("Could not fetch Gmail message %s", msg_id)
114
+ continue
115
+
116
+ raw = self._message_to_raw(msg)
117
+ events.append(raw)
118
+ await asyncio.sleep(_RATE_LIMIT_DELAY)
119
+
120
+ page_token = result.get("nextPageToken")
121
+ if not page_token:
122
+ break
123
+ await asyncio.sleep(_RATE_LIMIT_DELAY)
124
+
125
+ return events
126
+
127
+ def _message_to_raw(self, msg: dict[str, Any]) -> RawEvent:
128
+ """Transform a Gmail message into a ``RawEvent``."""
129
+ headers = {
130
+ h["name"].lower(): h["value"]
131
+ for h in msg.get("payload", {}).get("headers", [])
132
+ }
133
+ timestamp = self._parse_internal_date(msg.get("internalDate", "0"))
134
+ thread_id = msg.get("threadId", "")
135
+ labels = msg.get("labelIds", [])
136
+
137
+ sender_name, sender_email = parseaddr(headers.get("from", ""))
138
+ to_raw = headers.get("to", "")
139
+ cc_raw = headers.get("cc", "")
140
+
141
+ return RawEvent(
142
+ source="google_workspace:gmail",
143
+ external_id=f"gmail:{msg['id']}",
144
+ timestamp=timestamp,
145
+ data={
146
+ "_sub_source": "gmail",
147
+ "message_id": msg["id"],
148
+ "thread_id": thread_id,
149
+ "subject": headers.get("subject", "(no subject)"),
150
+ "from_name": sender_name,
151
+ "from_email": sender_email,
152
+ "to": to_raw,
153
+ "cc": cc_raw,
154
+ "snippet": msg.get("snippet", ""),
155
+ "labels": labels,
156
+ "size_estimate": msg.get("sizeEstimate", 0),
157
+ },
158
+ permissions={
159
+ "labels": labels,
160
+ "is_draft": "DRAFT" in labels,
161
+ "sender_email": sender_email,
162
+ },
163
+ )
164
+
165
+ @staticmethod
166
+ def _parse_internal_date(ms_str: str) -> datetime:
167
+ """Parse Gmail's internalDate (milliseconds since epoch)."""
168
+ try:
169
+ return datetime.fromtimestamp(int(ms_str) / 1000, tz=timezone.utc)
170
+ except (ValueError, OSError):
171
+ return datetime.now(timezone.utc)
172
+
173
+
174
+ class _DriveSubConnector:
175
+ """Fetches document change events from Google Drive via the Drive API v3."""
176
+
177
+ def __init__(
178
+ self,
179
+ delegated_user: str,
180
+ shared_drives: list[str] | None = None,
181
+ backfill_days: int = 30,
182
+ ) -> None:
183
+ self.delegated_user = delegated_user
184
+ self.shared_drives = shared_drives or []
185
+ self.backfill_days = backfill_days
186
+ self._saved_start_page_token: str | None = None
187
+
188
+ async def fetch(
189
+ self,
190
+ credentials: Any,
191
+ since: datetime | None = None,
192
+ ) -> list[RawEvent]:
193
+ """Fetch recently modified files from Google Drive."""
194
+ from googleapiclient.discovery import build
195
+
196
+ service = build("drive", "v3", credentials=credentials)
197
+ events: list[RawEvent] = []
198
+
199
+ if since is None:
200
+ since = datetime.now(timezone.utc) - timedelta(days=self.backfill_days)
201
+
202
+ modified_after = since.strftime("%Y-%m-%dT%H:%M:%SZ")
203
+
204
+ # Use files.list with modifiedTime filter for backfill/incremental.
205
+ query = (
206
+ f"modifiedTime > '{modified_after}' "
207
+ "and trashed = false"
208
+ )
209
+
210
+ page_token: str | None = None
211
+ while True:
212
+ try:
213
+ kwargs: dict[str, Any] = {
214
+ "q": query,
215
+ "pageSize": _PAGE_SIZE,
216
+ "fields": (
217
+ "nextPageToken,"
218
+ "files(id,name,mimeType,modifiedTime,createdTime,"
219
+ "owners,lastModifyingUser,shared,sharingUser,"
220
+ "parents,webViewLink,size,driveId)"
221
+ ),
222
+ "orderBy": "modifiedTime desc",
223
+ "supportsAllDrives": True,
224
+ "includeItemsFromAllDrives": True,
225
+ }
226
+ if page_token:
227
+ kwargs["pageToken"] = page_token
228
+
229
+ result = await asyncio.to_thread(
230
+ service.files().list(**kwargs).execute
231
+ )
232
+ except Exception as exc:
233
+ logger.warning("Drive files.list failed: %s", exc)
234
+ break
235
+
236
+ for f in result.get("files", []):
237
+ raw = self._file_to_raw(f)
238
+ events.append(raw)
239
+
240
+ page_token = result.get("nextPageToken")
241
+ if not page_token:
242
+ break
243
+ await asyncio.sleep(_RATE_LIMIT_DELAY)
244
+
245
+ # Additionally fetch shared drive contents if configured.
246
+ for drive_id in self.shared_drives:
247
+ drive_events = await self._fetch_shared_drive(
248
+ service, drive_id, modified_after
249
+ )
250
+ events.extend(drive_events)
251
+
252
+ return events
253
+
254
+ async def _fetch_shared_drive(
255
+ self,
256
+ service: Any,
257
+ drive_id: str,
258
+ modified_after: str,
259
+ ) -> list[RawEvent]:
260
+ """Fetch files from a specific shared drive."""
261
+ events: list[RawEvent] = []
262
+ query = (
263
+ f"modifiedTime > '{modified_after}' "
264
+ "and trashed = false"
265
+ )
266
+ page_token: str | None = None
267
+
268
+ while True:
269
+ try:
270
+ kwargs: dict[str, Any] = {
271
+ "q": query,
272
+ "pageSize": _PAGE_SIZE,
273
+ "fields": (
274
+ "nextPageToken,"
275
+ "files(id,name,mimeType,modifiedTime,createdTime,"
276
+ "owners,lastModifyingUser,shared,sharingUser,"
277
+ "parents,webViewLink,size,driveId)"
278
+ ),
279
+ "driveId": drive_id,
280
+ "corpora": "drive",
281
+ "supportsAllDrives": True,
282
+ "includeItemsFromAllDrives": True,
283
+ }
284
+ if page_token:
285
+ kwargs["pageToken"] = page_token
286
+
287
+ result = await asyncio.to_thread(
288
+ service.files().list(**kwargs).execute
289
+ )
290
+ except Exception as exc:
291
+ logger.warning(
292
+ "Drive shared-drive fetch failed for %s: %s", drive_id, exc
293
+ )
294
+ break
295
+
296
+ for f in result.get("files", []):
297
+ raw = self._file_to_raw(f, shared_drive_id=drive_id)
298
+ events.append(raw)
299
+
300
+ page_token = result.get("nextPageToken")
301
+ if not page_token:
302
+ break
303
+ await asyncio.sleep(_RATE_LIMIT_DELAY)
304
+
305
+ return events
306
+
307
+ def _file_to_raw(
308
+ self,
309
+ f: dict[str, Any],
310
+ shared_drive_id: str | None = None,
311
+ ) -> RawEvent:
312
+ """Transform a Drive file metadata object into a ``RawEvent``."""
313
+ modified_time = f.get("modifiedTime", "")
314
+ timestamp = self._parse_iso(modified_time)
315
+
316
+ last_modifier = f.get("lastModifyingUser", {})
317
+ owners = f.get("owners", [])
318
+ owner = owners[0] if owners else {}
319
+
320
+ return RawEvent(
321
+ source="google_workspace:drive",
322
+ external_id=f"drive:{f['id']}:{modified_time}",
323
+ timestamp=timestamp,
324
+ data={
325
+ "_sub_source": "drive",
326
+ "file_id": f["id"],
327
+ "name": f.get("name", ""),
328
+ "mime_type": f.get("mimeType", ""),
329
+ "modified_time": modified_time,
330
+ "created_time": f.get("createdTime", ""),
331
+ "last_modifier_name": last_modifier.get("displayName", ""),
332
+ "last_modifier_email": last_modifier.get("emailAddress", ""),
333
+ "owner_name": owner.get("displayName", ""),
334
+ "owner_email": owner.get("emailAddress", ""),
335
+ "shared": f.get("shared", False),
336
+ "web_view_link": f.get("webViewLink", ""),
337
+ "size": f.get("size"),
338
+ "parents": f.get("parents", []),
339
+ "drive_id": f.get("driveId") or shared_drive_id,
340
+ },
341
+ permissions={
342
+ "shared": f.get("shared", False),
343
+ "owner_email": owner.get("emailAddress", ""),
344
+ "drive_id": f.get("driveId") or shared_drive_id,
345
+ },
346
+ )
347
+
348
+ @staticmethod
349
+ def _parse_iso(iso_str: str) -> datetime:
350
+ if not iso_str:
351
+ return datetime.now(timezone.utc)
352
+ cleaned = iso_str.replace("Z", "+00:00")
353
+ try:
354
+ return datetime.fromisoformat(cleaned)
355
+ except ValueError:
356
+ return datetime.now(timezone.utc)
357
+
358
+
359
+ class _CalendarSubConnector:
360
+ """Fetches calendar events from Google Calendar API v3."""
361
+
362
+ def __init__(
363
+ self,
364
+ delegated_user: str,
365
+ calendar_ids: list[str] | None = None,
366
+ backfill_days: int = 30,
367
+ ) -> None:
368
+ self.delegated_user = delegated_user
369
+ self.calendar_ids = calendar_ids or ["primary"]
370
+ self.backfill_days = backfill_days
371
+
372
+ async def fetch(
373
+ self,
374
+ credentials: Any,
375
+ since: datetime | None = None,
376
+ ) -> list[RawEvent]:
377
+ """Fetch calendar events from specified calendars."""
378
+ from googleapiclient.discovery import build
379
+
380
+ service = build("calendar", "v3", credentials=credentials)
381
+ events: list[RawEvent] = []
382
+
383
+ if since is None:
384
+ since = datetime.now(timezone.utc) - timedelta(days=self.backfill_days)
385
+
386
+ time_min = since.strftime("%Y-%m-%dT%H:%M:%SZ")
387
+
388
+ for calendar_id in self.calendar_ids:
389
+ cal_events = await self._fetch_calendar(
390
+ service, calendar_id, time_min
391
+ )
392
+ events.extend(cal_events)
393
+
394
+ return events
395
+
396
+ async def _fetch_calendar(
397
+ self,
398
+ service: Any,
399
+ calendar_id: str,
400
+ time_min: str,
401
+ ) -> list[RawEvent]:
402
+ """Fetch events from a single calendar."""
403
+ events: list[RawEvent] = []
404
+ page_token: str | None = None
405
+
406
+ while True:
407
+ try:
408
+ kwargs: dict[str, Any] = {
409
+ "calendarId": calendar_id,
410
+ "timeMin": time_min,
411
+ "maxResults": _PAGE_SIZE,
412
+ "singleEvents": True,
413
+ "orderBy": "startTime",
414
+ }
415
+ if page_token:
416
+ kwargs["pageToken"] = page_token
417
+
418
+ result = await asyncio.to_thread(
419
+ service.events().list(**kwargs).execute
420
+ )
421
+ except Exception as exc:
422
+ logger.warning(
423
+ "Calendar events.list failed for %s: %s",
424
+ calendar_id,
425
+ exc,
426
+ )
427
+ break
428
+
429
+ for event in result.get("items", []):
430
+ raw = self._event_to_raw(event, calendar_id)
431
+ events.append(raw)
432
+
433
+ page_token = result.get("nextPageToken")
434
+ if not page_token:
435
+ break
436
+ await asyncio.sleep(_RATE_LIMIT_DELAY)
437
+
438
+ return events
439
+
440
+ def _event_to_raw(
441
+ self,
442
+ event: dict[str, Any],
443
+ calendar_id: str,
444
+ ) -> RawEvent:
445
+ """Transform a Calendar event into a ``RawEvent``."""
446
+ start = event.get("start", {})
447
+ start_time = start.get("dateTime") or start.get("date", "")
448
+ timestamp = self._parse_event_time(start_time)
449
+
450
+ organizer = event.get("organizer", {})
451
+ creator = event.get("creator", {})
452
+ attendees = event.get("attendees", [])
453
+
454
+ # Detect recurring events.
455
+ recurring_event_id = event.get("recurringEventId")
456
+
457
+ return RawEvent(
458
+ source="google_workspace:calendar",
459
+ external_id=f"calendar:{event.get('id', '')}",
460
+ timestamp=timestamp,
461
+ data={
462
+ "_sub_source": "calendar",
463
+ "event_id": event.get("id", ""),
464
+ "calendar_id": calendar_id,
465
+ "summary": event.get("summary", "(No title)"),
466
+ "description": event.get("description", ""),
467
+ "location": event.get("location", ""),
468
+ "start": start_time,
469
+ "end": (
470
+ event.get("end", {}).get("dateTime")
471
+ or event.get("end", {}).get("date", "")
472
+ ),
473
+ "status": event.get("status", "confirmed"),
474
+ "organizer_name": organizer.get("displayName", ""),
475
+ "organizer_email": organizer.get("email", ""),
476
+ "creator_name": creator.get("displayName", ""),
477
+ "creator_email": creator.get("email", ""),
478
+ "attendees": [
479
+ {
480
+ "email": a.get("email", ""),
481
+ "name": a.get("displayName", ""),
482
+ "response": a.get("responseStatus", "needsAction"),
483
+ "organizer": a.get("organizer", False),
484
+ }
485
+ for a in attendees
486
+ ],
487
+ "attendee_count": len(attendees),
488
+ "hangout_link": event.get("hangoutLink", ""),
489
+ "conference_data": event.get("conferenceData"),
490
+ "recurring_event_id": recurring_event_id,
491
+ "visibility": event.get("visibility", "default"),
492
+ },
493
+ permissions={
494
+ "visibility": event.get("visibility", "default"),
495
+ "organizer_email": organizer.get("email", ""),
496
+ "attendee_emails": [a.get("email", "") for a in attendees],
497
+ },
498
+ )
499
+
500
+ @staticmethod
501
+ def _parse_event_time(time_str: str) -> datetime:
502
+ """Parse a Calendar event start time (dateTime or date)."""
503
+ if not time_str:
504
+ return datetime.now(timezone.utc)
505
+ try:
506
+ # dateTime format: 2026-03-15T10:00:00-07:00
507
+ cleaned = time_str.replace("Z", "+00:00")
508
+ return datetime.fromisoformat(cleaned)
509
+ except ValueError:
510
+ pass
511
+ try:
512
+ # date format: 2026-03-15 (all-day events)
513
+ return datetime.strptime(time_str, "%Y-%m-%d").replace(
514
+ tzinfo=timezone.utc
515
+ )
516
+ except ValueError:
517
+ return datetime.now(timezone.utc)
518
+
519
+
520
+ # ---------------------------------------------------------------------------
521
+ # Main compound connector
522
+ # ---------------------------------------------------------------------------
523
+
524
+
525
+ class GoogleWorkspaceConnector(CortexConnector):
526
+ """Compound connector for Google Workspace (Gmail + Drive + Calendar).
527
+
528
+ Internally manages three sub-connectors that can be independently
529
+ enabled or disabled. ``fetch_events()`` aggregates events from all
530
+ enabled sub-connectors into a single list.
531
+
532
+ Authenticates via a Google Service Account with domain-wide delegation.
533
+
534
+ Parameters
535
+ ----------
536
+ cortex_url:
537
+ Base URL of the CortexDB API.
538
+ cortex_api_key:
539
+ Bearer token for CortexDB authentication.
540
+ service_account_key:
541
+ Path to a service-account JSON key file, **or** a base64-encoded
542
+ key string.
543
+ delegated_user:
544
+ The workspace user email to impersonate via domain-wide delegation.
545
+ gmail_enabled:
546
+ Enable Gmail sub-connector.
547
+ drive_enabled:
548
+ Enable Drive sub-connector.
549
+ calendar_enabled:
550
+ Enable Calendar sub-connector.
551
+ label_filter:
552
+ Gmail labels to filter (default: ``["INBOX"]``).
553
+ shared_drives:
554
+ List of shared Drive IDs to include.
555
+ calendar_ids:
556
+ List of calendar IDs to sync (default: ``["primary"]``).
557
+ namespace:
558
+ CortexDB namespace for ingested episodes.
559
+ tenant_id:
560
+ CortexDB tenant identifier.
561
+ backfill_days:
562
+ Days of history for initial sync.
563
+ """
564
+
565
+ connector_name: str = "google_workspace"
566
+
567
+ def __init__(
568
+ self,
569
+ cortex_url: str,
570
+ cortex_api_key: str,
571
+ service_account_key: str,
572
+ delegated_user: str,
573
+ gmail_enabled: bool = True,
574
+ drive_enabled: bool = True,
575
+ calendar_enabled: bool = True,
576
+ label_filter: list[str] | None = None,
577
+ shared_drives: list[str] | None = None,
578
+ calendar_ids: list[str] | None = None,
579
+ namespace: str = "google_workspace",
580
+ tenant_id: str = "default",
581
+ backfill_days: int = 30,
582
+ ) -> None:
583
+ super().__init__(cortex_url, cortex_api_key, tenant_id)
584
+ self.service_account_key = service_account_key
585
+ self.delegated_user = delegated_user
586
+ self.gmail_enabled = gmail_enabled
587
+ self.drive_enabled = drive_enabled
588
+ self.calendar_enabled = calendar_enabled
589
+ self.namespace = namespace
590
+ self.backfill_days = backfill_days
591
+
592
+ # Initialize sub-connectors.
593
+ self._gmail = _GmailSubConnector(
594
+ delegated_user=delegated_user,
595
+ label_filter=label_filter,
596
+ backfill_days=backfill_days,
597
+ ) if gmail_enabled else None
598
+
599
+ self._drive = _DriveSubConnector(
600
+ delegated_user=delegated_user,
601
+ shared_drives=shared_drives,
602
+ backfill_days=backfill_days,
603
+ ) if drive_enabled else None
604
+
605
+ self._calendar = _CalendarSubConnector(
606
+ delegated_user=delegated_user,
607
+ calendar_ids=calendar_ids,
608
+ backfill_days=backfill_days,
609
+ ) if calendar_enabled else None
610
+
611
+ # -- authentication ------------------------------------------------------
612
+
613
+ def _build_credentials(self, scopes: list[str]) -> Any:
614
+ """Build Google credentials from a service account key.
615
+
616
+ Supports both a file path and a base64-encoded JSON string.
617
+ """
618
+ from google.oauth2 import service_account
619
+
620
+ key_data = self._load_key_data()
621
+ creds = service_account.Credentials.from_service_account_info(
622
+ key_data,
623
+ scopes=scopes,
624
+ )
625
+ return creds.with_subject(self.delegated_user)
626
+
627
+ def _load_key_data(self) -> dict[str, Any]:
628
+ """Load service account key from file path or base64 string."""
629
+ key_str = self.service_account_key.strip()
630
+
631
+ # Try as file path first.
632
+ key_path = Path(key_str)
633
+ if key_path.is_file():
634
+ with open(key_path) as fh:
635
+ return json.load(fh)
636
+
637
+ # Try as base64-encoded JSON.
638
+ try:
639
+ decoded = base64.b64decode(key_str)
640
+ return json.loads(decoded)
641
+ except Exception:
642
+ pass
643
+
644
+ # Try as raw JSON string.
645
+ try:
646
+ return json.loads(key_str)
647
+ except Exception:
648
+ raise ValueError(
649
+ "service_account_key must be a file path, base64 string, "
650
+ "or raw JSON string"
651
+ )
652
+
653
+ # -- CortexConnector interface -------------------------------------------
654
+
655
+ async def fetch_events(
656
+ self, since: datetime | None = None,
657
+ ) -> list[RawEvent]:
658
+ """Aggregate events from all enabled Google Workspace sub-connectors."""
659
+ events: list[RawEvent] = []
660
+
661
+ if self._gmail:
662
+ gmail_scopes = ["https://www.googleapis.com/auth/gmail.readonly"]
663
+ creds = self._build_credentials(gmail_scopes)
664
+ try:
665
+ gmail_events = await self._gmail.fetch(creds, since)
666
+ events.extend(gmail_events)
667
+ logger.info("Gmail: fetched %d events", len(gmail_events))
668
+ except Exception as exc:
669
+ logger.error("Gmail fetch failed: %s", exc)
670
+
671
+ if self._drive:
672
+ drive_scopes = ["https://www.googleapis.com/auth/drive.readonly"]
673
+ creds = self._build_credentials(drive_scopes)
674
+ try:
675
+ drive_events = await self._drive.fetch(creds, since)
676
+ events.extend(drive_events)
677
+ logger.info("Drive: fetched %d events", len(drive_events))
678
+ except Exception as exc:
679
+ logger.error("Drive fetch failed: %s", exc)
680
+
681
+ if self._calendar:
682
+ cal_scopes = [
683
+ "https://www.googleapis.com/auth/calendar.readonly",
684
+ ]
685
+ creds = self._build_credentials(cal_scopes)
686
+ try:
687
+ cal_events = await self._calendar.fetch(creds, since)
688
+ events.extend(cal_events)
689
+ logger.info("Calendar: fetched %d events", len(cal_events))
690
+ except Exception as exc:
691
+ logger.error("Calendar fetch failed: %s", exc)
692
+
693
+ return events
694
+
695
+ def normalize(self, raw: RawEvent) -> Episode:
696
+ """Route normalization to the appropriate sub-connector handler."""
697
+ sub_source = raw.data.get("_sub_source", "")
698
+ if sub_source == "gmail":
699
+ return self._normalize_gmail(raw)
700
+ if sub_source == "drive":
701
+ return self._normalize_drive(raw)
702
+ if sub_source == "calendar":
703
+ return self._normalize_calendar(raw)
704
+ raise ValueError(f"Unknown sub-source: {sub_source!r}")
705
+
706
+ def map_permissions(self, raw: RawEvent) -> Visibility:
707
+ """Route permission mapping to the appropriate sub-connector handler."""
708
+ sub_source = raw.data.get("_sub_source", "")
709
+ if sub_source == "gmail":
710
+ return self._map_gmail_permissions(raw)
711
+ if sub_source == "drive":
712
+ return self._map_drive_permissions(raw)
713
+ if sub_source == "calendar":
714
+ return self._map_calendar_permissions(raw)
715
+ return Visibility(level=VisibilityLevel.ORGANIZATION)
716
+
717
+ # -- Gmail normalization -------------------------------------------------
718
+
719
+ def _normalize_gmail(self, raw: RawEvent) -> Episode:
720
+ """Normalize a Gmail message into a CortexDB Episode."""
721
+ data = raw.data
722
+ actor = Actor(
723
+ id=data.get("from_email", "unknown"),
724
+ name=data.get("from_name") or data.get("from_email", "unknown"),
725
+ email=data.get("from_email"),
726
+ )
727
+
728
+ subject = data.get("subject", "(no subject)")
729
+ snippet = data.get("snippet", "")
730
+ content = f"Subject: {subject}\n\n{snippet}" if snippet else subject
731
+
732
+ thread_id = data.get("thread_id")
733
+
734
+ entities: list[EntityRef] = []
735
+ # Sender entity.
736
+ if data.get("from_email"):
737
+ entities.append(
738
+ EntityRef(
739
+ entity_type="contact",
740
+ entity_id=data["from_email"],
741
+ display_name=data.get("from_name", data["from_email"]),
742
+ )
743
+ )
744
+ # Thread entity.
745
+ if thread_id:
746
+ entities.append(
747
+ EntityRef(
748
+ entity_type="thread",
749
+ entity_id=thread_id,
750
+ display_name=subject,
751
+ )
752
+ )
753
+ # Label entities.
754
+ for label in data.get("labels", []):
755
+ entities.append(
756
+ EntityRef(
757
+ entity_type="label",
758
+ entity_id=label,
759
+ display_name=label,
760
+ )
761
+ )
762
+
763
+ return Episode(
764
+ actor=actor,
765
+ source=_SOURCE,
766
+ episode_type=EpisodeType.MESSAGE,
767
+ occurred_at=raw.timestamp,
768
+ content=content,
769
+ raw_payload=data,
770
+ tenant_id=self.tenant_id,
771
+ namespace=self.namespace,
772
+ entities=entities,
773
+ tags=["google_workspace", "gmail"],
774
+ metadata={
775
+ "sub_source": "gmail",
776
+ "subject": subject,
777
+ "to": data.get("to", ""),
778
+ "cc": data.get("cc", ""),
779
+ "labels": data.get("labels", []),
780
+ "size_estimate": data.get("size_estimate", 0),
781
+ },
782
+ thread_id=f"gmail:{thread_id}" if thread_id else None,
783
+ idempotency_key=raw.external_id,
784
+ )
785
+
786
+ @staticmethod
787
+ def _map_gmail_permissions(raw: RawEvent) -> Visibility:
788
+ """Map Gmail message visibility.
789
+
790
+ All emails are treated as PRIVATE to the mailbox owner by default.
791
+ Drafts are also PRIVATE.
792
+ """
793
+ perms = raw.permissions
794
+ sender = perms.get("sender_email", "")
795
+ return Visibility(
796
+ level=VisibilityLevel.PRIVATE,
797
+ allowed_principals=(sender,) if sender else (),
798
+ )
799
+
800
+ # -- Drive normalization -------------------------------------------------
801
+
802
+ def _normalize_drive(self, raw: RawEvent) -> Episode:
803
+ """Normalize a Drive file event into a CortexDB Episode."""
804
+ data = raw.data
805
+ modifier_name = data.get("last_modifier_name", "")
806
+ modifier_email = data.get("last_modifier_email", "")
807
+
808
+ actor = Actor(
809
+ id=modifier_email or data.get("owner_email", "unknown"),
810
+ name=modifier_name or data.get("owner_name", "unknown"),
811
+ email=modifier_email or data.get("owner_email"),
812
+ )
813
+
814
+ file_name = data.get("name", "Untitled")
815
+ mime_type = data.get("mime_type", "")
816
+ doc_type = self._mime_to_label(mime_type)
817
+ content = f"{doc_type}: {file_name}"
818
+
819
+ entities: list[EntityRef] = []
820
+ # File entity.
821
+ entities.append(
822
+ EntityRef(
823
+ entity_type="file",
824
+ entity_id=data.get("file_id", ""),
825
+ display_name=file_name,
826
+ )
827
+ )
828
+ # Owner entity.
829
+ if data.get("owner_email"):
830
+ entities.append(
831
+ EntityRef(
832
+ entity_type="collaborator",
833
+ entity_id=data["owner_email"],
834
+ display_name=data.get("owner_name", data["owner_email"]),
835
+ )
836
+ )
837
+ # Drive entity.
838
+ drive_id = data.get("drive_id")
839
+ if drive_id:
840
+ entities.append(
841
+ EntityRef(
842
+ entity_type="shared_drive",
843
+ entity_id=drive_id,
844
+ display_name=f"drive:{drive_id}",
845
+ )
846
+ )
847
+ # Folder entities.
848
+ for parent_id in data.get("parents", []):
849
+ entities.append(
850
+ EntityRef(
851
+ entity_type="folder",
852
+ entity_id=parent_id,
853
+ display_name=parent_id,
854
+ )
855
+ )
856
+
857
+ return Episode(
858
+ actor=actor,
859
+ source=_SOURCE,
860
+ episode_type=EpisodeType.DOCUMENT,
861
+ occurred_at=raw.timestamp,
862
+ content=content,
863
+ raw_payload=data,
864
+ tenant_id=self.tenant_id,
865
+ namespace=self.namespace,
866
+ entities=entities,
867
+ tags=["google_workspace", "drive", doc_type.lower().replace(" ", "_")],
868
+ metadata={
869
+ "sub_source": "drive",
870
+ "file_id": data.get("file_id", ""),
871
+ "file_name": file_name,
872
+ "mime_type": mime_type,
873
+ "doc_type": doc_type,
874
+ "shared": data.get("shared", False),
875
+ "web_view_link": data.get("web_view_link", ""),
876
+ "size": data.get("size"),
877
+ "created_time": data.get("created_time", ""),
878
+ "modified_time": data.get("modified_time", ""),
879
+ },
880
+ idempotency_key=raw.external_id,
881
+ )
882
+
883
+ @staticmethod
884
+ def _map_drive_permissions(raw: RawEvent) -> Visibility:
885
+ """Map Drive file visibility based on sharing state."""
886
+ perms = raw.permissions
887
+ if perms.get("shared"):
888
+ return Visibility(level=VisibilityLevel.ORGANIZATION)
889
+ owner = perms.get("owner_email", "")
890
+ return Visibility(
891
+ level=VisibilityLevel.PRIVATE,
892
+ allowed_principals=(owner,) if owner else (),
893
+ )
894
+
895
+ # -- Calendar normalization ----------------------------------------------
896
+
897
+ def _normalize_calendar(self, raw: RawEvent) -> Episode:
898
+ """Normalize a Calendar event into a CortexDB Episode."""
899
+ data = raw.data
900
+ organizer_name = data.get("organizer_name", "")
901
+ organizer_email = data.get("organizer_email", "")
902
+
903
+ actor = Actor(
904
+ id=organizer_email or data.get("creator_email", "unknown"),
905
+ name=organizer_name or data.get("creator_name", "unknown"),
906
+ email=organizer_email or data.get("creator_email"),
907
+ )
908
+
909
+ summary = data.get("summary", "(No title)")
910
+ description = data.get("description", "")
911
+ location = data.get("location", "")
912
+ attendee_count = data.get("attendee_count", 0)
913
+
914
+ content_parts = [summary]
915
+ if description:
916
+ content_parts.append(description[:500])
917
+ if location:
918
+ content_parts.append(f"Location: {location}")
919
+ content = "\n".join(content_parts)
920
+
921
+ entities: list[EntityRef] = []
922
+ # Calendar entity.
923
+ cal_id = data.get("calendar_id", "")
924
+ if cal_id:
925
+ entities.append(
926
+ EntityRef(
927
+ entity_type="calendar",
928
+ entity_id=cal_id,
929
+ display_name=cal_id,
930
+ )
931
+ )
932
+ # Attendee entities.
933
+ for attendee in data.get("attendees", []):
934
+ if attendee.get("email"):
935
+ entities.append(
936
+ EntityRef(
937
+ entity_type="attendee",
938
+ entity_id=attendee["email"],
939
+ display_name=attendee.get("name", attendee["email"]),
940
+ )
941
+ )
942
+ # Recurring event entity.
943
+ recurring_id = data.get("recurring_event_id")
944
+ if recurring_id:
945
+ entities.append(
946
+ EntityRef(
947
+ entity_type="recurring_event",
948
+ entity_id=recurring_id,
949
+ display_name=summary,
950
+ )
951
+ )
952
+
953
+ # Thread ID: group recurring event instances together.
954
+ thread_id = None
955
+ if recurring_id:
956
+ thread_id = f"calendar:recurring:{recurring_id}"
957
+
958
+ tags = ["google_workspace", "calendar"]
959
+ if attendee_count > 5:
960
+ tags.append("large_meeting")
961
+ if data.get("hangout_link") or data.get("conference_data"):
962
+ tags.append("video_call")
963
+
964
+ return Episode(
965
+ actor=actor,
966
+ source=_SOURCE,
967
+ episode_type=EpisodeType.MEETING,
968
+ occurred_at=raw.timestamp,
969
+ content=content,
970
+ raw_payload=data,
971
+ tenant_id=self.tenant_id,
972
+ namespace=self.namespace,
973
+ entities=entities,
974
+ tags=tags,
975
+ metadata={
976
+ "sub_source": "calendar",
977
+ "event_id": data.get("event_id", ""),
978
+ "calendar_id": cal_id,
979
+ "start": data.get("start", ""),
980
+ "end": data.get("end", ""),
981
+ "status": data.get("status", "confirmed"),
982
+ "location": location,
983
+ "attendee_count": attendee_count,
984
+ "has_video_call": bool(
985
+ data.get("hangout_link") or data.get("conference_data")
986
+ ),
987
+ "recurring_event_id": recurring_id,
988
+ "organizer_email": organizer_email,
989
+ },
990
+ thread_id=thread_id,
991
+ idempotency_key=raw.external_id,
992
+ )
993
+
994
+ @staticmethod
995
+ def _map_calendar_permissions(raw: RawEvent) -> Visibility:
996
+ """Map Calendar event visibility.
997
+
998
+ - ``public`` visibility -> ORGANIZATION
999
+ - ``private`` / ``confidential`` -> RESTRICTED with attendees
1000
+ - default -> ORGANIZATION
1001
+ """
1002
+ perms = raw.permissions
1003
+ vis = perms.get("visibility", "default")
1004
+ if vis in ("private", "confidential"):
1005
+ attendees = tuple(
1006
+ e for e in perms.get("attendee_emails", []) if e
1007
+ )
1008
+ return Visibility(
1009
+ level=VisibilityLevel.RESTRICTED,
1010
+ allowed_principals=attendees,
1011
+ )
1012
+ return Visibility(level=VisibilityLevel.ORGANIZATION)
1013
+
1014
+ # -- helpers -------------------------------------------------------------
1015
+
1016
+ @staticmethod
1017
+ def _mime_to_label(mime_type: str) -> str:
1018
+ """Map a MIME type to a human-readable document type label."""
1019
+ mime_map = {
1020
+ "application/vnd.google-apps.document": "Google Doc",
1021
+ "application/vnd.google-apps.spreadsheet": "Google Sheet",
1022
+ "application/vnd.google-apps.presentation": "Google Slides",
1023
+ "application/vnd.google-apps.form": "Google Form",
1024
+ "application/vnd.google-apps.drawing": "Google Drawing",
1025
+ "application/pdf": "PDF",
1026
+ "image/": "Image",
1027
+ "video/": "Video",
1028
+ "audio/": "Audio",
1029
+ }
1030
+ for prefix, label in mime_map.items():
1031
+ if mime_type.startswith(prefix):
1032
+ return label
1033
+ return "File"