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,359 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Confluence connector for CortexDB.
|
|
3
|
+
|
|
4
|
+
Polls Confluence spaces for recently updated pages and ingests them as
|
|
5
|
+
CortexDB Episodes. Uses the ``atlassian-python-api`` 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="confluence")
|
|
29
|
+
|
|
30
|
+
_PAGE_LIMIT = 50
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ConfluenceConnector(CortexConnector):
|
|
34
|
+
"""Connector that syncs Confluence pages into CortexDB.
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
cortex_url:
|
|
39
|
+
Base URL of the CortexDB API.
|
|
40
|
+
cortex_api_key:
|
|
41
|
+
Bearer token for CortexDB authentication.
|
|
42
|
+
confluence_url:
|
|
43
|
+
Base URL of the Confluence instance (e.g.
|
|
44
|
+
``https://myteam.atlassian.net/wiki``).
|
|
45
|
+
email:
|
|
46
|
+
Email address for Confluence authentication.
|
|
47
|
+
api_token:
|
|
48
|
+
Atlassian API token.
|
|
49
|
+
space_keys:
|
|
50
|
+
List of Confluence space keys to sync (e.g. ``["ENG", "OPS"]``).
|
|
51
|
+
If empty, all spaces visible to the authenticated user are synced.
|
|
52
|
+
tenant_id:
|
|
53
|
+
CortexDB tenant identifier.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
connector_name: str = "confluence"
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
cortex_url: str,
|
|
61
|
+
cortex_api_key: str,
|
|
62
|
+
confluence_url: str,
|
|
63
|
+
email: str,
|
|
64
|
+
api_token: str,
|
|
65
|
+
space_keys: list[str] | None = None,
|
|
66
|
+
tenant_id: str = "default",
|
|
67
|
+
) -> None:
|
|
68
|
+
super().__init__(cortex_url, cortex_api_key, tenant_id)
|
|
69
|
+
self.confluence_url = confluence_url.rstrip("/")
|
|
70
|
+
self.email = email
|
|
71
|
+
self.api_token = api_token
|
|
72
|
+
self.space_keys = space_keys or []
|
|
73
|
+
|
|
74
|
+
# -- CortexConnector interface -------------------------------------------
|
|
75
|
+
|
|
76
|
+
async def fetch_events(
|
|
77
|
+
self, since: datetime | None = None
|
|
78
|
+
) -> list[RawEvent]:
|
|
79
|
+
"""Fetch recently updated pages from configured Confluence spaces.
|
|
80
|
+
|
|
81
|
+
Uses the CQL ``lastModified`` filter to retrieve incremental updates.
|
|
82
|
+
"""
|
|
83
|
+
from atlassian import Confluence
|
|
84
|
+
|
|
85
|
+
client = Confluence(
|
|
86
|
+
url=self.confluence_url,
|
|
87
|
+
username=self.email,
|
|
88
|
+
password=self.api_token,
|
|
89
|
+
cloud=True,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
spaces = self.space_keys or self._discover_spaces(client)
|
|
93
|
+
raw_events: list[RawEvent] = []
|
|
94
|
+
|
|
95
|
+
for space_key in spaces:
|
|
96
|
+
cql = f'space = "{space_key}" AND type = "page"'
|
|
97
|
+
if since:
|
|
98
|
+
cql_date = since.strftime("%Y-%m-%d %H:%M")
|
|
99
|
+
cql += f' AND lastModified >= "{cql_date}"'
|
|
100
|
+
cql += " ORDER BY lastModified DESC"
|
|
101
|
+
|
|
102
|
+
start = 0
|
|
103
|
+
while True:
|
|
104
|
+
try:
|
|
105
|
+
results = client.cql(
|
|
106
|
+
cql,
|
|
107
|
+
start=start,
|
|
108
|
+
limit=_PAGE_LIMIT,
|
|
109
|
+
expand="content.body.storage,content.version,"
|
|
110
|
+
"content.history.lastUpdated,"
|
|
111
|
+
"content.metadata.labels,"
|
|
112
|
+
"content.ancestors,"
|
|
113
|
+
"content.restrictions.read.restrictions.user,"
|
|
114
|
+
"content.restrictions.read.restrictions.group",
|
|
115
|
+
)
|
|
116
|
+
except Exception:
|
|
117
|
+
logger.exception(
|
|
118
|
+
"CQL query failed for space %s", space_key
|
|
119
|
+
)
|
|
120
|
+
break
|
|
121
|
+
|
|
122
|
+
search_results = results.get("results", [])
|
|
123
|
+
if not search_results:
|
|
124
|
+
break
|
|
125
|
+
|
|
126
|
+
for result in search_results:
|
|
127
|
+
content_obj = result.get("content", result)
|
|
128
|
+
page_data = self._extract_page_data(
|
|
129
|
+
content_obj, space_key
|
|
130
|
+
)
|
|
131
|
+
updated_str = page_data.get("last_modified", "")
|
|
132
|
+
try:
|
|
133
|
+
updated_at = datetime.fromisoformat(
|
|
134
|
+
updated_str.replace("Z", "+00:00")
|
|
135
|
+
)
|
|
136
|
+
except (ValueError, TypeError):
|
|
137
|
+
updated_at = datetime.now(timezone.utc)
|
|
138
|
+
|
|
139
|
+
restrictions = self._extract_restrictions(content_obj)
|
|
140
|
+
|
|
141
|
+
raw_events.append(
|
|
142
|
+
RawEvent(
|
|
143
|
+
source="confluence",
|
|
144
|
+
external_id=page_data.get("page_id", ""),
|
|
145
|
+
timestamp=updated_at,
|
|
146
|
+
data=page_data,
|
|
147
|
+
permissions={
|
|
148
|
+
"space_key": space_key,
|
|
149
|
+
"restrictions": restrictions,
|
|
150
|
+
},
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
total = results.get("totalSize", 0)
|
|
155
|
+
start += _PAGE_LIMIT
|
|
156
|
+
if start >= total:
|
|
157
|
+
break
|
|
158
|
+
|
|
159
|
+
return raw_events
|
|
160
|
+
|
|
161
|
+
def normalize(self, raw: RawEvent) -> Episode:
|
|
162
|
+
"""Convert a Confluence page ``RawEvent`` into a CortexDB ``Episode``."""
|
|
163
|
+
data = raw.data
|
|
164
|
+
title = data.get("title", "Untitled")
|
|
165
|
+
excerpt = data.get("excerpt", "")
|
|
166
|
+
body_text = data.get("body_text", "")
|
|
167
|
+
space_key = data.get("space_key", "")
|
|
168
|
+
|
|
169
|
+
actor = self._extract_actor(data)
|
|
170
|
+
entities = self._extract_entities(data)
|
|
171
|
+
|
|
172
|
+
content = title
|
|
173
|
+
if excerpt:
|
|
174
|
+
content += f"\n\n{excerpt}"
|
|
175
|
+
elif body_text:
|
|
176
|
+
content += f"\n\n{body_text[:1000]}"
|
|
177
|
+
|
|
178
|
+
labels = data.get("labels", [])
|
|
179
|
+
tags = ["confluence", space_key.lower()] + [
|
|
180
|
+
lbl.get("name", lbl) if isinstance(lbl, dict) else str(lbl)
|
|
181
|
+
for lbl in labels
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
ancestors = data.get("ancestors", [])
|
|
185
|
+
parent_id = None
|
|
186
|
+
if ancestors:
|
|
187
|
+
parent_id = f"confluence:{ancestors[-1].get('id', '')}"
|
|
188
|
+
|
|
189
|
+
return Episode(
|
|
190
|
+
actor=actor,
|
|
191
|
+
source=_SOURCE,
|
|
192
|
+
episode_type=EpisodeType.DOCUMENT,
|
|
193
|
+
occurred_at=raw.timestamp,
|
|
194
|
+
content=content,
|
|
195
|
+
raw_payload=data,
|
|
196
|
+
tenant_id=self.tenant_id,
|
|
197
|
+
namespace="confluence",
|
|
198
|
+
entities=entities,
|
|
199
|
+
tags=tags,
|
|
200
|
+
metadata={
|
|
201
|
+
"page_id": data.get("page_id", ""),
|
|
202
|
+
"space_key": space_key,
|
|
203
|
+
"version": data.get("version", 1),
|
|
204
|
+
"url": data.get("url", ""),
|
|
205
|
+
},
|
|
206
|
+
thread_id=f"confluence:{data.get('page_id', '')}",
|
|
207
|
+
parent_id=parent_id,
|
|
208
|
+
idempotency_key=(
|
|
209
|
+
f"{raw.external_id}:v{data.get('version', 1)}"
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def map_permissions(self, raw: RawEvent) -> Visibility:
|
|
214
|
+
"""Map Confluence page/space restrictions to CortexDB visibility.
|
|
215
|
+
|
|
216
|
+
Pages with explicit read restrictions are marked ``Restricted``
|
|
217
|
+
with the allowed users/groups as principals. Unrestricted pages
|
|
218
|
+
are ``Organization``.
|
|
219
|
+
"""
|
|
220
|
+
restrictions = raw.permissions.get("restrictions", {})
|
|
221
|
+
users = restrictions.get("users", [])
|
|
222
|
+
groups = restrictions.get("groups", [])
|
|
223
|
+
|
|
224
|
+
if users or groups:
|
|
225
|
+
principals = tuple(users + groups)
|
|
226
|
+
return Visibility(
|
|
227
|
+
level=VisibilityLevel.RESTRICTED,
|
|
228
|
+
allowed_principals=principals,
|
|
229
|
+
)
|
|
230
|
+
return Visibility(level=VisibilityLevel.ORGANIZATION)
|
|
231
|
+
|
|
232
|
+
# -- helpers -------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
@staticmethod
|
|
235
|
+
def _discover_spaces(client: Any) -> list[str]:
|
|
236
|
+
"""Auto-discover all spaces visible to the authenticated user."""
|
|
237
|
+
space_keys: list[str] = []
|
|
238
|
+
start = 0
|
|
239
|
+
while True:
|
|
240
|
+
results = client.get_all_spaces(start=start, limit=50)
|
|
241
|
+
spaces = results.get("results", [])
|
|
242
|
+
if not spaces:
|
|
243
|
+
break
|
|
244
|
+
for space in spaces:
|
|
245
|
+
space_keys.append(space["key"])
|
|
246
|
+
if len(spaces) < 50:
|
|
247
|
+
break
|
|
248
|
+
start += 50
|
|
249
|
+
return space_keys
|
|
250
|
+
|
|
251
|
+
@staticmethod
|
|
252
|
+
def _extract_page_data(
|
|
253
|
+
content_obj: dict[str, Any], space_key: str
|
|
254
|
+
) -> dict[str, Any]:
|
|
255
|
+
"""Flatten a Confluence content object into a plain dict."""
|
|
256
|
+
history = content_obj.get("history", {})
|
|
257
|
+
last_updated = history.get("lastUpdated", {})
|
|
258
|
+
version_info = content_obj.get("version", {})
|
|
259
|
+
body = content_obj.get("body", {})
|
|
260
|
+
storage = body.get("storage", {})
|
|
261
|
+
labels_data = content_obj.get("metadata", {}).get("labels", {})
|
|
262
|
+
labels = labels_data.get("results", []) if isinstance(labels_data, dict) else []
|
|
263
|
+
ancestors = content_obj.get("ancestors", [])
|
|
264
|
+
links = content_obj.get("_links", {})
|
|
265
|
+
|
|
266
|
+
modifier = last_updated.get("by", {})
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
"page_id": content_obj.get("id", ""),
|
|
270
|
+
"title": content_obj.get("title", ""),
|
|
271
|
+
"space_key": space_key,
|
|
272
|
+
"body_text": storage.get("value", ""),
|
|
273
|
+
"excerpt": content_obj.get("excerpt", ""),
|
|
274
|
+
"version": version_info.get("number", 1) if isinstance(version_info, dict) else 1,
|
|
275
|
+
"last_modified": last_updated.get("when", ""),
|
|
276
|
+
"modifier": {
|
|
277
|
+
"account_id": modifier.get("accountId", ""),
|
|
278
|
+
"display_name": modifier.get("displayName", ""),
|
|
279
|
+
"email": modifier.get("email"),
|
|
280
|
+
},
|
|
281
|
+
"labels": labels,
|
|
282
|
+
"ancestors": [
|
|
283
|
+
{"id": a.get("id", ""), "title": a.get("title", "")}
|
|
284
|
+
for a in ancestors
|
|
285
|
+
],
|
|
286
|
+
"url": links.get("webui", ""),
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
@staticmethod
|
|
290
|
+
def _extract_restrictions(content_obj: dict[str, Any]) -> dict[str, list[str]]:
|
|
291
|
+
"""Extract read restrictions from a content object."""
|
|
292
|
+
restrictions_data = content_obj.get("restrictions", {})
|
|
293
|
+
read_restrictions = restrictions_data.get("read", {}).get(
|
|
294
|
+
"restrictions", {}
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
users: list[str] = []
|
|
298
|
+
groups: list[str] = []
|
|
299
|
+
|
|
300
|
+
for user in read_restrictions.get("user", {}).get("results", []):
|
|
301
|
+
account_id = user.get("accountId", "")
|
|
302
|
+
if account_id:
|
|
303
|
+
users.append(account_id)
|
|
304
|
+
|
|
305
|
+
for group in read_restrictions.get("group", {}).get("results", []):
|
|
306
|
+
group_name = group.get("name", "")
|
|
307
|
+
if group_name:
|
|
308
|
+
groups.append(group_name)
|
|
309
|
+
|
|
310
|
+
return {"users": users, "groups": groups}
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
def _extract_actor(data: dict[str, Any]) -> Actor:
|
|
314
|
+
"""Extract the actor (last modifier) from page data."""
|
|
315
|
+
modifier = data.get("modifier", {})
|
|
316
|
+
if modifier and modifier.get("account_id"):
|
|
317
|
+
return Actor(
|
|
318
|
+
id=modifier["account_id"],
|
|
319
|
+
name=modifier.get("display_name", "Unknown"),
|
|
320
|
+
email=modifier.get("email"),
|
|
321
|
+
)
|
|
322
|
+
return Actor(id="unknown", name="Unknown")
|
|
323
|
+
|
|
324
|
+
@staticmethod
|
|
325
|
+
def _extract_entities(data: dict[str, Any]) -> list[EntityRef]:
|
|
326
|
+
"""Extract entity references from page data."""
|
|
327
|
+
entities: list[EntityRef] = []
|
|
328
|
+
|
|
329
|
+
space_key = data.get("space_key", "")
|
|
330
|
+
if space_key:
|
|
331
|
+
entities.append(
|
|
332
|
+
EntityRef(
|
|
333
|
+
entity_type="space",
|
|
334
|
+
entity_id=space_key,
|
|
335
|
+
display_name=space_key,
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
page_id = data.get("page_id", "")
|
|
340
|
+
if page_id:
|
|
341
|
+
entities.append(
|
|
342
|
+
EntityRef(
|
|
343
|
+
entity_type="page",
|
|
344
|
+
entity_id=page_id,
|
|
345
|
+
display_name=data.get("title", ""),
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
modifier = data.get("modifier", {})
|
|
350
|
+
if modifier and modifier.get("account_id"):
|
|
351
|
+
entities.append(
|
|
352
|
+
EntityRef(
|
|
353
|
+
entity_type="user",
|
|
354
|
+
entity_id=modifier["account_id"],
|
|
355
|
+
display_name=modifier.get("display_name", ""),
|
|
356
|
+
)
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
return entities
|