agentgraph-connector-discord 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-discord
3
+ Version: 0.5.0
4
+ Summary: Discord 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,620 @@
1
+ """Discord connector (bot token auth)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import re
8
+ from datetime import UTC, datetime, timedelta
9
+ from typing import Any, cast
10
+
11
+ import httpx
12
+
13
+ from agentgraph.connectors.base import (
14
+ BaseConnector,
15
+ ConnectorAccount,
16
+ EdgeRecord,
17
+ EntityBatch,
18
+ EntityRecord,
19
+ FetchPolicy,
20
+ PersonRecord,
21
+ ResourceType,
22
+ SourceReference,
23
+ get_known_channel_syncs,
24
+ )
25
+ from agentgraph.graph.upsert import upsert_batch
26
+ from agentgraph_connector_discord.auth import list_discord_accounts, load_discord_creds
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ DISCORD_API = "https://discord.com/api/v10"
31
+ _STALE_AFTER = 5 * 60 # 5 minutes
32
+ _MAX_RETRIES = 3
33
+ _DISCORD_CHANNEL_URL_RE = re.compile(
34
+ r"https://discord\.com/channels/(?P<guild_id>\d+)/(?P<channel_id>\d+)"
35
+ )
36
+ _DISCORD_DM_URL_RE = re.compile(
37
+ r"https://discord\.com/channels/@me/(?P<channel_id>\d+)"
38
+ )
39
+
40
+ # Module-level user cache so repeated syncs don't re-fetch the same users
41
+ _user_cache: dict[str, PersonRecord] = {}
42
+
43
+
44
+ def _get_headers(account_id: str | None = None) -> dict[str, str]:
45
+ creds = load_discord_creds(account_id)
46
+ return {"Authorization": f"Bot {creds.bot_token}"}
47
+
48
+
49
+ async def _api_get(client: httpx.AsyncClient, path: str, account_id: str | None = None, **params: Any) -> Any:
50
+ for _attempt in range(_MAX_RETRIES):
51
+ resp = await client.get(
52
+ f"{DISCORD_API}{path}",
53
+ headers=_get_headers(account_id),
54
+ params=params,
55
+ timeout=30,
56
+ )
57
+ if resp.status_code == 429:
58
+ retry_after = float(resp.headers.get("Retry-After", "1"))
59
+ is_global = resp.headers.get("X-RateLimit-Global") == "true"
60
+ logger.warning(
61
+ "Discord rate limited%s on %s — sleeping %.1fs",
62
+ " (global)" if is_global else "",
63
+ path,
64
+ retry_after,
65
+ )
66
+ await asyncio.sleep(retry_after)
67
+ continue
68
+ resp.raise_for_status()
69
+ return resp.json()
70
+ raise RuntimeError(f"Discord API {path} still rate-limited after {_MAX_RETRIES} retries")
71
+
72
+
73
+ def _snowflake_to_dt(snowflake: str) -> datetime:
74
+ """Convert a Discord snowflake ID to a UTC datetime."""
75
+ ts_ms = (int(snowflake) >> 22) + 1420070400000
76
+ return datetime.fromtimestamp(ts_ms / 1000, tz=UTC)
77
+
78
+
79
+ def _snowflake_after(dt: datetime) -> str:
80
+ """Return the smallest snowflake ID that is strictly after dt."""
81
+ ts_ms = int(dt.timestamp() * 1000) - 1420070400000
82
+ return str(max(ts_ms, 0) << 22)
83
+
84
+
85
+ async def refresh_attachment_urls(
86
+ channel_id: str,
87
+ message_id: str,
88
+ stored_json: str,
89
+ ) -> str:
90
+ """Return attachments JSON with fresh CDN URLs from Discord.
91
+
92
+ Uses POST /attachments/refresh-urls — the purpose-built Discord
93
+ endpoint for renewing expiring signed CDN tokens. Discord only issues
94
+ new tokens once the current ones have expired; while still valid the
95
+ same URL is returned unchanged, which is correct behaviour.
96
+
97
+ Falls back to stored_json on missing credentials or any API error.
98
+ channel_id and message_id are accepted for interface compatibility but
99
+ are not used by this endpoint (it operates on the URLs directly).
100
+ """
101
+ import json as _json
102
+
103
+ try:
104
+ token = load_discord_creds().bot_token
105
+ except Exception:
106
+ return stored_json
107
+
108
+ try:
109
+ attachments: list[dict[str, Any]] = _json.loads(stored_json)
110
+ urls = [a["url"] for a in attachments if a.get("url")]
111
+ if not urls:
112
+ return stored_json
113
+
114
+ async with httpx.AsyncClient(timeout=10) as client:
115
+ resp = await client.post(
116
+ f"{DISCORD_API}/attachments/refresh-urls",
117
+ headers={"Authorization": f"Bot {token}", "Content-Type": "application/json"},
118
+ json={"attachment_urls": urls},
119
+ timeout=10,
120
+ )
121
+ resp.raise_for_status()
122
+ data = resp.json()
123
+
124
+ url_map: dict[str, str] = {
125
+ r["original"]: r["refreshed"]
126
+ for r in data.get("refreshed_urls", [])
127
+ }
128
+ for a in attachments:
129
+ if a.get("url") in url_map:
130
+ a["url"] = url_map[a["url"]]
131
+ return _json.dumps(attachments)
132
+ except Exception as exc:
133
+ logger.debug(
134
+ "discord: could not refresh attachments for %s/%s: %s",
135
+ channel_id, message_id, exc,
136
+ )
137
+ return stored_json
138
+
139
+
140
+ def _parse_mentions(content: str) -> list[str]:
141
+ """Extract <@USER_ID> and <@!USER_ID> user IDs from message content."""
142
+ import re
143
+ return re.findall(r"<@!?(\d+)>", content)
144
+
145
+
146
+ def _extract_attachments(msg: dict[str, Any]) -> str | None:
147
+ """Return attachments as a JSON string for storage in scalar metadata, or None if none."""
148
+ import json
149
+ result: list[dict[str, Any]] = []
150
+ for a in msg.get("attachments", []):
151
+ url: str = a.get("url", "")
152
+ if not url:
153
+ continue
154
+ entry: dict[str, Any] = {"url": url, "filename": a.get("filename", "")}
155
+ content_type: str = a.get("content_type", "")
156
+ if content_type:
157
+ entry["content_type"] = content_type
158
+ width = a.get("width")
159
+ height = a.get("height")
160
+ if width and height:
161
+ entry["width"] = width
162
+ entry["height"] = height
163
+ result.append(entry)
164
+ return json.dumps(result) if result else None
165
+
166
+
167
+ class DiscordConnector(BaseConnector):
168
+ source = "discord"
169
+ fetch_policy = FetchPolicy(stale_after_seconds=_STALE_AFTER)
170
+ poll_interval: timedelta | None = timedelta(minutes=5) # type: ignore[assignment]
171
+ url_patterns = ["https://discord.com/*"]
172
+ auth_label = "discord"
173
+ auth_description = "Discord guild channels, DMs, and threads (channels the bot is in): Channel and Message entities with attachments, authors, and mentions."
174
+ onboard_prompt = "Set up Discord?"
175
+
176
+ @classmethod
177
+ def run_auth_flow(
178
+ cls,
179
+ account_id: str | None = None,
180
+ add: bool = False,
181
+ args: list[str] | None = None,
182
+ ) -> None:
183
+ from agentgraph_connector_discord.auth import run_token_flow
184
+
185
+ if args:
186
+ raise ValueError(f"Discord authentication does not accept options: {' '.join(args)}")
187
+ run_token_flow(account_id=account_id, add=add)
188
+
189
+ @classmethod
190
+ def get_authenticated_user(cls) -> str | None:
191
+ try:
192
+ return load_discord_creds().bot_user_id
193
+ except Exception:
194
+ return None
195
+
196
+ @classmethod
197
+ def list_accounts(cls) -> list[ConnectorAccount]:
198
+ return [
199
+ ConnectorAccount(
200
+ account_id=str(account["account_id"]),
201
+ label=str(account["label"]),
202
+ auth_group=cls.auth_label or cls.source,
203
+ source=cls.source,
204
+ user_id=account.get("bot_user_id"),
205
+ )
206
+ for account in list_discord_accounts()
207
+ ]
208
+
209
+ @classmethod
210
+ async def verify_auth(cls, account_id: str | None = None) -> tuple[str, str | None]:
211
+ try:
212
+ creds = load_discord_creds(account_id)
213
+ except Exception:
214
+ return ("missing", None)
215
+ try:
216
+ import httpx
217
+ async with httpx.AsyncClient(timeout=5) as client:
218
+ resp = await client.get(
219
+ "https://discord.com/api/v10/users/@me",
220
+ headers={"Authorization": f"Bot {creds.bot_token}"},
221
+ )
222
+ if resp.status_code == 200:
223
+ data = resp.json()
224
+ username: str | None = data.get("username")
225
+ return ("ok", username or data.get("id"))
226
+ if resp.status_code == 401:
227
+ return ("invalid", "token rejected (401) — run: agentgraph auth discord")
228
+ return ("invalid", f"HTTP {resp.status_code}")
229
+ except Exception as exc:
230
+ return ("invalid", f"network error: {type(exc).__name__}")
231
+
232
+ def can_handle(self, url: str) -> bool:
233
+ return self.resolve_url(url) is not None
234
+
235
+ def resolve_url(self, url: str) -> SourceReference | None:
236
+ dm_match = _DISCORD_DM_URL_RE.match(url)
237
+ if dm_match is not None:
238
+ return SourceReference(
239
+ source=self.source,
240
+ resource_type="dm",
241
+ resource_id=dm_match.group("channel_id"),
242
+ )
243
+
244
+ channel_match = _DISCORD_CHANNEL_URL_RE.match(url)
245
+ if channel_match is not None:
246
+ return SourceReference(
247
+ source=self.source,
248
+ resource_type="channel",
249
+ resource_id=channel_match.group("channel_id"),
250
+ )
251
+ return None
252
+
253
+ def normalise_fetch_id(self, resource_id: str, entity_type: str) -> tuple[str, ResourceType]:
254
+ # Message IDs are stored as "channel_id:message_id"; fetch operates on the channel.
255
+ if entity_type == "Message" and ":" in resource_id:
256
+ return resource_id.split(":")[0], "channel"
257
+ return super().normalise_fetch_id(resource_id, entity_type)
258
+
259
+ async def enrich_results(self, entities: list[dict[str, Any]]) -> None:
260
+ """Refresh expiring Discord CDN attachment URLs in query results."""
261
+ messages = [
262
+ entity for entity in entities
263
+ if entity.get("entity_type") == "Message"
264
+ ]
265
+ if not messages:
266
+ return
267
+
268
+ async def _refresh_one(entity: dict[str, Any]) -> None:
269
+ meta = entity.get("metadata")
270
+ if not isinstance(meta, dict):
271
+ return
272
+ metadata = cast(dict[str, Any], meta)
273
+ stored_json = metadata.get("attachments")
274
+ if not isinstance(stored_json, str) or not stored_json:
275
+ return
276
+ channel_id = metadata.get("channel_id")
277
+ message_id = metadata.get("message_id")
278
+ if not isinstance(channel_id, str) or not isinstance(message_id, str):
279
+ return
280
+ if not channel_id or not message_id:
281
+ return
282
+ metadata["attachments"] = await refresh_attachment_urls(
283
+ channel_id,
284
+ message_id,
285
+ stored_json,
286
+ )
287
+
288
+ await asyncio.gather(*(_refresh_one(message) for message in messages))
289
+
290
+ async def fetch(
291
+ self,
292
+ resource_type: ResourceType,
293
+ resource_id: str,
294
+ meta: dict[str, str] | None = None,
295
+ account_id: str | None = None,
296
+ ) -> EntityBatch:
297
+ last_sync = await self.last_synced_at(resource_id)
298
+ decision = self.fetch_policy.decide(last_sync)
299
+
300
+ if decision == FetchPolicy.FRESH:
301
+ logger.debug("discord/%s is fresh — updating last_accessed only", resource_id)
302
+ await _touch_last_accessed(resource_id)
303
+ return EntityBatch()
304
+
305
+ after_snowflake: str | None = None
306
+ if decision == FetchPolicy.INCREMENTAL and last_sync:
307
+ after_snowflake = _snowflake_after(last_sync)
308
+
309
+ selected_account_id = account_id or ((meta or {}).get("account_id") if meta else None)
310
+ is_dm = resource_type == "dm"
311
+ logger.info("Fetching Discord %s %s (policy=%s)", resource_type, resource_id, decision)
312
+ batch = await _fetch_channel(
313
+ resource_id,
314
+ after_snowflake=after_snowflake,
315
+ is_dm=is_dm,
316
+ account_id=selected_account_id,
317
+ )
318
+ await upsert_batch(batch)
319
+ return batch
320
+
321
+ async def poll(
322
+ self,
323
+ cursor: dict[str, Any],
324
+ account_id: str | None = None,
325
+ ) -> tuple[EntityBatch, dict[str, Any]]:
326
+ channel_rows = await get_known_channel_syncs("discord", account_id=account_id)
327
+ combined = EntityBatch()
328
+ for channel_id, synced_at in channel_rows:
329
+ after_snowflake = _snowflake_after(synced_at) if synced_at else None
330
+ try:
331
+ batch = await _fetch_channel(
332
+ channel_id,
333
+ after_snowflake=after_snowflake,
334
+ account_id=account_id,
335
+ )
336
+ combined.entities.extend(batch.entities)
337
+ combined.persons.extend(batch.persons)
338
+ combined.edges.extend(batch.edges)
339
+ except Exception:
340
+ logger.exception("discord poll: failed to fetch channel %s", channel_id)
341
+ return combined, cursor
342
+
343
+
344
+ async def _touch_last_accessed(channel_id: str) -> None:
345
+ from agentgraph.core.context import get_backend
346
+
347
+ await get_backend().upsert_stub_entity("Channel", "discord", channel_id)
348
+
349
+
350
+ async def _fetch_user(
351
+ client: httpx.AsyncClient,
352
+ user_id: str,
353
+ seen_users: dict[str, PersonRecord],
354
+ account_id: str | None = None,
355
+ ) -> PersonRecord | None:
356
+ if user_id in seen_users:
357
+ return seen_users[user_id]
358
+ if user_id in _user_cache:
359
+ seen_users[user_id] = _user_cache[user_id]
360
+ return _user_cache[user_id]
361
+ try:
362
+ data = await _api_get(client, f"/users/{user_id}", account_id=account_id)
363
+ username = data.get("username", "")
364
+ global_name = data.get("global_name") or username
365
+ person = PersonRecord(
366
+ platform="discord",
367
+ platform_user_id=user_id,
368
+ platform_username=username,
369
+ display_name=global_name or None,
370
+ # Discord bots cannot access user emails
371
+ )
372
+ seen_users[user_id] = person
373
+ _user_cache[user_id] = person
374
+ return person
375
+ except Exception as exc:
376
+ logger.debug("Could not fetch Discord user %s: %s", user_id, exc)
377
+ return None
378
+
379
+
380
+ async def _fetch_thread_messages(
381
+ client: httpx.AsyncClient,
382
+ thread_id: str,
383
+ parent_channel_id: str,
384
+ parent_message_id: str,
385
+ entities: list[EntityRecord],
386
+ edges: list[EdgeRecord],
387
+ seen_users: dict[str, PersonRecord],
388
+ persons: list[PersonRecord],
389
+ after_snowflake: str | None,
390
+ guild_id: str = "",
391
+ account_id: str | None = None,
392
+ ) -> None:
393
+ """Fetch messages in a Discord thread (threads are channels in Discord API)."""
394
+ try:
395
+ params: dict[str, Any] = {"limit": 100}
396
+ if after_snowflake:
397
+ params["after"] = after_snowflake
398
+
399
+ messages = await _api_get(client, f"/channels/{thread_id}/messages", account_id=account_id, **params)
400
+ except Exception as exc:
401
+ logger.warning("Could not fetch thread %s: %s", thread_id, exc)
402
+ return
403
+
404
+ for msg in messages:
405
+ msg_id: str = msg.get("id", "")
406
+ content: str = msg.get("content", "")
407
+ author: dict[str, Any] = msg.get("author", {})
408
+ user_id: str = author.get("id", "")
409
+
410
+ if not msg_id:
411
+ continue
412
+
413
+ attachments_json = _extract_attachments(msg)
414
+ meta: dict[str, Any] = {"channel_id": parent_channel_id, "thread_id": thread_id, "message_id": msg_id}
415
+ if guild_id:
416
+ meta["web_url"] = f"https://discord.com/channels/{guild_id}/{parent_channel_id}/{msg_id}"
417
+ if attachments_json:
418
+ meta["attachments"] = attachments_json
419
+ if account_id:
420
+ meta["account_id"] = account_id
421
+
422
+ entities.append(EntityRecord(
423
+ entity_type="Message",
424
+ platform="discord",
425
+ platform_entity_id=f"{thread_id}:{msg_id}",
426
+ content=content,
427
+ created_at=_snowflake_to_dt(msg_id),
428
+ updated_at=_snowflake_to_dt(msg_id),
429
+ metadata=meta,
430
+ ))
431
+
432
+ edges.append(EdgeRecord(
433
+ edge_type="replied_to",
434
+ source_platform_entity_id=f"{thread_id}:{msg_id}",
435
+ target_platform_entity_id=f"{parent_channel_id}:{parent_message_id}",
436
+ platform="discord",
437
+ ))
438
+ edges.append(EdgeRecord(
439
+ edge_type="posted_in",
440
+ source_platform_entity_id=f"{thread_id}:{msg_id}",
441
+ target_platform_entity_id=parent_channel_id,
442
+ platform="discord",
443
+ ))
444
+
445
+ if user_id:
446
+ person = await _fetch_user(client, user_id, seen_users, account_id=account_id)
447
+ if person and person not in persons:
448
+ persons.append(person)
449
+ edges.append(EdgeRecord(
450
+ edge_type="authored",
451
+ source_platform_user_id=user_id,
452
+ target_platform_entity_id=f"{thread_id}:{msg_id}",
453
+ platform="discord",
454
+ ))
455
+
456
+ for mentioned_id in _parse_mentions(content):
457
+ person = await _fetch_user(client, mentioned_id, seen_users, account_id=account_id)
458
+ if person and person not in persons:
459
+ persons.append(person)
460
+ edges.append(EdgeRecord(
461
+ edge_type="mentions",
462
+ source_platform_entity_id=f"{thread_id}:{msg_id}",
463
+ target_platform_user_id=mentioned_id,
464
+ platform="discord",
465
+ ))
466
+
467
+
468
+ async def _fetch_channel(
469
+ channel_id: str,
470
+ after_snowflake: str | None = None,
471
+ is_dm: bool = False,
472
+ account_id: str | None = None,
473
+ ) -> EntityBatch:
474
+ entities: list[EntityRecord] = []
475
+ persons: list[PersonRecord] = []
476
+ edges: list[EdgeRecord] = []
477
+ seen_users: dict[str, PersonRecord] = {}
478
+
479
+ async with httpx.AsyncClient(timeout=30) as client:
480
+ # Channel entity
481
+ try:
482
+ channel_info = await _api_get(client, f"/channels/{channel_id}", account_id=account_id)
483
+ except Exception as exc:
484
+ logger.error("Could not fetch Discord channel %s: %s", channel_id, exc)
485
+ return EntityBatch()
486
+
487
+ guild_id = channel_info.get("guild_id", "")
488
+ channel_type: int = channel_info.get("type", 0)
489
+
490
+ if channel_type in (1, 3): # 1 = DM, 3 = Group DM
491
+ recipients: list[dict[str, Any]] = channel_info.get("recipients", [])
492
+ if channel_type == 3 and channel_info.get("name"):
493
+ channel_name = channel_info["name"]
494
+ elif recipients:
495
+ channel_name = ", ".join(r.get("username", r.get("id", "")) for r in recipients)
496
+ else:
497
+ channel_name = channel_id
498
+ # Emit Person records for DM participants from channel metadata
499
+ for recipient in recipients:
500
+ user_id: str = recipient.get("id", "")
501
+ if not user_id or user_id in seen_users:
502
+ continue
503
+ username = recipient.get("username", "")
504
+ global_name = recipient.get("global_name") or username
505
+ person = PersonRecord(
506
+ platform="discord",
507
+ platform_user_id=user_id,
508
+ platform_username=username,
509
+ display_name=global_name or None,
510
+ )
511
+ seen_users[user_id] = person
512
+ _user_cache[user_id] = person
513
+ persons.append(person)
514
+ edges.append(EdgeRecord(
515
+ edge_type="participated_in",
516
+ source_platform_user_id=user_id,
517
+ target_platform_entity_id=channel_id,
518
+ platform="discord",
519
+ ))
520
+ else:
521
+ channel_name = channel_info.get("name", channel_id)
522
+
523
+ channel_meta: dict[str, Any] = {}
524
+ if guild_id:
525
+ channel_meta["guild_id"] = guild_id
526
+ channel_meta["web_url"] = f"https://discord.com/channels/{guild_id}/{channel_id}"
527
+ if account_id:
528
+ channel_meta["account_id"] = account_id
529
+ entities.append(EntityRecord(
530
+ entity_type="Channel",
531
+ platform="discord",
532
+ platform_entity_id=channel_id,
533
+ title=f"#{channel_name}",
534
+ updated_at=datetime.now(UTC),
535
+ metadata=channel_meta,
536
+ ))
537
+
538
+ # Fetch messages (Discord returns newest-first; reverse for chronological)
539
+ params: dict[str, Any] = {"limit": 100}
540
+ if after_snowflake:
541
+ params["after"] = after_snowflake
542
+
543
+ try:
544
+ messages = await _api_get(client, f"/channels/{channel_id}/messages", account_id=account_id, **params)
545
+ except Exception as exc:
546
+ logger.error("Could not fetch messages for Discord channel %s: %s", channel_id, exc)
547
+ return EntityBatch(entities=entities)
548
+
549
+ for msg in messages:
550
+ msg_id: str = msg.get("id", "")
551
+ content: str = msg.get("content", "")
552
+ author: dict[str, Any] = msg.get("author", {})
553
+ user_id: str = author.get("id", "")
554
+ thread: dict[str, Any] | None = msg.get("thread")
555
+
556
+ if not msg_id:
557
+ continue
558
+
559
+ attachments_json = _extract_attachments(msg)
560
+ meta: dict[str, Any] = {"channel_id": channel_id, "message_id": msg_id, "guild_id": guild_id}
561
+ if guild_id:
562
+ meta["web_url"] = f"https://discord.com/channels/{guild_id}/{channel_id}/{msg_id}"
563
+ if attachments_json:
564
+ meta["attachments"] = attachments_json
565
+ if account_id:
566
+ meta["account_id"] = account_id
567
+
568
+ entities.append(EntityRecord(
569
+ entity_type="Message",
570
+ platform="discord",
571
+ platform_entity_id=f"{channel_id}:{msg_id}",
572
+ content=content,
573
+ created_at=_snowflake_to_dt(msg_id),
574
+ updated_at=_snowflake_to_dt(msg_id),
575
+ metadata=meta,
576
+ ))
577
+
578
+ edges.append(EdgeRecord(
579
+ edge_type="posted_in",
580
+ source_platform_entity_id=f"{channel_id}:{msg_id}",
581
+ target_platform_entity_id=channel_id,
582
+ platform="discord",
583
+ ))
584
+
585
+ if user_id:
586
+ person = await _fetch_user(client, user_id, seen_users, account_id=account_id)
587
+ if person and person not in persons:
588
+ persons.append(person)
589
+ edges.append(EdgeRecord(
590
+ edge_type="authored",
591
+ source_platform_user_id=user_id,
592
+ target_platform_entity_id=f"{channel_id}:{msg_id}",
593
+ platform="discord",
594
+ ))
595
+
596
+ for mentioned_id in _parse_mentions(content):
597
+ person = await _fetch_user(client, mentioned_id, seen_users, account_id=account_id)
598
+ if person and person not in persons:
599
+ persons.append(person)
600
+ edges.append(EdgeRecord(
601
+ edge_type="mentions",
602
+ source_platform_entity_id=f"{channel_id}:{msg_id}",
603
+ target_platform_user_id=mentioned_id,
604
+ platform="discord",
605
+ ))
606
+
607
+ # Fetch thread replies if this message spawned a thread
608
+ if thread:
609
+ thread_id: str = thread.get("id", "")
610
+ if thread_id:
611
+ await _fetch_thread_messages(
612
+ client, thread_id, channel_id, msg_id,
613
+ entities, edges, seen_users, persons, after_snowflake,
614
+ guild_id=guild_id, account_id=account_id,
615
+ )
616
+
617
+ batch = EntityBatch(entities=entities, persons=persons, edges=edges)
618
+ for entity in batch.entities[:]:
619
+ batch.add_stubs_from(entity)
620
+ return batch
@@ -0,0 +1,154 @@
1
+ """Discord bot token credential flow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from pydantic import BaseModel
8
+
9
+
10
+ class DiscordCredentials(BaseModel):
11
+ bot_token: str
12
+ bot_user_id: str | None = None
13
+
14
+
15
+ def load_discord_creds(account_id: str | None = None) -> DiscordCredentials:
16
+ from agentgraph.auth.credentials import load_platform_account
17
+
18
+ data = load_platform_account("discord", account_id)
19
+ if data is None:
20
+ raise RuntimeError("Discord credentials not configured. Run: agentgraph auth discord")
21
+ return DiscordCredentials(**data)
22
+
23
+
24
+ def list_discord_accounts() -> list[dict[str, str | None]]:
25
+ from agentgraph.auth.credentials import load_platform_accounts
26
+
27
+ results: list[dict[str, str | None]] = []
28
+ for raw in load_platform_accounts("discord"):
29
+ try:
30
+ creds = DiscordCredentials(**raw)
31
+ except Exception:
32
+ continue
33
+ bot_user_id = creds.bot_user_id
34
+ account_id = str(raw.get("account_id") or (f"discord:{bot_user_id}" if bot_user_id else "discord"))
35
+ results.append({
36
+ "account_id": account_id,
37
+ "bot_user_id": bot_user_id,
38
+ "label": bot_user_id or account_id,
39
+ })
40
+ return results
41
+
42
+
43
+ def _verify_token(token: str) -> tuple[str | None, str | None]:
44
+ """Call Discord /users/@me. Return (bot_user_id, bot_username), or (None, None) on failure."""
45
+ try:
46
+ import httpx
47
+
48
+ resp = httpx.get(
49
+ "https://discord.com/api/v10/users/@me",
50
+ headers={"Authorization": f"Bot {token}"},
51
+ timeout=10,
52
+ )
53
+ if resp.status_code == 200:
54
+ data = resp.json()
55
+ user_id: str | None = data.get("id")
56
+ username: str | None = data.get("username")
57
+ return user_id, username
58
+ except Exception:
59
+ pass
60
+ return None, None
61
+
62
+
63
+ _FIRST_TIME_INSTRUCTIONS = """
64
+ To get your Discord bot token:
65
+
66
+ 1. Go to https://discord.com/developers/applications
67
+ 2. Click 'New Application', give it a name (e.g. AgentGraph)
68
+ 3. Go to 'Bot' in the left sidebar
69
+ 4. Click 'Reset Token' and copy the token shown
70
+ 5. Under 'Privileged Gateway Intents', enable:
71
+ • Message Content Intent
72
+ 6. Go to 'OAuth2 → URL Generator':
73
+ • Scopes: bot
74
+ • Bot Permissions: Read Messages/View Channels, Read Message History
75
+ 7. Open the generated URL to invite the bot to your server
76
+ """
77
+
78
+ _REAUTH_INSTRUCTIONS = """
79
+ To get a fresh bot token:
80
+
81
+ 1. Go to https://discord.com/developers/applications
82
+ 2. Click your existing application
83
+ 3. Go to 'Bot' in the left sidebar
84
+ 4. Click 'Reset Token' and copy the new token shown
85
+ """
86
+
87
+
88
+ def _save_token(token: str) -> str | None:
89
+ """Verify the token and save it. Return the bot username (or None if unverified)."""
90
+ from agentgraph.auth.credentials import save_platform, upsert_platform_account
91
+
92
+ bot_user_id, bot_username = _verify_token(token)
93
+ creds = DiscordCredentials(bot_token=token, bot_user_id=bot_user_id)
94
+ account_id = f"discord:{bot_user_id}" if bot_user_id else "discord"
95
+ if not list_discord_accounts():
96
+ save_platform("discord", {**creds.model_dump(mode="json"), "account_id": account_id})
97
+ else:
98
+ upsert_platform_account("discord", account_id, creds, make_default=True)
99
+ return bot_username
100
+
101
+
102
+ def run_token_flow(account_id: str | None = None, add: bool = False) -> None:
103
+ """Guide the user through obtaining (or refreshing) a Discord bot token."""
104
+ import typer
105
+
106
+ from agentgraph.auth.credentials import load_platform_account
107
+
108
+ # 1. Non-interactive override via env var (useful for scripted re-auth)
109
+ env_token = os.environ.get("AGENTGRAPH_DISCORD_BOT_TOKEN")
110
+ if env_token:
111
+ typer.echo("Using token from $AGENTGRAPH_DISCORD_BOT_TOKEN")
112
+ username = _save_token(env_token.strip())
113
+ suffix = f" (bot: {username})" if username else " (token unverified)"
114
+ typer.echo(f"Discord credentials saved{suffix}")
115
+ return
116
+
117
+ # 2. Detect existing creds → re-auth path with terse instructions
118
+ existing_raw = None if add else load_platform_account("discord", account_id)
119
+ existing: DiscordCredentials | None = None
120
+ if existing_raw is not None:
121
+ try:
122
+ existing = DiscordCredentials(**existing_raw)
123
+ except Exception:
124
+ existing = None
125
+
126
+ if existing is not None:
127
+ _, current_username = _verify_token(existing.bot_token)
128
+ bot_label = current_username or existing.bot_user_id or "unknown"
129
+ typer.echo(f"\nRe-authenticating Discord (current bot: {bot_label})")
130
+ typer.echo(_REAUTH_INSTRUCTIONS)
131
+ bot_token = typer.prompt(
132
+ "Bot token (press Enter to keep current)",
133
+ default="",
134
+ show_default=False,
135
+ ).strip()
136
+ if not bot_token:
137
+ typer.echo("Keeping existing token.")
138
+ return
139
+ else:
140
+ typer.echo(_FIRST_TIME_INSTRUCTIONS)
141
+ bot_token = typer.prompt("Bot token").strip()
142
+
143
+ if not bot_token.startswith("MT") and not bot_token.startswith("OT") and "." not in bot_token:
144
+ typer.echo("Warning: token format looks unexpected — double-check the value.")
145
+
146
+ username = _save_token(bot_token)
147
+ from agentgraph.config import CREDENTIALS_FILE
148
+
149
+ msg = f"\nDiscord credentials saved to {CREDENTIALS_FILE}"
150
+ if username:
151
+ msg += f" (bot: {username})"
152
+ else:
153
+ msg += " (token unverified — Discord API did not respond)"
154
+ typer.echo(msg)
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentgraph-connector-discord
3
+ Version: 0.5.0
4
+ Summary: Discord 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,9 @@
1
+ pyproject.toml
2
+ agentgraph_connector_discord/__init__.py
3
+ agentgraph_connector_discord/auth.py
4
+ agentgraph_connector_discord.egg-info/PKG-INFO
5
+ agentgraph_connector_discord.egg-info/SOURCES.txt
6
+ agentgraph_connector_discord.egg-info/dependency_links.txt
7
+ agentgraph_connector_discord.egg-info/entry_points.txt
8
+ agentgraph_connector_discord.egg-info/requires.txt
9
+ agentgraph_connector_discord.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [agentgraph.connectors]
2
+ discord = agentgraph_connector_discord:DiscordConnector
@@ -0,0 +1,2 @@
1
+ agentgraph-server<0.6,>=0.5.0
2
+ httpx>=0.28.1
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "agentgraph-connector-discord"
3
+ version = "0.5.0"
4
+ description = "Discord connector for AgentGraph"
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "agentgraph-server>=0.5.0,<0.6",
8
+ "httpx>=0.28.1",
9
+ ]
10
+
11
+ [project.entry-points."agentgraph.connectors"]
12
+ discord = "agentgraph_connector_discord:DiscordConnector"
13
+
14
+ [tool.uv]
15
+ package = true
16
+
17
+ [tool.uv.sources]
18
+ agentgraph-server = { workspace = true }
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+