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