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,192 @@
1
+ """Background sync worker that periodically runs all configured connectors."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ import signal
7
+ import sys
8
+ from datetime import datetime, timezone
9
+
10
+ logger = logging.getLogger("cortexdb.connectors.worker")
11
+
12
+
13
+ async def run_sync_cycle(cortex_url: str, api_key: str, tenant_id: str) -> None:
14
+ """Run one sync cycle for all configured connectors."""
15
+ connectors = []
16
+
17
+ # Slack
18
+ slack_token = os.environ.get("SLACK_BOT_TOKEN")
19
+ if slack_token:
20
+ try:
21
+ from cortexdb_connectors.slack import SlackConnector
22
+
23
+ channels = os.environ.get("SLACK_CHANNELS", "").split(",")
24
+ channels = [c.strip() for c in channels if c.strip()]
25
+ connector = SlackConnector(
26
+ cortex_url=cortex_url,
27
+ cortex_api_key=api_key,
28
+ tenant_id=tenant_id,
29
+ slack_bot_token=slack_token,
30
+ channels=channels or None,
31
+ )
32
+ connectors.append(("slack", connector))
33
+ except ImportError:
34
+ logger.warning("slack-sdk not installed, skipping Slack connector")
35
+
36
+ # GitHub
37
+ github_token = os.environ.get("GITHUB_TOKEN")
38
+ if github_token:
39
+ try:
40
+ from cortexdb_connectors.github import GitHubConnector
41
+
42
+ repos = os.environ.get("GITHUB_REPOS", "").split(",")
43
+ repos = [r.strip() for r in repos if r.strip()]
44
+ connector = GitHubConnector(
45
+ cortex_url=cortex_url,
46
+ cortex_api_key=api_key,
47
+ tenant_id=tenant_id,
48
+ github_token=github_token,
49
+ repos=repos,
50
+ )
51
+ connectors.append(("github", connector))
52
+ except ImportError:
53
+ logger.warning("pygithub not installed, skipping GitHub connector")
54
+
55
+ # PagerDuty
56
+ pd_key = os.environ.get("PAGERDUTY_API_KEY")
57
+ if pd_key:
58
+ try:
59
+ from cortexdb_connectors.pagerduty import PagerDutyConnector
60
+
61
+ service_ids = os.environ.get("PAGERDUTY_SERVICE_IDS", "").split(",")
62
+ service_ids = [s.strip() for s in service_ids if s.strip()]
63
+ connector = PagerDutyConnector(
64
+ cortex_url=cortex_url,
65
+ cortex_api_key=api_key,
66
+ tenant_id=tenant_id,
67
+ pagerduty_api_key=pd_key,
68
+ service_ids=service_ids or None,
69
+ )
70
+ connectors.append(("pagerduty", connector))
71
+ except ImportError:
72
+ logger.warning("pdpyras not installed, skipping PagerDuty connector")
73
+
74
+ # Jira
75
+ jira_url = os.environ.get("JIRA_URL")
76
+ jira_email = os.environ.get("JIRA_EMAIL")
77
+ jira_token = os.environ.get("JIRA_API_TOKEN")
78
+ if jira_url and jira_email and jira_token:
79
+ try:
80
+ from cortexdb_connectors.jira import JiraConnector
81
+
82
+ project_keys = os.environ.get("JIRA_PROJECT_KEYS", "").split(",")
83
+ project_keys = [k.strip() for k in project_keys if k.strip()]
84
+ connector = JiraConnector(
85
+ cortex_url=cortex_url,
86
+ cortex_api_key=api_key,
87
+ tenant_id=tenant_id,
88
+ jira_url=jira_url,
89
+ jira_email=jira_email,
90
+ jira_api_token=jira_token,
91
+ project_keys=project_keys,
92
+ )
93
+ connectors.append(("jira", connector))
94
+ except ImportError:
95
+ logger.warning("jira not installed, skipping Jira connector")
96
+
97
+ # Confluence
98
+ confluence_url = os.environ.get("CONFLUENCE_URL")
99
+ confluence_email = os.environ.get("CONFLUENCE_EMAIL")
100
+ confluence_token = os.environ.get("CONFLUENCE_API_TOKEN")
101
+ if confluence_url and confluence_email and confluence_token:
102
+ try:
103
+ from cortexdb_connectors.confluence import ConfluenceConnector
104
+
105
+ space_keys = os.environ.get("CONFLUENCE_SPACE_KEYS", "").split(",")
106
+ space_keys = [k.strip() for k in space_keys if k.strip()]
107
+ connector = ConfluenceConnector(
108
+ cortex_url=cortex_url,
109
+ cortex_api_key=api_key,
110
+ tenant_id=tenant_id,
111
+ confluence_url=confluence_url,
112
+ email=confluence_email,
113
+ api_token=confluence_token,
114
+ space_keys=space_keys,
115
+ )
116
+ connectors.append(("confluence", connector))
117
+ except ImportError:
118
+ logger.warning(
119
+ "atlassian-python-api not installed, skipping Confluence connector"
120
+ )
121
+
122
+ if not connectors:
123
+ logger.info("No connectors configured, skipping sync cycle")
124
+ return
125
+
126
+ for name, connector in connectors:
127
+ try:
128
+ result = await connector.sync()
129
+ logger.info(
130
+ "Connector %s: fetched=%d ingested=%d errors=%d duration=%.1fs",
131
+ name,
132
+ result.episodes_fetched,
133
+ result.episodes_ingested,
134
+ result.errors,
135
+ result.duration_seconds,
136
+ )
137
+ except Exception:
138
+ logger.exception("Connector %s failed", name)
139
+
140
+
141
+ async def main_loop() -> None:
142
+ """Main worker loop."""
143
+ cortex_url = os.environ.get("CORTEXDB_URL", "http://localhost:3141")
144
+ api_key = os.environ.get("CORTEXDB_API_KEY", "cx_live_default_key")
145
+ tenant_id = os.environ.get("CORTEXDB_TENANT_ID", "default")
146
+ interval = int(os.environ.get("SYNC_INTERVAL", "300"))
147
+
148
+ logger.info(
149
+ "Starting connector worker: url=%s tenant=%s interval=%ds",
150
+ cortex_url,
151
+ tenant_id,
152
+ interval,
153
+ )
154
+
155
+ shutdown = asyncio.Event()
156
+
157
+ def handle_signal() -> None:
158
+ logger.info("Received shutdown signal")
159
+ shutdown.set()
160
+
161
+ loop = asyncio.get_running_loop()
162
+ for sig in (signal.SIGINT, signal.SIGTERM):
163
+ try:
164
+ loop.add_signal_handler(sig, handle_signal)
165
+ except NotImplementedError:
166
+ pass # Windows
167
+
168
+ while not shutdown.is_set():
169
+ try:
170
+ await run_sync_cycle(cortex_url, api_key, tenant_id)
171
+ except Exception:
172
+ logger.exception("Sync cycle failed")
173
+
174
+ try:
175
+ await asyncio.wait_for(shutdown.wait(), timeout=interval)
176
+ except asyncio.TimeoutError:
177
+ pass
178
+
179
+ logger.info("Worker shut down cleanly")
180
+
181
+
182
+ def main() -> None:
183
+ """Entry point."""
184
+ logging.basicConfig(
185
+ level=logging.INFO,
186
+ format="%(asctime)s %(name)s %(levelname)s %(message)s",
187
+ )
188
+ asyncio.run(main_loop())
189
+
190
+
191
+ if __name__ == "__main__":
192
+ main()
@@ -0,0 +1,496 @@
1
+ """
2
+ Zendesk connector for CortexDB.
3
+
4
+ Polls Zendesk Support API v2 for Tickets, Comments, and Help Center
5
+ Articles, converting them into CortexDB Episodes. Uses ``httpx`` for
6
+ direct HTTP access with cursor-based pagination.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from datetime import datetime, timedelta, timezone
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+ from cortexdb_connectors.base import (
18
+ Actor,
19
+ CortexConnector,
20
+ EntityRef,
21
+ Episode,
22
+ EpisodeType,
23
+ RawEvent,
24
+ Source,
25
+ Visibility,
26
+ VisibilityLevel,
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ _SOURCE = Source(system="zendesk")
32
+
33
+ _RATE_LIMIT_RETRY_HEADER = "Retry-After"
34
+ _MAX_RETRIES = 3
35
+
36
+
37
+ class ZendeskConnector(CortexConnector):
38
+ """Connector that syncs Zendesk tickets, comments, and articles into CortexDB.
39
+
40
+ Parameters
41
+ ----------
42
+ cortex_url:
43
+ Base URL of the CortexDB API.
44
+ cortex_api_key:
45
+ Bearer token for CortexDB authentication.
46
+ subdomain:
47
+ Zendesk subdomain (e.g. ``mycompany`` for mycompany.zendesk.com).
48
+ email:
49
+ Zendesk agent email for API authentication.
50
+ api_token:
51
+ Zendesk API token.
52
+ include_help_center:
53
+ Whether to ingest Help Center articles.
54
+ namespace:
55
+ CortexDB namespace for ingested episodes.
56
+ backfill_days:
57
+ Number of days of historical data to backfill on first sync.
58
+ tenant_id:
59
+ CortexDB tenant identifier.
60
+ """
61
+
62
+ connector_name: str = "zendesk"
63
+
64
+ def __init__(
65
+ self,
66
+ cortex_url: str,
67
+ cortex_api_key: str,
68
+ subdomain: str,
69
+ email: str,
70
+ api_token: str,
71
+ include_help_center: bool = False,
72
+ namespace: str = "zendesk",
73
+ backfill_days: int = 90,
74
+ tenant_id: str = "default",
75
+ ) -> None:
76
+ super().__init__(cortex_url, cortex_api_key, tenant_id)
77
+ self.subdomain = subdomain
78
+ self.email = email
79
+ self.api_token = api_token
80
+ self.include_help_center = include_help_center
81
+ self.namespace = namespace
82
+ self.backfill_days = backfill_days
83
+ self._base_url = f"https://{subdomain}.zendesk.com"
84
+
85
+ # -- HTTP helpers --------------------------------------------------------
86
+
87
+ def _auth(self) -> tuple[str, str]:
88
+ """Return HTTP basic auth credentials (email/token)."""
89
+ return f"{self.email}/token", self.api_token
90
+
91
+ async def _request(
92
+ self,
93
+ client: httpx.AsyncClient,
94
+ method: str,
95
+ url: str,
96
+ **kwargs: Any,
97
+ ) -> httpx.Response:
98
+ """Execute an HTTP request with rate-limit retry."""
99
+ for attempt in range(_MAX_RETRIES):
100
+ resp = await client.request(method, url, **kwargs)
101
+ if resp.status_code == 429:
102
+ retry_after = int(
103
+ resp.headers.get(_RATE_LIMIT_RETRY_HEADER, "5")
104
+ )
105
+ logger.warning(
106
+ "Zendesk rate limited, retrying in %ds (attempt %d/%d)",
107
+ retry_after,
108
+ attempt + 1,
109
+ _MAX_RETRIES,
110
+ )
111
+ import asyncio
112
+
113
+ await asyncio.sleep(retry_after)
114
+ continue
115
+ resp.raise_for_status()
116
+ return resp
117
+ raise httpx.HTTPStatusError(
118
+ "Rate limit retries exhausted",
119
+ request=resp.request, # type: ignore[possibly-undefined]
120
+ response=resp, # type: ignore[possibly-undefined]
121
+ )
122
+
123
+ # -- CortexConnector interface -------------------------------------------
124
+
125
+ async def fetch_events(
126
+ self, since: datetime | None = None
127
+ ) -> list[RawEvent]:
128
+ """Fetch tickets, comments, and optionally articles from Zendesk.
129
+
130
+ Uses cursor-based pagination via the incremental exports API for
131
+ tickets and standard pagination for comments and articles.
132
+ """
133
+ cutoff = since or datetime.now(timezone.utc) - timedelta(
134
+ days=self.backfill_days
135
+ )
136
+ raw_events: list[RawEvent] = []
137
+
138
+ async with httpx.AsyncClient(
139
+ auth=self._auth(), timeout=30.0
140
+ ) as client:
141
+ raw_events.extend(await self._fetch_tickets(client, cutoff))
142
+ if self.include_help_center:
143
+ raw_events.extend(await self._fetch_articles(client, cutoff))
144
+
145
+ return raw_events
146
+
147
+ def normalize(self, raw: RawEvent) -> Episode:
148
+ """Convert a Zendesk ``RawEvent`` into a CortexDB ``Episode``."""
149
+ data = raw.data
150
+ event_kind = data.get("event_kind", "ticket")
151
+
152
+ if event_kind == "ticket":
153
+ return self._normalize_ticket(raw)
154
+ if event_kind == "comment":
155
+ return self._normalize_comment(raw)
156
+ if event_kind == "article":
157
+ return self._normalize_article(raw)
158
+ return self._normalize_ticket(raw)
159
+
160
+ def map_permissions(self, raw: RawEvent) -> Visibility:
161
+ """Map Zendesk visibility to CortexDB visibility.
162
+
163
+ Internal notes are ``Restricted``; public comments and articles
164
+ are ``Organization``; everything else is ``Organization``.
165
+ """
166
+ data = raw.data
167
+ if data.get("is_public") is False:
168
+ return Visibility(level=VisibilityLevel.RESTRICTED)
169
+ return Visibility(level=VisibilityLevel.ORGANIZATION)
170
+
171
+ # -- fetchers ------------------------------------------------------------
172
+
173
+ async def _fetch_tickets(
174
+ self,
175
+ client: httpx.AsyncClient,
176
+ cutoff: datetime,
177
+ ) -> list[RawEvent]:
178
+ """Fetch tickets and their comments using incremental export."""
179
+ events: list[RawEvent] = []
180
+ cutoff_unix = int(cutoff.timestamp())
181
+
182
+ url = (
183
+ f"{self._base_url}/api/v2/incremental/tickets/cursor.json"
184
+ f"?start_time={cutoff_unix}"
185
+ )
186
+
187
+ while url:
188
+ try:
189
+ resp = await self._request(client, "GET", url)
190
+ except Exception:
191
+ logger.exception("Failed to fetch Zendesk tickets")
192
+ break
193
+
194
+ body = resp.json()
195
+ tickets = body.get("tickets", [])
196
+
197
+ for ticket in tickets:
198
+ ts = self._parse_ts(ticket.get("updated_at"))
199
+ events.append(
200
+ RawEvent(
201
+ source="zendesk",
202
+ external_id=str(ticket.get("id", "")),
203
+ timestamp=ts,
204
+ data={
205
+ "event_kind": "ticket",
206
+ "ticket": ticket,
207
+ "is_public": True,
208
+ },
209
+ permissions={
210
+ "requester_id": ticket.get("requester_id"),
211
+ "organization_id": ticket.get("organization_id"),
212
+ },
213
+ )
214
+ )
215
+
216
+ # Fetch comments for this ticket.
217
+ events.extend(
218
+ await self._fetch_comments(
219
+ client, ticket.get("id", ""), ticket
220
+ )
221
+ )
222
+
223
+ # Cursor-based pagination.
224
+ if body.get("end_of_stream"):
225
+ break
226
+ url = body.get("after_url", "")
227
+
228
+ return events
229
+
230
+ async def _fetch_comments(
231
+ self,
232
+ client: httpx.AsyncClient,
233
+ ticket_id: str | int,
234
+ ticket: dict[str, Any],
235
+ ) -> list[RawEvent]:
236
+ """Fetch all comments for a single ticket."""
237
+ events: list[RawEvent] = []
238
+ url: str | None = (
239
+ f"{self._base_url}/api/v2/tickets/{ticket_id}/comments.json"
240
+ )
241
+
242
+ while url:
243
+ try:
244
+ resp = await self._request(client, "GET", url)
245
+ except Exception:
246
+ logger.debug("Could not fetch comments for ticket %s", ticket_id)
247
+ break
248
+
249
+ body = resp.json()
250
+ for comment in body.get("comments", []):
251
+ ts = self._parse_ts(comment.get("created_at"))
252
+ events.append(
253
+ RawEvent(
254
+ source="zendesk",
255
+ external_id=f"comment:{comment.get('id', '')}",
256
+ timestamp=ts,
257
+ data={
258
+ "event_kind": "comment",
259
+ "comment": comment,
260
+ "ticket_id": str(ticket_id),
261
+ "ticket_subject": ticket.get("subject", ""),
262
+ "is_public": comment.get("public", True),
263
+ },
264
+ permissions={
265
+ "author_id": comment.get("author_id"),
266
+ "organization_id": ticket.get("organization_id"),
267
+ },
268
+ )
269
+ )
270
+
271
+ url = body.get("next_page")
272
+
273
+ return events
274
+
275
+ async def _fetch_articles(
276
+ self,
277
+ client: httpx.AsyncClient,
278
+ cutoff: datetime,
279
+ ) -> list[RawEvent]:
280
+ """Fetch Help Center articles updated since cutoff."""
281
+ events: list[RawEvent] = []
282
+ cutoff_str = cutoff.strftime("%Y-%m-%dT%H:%M:%SZ")
283
+ url: str | None = (
284
+ f"{self._base_url}/api/v2/help_center/articles.json"
285
+ f"?sort_by=updated_at&sort_order=desc&start_time={cutoff_str}"
286
+ )
287
+
288
+ while url:
289
+ try:
290
+ resp = await self._request(client, "GET", url)
291
+ except Exception:
292
+ logger.exception("Failed to fetch Zendesk articles")
293
+ break
294
+
295
+ body = resp.json()
296
+ for article in body.get("articles", []):
297
+ ts = self._parse_ts(article.get("updated_at"))
298
+ events.append(
299
+ RawEvent(
300
+ source="zendesk",
301
+ external_id=f"article:{article.get('id', '')}",
302
+ timestamp=ts,
303
+ data={
304
+ "event_kind": "article",
305
+ "article": article,
306
+ "is_public": not article.get("draft", False),
307
+ },
308
+ permissions={
309
+ "author_id": article.get("author_id"),
310
+ "section_id": article.get("section_id"),
311
+ },
312
+ )
313
+ )
314
+
315
+ url = body.get("next_page")
316
+
317
+ return events
318
+
319
+ # -- normalization helpers -----------------------------------------------
320
+
321
+ def _normalize_ticket(self, raw: RawEvent) -> Episode:
322
+ ticket = raw.data.get("ticket", {})
323
+ subject = ticket.get("subject", "Untitled ticket")
324
+ description = (ticket.get("description") or "")[:1000]
325
+ status = ticket.get("status", "")
326
+ priority = ticket.get("priority", "")
327
+
328
+ actor = Actor(
329
+ id=str(ticket.get("requester_id", "")),
330
+ name=str(ticket.get("requester_id", "Unknown")),
331
+ )
332
+
333
+ entities = self._extract_ticket_entities(ticket)
334
+ tags = ["zendesk", "ticket", status] + (ticket.get("tags") or [])
335
+
336
+ return Episode(
337
+ actor=actor,
338
+ source=_SOURCE,
339
+ episode_type=EpisodeType.ISSUE,
340
+ occurred_at=raw.timestamp,
341
+ content=f"Ticket [{status}] [{priority}]: {subject}\n{description}".strip(),
342
+ raw_payload=raw.data,
343
+ tenant_id=self.tenant_id,
344
+ namespace=self.namespace,
345
+ entities=entities,
346
+ tags=tags,
347
+ metadata={
348
+ "zendesk_ticket_id": ticket.get("id"),
349
+ "status": status,
350
+ "priority": priority,
351
+ "type": ticket.get("type", ""),
352
+ "group_id": ticket.get("group_id"),
353
+ "assignee_id": ticket.get("assignee_id"),
354
+ },
355
+ thread_id=f"zd:ticket:{ticket.get('id', '')}",
356
+ idempotency_key=f"zd:ticket:{ticket.get('id', raw.external_id)}",
357
+ )
358
+
359
+ def _normalize_comment(self, raw: RawEvent) -> Episode:
360
+ data = raw.data
361
+ comment = data.get("comment", {})
362
+ ticket_id = data.get("ticket_id", "")
363
+ ticket_subject = data.get("ticket_subject", "")
364
+ body = (comment.get("body") or comment.get("plain_body") or "")[:1000]
365
+
366
+ actor = Actor(
367
+ id=str(comment.get("author_id", "")),
368
+ name=str(comment.get("author_id", "Unknown")),
369
+ )
370
+
371
+ visibility_tag = "public" if comment.get("public", True) else "internal"
372
+
373
+ return Episode(
374
+ actor=actor,
375
+ source=_SOURCE,
376
+ episode_type=EpisodeType.COMMENT,
377
+ occurred_at=raw.timestamp,
378
+ content=f"Comment on [{ticket_subject}]: {body}",
379
+ raw_payload=raw.data,
380
+ tenant_id=self.tenant_id,
381
+ namespace=self.namespace,
382
+ entities=[
383
+ EntityRef(
384
+ entity_type="ticket",
385
+ entity_id=ticket_id,
386
+ display_name=ticket_subject,
387
+ ),
388
+ ],
389
+ tags=["zendesk", "comment", visibility_tag],
390
+ metadata={
391
+ "ticket_id": ticket_id,
392
+ "is_public": comment.get("public", True),
393
+ "has_attachments": bool(comment.get("attachments")),
394
+ },
395
+ thread_id=f"zd:ticket:{ticket_id}",
396
+ parent_id=f"zd:ticket:{ticket_id}",
397
+ idempotency_key=raw.external_id,
398
+ )
399
+
400
+ def _normalize_article(self, raw: RawEvent) -> Episode:
401
+ article = raw.data.get("article", {})
402
+ title = article.get("title", "Untitled article")
403
+ body = (article.get("body") or "")[:2000]
404
+
405
+ actor = Actor(
406
+ id=str(article.get("author_id", "")),
407
+ name=str(article.get("author_id", "Unknown")),
408
+ )
409
+
410
+ return Episode(
411
+ actor=actor,
412
+ source=_SOURCE,
413
+ episode_type=EpisodeType.DOCUMENT,
414
+ occurred_at=raw.timestamp,
415
+ content=f"Article: {title}\n{body}".strip(),
416
+ raw_payload=raw.data,
417
+ tenant_id=self.tenant_id,
418
+ namespace=self.namespace,
419
+ entities=[
420
+ EntityRef(
421
+ entity_type="article",
422
+ entity_id=str(article.get("id", "")),
423
+ display_name=title,
424
+ ),
425
+ ],
426
+ tags=["zendesk", "article"] + (article.get("label_names") or []),
427
+ metadata={
428
+ "section_id": article.get("section_id"),
429
+ "locale": article.get("locale", ""),
430
+ "draft": article.get("draft", False),
431
+ "vote_sum": article.get("vote_sum", 0),
432
+ },
433
+ thread_id=f"zd:article:{article.get('id', '')}",
434
+ idempotency_key=raw.external_id,
435
+ )
436
+
437
+ # -- entity extraction ---------------------------------------------------
438
+
439
+ @staticmethod
440
+ def _extract_ticket_entities(
441
+ ticket: dict[str, Any],
442
+ ) -> list[EntityRef]:
443
+ entities: list[EntityRef] = []
444
+
445
+ ticket_id = ticket.get("id")
446
+ if ticket_id:
447
+ entities.append(
448
+ EntityRef(
449
+ entity_type="ticket",
450
+ entity_id=str(ticket_id),
451
+ display_name=ticket.get("subject", str(ticket_id)),
452
+ )
453
+ )
454
+
455
+ org_id = ticket.get("organization_id")
456
+ if org_id:
457
+ entities.append(
458
+ EntityRef(
459
+ entity_type="organization",
460
+ entity_id=str(org_id),
461
+ display_name=str(org_id),
462
+ )
463
+ )
464
+
465
+ assignee_id = ticket.get("assignee_id")
466
+ if assignee_id:
467
+ entities.append(
468
+ EntityRef(
469
+ entity_type="user",
470
+ entity_id=str(assignee_id),
471
+ display_name=str(assignee_id),
472
+ )
473
+ )
474
+
475
+ group_id = ticket.get("group_id")
476
+ if group_id:
477
+ entities.append(
478
+ EntityRef(
479
+ entity_type="group",
480
+ entity_id=str(group_id),
481
+ display_name=str(group_id),
482
+ )
483
+ )
484
+
485
+ return entities
486
+
487
+ # -- utilities -----------------------------------------------------------
488
+
489
+ @staticmethod
490
+ def _parse_ts(value: str | None) -> datetime:
491
+ if not value:
492
+ return datetime.now(timezone.utc)
493
+ try:
494
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
495
+ except (ValueError, TypeError):
496
+ return datetime.now(timezone.utc)