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,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
|