cortexdb-connectors 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,646 @@
1
+ """
2
+ Discord connector for CortexDB.
3
+
4
+ Ingests messages, thread messages, and forum posts from Discord guilds into
5
+ CortexDB as Episodes. Uses ``aiohttp`` for lightweight REST API access
6
+ against the Discord API v10 and respects per-route rate limits.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import logging
13
+ from datetime import datetime, timedelta, timezone
14
+ from typing import Any
15
+
16
+ from cortexdb_connectors.base import (
17
+ Actor,
18
+ CortexConnector,
19
+ EntityRef,
20
+ Episode,
21
+ EpisodeType,
22
+ RawEvent,
23
+ Source,
24
+ Visibility,
25
+ VisibilityLevel,
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ _SOURCE = Source(system="discord")
31
+
32
+ _API_BASE = "https://discord.com/api/v10"
33
+
34
+ # Per-route rate limit safety delay.
35
+ _RATE_LIMIT_DELAY = 0.25
36
+
37
+ # Number of messages to fetch per request (max 100 per Discord docs).
38
+ _MESSAGE_LIMIT = 100
39
+
40
+ # Discord epoch: 2015-01-01T00:00:00Z in milliseconds.
41
+ _DISCORD_EPOCH = 1420070400000
42
+
43
+
44
+ class DiscordConnector(CortexConnector):
45
+ """Connector that syncs Discord guild messages, threads, and forum
46
+ posts into CortexDB.
47
+
48
+ Parameters
49
+ ----------
50
+ cortex_url:
51
+ Base URL of the CortexDB API.
52
+ cortex_api_key:
53
+ Bearer token for CortexDB authentication.
54
+ bot_token:
55
+ Discord bot token.
56
+ guild_ids:
57
+ List of guild (server) IDs to sync.
58
+ channel_ids:
59
+ Explicit list of channel IDs to sync. If empty, all text
60
+ channels in the configured guilds are discovered.
61
+ tenant_id:
62
+ CortexDB tenant identifier.
63
+ namespace:
64
+ CortexDB namespace for ingested episodes.
65
+ backfill_days:
66
+ Number of days of history to backfill on first sync.
67
+ include_bots:
68
+ Whether to include messages from bot accounts.
69
+ """
70
+
71
+ connector_name: str = "discord"
72
+
73
+ def __init__(
74
+ self,
75
+ cortex_url: str,
76
+ cortex_api_key: str,
77
+ bot_token: str,
78
+ guild_ids: list[str] | None = None,
79
+ channel_ids: list[str] | None = None,
80
+ tenant_id: str = "default",
81
+ namespace: str = "discord",
82
+ backfill_days: int = 30,
83
+ include_bots: bool = False,
84
+ ) -> None:
85
+ super().__init__(cortex_url, cortex_api_key, tenant_id)
86
+ self.bot_token = bot_token
87
+ self.guild_ids = guild_ids or []
88
+ self.channel_ids = channel_ids or []
89
+ self.namespace = namespace
90
+ self.backfill_days = backfill_days
91
+ self.include_bots = include_bots
92
+ self._guild_cache: dict[str, dict[str, Any]] = {}
93
+ self._channel_cache: dict[str, dict[str, Any]] = {}
94
+
95
+ # -- CortexConnector interface -------------------------------------------
96
+
97
+ async def fetch_events(
98
+ self, since: datetime | None = None
99
+ ) -> list[RawEvent]:
100
+ """Fetch messages from configured Discord guilds and channels.
101
+
102
+ If *since* is provided, only messages newer than that timestamp are
103
+ returned. Uses ``before`` message-ID pagination.
104
+ """
105
+ import aiohttp
106
+
107
+ cutoff = since or datetime.now(timezone.utc) - timedelta(
108
+ days=self.backfill_days
109
+ )
110
+ events: list[RawEvent] = []
111
+
112
+ headers = {
113
+ "Authorization": f"Bot {self.bot_token}",
114
+ "Content-Type": "application/json",
115
+ }
116
+
117
+ async with aiohttp.ClientSession(headers=headers) as session:
118
+ # Resolve channels to sync.
119
+ channels = await self._resolve_channels(session)
120
+
121
+ for channel in channels:
122
+ channel_id = channel["id"]
123
+ channel_type = channel.get("type", 0)
124
+
125
+ # Text channels (0), announcement (5), voice text (2 is
126
+ # voice but may have text), forum (15).
127
+ if channel_type == 15:
128
+ # Forum channel: fetch threads as documents.
129
+ thread_events = await self._fetch_forum_threads(
130
+ session, channel, cutoff
131
+ )
132
+ events.extend(thread_events)
133
+ elif channel_type in (0, 5, 11, 12):
134
+ # Regular text / announcement / public/private thread.
135
+ msg_events = await self._fetch_channel_messages(
136
+ session, channel, cutoff
137
+ )
138
+ events.extend(msg_events)
139
+
140
+ # Fetch active threads in each guild.
141
+ for guild_id in self.guild_ids:
142
+ thread_events = await self._fetch_active_threads(
143
+ session, guild_id, cutoff
144
+ )
145
+ events.extend(thread_events)
146
+
147
+ return events
148
+
149
+ def normalize(self, raw: RawEvent) -> Episode:
150
+ """Convert a Discord ``RawEvent`` into a CortexDB ``Episode``."""
151
+ data = raw.data
152
+ event_kind = data.get("_event_kind", "message")
153
+
154
+ if event_kind == "forum_post":
155
+ return self._normalize_forum_post(raw)
156
+ return self._normalize_message(raw)
157
+
158
+ def map_permissions(self, raw: RawEvent) -> Visibility:
159
+ """Map Discord channel permissions to CortexDB visibility.
160
+
161
+ DMs are ``Private``, private threads and channels with restricted
162
+ overwrites are ``Restricted``, and public channels are
163
+ ``Organization``.
164
+ """
165
+ perms = raw.permissions
166
+ channel_type = perms.get("channel_type", 0)
167
+
168
+ # DM channels.
169
+ if channel_type in (1, 3):
170
+ return Visibility(level=VisibilityLevel.PRIVATE)
171
+
172
+ # Private threads.
173
+ if channel_type == 12:
174
+ members = tuple(perms.get("thread_member_ids", []))
175
+ return Visibility(
176
+ level=VisibilityLevel.RESTRICTED,
177
+ allowed_principals=members,
178
+ )
179
+
180
+ # Channels with specific role restrictions.
181
+ if perms.get("is_restricted"):
182
+ roles = tuple(perms.get("allowed_role_ids", []))
183
+ return Visibility(
184
+ level=VisibilityLevel.RESTRICTED,
185
+ allowed_principals=roles,
186
+ )
187
+
188
+ return Visibility(level=VisibilityLevel.ORGANIZATION)
189
+
190
+ # -- message normalization -----------------------------------------------
191
+
192
+ def _normalize_message(self, raw: RawEvent) -> Episode:
193
+ """Normalize a Discord message into an Episode."""
194
+ data = raw.data
195
+ author = data.get("author", {})
196
+ actor = Actor(
197
+ id=author.get("id", "unknown"),
198
+ name=author.get("username", "Unknown"),
199
+ avatar_url=(
200
+ f"https://cdn.discordapp.com/avatars/"
201
+ f"{author.get('id', '')}/{author.get('avatar', '')}.png"
202
+ if author.get("avatar")
203
+ else None
204
+ ),
205
+ )
206
+
207
+ channel_id = data.get("channel_id", "")
208
+ channel_name = data.get("_channel_name", channel_id)
209
+ guild_id = data.get("guild_id", "")
210
+ guild_name = data.get("_guild_name", guild_id)
211
+
212
+ content = data.get("content", "")
213
+
214
+ # Append attachment descriptions.
215
+ attachments = data.get("attachments", [])
216
+ if attachments:
217
+ att_desc = ", ".join(
218
+ a.get("filename", "attachment") for a in attachments
219
+ )
220
+ content += f"\n[Attachments: {att_desc}]"
221
+
222
+ # Append embed titles.
223
+ embeds = data.get("embeds", [])
224
+ if embeds:
225
+ embed_desc = ", ".join(
226
+ e.get("title", "embed") for e in embeds if e.get("title")
227
+ )
228
+ if embed_desc:
229
+ content += f"\n[Embeds: {embed_desc}]"
230
+
231
+ entities = self._extract_message_entities(data)
232
+
233
+ # Thread context.
234
+ thread_id_val: str | None = None
235
+ parent_id_val: str | None = None
236
+ if data.get("_thread_id"):
237
+ thread_id_val = f"discord:{data['_thread_id']}"
238
+ parent_id_val = f"discord:{channel_id}"
239
+ elif data.get("message_reference"):
240
+ ref = data["message_reference"]
241
+ parent_id_val = f"discord:{ref.get('message_id', '')}"
242
+
243
+ # Reaction metadata.
244
+ reactions = data.get("reactions", [])
245
+ reaction_summary = [
246
+ {
247
+ "emoji": r.get("emoji", {}).get("name", ""),
248
+ "count": r.get("count", 0),
249
+ }
250
+ for r in reactions
251
+ ]
252
+
253
+ return Episode(
254
+ actor=actor,
255
+ source=_SOURCE,
256
+ episode_type=EpisodeType.MESSAGE,
257
+ occurred_at=raw.timestamp,
258
+ content=content,
259
+ raw_payload=data,
260
+ tenant_id=self.tenant_id,
261
+ namespace=self.namespace,
262
+ entities=entities,
263
+ tags=["discord", guild_name.lower().replace(" ", "_")],
264
+ metadata={
265
+ "message_id": data.get("id", ""),
266
+ "channel_id": channel_id,
267
+ "channel_name": channel_name,
268
+ "guild_id": guild_id,
269
+ "guild_name": guild_name,
270
+ "attachment_count": len(attachments),
271
+ "embed_count": len(embeds),
272
+ "reactions": reaction_summary,
273
+ "mention_count": len(data.get("mentions", [])),
274
+ "pinned": data.get("pinned", False),
275
+ },
276
+ thread_id=thread_id_val,
277
+ parent_id=parent_id_val,
278
+ idempotency_key=f"discord:{data.get('id', raw.external_id)}",
279
+ )
280
+
281
+ def _normalize_forum_post(self, raw: RawEvent) -> Episode:
282
+ """Normalize a Discord forum post (thread starter) into an Episode."""
283
+ data = raw.data
284
+ author = data.get("author", {})
285
+ actor = Actor(
286
+ id=author.get("id", "unknown"),
287
+ name=author.get("username", "Unknown"),
288
+ avatar_url=(
289
+ f"https://cdn.discordapp.com/avatars/"
290
+ f"{author.get('id', '')}/{author.get('avatar', '')}.png"
291
+ if author.get("avatar")
292
+ else None
293
+ ),
294
+ )
295
+
296
+ thread_name = data.get("_thread_name", "")
297
+ content = data.get("content", "")
298
+ if thread_name:
299
+ content = f"[{thread_name}]\n\n{content}"
300
+
301
+ entities = self._extract_message_entities(data)
302
+
303
+ return Episode(
304
+ actor=actor,
305
+ source=_SOURCE,
306
+ episode_type=EpisodeType.DOCUMENT,
307
+ occurred_at=raw.timestamp,
308
+ content=content,
309
+ raw_payload=data,
310
+ tenant_id=self.tenant_id,
311
+ namespace=self.namespace,
312
+ entities=entities,
313
+ tags=["discord", "forum_post"],
314
+ metadata={
315
+ "message_id": data.get("id", ""),
316
+ "thread_id": data.get("_thread_id", ""),
317
+ "thread_name": thread_name,
318
+ "channel_id": data.get("channel_id", ""),
319
+ "guild_id": data.get("guild_id", ""),
320
+ "applied_tags": data.get("_applied_tags", []),
321
+ },
322
+ thread_id=f"discord:{data.get('_thread_id', '')}",
323
+ idempotency_key=f"discord:forum:{raw.external_id}",
324
+ )
325
+
326
+ # -- fetchers ------------------------------------------------------------
327
+
328
+ async def _api_get(
329
+ self, session: Any, path: str, params: dict[str, Any] | None = None
330
+ ) -> Any:
331
+ """Make a GET request to the Discord API with rate-limit handling."""
332
+ url = f"{_API_BASE}{path}"
333
+
334
+ for attempt in range(5):
335
+ async with session.get(url, params=params) as resp:
336
+ if resp.status == 429:
337
+ retry_data = await resp.json()
338
+ retry_after = retry_data.get("retry_after", 1.0)
339
+ logger.warning(
340
+ "Discord rate-limited on %s; sleeping %.1f s",
341
+ path,
342
+ retry_after,
343
+ )
344
+ await asyncio.sleep(retry_after)
345
+ continue
346
+
347
+ if resp.status == 404:
348
+ logger.debug("Discord 404 for %s", path)
349
+ return None
350
+
351
+ if resp.status == 403:
352
+ logger.warning("Discord 403 forbidden for %s", path)
353
+ return None
354
+
355
+ resp.raise_for_status()
356
+ await asyncio.sleep(_RATE_LIMIT_DELAY)
357
+ return await resp.json()
358
+
359
+ logger.error("Exhausted retries for %s", path)
360
+ return None
361
+
362
+ async def _resolve_channels(
363
+ self, session: Any
364
+ ) -> list[dict[str, Any]]:
365
+ """Resolve the list of channels to sync."""
366
+ if self.channel_ids:
367
+ channels: list[dict[str, Any]] = []
368
+ for cid in self.channel_ids:
369
+ ch = await self._api_get(session, f"/channels/{cid}")
370
+ if ch:
371
+ self._channel_cache[cid] = ch
372
+ channels.append(ch)
373
+ return channels
374
+
375
+ # Discover text channels from configured guilds.
376
+ channels = []
377
+ for guild_id in self.guild_ids:
378
+ guild = await self._api_get(session, f"/guilds/{guild_id}")
379
+ if guild:
380
+ self._guild_cache[guild_id] = guild
381
+
382
+ guild_channels = await self._api_get(
383
+ session, f"/guilds/{guild_id}/channels"
384
+ )
385
+ if not guild_channels:
386
+ continue
387
+
388
+ for ch in guild_channels:
389
+ ch_type = ch.get("type", 0)
390
+ # 0=text, 5=announcement, 15=forum.
391
+ if ch_type in (0, 5, 15):
392
+ self._channel_cache[ch["id"]] = ch
393
+ channels.append(ch)
394
+
395
+ return channels
396
+
397
+ async def _fetch_channel_messages(
398
+ self,
399
+ session: Any,
400
+ channel: dict[str, Any],
401
+ cutoff: datetime,
402
+ ) -> list[RawEvent]:
403
+ """Fetch messages from a text channel newer than *cutoff*."""
404
+ channel_id = channel["id"]
405
+ channel_name = channel.get("name", channel_id)
406
+ guild_id = channel.get("guild_id", "")
407
+ guild_name = self._guild_cache.get(guild_id, {}).get(
408
+ "name", guild_id
409
+ )
410
+ channel_type = channel.get("type", 0)
411
+
412
+ events: list[RawEvent] = []
413
+ # Convert cutoff to a Discord snowflake for efficient pagination.
414
+ cutoff_snowflake = self._datetime_to_snowflake(cutoff)
415
+ before: str | None = None
416
+
417
+ while True:
418
+ params: dict[str, Any] = {"limit": _MESSAGE_LIMIT}
419
+ if before:
420
+ params["before"] = before
421
+ else:
422
+ # Start from the newest message.
423
+ pass
424
+
425
+ messages = await self._api_get(
426
+ session, f"/channels/{channel_id}/messages", params=params
427
+ )
428
+ if not messages:
429
+ break
430
+
431
+ reached_cutoff = False
432
+ for msg in messages:
433
+ msg_id = msg.get("id", "")
434
+
435
+ # Check if we've gone past the cutoff.
436
+ if int(msg_id) < cutoff_snowflake:
437
+ reached_cutoff = True
438
+ break
439
+
440
+ # Skip bot messages unless configured to include them.
441
+ author = msg.get("author", {})
442
+ if author.get("bot") and not self.include_bots:
443
+ continue
444
+
445
+ timestamp = self._parse_discord_timestamp(
446
+ msg.get("timestamp", "")
447
+ )
448
+
449
+ events.append(
450
+ RawEvent(
451
+ source="discord",
452
+ external_id=msg_id,
453
+ timestamp=timestamp,
454
+ data={
455
+ **msg,
456
+ "_channel_name": channel_name,
457
+ "_guild_name": guild_name,
458
+ },
459
+ permissions={
460
+ "channel_type": channel_type,
461
+ "guild_id": guild_id,
462
+ "is_restricted": False,
463
+ },
464
+ )
465
+ )
466
+
467
+ if reached_cutoff or len(messages) < _MESSAGE_LIMIT:
468
+ break
469
+
470
+ before = messages[-1]["id"]
471
+
472
+ return events
473
+
474
+ async def _fetch_forum_threads(
475
+ self,
476
+ session: Any,
477
+ channel: dict[str, Any],
478
+ cutoff: datetime,
479
+ ) -> list[RawEvent]:
480
+ """Fetch forum threads and their starter messages."""
481
+ channel_id = channel["id"]
482
+ guild_id = channel.get("guild_id", "")
483
+ events: list[RawEvent] = []
484
+
485
+ # Fetch archived threads.
486
+ for archive_type in ("public", "private"):
487
+ path = (
488
+ f"/channels/{channel_id}/threads/archived/{archive_type}"
489
+ )
490
+ data = await self._api_get(session, path)
491
+ if not data:
492
+ continue
493
+
494
+ for thread in data.get("threads", []):
495
+ thread_events = await self._ingest_thread(
496
+ session, thread, channel, cutoff, is_forum=True
497
+ )
498
+ events.extend(thread_events)
499
+
500
+ return events
501
+
502
+ async def _fetch_active_threads(
503
+ self, session: Any, guild_id: str, cutoff: datetime
504
+ ) -> list[RawEvent]:
505
+ """Fetch active threads in a guild."""
506
+ events: list[RawEvent] = []
507
+ data = await self._api_get(
508
+ session, f"/guilds/{guild_id}/threads/active"
509
+ )
510
+ if not data:
511
+ return events
512
+
513
+ for thread in data.get("threads", []):
514
+ parent_id = thread.get("parent_id", "")
515
+ parent_channel = self._channel_cache.get(parent_id, {})
516
+ thread_events = await self._ingest_thread(
517
+ session,
518
+ thread,
519
+ parent_channel or {"id": parent_id, "guild_id": guild_id},
520
+ cutoff,
521
+ is_forum=(parent_channel.get("type") == 15),
522
+ )
523
+ events.extend(thread_events)
524
+
525
+ return events
526
+
527
+ async def _ingest_thread(
528
+ self,
529
+ session: Any,
530
+ thread: dict[str, Any],
531
+ parent_channel: dict[str, Any],
532
+ cutoff: datetime,
533
+ is_forum: bool = False,
534
+ ) -> list[RawEvent]:
535
+ """Fetch messages inside a thread / forum post."""
536
+ thread_id = thread["id"]
537
+ thread_name = thread.get("name", "")
538
+ guild_id = parent_channel.get("guild_id", "")
539
+ guild_name = self._guild_cache.get(guild_id, {}).get(
540
+ "name", guild_id
541
+ )
542
+ channel_type = thread.get("type", 11)
543
+ applied_tags = thread.get("applied_tags", [])
544
+
545
+ fake_channel = {
546
+ "id": thread_id,
547
+ "name": thread_name,
548
+ "guild_id": guild_id,
549
+ "type": channel_type,
550
+ }
551
+ events = await self._fetch_channel_messages(
552
+ session, fake_channel, cutoff
553
+ )
554
+
555
+ # Mark events with thread context.
556
+ enriched: list[RawEvent] = []
557
+ for raw in events:
558
+ raw.data["_thread_id"] = thread_id
559
+ raw.data["_thread_name"] = thread_name
560
+ raw.data["_guild_name"] = guild_name
561
+ raw.data["_applied_tags"] = applied_tags
562
+
563
+ # Mark the first message of a forum thread as a forum post.
564
+ if is_forum and raw.external_id == thread_id:
565
+ raw.data["_event_kind"] = "forum_post"
566
+ enriched.append(raw)
567
+
568
+ return enriched
569
+
570
+ # -- helpers -------------------------------------------------------------
571
+
572
+ @staticmethod
573
+ def _parse_discord_timestamp(ts: str) -> datetime:
574
+ """Parse an ISO-8601 timestamp from the Discord API."""
575
+ if not ts:
576
+ return datetime.now(timezone.utc)
577
+ try:
578
+ return datetime.fromisoformat(ts.replace("Z", "+00:00"))
579
+ except (ValueError, TypeError):
580
+ return datetime.now(timezone.utc)
581
+
582
+ @staticmethod
583
+ def _datetime_to_snowflake(dt: datetime) -> int:
584
+ """Convert a datetime to a Discord snowflake ID for pagination."""
585
+ ms = int(dt.timestamp() * 1000) - _DISCORD_EPOCH
586
+ if ms < 0:
587
+ ms = 0
588
+ return ms << 22
589
+
590
+ @staticmethod
591
+ def _extract_message_entities(data: dict[str, Any]) -> list[EntityRef]:
592
+ """Extract entity references from a Discord message."""
593
+ entities: list[EntityRef] = []
594
+
595
+ guild_id = data.get("guild_id", "")
596
+ if guild_id:
597
+ entities.append(
598
+ EntityRef(
599
+ entity_type="guild",
600
+ entity_id=guild_id,
601
+ display_name=data.get("_guild_name", guild_id),
602
+ )
603
+ )
604
+
605
+ channel_id = data.get("channel_id", "")
606
+ if channel_id:
607
+ entities.append(
608
+ EntityRef(
609
+ entity_type="channel",
610
+ entity_id=channel_id,
611
+ display_name=data.get("_channel_name", channel_id),
612
+ )
613
+ )
614
+
615
+ author = data.get("author", {})
616
+ if author.get("id"):
617
+ entities.append(
618
+ EntityRef(
619
+ entity_type="user",
620
+ entity_id=author["id"],
621
+ display_name=author.get("username", ""),
622
+ )
623
+ )
624
+
625
+ # Mentioned users.
626
+ for mention in data.get("mentions", []):
627
+ if mention.get("id") and mention["id"] != author.get("id"):
628
+ entities.append(
629
+ EntityRef(
630
+ entity_type="user",
631
+ entity_id=mention["id"],
632
+ display_name=mention.get("username", ""),
633
+ )
634
+ )
635
+
636
+ # Mentioned roles.
637
+ for role_id in data.get("mention_roles", []):
638
+ entities.append(
639
+ EntityRef(
640
+ entity_type="role",
641
+ entity_id=str(role_id),
642
+ display_name="",
643
+ )
644
+ )
645
+
646
+ return entities