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