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