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.
- agentgraph_connector_discord-0.5.0/PKG-INFO +7 -0
- agentgraph_connector_discord-0.5.0/agentgraph_connector_discord/__init__.py +620 -0
- agentgraph_connector_discord-0.5.0/agentgraph_connector_discord/auth.py +154 -0
- agentgraph_connector_discord-0.5.0/agentgraph_connector_discord.egg-info/PKG-INFO +7 -0
- agentgraph_connector_discord-0.5.0/agentgraph_connector_discord.egg-info/SOURCES.txt +9 -0
- agentgraph_connector_discord-0.5.0/agentgraph_connector_discord.egg-info/dependency_links.txt +1 -0
- agentgraph_connector_discord-0.5.0/agentgraph_connector_discord.egg-info/entry_points.txt +2 -0
- agentgraph_connector_discord-0.5.0/agentgraph_connector_discord.egg-info/requires.txt +2 -0
- agentgraph_connector_discord-0.5.0/agentgraph_connector_discord.egg-info/top_level.txt +1 -0
- agentgraph_connector_discord-0.5.0/pyproject.toml +18 -0
- agentgraph_connector_discord-0.5.0/setup.cfg +4 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agentgraph_connector_discord
|
|
@@ -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 }
|