@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.
- package/CHANGELOG.md +43 -0
- package/dist/command-path-contract.d.ts +26 -0
- package/dist/command-path-contract.js +157 -0
- package/dist/control-permissions.js +1 -0
- package/dist/control-server.d.ts +1 -1
- package/dist/control-server.js +8 -2
- package/dist/dashboard-commands.js +5 -35
- package/dist/discord-analytics.d.ts +30 -0
- package/dist/discord-analytics.js +337 -0
- package/dist/discord-invites.d.ts +24 -0
- package/dist/discord-invites.js +91 -0
- package/dist/events.js +53 -2
- package/dist/index.js +11 -3
- package/dist/interactions.js +66 -42
- package/dist/management-module.d.ts +1 -1
- package/dist/modules.d.ts +2 -0
- package/dist/modules.js +27 -4
- package/dist/permissions-module.js +13 -16
- package/dist/shard-contracts.js +1 -1
- package/dist/shard-worker.js +11 -3
- package/package.json +4 -4
|
@@ -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
|
-
...
|
|
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
|
-
|
|
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
|
}
|