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.
- cortexdb_connectors/__init__.py +40 -0
- cortexdb_connectors/base.py +364 -0
- cortexdb_connectors/cli.py +476 -0
- cortexdb_connectors/confluence/__init__.py +359 -0
- cortexdb_connectors/discord/__init__.py +646 -0
- cortexdb_connectors/github/__init__.py +309 -0
- cortexdb_connectors/gitlab/__init__.py +749 -0
- cortexdb_connectors/google_workspace/__init__.py +1033 -0
- cortexdb_connectors/hubspot/__init__.py +489 -0
- cortexdb_connectors/insights/__init__.py +40 -0
- cortexdb_connectors/insights/api.py +252 -0
- cortexdb_connectors/insights/detectors.py +771 -0
- cortexdb_connectors/insights/engine.py +308 -0
- cortexdb_connectors/intercom/__init__.py +631 -0
- cortexdb_connectors/jira/__init__.py +298 -0
- cortexdb_connectors/linear/__init__.py +705 -0
- cortexdb_connectors/notion/__init__.py +702 -0
- cortexdb_connectors/pagerduty/__init__.py +298 -0
- cortexdb_connectors/salesforce/__init__.py +399 -0
- cortexdb_connectors/servicenow/__init__.py +460 -0
- cortexdb_connectors/slack/__init__.py +331 -0
- cortexdb_connectors/state.py +145 -0
- cortexdb_connectors/teams/__init__.py +656 -0
- cortexdb_connectors/webhooks.py +672 -0
- cortexdb_connectors/worker.py +192 -0
- cortexdb_connectors/zendesk/__init__.py +496 -0
- cortexdb_connectors-0.2.0.dist-info/METADATA +207 -0
- cortexdb_connectors-0.2.0.dist-info/RECORD +31 -0
- cortexdb_connectors-0.2.0.dist-info/WHEEL +5 -0
- cortexdb_connectors-0.2.0.dist-info/entry_points.txt +2 -0
- cortexdb_connectors-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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"
|