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.
- cortexdb_connectors/__init__.py +40 -0
- cortexdb_connectors/base.py +364 -0
- cortexdb_connectors/cli.py +476 -0
- cortexdb_connectors/confluence/__init__.py +359 -0
- cortexdb_connectors/discord/__init__.py +646 -0
- cortexdb_connectors/github/__init__.py +309 -0
- cortexdb_connectors/gitlab/__init__.py +749 -0
- cortexdb_connectors/google_workspace/__init__.py +1033 -0
- cortexdb_connectors/hubspot/__init__.py +489 -0
- cortexdb_connectors/insights/__init__.py +40 -0
- cortexdb_connectors/insights/api.py +252 -0
- cortexdb_connectors/insights/detectors.py +771 -0
- cortexdb_connectors/insights/engine.py +308 -0
- cortexdb_connectors/intercom/__init__.py +631 -0
- cortexdb_connectors/jira/__init__.py +298 -0
- cortexdb_connectors/linear/__init__.py +705 -0
- cortexdb_connectors/notion/__init__.py +702 -0
- cortexdb_connectors/pagerduty/__init__.py +298 -0
- cortexdb_connectors/salesforce/__init__.py +399 -0
- cortexdb_connectors/servicenow/__init__.py +460 -0
- cortexdb_connectors/slack/__init__.py +331 -0
- cortexdb_connectors/state.py +145 -0
- cortexdb_connectors/teams/__init__.py +656 -0
- cortexdb_connectors/webhooks.py +672 -0
- cortexdb_connectors/worker.py +192 -0
- cortexdb_connectors/zendesk/__init__.py +496 -0
- cortexdb_connectors-0.2.0.dist-info/METADATA +207 -0
- cortexdb_connectors-0.2.0.dist-info/RECORD +31 -0
- cortexdb_connectors-0.2.0.dist-info/WHEEL +5 -0
- cortexdb_connectors-0.2.0.dist-info/entry_points.txt +2 -0
- cortexdb_connectors-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|