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,631 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Intercom connector for CortexDB.
|
|
3
|
+
|
|
4
|
+
Polls Intercom API v2 for Conversations, Conversation parts, Articles,
|
|
5
|
+
and Contacts, converting them into CortexDB Episodes. Uses ``httpx``
|
|
6
|
+
for direct HTTP access with cursor-based pagination (``starting_after``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
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="intercom")
|
|
32
|
+
_BASE_URL = "https://api.intercom.io"
|
|
33
|
+
_MAX_RETRIES = 3
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class IntercomConnector(CortexConnector):
|
|
37
|
+
"""Connector that syncs Intercom conversations and articles into CortexDB.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
cortex_url:
|
|
42
|
+
Base URL of the CortexDB API.
|
|
43
|
+
cortex_api_key:
|
|
44
|
+
Bearer token for CortexDB authentication.
|
|
45
|
+
access_token:
|
|
46
|
+
Intercom access token.
|
|
47
|
+
include_articles:
|
|
48
|
+
Whether to ingest Help Center articles.
|
|
49
|
+
namespace:
|
|
50
|
+
CortexDB namespace for ingested episodes.
|
|
51
|
+
backfill_days:
|
|
52
|
+
Number of days of historical data to backfill on first sync.
|
|
53
|
+
tenant_id:
|
|
54
|
+
CortexDB tenant identifier.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
connector_name: str = "intercom"
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
cortex_url: str,
|
|
62
|
+
cortex_api_key: str,
|
|
63
|
+
access_token: str,
|
|
64
|
+
include_articles: bool = False,
|
|
65
|
+
namespace: str = "intercom",
|
|
66
|
+
backfill_days: int = 90,
|
|
67
|
+
tenant_id: str = "default",
|
|
68
|
+
) -> None:
|
|
69
|
+
super().__init__(cortex_url, cortex_api_key, tenant_id)
|
|
70
|
+
self.access_token = access_token
|
|
71
|
+
self.include_articles = include_articles
|
|
72
|
+
self.namespace = namespace
|
|
73
|
+
self.backfill_days = backfill_days
|
|
74
|
+
|
|
75
|
+
# -- HTTP helpers --------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def _headers(self) -> dict[str, str]:
|
|
78
|
+
return {
|
|
79
|
+
"Authorization": f"Bearer {self.access_token}",
|
|
80
|
+
"Accept": "application/json",
|
|
81
|
+
"Content-Type": "application/json",
|
|
82
|
+
"Intercom-Version": "2.11",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async def _request(
|
|
86
|
+
self,
|
|
87
|
+
client: httpx.AsyncClient,
|
|
88
|
+
method: str,
|
|
89
|
+
path: str,
|
|
90
|
+
**kwargs: Any,
|
|
91
|
+
) -> httpx.Response:
|
|
92
|
+
"""Execute an HTTP request with rate-limit retry."""
|
|
93
|
+
url = f"{_BASE_URL}{path}"
|
|
94
|
+
for attempt in range(_MAX_RETRIES):
|
|
95
|
+
resp = await client.request(
|
|
96
|
+
method, url, headers=self._headers(), **kwargs
|
|
97
|
+
)
|
|
98
|
+
if resp.status_code == 429:
|
|
99
|
+
import asyncio
|
|
100
|
+
|
|
101
|
+
retry_after = int(resp.headers.get("Retry-After", "5"))
|
|
102
|
+
logger.warning(
|
|
103
|
+
"Intercom rate limited, retrying in %ds (attempt %d/%d)",
|
|
104
|
+
retry_after,
|
|
105
|
+
attempt + 1,
|
|
106
|
+
_MAX_RETRIES,
|
|
107
|
+
)
|
|
108
|
+
await asyncio.sleep(retry_after)
|
|
109
|
+
continue
|
|
110
|
+
resp.raise_for_status()
|
|
111
|
+
return resp
|
|
112
|
+
raise httpx.HTTPStatusError(
|
|
113
|
+
"Rate limit retries exhausted",
|
|
114
|
+
request=resp.request, # type: ignore[possibly-undefined]
|
|
115
|
+
response=resp, # type: ignore[possibly-undefined]
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# -- CortexConnector interface -------------------------------------------
|
|
119
|
+
|
|
120
|
+
async def fetch_events(
|
|
121
|
+
self, since: datetime | None = None
|
|
122
|
+
) -> list[RawEvent]:
|
|
123
|
+
"""Fetch conversations, parts, contacts, and articles from Intercom."""
|
|
124
|
+
cutoff = since or datetime.now(timezone.utc) - timedelta(
|
|
125
|
+
days=self.backfill_days
|
|
126
|
+
)
|
|
127
|
+
raw_events: list[RawEvent] = []
|
|
128
|
+
|
|
129
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
130
|
+
raw_events.extend(
|
|
131
|
+
await self._fetch_conversations(client, cutoff)
|
|
132
|
+
)
|
|
133
|
+
raw_events.extend(await self._fetch_contacts(client, cutoff))
|
|
134
|
+
if self.include_articles:
|
|
135
|
+
raw_events.extend(await self._fetch_articles(client, cutoff))
|
|
136
|
+
|
|
137
|
+
return raw_events
|
|
138
|
+
|
|
139
|
+
def normalize(self, raw: RawEvent) -> Episode:
|
|
140
|
+
"""Convert an Intercom ``RawEvent`` into a CortexDB ``Episode``."""
|
|
141
|
+
kind = raw.data.get("event_kind", "conversation")
|
|
142
|
+
if kind == "conversation":
|
|
143
|
+
return self._normalize_conversation(raw)
|
|
144
|
+
if kind == "conversation_part":
|
|
145
|
+
return self._normalize_part(raw)
|
|
146
|
+
if kind == "article":
|
|
147
|
+
return self._normalize_article(raw)
|
|
148
|
+
if kind == "contact":
|
|
149
|
+
return self._normalize_contact(raw)
|
|
150
|
+
return self._normalize_conversation(raw)
|
|
151
|
+
|
|
152
|
+
def map_permissions(self, raw: RawEvent) -> Visibility:
|
|
153
|
+
"""Map Intercom visibility to CortexDB visibility.
|
|
154
|
+
|
|
155
|
+
Conversations are ``Organization`` by default. Internal admin
|
|
156
|
+
notes are ``Restricted``.
|
|
157
|
+
"""
|
|
158
|
+
data = raw.data
|
|
159
|
+
if data.get("is_admin_only"):
|
|
160
|
+
return Visibility(level=VisibilityLevel.RESTRICTED)
|
|
161
|
+
return Visibility(level=VisibilityLevel.ORGANIZATION)
|
|
162
|
+
|
|
163
|
+
# -- fetchers ------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
async def _fetch_conversations(
|
|
166
|
+
self,
|
|
167
|
+
client: httpx.AsyncClient,
|
|
168
|
+
cutoff: datetime,
|
|
169
|
+
) -> list[RawEvent]:
|
|
170
|
+
"""Fetch conversations and their parts using search + pagination."""
|
|
171
|
+
events: list[RawEvent] = []
|
|
172
|
+
cutoff_unix = int(cutoff.timestamp())
|
|
173
|
+
|
|
174
|
+
# Use search endpoint to filter by updated_at.
|
|
175
|
+
starting_after: str | None = None
|
|
176
|
+
|
|
177
|
+
while True:
|
|
178
|
+
payload: dict[str, Any] = {
|
|
179
|
+
"query": {
|
|
180
|
+
"field": "updated_at",
|
|
181
|
+
"operator": ">",
|
|
182
|
+
"value": cutoff_unix,
|
|
183
|
+
},
|
|
184
|
+
"pagination": {"per_page": 50},
|
|
185
|
+
}
|
|
186
|
+
if starting_after:
|
|
187
|
+
payload["pagination"]["starting_after"] = starting_after
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
resp = await self._request(
|
|
191
|
+
client, "POST", "/conversations/search", json=payload
|
|
192
|
+
)
|
|
193
|
+
except Exception:
|
|
194
|
+
logger.exception("Failed to search Intercom conversations")
|
|
195
|
+
break
|
|
196
|
+
|
|
197
|
+
body = resp.json()
|
|
198
|
+
conversations = body.get("conversations", [])
|
|
199
|
+
|
|
200
|
+
for conv in conversations:
|
|
201
|
+
ts = self._epoch_to_dt(conv.get("updated_at"))
|
|
202
|
+
events.append(
|
|
203
|
+
RawEvent(
|
|
204
|
+
source="intercom",
|
|
205
|
+
external_id=conv.get("id", ""),
|
|
206
|
+
timestamp=ts,
|
|
207
|
+
data={
|
|
208
|
+
"event_kind": "conversation",
|
|
209
|
+
"conversation": conv,
|
|
210
|
+
"is_admin_only": False,
|
|
211
|
+
},
|
|
212
|
+
permissions={
|
|
213
|
+
"admin_ids": [
|
|
214
|
+
a.get("id", "")
|
|
215
|
+
for a in conv.get("admins", {}).get(
|
|
216
|
+
"admins", []
|
|
217
|
+
)
|
|
218
|
+
],
|
|
219
|
+
"tags": [
|
|
220
|
+
t.get("name", "")
|
|
221
|
+
for t in conv.get("tags", {}).get("tags", [])
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Fetch conversation parts.
|
|
228
|
+
events.extend(
|
|
229
|
+
await self._fetch_parts(client, conv)
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Cursor-based pagination.
|
|
233
|
+
pages = body.get("pages", {})
|
|
234
|
+
next_cursor = pages.get("next", {})
|
|
235
|
+
if next_cursor and next_cursor.get("starting_after"):
|
|
236
|
+
starting_after = next_cursor["starting_after"]
|
|
237
|
+
else:
|
|
238
|
+
break
|
|
239
|
+
|
|
240
|
+
return events
|
|
241
|
+
|
|
242
|
+
async def _fetch_parts(
|
|
243
|
+
self,
|
|
244
|
+
client: httpx.AsyncClient,
|
|
245
|
+
conversation: dict[str, Any],
|
|
246
|
+
) -> list[RawEvent]:
|
|
247
|
+
"""Fetch conversation parts (replies, notes) for a conversation."""
|
|
248
|
+
events: list[RawEvent] = []
|
|
249
|
+
conv_id = conversation.get("id", "")
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
resp = await self._request(
|
|
253
|
+
client, "GET", f"/conversations/{conv_id}"
|
|
254
|
+
)
|
|
255
|
+
except Exception:
|
|
256
|
+
logger.debug("Could not fetch parts for conversation %s", conv_id)
|
|
257
|
+
return events
|
|
258
|
+
|
|
259
|
+
body = resp.json()
|
|
260
|
+
parts = body.get("conversation_parts", {}).get(
|
|
261
|
+
"conversation_parts", []
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
for part in parts:
|
|
265
|
+
ts = self._epoch_to_dt(part.get("updated_at") or part.get("created_at"))
|
|
266
|
+
is_admin_note = part.get("part_type") == "note"
|
|
267
|
+
|
|
268
|
+
events.append(
|
|
269
|
+
RawEvent(
|
|
270
|
+
source="intercom",
|
|
271
|
+
external_id=f"part:{part.get('id', '')}",
|
|
272
|
+
timestamp=ts,
|
|
273
|
+
data={
|
|
274
|
+
"event_kind": "conversation_part",
|
|
275
|
+
"part": part,
|
|
276
|
+
"conversation_id": conv_id,
|
|
277
|
+
"conversation_subject": conversation.get(
|
|
278
|
+
"source", {}
|
|
279
|
+
).get("subject", ""),
|
|
280
|
+
"is_admin_only": is_admin_note,
|
|
281
|
+
},
|
|
282
|
+
permissions={
|
|
283
|
+
"author_type": part.get("author", {}).get("type", ""),
|
|
284
|
+
},
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
return events
|
|
289
|
+
|
|
290
|
+
async def _fetch_contacts(
|
|
291
|
+
self,
|
|
292
|
+
client: httpx.AsyncClient,
|
|
293
|
+
cutoff: datetime,
|
|
294
|
+
) -> list[RawEvent]:
|
|
295
|
+
"""Fetch contacts updated since cutoff."""
|
|
296
|
+
events: list[RawEvent] = []
|
|
297
|
+
cutoff_unix = int(cutoff.timestamp())
|
|
298
|
+
starting_after: str | None = None
|
|
299
|
+
|
|
300
|
+
while True:
|
|
301
|
+
payload: dict[str, Any] = {
|
|
302
|
+
"query": {
|
|
303
|
+
"field": "updated_at",
|
|
304
|
+
"operator": ">",
|
|
305
|
+
"value": cutoff_unix,
|
|
306
|
+
},
|
|
307
|
+
"pagination": {"per_page": 50},
|
|
308
|
+
}
|
|
309
|
+
if starting_after:
|
|
310
|
+
payload["pagination"]["starting_after"] = starting_after
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
resp = await self._request(
|
|
314
|
+
client, "POST", "/contacts/search", json=payload
|
|
315
|
+
)
|
|
316
|
+
except Exception:
|
|
317
|
+
logger.exception("Failed to search Intercom contacts")
|
|
318
|
+
break
|
|
319
|
+
|
|
320
|
+
body = resp.json()
|
|
321
|
+
for contact in body.get("data", []):
|
|
322
|
+
ts = self._epoch_to_dt(contact.get("updated_at"))
|
|
323
|
+
events.append(
|
|
324
|
+
RawEvent(
|
|
325
|
+
source="intercom",
|
|
326
|
+
external_id=f"contact:{contact.get('id', '')}",
|
|
327
|
+
timestamp=ts,
|
|
328
|
+
data={
|
|
329
|
+
"event_kind": "contact",
|
|
330
|
+
"contact": contact,
|
|
331
|
+
"is_admin_only": False,
|
|
332
|
+
},
|
|
333
|
+
permissions={},
|
|
334
|
+
)
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
pages = body.get("pages", {})
|
|
338
|
+
next_cursor = pages.get("next", {})
|
|
339
|
+
if next_cursor and next_cursor.get("starting_after"):
|
|
340
|
+
starting_after = next_cursor["starting_after"]
|
|
341
|
+
else:
|
|
342
|
+
break
|
|
343
|
+
|
|
344
|
+
return events
|
|
345
|
+
|
|
346
|
+
async def _fetch_articles(
|
|
347
|
+
self,
|
|
348
|
+
client: httpx.AsyncClient,
|
|
349
|
+
cutoff: datetime,
|
|
350
|
+
) -> list[RawEvent]:
|
|
351
|
+
"""Fetch Help Center articles."""
|
|
352
|
+
events: list[RawEvent] = []
|
|
353
|
+
page_num = 1
|
|
354
|
+
|
|
355
|
+
while True:
|
|
356
|
+
try:
|
|
357
|
+
resp = await self._request(
|
|
358
|
+
client,
|
|
359
|
+
"GET",
|
|
360
|
+
f"/articles?page={page_num}&per_page=50",
|
|
361
|
+
)
|
|
362
|
+
except Exception:
|
|
363
|
+
logger.exception("Failed to fetch Intercom articles")
|
|
364
|
+
break
|
|
365
|
+
|
|
366
|
+
body = resp.json()
|
|
367
|
+
articles = body.get("data", [])
|
|
368
|
+
if not articles:
|
|
369
|
+
break
|
|
370
|
+
|
|
371
|
+
for article in articles:
|
|
372
|
+
ts = self._epoch_to_dt(article.get("updated_at"))
|
|
373
|
+
if ts < cutoff:
|
|
374
|
+
continue
|
|
375
|
+
events.append(
|
|
376
|
+
RawEvent(
|
|
377
|
+
source="intercom",
|
|
378
|
+
external_id=f"article:{article.get('id', '')}",
|
|
379
|
+
timestamp=ts,
|
|
380
|
+
data={
|
|
381
|
+
"event_kind": "article",
|
|
382
|
+
"article": article,
|
|
383
|
+
"is_admin_only": False,
|
|
384
|
+
},
|
|
385
|
+
permissions={
|
|
386
|
+
"author_id": article.get("author_id"),
|
|
387
|
+
},
|
|
388
|
+
)
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
total_pages = body.get("pages", {}).get("total_pages", 1)
|
|
392
|
+
if page_num >= total_pages:
|
|
393
|
+
break
|
|
394
|
+
page_num += 1
|
|
395
|
+
|
|
396
|
+
return events
|
|
397
|
+
|
|
398
|
+
# -- normalization helpers -----------------------------------------------
|
|
399
|
+
|
|
400
|
+
def _normalize_conversation(self, raw: RawEvent) -> Episode:
|
|
401
|
+
conv = raw.data.get("conversation", {})
|
|
402
|
+
source_msg = conv.get("source", {})
|
|
403
|
+
subject = source_msg.get("subject", "")
|
|
404
|
+
body = (source_msg.get("body") or "")[:1000]
|
|
405
|
+
state = conv.get("state", "")
|
|
406
|
+
|
|
407
|
+
author = source_msg.get("author", {})
|
|
408
|
+
actor = Actor(
|
|
409
|
+
id=str(author.get("id", "")),
|
|
410
|
+
name=author.get("name") or author.get("email") or str(author.get("id", "Unknown")),
|
|
411
|
+
email=author.get("email"),
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
entities = self._extract_conversation_entities(conv)
|
|
415
|
+
tags_list = [
|
|
416
|
+
t.get("name", "")
|
|
417
|
+
for t in conv.get("tags", {}).get("tags", [])
|
|
418
|
+
]
|
|
419
|
+
|
|
420
|
+
return Episode(
|
|
421
|
+
actor=actor,
|
|
422
|
+
source=_SOURCE,
|
|
423
|
+
episode_type=EpisodeType.MESSAGE,
|
|
424
|
+
occurred_at=raw.timestamp,
|
|
425
|
+
content=f"Conversation [{state}]: {subject}\n{body}".strip(),
|
|
426
|
+
raw_payload=raw.data,
|
|
427
|
+
tenant_id=self.tenant_id,
|
|
428
|
+
namespace=self.namespace,
|
|
429
|
+
entities=entities,
|
|
430
|
+
tags=["intercom", "conversation", state] + tags_list,
|
|
431
|
+
metadata={
|
|
432
|
+
"conversation_id": conv.get("id"),
|
|
433
|
+
"state": state,
|
|
434
|
+
"priority": conv.get("priority", ""),
|
|
435
|
+
"sla_applied": bool(conv.get("sla_applied")),
|
|
436
|
+
"statistics": conv.get("statistics", {}),
|
|
437
|
+
},
|
|
438
|
+
thread_id=f"ic:conv:{conv.get('id', '')}",
|
|
439
|
+
idempotency_key=f"ic:conv:{conv.get('id', raw.external_id)}",
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
def _normalize_part(self, raw: RawEvent) -> Episode:
|
|
443
|
+
data = raw.data
|
|
444
|
+
part = data.get("part", {})
|
|
445
|
+
conv_id = data.get("conversation_id", "")
|
|
446
|
+
conv_subject = data.get("conversation_subject", "")
|
|
447
|
+
body = (part.get("body") or "")[:1000]
|
|
448
|
+
part_type = part.get("part_type", "")
|
|
449
|
+
|
|
450
|
+
author = part.get("author", {})
|
|
451
|
+
actor = Actor(
|
|
452
|
+
id=str(author.get("id", "")),
|
|
453
|
+
name=author.get("name") or str(author.get("id", "Unknown")),
|
|
454
|
+
email=author.get("email"),
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
return Episode(
|
|
458
|
+
actor=actor,
|
|
459
|
+
source=_SOURCE,
|
|
460
|
+
episode_type=EpisodeType.COMMENT,
|
|
461
|
+
occurred_at=raw.timestamp,
|
|
462
|
+
content=f"Reply on [{conv_subject}] ({part_type}): {body}",
|
|
463
|
+
raw_payload=raw.data,
|
|
464
|
+
tenant_id=self.tenant_id,
|
|
465
|
+
namespace=self.namespace,
|
|
466
|
+
entities=[
|
|
467
|
+
EntityRef(
|
|
468
|
+
entity_type="conversation",
|
|
469
|
+
entity_id=conv_id,
|
|
470
|
+
display_name=conv_subject,
|
|
471
|
+
),
|
|
472
|
+
],
|
|
473
|
+
tags=["intercom", "conversation_part", part_type],
|
|
474
|
+
metadata={
|
|
475
|
+
"conversation_id": conv_id,
|
|
476
|
+
"part_type": part_type,
|
|
477
|
+
"author_type": author.get("type", ""),
|
|
478
|
+
},
|
|
479
|
+
thread_id=f"ic:conv:{conv_id}",
|
|
480
|
+
parent_id=f"ic:conv:{conv_id}",
|
|
481
|
+
idempotency_key=raw.external_id,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
def _normalize_article(self, raw: RawEvent) -> Episode:
|
|
485
|
+
article = raw.data.get("article", {})
|
|
486
|
+
title = article.get("title", "Untitled article")
|
|
487
|
+
body = (article.get("body") or "")[:2000]
|
|
488
|
+
state = article.get("state", "")
|
|
489
|
+
|
|
490
|
+
return Episode(
|
|
491
|
+
actor=Actor(
|
|
492
|
+
id=str(article.get("author_id", "")),
|
|
493
|
+
name=str(article.get("author_id", "Unknown")),
|
|
494
|
+
),
|
|
495
|
+
source=_SOURCE,
|
|
496
|
+
episode_type=EpisodeType.DOCUMENT,
|
|
497
|
+
occurred_at=raw.timestamp,
|
|
498
|
+
content=f"Article [{state}]: {title}\n{body}".strip(),
|
|
499
|
+
raw_payload=raw.data,
|
|
500
|
+
tenant_id=self.tenant_id,
|
|
501
|
+
namespace=self.namespace,
|
|
502
|
+
entities=[
|
|
503
|
+
EntityRef(
|
|
504
|
+
entity_type="article",
|
|
505
|
+
entity_id=str(article.get("id", "")),
|
|
506
|
+
display_name=title,
|
|
507
|
+
),
|
|
508
|
+
],
|
|
509
|
+
tags=["intercom", "article", state],
|
|
510
|
+
metadata={
|
|
511
|
+
"article_id": article.get("id"),
|
|
512
|
+
"state": state,
|
|
513
|
+
"parent_id": article.get("parent_id"),
|
|
514
|
+
"parent_type": article.get("parent_type", ""),
|
|
515
|
+
},
|
|
516
|
+
thread_id=f"ic:article:{article.get('id', '')}",
|
|
517
|
+
idempotency_key=raw.external_id,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
def _normalize_contact(self, raw: RawEvent) -> Episode:
|
|
521
|
+
contact = raw.data.get("contact", {})
|
|
522
|
+
name = contact.get("name") or contact.get("email") or "Unknown"
|
|
523
|
+
role = contact.get("role", "")
|
|
524
|
+
email = contact.get("email", "")
|
|
525
|
+
|
|
526
|
+
parts = [f"Contact: {name}"]
|
|
527
|
+
if role:
|
|
528
|
+
parts.append(f"Role: {role}")
|
|
529
|
+
if email:
|
|
530
|
+
parts.append(f"Email: {email}")
|
|
531
|
+
if contact.get("phone"):
|
|
532
|
+
parts.append(f"Phone: {contact['phone']}")
|
|
533
|
+
|
|
534
|
+
company = contact.get("companies", {}).get("data", [])
|
|
535
|
+
company_names = [c.get("name", "") for c in company if c.get("name")]
|
|
536
|
+
if company_names:
|
|
537
|
+
parts.append(f"Companies: {', '.join(company_names)}")
|
|
538
|
+
|
|
539
|
+
return Episode(
|
|
540
|
+
actor=Actor(
|
|
541
|
+
id=str(contact.get("id", "")),
|
|
542
|
+
name=name,
|
|
543
|
+
email=email or None,
|
|
544
|
+
),
|
|
545
|
+
source=_SOURCE,
|
|
546
|
+
episode_type=EpisodeType.DOCUMENT,
|
|
547
|
+
occurred_at=raw.timestamp,
|
|
548
|
+
content=" | ".join(parts),
|
|
549
|
+
raw_payload=raw.data,
|
|
550
|
+
tenant_id=self.tenant_id,
|
|
551
|
+
namespace=self.namespace,
|
|
552
|
+
entities=[
|
|
553
|
+
EntityRef(
|
|
554
|
+
entity_type="contact",
|
|
555
|
+
entity_id=str(contact.get("id", "")),
|
|
556
|
+
display_name=name,
|
|
557
|
+
),
|
|
558
|
+
],
|
|
559
|
+
tags=["intercom", "contact", role],
|
|
560
|
+
metadata={
|
|
561
|
+
"contact_id": contact.get("id"),
|
|
562
|
+
"role": role,
|
|
563
|
+
"has_hard_bounced": contact.get("has_hard_bounced", False),
|
|
564
|
+
"unsubscribed": contact.get(
|
|
565
|
+
"unsubscribed_from_emails", False
|
|
566
|
+
),
|
|
567
|
+
},
|
|
568
|
+
idempotency_key=raw.external_id,
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
# -- entity extraction ---------------------------------------------------
|
|
572
|
+
|
|
573
|
+
@staticmethod
|
|
574
|
+
def _extract_conversation_entities(
|
|
575
|
+
conv: dict[str, Any],
|
|
576
|
+
) -> list[EntityRef]:
|
|
577
|
+
entities: list[EntityRef] = []
|
|
578
|
+
|
|
579
|
+
conv_id = conv.get("id", "")
|
|
580
|
+
if conv_id:
|
|
581
|
+
entities.append(
|
|
582
|
+
EntityRef(
|
|
583
|
+
entity_type="conversation",
|
|
584
|
+
entity_id=conv_id,
|
|
585
|
+
display_name=conv.get("source", {}).get("subject", conv_id),
|
|
586
|
+
)
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
for admin in conv.get("admins", {}).get("admins", []):
|
|
590
|
+
if admin.get("id"):
|
|
591
|
+
entities.append(
|
|
592
|
+
EntityRef(
|
|
593
|
+
entity_type="admin",
|
|
594
|
+
entity_id=str(admin["id"]),
|
|
595
|
+
display_name=admin.get("name", str(admin["id"])),
|
|
596
|
+
)
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
contacts = conv.get("contacts", {}).get("contacts", [])
|
|
600
|
+
for contact in contacts:
|
|
601
|
+
if contact.get("id"):
|
|
602
|
+
entities.append(
|
|
603
|
+
EntityRef(
|
|
604
|
+
entity_type="contact",
|
|
605
|
+
entity_id=str(contact["id"]),
|
|
606
|
+
display_name=contact.get("name", str(contact["id"])),
|
|
607
|
+
)
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
for tag in conv.get("tags", {}).get("tags", []):
|
|
611
|
+
if tag.get("id"):
|
|
612
|
+
entities.append(
|
|
613
|
+
EntityRef(
|
|
614
|
+
entity_type="tag",
|
|
615
|
+
entity_id=str(tag["id"]),
|
|
616
|
+
display_name=tag.get("name", str(tag["id"])),
|
|
617
|
+
)
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
return entities
|
|
621
|
+
|
|
622
|
+
# -- utilities -----------------------------------------------------------
|
|
623
|
+
|
|
624
|
+
@staticmethod
|
|
625
|
+
def _epoch_to_dt(value: int | str | None) -> datetime:
|
|
626
|
+
if not value:
|
|
627
|
+
return datetime.now(timezone.utc)
|
|
628
|
+
try:
|
|
629
|
+
return datetime.fromtimestamp(int(value), tz=timezone.utc)
|
|
630
|
+
except (ValueError, TypeError, OSError):
|
|
631
|
+
return datetime.now(timezone.utc)
|