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.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentgraph-connector-slack
3
+ Version: 0.5.0
4
+ Summary: Slack connector for AgentGraph
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: agentgraph-server<0.6,>=0.5.0
7
+ Requires-Dist: httpx>=0.28.1
@@ -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