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,656 @@
1
+ """
2
+ Microsoft Teams connector for CortexDB.
3
+
4
+ Ingests channel messages, chat messages, threaded replies, meeting transcripts,
5
+ and file shares from Microsoft Teams into CortexDB as Episodes. Uses the
6
+ Microsoft Graph API v1.0 via ``httpx`` for HTTP access, and ``msal`` for
7
+ Azure AD OAuth2 client-credentials authentication.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import logging
14
+ from datetime import datetime, timedelta, timezone
15
+ from typing import Any
16
+
17
+ from cortexdb_connectors.base import (
18
+ Actor,
19
+ CortexConnector,
20
+ EntityRef,
21
+ Episode,
22
+ EpisodeType,
23
+ RawEvent,
24
+ Source,
25
+ Visibility,
26
+ VisibilityLevel,
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ _SOURCE = Source(system="teams")
32
+ _GRAPH_BASE = "https://graph.microsoft.com/v1.0"
33
+ _PAGE_SIZE = 50
34
+
35
+ # Microsoft Graph throttling: respect Retry-After, default 60 req/min.
36
+ _DEFAULT_RETRY_DELAY = 5
37
+ _RATE_LIMIT_DELAY = 1.0
38
+
39
+
40
+ class TeamsConnector(CortexConnector):
41
+ """Connector that syncs Microsoft Teams messages into CortexDB.
42
+
43
+ Uses Azure AD application (client credentials) flow to authenticate
44
+ against the Microsoft Graph API. Requires an Azure AD app registration
45
+ with the following *application* permissions:
46
+
47
+ - ``ChannelMessage.Read.All``
48
+ - ``Chat.Read.All``
49
+ - ``User.Read.All``
50
+ - ``TeamMember.Read.All``
51
+
52
+ Parameters
53
+ ----------
54
+ cortex_url:
55
+ Base URL of the CortexDB API.
56
+ cortex_api_key:
57
+ Bearer token for CortexDB authentication.
58
+ azure_tenant_id:
59
+ Azure AD tenant (directory) ID.
60
+ client_id:
61
+ Azure AD application (client) ID.
62
+ client_secret:
63
+ Azure AD client secret.
64
+ team_ids:
65
+ List of Teams team IDs to sync. Required for channel message
66
+ ingestion.
67
+ include_chats:
68
+ Whether to ingest 1:1 and group chat messages.
69
+ namespace:
70
+ CortexDB namespace for ingested episodes.
71
+ tenant_id:
72
+ CortexDB tenant identifier.
73
+ backfill_days:
74
+ Number of days of history to fetch on the first sync.
75
+ poll_interval_sec:
76
+ Seconds between polling cycles (used by the worker loop, not
77
+ by ``fetch_events`` directly).
78
+ """
79
+
80
+ connector_name: str = "teams"
81
+
82
+ def __init__(
83
+ self,
84
+ cortex_url: str,
85
+ cortex_api_key: str,
86
+ azure_tenant_id: str,
87
+ client_id: str,
88
+ client_secret: str,
89
+ team_ids: list[str] | None = None,
90
+ include_chats: bool = False,
91
+ namespace: str = "teams",
92
+ tenant_id: str = "default",
93
+ backfill_days: int = 30,
94
+ poll_interval_sec: int = 30,
95
+ ) -> None:
96
+ super().__init__(cortex_url, cortex_api_key, tenant_id)
97
+ self.azure_tenant_id = azure_tenant_id
98
+ self.client_id = client_id
99
+ self.client_secret = client_secret
100
+ self.team_ids = team_ids or []
101
+ self.include_chats = include_chats
102
+ self.namespace = namespace
103
+ self.backfill_days = backfill_days
104
+ self.poll_interval_sec = poll_interval_sec
105
+
106
+ self._access_token: str | None = None
107
+ self._token_expires_at: datetime = datetime.min.replace(tzinfo=timezone.utc)
108
+ self._user_cache: dict[str, dict[str, Any]] = {}
109
+
110
+ # -- authentication ------------------------------------------------------
111
+
112
+ async def _ensure_token(self) -> str:
113
+ """Acquire or refresh an Azure AD access token via MSAL."""
114
+ now = datetime.now(timezone.utc)
115
+ if self._access_token and now < self._token_expires_at:
116
+ return self._access_token
117
+
118
+ import msal
119
+
120
+ app = msal.ConfidentialClientApplication(
121
+ client_id=self.client_id,
122
+ client_credential=self.client_secret,
123
+ authority=f"https://login.microsoftonline.com/{self.azure_tenant_id}",
124
+ )
125
+ result = app.acquire_token_for_client(
126
+ scopes=["https://graph.microsoft.com/.default"]
127
+ )
128
+ if "access_token" not in result:
129
+ error = result.get("error_description", result.get("error", "unknown"))
130
+ raise RuntimeError(f"Azure AD token acquisition failed: {error}")
131
+
132
+ self._access_token = result["access_token"]
133
+ # MSAL returns expires_in in seconds; buffer 60 s for safety.
134
+ expires_in = int(result.get("expires_in", 3600))
135
+ self._token_expires_at = now + timedelta(seconds=expires_in - 60)
136
+ return self._access_token
137
+
138
+ # -- Graph API helpers ---------------------------------------------------
139
+
140
+ async def _graph_get(
141
+ self,
142
+ client: Any,
143
+ url: str,
144
+ params: dict[str, Any] | None = None,
145
+ ) -> dict[str, Any]:
146
+ """Perform a GET against Microsoft Graph with retry-after handling."""
147
+ token = await self._ensure_token()
148
+ headers = {"Authorization": f"Bearer {token}"}
149
+
150
+ for attempt in range(5):
151
+ resp = await client.get(url, headers=headers, params=params, timeout=30.0)
152
+
153
+ if resp.status_code == 429:
154
+ retry_after = int(resp.headers.get("Retry-After", _DEFAULT_RETRY_DELAY))
155
+ logger.warning(
156
+ "Graph API rate-limited; sleeping %d s (attempt %d)",
157
+ retry_after,
158
+ attempt + 1,
159
+ )
160
+ await asyncio.sleep(retry_after)
161
+ continue
162
+
163
+ if resp.status_code == 401:
164
+ # Token may have expired mid-sync; force refresh.
165
+ self._access_token = None
166
+ token = await self._ensure_token()
167
+ headers = {"Authorization": f"Bearer {token}"}
168
+ continue
169
+
170
+ resp.raise_for_status()
171
+ return resp.json()
172
+
173
+ raise RuntimeError(f"Graph API request failed after 5 retries: {url}")
174
+
175
+ async def _graph_get_all_pages(
176
+ self,
177
+ client: Any,
178
+ url: str,
179
+ params: dict[str, Any] | None = None,
180
+ value_key: str = "value",
181
+ ) -> list[dict[str, Any]]:
182
+ """Paginate through all ``@odata.nextLink`` pages."""
183
+ items: list[dict[str, Any]] = []
184
+ next_url: str | None = url
185
+ current_params = params
186
+
187
+ while next_url:
188
+ data = await self._graph_get(client, next_url, params=current_params)
189
+ items.extend(data.get(value_key, []))
190
+ next_url = data.get("@odata.nextLink")
191
+ # After the first page the nextLink includes query params already.
192
+ current_params = None
193
+ await asyncio.sleep(_RATE_LIMIT_DELAY)
194
+
195
+ return items
196
+
197
+ # -- CortexConnector interface -------------------------------------------
198
+
199
+ async def fetch_events(
200
+ self, since: datetime | None = None,
201
+ ) -> list[RawEvent]:
202
+ """Fetch channel messages, chat messages, and meeting data from Teams."""
203
+ import httpx
204
+
205
+ if since is None:
206
+ since = datetime.now(timezone.utc) - timedelta(days=self.backfill_days)
207
+
208
+ since_iso = since.strftime("%Y-%m-%dT%H:%M:%SZ")
209
+ events: list[RawEvent] = []
210
+
211
+ async with httpx.AsyncClient() as client:
212
+ # --- Channel messages ---
213
+ for team_id in self.team_ids:
214
+ channel_events = await self._fetch_team_channels(
215
+ client, team_id, since_iso
216
+ )
217
+ events.extend(channel_events)
218
+
219
+ # --- 1:1 and group chats ---
220
+ if self.include_chats:
221
+ chat_events = await self._fetch_chats(client, since_iso)
222
+ events.extend(chat_events)
223
+
224
+ return events
225
+
226
+ async def _fetch_team_channels(
227
+ self,
228
+ client: Any,
229
+ team_id: str,
230
+ since_iso: str,
231
+ ) -> list[RawEvent]:
232
+ """Fetch messages from all channels in a team."""
233
+ events: list[RawEvent] = []
234
+ channels_url = f"{_GRAPH_BASE}/teams/{team_id}/channels"
235
+ channels = await self._graph_get_all_pages(client, channels_url)
236
+
237
+ for channel in channels:
238
+ channel_id = channel["id"]
239
+ channel_name = channel.get("displayName", channel_id)
240
+ membership_type = channel.get("membershipType", "standard")
241
+
242
+ messages_url = (
243
+ f"{_GRAPH_BASE}/teams/{team_id}/channels/{channel_id}/messages"
244
+ )
245
+ params = {
246
+ "$top": str(_PAGE_SIZE),
247
+ "$filter": f"lastModifiedDateTime gt {since_iso}",
248
+ }
249
+
250
+ try:
251
+ messages = await self._graph_get_all_pages(
252
+ client, messages_url, params=params
253
+ )
254
+ except Exception:
255
+ logger.warning(
256
+ "Failed to fetch messages for channel %s/%s; skipping",
257
+ team_id,
258
+ channel_id,
259
+ )
260
+ continue
261
+
262
+ for msg in messages:
263
+ raw = self._message_to_raw_event(
264
+ msg,
265
+ context_type="channel",
266
+ team_id=team_id,
267
+ channel_id=channel_id,
268
+ channel_name=channel_name,
269
+ membership_type=membership_type,
270
+ )
271
+ events.append(raw)
272
+
273
+ # Fetch threaded replies.
274
+ reply_events = await self._fetch_replies(
275
+ client, team_id, channel_id, msg["id"],
276
+ channel_name, membership_type, since_iso,
277
+ )
278
+ events.extend(reply_events)
279
+
280
+ return events
281
+
282
+ async def _fetch_replies(
283
+ self,
284
+ client: Any,
285
+ team_id: str,
286
+ channel_id: str,
287
+ message_id: str,
288
+ channel_name: str,
289
+ membership_type: str,
290
+ since_iso: str,
291
+ ) -> list[RawEvent]:
292
+ """Fetch replies to a channel message."""
293
+ replies_url = (
294
+ f"{_GRAPH_BASE}/teams/{team_id}/channels/{channel_id}"
295
+ f"/messages/{message_id}/replies"
296
+ )
297
+ params = {"$top": str(_PAGE_SIZE)}
298
+
299
+ try:
300
+ replies = await self._graph_get_all_pages(client, replies_url, params=params)
301
+ except Exception:
302
+ logger.debug("Could not fetch replies for message %s", message_id)
303
+ return []
304
+
305
+ events: list[RawEvent] = []
306
+ for reply in replies:
307
+ created = reply.get("createdDateTime", "")
308
+ if created and created < since_iso:
309
+ continue
310
+ raw = self._message_to_raw_event(
311
+ reply,
312
+ context_type="channel_reply",
313
+ team_id=team_id,
314
+ channel_id=channel_id,
315
+ channel_name=channel_name,
316
+ membership_type=membership_type,
317
+ parent_message_id=message_id,
318
+ )
319
+ events.append(raw)
320
+ return events
321
+
322
+ async def _fetch_chats(
323
+ self,
324
+ client: Any,
325
+ since_iso: str,
326
+ ) -> list[RawEvent]:
327
+ """Fetch messages from 1:1 and group chats."""
328
+ events: list[RawEvent] = []
329
+ chats_url = f"{_GRAPH_BASE}/chats"
330
+ params = {"$top": str(_PAGE_SIZE), "$expand": "members"}
331
+
332
+ try:
333
+ chats = await self._graph_get_all_pages(client, chats_url, params=params)
334
+ except Exception:
335
+ logger.warning("Failed to fetch chats; skipping chat ingestion")
336
+ return events
337
+
338
+ for chat in chats:
339
+ chat_id = chat["id"]
340
+ chat_type = chat.get("chatType", "oneOnOne")
341
+ members = [
342
+ m.get("displayName", "")
343
+ for m in chat.get("members", [])
344
+ ]
345
+ member_ids = [
346
+ m.get("userId", "")
347
+ for m in chat.get("members", [])
348
+ if m.get("userId")
349
+ ]
350
+
351
+ messages_url = f"{_GRAPH_BASE}/chats/{chat_id}/messages"
352
+ params_msgs = {
353
+ "$top": str(_PAGE_SIZE),
354
+ "$filter": f"lastModifiedDateTime gt {since_iso}",
355
+ }
356
+
357
+ try:
358
+ messages = await self._graph_get_all_pages(
359
+ client, messages_url, params=params_msgs
360
+ )
361
+ except Exception:
362
+ logger.debug("Could not fetch messages for chat %s", chat_id)
363
+ continue
364
+
365
+ for msg in messages:
366
+ raw = self._message_to_raw_event(
367
+ msg,
368
+ context_type="chat",
369
+ chat_id=chat_id,
370
+ chat_type=chat_type,
371
+ chat_members=members,
372
+ chat_member_ids=member_ids,
373
+ )
374
+ events.append(raw)
375
+
376
+ return events
377
+
378
+ # -- raw event construction ----------------------------------------------
379
+
380
+ def _message_to_raw_event(
381
+ self,
382
+ msg: dict[str, Any],
383
+ *,
384
+ context_type: str,
385
+ team_id: str | None = None,
386
+ channel_id: str | None = None,
387
+ channel_name: str | None = None,
388
+ membership_type: str | None = None,
389
+ parent_message_id: str | None = None,
390
+ chat_id: str | None = None,
391
+ chat_type: str | None = None,
392
+ chat_members: list[str] | None = None,
393
+ chat_member_ids: list[str] | None = None,
394
+ ) -> RawEvent:
395
+ """Transform a Graph API message object into a ``RawEvent``."""
396
+ msg_id = msg.get("id", "")
397
+ created = msg.get("createdDateTime", "")
398
+ timestamp = self._parse_datetime(created)
399
+
400
+ # Detect file attachments -> DOCUMENT type marker.
401
+ attachments = msg.get("attachments", [])
402
+ has_file = any(
403
+ a.get("contentType", "").startswith("reference")
404
+ or a.get("contentType") == "application/vnd.microsoft.card.adaptive"
405
+ for a in attachments
406
+ )
407
+
408
+ # Detect meeting-related messages.
409
+ event_detail = msg.get("eventDetail")
410
+ is_meeting = event_detail is not None and "call" in str(
411
+ event_detail.get("@odata.type", "")
412
+ ).lower()
413
+
414
+ data: dict[str, Any] = {
415
+ **msg,
416
+ "_context_type": context_type,
417
+ "_team_id": team_id,
418
+ "_channel_id": channel_id,
419
+ "_channel_name": channel_name,
420
+ "_membership_type": membership_type,
421
+ "_parent_message_id": parent_message_id,
422
+ "_chat_id": chat_id,
423
+ "_chat_type": chat_type,
424
+ "_chat_members": chat_members or [],
425
+ "_chat_member_ids": chat_member_ids or [],
426
+ "_has_file": has_file,
427
+ "_is_meeting": is_meeting,
428
+ }
429
+
430
+ # Build external_id for idempotency.
431
+ if context_type == "chat":
432
+ external_id = f"teams:chat:{chat_id}:{msg_id}"
433
+ else:
434
+ external_id = f"teams:channel:{team_id}:{channel_id}:{msg_id}"
435
+
436
+ # Determine permissions.
437
+ is_private = (
438
+ context_type == "chat"
439
+ or membership_type == "private"
440
+ )
441
+ allowed = tuple(chat_member_ids or []) if context_type == "chat" else ()
442
+
443
+ return RawEvent(
444
+ source="teams",
445
+ external_id=external_id,
446
+ timestamp=timestamp,
447
+ data=data,
448
+ permissions={
449
+ "is_private": is_private,
450
+ "allowed_principals": allowed,
451
+ "membership_type": membership_type or chat_type or "standard",
452
+ },
453
+ )
454
+
455
+ # -- CortexConnector interface: normalize --------------------------------
456
+
457
+ def normalize(self, raw: RawEvent) -> Episode:
458
+ """Convert a Teams message ``RawEvent`` into a CortexDB ``Episode``."""
459
+ data = raw.data
460
+
461
+ # Determine episode type.
462
+ if data.get("_is_meeting"):
463
+ episode_type = EpisodeType.MEETING
464
+ elif data.get("_has_file"):
465
+ episode_type = EpisodeType.DOCUMENT
466
+ else:
467
+ episode_type = EpisodeType.MESSAGE
468
+
469
+ # Actor.
470
+ from_field = data.get("from", {}) or {}
471
+ user_field = from_field.get("user", {}) or {}
472
+ actor = Actor(
473
+ id=user_field.get("id", "unknown"),
474
+ name=user_field.get("displayName", "unknown"),
475
+ email=user_field.get("userPrincipalName"),
476
+ )
477
+
478
+ # Content: prefer plaintext body.
479
+ body = data.get("body", {}) or {}
480
+ content = body.get("content", "")
481
+ if body.get("contentType") == "html":
482
+ # Strip HTML tags for plain text storage.
483
+ content = self._strip_html(content)
484
+
485
+ # Entities.
486
+ entities = self._extract_entities(data)
487
+
488
+ # Tags.
489
+ context_type = data.get("_context_type", "channel")
490
+ tags = ["teams", context_type]
491
+ if episode_type == EpisodeType.MEETING:
492
+ tags.append("meeting")
493
+ if data.get("_has_file"):
494
+ tags.append("file_share")
495
+ if data.get("importance", "normal") == "high":
496
+ tags.append("high_importance")
497
+
498
+ # Thread ID: group channel replies under the parent message.
499
+ team_id = data.get("_team_id")
500
+ channel_id = data.get("_channel_id")
501
+ parent_msg = data.get("_parent_message_id")
502
+ chat_id = data.get("_chat_id")
503
+
504
+ if context_type == "channel_reply" and parent_msg:
505
+ thread_id = f"teams:{team_id}:{channel_id}:{parent_msg}"
506
+ elif context_type == "chat" and chat_id:
507
+ thread_id = f"teams:chat:{chat_id}"
508
+ else:
509
+ thread_id = None
510
+
511
+ parent_id = None
512
+ if context_type == "channel_reply" and parent_msg:
513
+ parent_id = f"teams:channel:{team_id}:{channel_id}:{parent_msg}"
514
+
515
+ # Metadata.
516
+ metadata: dict[str, Any] = {
517
+ "context_type": context_type,
518
+ "message_type": data.get("messageType", "message"),
519
+ "importance": data.get("importance", "normal"),
520
+ }
521
+ if team_id:
522
+ metadata["team_id"] = team_id
523
+ if channel_id:
524
+ metadata["channel_id"] = channel_id
525
+ metadata["channel_name"] = data.get("_channel_name", "")
526
+ if chat_id:
527
+ metadata["chat_id"] = chat_id
528
+ metadata["chat_type"] = data.get("_chat_type", "")
529
+ if data.get("attachments"):
530
+ metadata["attachment_count"] = len(data["attachments"])
531
+ mentions = data.get("mentions", [])
532
+ if mentions:
533
+ metadata["mentions"] = [
534
+ m.get("mentioned", {}).get("user", {}).get("displayName", "")
535
+ for m in mentions
536
+ ]
537
+
538
+ return Episode(
539
+ actor=actor,
540
+ source=_SOURCE,
541
+ episode_type=episode_type,
542
+ occurred_at=raw.timestamp,
543
+ content=content,
544
+ raw_payload=data,
545
+ tenant_id=self.tenant_id,
546
+ namespace=self.namespace,
547
+ entities=entities,
548
+ tags=tags,
549
+ metadata=metadata,
550
+ thread_id=thread_id,
551
+ parent_id=parent_id,
552
+ idempotency_key=raw.external_id,
553
+ )
554
+
555
+ # -- CortexConnector interface: map_permissions --------------------------
556
+
557
+ def map_permissions(self, raw: RawEvent) -> Visibility:
558
+ """Map Teams channel/chat privacy to CortexDB visibility.
559
+
560
+ - Standard (public) channels -> ``ORGANIZATION``
561
+ - Private channels -> ``RESTRICTED`` (org-visible but flagged)
562
+ - 1:1 / group chats -> ``PRIVATE`` with member allow-list
563
+ """
564
+ perms = raw.permissions
565
+ if perms.get("is_private"):
566
+ principals = tuple(str(p) for p in perms.get("allowed_principals", ()))
567
+ if principals:
568
+ return Visibility(
569
+ level=VisibilityLevel.PRIVATE,
570
+ allowed_principals=principals,
571
+ )
572
+ return Visibility(level=VisibilityLevel.RESTRICTED)
573
+ return Visibility(level=VisibilityLevel.ORGANIZATION)
574
+
575
+ # -- helpers -------------------------------------------------------------
576
+
577
+ def _extract_entities(self, data: dict[str, Any]) -> list[EntityRef]:
578
+ """Extract entity references from a Teams message."""
579
+ entities: list[EntityRef] = []
580
+
581
+ team_id = data.get("_team_id")
582
+ if team_id:
583
+ entities.append(
584
+ EntityRef(
585
+ entity_type="team",
586
+ entity_id=team_id,
587
+ display_name=f"team:{team_id}",
588
+ )
589
+ )
590
+
591
+ channel_id = data.get("_channel_id")
592
+ if channel_id:
593
+ entities.append(
594
+ EntityRef(
595
+ entity_type="channel",
596
+ entity_id=channel_id,
597
+ display_name=data.get("_channel_name", channel_id),
598
+ )
599
+ )
600
+
601
+ chat_id = data.get("_chat_id")
602
+ if chat_id:
603
+ entities.append(
604
+ EntityRef(
605
+ entity_type="chat",
606
+ entity_id=chat_id,
607
+ display_name=f"chat:{data.get('_chat_type', 'oneOnOne')}",
608
+ )
609
+ )
610
+
611
+ # Extract mentioned users.
612
+ for mention in data.get("mentions", []):
613
+ mentioned_user = mention.get("mentioned", {}).get("user", {})
614
+ if mentioned_user.get("id"):
615
+ entities.append(
616
+ EntityRef(
617
+ entity_type="user",
618
+ entity_id=mentioned_user["id"],
619
+ display_name=mentioned_user.get("displayName", ""),
620
+ )
621
+ )
622
+
623
+ # Extract actors from chat members.
624
+ for member_id in data.get("_chat_member_ids", []):
625
+ entities.append(
626
+ EntityRef(
627
+ entity_type="user",
628
+ entity_id=member_id,
629
+ display_name="",
630
+ )
631
+ )
632
+
633
+ return entities
634
+
635
+ @staticmethod
636
+ def _parse_datetime(iso_str: str) -> datetime:
637
+ """Parse an ISO 8601 datetime string from the Graph API."""
638
+ if not iso_str:
639
+ return datetime.now(timezone.utc)
640
+ # Graph API returns e.g. "2026-03-15T10:30:00.000Z"
641
+ cleaned = iso_str.replace("Z", "+00:00")
642
+ try:
643
+ return datetime.fromisoformat(cleaned)
644
+ except ValueError:
645
+ logger.debug("Could not parse datetime %r; using now()", iso_str)
646
+ return datetime.now(timezone.utc)
647
+
648
+ @staticmethod
649
+ def _strip_html(html: str) -> str:
650
+ """Naively strip HTML tags for plain-text extraction."""
651
+ import re
652
+
653
+ text = re.sub(r"<br\s*/?>", "\n", html)
654
+ text = re.sub(r"<[^>]+>", "", text)
655
+ text = text.strip()
656
+ return text