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,331 @@
1
+ """
2
+ Slack connector for CortexDB.
3
+
4
+ Ingests messages (including threaded replies) from Slack channels into
5
+ CortexDB as Episodes. Uses the ``slack_sdk`` WebClient to interact with
6
+ the Slack API and respects Tier-3 rate limits (~50 requests/minute).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import logging
13
+ from datetime import datetime, timezone
14
+ from typing import Any
15
+
16
+ from cortexdb_connectors.base import (
17
+ Actor,
18
+ CortexConnector,
19
+ EntityRef,
20
+ Episode,
21
+ EpisodeType,
22
+ RawEvent,
23
+ Source,
24
+ Visibility,
25
+ VisibilityLevel,
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ _SOURCE = Source(system="slack")
31
+
32
+ # Slack Tier-3 rate limit: ~50 req/min -> ~1.2 s between requests.
33
+ _RATE_LIMIT_DELAY = 1.3
34
+
35
+
36
+ class SlackConnector(CortexConnector):
37
+ """Connector that syncs Slack channel messages into CortexDB.
38
+
39
+ Parameters
40
+ ----------
41
+ cortex_url:
42
+ Base URL of the CortexDB API.
43
+ cortex_api_key:
44
+ Bearer token for CortexDB authentication.
45
+ slack_bot_token:
46
+ Slack Bot User OAuth Token (``xoxb-...``).
47
+ channels:
48
+ Explicit list of channel IDs to sync. If empty, the connector
49
+ will auto-discover all channels the bot has been added to.
50
+ tenant_id:
51
+ CortexDB tenant identifier.
52
+ """
53
+
54
+ connector_name: str = "slack"
55
+
56
+ def __init__(
57
+ self,
58
+ cortex_url: str,
59
+ cortex_api_key: str,
60
+ slack_bot_token: str,
61
+ channels: list[str] | None = None,
62
+ tenant_id: str = "default",
63
+ ) -> None:
64
+ super().__init__(cortex_url, cortex_api_key, tenant_id)
65
+ self.slack_bot_token = slack_bot_token
66
+ self.channels = channels or []
67
+ self._user_cache: dict[str, dict[str, Any]] = {}
68
+
69
+ # -- CortexConnector interface -------------------------------------------
70
+
71
+ async def fetch_events(
72
+ self, since: datetime | None = None
73
+ ) -> list[RawEvent]:
74
+ """Fetch messages from configured Slack channels.
75
+
76
+ If *since* is provided, only messages newer than that timestamp
77
+ are returned. Pagination is handled automatically.
78
+ """
79
+ from slack_sdk import WebClient
80
+ from slack_sdk.errors import SlackApiError
81
+
82
+ client = WebClient(token=self.slack_bot_token)
83
+ channels = self.channels or self._discover_channels(client)
84
+ oldest = str(since.timestamp()) if since else "0"
85
+ events: list[RawEvent] = []
86
+
87
+ for channel_id in channels:
88
+ cursor: str | None = None
89
+ while True:
90
+ try:
91
+ kwargs: dict[str, Any] = {
92
+ "channel": channel_id,
93
+ "oldest": oldest,
94
+ "limit": 200,
95
+ }
96
+ if cursor:
97
+ kwargs["cursor"] = cursor
98
+
99
+ resp = client.conversations_history(**kwargs)
100
+ except SlackApiError as exc:
101
+ if exc.response.get("error") == "ratelimited":
102
+ retry_after = int(
103
+ exc.response.headers.get("Retry-After", 5)
104
+ )
105
+ logger.warning(
106
+ "Slack rate-limited; sleeping %d s", retry_after
107
+ )
108
+ await asyncio.sleep(retry_after)
109
+ continue
110
+ raise
111
+
112
+ channel_info = self._get_channel_info(client, channel_id)
113
+
114
+ for msg in resp.get("messages", []):
115
+ raw = RawEvent(
116
+ source="slack",
117
+ external_id=f"{channel_id}:{msg.get('ts', '')}",
118
+ timestamp=datetime.fromtimestamp(
119
+ float(msg.get("ts", "0")), tz=timezone.utc
120
+ ),
121
+ data={**msg, "_channel_id": channel_id},
122
+ permissions={
123
+ "is_private": channel_info.get("is_private", False),
124
+ "channel_members": channel_info.get("members", []),
125
+ },
126
+ )
127
+ events.append(raw)
128
+
129
+ # Fetch thread replies if this message has a thread.
130
+ if msg.get("reply_count", 0) > 0:
131
+ thread_events = self._fetch_thread_replies(
132
+ client, channel_id, msg["ts"], oldest, channel_info
133
+ )
134
+ events.extend(thread_events)
135
+
136
+ next_cursor = (
137
+ resp.get("response_metadata", {}).get("next_cursor")
138
+ )
139
+ if not next_cursor:
140
+ break
141
+ cursor = next_cursor
142
+ await asyncio.sleep(_RATE_LIMIT_DELAY)
143
+
144
+ await asyncio.sleep(_RATE_LIMIT_DELAY)
145
+
146
+ return events
147
+
148
+ def normalize(self, raw: RawEvent) -> Episode:
149
+ """Convert a Slack message ``RawEvent`` into a CortexDB ``Episode``."""
150
+ data = raw.data
151
+ user_id = data.get("user", "unknown")
152
+ actor = self._make_actor(user_id)
153
+ channel_id = data.get("_channel_id", "")
154
+
155
+ thread_ts = data.get("thread_ts")
156
+ parent_ts = None
157
+ if thread_ts and thread_ts != data.get("ts"):
158
+ parent_ts = f"{channel_id}:{thread_ts}"
159
+
160
+ return Episode(
161
+ actor=actor,
162
+ source=_SOURCE,
163
+ episode_type=EpisodeType.MESSAGE,
164
+ occurred_at=raw.timestamp,
165
+ content=data.get("text", ""),
166
+ raw_payload=data,
167
+ tenant_id=self.tenant_id,
168
+ namespace="slack",
169
+ entities=[
170
+ EntityRef(
171
+ entity_type="channel",
172
+ entity_id=channel_id,
173
+ display_name=data.get("_channel_name", channel_id),
174
+ ),
175
+ ],
176
+ tags=["slack"],
177
+ metadata={
178
+ "subtype": data.get("subtype", "message"),
179
+ "reactions": data.get("reactions", []),
180
+ },
181
+ thread_id=(
182
+ f"{channel_id}:{thread_ts}" if thread_ts else None
183
+ ),
184
+ parent_id=parent_ts,
185
+ idempotency_key=raw.external_id,
186
+ )
187
+
188
+ def map_permissions(self, raw: RawEvent) -> Visibility:
189
+ """Map Slack channel privacy to CortexDB visibility.
190
+
191
+ Public channels map to ``Organization``; private channels map to
192
+ ``Restricted`` with the channel member list as allowed principals.
193
+ """
194
+ perms = raw.permissions
195
+ if perms.get("is_private"):
196
+ members = tuple(str(m) for m in perms.get("channel_members", []))
197
+ return Visibility(
198
+ level=VisibilityLevel.RESTRICTED,
199
+ allowed_principals=members,
200
+ )
201
+ return Visibility(level=VisibilityLevel.ORGANIZATION)
202
+
203
+ # -- helpers -------------------------------------------------------------
204
+
205
+ def _discover_channels(self, client: Any) -> list[str]:
206
+ """Auto-discover channels the bot is a member of."""
207
+ channel_ids: list[str] = []
208
+ cursor: str | None = None
209
+ while True:
210
+ kwargs: dict[str, Any] = {
211
+ "types": "public_channel,private_channel",
212
+ "limit": 200,
213
+ }
214
+ if cursor:
215
+ kwargs["cursor"] = cursor
216
+ resp = client.conversations_list(**kwargs)
217
+ for ch in resp.get("channels", []):
218
+ if ch.get("is_member"):
219
+ channel_ids.append(ch["id"])
220
+ next_cursor = resp.get("response_metadata", {}).get("next_cursor")
221
+ if not next_cursor:
222
+ break
223
+ cursor = next_cursor
224
+ return channel_ids
225
+
226
+ def _get_channel_info(self, client: Any, channel_id: str) -> dict[str, Any]:
227
+ """Return cached channel metadata."""
228
+ try:
229
+ resp = client.conversations_info(channel=channel_id)
230
+ return resp.get("channel", {})
231
+ except Exception:
232
+ logger.debug("Could not fetch channel info for %s", channel_id)
233
+ return {}
234
+
235
+ def _fetch_thread_replies(
236
+ self,
237
+ client: Any,
238
+ channel_id: str,
239
+ thread_ts: str,
240
+ oldest: str,
241
+ channel_info: dict[str, Any],
242
+ ) -> list[RawEvent]:
243
+ """Fetch all replies within a Slack thread."""
244
+ from slack_sdk.errors import SlackApiError
245
+
246
+ events: list[RawEvent] = []
247
+ cursor: str | None = None
248
+ while True:
249
+ try:
250
+ kwargs: dict[str, Any] = {
251
+ "channel": channel_id,
252
+ "ts": thread_ts,
253
+ "oldest": oldest,
254
+ "limit": 200,
255
+ }
256
+ if cursor:
257
+ kwargs["cursor"] = cursor
258
+ resp = client.conversations_replies(**kwargs)
259
+ except SlackApiError as exc:
260
+ if exc.response.get("error") == "ratelimited":
261
+ retry_after = int(
262
+ exc.response.headers.get("Retry-After", 5)
263
+ )
264
+ logger.warning(
265
+ "Slack rate-limited (thread); sleeping %d s",
266
+ retry_after,
267
+ )
268
+ import time
269
+ time.sleep(retry_after)
270
+ continue
271
+ raise
272
+
273
+ for msg in resp.get("messages", []):
274
+ # Skip the parent message itself (already captured).
275
+ if msg.get("ts") == thread_ts and not msg.get("parent_user_id"):
276
+ continue
277
+ raw = RawEvent(
278
+ source="slack",
279
+ external_id=f"{channel_id}:{msg.get('ts', '')}",
280
+ timestamp=datetime.fromtimestamp(
281
+ float(msg.get("ts", "0")), tz=timezone.utc
282
+ ),
283
+ data={**msg, "_channel_id": channel_id},
284
+ permissions={
285
+ "is_private": channel_info.get("is_private", False),
286
+ "channel_members": channel_info.get("members", []),
287
+ },
288
+ )
289
+ events.append(raw)
290
+
291
+ next_cursor = resp.get("response_metadata", {}).get("next_cursor")
292
+ if not next_cursor:
293
+ break
294
+ cursor = next_cursor
295
+ return events
296
+
297
+ def _make_actor(self, user_id: str) -> Actor:
298
+ """Build an Actor from a Slack user ID (uses in-memory cache)."""
299
+ if user_id in self._user_cache:
300
+ info = self._user_cache[user_id]
301
+ else:
302
+ info = {"id": user_id, "name": user_id}
303
+ self._user_cache[user_id] = info
304
+ return Actor(
305
+ id=info.get("id", user_id),
306
+ name=info.get("real_name", info.get("name", user_id)),
307
+ email=info.get("profile", {}).get("email"),
308
+ avatar_url=info.get("profile", {}).get("image_72"),
309
+ )
310
+
311
+ async def populate_user_cache(self) -> None:
312
+ """Pre-populate the user cache by calling ``users.list``.
313
+
314
+ Call this before ``sync`` to enrich actor metadata on episodes.
315
+ """
316
+ from slack_sdk import WebClient
317
+
318
+ client = WebClient(token=self.slack_bot_token)
319
+ cursor: str | None = None
320
+ while True:
321
+ kwargs: dict[str, Any] = {"limit": 200}
322
+ if cursor:
323
+ kwargs["cursor"] = cursor
324
+ resp = client.users_list(**kwargs)
325
+ for member in resp.get("members", []):
326
+ self._user_cache[member["id"]] = member
327
+ next_cursor = resp.get("response_metadata", {}).get("next_cursor")
328
+ if not next_cursor:
329
+ break
330
+ cursor = next_cursor
331
+ await asyncio.sleep(_RATE_LIMIT_DELAY)
@@ -0,0 +1,145 @@
1
+ """
2
+ Sync state persistence for CortexDB connectors.
3
+
4
+ Stores per-connector, per-tenant cursor positions in a local JSON file so
5
+ that incremental syncs can resume from where they left off.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from cortexdb_connectors.base import SyncState
17
+
18
+
19
+ _DEFAULT_STATE_PATH = Path.home() / ".cortexdb" / "sync_state.json"
20
+
21
+
22
+ class SyncStateStore:
23
+ """Persists connector sync cursors to a JSON file on disk.
24
+
25
+ The file layout is::
26
+
27
+ {
28
+ "<connector_name>::<tenant_id>": {
29
+ "connector_name": "...",
30
+ "tenant_id": "...",
31
+ "last_synced_at": "ISO-8601 | null",
32
+ "cursor": "...",
33
+ "extra": {}
34
+ }
35
+ }
36
+ """
37
+
38
+ def __init__(self, path: str | Path | None = None) -> None:
39
+ self.path = Path(path) if path else _DEFAULT_STATE_PATH
40
+ self._ensure_dir()
41
+
42
+ # -- public API ----------------------------------------------------------
43
+
44
+ def get(self, connector_name: str, tenant_id: str = "default") -> SyncState:
45
+ """Load the sync state for *connector_name* and *tenant_id*.
46
+
47
+ Returns a fresh ``SyncState`` with ``last_synced_at=None`` if no
48
+ previous state exists.
49
+ """
50
+ data = self._load()
51
+ key = self._key(connector_name, tenant_id)
52
+ entry = data.get(key)
53
+
54
+ if entry is None:
55
+ return SyncState(connector_name=connector_name, tenant_id=tenant_id)
56
+
57
+ last_synced: datetime | None = None
58
+ if entry.get("last_synced_at"):
59
+ last_synced = datetime.fromisoformat(entry["last_synced_at"])
60
+
61
+ return SyncState(
62
+ connector_name=connector_name,
63
+ tenant_id=tenant_id,
64
+ last_synced_at=last_synced,
65
+ cursor=entry.get("cursor"),
66
+ extra=entry.get("extra", {}),
67
+ )
68
+
69
+ def save(self, state: SyncState) -> None:
70
+ """Persist the given ``SyncState`` to disk."""
71
+ data = self._load()
72
+ key = self._key(state.connector_name, state.tenant_id)
73
+ data[key] = {
74
+ "connector_name": state.connector_name,
75
+ "tenant_id": state.tenant_id,
76
+ "last_synced_at": (
77
+ state.last_synced_at.isoformat() if state.last_synced_at else None
78
+ ),
79
+ "cursor": state.cursor,
80
+ "extra": state.extra,
81
+ }
82
+ self._write(data)
83
+
84
+ def update_last_synced(
85
+ self,
86
+ connector_name: str,
87
+ tenant_id: str = "default",
88
+ timestamp: datetime | None = None,
89
+ ) -> SyncState:
90
+ """Convenience: set ``last_synced_at`` to *timestamp* (default: now UTC)."""
91
+ state = self.get(connector_name, tenant_id)
92
+ state.last_synced_at = timestamp or datetime.now(timezone.utc)
93
+ self.save(state)
94
+ return state
95
+
96
+ def list_all(self) -> list[SyncState]:
97
+ """Return every stored ``SyncState`` entry."""
98
+ data = self._load()
99
+ states: list[SyncState] = []
100
+ for entry in data.values():
101
+ last_synced: datetime | None = None
102
+ if entry.get("last_synced_at"):
103
+ last_synced = datetime.fromisoformat(entry["last_synced_at"])
104
+ states.append(
105
+ SyncState(
106
+ connector_name=entry["connector_name"],
107
+ tenant_id=entry["tenant_id"],
108
+ last_synced_at=last_synced,
109
+ cursor=entry.get("cursor"),
110
+ extra=entry.get("extra", {}),
111
+ )
112
+ )
113
+ return states
114
+
115
+ def clear(self, connector_name: str, tenant_id: str = "default") -> None:
116
+ """Remove the stored state for a specific connector/tenant pair."""
117
+ data = self._load()
118
+ key = self._key(connector_name, tenant_id)
119
+ data.pop(key, None)
120
+ self._write(data)
121
+
122
+ # -- internals -----------------------------------------------------------
123
+
124
+ @staticmethod
125
+ def _key(connector_name: str, tenant_id: str) -> str:
126
+ return f"{connector_name}::{tenant_id}"
127
+
128
+ def _ensure_dir(self) -> None:
129
+ self.path.parent.mkdir(parents=True, exist_ok=True)
130
+
131
+ def _load(self) -> dict[str, Any]:
132
+ if not self.path.exists():
133
+ return {}
134
+ with open(self.path, "r", encoding="utf-8") as fh:
135
+ return json.load(fh)
136
+
137
+ def _write(self, data: dict[str, Any]) -> None:
138
+ tmp = self.path.with_suffix(".tmp")
139
+ with open(tmp, "w", encoding="utf-8") as fh:
140
+ json.dump(data, fh, indent=2, default=str)
141
+ # Atomic-ish rename (works on POSIX; on Windows replaces if exists in
142
+ # Python 3.12+, but we guard with a remove for older versions).
143
+ if os.name == "nt" and tmp.exists() and self.path.exists():
144
+ self.path.unlink()
145
+ tmp.rename(self.path)