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