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