agentgraph-connector-slack 0.5.0__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.
- agentgraph_connector_slack-0.5.0/PKG-INFO +7 -0
- agentgraph_connector_slack-0.5.0/agentgraph_connector_slack/__init__.py +608 -0
- agentgraph_connector_slack-0.5.0/agentgraph_connector_slack/auth.py +673 -0
- agentgraph_connector_slack-0.5.0/agentgraph_connector_slack/slack-app-manifest.json +37 -0
- agentgraph_connector_slack-0.5.0/agentgraph_connector_slack.egg-info/PKG-INFO +7 -0
- agentgraph_connector_slack-0.5.0/agentgraph_connector_slack.egg-info/SOURCES.txt +10 -0
- agentgraph_connector_slack-0.5.0/agentgraph_connector_slack.egg-info/dependency_links.txt +1 -0
- agentgraph_connector_slack-0.5.0/agentgraph_connector_slack.egg-info/entry_points.txt +2 -0
- agentgraph_connector_slack-0.5.0/agentgraph_connector_slack.egg-info/requires.txt +2 -0
- agentgraph_connector_slack-0.5.0/agentgraph_connector_slack.egg-info/top_level.txt +1 -0
- agentgraph_connector_slack-0.5.0/pyproject.toml +21 -0
- agentgraph_connector_slack-0.5.0/setup.cfg +4 -0
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
"""Slack connector with OAuth and browser-session authentication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from datetime import UTC, datetime, timedelta
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from agentgraph.connectors.base import (
|
|
13
|
+
BaseConnector,
|
|
14
|
+
ConnectorAccount,
|
|
15
|
+
EdgeRecord,
|
|
16
|
+
EntityBatch,
|
|
17
|
+
EntityRecord,
|
|
18
|
+
FetchPolicy,
|
|
19
|
+
PersonRecord,
|
|
20
|
+
ResourceType,
|
|
21
|
+
SourceReference,
|
|
22
|
+
get_known_channel_syncs,
|
|
23
|
+
)
|
|
24
|
+
from agentgraph.graph.upsert import upsert_batch
|
|
25
|
+
from agentgraph_connector_slack.auth import (
|
|
26
|
+
SlackBrowserCredentials,
|
|
27
|
+
account_id_for_team,
|
|
28
|
+
list_slack_accounts,
|
|
29
|
+
load_slack_creds,
|
|
30
|
+
slack_headers,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
SLACK_API = "https://slack.com/api"
|
|
36
|
+
_STALE_AFTER = 5 * 60 # 5 minutes
|
|
37
|
+
_PADDING_MESSAGES = 20 # messages to fetch on first visit as immediate context
|
|
38
|
+
_SLACK_CHANNEL_URL_RE = re.compile(
|
|
39
|
+
r"https://app\.slack\.com/client/(?P<workspace_id>[A-Z0-9]+)/(?P<channel_id>[A-Z0-9]+)"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _team_id_from_token(account_id: str | None = None) -> str | None:
|
|
44
|
+
"""Extract the Slack team ID from the stored credentials."""
|
|
45
|
+
try:
|
|
46
|
+
creds = load_slack_creds(account_id)
|
|
47
|
+
except RuntimeError:
|
|
48
|
+
return None
|
|
49
|
+
if creds.team_id:
|
|
50
|
+
return creds.team_id
|
|
51
|
+
if not isinstance(creds, SlackBrowserCredentials):
|
|
52
|
+
return None
|
|
53
|
+
parts = creds.xoxc_token.split("-")
|
|
54
|
+
return parts[1] if len(parts) >= 2 else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _channel_ref(team_id: str, channel_id: str) -> str:
|
|
58
|
+
return f"{team_id}/{channel_id}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _message_ref(team_id: str, channel_id: str, ts: str) -> str:
|
|
62
|
+
return f"{team_id}/{channel_id}:{ts}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _user_ref(team_id: str, user_id: str) -> str:
|
|
66
|
+
return f"{team_id}/{user_id}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _split_channel_ref(resource_id: str) -> tuple[str, str]:
|
|
70
|
+
team_id, _, channel_id = resource_id.partition("/")
|
|
71
|
+
if not team_id or not channel_id:
|
|
72
|
+
raise ValueError(f"Slack resource must be workspace-qualified: {resource_id}")
|
|
73
|
+
return team_id, channel_id
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _normalise_channel_ref(resource_id: str, account_id: str | None = None) -> str:
|
|
77
|
+
if "/" in resource_id:
|
|
78
|
+
return resource_id
|
|
79
|
+
team_id = _team_id_from_token(account_id)
|
|
80
|
+
if not team_id:
|
|
81
|
+
raise ValueError(f"Slack resource must be workspace-qualified: {resource_id}")
|
|
82
|
+
return _channel_ref(team_id, resource_id)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def _api_get(
|
|
86
|
+
client: httpx.AsyncClient,
|
|
87
|
+
method: str,
|
|
88
|
+
account_id: str | None = None,
|
|
89
|
+
**params: Any,
|
|
90
|
+
) -> dict[str, Any]:
|
|
91
|
+
for attempt in range(2):
|
|
92
|
+
resp = await client.get(
|
|
93
|
+
f"{SLACK_API}/{method}",
|
|
94
|
+
headers=await slack_headers(
|
|
95
|
+
account_id,
|
|
96
|
+
force_refresh=attempt == 1,
|
|
97
|
+
client=client,
|
|
98
|
+
),
|
|
99
|
+
params=params,
|
|
100
|
+
)
|
|
101
|
+
resp.raise_for_status()
|
|
102
|
+
data: dict[str, Any] = resp.json()
|
|
103
|
+
if data.get("ok"):
|
|
104
|
+
return data
|
|
105
|
+
if data.get("error") != "token_expired" or attempt == 1:
|
|
106
|
+
raise RuntimeError(f"Slack API error on {method}: {data.get('error', 'unknown')}")
|
|
107
|
+
raise RuntimeError(f"Slack API error on {method}: token_expired")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def _fetch_channel_info(client: httpx.AsyncClient, channel_id: str, account_id: str | None = None) -> dict[str, Any]:
|
|
111
|
+
data = await _api_get(client, "conversations.info", account_id=account_id, channel=channel_id)
|
|
112
|
+
return data.get("channel", {}) # type: ignore[return-value]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def _fetch_user(client: httpx.AsyncClient, user_id: str, account_id: str | None = None) -> dict[str, Any]:
|
|
116
|
+
data = await _api_get(client, "users.info", account_id=account_id, user=user_id)
|
|
117
|
+
return data.get("user", {}) # type: ignore[return-value]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _ts_to_dt(ts: str) -> datetime:
|
|
121
|
+
return datetime.fromtimestamp(float(ts), tz=UTC)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _parse_mentions(text: str) -> list[str]:
|
|
125
|
+
"""Extract <@UXXXXXXX> user IDs from message text."""
|
|
126
|
+
import re
|
|
127
|
+
return re.findall(r"<@([A-Z0-9]+)>", text)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parse_channel_mentions(text: str) -> list[str]:
|
|
131
|
+
"""Extract <#CXXXXXXX> channel IDs from message text."""
|
|
132
|
+
import re
|
|
133
|
+
return re.findall(r"<#([A-Z0-9]+)(?:\|[^>]*)?>", text)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class SlackConnector(BaseConnector):
|
|
137
|
+
source = "slack"
|
|
138
|
+
fetch_policy = FetchPolicy(stale_after_seconds=_STALE_AFTER)
|
|
139
|
+
poll_interval: timedelta | None = timedelta(minutes=5) # type: ignore[assignment]
|
|
140
|
+
url_patterns = ["https://app.slack.com/*"]
|
|
141
|
+
auth_label = "slack"
|
|
142
|
+
auth_description = "Slack workspace channels and DMs: Channel and Message entities with thread replies, authors, and user/channel mentions."
|
|
143
|
+
onboard_prompt = "Set up Slack?"
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def run_auth_flow(
|
|
147
|
+
cls,
|
|
148
|
+
account_id: str | None = None,
|
|
149
|
+
add: bool = False,
|
|
150
|
+
args: list[str] | None = None,
|
|
151
|
+
) -> None:
|
|
152
|
+
from agentgraph_connector_slack.auth import run_interactive_auth_flow
|
|
153
|
+
|
|
154
|
+
if args:
|
|
155
|
+
cls.run_auth_flow_with_args(args, account_id=account_id, add=add)
|
|
156
|
+
return
|
|
157
|
+
run_interactive_auth_flow(account_id=account_id, add=add)
|
|
158
|
+
|
|
159
|
+
@classmethod
|
|
160
|
+
def run_auth_flow_with_args(
|
|
161
|
+
cls,
|
|
162
|
+
args: list[str],
|
|
163
|
+
account_id: str | None = None,
|
|
164
|
+
add: bool = False,
|
|
165
|
+
) -> None:
|
|
166
|
+
from agentgraph_connector_slack.auth import (
|
|
167
|
+
run_cookie_flow,
|
|
168
|
+
run_guided_oauth_flow,
|
|
169
|
+
run_interactive_auth_flow,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
method: str | None = None
|
|
173
|
+
client_id: str | None = None
|
|
174
|
+
xoxc_token: str | None = None
|
|
175
|
+
d_cookie: str | None = None
|
|
176
|
+
index = 0
|
|
177
|
+
while index < len(args):
|
|
178
|
+
arg = args[index]
|
|
179
|
+
if arg in {"--method", "--client-id", "--xoxc-token", "--d-cookie"}:
|
|
180
|
+
if index + 1 >= len(args):
|
|
181
|
+
raise ValueError(f"{arg} requires a value")
|
|
182
|
+
value = args[index + 1]
|
|
183
|
+
index += 1
|
|
184
|
+
elif any(arg.startswith(f"{option}=") for option in (
|
|
185
|
+
"--method", "--client-id", "--xoxc-token", "--d-cookie"
|
|
186
|
+
)):
|
|
187
|
+
option, value = arg.split("=", 1)
|
|
188
|
+
arg = option
|
|
189
|
+
else:
|
|
190
|
+
raise ValueError(f"Unknown Slack authentication option: {arg}")
|
|
191
|
+
if arg == "--method":
|
|
192
|
+
method = value
|
|
193
|
+
elif arg == "--client-id":
|
|
194
|
+
client_id = value.strip()
|
|
195
|
+
if not client_id:
|
|
196
|
+
raise ValueError("--client-id requires a non-empty value")
|
|
197
|
+
elif arg == "--xoxc-token":
|
|
198
|
+
xoxc_token = value
|
|
199
|
+
else:
|
|
200
|
+
d_cookie = value
|
|
201
|
+
index += 1
|
|
202
|
+
|
|
203
|
+
if method not in {None, "oauth", "browser"}:
|
|
204
|
+
raise ValueError("Slack auth method must be 'oauth' or 'browser'")
|
|
205
|
+
browser_options = xoxc_token is not None or d_cookie is not None
|
|
206
|
+
if method == "oauth" and browser_options:
|
|
207
|
+
raise ValueError("--xoxc-token and --d-cookie cannot be used with --method oauth")
|
|
208
|
+
if method == "browser" and client_id is not None:
|
|
209
|
+
raise ValueError("--client-id cannot be used with --method browser")
|
|
210
|
+
selected_method = method or (
|
|
211
|
+
"browser" if browser_options else "oauth" if client_id is not None else None
|
|
212
|
+
)
|
|
213
|
+
if selected_method is None:
|
|
214
|
+
run_interactive_auth_flow(account_id=account_id, add=add)
|
|
215
|
+
return
|
|
216
|
+
if selected_method == "browser":
|
|
217
|
+
run_cookie_flow(
|
|
218
|
+
account_id=account_id,
|
|
219
|
+
add=add,
|
|
220
|
+
xoxc_token=xoxc_token,
|
|
221
|
+
d_cookie=d_cookie,
|
|
222
|
+
)
|
|
223
|
+
return
|
|
224
|
+
run_guided_oauth_flow(account_id=account_id, add=add, client_id=client_id)
|
|
225
|
+
|
|
226
|
+
@classmethod
|
|
227
|
+
def get_authenticated_user(cls) -> str | None:
|
|
228
|
+
try:
|
|
229
|
+
creds = load_slack_creds()
|
|
230
|
+
if creds.team_name and creds.user_id:
|
|
231
|
+
return f"{creds.team_name} / {creds.user_id}"
|
|
232
|
+
return creds.user_id
|
|
233
|
+
except Exception:
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
@classmethod
|
|
237
|
+
def list_accounts(cls) -> list[ConnectorAccount]:
|
|
238
|
+
return [
|
|
239
|
+
ConnectorAccount(
|
|
240
|
+
account_id=str(account["account_id"]),
|
|
241
|
+
label=str(account["label"]),
|
|
242
|
+
auth_group=cls.auth_label or cls.source,
|
|
243
|
+
source=cls.source,
|
|
244
|
+
user_id=account.get("user_id"),
|
|
245
|
+
workspace_id=account.get("team_id"),
|
|
246
|
+
email=account.get("email"),
|
|
247
|
+
auth_method=account.get("auth_method"),
|
|
248
|
+
)
|
|
249
|
+
for account in list_slack_accounts()
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
@classmethod
|
|
253
|
+
async def verify_auth(cls, account_id: str | None = None) -> tuple[str, str | None]:
|
|
254
|
+
try:
|
|
255
|
+
credentials = load_slack_creds(account_id)
|
|
256
|
+
except RuntimeError:
|
|
257
|
+
return ("missing", None)
|
|
258
|
+
except Exception as exc:
|
|
259
|
+
return ("invalid", str(exc))
|
|
260
|
+
try:
|
|
261
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
262
|
+
data = await _api_get(client, "auth.test", account_id=account_id)
|
|
263
|
+
except Exception as exc:
|
|
264
|
+
return ("invalid", str(exc))
|
|
265
|
+
team_name = data.get("team") or credentials.team_name or credentials.team_id
|
|
266
|
+
user_id = data.get("user_id") or credentials.user_id
|
|
267
|
+
detail = f"{team_name} / {user_id}" if team_name and user_id else user_id or team_name
|
|
268
|
+
return ("ok", str(detail) if detail else "authenticated")
|
|
269
|
+
|
|
270
|
+
@classmethod
|
|
271
|
+
def current_user_ids(cls) -> list[str]:
|
|
272
|
+
ids: list[str] = []
|
|
273
|
+
for account in list_slack_accounts():
|
|
274
|
+
team_id = account.get("team_id")
|
|
275
|
+
user_id = account.get("user_id")
|
|
276
|
+
if team_id and user_id:
|
|
277
|
+
ids.append(f"slack:{team_id}/{user_id}")
|
|
278
|
+
return ids
|
|
279
|
+
|
|
280
|
+
def can_handle(self, url: str) -> bool:
|
|
281
|
+
return self.resolve_url(url) is not None
|
|
282
|
+
|
|
283
|
+
def resolve_url(self, url: str) -> SourceReference | None:
|
|
284
|
+
match = _SLACK_CHANNEL_URL_RE.match(url)
|
|
285
|
+
if match is None:
|
|
286
|
+
return None
|
|
287
|
+
return SourceReference(
|
|
288
|
+
source=self.source,
|
|
289
|
+
resource_type="channel",
|
|
290
|
+
resource_id=f"{match.group('workspace_id')}/{match.group('channel_id')}",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
async def fetch(
|
|
294
|
+
self,
|
|
295
|
+
resource_type: ResourceType,
|
|
296
|
+
resource_id: str,
|
|
297
|
+
meta: dict[str, str] | None = None,
|
|
298
|
+
account_id: str | None = None,
|
|
299
|
+
) -> EntityBatch:
|
|
300
|
+
last_sync = await self.last_synced_at(resource_id)
|
|
301
|
+
decision = self.fetch_policy.decide(last_sync)
|
|
302
|
+
|
|
303
|
+
if decision == FetchPolicy.FRESH:
|
|
304
|
+
logger.debug("slack/%s is fresh — updating last_accessed only", resource_id)
|
|
305
|
+
await _touch_last_accessed(resource_id)
|
|
306
|
+
return EntityBatch()
|
|
307
|
+
|
|
308
|
+
oldest: str | None = None
|
|
309
|
+
if decision == FetchPolicy.INCREMENTAL and last_sync:
|
|
310
|
+
oldest = str(last_sync.timestamp())
|
|
311
|
+
|
|
312
|
+
resource_id = _normalise_channel_ref(resource_id, account_id)
|
|
313
|
+
team_id, _ = _split_channel_ref(resource_id)
|
|
314
|
+
selected_account_id = account_id or account_id_for_team(team_id)
|
|
315
|
+
logger.info("Fetching Slack channel %s (policy=%s)", resource_id, decision)
|
|
316
|
+
batch = await _fetch_channel(resource_id, oldest=oldest, account_id=selected_account_id)
|
|
317
|
+
await upsert_batch(batch)
|
|
318
|
+
return batch
|
|
319
|
+
|
|
320
|
+
async def poll(
|
|
321
|
+
self,
|
|
322
|
+
cursor: dict[str, Any],
|
|
323
|
+
account_id: str | None = None,
|
|
324
|
+
) -> tuple[EntityBatch, dict[str, Any]]:
|
|
325
|
+
channel_rows = await get_known_channel_syncs("slack", account_id=account_id)
|
|
326
|
+
combined = EntityBatch()
|
|
327
|
+
for channel_id, synced_at in channel_rows:
|
|
328
|
+
oldest = str(synced_at.timestamp()) if synced_at else None
|
|
329
|
+
try:
|
|
330
|
+
batch = await _fetch_channel(channel_id, oldest=oldest, account_id=account_id)
|
|
331
|
+
combined.entities.extend(batch.entities)
|
|
332
|
+
combined.persons.extend(batch.persons)
|
|
333
|
+
combined.edges.extend(batch.edges)
|
|
334
|
+
except Exception:
|
|
335
|
+
logger.exception("slack poll: failed to fetch channel %s", channel_id)
|
|
336
|
+
return combined, cursor
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
async def _touch_last_accessed(channel_id: str) -> None:
|
|
340
|
+
from agentgraph.core.context import get_backend
|
|
341
|
+
|
|
342
|
+
backend = get_backend()
|
|
343
|
+
entity = await backend.get_entity_by_platform("slack", channel_id)
|
|
344
|
+
if entity:
|
|
345
|
+
# Re-upsert the same entity to bump last_accessed via the normal path.
|
|
346
|
+
# The cheapest way is just a stub upsert which only updates last_accessed.
|
|
347
|
+
await backend.upsert_stub_entity("Channel", "slack", channel_id)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
async def _fetch_thread_replies(
|
|
351
|
+
client: httpx.AsyncClient,
|
|
352
|
+
channel_ref: str,
|
|
353
|
+
thread_ts: str,
|
|
354
|
+
entities: list[EntityRecord],
|
|
355
|
+
persons: list[PersonRecord],
|
|
356
|
+
edges: list[EdgeRecord],
|
|
357
|
+
seen_users: set[str],
|
|
358
|
+
team_id: str | None = None,
|
|
359
|
+
account_id: str | None = None,
|
|
360
|
+
) -> None:
|
|
361
|
+
"""Fetch replies to a threaded message and add them to the batch in-place."""
|
|
362
|
+
if team_id is None:
|
|
363
|
+
return
|
|
364
|
+
_, channel_id = _split_channel_ref(channel_ref)
|
|
365
|
+
try:
|
|
366
|
+
data = await _api_get(
|
|
367
|
+
client, "conversations.replies", account_id=account_id, channel=channel_id, ts=thread_ts
|
|
368
|
+
)
|
|
369
|
+
except Exception as exc:
|
|
370
|
+
logger.warning("Could not fetch replies for %s:%s: %s", channel_id, thread_ts, exc)
|
|
371
|
+
return
|
|
372
|
+
|
|
373
|
+
reply_messages: list[dict[str, Any]] = data.get("messages", [])
|
|
374
|
+
|
|
375
|
+
for reply in reply_messages:
|
|
376
|
+
ts: str = reply.get("ts", "")
|
|
377
|
+
if not ts or ts == thread_ts:
|
|
378
|
+
continue # skip the parent message Slack echoes back
|
|
379
|
+
|
|
380
|
+
user_id: str = reply.get("user", "")
|
|
381
|
+
text: str = reply.get("text", "")
|
|
382
|
+
|
|
383
|
+
reply_meta: dict[str, Any] = {"team_id": team_id, "channel_id": channel_id, "ts": ts, "thread_ts": thread_ts}
|
|
384
|
+
if team_id:
|
|
385
|
+
ts_compact = ts.replace(".", "")
|
|
386
|
+
reply_meta["web_url"] = f"https://app.slack.com/client/{team_id}/{channel_id}/p{ts_compact}"
|
|
387
|
+
if account_id:
|
|
388
|
+
reply_meta["account_id"] = account_id
|
|
389
|
+
entities.append(EntityRecord(
|
|
390
|
+
entity_type="Message",
|
|
391
|
+
platform="slack",
|
|
392
|
+
platform_entity_id=_message_ref(team_id, channel_id, ts),
|
|
393
|
+
content=text,
|
|
394
|
+
created_at=_ts_to_dt(ts),
|
|
395
|
+
updated_at=_ts_to_dt(ts),
|
|
396
|
+
metadata=reply_meta,
|
|
397
|
+
))
|
|
398
|
+
|
|
399
|
+
edges.append(EdgeRecord(
|
|
400
|
+
edge_type="replied_to",
|
|
401
|
+
source_platform_entity_id=_message_ref(team_id, channel_id, ts),
|
|
402
|
+
target_platform_entity_id=_message_ref(team_id, channel_id, thread_ts),
|
|
403
|
+
platform="slack",
|
|
404
|
+
))
|
|
405
|
+
edges.append(EdgeRecord(
|
|
406
|
+
edge_type="posted_in",
|
|
407
|
+
source_platform_entity_id=_message_ref(team_id, channel_id, ts),
|
|
408
|
+
target_platform_entity_id=channel_ref,
|
|
409
|
+
platform="slack",
|
|
410
|
+
))
|
|
411
|
+
|
|
412
|
+
if user_id and user_id not in seen_users:
|
|
413
|
+
seen_users.add(user_id)
|
|
414
|
+
try:
|
|
415
|
+
user_info = await _fetch_user(client, user_id, account_id=account_id)
|
|
416
|
+
profile = user_info.get("profile", {})
|
|
417
|
+
persons.append(PersonRecord(
|
|
418
|
+
platform="slack",
|
|
419
|
+
platform_user_id=_user_ref(team_id, user_id),
|
|
420
|
+
platform_username=user_info.get("name"),
|
|
421
|
+
canonical_email=profile.get("email") or None,
|
|
422
|
+
display_name=profile.get("real_name") or None,
|
|
423
|
+
))
|
|
424
|
+
except Exception:
|
|
425
|
+
logger.debug("Could not fetch user %s", user_id)
|
|
426
|
+
|
|
427
|
+
if user_id:
|
|
428
|
+
edges.append(EdgeRecord(
|
|
429
|
+
edge_type="authored",
|
|
430
|
+
source_platform_user_id=_user_ref(team_id, user_id),
|
|
431
|
+
target_platform_entity_id=_message_ref(team_id, channel_id, ts),
|
|
432
|
+
platform="slack",
|
|
433
|
+
))
|
|
434
|
+
|
|
435
|
+
for mentioned_id in _parse_mentions(text):
|
|
436
|
+
if mentioned_id not in seen_users:
|
|
437
|
+
seen_users.add(mentioned_id)
|
|
438
|
+
try:
|
|
439
|
+
user_info = await _fetch_user(client, mentioned_id, account_id=account_id)
|
|
440
|
+
profile = user_info.get("profile", {})
|
|
441
|
+
persons.append(PersonRecord(
|
|
442
|
+
platform="slack",
|
|
443
|
+
platform_user_id=_user_ref(team_id, mentioned_id),
|
|
444
|
+
platform_username=user_info.get("name"),
|
|
445
|
+
canonical_email=profile.get("email") or None,
|
|
446
|
+
display_name=profile.get("real_name") or None,
|
|
447
|
+
))
|
|
448
|
+
except Exception:
|
|
449
|
+
logger.debug("Could not fetch user %s", mentioned_id)
|
|
450
|
+
edges.append(EdgeRecord(
|
|
451
|
+
edge_type="mentions",
|
|
452
|
+
source_platform_entity_id=_message_ref(team_id, channel_id, ts),
|
|
453
|
+
target_platform_user_id=_user_ref(team_id, mentioned_id),
|
|
454
|
+
platform="slack",
|
|
455
|
+
))
|
|
456
|
+
|
|
457
|
+
for mentioned_channel_id in _parse_channel_mentions(text):
|
|
458
|
+
edges.append(EdgeRecord(
|
|
459
|
+
edge_type="mentions",
|
|
460
|
+
source_platform_entity_id=_message_ref(team_id, channel_id, ts),
|
|
461
|
+
target_platform_entity_id=_channel_ref(team_id, mentioned_channel_id),
|
|
462
|
+
platform="slack",
|
|
463
|
+
))
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
async def _fetch_channel(channel_ref: str, oldest: str | None = None, account_id: str | None = None) -> EntityBatch:
|
|
467
|
+
entities: list[EntityRecord] = []
|
|
468
|
+
persons: list[PersonRecord] = []
|
|
469
|
+
edges: list[EdgeRecord] = []
|
|
470
|
+
seen_users: set[str] = set()
|
|
471
|
+
|
|
472
|
+
team_id, channel_id = _split_channel_ref(channel_ref)
|
|
473
|
+
|
|
474
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
475
|
+
# Channel entity
|
|
476
|
+
channel_info = await _fetch_channel_info(client, channel_id, account_id=account_id)
|
|
477
|
+
channel_name = channel_info.get("name", channel_id)
|
|
478
|
+
|
|
479
|
+
channel_meta: dict[str, Any] = {"team_id": team_id, "channel_id": channel_id}
|
|
480
|
+
if team_id:
|
|
481
|
+
channel_meta["web_url"] = f"https://app.slack.com/client/{team_id}/{channel_id}"
|
|
482
|
+
if account_id:
|
|
483
|
+
channel_meta["account_id"] = account_id
|
|
484
|
+
channel_entity = EntityRecord(
|
|
485
|
+
entity_type="Channel",
|
|
486
|
+
platform="slack",
|
|
487
|
+
platform_entity_id=channel_ref,
|
|
488
|
+
title=f"#{channel_name}",
|
|
489
|
+
updated_at=datetime.now(UTC),
|
|
490
|
+
metadata=channel_meta,
|
|
491
|
+
)
|
|
492
|
+
entities.append(channel_entity)
|
|
493
|
+
|
|
494
|
+
# Fetch messages
|
|
495
|
+
params: dict[str, Any] = {"channel": channel_id, "limit": 100}
|
|
496
|
+
if oldest:
|
|
497
|
+
params["oldest"] = oldest
|
|
498
|
+
|
|
499
|
+
data = await _api_get(client, "conversations.history", account_id=account_id, **params)
|
|
500
|
+
messages: list[dict[str, Any]] = data.get("messages", [])
|
|
501
|
+
|
|
502
|
+
for msg in messages:
|
|
503
|
+
user_id: str = msg.get("user", "")
|
|
504
|
+
ts: str = msg.get("ts", "")
|
|
505
|
+
text: str = msg.get("text", "")
|
|
506
|
+
thread_ts: str | None = msg.get("thread_ts")
|
|
507
|
+
reply_count: int = msg.get("reply_count", 0)
|
|
508
|
+
|
|
509
|
+
if not ts:
|
|
510
|
+
continue
|
|
511
|
+
|
|
512
|
+
msg_meta: dict[str, Any] = {"team_id": team_id, "channel_id": channel_id, "ts": ts}
|
|
513
|
+
if team_id:
|
|
514
|
+
ts_compact = ts.replace(".", "")
|
|
515
|
+
msg_meta["web_url"] = f"https://app.slack.com/client/{team_id}/{channel_id}/p{ts_compact}"
|
|
516
|
+
if account_id:
|
|
517
|
+
msg_meta["account_id"] = account_id
|
|
518
|
+
msg_entity = EntityRecord(
|
|
519
|
+
entity_type="Message",
|
|
520
|
+
platform="slack",
|
|
521
|
+
platform_entity_id=_message_ref(team_id, channel_id, ts),
|
|
522
|
+
content=text,
|
|
523
|
+
created_at=_ts_to_dt(ts),
|
|
524
|
+
updated_at=_ts_to_dt(ts),
|
|
525
|
+
metadata=msg_meta,
|
|
526
|
+
)
|
|
527
|
+
entities.append(msg_entity)
|
|
528
|
+
|
|
529
|
+
# posted_in edge: message → channel
|
|
530
|
+
edges.append(EdgeRecord(
|
|
531
|
+
edge_type="posted_in",
|
|
532
|
+
source_platform_entity_id=_message_ref(team_id, channel_id, ts),
|
|
533
|
+
target_platform_entity_id=channel_ref,
|
|
534
|
+
platform="slack",
|
|
535
|
+
))
|
|
536
|
+
|
|
537
|
+
# Thread reply edge (this message is itself a reply)
|
|
538
|
+
if thread_ts and thread_ts != ts:
|
|
539
|
+
edges.append(EdgeRecord(
|
|
540
|
+
edge_type="replied_to",
|
|
541
|
+
source_platform_entity_id=_message_ref(team_id, channel_id, ts),
|
|
542
|
+
target_platform_entity_id=_message_ref(team_id, channel_id, thread_ts),
|
|
543
|
+
platform="slack",
|
|
544
|
+
))
|
|
545
|
+
|
|
546
|
+
# Author
|
|
547
|
+
if user_id and user_id not in seen_users:
|
|
548
|
+
seen_users.add(user_id)
|
|
549
|
+
user_info = await _fetch_user(client, user_id, account_id=account_id)
|
|
550
|
+
profile = user_info.get("profile", {})
|
|
551
|
+
persons.append(PersonRecord(
|
|
552
|
+
platform="slack",
|
|
553
|
+
platform_user_id=_user_ref(team_id, user_id),
|
|
554
|
+
platform_username=user_info.get("name"),
|
|
555
|
+
canonical_email=profile.get("email") or None,
|
|
556
|
+
display_name=profile.get("real_name") or None,
|
|
557
|
+
))
|
|
558
|
+
|
|
559
|
+
if user_id:
|
|
560
|
+
edges.append(EdgeRecord(
|
|
561
|
+
edge_type="authored",
|
|
562
|
+
source_platform_user_id=_user_ref(team_id, user_id),
|
|
563
|
+
target_platform_entity_id=_message_ref(team_id, channel_id, ts),
|
|
564
|
+
platform="slack",
|
|
565
|
+
))
|
|
566
|
+
|
|
567
|
+
# User mention edges
|
|
568
|
+
for mentioned_id in _parse_mentions(text):
|
|
569
|
+
if mentioned_id not in seen_users:
|
|
570
|
+
seen_users.add(mentioned_id)
|
|
571
|
+
try:
|
|
572
|
+
user_info = await _fetch_user(client, mentioned_id, account_id=account_id)
|
|
573
|
+
profile = user_info.get("profile", {})
|
|
574
|
+
persons.append(PersonRecord(
|
|
575
|
+
platform="slack",
|
|
576
|
+
platform_user_id=_user_ref(team_id, mentioned_id),
|
|
577
|
+
platform_username=user_info.get("name"),
|
|
578
|
+
canonical_email=profile.get("email") or None,
|
|
579
|
+
display_name=profile.get("real_name") or None,
|
|
580
|
+
))
|
|
581
|
+
except Exception:
|
|
582
|
+
logger.debug("Could not fetch user %s", mentioned_id)
|
|
583
|
+
edges.append(EdgeRecord(
|
|
584
|
+
edge_type="mentions",
|
|
585
|
+
source_platform_entity_id=_message_ref(team_id, channel_id, ts),
|
|
586
|
+
target_platform_user_id=_user_ref(team_id, mentioned_id),
|
|
587
|
+
platform="slack",
|
|
588
|
+
))
|
|
589
|
+
|
|
590
|
+
# Channel mention edges
|
|
591
|
+
for mentioned_channel_id in _parse_channel_mentions(text):
|
|
592
|
+
edges.append(EdgeRecord(
|
|
593
|
+
edge_type="mentions",
|
|
594
|
+
source_platform_entity_id=_message_ref(team_id, channel_id, ts),
|
|
595
|
+
target_platform_entity_id=_channel_ref(team_id, mentioned_channel_id),
|
|
596
|
+
platform="slack",
|
|
597
|
+
))
|
|
598
|
+
|
|
599
|
+
# Fetch thread replies for messages that have them
|
|
600
|
+
if reply_count > 0 and not (thread_ts and thread_ts != ts):
|
|
601
|
+
await _fetch_thread_replies(
|
|
602
|
+
client, channel_ref, ts, entities, persons, edges, seen_users, team_id=team_id, account_id=account_id
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
batch = EntityBatch(entities=entities, persons=persons, edges=edges)
|
|
606
|
+
for entity in batch.entities[:]:
|
|
607
|
+
batch.add_stubs_from(entity)
|
|
608
|
+
return batch
|