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.
Files changed (54) hide show
  1. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/PKG-INFO +1 -1
  2. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/__init__.py +40 -40
  3. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/confluence/__init__.py +195 -87
  4. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/discord/__init__.py +95 -24
  5. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/google_workspace/__init__.py +18 -6
  6. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/notion/__init__.py +57 -12
  7. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/teams/__init__.py +5 -4
  8. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors.egg-info/PKG-INFO +1 -1
  9. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors.egg-info/SOURCES.txt +3 -0
  10. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/pyproject.toml +92 -92
  11. cortexdb_connectors-0.2.14/tests/test_discord_normalize.py +153 -0
  12. cortexdb_connectors-0.2.14/tests/test_notion_comments.py +318 -0
  13. cortexdb_connectors-0.2.14/tests/test_product_findings_0_2_14.py +218 -0
  14. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/README.md +0 -0
  15. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/base.py +0 -0
  16. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/cli.py +0 -0
  17. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/freshdesk/__init__.py +0 -0
  18. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/github/__init__.py +0 -0
  19. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/gitlab/__init__.py +0 -0
  20. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/hubspot/__init__.py +0 -0
  21. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/insights/__init__.py +0 -0
  22. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/insights/api.py +0 -0
  23. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/insights/detectors.py +0 -0
  24. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/insights/engine.py +0 -0
  25. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/intercom/__init__.py +0 -0
  26. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/jira/__init__.py +0 -0
  27. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/linear/__init__.py +0 -0
  28. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/pagerduty/__init__.py +0 -0
  29. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/salesforce/__init__.py +0 -0
  30. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/servicenow/__init__.py +0 -0
  31. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/slack/__init__.py +0 -0
  32. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/state.py +0 -0
  33. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/tldv/__init__.py +0 -0
  34. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/webhooks.py +0 -0
  35. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/worker.py +0 -0
  36. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors/zendesk/__init__.py +0 -0
  37. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors.egg-info/dependency_links.txt +0 -0
  38. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors.egg-info/entry_points.txt +0 -0
  39. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors.egg-info/requires.txt +0 -0
  40. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/cortexdb_connectors.egg-info/top_level.txt +0 -0
  41. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/setup.cfg +0 -0
  42. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/tests/test_base.py +0 -0
  43. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/tests/test_connector_coverage.py +0 -0
  44. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/tests/test_github.py +0 -0
  45. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/tests/test_ingest_e2e.py +0 -0
  46. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/tests/test_jira_attachments.py +0 -0
  47. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/tests/test_jira_findings_0_2_11.py +0 -0
  48. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/tests/test_jira_findings_0_2_9.py +0 -0
  49. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/tests/test_product_findings_0_2_12.py +0 -0
  50. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/tests/test_slack.py +0 -0
  51. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/tests/test_streaming_cursor.py +0 -0
  52. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/tests/test_tldv_findings_0_2_12.py +0 -0
  53. {cortexdb_connectors-0.2.13 → cortexdb_connectors-0.2.14}/tests/test_webhooks.py +0 -0
  54. {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.13
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.8"
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
+ ]
@@ -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 Confluence spaces.
113
-
114
- Uses the CQL ``lastModified`` filter to retrieve incremental updates.
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
- # Comments are content too (type filter includes them): a
130
- # conversation planted on a page used to be silently dropped —
131
- # the connector fetched pages only.
132
- cql = f'space = "{space_key}" AND type IN ("page", "comment")'
133
- if since:
134
- # CQL date strings are interpreted in the API user's account
135
- # timezone and carry no offset syntax; a UTC-formatted cursor
136
- # can therefore sit up to a day in the "future" or "past" of
137
- # what Confluence compares against. Widen the window by 24h —
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
- search_results = results.get("results", [])
166
- if not search_results:
167
- break
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
- for result in search_results:
170
- content_obj = result.get("content", result)
171
- is_comment = (content_obj.get("type") == "comment")
172
- if is_comment:
173
- item_data = self._extract_comment_data(
174
- content_obj, space_key
175
- )
176
- if not item_data.get("comment_id"):
177
- continue
178
- external_id = f"comment:{item_data['comment_id']}"
179
- else:
180
- item_data = self._extract_page_data(
181
- content_obj, space_key
182
- )
183
- # The search `excerpt` lives on the RESULT row, not
184
- # the content object (the old read was dead code).
185
- if not item_data.get("excerpt"):
186
- item_data["excerpt"] = result.get("excerpt", "")
187
- external_id = item_data.get("page_id", "")
188
- updated_str = item_data.get("last_modified", "")
189
- try:
190
- updated_at = datetime.fromisoformat(
191
- updated_str.replace("Z", "+00:00")
192
- )
193
- except (ValueError, TypeError):
194
- updated_at = datetime.now(timezone.utc)
195
-
196
- restrictions = self._extract_restrictions(content_obj)
197
-
198
- raw_events.append(
199
- RawEvent(
200
- source="confluence",
201
- external_id=external_id,
202
- timestamp=updated_at,
203
- data=item_data,
204
- permissions={
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
- return raw_events
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
@@ -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
- def _normalize_message(self, raw: RawEvent) -> Episode:
193
- """Normalize a Discord message into an Episode."""
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
- content = data.get("content", "")
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", "(no 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")