@helyx/bot 0.4.44 → 0.5.0

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,337 @@
1
+ import { ChannelType, GatewayIntentBits, PermissionFlagsBits, } from "discord.js";
2
+ const MAX_MESSAGE_PAGE = 100;
3
+ const MAX_MEMBER_PAGE = 1_000;
4
+ const MAX_THREAD_PAGE = 100;
5
+ export class DiscordAnalyticsInspection {
6
+ client;
7
+ constructor(client) {
8
+ this.client = client;
9
+ }
10
+ async listChannels(input) {
11
+ const guild = this.client.guilds.cache.get(input.guildId);
12
+ if (!guild)
13
+ return unavailable("discord_unavailable");
14
+ try {
15
+ const [channels, selectedRole] = await Promise.all([
16
+ guild.channels.fetch(),
17
+ input.roleId
18
+ ? guild.roles.fetch(input.roleId).catch(() => null)
19
+ : Promise.resolve(null),
20
+ ]);
21
+ if (input.roleId && !selectedRole)
22
+ return unavailable("missing_permission");
23
+ const items = [];
24
+ for (const channel of channels.values()) {
25
+ if (!channel)
26
+ continue;
27
+ const type = analyticsChannelType(channel);
28
+ if (!type)
29
+ continue;
30
+ const botPermissions = guild.members.me
31
+ ? channel.permissionsFor(guild.members.me)
32
+ : null;
33
+ const rolePermissions = selectedRole
34
+ ? channel.permissionsFor(selectedRole)
35
+ : null;
36
+ const threadLike = type === "forum" || type === "media";
37
+ items.push({
38
+ channelId: channel.id,
39
+ type,
40
+ parentId: channel.parentId,
41
+ botCanView: botPermissions?.has(PermissionFlagsBits.ViewChannel) ?? false,
42
+ botCanReadHistory: botPermissions?.has(PermissionFlagsBits.ReadMessageHistory) ??
43
+ false,
44
+ selectedRoleCanView: selectedRole
45
+ ? (rolePermissions?.has(PermissionFlagsBits.ViewChannel) ?? false)
46
+ : null,
47
+ selectedRoleCanWrite: selectedRole
48
+ ? (rolePermissions?.has(threadLike
49
+ ? PermissionFlagsBits.SendMessagesInThreads
50
+ : PermissionFlagsBits.SendMessages) ?? false)
51
+ : null,
52
+ });
53
+ }
54
+ return {
55
+ available: true,
56
+ items: items.sort((left, right) => left.channelId.localeCompare(right.channelId)),
57
+ };
58
+ }
59
+ catch {
60
+ return unavailable("discord_unavailable");
61
+ }
62
+ }
63
+ async listMessages(input) {
64
+ const limit = boundedLimit(input.limit, MAX_MESSAGE_PAGE);
65
+ if (!limit)
66
+ return unavailable("not_supported");
67
+ const guild = this.client.guilds.cache.get(input.guildId);
68
+ if (!guild)
69
+ return unavailable("discord_unavailable");
70
+ try {
71
+ const channel = await guild.channels.fetch(input.channelId);
72
+ if (!channel?.isTextBased() || !("messages" in channel))
73
+ return unavailable("not_supported");
74
+ const permissions = guild.members.me
75
+ ? channel.permissionsFor(guild.members.me)
76
+ : null;
77
+ if (!permissions?.has(PermissionFlagsBits.ViewChannel))
78
+ return unavailable("missing_permission");
79
+ if (!permissions.has(PermissionFlagsBits.ReadMessageHistory))
80
+ return unavailable("missing_permission");
81
+ const messages = await channel.messages.fetch({
82
+ limit,
83
+ ...(input.before ? { before: input.before } : {}),
84
+ });
85
+ const items = [...messages.values()].map((message) => ({
86
+ messageId: message.id,
87
+ createdAt: message.createdAt,
88
+ authorUserId: message.author?.id ?? null,
89
+ authorIsBot: message.author?.bot ?? false,
90
+ webhook: message.webhookId !== null,
91
+ system: message.system,
92
+ currentRoleIds: message.member
93
+ ? [...message.member.roles.cache.keys()].sort()
94
+ : null,
95
+ }));
96
+ return {
97
+ available: true,
98
+ items,
99
+ ...(items.length === limit
100
+ ? { nextBefore: items.at(-1).messageId }
101
+ : {}),
102
+ };
103
+ }
104
+ catch {
105
+ return unavailable("discord_unavailable");
106
+ }
107
+ }
108
+ async listMembers(input) {
109
+ const limit = boundedLimit(input.limit, MAX_MEMBER_PAGE);
110
+ if (!limit)
111
+ return unavailable("not_supported");
112
+ if (!this.client.options.intents.has(GatewayIntentBits.GuildMembers))
113
+ return unavailable("missing_intent");
114
+ const guild = this.client.guilds.cache.get(input.guildId);
115
+ if (!guild)
116
+ return unavailable("discord_unavailable");
117
+ try {
118
+ const members = await guild.members.list({
119
+ limit,
120
+ ...(input.after ? { after: input.after } : {}),
121
+ });
122
+ const items = [...members.values()].map((member) => ({
123
+ userId: member.id,
124
+ joinedAt: member.joinedAt,
125
+ accountCreatedAt: member.user.createdAt,
126
+ isBot: member.user.bot,
127
+ roleIds: [...member.roles.cache.keys()].sort(),
128
+ }));
129
+ return {
130
+ available: true,
131
+ items,
132
+ ...(items.length === limit ? { nextAfter: items.at(-1).userId } : {}),
133
+ };
134
+ }
135
+ catch {
136
+ return unavailable("discord_unavailable");
137
+ }
138
+ }
139
+ async listThreads(input) {
140
+ const limit = boundedLimit(input.limit, MAX_THREAD_PAGE);
141
+ if (!limit)
142
+ return unavailable("not_supported");
143
+ const guild = this.client.guilds.cache.get(input.guildId);
144
+ if (!guild)
145
+ return unavailable("discord_unavailable");
146
+ const cursor = decodeThreadCursor(input.cursor);
147
+ if (input.cursor && !cursor)
148
+ return unavailable("not_supported");
149
+ try {
150
+ const parents = await threadParents(guild, input.parentChannelId);
151
+ const privateCoverage = privateThreadCoverage(guild);
152
+ const current = cursor ?? { phase: "active", offset: 0 };
153
+ if (current.phase === "active") {
154
+ const active = await guild.channels.fetchActiveThreads(false);
155
+ const facts = [...active.threads.values()]
156
+ .filter((thread) => !input.parentChannelId ||
157
+ thread.parentId === input.parentChannelId)
158
+ .sort((left, right) => left.id.localeCompare(right.id))
159
+ .map((thread) => threadFact(guild, thread));
160
+ const items = facts.slice(current.offset, current.offset + limit);
161
+ const nextOffset = current.offset + items.length;
162
+ const nextCursor = nextOffset < facts.length
163
+ ? encodeThreadCursor({ phase: "active", offset: nextOffset })
164
+ : input.includeArchived
165
+ ? encodeThreadCursor({ phase: "public", parentIndex: 0 })
166
+ : undefined;
167
+ return {
168
+ available: true,
169
+ items,
170
+ ...(nextCursor ? { nextCursor } : {}),
171
+ privateCoverage,
172
+ };
173
+ }
174
+ const phases = input.includePrivate
175
+ ? ["public", "private"]
176
+ : ["public"];
177
+ let phaseIndex = phases.indexOf(current.phase);
178
+ if (phaseIndex < 0)
179
+ return unavailable("not_supported");
180
+ let parentIndex = current.parentIndex;
181
+ let before = current.before;
182
+ while (phaseIndex < phases.length) {
183
+ const phase = phases[phaseIndex];
184
+ while (parentIndex < parents.length) {
185
+ const parent = parents[parentIndex];
186
+ const page = await parent.threads.fetchArchived({
187
+ type: phase,
188
+ fetchAll: phase === "private" && privateCoverage === "all",
189
+ limit,
190
+ ...(before ? { before } : {}),
191
+ }, false);
192
+ const threads = [...page.threads.values()].sort((left, right) => left.id.localeCompare(right.id));
193
+ const items = threads.map((thread) => threadFact(guild, thread));
194
+ if (page.hasMore && threads.length > 0) {
195
+ const oldestArchive = threads.reduce((oldest, thread) => thread.archivedAt &&
196
+ (!oldest || thread.archivedAt.getTime() < oldest.getTime())
197
+ ? thread.archivedAt
198
+ : oldest, null);
199
+ return {
200
+ available: true,
201
+ items,
202
+ nextCursor: encodeThreadCursor({
203
+ phase,
204
+ parentIndex,
205
+ before: oldestArchive?.toISOString() ?? threads.at(0).id,
206
+ }),
207
+ privateCoverage,
208
+ };
209
+ }
210
+ parentIndex += 1;
211
+ before = undefined;
212
+ if (items.length > 0) {
213
+ const next = nextArchiveCursor(phases, phaseIndex, parentIndex, parents.length);
214
+ return {
215
+ available: true,
216
+ items,
217
+ ...(next ? { nextCursor: encodeThreadCursor(next) } : {}),
218
+ privateCoverage,
219
+ };
220
+ }
221
+ }
222
+ phaseIndex += 1;
223
+ parentIndex = 0;
224
+ }
225
+ return { available: true, items: [], privateCoverage };
226
+ }
227
+ catch {
228
+ return unavailable("discord_unavailable");
229
+ }
230
+ }
231
+ }
232
+ function analyticsChannelType(channel) {
233
+ switch (channel.type) {
234
+ case ChannelType.GuildCategory:
235
+ return "category";
236
+ case ChannelType.GuildText:
237
+ return "text";
238
+ case ChannelType.GuildAnnouncement:
239
+ return "announcement";
240
+ case ChannelType.GuildForum:
241
+ return "forum";
242
+ case ChannelType.GuildMedia:
243
+ return "media";
244
+ default:
245
+ return null;
246
+ }
247
+ }
248
+ async function threadParents(guild, parentChannelId) {
249
+ const channels = await guild.channels.fetch();
250
+ return [...channels.values()]
251
+ .filter((channel) => channel !== null &&
252
+ "threads" in channel &&
253
+ (!parentChannelId || channel.id === parentChannelId))
254
+ .sort((left, right) => left.id.localeCompare(right.id));
255
+ }
256
+ function threadFact(guild, thread) {
257
+ const permissions = guild.members.me
258
+ ? thread.permissionsFor(guild.members.me)
259
+ : null;
260
+ return {
261
+ threadId: thread.id,
262
+ parentChannelId: thread.parentId ?? "",
263
+ type: thread.type === ChannelType.PrivateThread
264
+ ? "private"
265
+ : thread.type === ChannelType.AnnouncementThread
266
+ ? "announcement"
267
+ : "public",
268
+ ownerUserId: thread.ownerId,
269
+ createdAt: thread.createdAt ?? new Date(snowflakeTimestamp(thread.id)),
270
+ lastActivityAt: thread.lastMessageId
271
+ ? new Date(snowflakeTimestamp(thread.lastMessageId))
272
+ : null,
273
+ archived: thread.archived ?? false,
274
+ locked: thread.locked ?? false,
275
+ messageCount: thread.messageCount ?? 0,
276
+ totalMessageSent: thread.totalMessageSent ?? 0,
277
+ appliedTagIds: "appliedTags" in thread ? [...thread.appliedTags] : [],
278
+ botCanView: permissions?.has(PermissionFlagsBits.ViewChannel) ?? false,
279
+ botCanReadHistory: permissions?.has(PermissionFlagsBits.ReadMessageHistory) ?? false,
280
+ };
281
+ }
282
+ function privateThreadCoverage(guild) {
283
+ const member = guild.members.me;
284
+ if (!member)
285
+ return "none";
286
+ return member.permissions.has(PermissionFlagsBits.ManageThreads)
287
+ ? "all"
288
+ : "joined_only";
289
+ }
290
+ function nextArchiveCursor(phases, phaseIndex, parentIndex, parentCount) {
291
+ if (parentIndex < parentCount)
292
+ return { phase: phases[phaseIndex], parentIndex };
293
+ const nextPhase = phases[phaseIndex + 1];
294
+ return nextPhase ? { phase: nextPhase, parentIndex: 0 } : undefined;
295
+ }
296
+ function encodeThreadCursor(cursor) {
297
+ return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
298
+ }
299
+ function decodeThreadCursor(value) {
300
+ if (!value)
301
+ return null;
302
+ try {
303
+ const candidate = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
304
+ if (candidate.phase === "active")
305
+ return Number.isSafeInteger(candidate.offset) && candidate.offset >= 0
306
+ ? { phase: "active", offset: candidate.offset }
307
+ : null;
308
+ if (candidate.phase === "public" || candidate.phase === "private")
309
+ return Number.isSafeInteger(candidate.parentIndex) &&
310
+ candidate.parentIndex >= 0 &&
311
+ (candidate.before === undefined ||
312
+ (typeof candidate.before === "string" &&
313
+ candidate.before.length <= 100))
314
+ ? {
315
+ phase: candidate.phase,
316
+ parentIndex: candidate.parentIndex,
317
+ ...(candidate.before ? { before: candidate.before } : {}),
318
+ }
319
+ : null;
320
+ return null;
321
+ }
322
+ catch {
323
+ return null;
324
+ }
325
+ }
326
+ function boundedLimit(value, maximum) {
327
+ return Number.isSafeInteger(value) && value >= 1 && value <= maximum
328
+ ? value
329
+ : null;
330
+ }
331
+ function snowflakeTimestamp(id) {
332
+ return Number((BigInt(id) >> 22n) + 1420070400000n);
333
+ }
334
+ function unavailable(code) {
335
+ return { available: false, code };
336
+ }
337
+ //# sourceMappingURL=discord-analytics.js.map
@@ -0,0 +1,24 @@
1
+ import type { DiscordInviteInspectionService, DiscordInviteSnapshot, DiscordInviteUnavailable } from "@helyx/sdk";
2
+ import { type Client } from "discord.js";
3
+ export declare class DiscordInviteInspection implements DiscordInviteInspectionService {
4
+ #private;
5
+ private readonly client;
6
+ constructor(client: Client, fingerprintKey: Uint8Array);
7
+ continuityGeneration(input: {
8
+ guildId: string;
9
+ }): string;
10
+ invalidateContinuity(input: {
11
+ guildId: string;
12
+ reason: "event_queue_gap" | "gateway_session_gap" | "ownership_gap";
13
+ }): void;
14
+ fingerprintCode(input: {
15
+ guildId: string;
16
+ code: string;
17
+ }): Promise<{
18
+ sourceKey: string;
19
+ }>;
20
+ captureSnapshot(input: {
21
+ guildId: string;
22
+ }): Promise<DiscordInviteSnapshot | DiscordInviteUnavailable>;
23
+ }
24
+ //# sourceMappingURL=discord-invites.d.ts.map
@@ -0,0 +1,91 @@
1
+ import { PermissionFlagsBits } from "discord.js";
2
+ import { createHmac, randomUUID } from "node:crypto";
3
+ const VANITY_FEATURE = "VANITY_URL";
4
+ export class DiscordInviteInspection {
5
+ client;
6
+ #processGeneration = randomUUID();
7
+ #guildGenerations = new Map();
8
+ #fingerprintKey;
9
+ constructor(client, fingerprintKey) {
10
+ this.client = client;
11
+ if (fingerprintKey.byteLength !== 32)
12
+ throw new Error("Discord invite fingerprint key must contain 32 bytes");
13
+ this.#fingerprintKey = Buffer.from(fingerprintKey);
14
+ }
15
+ continuityGeneration(input) {
16
+ const readyGeneration = this.client.readyTimestamp ?? 0;
17
+ const localGeneration = this.#guildGenerations.get(input.guildId) ?? 0;
18
+ return `${this.#processGeneration}:${readyGeneration}:${localGeneration}`;
19
+ }
20
+ invalidateContinuity(input) {
21
+ void input.reason;
22
+ this.#guildGenerations.set(input.guildId, (this.#guildGenerations.get(input.guildId) ?? 0) + 1);
23
+ }
24
+ fingerprintCode(input) {
25
+ const code = input.code.trim();
26
+ if (!/^\d{17,20}$/u.test(input.guildId))
27
+ return Promise.reject(new Error("Discord server ID is invalid"));
28
+ if (code.length < 1 || code.length > 128)
29
+ return Promise.reject(new Error("Discord invite code is invalid"));
30
+ return Promise.resolve({
31
+ sourceKey: this.#fingerprint(input.guildId, code),
32
+ });
33
+ }
34
+ async captureSnapshot(input) {
35
+ const guild = this.client.guilds.cache.get(input.guildId);
36
+ if (!guild)
37
+ return unavailable("discord_unavailable");
38
+ if (!hasManageGuild(guild))
39
+ return unavailable("missing_permission");
40
+ try {
41
+ const [invites, vanity] = await Promise.all([
42
+ guild.invites.fetch(),
43
+ guild.features.includes(VANITY_FEATURE)
44
+ ? guild.fetchVanityData().catch(() => null)
45
+ : Promise.resolve(null),
46
+ ]);
47
+ const observedAt = new Date();
48
+ return {
49
+ available: true,
50
+ observedAt,
51
+ continuityGeneration: this.continuityGeneration(input),
52
+ invites: [...invites.values()]
53
+ .map((invite) => ({
54
+ sourceKey: this.#fingerprint(guild.id, invite.code),
55
+ channelId: invite.channelId,
56
+ inviterUserId: invite.inviterId,
57
+ createdAt: invite.createdAt,
58
+ expiresAt: invite.expiresAt,
59
+ maxUses: invite.maxUses ?? 0,
60
+ uses: invite.uses ?? 0,
61
+ temporary: invite.temporary ?? false,
62
+ }))
63
+ .sort((left, right) => left.sourceKey.localeCompare(right.sourceKey)),
64
+ vanity: vanity?.code && typeof vanity.uses === "number"
65
+ ? {
66
+ sourceKey: this.#fingerprint(guild.id, vanity.code),
67
+ uses: vanity.uses,
68
+ }
69
+ : null,
70
+ };
71
+ }
72
+ catch {
73
+ return unavailable("discord_unavailable");
74
+ }
75
+ }
76
+ #fingerprint(guildId, code) {
77
+ return createHmac("sha256", this.#fingerprintKey)
78
+ .update("helyx.discord-invite-source.v1\0", "utf8")
79
+ .update(guildId, "utf8")
80
+ .update("\0", "utf8")
81
+ .update(code, "utf8")
82
+ .digest("base64url");
83
+ }
84
+ }
85
+ function hasManageGuild(guild) {
86
+ return (guild.members.me?.permissions.has(PermissionFlagsBits.ManageGuild) ?? false);
87
+ }
88
+ function unavailable(code) {
89
+ return { available: false, code };
90
+ }
91
+ //# sourceMappingURL=discord-invites.js.map
package/dist/events.js CHANGED
@@ -122,6 +122,28 @@ export function installModuleEvents(client, modules, logger, services) {
122
122
  const guildMessageQueue = guildMessageContributions.length > 0
123
123
  ? new BoundedGuildEventQueue(logger)
124
124
  : null;
125
+ const inviteInspection = memberAddContributions.some(({ moduleId }) => moduleId === "helyx.invite-tracking") &&
126
+ typeof services.has === "function" &&
127
+ services.has(HELYX_SERVICE_NAMES.discordInvites)
128
+ ? services.get(HELYX_SERVICE_NAMES.discordInvites)
129
+ : null;
130
+ if (inviteInspection) {
131
+ client.on(Events.ShardDisconnect, (_event, shardId) => {
132
+ for (const guild of client.guilds.cache.values())
133
+ if (guild.shardId === shardId)
134
+ inviteInspection.invalidateContinuity({
135
+ guildId: guild.id,
136
+ reason: "gateway_session_gap",
137
+ });
138
+ });
139
+ client.on(Events.Invalidated, () => {
140
+ for (const guildId of client.guilds.cache.keys())
141
+ inviteInspection.invalidateContinuity({
142
+ guildId,
143
+ reason: "gateway_session_gap",
144
+ });
145
+ });
146
+ }
125
147
  if (privilegedMessageContributions.length > 0) {
126
148
  client.on(Events.MessageCreate, (message) => {
127
149
  if (!message.guildId || !privilegedEligibilityQueue)
@@ -231,7 +253,12 @@ export function installModuleEvents(client, modules, logger, services) {
231
253
  }
232
254
  if (memberAddContributions.length > 0) {
233
255
  client.on(Events.GuildMemberAdd, (member) => {
234
- queue.enqueue(member.guild.id, () => handleMemberAdd(member));
256
+ const accepted = queue.enqueue(member.guild.id, () => handleMemberAdd(member));
257
+ if (!accepted && inviteInspection)
258
+ inviteInspection.invalidateContinuity({
259
+ guildId: member.guild.id,
260
+ reason: "event_queue_gap",
261
+ });
235
262
  });
236
263
  }
237
264
  if (memberRemoveContributions.length > 0) {
@@ -343,6 +370,7 @@ export function installModuleEvents(client, modules, logger, services) {
343
370
  const authorDisplayName = message.member?.displayName ??
344
371
  message.author.globalName ??
345
372
  message.author.username;
373
+ const mentionedUserIds = [...message.mentions.users.keys()].slice(0, 25);
346
374
  let classifiedMessage;
347
375
  for (const { moduleId, classificationRequested, contribution, } of guildMessageContributions) {
348
376
  try {
@@ -362,6 +390,7 @@ export function installModuleEvents(client, modules, logger, services) {
362
390
  authorIsBot: false,
363
391
  authorIsWebhook: false,
364
392
  authorIsSystem: false,
393
+ mentionedUserIds,
365
394
  get classification() {
366
395
  if (!classificationRequested || !messageClassificationAvailable)
367
396
  return unavailableGuildMessageClassification;
@@ -485,9 +514,26 @@ export function installModuleEvents(client, modules, logger, services) {
485
514
  if (!member.joinedAt)
486
515
  throw new Error("Discord member join timestamp is unavailable");
487
516
  const installations = availabilityAwareInstallations(services);
517
+ const continuityGeneration = inviteInspection
518
+ ? inviteInspection.continuityGeneration({ guildId: member.guild.id })
519
+ : `runtime:${client.readyTimestamp ?? 0}`;
520
+ const occurrenceId = createHash("sha256")
521
+ .update("helyx.guild-member-add.v1\0", "utf8")
522
+ .update(member.guild.id, "utf8")
523
+ .update("\0", "utf8")
524
+ .update(member.id, "utf8")
525
+ .update("\0", "utf8")
526
+ .update(member.joinedAt.toISOString(), "utf8")
527
+ .digest("base64url");
488
528
  for (const { moduleId, contribution } of memberAddContributions) {
489
529
  try {
490
530
  if (!(await installations.isModuleEnabled(member.guild.id, moduleId))) {
531
+ if (moduleId === "helyx.invite-tracking" && inviteInspection) {
532
+ inviteInspection.invalidateContinuity({
533
+ guildId: member.guild.id,
534
+ reason: "ownership_gap",
535
+ });
536
+ }
491
537
  continue;
492
538
  }
493
539
  await contribution.execute({
@@ -498,6 +544,8 @@ export function installModuleEvents(client, modules, logger, services) {
498
544
  memberIsBot: member.user.bot,
499
545
  accountCreatedAt: member.user.createdAt,
500
546
  joinedAt: member.joinedAt,
547
+ occurrenceId,
548
+ continuityGeneration,
501
549
  services,
502
550
  assignRole: (roleId) => assignMemberRole(member, roleId),
503
551
  sendToChannel: (channelId, response) => sendToGuildChannel(member, channelId, response),
@@ -523,7 +571,9 @@ export function installModuleEvents(client, modules, logger, services) {
523
571
  }
524
572
  await contribution.execute({
525
573
  serverId: member.guild.id,
574
+ serverName: member.guild.name,
526
575
  memberId: member.id,
576
+ memberDisplayName: member.displayName,
527
577
  memberIsBot: member.user.bot,
528
578
  accountCreatedAt: member.user.createdAt,
529
579
  joinedAt: member.joinedAt,
@@ -818,10 +868,11 @@ class BoundedGuildEventQueue {
818
868
  enqueue(guildId, run) {
819
869
  if (!this.#accepting || this.#pending.length >= 1_000) {
820
870
  this.logger.warn({ event: "module.event_dropped", reason: "queue_capacity" }, "Module gateway event queue rejected work");
821
- return;
871
+ return false;
822
872
  }
823
873
  this.#pending.push({ guildId, run });
824
874
  this.#pump();
875
+ return true;
825
876
  }
826
877
  queueDepth() {
827
878
  return this.#pending.length;
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { StatusHeartbeatReporter } from "@helyx/status-client";
3
3
  import { safeError, ServiceRegistry } from "@helyx/core";
4
4
  import { checkDatabaseConnection, createDatabasePool, AuditRepository, GuildCommandRepository, InteractionSessionRepository, RateLimitRepository, runCoreMigrations, runConfiguredModuleMigrations, } from "@helyx/database";
5
5
  import { createRuntime } from "@helyx/framework";
6
- import { createPlatformServices, gamingStatisticsRecordOwner, achievementContributionRecordOwner, PostgresPrivacyProviderExecutor, } from "@helyx/platform";
6
+ import { createPlatformServices, gamingStatisticsRecordOwner, achievementContributionRecordOwner, PostgresPrivacyProviderExecutor, PlatformMemberRoleGrantOwnershipService, PostgresMemberRoleGrantOwnershipStore, } from "@helyx/platform";
7
7
  import { HELYX_SERVICE_NAMES, } from "@helyx/sdk";
8
8
  import { Client, Events, GatewayIntentBits, Partials } from "discord.js";
9
9
  import pino from "pino";
@@ -33,6 +33,8 @@ import { createPrivacyModule } from "./privacy-module.js";
33
33
  import { HttpOAuthService } from "./oauth-service.js";
34
34
  import { DiscordOwnedMessageDeletionService } from "./discord-owned-message-service.js";
35
35
  import { DiscordGuildHealthInspection } from "./discord-guild-health.js";
36
+ import { DiscordAnalyticsInspection } from "./discord-analytics.js";
37
+ import { DiscordInviteInspection } from "./discord-invites.js";
36
38
  import { GuildCommandWorker } from "./guild-command-worker.js";
37
39
  import { GuildCommandDiscordClient } from "./guild-command-discord-client.js";
38
40
  import { GuildCommandReadService } from "./guild-command-read-service.js";
@@ -320,8 +322,9 @@ async function startUnshardedBot(processEnvironment) {
320
322
  ];
321
323
  const platformServices = new Map();
322
324
  const serviceAccess = new ServiceRegistry(platformServices);
325
+ const serviceOptions = platformServiceOptions(environment, publicModules);
323
326
  for (const [name, service] of createPlatformServices(database, publicModules, {
324
- ...platformServiceOptions(environment, publicModules),
327
+ ...serviceOptions,
325
328
  activationServices: serviceAccess,
326
329
  })) {
327
330
  platformServices.set(name, service);
@@ -349,9 +352,14 @@ async function startUnshardedBot(processEnvironment) {
349
352
  platformServices.set(HELYX_SERVICE_NAMES.discordMessages, new DiscordMessageDeliveryService(client));
350
353
  platformServices.set(HELYX_SERVICE_NAMES.discordOwnedMessages, new DiscordOwnedMessageDeletionService(client));
351
354
  platformServices.set(HELYX_SERVICE_NAMES.memberEnforcement, new DiscordMemberEnforcementService(client));
352
- platformServices.set(HELYX_SERVICE_NAMES.memberRoles, new DiscordMemberRoleService(client));
355
+ const memberRoles = new DiscordMemberRoleService(client);
356
+ platformServices.set(HELYX_SERVICE_NAMES.memberRoles, memberRoles);
357
+ platformServices.set(HELYX_SERVICE_NAMES.memberRoleGrants, new PlatformMemberRoleGrantOwnershipService(new PostgresMemberRoleGrantOwnershipStore(database), memberRoles));
353
358
  platformServices.set(HELYX_SERVICE_NAMES.discordResources, new DiscordResourceInspection(client));
354
359
  platformServices.set(HELYX_SERVICE_NAMES.discordGuildHealth, new DiscordGuildHealthInspection(client, publicModules, serviceAccess));
360
+ platformServices.set(HELYX_SERVICE_NAMES.discordAnalytics, new DiscordAnalyticsInspection(client));
361
+ if (serviceOptions.inviteFingerprintKey)
362
+ platformServices.set(HELYX_SERVICE_NAMES.discordInvites, new DiscordInviteInspection(client, serviceOptions.inviteFingerprintKey));
355
363
  if (featureModules.some(({ manifest }) => manifest.id === "helyx.logging")) {
356
364
  platformServices.set(HELYX_SERVICE_NAMES.moduleLogging, new DiscordModuleLoggingService(client, database, publicModules, bootstrapLogger));
357
365
  }