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,298 @@
1
+ """
2
+ PagerDuty connector for CortexDB.
3
+
4
+ Polls PagerDuty for incidents and log entries, converting them into
5
+ CortexDB Episodes. Uses the ``pdpyras`` library for API access.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from datetime import datetime, timezone
12
+ from typing import Any
13
+
14
+ from cortexdb_connectors.base import (
15
+ Actor,
16
+ CortexConnector,
17
+ EntityRef,
18
+ Episode,
19
+ EpisodeType,
20
+ RawEvent,
21
+ Source,
22
+ Visibility,
23
+ VisibilityLevel,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ _SOURCE = Source(system="pagerduty")
29
+
30
+
31
+ class PagerDutyConnector(CortexConnector):
32
+ """Connector that syncs PagerDuty incidents into CortexDB.
33
+
34
+ Parameters
35
+ ----------
36
+ cortex_url:
37
+ Base URL of the CortexDB API.
38
+ cortex_api_key:
39
+ Bearer token for CortexDB authentication.
40
+ pagerduty_api_key:
41
+ PagerDuty REST API key (v2).
42
+ service_ids:
43
+ Optional list of PagerDuty service IDs to restrict the sync to.
44
+ If empty, all incidents visible to the API key are synced.
45
+ tenant_id:
46
+ CortexDB tenant identifier.
47
+ """
48
+
49
+ connector_name: str = "pagerduty"
50
+
51
+ def __init__(
52
+ self,
53
+ cortex_url: str,
54
+ cortex_api_key: str,
55
+ pagerduty_api_key: str,
56
+ service_ids: list[str] | None = None,
57
+ tenant_id: str = "default",
58
+ ) -> None:
59
+ super().__init__(cortex_url, cortex_api_key, tenant_id)
60
+ self.pagerduty_api_key = pagerduty_api_key
61
+ self.service_ids = service_ids or []
62
+
63
+ # -- CortexConnector interface -------------------------------------------
64
+
65
+ async def fetch_events(
66
+ self, since: datetime | None = None
67
+ ) -> list[RawEvent]:
68
+ """Fetch incidents and their log entries from PagerDuty.
69
+
70
+ Returns one ``RawEvent`` per incident state-change (triggered,
71
+ acknowledged, resolved) as recorded in the incident log entries.
72
+ """
73
+ from pdpyras import APISession
74
+
75
+ session = APISession(self.pagerduty_api_key)
76
+ raw_events: list[RawEvent] = []
77
+
78
+ params: dict[str, Any] = {
79
+ "sort_by": "created_at:desc",
80
+ }
81
+ if since:
82
+ params["since"] = since.isoformat()
83
+ if self.service_ids:
84
+ params["service_ids[]"] = self.service_ids
85
+
86
+ try:
87
+ incidents = session.list_all("incidents", params=params)
88
+ except Exception:
89
+ logger.exception("Failed to list PagerDuty incidents")
90
+ return raw_events
91
+
92
+ for incident in incidents:
93
+ created_str = incident.get("created_at", "")
94
+ try:
95
+ created_at = datetime.fromisoformat(
96
+ created_str.replace("Z", "+00:00")
97
+ )
98
+ except (ValueError, TypeError):
99
+ created_at = datetime.now(timezone.utc)
100
+
101
+ raw_events.append(
102
+ RawEvent(
103
+ source="pagerduty",
104
+ external_id=incident.get("id", ""),
105
+ timestamp=created_at,
106
+ data={
107
+ "type": "incident",
108
+ "incident": incident,
109
+ },
110
+ permissions={},
111
+ )
112
+ )
113
+
114
+ # Fetch log entries for state-change tracking.
115
+ try:
116
+ log_entries = session.list_all(
117
+ f"incidents/{incident['id']}/log_entries"
118
+ )
119
+ except Exception:
120
+ logger.debug(
121
+ "Could not fetch log entries for %s", incident.get("id")
122
+ )
123
+ continue
124
+
125
+ for entry in log_entries:
126
+ entry_ts_str = entry.get("created_at", "")
127
+ try:
128
+ entry_ts = datetime.fromisoformat(
129
+ entry_ts_str.replace("Z", "+00:00")
130
+ )
131
+ except (ValueError, TypeError):
132
+ entry_ts = created_at
133
+
134
+ if since and entry_ts <= since:
135
+ continue
136
+
137
+ raw_events.append(
138
+ RawEvent(
139
+ source="pagerduty",
140
+ external_id=entry.get("id", ""),
141
+ timestamp=entry_ts,
142
+ data={
143
+ "type": "log_entry",
144
+ "log_entry": entry,
145
+ "incident_id": incident.get("id", ""),
146
+ "incident_title": incident.get("title", ""),
147
+ },
148
+ permissions={},
149
+ )
150
+ )
151
+
152
+ return raw_events
153
+
154
+ def normalize(self, raw: RawEvent) -> Episode:
155
+ """Convert a PagerDuty event into a CortexDB ``Episode``."""
156
+ data = raw.data
157
+ event_kind = data.get("type", "incident")
158
+
159
+ if event_kind == "incident":
160
+ return self._normalize_incident(raw)
161
+ return self._normalize_log_entry(raw)
162
+
163
+ def map_permissions(self, raw: RawEvent) -> Visibility:
164
+ """PagerDuty data is internal; always ``Organization`` visibility."""
165
+ return Visibility(level=VisibilityLevel.ORGANIZATION)
166
+
167
+ # -- normalization helpers -----------------------------------------------
168
+
169
+ def _normalize_incident(self, raw: RawEvent) -> Episode:
170
+ """Normalize an incident-level event."""
171
+ incident = raw.data.get("incident", {})
172
+ title = incident.get("title", "Untitled incident")
173
+ status = incident.get("status", "unknown")
174
+ urgency = incident.get("urgency", "unknown")
175
+ service = incident.get("service", {})
176
+
177
+ actor = self._extract_actor(incident)
178
+ entities = self._extract_entities(incident)
179
+
180
+ content = f"[{status.upper()}] {title}"
181
+
182
+ return Episode(
183
+ actor=actor,
184
+ source=_SOURCE,
185
+ episode_type=EpisodeType.INCIDENT,
186
+ occurred_at=raw.timestamp,
187
+ content=content,
188
+ raw_payload=raw.data,
189
+ tenant_id=self.tenant_id,
190
+ namespace="pagerduty",
191
+ entities=entities,
192
+ tags=["pagerduty", "incident", status],
193
+ metadata={
194
+ "status": status,
195
+ "urgency": urgency,
196
+ "severity": incident.get("severity", {}).get("value", "unknown"),
197
+ "service_id": service.get("id", ""),
198
+ "service_name": service.get("summary", ""),
199
+ "incident_number": incident.get("incident_number"),
200
+ "escalation_policy": incident.get("escalation_policy", {}).get(
201
+ "summary", ""
202
+ ),
203
+ },
204
+ thread_id=f"pd:incident:{incident.get('id', '')}",
205
+ idempotency_key=raw.external_id,
206
+ )
207
+
208
+ def _normalize_log_entry(self, raw: RawEvent) -> Episode:
209
+ """Normalize an incident log-entry event."""
210
+ entry = raw.data.get("log_entry", {})
211
+ incident_id = raw.data.get("incident_id", "")
212
+ incident_title = raw.data.get("incident_title", "")
213
+ entry_type = entry.get("type", "unknown")
214
+
215
+ agent = entry.get("agent", {})
216
+ actor = Actor(
217
+ id=agent.get("id", "system"),
218
+ name=agent.get("summary", "PagerDuty"),
219
+ )
220
+
221
+ summary = entry.get("summary", entry_type)
222
+ content = f"{summary} on incident: {incident_title}"
223
+
224
+ return Episode(
225
+ actor=actor,
226
+ source=_SOURCE,
227
+ episode_type=EpisodeType.INCIDENT,
228
+ occurred_at=raw.timestamp,
229
+ content=content,
230
+ raw_payload=raw.data,
231
+ tenant_id=self.tenant_id,
232
+ namespace="pagerduty",
233
+ entities=[
234
+ EntityRef(
235
+ entity_type="incident",
236
+ entity_id=incident_id,
237
+ display_name=incident_title,
238
+ ),
239
+ ],
240
+ tags=["pagerduty", "log_entry", entry_type],
241
+ metadata={
242
+ "log_entry_type": entry_type,
243
+ "channel_type": entry.get("channel", {}).get("type", ""),
244
+ },
245
+ thread_id=f"pd:incident:{incident_id}",
246
+ parent_id=f"pd:incident:{incident_id}",
247
+ idempotency_key=raw.external_id,
248
+ )
249
+
250
+ @staticmethod
251
+ def _extract_actor(incident: dict[str, Any]) -> Actor:
252
+ """Extract the primary actor from an incident payload."""
253
+ assignments = incident.get("assignments", [])
254
+ if assignments:
255
+ assignee = assignments[0].get("assignee", {})
256
+ return Actor(
257
+ id=assignee.get("id", "unassigned"),
258
+ name=assignee.get("summary", "Unknown"),
259
+ )
260
+ return Actor(id="system", name="PagerDuty")
261
+
262
+ @staticmethod
263
+ def _extract_entities(incident: dict[str, Any]) -> list[EntityRef]:
264
+ """Extract entity references from an incident."""
265
+ entities: list[EntityRef] = []
266
+
267
+ service = incident.get("service", {})
268
+ if service.get("id"):
269
+ entities.append(
270
+ EntityRef(
271
+ entity_type="service",
272
+ entity_id=service["id"],
273
+ display_name=service.get("summary", ""),
274
+ )
275
+ )
276
+
277
+ for assignment in incident.get("assignments", []):
278
+ assignee = assignment.get("assignee", {})
279
+ if assignee.get("id"):
280
+ entities.append(
281
+ EntityRef(
282
+ entity_type="user",
283
+ entity_id=assignee["id"],
284
+ display_name=assignee.get("summary", ""),
285
+ )
286
+ )
287
+
288
+ escalation = incident.get("escalation_policy", {})
289
+ if escalation.get("id"):
290
+ entities.append(
291
+ EntityRef(
292
+ entity_type="escalation_policy",
293
+ entity_id=escalation["id"],
294
+ display_name=escalation.get("summary", ""),
295
+ )
296
+ )
297
+
298
+ return entities
@@ -0,0 +1,399 @@
1
+ """
2
+ Salesforce connector for CortexDB.
3
+
4
+ Polls Salesforce for Cases, Opportunities, Accounts, Tasks, Chatter feed,
5
+ and Notes, converting them into CortexDB Episodes. Uses the
6
+ ``simple-salesforce`` library for REST API / SOQL access with OAuth2
7
+ authentication.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from datetime import datetime, timedelta, timezone
14
+ from typing import Any
15
+
16
+ from cortexdb_connectors.base import (
17
+ Actor,
18
+ CortexConnector,
19
+ EntityRef,
20
+ Episode,
21
+ EpisodeType,
22
+ RawEvent,
23
+ Source,
24
+ Visibility,
25
+ VisibilityLevel,
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ _SOURCE = Source(system="salesforce")
31
+
32
+ _DEFAULT_OBJECTS = frozenset(
33
+ ["Case", "Opportunity", "Account", "Task", "FeedItem", "Note"]
34
+ )
35
+
36
+ _OBJECT_TYPE_MAP: dict[str, EpisodeType] = {
37
+ "Case": EpisodeType.ISSUE,
38
+ "Opportunity": EpisodeType.DOCUMENT,
39
+ "Account": EpisodeType.DOCUMENT,
40
+ "Task": EpisodeType.ISSUE,
41
+ "FeedItem": EpisodeType.MESSAGE,
42
+ "Note": EpisodeType.COMMENT,
43
+ }
44
+
45
+
46
+ class SalesforceConnector(CortexConnector):
47
+ """Connector that syncs Salesforce objects into CortexDB.
48
+
49
+ Parameters
50
+ ----------
51
+ cortex_url:
52
+ Base URL of the CortexDB API.
53
+ cortex_api_key:
54
+ Bearer token for CortexDB authentication.
55
+ instance_url:
56
+ Salesforce instance URL (e.g. ``https://myorg.salesforce.com``).
57
+ client_id:
58
+ OAuth2 connected-app client ID.
59
+ client_secret:
60
+ OAuth2 connected-app client secret.
61
+ username:
62
+ Salesforce username for password-based OAuth flow.
63
+ password:
64
+ Salesforce password (with security token appended if required).
65
+ objects:
66
+ Salesforce object types to ingest. Defaults to Case, Opportunity,
67
+ Account, Task, FeedItem, Note.
68
+ namespace:
69
+ CortexDB namespace for ingested episodes.
70
+ backfill_days:
71
+ Number of days of historical data to backfill on first sync.
72
+ tenant_id:
73
+ CortexDB tenant identifier.
74
+ """
75
+
76
+ connector_name: str = "salesforce"
77
+
78
+ def __init__(
79
+ self,
80
+ cortex_url: str,
81
+ cortex_api_key: str,
82
+ instance_url: str,
83
+ client_id: str,
84
+ client_secret: str,
85
+ username: str,
86
+ password: str,
87
+ objects: list[str] | None = None,
88
+ namespace: str = "salesforce",
89
+ backfill_days: int = 90,
90
+ tenant_id: str = "default",
91
+ ) -> None:
92
+ super().__init__(cortex_url, cortex_api_key, tenant_id)
93
+ self.instance_url = instance_url.rstrip("/")
94
+ self.client_id = client_id
95
+ self.client_secret = client_secret
96
+ self.username = username
97
+ self.password = password
98
+ self.object_filter = set(objects) if objects else set(_DEFAULT_OBJECTS)
99
+ self.namespace = namespace
100
+ self.backfill_days = backfill_days
101
+ self._sf_client: Any = None
102
+
103
+ # -- Salesforce client management ----------------------------------------
104
+
105
+ def _get_client(self) -> Any:
106
+ """Lazy-initialise and return the simple-salesforce client."""
107
+ if self._sf_client is not None:
108
+ return self._sf_client
109
+
110
+ from simple_salesforce import Salesforce
111
+
112
+ self._sf_client = Salesforce(
113
+ username=self.username,
114
+ password=self.password,
115
+ consumer_key=self.client_id,
116
+ consumer_secret=self.client_secret,
117
+ instance_url=self.instance_url,
118
+ )
119
+ return self._sf_client
120
+
121
+ # -- CortexConnector interface -------------------------------------------
122
+
123
+ async def fetch_events(
124
+ self, since: datetime | None = None
125
+ ) -> list[RawEvent]:
126
+ """Fetch records from configured Salesforce objects via SOQL.
127
+
128
+ Uses ``LastModifiedDate`` for incremental sync and handles
129
+ query-more pagination for large result sets.
130
+ """
131
+ sf = self._get_client()
132
+ cutoff = since or datetime.now(timezone.utc) - timedelta(
133
+ days=self.backfill_days
134
+ )
135
+ cutoff_str = cutoff.strftime("%Y-%m-%dT%H:%M:%SZ")
136
+ raw_events: list[RawEvent] = []
137
+
138
+ for obj_type in self.object_filter:
139
+ try:
140
+ fields = self._fields_for(obj_type)
141
+ soql = (
142
+ f"SELECT {fields} FROM {obj_type} "
143
+ f"WHERE LastModifiedDate > {cutoff_str} "
144
+ f"ORDER BY LastModifiedDate DESC"
145
+ )
146
+ result = sf.query(soql)
147
+ raw_events.extend(
148
+ self._records_to_events(obj_type, result.get("records", []))
149
+ )
150
+
151
+ # Handle query-more pagination.
152
+ while not result.get("done", True):
153
+ next_url = result.get("nextRecordsUrl", "")
154
+ if not next_url:
155
+ break
156
+ result = sf.query_more(next_url, identifier_is_url=True)
157
+ raw_events.extend(
158
+ self._records_to_events(
159
+ obj_type, result.get("records", [])
160
+ )
161
+ )
162
+ except Exception:
163
+ logger.exception(
164
+ "Failed to fetch Salesforce %s records", obj_type
165
+ )
166
+ continue
167
+
168
+ return raw_events
169
+
170
+ def normalize(self, raw: RawEvent) -> Episode:
171
+ """Convert a Salesforce record ``RawEvent`` into a CortexDB ``Episode``."""
172
+ data = raw.data
173
+ obj_type = data.get("object_type", "Unknown")
174
+ record = data.get("record", {})
175
+ episode_type = _OBJECT_TYPE_MAP.get(obj_type, EpisodeType.CUSTOM)
176
+
177
+ actor = self._extract_actor(record)
178
+ content = self._build_content(obj_type, record)
179
+ entities = self._extract_entities(obj_type, record)
180
+ tags = ["salesforce", obj_type.lower()]
181
+ thread_id = self._derive_thread_id(obj_type, record)
182
+
183
+ metadata: dict[str, Any] = {
184
+ "salesforce_object": obj_type,
185
+ "salesforce_id": record.get("Id", ""),
186
+ }
187
+ if record.get("Status"):
188
+ metadata["status"] = record["Status"]
189
+ if record.get("StageName"):
190
+ metadata["stage"] = record["StageName"]
191
+ if record.get("Priority"):
192
+ metadata["priority"] = record["Priority"]
193
+
194
+ return Episode(
195
+ actor=actor,
196
+ source=_SOURCE,
197
+ episode_type=episode_type,
198
+ occurred_at=raw.timestamp,
199
+ content=content,
200
+ raw_payload=data,
201
+ tenant_id=self.tenant_id,
202
+ namespace=self.namespace,
203
+ entities=entities,
204
+ tags=tags,
205
+ metadata=metadata,
206
+ thread_id=thread_id,
207
+ idempotency_key=f"sf:{obj_type}:{record.get('Id', raw.external_id)}",
208
+ )
209
+
210
+ def map_permissions(self, raw: RawEvent) -> Visibility:
211
+ """Map Salesforce record visibility.
212
+
213
+ Records linked to portal users are ``Restricted``; everything else
214
+ is ``Organization``.
215
+ """
216
+ record = raw.data.get("record", {})
217
+ if record.get("IsEscalated") or record.get("ContactId"):
218
+ return Visibility(level=VisibilityLevel.RESTRICTED)
219
+ return Visibility(level=VisibilityLevel.ORGANIZATION)
220
+
221
+ # -- helpers -------------------------------------------------------------
222
+
223
+ @staticmethod
224
+ def _fields_for(obj_type: str) -> str:
225
+ """Return the SOQL field list for a given object type."""
226
+ base = "Id, LastModifiedDate, LastModifiedById, OwnerId"
227
+ fields_map: dict[str, str] = {
228
+ "Case": (
229
+ f"{base}, Subject, Description, Status, Priority, "
230
+ "CaseNumber, ContactId, AccountId, IsEscalated"
231
+ ),
232
+ "Opportunity": (
233
+ f"{base}, Name, StageName, Amount, CloseDate, "
234
+ "AccountId, Probability"
235
+ ),
236
+ "Account": f"{base}, Name, Industry, Type, BillingCity, Website",
237
+ "Task": (
238
+ f"{base}, Subject, Description, Status, Priority, "
239
+ "ActivityDate, WhoId, WhatId"
240
+ ),
241
+ "FeedItem": (
242
+ f"{base}, Body, Title, Type, ParentId, "
243
+ "CreatedById, CreatedDate"
244
+ ),
245
+ "Note": f"{base}, Title, Body, ParentId",
246
+ }
247
+ return fields_map.get(obj_type, base)
248
+
249
+ def _records_to_events(
250
+ self, obj_type: str, records: list[dict[str, Any]]
251
+ ) -> list[RawEvent]:
252
+ """Convert a batch of Salesforce records to RawEvents."""
253
+ events: list[RawEvent] = []
254
+ for rec in records:
255
+ ts_str = rec.get("LastModifiedDate", "")
256
+ try:
257
+ ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
258
+ except (ValueError, TypeError):
259
+ ts = datetime.now(timezone.utc)
260
+
261
+ events.append(
262
+ RawEvent(
263
+ source="salesforce",
264
+ external_id=rec.get("Id", ""),
265
+ timestamp=ts,
266
+ data={
267
+ "object_type": obj_type,
268
+ "record": rec,
269
+ },
270
+ permissions={
271
+ "owner_id": rec.get("OwnerId", ""),
272
+ "is_escalated": rec.get("IsEscalated", False),
273
+ "contact_id": rec.get("ContactId"),
274
+ },
275
+ )
276
+ )
277
+ return events
278
+
279
+ @staticmethod
280
+ def _extract_actor(record: dict[str, Any]) -> Actor:
281
+ """Extract the actor from a Salesforce record."""
282
+ owner_id = record.get("OwnerId") or record.get("CreatedById") or ""
283
+ modifier_id = record.get("LastModifiedById") or owner_id
284
+ return Actor(
285
+ id=modifier_id,
286
+ name=modifier_id, # Resolved to name during enrichment
287
+ )
288
+
289
+ @staticmethod
290
+ def _build_content(obj_type: str, record: dict[str, Any]) -> str:
291
+ """Build human-readable content from a Salesforce record."""
292
+ if obj_type == "Case":
293
+ subject = record.get("Subject", "Untitled case")
294
+ status = record.get("Status", "")
295
+ desc = (record.get("Description") or "")[:500]
296
+ return f"Case [{status}]: {subject}\n{desc}".strip()
297
+
298
+ if obj_type == "Opportunity":
299
+ name = record.get("Name", "Untitled opportunity")
300
+ stage = record.get("StageName", "")
301
+ amount = record.get("Amount")
302
+ amount_str = f" (${amount:,.2f})" if amount else ""
303
+ return f"Opportunity [{stage}]: {name}{amount_str}"
304
+
305
+ if obj_type == "Account":
306
+ name = record.get("Name", "Unnamed account")
307
+ industry = record.get("Industry", "")
308
+ acct_type = record.get("Type", "")
309
+ return f"Account: {name} | {industry} | {acct_type}".rstrip(" | ")
310
+
311
+ if obj_type == "Task":
312
+ subject = record.get("Subject", "Untitled task")
313
+ status = record.get("Status", "")
314
+ desc = (record.get("Description") or "")[:300]
315
+ return f"Task [{status}]: {subject}\n{desc}".strip()
316
+
317
+ if obj_type == "FeedItem":
318
+ title = record.get("Title") or ""
319
+ body = (record.get("Body") or "")[:500]
320
+ prefix = f"{title}: " if title else ""
321
+ return f"Chatter: {prefix}{body}"
322
+
323
+ if obj_type == "Note":
324
+ title = record.get("Title", "Untitled note")
325
+ body = (record.get("Body") or "")[:500]
326
+ return f"Note: {title}\n{body}".strip()
327
+
328
+ return f"Salesforce {obj_type}: {record.get('Id', '')}"
329
+
330
+ @staticmethod
331
+ def _extract_entities(
332
+ obj_type: str, record: dict[str, Any]
333
+ ) -> list[EntityRef]:
334
+ """Extract entity references from a Salesforce record."""
335
+ entities: list[EntityRef] = []
336
+
337
+ sf_id = record.get("Id", "")
338
+ if sf_id:
339
+ entities.append(
340
+ EntityRef(
341
+ entity_type=obj_type.lower(),
342
+ entity_id=sf_id,
343
+ display_name=record.get("Name")
344
+ or record.get("Subject")
345
+ or record.get("CaseNumber")
346
+ or sf_id,
347
+ )
348
+ )
349
+
350
+ account_id = record.get("AccountId")
351
+ if account_id:
352
+ entities.append(
353
+ EntityRef(
354
+ entity_type="account",
355
+ entity_id=account_id,
356
+ display_name=account_id,
357
+ )
358
+ )
359
+
360
+ contact_id = record.get("ContactId")
361
+ if contact_id:
362
+ entities.append(
363
+ EntityRef(
364
+ entity_type="contact",
365
+ entity_id=contact_id,
366
+ display_name=contact_id,
367
+ )
368
+ )
369
+
370
+ owner_id = record.get("OwnerId")
371
+ if owner_id:
372
+ entities.append(
373
+ EntityRef(
374
+ entity_type="user",
375
+ entity_id=owner_id,
376
+ display_name=owner_id,
377
+ )
378
+ )
379
+
380
+ return entities
381
+
382
+ @staticmethod
383
+ def _derive_thread_id(
384
+ obj_type: str, record: dict[str, Any]
385
+ ) -> str | None:
386
+ """Derive a thread_id for grouping related records."""
387
+ sf_id = record.get("Id", "")
388
+ if not sf_id:
389
+ return None
390
+
391
+ if obj_type in ("Case", "Opportunity", "Account"):
392
+ return f"sf:{obj_type.lower()}:{sf_id}"
393
+
394
+ # Tasks and notes thread to their parent.
395
+ parent_id = record.get("WhatId") or record.get("ParentId")
396
+ if parent_id:
397
+ return f"sf:parent:{parent_id}"
398
+
399
+ return f"sf:{obj_type.lower()}:{sf_id}"