cortexdb-connectors 0.2.13__tar.gz → 0.2.14__tar.gz
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-0.2.13 → cortexdb_connectors-0.2.14}/PKG-INFO +1 -1
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/__init__.py +40 -40
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/confluence/__init__.py +195 -87
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/discord/__init__.py +95 -24
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/google_workspace/__init__.py +18 -6
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/notion/__init__.py +57 -12
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/teams/__init__.py +5 -4
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors.egg-info/PKG-INFO +1 -1
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors.egg-info/SOURCES.txt +3 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/pyproject.toml +92 -92
- cortexdb_connectors-0.2.14/tests/test_discord_normalize.py +153 -0
- cortexdb_connectors-0.2.14/tests/test_notion_comments.py +318 -0
- cortexdb_connectors-0.2.14/tests/test_product_findings_0_2_14.py +218 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/README.md +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/base.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/cli.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/freshdesk/__init__.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/github/__init__.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/gitlab/__init__.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/hubspot/__init__.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/insights/__init__.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/insights/api.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/insights/detectors.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/insights/engine.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/intercom/__init__.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/jira/__init__.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/linear/__init__.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/pagerduty/__init__.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/salesforce/__init__.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/servicenow/__init__.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/slack/__init__.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/state.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/tldv/__init__.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/webhooks.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/worker.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/zendesk/__init__.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors.egg-info/dependency_links.txt +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors.egg-info/entry_points.txt +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors.egg-info/requires.txt +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors.egg-info/top_level.txt +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/setup.cfg +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/tests/test_base.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/tests/test_connector_coverage.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/tests/test_github.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/tests/test_ingest_e2e.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/tests/test_jira_attachments.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/tests/test_jira_findings_0_2_11.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/tests/test_jira_findings_0_2_9.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/tests/test_product_findings_0_2_12.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/tests/test_slack.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/tests/test_streaming_cursor.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/tests/test_tldv_findings_0_2_12.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/tests/test_webhooks.py +0 -0
- {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/tests/test_webhooks_replay.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cortexdb-connectors
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.14
|
|
4
4
|
Summary: Data connectors for CortexDB — ingest from Slack, GitHub, GitLab, Jira, Linear, Confluence, Notion, PagerDuty, Discord, Teams, Google Workspace, Salesforce, HubSpot, Zendesk, Intercom, and ServiceNow into the v1 memory API.
|
|
5
5
|
Author-email: CortexDB Team <team@cortexdb.ai>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -1,40 +1,40 @@
|
|
|
1
|
-
"""cortexdb_connectors — data connector SDK for the CortexDB v1 API.
|
|
2
|
-
|
|
3
|
-
Each connector subclass implements `fetch_events`, `normalize`, and
|
|
4
|
-
`map_permissions`; the base class POSTs every normalised Episode to
|
|
5
|
-
`/v1/experience` with the required Authorization + X-Cortex-Actor
|
|
6
|
-
headers. Use the `cortexdb-sync` CLI to run a connector once or in a
|
|
7
|
-
poll loop; or `from cortexdb_connectors.slack import SlackConnector`
|
|
8
|
-
to drive it programmatically.
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
__version__ = "0.2.
|
|
12
|
-
|
|
13
|
-
from cortexdb_connectors.base import (
|
|
14
|
-
Actor,
|
|
15
|
-
CortexConnector,
|
|
16
|
-
EntityRef,
|
|
17
|
-
Episode,
|
|
18
|
-
EpisodeType,
|
|
19
|
-
RawEvent,
|
|
20
|
-
Source,
|
|
21
|
-
SyncResult,
|
|
22
|
-
SyncState,
|
|
23
|
-
Visibility,
|
|
24
|
-
VisibilityLevel,
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
__all__ = [
|
|
28
|
-
"__version__",
|
|
29
|
-
"Actor",
|
|
30
|
-
"CortexConnector",
|
|
31
|
-
"EntityRef",
|
|
32
|
-
"Episode",
|
|
33
|
-
"EpisodeType",
|
|
34
|
-
"RawEvent",
|
|
35
|
-
"Source",
|
|
36
|
-
"SyncResult",
|
|
37
|
-
"SyncState",
|
|
38
|
-
"Visibility",
|
|
39
|
-
"VisibilityLevel",
|
|
40
|
-
]
|
|
1
|
+
"""cortexdb_connectors — data connector SDK for the CortexDB v1 API.
|
|
2
|
+
|
|
3
|
+
Each connector subclass implements `fetch_events`, `normalize`, and
|
|
4
|
+
`map_permissions`; the base class POSTs every normalised Episode to
|
|
5
|
+
`/v1/experience` with the required Authorization + X-Cortex-Actor
|
|
6
|
+
headers. Use the `cortexdb-sync` CLI to run a connector once or in a
|
|
7
|
+
poll loop; or `from cortexdb_connectors.slack import SlackConnector`
|
|
8
|
+
to drive it programmatically.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.2.14"
|
|
12
|
+
|
|
13
|
+
from cortexdb_connectors.base import (
|
|
14
|
+
Actor,
|
|
15
|
+
CortexConnector,
|
|
16
|
+
EntityRef,
|
|
17
|
+
Episode,
|
|
18
|
+
EpisodeType,
|
|
19
|
+
RawEvent,
|
|
20
|
+
Source,
|
|
21
|
+
SyncResult,
|
|
22
|
+
SyncState,
|
|
23
|
+
Visibility,
|
|
24
|
+
VisibilityLevel,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"__version__",
|
|
29
|
+
"Actor",
|
|
30
|
+
"CortexConnector",
|
|
31
|
+
"EntityRef",
|
|
32
|
+
"Episode",
|
|
33
|
+
"EpisodeType",
|
|
34
|
+
"RawEvent",
|
|
35
|
+
"Source",
|
|
36
|
+
"SyncResult",
|
|
37
|
+
"SyncState",
|
|
38
|
+
"Visibility",
|
|
39
|
+
"VisibilityLevel",
|
|
40
|
+
]
|
{cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/confluence/__init__.py
RENAMED
|
@@ -43,6 +43,15 @@ _SOURCE = Source(system="confluence")
|
|
|
43
43
|
|
|
44
44
|
_PAGE_LIMIT = 50
|
|
45
45
|
|
|
46
|
+
# Expansions needed to build a full RawEvent from a content object. The CQL
|
|
47
|
+
# search endpoint nests content under `content.`; the content REST API
|
|
48
|
+
# returns the object directly.
|
|
49
|
+
_CONTENT_EXPAND = (
|
|
50
|
+
"body.storage,version,history.lastUpdated,metadata.labels,"
|
|
51
|
+
"ancestors,container,restrictions.read.restrictions.user,"
|
|
52
|
+
"restrictions.read.restrictions.group"
|
|
53
|
+
)
|
|
54
|
+
|
|
46
55
|
_TAG_RE = re.compile(r"<[^>]+>")
|
|
47
56
|
_WS_RE = re.compile(r"[ \t]+")
|
|
48
57
|
|
|
@@ -109,9 +118,16 @@ class ConfluenceConnector(CortexConnector):
|
|
|
109
118
|
async def fetch_events(
|
|
110
119
|
self, since: datetime | None = None
|
|
111
120
|
) -> list[RawEvent]:
|
|
112
|
-
"""Fetch recently updated pages from configured
|
|
113
|
-
|
|
114
|
-
|
|
121
|
+
"""Fetch recently updated pages and comments from configured spaces.
|
|
122
|
+
|
|
123
|
+
Pages are enumerated primarily through the content REST API, which
|
|
124
|
+
reads the content store directly — a page created seconds before
|
|
125
|
+
the sync is visible immediately. The CQL search index is only a
|
|
126
|
+
supplement (it is the sole space-wide way to enumerate comments):
|
|
127
|
+
it is eventually consistent, so relying on it as the ONLY discovery
|
|
128
|
+
path meant a just-created page was invisible to its own sync run
|
|
129
|
+
(KAN-114..122). Duplicates across the two sources are deduped by
|
|
130
|
+
``{id}:v{version}`` — the same shape as the idempotency key.
|
|
115
131
|
"""
|
|
116
132
|
from atlassian import Confluence
|
|
117
133
|
|
|
@@ -126,94 +142,186 @@ class ConfluenceConnector(CortexConnector):
|
|
|
126
142
|
raw_events: list[RawEvent] = []
|
|
127
143
|
|
|
128
144
|
for space_key in spaces:
|
|
129
|
-
|
|
130
|
-
#
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
# the overlap is absorbed by idempotency replay, a missed
|
|
139
|
-
# window is silent data loss.
|
|
140
|
-
cql_date = (since - timedelta(hours=24)).strftime("%Y-%m-%d %H:%M")
|
|
141
|
-
cql += f' AND lastModified >= "{cql_date}"'
|
|
142
|
-
cql += " ORDER BY lastModified DESC"
|
|
143
|
-
|
|
144
|
-
start = 0
|
|
145
|
-
while True:
|
|
146
|
-
try:
|
|
147
|
-
results = client.cql(
|
|
148
|
-
cql,
|
|
149
|
-
start=start,
|
|
150
|
-
limit=_PAGE_LIMIT,
|
|
151
|
-
expand="content.body.storage,content.version,"
|
|
152
|
-
"content.history.lastUpdated,"
|
|
153
|
-
"content.metadata.labels,"
|
|
154
|
-
"content.ancestors,"
|
|
155
|
-
"content.container,"
|
|
156
|
-
"content.restrictions.read.restrictions.user,"
|
|
157
|
-
"content.restrictions.read.restrictions.group",
|
|
158
|
-
)
|
|
159
|
-
except Exception:
|
|
160
|
-
logger.exception(
|
|
161
|
-
"CQL query failed for space %s", space_key
|
|
162
|
-
)
|
|
163
|
-
break
|
|
145
|
+
seen: set[str] = set()
|
|
146
|
+
# Primary: content REST API (read-your-writes).
|
|
147
|
+
raw_events.extend(
|
|
148
|
+
self._fetch_space_pages_rest(client, space_key, since, seen)
|
|
149
|
+
)
|
|
150
|
+
# Supplement: CQL search (comments + anything REST missed).
|
|
151
|
+
raw_events.extend(
|
|
152
|
+
self._fetch_space_cql(client, space_key, since, seen)
|
|
153
|
+
)
|
|
164
154
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
155
|
+
return raw_events
|
|
156
|
+
|
|
157
|
+
def _fetch_space_pages_rest(
|
|
158
|
+
self,
|
|
159
|
+
client: Any,
|
|
160
|
+
space_key: str,
|
|
161
|
+
since: datetime | None,
|
|
162
|
+
seen: set[str],
|
|
163
|
+
) -> list[RawEvent]:
|
|
164
|
+
"""Enumerate a space's pages via ``/rest/api/content``.
|
|
168
165
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
"space_key": space_key,
|
|
206
|
-
"restrictions": restrictions,
|
|
207
|
-
},
|
|
208
|
-
)
|
|
209
|
-
)
|
|
210
|
-
|
|
211
|
-
total = results.get("totalSize", 0)
|
|
212
|
-
start += _PAGE_LIMIT
|
|
213
|
-
if start >= total:
|
|
166
|
+
Ordered newest-first by ``history.lastUpdated`` so incremental runs
|
|
167
|
+
can stop paging at the (24h-widened, same as CQL) cursor window.
|
|
168
|
+
"""
|
|
169
|
+
events: list[RawEvent] = []
|
|
170
|
+
cutoff = (since - timedelta(hours=24)) if since else None
|
|
171
|
+
start = 0
|
|
172
|
+
while True:
|
|
173
|
+
try:
|
|
174
|
+
results = client.get(
|
|
175
|
+
"rest/api/content",
|
|
176
|
+
params={
|
|
177
|
+
"spaceKey": space_key,
|
|
178
|
+
"type": "page",
|
|
179
|
+
"orderby": "-history.lastUpdated",
|
|
180
|
+
"expand": _CONTENT_EXPAND,
|
|
181
|
+
"start": start,
|
|
182
|
+
"limit": _PAGE_LIMIT,
|
|
183
|
+
},
|
|
184
|
+
)
|
|
185
|
+
except Exception:
|
|
186
|
+
logger.exception(
|
|
187
|
+
"content listing failed for space %s", space_key
|
|
188
|
+
)
|
|
189
|
+
break
|
|
190
|
+
|
|
191
|
+
items = results.get("results", []) if isinstance(results, dict) else []
|
|
192
|
+
if not items:
|
|
193
|
+
break
|
|
194
|
+
|
|
195
|
+
reached_cutoff = False
|
|
196
|
+
for content_obj in items:
|
|
197
|
+
event = self._content_to_event(content_obj, space_key)
|
|
198
|
+
if event is None:
|
|
199
|
+
continue
|
|
200
|
+
if cutoff is not None and event.timestamp < cutoff:
|
|
201
|
+
reached_cutoff = True
|
|
214
202
|
break
|
|
203
|
+
key = f"{event.external_id}:v{event.data.get('version', 1)}"
|
|
204
|
+
if key in seen:
|
|
205
|
+
continue
|
|
206
|
+
seen.add(key)
|
|
207
|
+
events.append(event)
|
|
215
208
|
|
|
216
|
-
|
|
209
|
+
if reached_cutoff or len(items) < _PAGE_LIMIT:
|
|
210
|
+
break
|
|
211
|
+
start += _PAGE_LIMIT
|
|
212
|
+
|
|
213
|
+
return events
|
|
214
|
+
|
|
215
|
+
def _fetch_space_cql(
|
|
216
|
+
self,
|
|
217
|
+
client: Any,
|
|
218
|
+
space_key: str,
|
|
219
|
+
since: datetime | None,
|
|
220
|
+
seen: set[str],
|
|
221
|
+
) -> list[RawEvent]:
|
|
222
|
+
"""Enumerate a space's content via CQL search (supplement)."""
|
|
223
|
+
events: list[RawEvent] = []
|
|
224
|
+
# Comments are content too (type filter includes them): a
|
|
225
|
+
# conversation planted on a page used to be silently dropped —
|
|
226
|
+
# the connector fetched pages only.
|
|
227
|
+
cql = f'space = "{space_key}" AND type IN ("page", "comment")'
|
|
228
|
+
if since:
|
|
229
|
+
# CQL date strings are interpreted in the API user's account
|
|
230
|
+
# timezone and carry no offset syntax; a UTC-formatted cursor
|
|
231
|
+
# can therefore sit up to a day in the "future" or "past" of
|
|
232
|
+
# what Confluence compares against. Widen the window by 24h —
|
|
233
|
+
# the overlap is absorbed by idempotency replay, a missed
|
|
234
|
+
# window is silent data loss.
|
|
235
|
+
cql_date = (since - timedelta(hours=24)).strftime("%Y-%m-%d %H:%M")
|
|
236
|
+
cql += f' AND lastModified >= "{cql_date}"'
|
|
237
|
+
cql += " ORDER BY lastModified DESC"
|
|
238
|
+
|
|
239
|
+
start = 0
|
|
240
|
+
while True:
|
|
241
|
+
try:
|
|
242
|
+
results = client.cql(
|
|
243
|
+
cql,
|
|
244
|
+
start=start,
|
|
245
|
+
limit=_PAGE_LIMIT,
|
|
246
|
+
expand=",".join(
|
|
247
|
+
f"content.{part}" for part in _CONTENT_EXPAND.split(",")
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
except Exception:
|
|
251
|
+
logger.exception(
|
|
252
|
+
"CQL query failed for space %s", space_key
|
|
253
|
+
)
|
|
254
|
+
break
|
|
255
|
+
|
|
256
|
+
search_results = results.get("results", [])
|
|
257
|
+
if not search_results:
|
|
258
|
+
break
|
|
259
|
+
|
|
260
|
+
for result in search_results:
|
|
261
|
+
content_obj = result.get("content", result)
|
|
262
|
+
event = self._content_to_event(
|
|
263
|
+
content_obj,
|
|
264
|
+
space_key,
|
|
265
|
+
# The search `excerpt` lives on the RESULT row, not
|
|
266
|
+
# the content object (the old read was dead code).
|
|
267
|
+
fallback_excerpt=result.get("excerpt", ""),
|
|
268
|
+
)
|
|
269
|
+
if event is None:
|
|
270
|
+
continue
|
|
271
|
+
key = f"{event.external_id}:v{event.data.get('version', 1)}"
|
|
272
|
+
if key in seen:
|
|
273
|
+
continue
|
|
274
|
+
seen.add(key)
|
|
275
|
+
events.append(event)
|
|
276
|
+
|
|
277
|
+
total = results.get("totalSize", 0)
|
|
278
|
+
start += _PAGE_LIMIT
|
|
279
|
+
if start >= total:
|
|
280
|
+
break
|
|
281
|
+
|
|
282
|
+
return events
|
|
283
|
+
|
|
284
|
+
def _content_to_event(
|
|
285
|
+
self,
|
|
286
|
+
content_obj: dict[str, Any],
|
|
287
|
+
space_key: str,
|
|
288
|
+
fallback_excerpt: str = "",
|
|
289
|
+
) -> RawEvent | None:
|
|
290
|
+
"""Build a RawEvent from a content object (page or comment)."""
|
|
291
|
+
is_comment = (content_obj.get("type") == "comment")
|
|
292
|
+
if is_comment:
|
|
293
|
+
item_data = self._extract_comment_data(content_obj, space_key)
|
|
294
|
+
if not item_data.get("comment_id"):
|
|
295
|
+
return None
|
|
296
|
+
external_id = f"comment:{item_data['comment_id']}"
|
|
297
|
+
else:
|
|
298
|
+
item_data = self._extract_page_data(content_obj, space_key)
|
|
299
|
+
if not item_data.get("excerpt"):
|
|
300
|
+
item_data["excerpt"] = fallback_excerpt
|
|
301
|
+
external_id = item_data.get("page_id", "")
|
|
302
|
+
|
|
303
|
+
updated_str = item_data.get("last_modified", "")
|
|
304
|
+
try:
|
|
305
|
+
updated_at = datetime.fromisoformat(
|
|
306
|
+
updated_str.replace("Z", "+00:00")
|
|
307
|
+
)
|
|
308
|
+
except (ValueError, TypeError):
|
|
309
|
+
updated_at = datetime.now(timezone.utc)
|
|
310
|
+
if updated_at.tzinfo is None:
|
|
311
|
+
updated_at = updated_at.replace(tzinfo=timezone.utc)
|
|
312
|
+
|
|
313
|
+
restrictions = self._extract_restrictions(content_obj)
|
|
314
|
+
|
|
315
|
+
return RawEvent(
|
|
316
|
+
source="confluence",
|
|
317
|
+
external_id=external_id,
|
|
318
|
+
timestamp=updated_at,
|
|
319
|
+
data=item_data,
|
|
320
|
+
permissions={
|
|
321
|
+
"space_key": space_key,
|
|
322
|
+
"restrictions": restrictions,
|
|
323
|
+
},
|
|
324
|
+
)
|
|
217
325
|
|
|
218
326
|
def normalize(self, raw: RawEvent) -> Episode | None:
|
|
219
327
|
"""Convert a Confluence ``RawEvent`` (page or comment) into an
|
{cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/discord/__init__.py
RENAMED
|
@@ -146,8 +146,15 @@ class DiscordConnector(CortexConnector):
|
|
|
146
146
|
|
|
147
147
|
return events
|
|
148
148
|
|
|
149
|
-
def normalize(self, raw: RawEvent) -> Episode:
|
|
150
|
-
"""Convert a Discord ``RawEvent`` into a CortexDB ``Episode``.
|
|
149
|
+
def normalize(self, raw: RawEvent) -> Episode | None:
|
|
150
|
+
"""Convert a Discord ``RawEvent`` into a CortexDB ``Episode``.
|
|
151
|
+
|
|
152
|
+
Returns None (skip) for messages the content policy excludes:
|
|
153
|
+
text-less system messages (thread-created announcements, member
|
|
154
|
+
joins, pins) and anything else with no renderable content — the
|
|
155
|
+
server rejects empty-text envelopes (422 INVALID_ENVELOPE,
|
|
156
|
+
permanent), so they must be dropped client-side.
|
|
157
|
+
"""
|
|
151
158
|
data = raw.data
|
|
152
159
|
event_kind = data.get("_event_kind", "message")
|
|
153
160
|
|
|
@@ -189,9 +196,82 @@ class DiscordConnector(CortexConnector):
|
|
|
189
196
|
|
|
190
197
|
# -- message normalization -----------------------------------------------
|
|
191
198
|
|
|
192
|
-
|
|
193
|
-
|
|
199
|
+
@staticmethod
|
|
200
|
+
def _render_message_content(data: dict[str, Any]) -> str | None:
|
|
201
|
+
"""Discord content policy; None means skip the message.
|
|
202
|
+
|
|
203
|
+
The server rejects empty-text envelopes (422 INVALID_ENVELOPE,
|
|
204
|
+
permanent — KAN-97..111/123..127), so a text-less message must
|
|
205
|
+
either render its non-text payload or be skipped client-side.
|
|
206
|
+
Rendered: message text, attachment filenames, embeds (title, or
|
|
207
|
+
description/URL for untitled embeds), stickers, poll question +
|
|
208
|
+
options. Skipped: only when nothing renderable remains — which
|
|
209
|
+
covers thread-created / member-join / pin system messages, whose
|
|
210
|
+
``content`` is always empty.
|
|
211
|
+
"""
|
|
212
|
+
segments: list[str] = []
|
|
213
|
+
|
|
214
|
+
text = (data.get("content") or "").strip()
|
|
215
|
+
if text:
|
|
216
|
+
segments.append(text)
|
|
217
|
+
|
|
218
|
+
# Attachment filenames.
|
|
219
|
+
attachments = data.get("attachments", [])
|
|
220
|
+
if attachments:
|
|
221
|
+
att_desc = ", ".join(
|
|
222
|
+
a.get("filename", "attachment") for a in attachments
|
|
223
|
+
)
|
|
224
|
+
segments.append(f"[Attachments: {att_desc}]")
|
|
225
|
+
|
|
226
|
+
# Embeds: titled embeds render their title (previous behaviour);
|
|
227
|
+
# untitled embeds fall back to description or URL instead of
|
|
228
|
+
# vanishing (untitled-embed-only messages were sent empty).
|
|
229
|
+
embed_descs = [
|
|
230
|
+
desc
|
|
231
|
+
for e in data.get("embeds", [])
|
|
232
|
+
if (
|
|
233
|
+
desc := str(
|
|
234
|
+
e.get("title") or e.get("description") or e.get("url") or ""
|
|
235
|
+
).strip()
|
|
236
|
+
)
|
|
237
|
+
]
|
|
238
|
+
if embed_descs:
|
|
239
|
+
segments.append(f"[Embeds: {', '.join(embed_descs)}]")
|
|
240
|
+
|
|
241
|
+
# Stickers (sticker-only messages carry no ``content``).
|
|
242
|
+
for sticker in data.get("sticker_items") or data.get("stickers") or []:
|
|
243
|
+
name = (sticker.get("name") or "").strip()
|
|
244
|
+
segments.append(f"[sticker: {name}]" if name else "[sticker]")
|
|
245
|
+
|
|
246
|
+
# Polls: question + options (poll-only messages carry no ``content``).
|
|
247
|
+
poll = data.get("poll") or {}
|
|
248
|
+
question = ((poll.get("question") or {}).get("text") or "").strip()
|
|
249
|
+
options = [
|
|
250
|
+
opt
|
|
251
|
+
for a in poll.get("answers", [])
|
|
252
|
+
if (opt := ((a.get("poll_media") or {}).get("text") or "").strip())
|
|
253
|
+
]
|
|
254
|
+
if question or options:
|
|
255
|
+
poll_desc = question
|
|
256
|
+
if options:
|
|
257
|
+
joined = ", ".join(options)
|
|
258
|
+
poll_desc = (
|
|
259
|
+
f"{question} — options: {joined}"
|
|
260
|
+
if question
|
|
261
|
+
else f"options: {joined}"
|
|
262
|
+
)
|
|
263
|
+
segments.append(f"[poll: {poll_desc}]")
|
|
264
|
+
|
|
265
|
+
if not segments:
|
|
266
|
+
return None
|
|
267
|
+
return "\n".join(segments)
|
|
268
|
+
|
|
269
|
+
def _normalize_message(self, raw: RawEvent) -> Episode | None:
|
|
270
|
+
"""Normalize a Discord message into an Episode (None = skip)."""
|
|
194
271
|
data = raw.data
|
|
272
|
+
content = self._render_message_content(data)
|
|
273
|
+
if content is None:
|
|
274
|
+
return None
|
|
195
275
|
author = data.get("author", {})
|
|
196
276
|
actor = Actor(
|
|
197
277
|
id=author.get("id", "unknown"),
|
|
@@ -209,24 +289,8 @@ class DiscordConnector(CortexConnector):
|
|
|
209
289
|
guild_id = data.get("guild_id", "")
|
|
210
290
|
guild_name = data.get("_guild_name", guild_id)
|
|
211
291
|
|
|
212
|
-
content = data.get("content", "")
|
|
213
|
-
|
|
214
|
-
# Append attachment descriptions.
|
|
215
292
|
attachments = data.get("attachments", [])
|
|
216
|
-
if attachments:
|
|
217
|
-
att_desc = ", ".join(
|
|
218
|
-
a.get("filename", "attachment") for a in attachments
|
|
219
|
-
)
|
|
220
|
-
content += f"\n[Attachments: {att_desc}]"
|
|
221
|
-
|
|
222
|
-
# Append embed titles.
|
|
223
293
|
embeds = data.get("embeds", [])
|
|
224
|
-
if embeds:
|
|
225
|
-
embed_desc = ", ".join(
|
|
226
|
-
e.get("title", "embed") for e in embeds if e.get("title")
|
|
227
|
-
)
|
|
228
|
-
if embed_desc:
|
|
229
|
-
content += f"\n[Embeds: {embed_desc}]"
|
|
230
294
|
|
|
231
295
|
entities = self._extract_message_entities(data)
|
|
232
296
|
|
|
@@ -278,8 +342,12 @@ class DiscordConnector(CortexConnector):
|
|
|
278
342
|
idempotency_key=f"discord:{data.get('id', raw.external_id)}",
|
|
279
343
|
)
|
|
280
344
|
|
|
281
|
-
def _normalize_forum_post(self, raw: RawEvent) -> Episode:
|
|
282
|
-
"""Normalize a Discord forum post (thread starter) into an Episode.
|
|
345
|
+
def _normalize_forum_post(self, raw: RawEvent) -> Episode | None:
|
|
346
|
+
"""Normalize a Discord forum post (thread starter) into an Episode.
|
|
347
|
+
|
|
348
|
+
Returns None (skip) when neither the thread name nor the starter
|
|
349
|
+
message renders any content.
|
|
350
|
+
"""
|
|
283
351
|
data = raw.data
|
|
284
352
|
author = data.get("author", {})
|
|
285
353
|
actor = Actor(
|
|
@@ -294,9 +362,12 @@ class DiscordConnector(CortexConnector):
|
|
|
294
362
|
)
|
|
295
363
|
|
|
296
364
|
thread_name = data.get("_thread_name", "")
|
|
297
|
-
|
|
365
|
+
body = self._render_message_content(data)
|
|
366
|
+
if body is None and not thread_name:
|
|
367
|
+
return None
|
|
368
|
+
content = body or ""
|
|
298
369
|
if thread_name:
|
|
299
|
-
content = f"[{thread_name}]\n\n{content}"
|
|
370
|
+
content = f"[{thread_name}]\n\n{content}" if content else f"[{thread_name}]"
|
|
300
371
|
|
|
301
372
|
entities = self._extract_message_entities(data)
|
|
302
373
|
|
|
@@ -752,8 +752,13 @@ class GoogleWorkspaceConnector(CortexConnector):
|
|
|
752
752
|
|
|
753
753
|
return events
|
|
754
754
|
|
|
755
|
-
def normalize(self, raw: RawEvent) -> Episode:
|
|
756
|
-
"""Route normalization to the appropriate sub-connector handler.
|
|
755
|
+
def normalize(self, raw: RawEvent) -> Episode | None:
|
|
756
|
+
"""Route normalization to the appropriate sub-connector handler.
|
|
757
|
+
|
|
758
|
+
Returns None (skip) for items with no renderable content — the
|
|
759
|
+
server rejects empty-text envelopes (422 INVALID_ENVELOPE,
|
|
760
|
+
permanent), so they must be dropped client-side.
|
|
761
|
+
"""
|
|
757
762
|
sub_source = raw.data.get("_sub_source", "")
|
|
758
763
|
if sub_source == "gmail":
|
|
759
764
|
return self._normalize_gmail(raw)
|
|
@@ -776,8 +781,8 @@ class GoogleWorkspaceConnector(CortexConnector):
|
|
|
776
781
|
|
|
777
782
|
# -- Gmail normalization -------------------------------------------------
|
|
778
783
|
|
|
779
|
-
def _normalize_gmail(self, raw: RawEvent) -> Episode:
|
|
780
|
-
"""Normalize a Gmail message into a CortexDB Episode."""
|
|
784
|
+
def _normalize_gmail(self, raw: RawEvent) -> Episode | None:
|
|
785
|
+
"""Normalize a Gmail message into a CortexDB Episode (None = skip)."""
|
|
781
786
|
data = raw.data
|
|
782
787
|
actor = Actor(
|
|
783
788
|
id=data.get("from_email", "unknown"),
|
|
@@ -785,11 +790,18 @@ class GoogleWorkspaceConnector(CortexConnector):
|
|
|
785
790
|
email=data.get("from_email"),
|
|
786
791
|
)
|
|
787
792
|
|
|
788
|
-
subject = data.get("subject"
|
|
793
|
+
subject = (data.get("subject") or "").strip()
|
|
789
794
|
# Full body when the fetch carried one (format="full"); the ~200-char
|
|
790
795
|
# snippet is only the no-body fallback — it silently lost every mail
|
|
791
796
|
# body otherwise.
|
|
792
|
-
body = data.get("body", "") or data.get("snippet", "")
|
|
797
|
+
body = (data.get("body", "") or data.get("snippet", "")).strip()
|
|
798
|
+
if not subject and not body:
|
|
799
|
+
# Nothing renderable (empty Subject, no body, no snippet): skip
|
|
800
|
+
# client-side — the server rejects empty-text envelopes with a
|
|
801
|
+
# permanent 422 INVALID_ENVELOPE.
|
|
802
|
+
return None
|
|
803
|
+
if not subject:
|
|
804
|
+
subject = "(no subject)"
|
|
793
805
|
content = f"Subject: {subject}\n\n{body}" if body else subject
|
|
794
806
|
|
|
795
807
|
thread_id = data.get("thread_id")
|