@helyx/bot 0.4.9 → 0.4.13
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 +66 -0
- package/dist/cloud-control-dispatcher.js +2 -1
- package/dist/control-server.d.ts +43 -3
- package/dist/control-server.js +228 -18
- package/dist/events.d.ts +92 -1
- package/dist/events.js +1483 -5
- package/dist/guilds.d.ts +20 -1
- package/dist/guilds.js +149 -9
- package/dist/index.d.ts +1 -1
- package/dist/index.js +89 -23
- package/dist/interactions.js +101 -22
- package/dist/management-module.d.ts +1 -1
- package/dist/management-module.js +42 -10
- package/dist/message-classification.d.ts +5 -0
- package/dist/message-classification.js +75 -0
- package/dist/modules.js +9 -0
- package/dist/numeric-write-behind-proxy.d.ts +62 -0
- package/dist/numeric-write-behind-proxy.js +313 -0
- package/dist/numeric-write-behind-redis.d.ts +123 -0
- package/dist/numeric-write-behind-redis.js +1281 -0
- package/dist/numeric-write-behind.d.ts +5 -0
- package/dist/numeric-write-behind.js +37 -0
- package/dist/shard-contracts.d.ts +810 -0
- package/dist/shard-contracts.js +292 -3
- package/dist/shard-router.d.ts +3 -0
- package/dist/shard-router.js +42 -0
- package/dist/shard-supervisor.d.ts +4 -1
- package/dist/shard-supervisor.js +262 -7
- package/dist/shard-worker.d.ts +7 -0
- package/dist/shard-worker.js +64 -10
- package/dist/verification-service.d.ts +50 -0
- package/dist/verification-service.js +166 -0
- package/package.json +10 -9
package/dist/events.js
CHANGED
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
|
|
2
|
-
import {
|
|
2
|
+
import { VerificationSetupLeaseLostError } from "@helyx/database";
|
|
3
|
+
import { ChannelType, Events, GatewayIntentBits, GuildMemberFlags, GuildOnboardingMode, PermissionFlagsBits, RESTJSONErrorCodes, UserFlags, } from "discord.js";
|
|
3
4
|
import { toReplyOptions } from "./interactions.js";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
import { classifyGuildMessage, unavailableGuildMessageClassification, } from "./message-classification.js";
|
|
4
7
|
export function installModuleEvents(client, modules, logger, services) {
|
|
8
|
+
const privilegedMessageContributions = modules.flatMap((module) => (module.events?.privilegedMessageCreate ?? []).map((contribution) => ({
|
|
9
|
+
moduleId: module.manifest.id,
|
|
10
|
+
contribution,
|
|
11
|
+
})));
|
|
12
|
+
const guildMessageContributions = modules.flatMap((module) => (module.events?.guildMessageCreate ?? []).map((contribution) => ({
|
|
13
|
+
moduleId: module.manifest.id,
|
|
14
|
+
classificationRequested: module.requiredDiscordCapabilities?.includes("message-content") ??
|
|
15
|
+
false,
|
|
16
|
+
contribution,
|
|
17
|
+
})));
|
|
18
|
+
const messageClassificationRequested = modules.some((module) => module.requiredDiscordCapabilities?.includes("message-content"));
|
|
19
|
+
const messageClassificationAvailable = messageClassificationRequested &&
|
|
20
|
+
client.options.intents.has(GatewayIntentBits.MessageContent);
|
|
5
21
|
const reactionContributions = modules.flatMap((module) => (module.events?.messageReactionAdd ?? []).map((contribution) => ({
|
|
6
22
|
moduleId: module.manifest.id,
|
|
7
23
|
contribution,
|
|
@@ -18,6 +34,10 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
18
34
|
moduleId: module.manifest.id,
|
|
19
35
|
contribution,
|
|
20
36
|
})));
|
|
37
|
+
const memberRemoveContributions = modules.flatMap((module) => (module.events?.guildMemberRemove ?? []).map((contribution) => ({
|
|
38
|
+
moduleId: module.manifest.id,
|
|
39
|
+
contribution,
|
|
40
|
+
})));
|
|
21
41
|
const guildRoleDeleteContributions = modules.flatMap((module) => (module.events?.guildRoleDelete ?? []).map((contribution) => ({
|
|
22
42
|
moduleId: module.manifest.id,
|
|
23
43
|
contribution,
|
|
@@ -42,10 +62,13 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
42
62
|
moduleId: module.manifest.id,
|
|
43
63
|
contribution,
|
|
44
64
|
})));
|
|
45
|
-
if (
|
|
65
|
+
if (privilegedMessageContributions.length === 0 &&
|
|
66
|
+
guildMessageContributions.length === 0 &&
|
|
67
|
+
reactionContributions.length === 0 &&
|
|
46
68
|
reactionRemoveContributions.length === 0 &&
|
|
47
69
|
messageDeleteContributions.length === 0 &&
|
|
48
70
|
memberAddContributions.length === 0 &&
|
|
71
|
+
memberRemoveContributions.length === 0 &&
|
|
49
72
|
guildRoleDeleteContributions.length === 0 &&
|
|
50
73
|
guildRoleUpdateContributions.length === 0 &&
|
|
51
74
|
channelDeleteContributions.length === 0 &&
|
|
@@ -54,6 +77,59 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
54
77
|
guildEmojiUpdateContributions.length === 0)
|
|
55
78
|
return { stop: () => Promise.resolve(), queueDepth: () => 0 };
|
|
56
79
|
const queue = new BoundedGuildEventQueue(logger);
|
|
80
|
+
const privilegedEligibilityQueue = privilegedMessageContributions.length > 0
|
|
81
|
+
? new BoundedGuildEventQueue(logger)
|
|
82
|
+
: null;
|
|
83
|
+
const guildMessageQueue = guildMessageContributions.length > 0
|
|
84
|
+
? new BoundedGuildEventQueue(logger)
|
|
85
|
+
: null;
|
|
86
|
+
if (privilegedMessageContributions.length > 0) {
|
|
87
|
+
client.on(Events.MessageCreate, (message) => {
|
|
88
|
+
if (!message.guildId || !privilegedEligibilityQueue)
|
|
89
|
+
return;
|
|
90
|
+
privilegedEligibilityQueue.enqueue(message.guildId, async () => {
|
|
91
|
+
const installations = services.get(HELYX_SERVICE_NAMES.installations);
|
|
92
|
+
const eligible = [];
|
|
93
|
+
for (const item of privilegedMessageContributions) {
|
|
94
|
+
try {
|
|
95
|
+
if ((await installations.isModuleEnabled(message.guildId, item.moduleId)) &&
|
|
96
|
+
(await item.contribution.isEligible({
|
|
97
|
+
serverId: message.guildId,
|
|
98
|
+
channelId: message.channelId,
|
|
99
|
+
services,
|
|
100
|
+
}))) {
|
|
101
|
+
eligible.push(item);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
logger.warn({
|
|
106
|
+
event: "module.privileged_message_eligibility_failed",
|
|
107
|
+
moduleId: item.moduleId,
|
|
108
|
+
error: safeError(error),
|
|
109
|
+
guildId: message.guildId,
|
|
110
|
+
channelId: message.channelId,
|
|
111
|
+
}, "Module privileged message eligibility failed");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (eligible.length > 0) {
|
|
115
|
+
queue.enqueue(message.guildId, () => handlePrivilegedMessageCreate(message, eligible));
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
if (guildMessageContributions.length > 0) {
|
|
121
|
+
client.on(Events.MessageCreate, (message) => {
|
|
122
|
+
if (!message.guildId ||
|
|
123
|
+
!guildMessageQueue ||
|
|
124
|
+
message.author.bot ||
|
|
125
|
+
message.author.system ||
|
|
126
|
+
Boolean(message.webhookId) ||
|
|
127
|
+
message.system) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
guildMessageQueue.enqueue(message.guildId, () => handleGuildMessageCreate(message));
|
|
131
|
+
});
|
|
132
|
+
}
|
|
57
133
|
if (reactionContributions.length > 0) {
|
|
58
134
|
client.on(Events.MessageReactionAdd, (reaction, user, details) => {
|
|
59
135
|
const guildId = reaction.message.guildId;
|
|
@@ -79,6 +155,11 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
79
155
|
queue.enqueue(member.guild.id, () => handleMemberAdd(member));
|
|
80
156
|
});
|
|
81
157
|
}
|
|
158
|
+
if (memberRemoveContributions.length > 0) {
|
|
159
|
+
client.on(Events.GuildMemberRemove, (member) => {
|
|
160
|
+
queue.enqueue(member.guild.id, () => handleMemberRemove(member));
|
|
161
|
+
});
|
|
162
|
+
}
|
|
82
163
|
if (guildRoleDeleteContributions.length > 0) {
|
|
83
164
|
client.on(Events.GuildRoleDelete, (role) => {
|
|
84
165
|
queue.enqueue(role.guild.id, () => handleResourceLifecycle(guildRoleDeleteContributions, role.guild.id, "roleId", role.id, "guild_role_delete"));
|
|
@@ -137,6 +218,116 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
137
218
|
}
|
|
138
219
|
}
|
|
139
220
|
}
|
|
221
|
+
async function handlePrivilegedMessageCreate(message, contributions) {
|
|
222
|
+
if (!message.guildId || !message.channelId)
|
|
223
|
+
return;
|
|
224
|
+
for (const { moduleId, contribution } of contributions) {
|
|
225
|
+
try {
|
|
226
|
+
await contribution.execute({
|
|
227
|
+
serverId: message.guildId,
|
|
228
|
+
channelId: message.channelId,
|
|
229
|
+
messageId: message.id,
|
|
230
|
+
userId: message.author.id,
|
|
231
|
+
userIsBot: message.author.bot,
|
|
232
|
+
webhookId: message.webhookId,
|
|
233
|
+
content: message.content,
|
|
234
|
+
services,
|
|
235
|
+
deleteSource: async () => {
|
|
236
|
+
await message.delete();
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
logger.warn({
|
|
242
|
+
event: "module.privileged_message_create_failed",
|
|
243
|
+
moduleId,
|
|
244
|
+
error: safeError(error),
|
|
245
|
+
messageId: message.id,
|
|
246
|
+
guildId: message.guildId,
|
|
247
|
+
}, "Module privileged message-create event failed");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
async function handleGuildMessageCreate(message) {
|
|
252
|
+
if (!message.guildId || !message.channelId)
|
|
253
|
+
return;
|
|
254
|
+
const installations = services.get(HELYX_SERVICE_NAMES.installations);
|
|
255
|
+
const parentChannelId = message.channel.isThread()
|
|
256
|
+
? (message.channel.parentId ?? undefined)
|
|
257
|
+
: undefined;
|
|
258
|
+
const authorRoleIds = message.member
|
|
259
|
+
? [...message.member.roles.cache.keys()].sort((left, right) => left.localeCompare(right, "en"))
|
|
260
|
+
: [];
|
|
261
|
+
const authorDisplayName = message.member?.displayName ??
|
|
262
|
+
message.author.globalName ??
|
|
263
|
+
message.author.username;
|
|
264
|
+
let classifiedMessage;
|
|
265
|
+
for (const { moduleId, classificationRequested, contribution, } of guildMessageContributions) {
|
|
266
|
+
try {
|
|
267
|
+
if (!(await installations.isModuleEnabled(message.guildId, moduleId))) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
await contribution.execute({
|
|
271
|
+
serverId: message.guildId,
|
|
272
|
+
channelId: message.channelId,
|
|
273
|
+
...(parentChannelId ? { parentChannelId } : {}),
|
|
274
|
+
messageId: message.id,
|
|
275
|
+
authorId: message.author.id,
|
|
276
|
+
authorDisplayName,
|
|
277
|
+
authorRoleIds,
|
|
278
|
+
authorIsBot: false,
|
|
279
|
+
authorIsWebhook: false,
|
|
280
|
+
authorIsSystem: false,
|
|
281
|
+
get classification() {
|
|
282
|
+
if (!classificationRequested || !messageClassificationAvailable)
|
|
283
|
+
return unavailableGuildMessageClassification;
|
|
284
|
+
classifiedMessage ??= classifyGuildMessage(message);
|
|
285
|
+
return classifiedMessage;
|
|
286
|
+
},
|
|
287
|
+
createdAt: message.createdAt,
|
|
288
|
+
services,
|
|
289
|
+
addAuthorRole: async (roleId) => {
|
|
290
|
+
await services
|
|
291
|
+
.get(HELYX_SERVICE_NAMES.memberRoles)
|
|
292
|
+
.addApprovedRole({
|
|
293
|
+
guildId: message.guildId,
|
|
294
|
+
memberUserId: message.author.id,
|
|
295
|
+
roleId,
|
|
296
|
+
moduleId,
|
|
297
|
+
reason: "Guild message contribution author role addition",
|
|
298
|
+
});
|
|
299
|
+
},
|
|
300
|
+
removeAuthorRole: async (roleId) => {
|
|
301
|
+
await services
|
|
302
|
+
.get(HELYX_SERVICE_NAMES.memberRoles)
|
|
303
|
+
.removeApprovedRole({
|
|
304
|
+
guildId: message.guildId,
|
|
305
|
+
memberUserId: message.author.id,
|
|
306
|
+
roleId,
|
|
307
|
+
moduleId,
|
|
308
|
+
reason: "Guild message contribution author role removal",
|
|
309
|
+
});
|
|
310
|
+
},
|
|
311
|
+
sendToChannel: (channelId, response) => services
|
|
312
|
+
.get(HELYX_SERVICE_NAMES.discordMessages)
|
|
313
|
+
.send({
|
|
314
|
+
guildId: message.guildId,
|
|
315
|
+
channelId,
|
|
316
|
+
response,
|
|
317
|
+
}),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
logger.warn({
|
|
322
|
+
event: "module.guild_message_create_failed",
|
|
323
|
+
moduleId,
|
|
324
|
+
error: safeError(error),
|
|
325
|
+
messageId: message.id,
|
|
326
|
+
guildId: message.guildId,
|
|
327
|
+
}, "Module guild message-create event failed");
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
140
331
|
async function handleReaction(receivedReaction, receivedUser, burst) {
|
|
141
332
|
try {
|
|
142
333
|
const user = receivedUser.partial
|
|
@@ -197,6 +388,8 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
197
388
|
}
|
|
198
389
|
}
|
|
199
390
|
async function handleMemberAdd(member) {
|
|
391
|
+
if (!member.joinedAt)
|
|
392
|
+
throw new Error("Discord member join timestamp is unavailable");
|
|
200
393
|
const installations = services.get(HELYX_SERVICE_NAMES.installations);
|
|
201
394
|
for (const { moduleId, contribution } of memberAddContributions) {
|
|
202
395
|
try {
|
|
@@ -209,6 +402,8 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
209
402
|
memberId: member.id,
|
|
210
403
|
memberDisplayName: member.displayName,
|
|
211
404
|
memberIsBot: member.user.bot,
|
|
405
|
+
accountCreatedAt: member.user.createdAt,
|
|
406
|
+
joinedAt: member.joinedAt,
|
|
212
407
|
services,
|
|
213
408
|
assignRole: (roleId) => assignMemberRole(member, roleId),
|
|
214
409
|
sendToChannel: (channelId, response) => sendToGuildChannel(member, channelId, response),
|
|
@@ -225,6 +420,33 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
225
420
|
}
|
|
226
421
|
}
|
|
227
422
|
}
|
|
423
|
+
async function handleMemberRemove(member) {
|
|
424
|
+
const installations = services.get(HELYX_SERVICE_NAMES.installations);
|
|
425
|
+
for (const { moduleId, contribution } of memberRemoveContributions) {
|
|
426
|
+
try {
|
|
427
|
+
if (!(await installations.isModuleEnabled(member.guild.id, moduleId))) {
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
await contribution.execute({
|
|
431
|
+
serverId: member.guild.id,
|
|
432
|
+
memberId: member.id,
|
|
433
|
+
memberIsBot: member.user.bot,
|
|
434
|
+
accountCreatedAt: member.user.createdAt,
|
|
435
|
+
joinedAt: member.joinedAt,
|
|
436
|
+
services,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
logger.warn({
|
|
441
|
+
event: "module.member_remove_failed",
|
|
442
|
+
moduleId,
|
|
443
|
+
error: safeError(error),
|
|
444
|
+
memberId: member.id,
|
|
445
|
+
guildId: member.guild.id,
|
|
446
|
+
}, "Module member-remove event failed");
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
228
450
|
async function handleReactionRemove(receivedReaction, receivedUser, burst) {
|
|
229
451
|
try {
|
|
230
452
|
if (receivedUser.bot)
|
|
@@ -293,7 +515,16 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
293
515
|
}
|
|
294
516
|
}
|
|
295
517
|
}
|
|
296
|
-
return {
|
|
518
|
+
return {
|
|
519
|
+
stop: async () => {
|
|
520
|
+
await privilegedEligibilityQueue?.stop();
|
|
521
|
+
await guildMessageQueue?.stop();
|
|
522
|
+
await queue.stop();
|
|
523
|
+
},
|
|
524
|
+
queueDepth: () => queue.queueDepth() +
|
|
525
|
+
(privilegedEligibilityQueue?.queueDepth() ?? 0) +
|
|
526
|
+
(guildMessageQueue?.queueDepth() ?? 0),
|
|
527
|
+
};
|
|
297
528
|
}
|
|
298
529
|
class BoundedGuildEventQueue {
|
|
299
530
|
logger;
|
|
@@ -405,11 +636,966 @@ const dangerousRolePermissionNames = new Set([
|
|
|
405
636
|
"MUTE_MEMBERS",
|
|
406
637
|
"VIEW_AUDIT_LOG",
|
|
407
638
|
]);
|
|
639
|
+
const directMessageAttemptTimeoutMs = 5_000;
|
|
640
|
+
const guildChannelAudienceInspectionLimit = 500;
|
|
641
|
+
export class DiscordMessageDeliveryService {
|
|
642
|
+
client;
|
|
643
|
+
constructor(client) {
|
|
644
|
+
this.client = client;
|
|
645
|
+
}
|
|
646
|
+
async send(input) {
|
|
647
|
+
validateSnowflake(input.guildId, "guild");
|
|
648
|
+
validateSnowflake(input.channelId, "channel");
|
|
649
|
+
const guild = await this.client.guilds.fetch(input.guildId);
|
|
650
|
+
if (guild.id !== input.guildId)
|
|
651
|
+
throw new Error("Discord guild validation failed");
|
|
652
|
+
const channel = await guild.channels.fetch(input.channelId);
|
|
653
|
+
if (!channel ||
|
|
654
|
+
channel.guild.id !== guild.id ||
|
|
655
|
+
!channel.isSendable() ||
|
|
656
|
+
channel.isDMBased()) {
|
|
657
|
+
throw new Error("Discord message channel is unavailable");
|
|
658
|
+
}
|
|
659
|
+
const me = guild.members.me ?? (await guild.members.fetchMe());
|
|
660
|
+
const effective = channel.permissionsFor(me);
|
|
661
|
+
if (!effective?.has(PermissionFlagsBits.ViewChannel) ||
|
|
662
|
+
!effective.has(PermissionFlagsBits.SendMessages)) {
|
|
663
|
+
throw new Error("Discord message channel permissions are unavailable");
|
|
664
|
+
}
|
|
665
|
+
const options = {
|
|
666
|
+
...toReplyOptions({ ...input.response, ephemeral: false }),
|
|
667
|
+
};
|
|
668
|
+
if (input.idempotencyKey) {
|
|
669
|
+
const nonce = createHash("sha256")
|
|
670
|
+
.update(input.idempotencyKey)
|
|
671
|
+
.digest("hex")
|
|
672
|
+
.slice(0, 24);
|
|
673
|
+
options.nonce = nonce;
|
|
674
|
+
options.enforceNonce = true;
|
|
675
|
+
}
|
|
676
|
+
const message = await channel.send(options);
|
|
677
|
+
if (message.guildId !== guild.id ||
|
|
678
|
+
message.channelId !== channel.id ||
|
|
679
|
+
!discordSnowflake.test(message.id)) {
|
|
680
|
+
throw new Error("Discord sent message validation failed");
|
|
681
|
+
}
|
|
682
|
+
return { messageId: message.id };
|
|
683
|
+
}
|
|
684
|
+
async sendDirect(input) {
|
|
685
|
+
validateSnowflake(input.userId, "user");
|
|
686
|
+
try {
|
|
687
|
+
const user = await this.client.users.fetch(input.userId);
|
|
688
|
+
if (user.id !== input.userId)
|
|
689
|
+
throw new Error("Discord user validation failed");
|
|
690
|
+
const options = toReplyOptions({ ...input.response, ephemeral: false });
|
|
691
|
+
const message = await user.send(options);
|
|
692
|
+
if (!discordSnowflake.test(message.id)) {
|
|
693
|
+
return { outcome: "failed" };
|
|
694
|
+
}
|
|
695
|
+
return { outcome: "sent", messageId: message.id };
|
|
696
|
+
}
|
|
697
|
+
catch (error) {
|
|
698
|
+
return {
|
|
699
|
+
outcome: isClosedDirectMessage(error) ? "closed" : "failed",
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
export class DiscordMemberEnforcementService {
|
|
705
|
+
client;
|
|
706
|
+
constructor(client) {
|
|
707
|
+
this.client = client;
|
|
708
|
+
}
|
|
709
|
+
async kick(input) {
|
|
710
|
+
validateEnforcementMetadata(input);
|
|
711
|
+
const directMessage = input.directMessage
|
|
712
|
+
? await this.#sendBestEffortDirectMessage(input.memberUserId, input.directMessage)
|
|
713
|
+
: "not_requested";
|
|
714
|
+
const guild = await this.client.guilds
|
|
715
|
+
.fetch(input.guildId)
|
|
716
|
+
.catch(() => null);
|
|
717
|
+
if (!guild)
|
|
718
|
+
return { outcome: "failed", directMessage };
|
|
719
|
+
if (guild.id !== input.guildId)
|
|
720
|
+
throw new Error("Discord guild validation failed");
|
|
721
|
+
let member;
|
|
722
|
+
try {
|
|
723
|
+
member = await guild.members.fetch(input.memberUserId);
|
|
724
|
+
}
|
|
725
|
+
catch (error) {
|
|
726
|
+
if (isUnknownMember(error))
|
|
727
|
+
return { outcome: "member_missing", directMessage };
|
|
728
|
+
return { outcome: "failed", directMessage };
|
|
729
|
+
}
|
|
730
|
+
if (!member)
|
|
731
|
+
return { outcome: "member_missing", directMessage };
|
|
732
|
+
if (member.id !== input.memberUserId || member.guild.id !== guild.id)
|
|
733
|
+
throw new Error("Discord member validation failed");
|
|
734
|
+
const me = guild.members.me ?? (await guild.members.fetchMe());
|
|
735
|
+
if (!me.permissions.has(PermissionFlagsBits.KickMembers)) {
|
|
736
|
+
return { outcome: "missing_permission", directMessage };
|
|
737
|
+
}
|
|
738
|
+
if (!member.kickable) {
|
|
739
|
+
return { outcome: "hierarchy_blocked", directMessage };
|
|
740
|
+
}
|
|
741
|
+
let stillRequired;
|
|
742
|
+
try {
|
|
743
|
+
stillRequired = await input.revalidate();
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
return { outcome: "failed", directMessage };
|
|
747
|
+
}
|
|
748
|
+
if (!stillRequired) {
|
|
749
|
+
return { outcome: "no_longer_required", directMessage };
|
|
750
|
+
}
|
|
751
|
+
try {
|
|
752
|
+
await member.kick(discordAuditReason(input));
|
|
753
|
+
return { outcome: "kicked", directMessage };
|
|
754
|
+
}
|
|
755
|
+
catch (error) {
|
|
756
|
+
if (isUnknownMember(error)) {
|
|
757
|
+
return { outcome: "member_missing", directMessage };
|
|
758
|
+
}
|
|
759
|
+
return { outcome: "failed", directMessage };
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
async ban(input) {
|
|
763
|
+
validateEnforcementMetadata(input);
|
|
764
|
+
const directMessage = input.directMessage
|
|
765
|
+
? await this.#sendBestEffortDirectMessage(input.memberUserId, input.directMessage)
|
|
766
|
+
: "not_requested";
|
|
767
|
+
const guild = await this.client.guilds
|
|
768
|
+
.fetch(input.guildId)
|
|
769
|
+
.catch(() => null);
|
|
770
|
+
if (!guild)
|
|
771
|
+
return { outcome: "failed", directMessage };
|
|
772
|
+
const me = guild.members.me ?? (await guild.members.fetchMe());
|
|
773
|
+
if (!me.permissions.has(PermissionFlagsBits.BanMembers))
|
|
774
|
+
return { outcome: "missing_permission", directMessage };
|
|
775
|
+
const member = await guild.members
|
|
776
|
+
.fetch(input.memberUserId)
|
|
777
|
+
.catch(() => null);
|
|
778
|
+
if (member && !member.bannable)
|
|
779
|
+
return { outcome: "hierarchy_blocked", directMessage };
|
|
780
|
+
let stillRequired;
|
|
781
|
+
try {
|
|
782
|
+
stillRequired = await input.revalidate();
|
|
783
|
+
}
|
|
784
|
+
catch {
|
|
785
|
+
return { outcome: "failed", directMessage };
|
|
786
|
+
}
|
|
787
|
+
if (!stillRequired)
|
|
788
|
+
return { outcome: "no_longer_required", directMessage };
|
|
789
|
+
try {
|
|
790
|
+
await guild.members.ban(input.memberUserId, {
|
|
791
|
+
deleteMessageSeconds: 0,
|
|
792
|
+
reason: discordAuditReason(input),
|
|
793
|
+
});
|
|
794
|
+
return { outcome: "banned", directMessage };
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
return { outcome: "failed", directMessage };
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
async #sendBestEffortDirectMessage(memberUserId, response) {
|
|
801
|
+
const attempt = async () => {
|
|
802
|
+
try {
|
|
803
|
+
const user = await this.client.users.fetch(memberUserId);
|
|
804
|
+
if (user.id !== memberUserId)
|
|
805
|
+
throw new Error("Discord user validation failed");
|
|
806
|
+
const options = toReplyOptions({ ...response, ephemeral: false });
|
|
807
|
+
await user.send(options);
|
|
808
|
+
return "sent";
|
|
809
|
+
}
|
|
810
|
+
catch (error) {
|
|
811
|
+
return isClosedDirectMessage(error) ? "closed" : "failed";
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
return new Promise((resolve) => {
|
|
815
|
+
let settled = false;
|
|
816
|
+
const timer = setTimeout(() => {
|
|
817
|
+
settled = true;
|
|
818
|
+
resolve("failed");
|
|
819
|
+
}, directMessageAttemptTimeoutMs);
|
|
820
|
+
timer.unref();
|
|
821
|
+
void attempt().then((result) => {
|
|
822
|
+
if (settled)
|
|
823
|
+
return;
|
|
824
|
+
settled = true;
|
|
825
|
+
clearTimeout(timer);
|
|
826
|
+
resolve(result);
|
|
827
|
+
});
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
const reviewedPublicFlags = [
|
|
832
|
+
[UserFlags.Staff, "staff"],
|
|
833
|
+
[UserFlags.Partner, "partner"],
|
|
834
|
+
[UserFlags.Hypesquad, "hypesquad"],
|
|
835
|
+
[UserFlags.BugHunterLevel1, "bug_hunter_level_1"],
|
|
836
|
+
[UserFlags.BugHunterLevel2, "bug_hunter_level_2"],
|
|
837
|
+
[UserFlags.VerifiedDeveloper, "verified_bot_developer"],
|
|
838
|
+
[UserFlags.ActiveDeveloper, "active_developer"],
|
|
839
|
+
];
|
|
840
|
+
const reviewedMemberFlags = [
|
|
841
|
+
[GuildMemberFlags.DidRejoin, "did_rejoin"],
|
|
842
|
+
[GuildMemberFlags.CompletedOnboarding, "completed_onboarding"],
|
|
843
|
+
[GuildMemberFlags.BypassesVerification, "bypasses_verification"],
|
|
844
|
+
[GuildMemberFlags.StartedOnboarding, "started_onboarding"],
|
|
845
|
+
];
|
|
846
|
+
export class DiscordVerificationMemberService {
|
|
847
|
+
client;
|
|
848
|
+
constructor(client) {
|
|
849
|
+
this.client = client;
|
|
850
|
+
}
|
|
851
|
+
async captureSnapshot(input) {
|
|
852
|
+
validateSnowflake(input.guildId, "guild");
|
|
853
|
+
validateSnowflake(input.userId, "user");
|
|
854
|
+
validateSnowflake(input.verifiedRoleId, "verified role");
|
|
855
|
+
const guild = await this.client.guilds.fetch(input.guildId);
|
|
856
|
+
const [member, verifiedRole] = await Promise.all([
|
|
857
|
+
guild.members.fetch(input.userId),
|
|
858
|
+
guild.roles.fetch(input.verifiedRoleId),
|
|
859
|
+
]);
|
|
860
|
+
if (!verifiedRole || verifiedRole.guild.id !== guild.id)
|
|
861
|
+
throw new Error("The configured verified role is unavailable");
|
|
862
|
+
const me = guild.members.me ?? (await guild.members.fetchMe());
|
|
863
|
+
const publicFlags = await member.user.fetchFlags(true);
|
|
864
|
+
const publicBitfield = BigInt(publicFlags.bitfield);
|
|
865
|
+
const memberBitfield = BigInt(member.flags.bitfield);
|
|
866
|
+
const publicKnownMask = reviewedPublicFlags.reduce((mask, [flag]) => mask | BigInt(flag), 0n);
|
|
867
|
+
const memberKnownMask = reviewedMemberFlags.reduce((mask, [flag]) => mask | BigInt(flag), 0n);
|
|
868
|
+
const unknownPublic = publicBitfield & ~publicKnownMask;
|
|
869
|
+
const unknownMember = memberBitfield & ~memberKnownMask;
|
|
870
|
+
return {
|
|
871
|
+
sourceClassification: input.sourceClassification,
|
|
872
|
+
capturedAt: new Date().toISOString(),
|
|
873
|
+
safeDisplayName: (member.displayName || member.user.username)
|
|
874
|
+
.normalize("NFC")
|
|
875
|
+
.slice(0, 100),
|
|
876
|
+
guildOwnerId: guild.ownerId,
|
|
877
|
+
accountCreatedAt: member.user.createdAt.toISOString(),
|
|
878
|
+
joinedAt: member.joinedAt?.toISOString() ?? null,
|
|
879
|
+
publicFlags: reviewedPublicFlags
|
|
880
|
+
.filter(([flag]) => publicFlags.has(flag))
|
|
881
|
+
.map(([, name]) => name),
|
|
882
|
+
memberFlags: reviewedMemberFlags
|
|
883
|
+
.filter(([flag]) => member.flags.has(flag))
|
|
884
|
+
.map(([, name]) => name),
|
|
885
|
+
...(unknownPublic === 0n
|
|
886
|
+
? {}
|
|
887
|
+
: { unknownPublicFlagsBitset: unknownPublic.toString() }),
|
|
888
|
+
...(unknownMember === 0n
|
|
889
|
+
? {}
|
|
890
|
+
: { unknownMemberFlagsBitset: unknownMember.toString() }),
|
|
891
|
+
screeningPending: member.pending,
|
|
892
|
+
rejoined: member.flags.has(GuildMemberFlags.DidRejoin),
|
|
893
|
+
nameQuarantined: member.flags.has(GuildMemberFlags.AutomodQuarantinedUsernameOrGuildNickname),
|
|
894
|
+
memberRoleIds: [...member.roles.cache.keys()].slice(0, 100),
|
|
895
|
+
memberHighestRolePosition: member.roles.highest.position,
|
|
896
|
+
botHighestRolePosition: me.roles.highest.position,
|
|
897
|
+
verifiedRolePosition: verifiedRole.position,
|
|
898
|
+
verifiedRoleManageable: verifiedRole.editable,
|
|
899
|
+
isBot: member.user.bot,
|
|
900
|
+
isSystem: member.user.system ?? false,
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
async isCurrentlyBanned(input) {
|
|
904
|
+
validateSnowflake(input.guildId, "guild");
|
|
905
|
+
validateSnowflake(input.userId, "user");
|
|
906
|
+
const guild = await this.client.guilds.fetch(input.guildId);
|
|
907
|
+
try {
|
|
908
|
+
const ban = await guild.bans.fetch(input.userId);
|
|
909
|
+
return ban.user.id === input.userId;
|
|
910
|
+
}
|
|
911
|
+
catch (error) {
|
|
912
|
+
if (typeof error === "object" &&
|
|
913
|
+
error !== null &&
|
|
914
|
+
"code" in error &&
|
|
915
|
+
error.code === RESTJSONErrorCodes.UnknownBan)
|
|
916
|
+
return false;
|
|
917
|
+
throw error;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
const verificationSetupMaximumAccessTargets = 50;
|
|
922
|
+
const verificationSetupLeaseMs = 120_000;
|
|
923
|
+
const verificationRoleNameMaximum = 100;
|
|
924
|
+
const verificationChannelNamePattern = /^[a-z0-9][a-z0-9-_]{0,99}$/u;
|
|
925
|
+
const verificationMembershipScreeningFeature = "MEMBER_VERIFICATION_GATE_ENABLED";
|
|
926
|
+
/**
|
|
927
|
+
* The single Discord mutation boundary for Verification setup. It deliberately
|
|
928
|
+
* exposes a preview/apply protocol rather than raw role or overwrite writes.
|
|
929
|
+
*/
|
|
930
|
+
export class DiscordVerificationSetupService {
|
|
931
|
+
client;
|
|
932
|
+
persistence;
|
|
933
|
+
#guildLocks = new Map();
|
|
934
|
+
constructor(client, persistence) {
|
|
935
|
+
this.client = client;
|
|
936
|
+
this.persistence = persistence;
|
|
937
|
+
}
|
|
938
|
+
async inspect(input) {
|
|
939
|
+
validateSnowflake(input.guildId, "guild");
|
|
940
|
+
const guild = await this.client.guilds.fetch(input.guildId);
|
|
941
|
+
if (guild.id !== input.guildId)
|
|
942
|
+
throw new Error("Discord guild validation failed");
|
|
943
|
+
const me = guild.members.me ?? (await guild.members.fetchMe());
|
|
944
|
+
const permissions = {
|
|
945
|
+
viewChannel: me.permissions.has(PermissionFlagsBits.ViewChannel),
|
|
946
|
+
sendMessages: me.permissions.has(PermissionFlagsBits.SendMessages),
|
|
947
|
+
embedLinks: me.permissions.has(PermissionFlagsBits.EmbedLinks),
|
|
948
|
+
manageRoles: me.permissions.has(PermissionFlagsBits.ManageRoles),
|
|
949
|
+
manageChannels: me.permissions.has(PermissionFlagsBits.ManageChannels),
|
|
950
|
+
kickMembers: me.permissions.has(PermissionFlagsBits.KickMembers),
|
|
951
|
+
banMembers: me.permissions.has(PermissionFlagsBits.BanMembers),
|
|
952
|
+
administrator: me.permissions.has(PermissionFlagsBits.Administrator),
|
|
953
|
+
};
|
|
954
|
+
const membershipScreeningEnabled = guild.features.includes(verificationMembershipScreeningFeature);
|
|
955
|
+
try {
|
|
956
|
+
const onboarding = await guild.fetchOnboarding();
|
|
957
|
+
return {
|
|
958
|
+
guildId: guild.id,
|
|
959
|
+
permissions,
|
|
960
|
+
onboarding: {
|
|
961
|
+
status: onboarding.enabled ? "enabled" : "disabled",
|
|
962
|
+
mode: onboarding.mode === GuildOnboardingMode.OnboardingDefault
|
|
963
|
+
? "default"
|
|
964
|
+
: onboarding.mode === GuildOnboardingMode.OnboardingAdvanced
|
|
965
|
+
? "advanced"
|
|
966
|
+
: "unknown",
|
|
967
|
+
membershipScreeningEnabled,
|
|
968
|
+
defaultChannelIds: [...onboarding.defaultChannels.keys()]
|
|
969
|
+
.slice(0, verificationSetupMaximumAccessTargets)
|
|
970
|
+
.sort((left, right) => left.localeCompare(right, "en")),
|
|
971
|
+
unverifiedInteractionChannelRecommended: onboarding.enabled || membershipScreeningEnabled,
|
|
972
|
+
},
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
catch {
|
|
976
|
+
return {
|
|
977
|
+
guildId: guild.id,
|
|
978
|
+
permissions,
|
|
979
|
+
onboarding: {
|
|
980
|
+
status: "unavailable",
|
|
981
|
+
mode: "unknown",
|
|
982
|
+
membershipScreeningEnabled,
|
|
983
|
+
defaultChannelIds: [],
|
|
984
|
+
unverifiedInteractionChannelRecommended: membershipScreeningEnabled,
|
|
985
|
+
},
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
async preview(input) {
|
|
990
|
+
validateVerificationSetupRequest(input);
|
|
991
|
+
const guild = await this.client.guilds.fetch(input.guildId);
|
|
992
|
+
if (guild.id !== input.guildId)
|
|
993
|
+
throw new Error("Discord guild validation failed");
|
|
994
|
+
const me = guild.members.me ?? (await guild.members.fetchMe());
|
|
995
|
+
const [inspection, verifiedRole, verificationChannel] = await Promise.all([
|
|
996
|
+
this.inspect({ guildId: guild.id }),
|
|
997
|
+
resolveSetupRole(guild, me, input.verifiedRole),
|
|
998
|
+
resolveSetupChannel(guild, me, input.verificationChannel),
|
|
999
|
+
]);
|
|
1000
|
+
const accessOverwrites = await Promise.all(input.accessTargetIds.map(async (targetId) => {
|
|
1001
|
+
const channel = await fetchVerificationAccessTarget(guild, targetId);
|
|
1002
|
+
const current = verifiedRole.role
|
|
1003
|
+
? viewChannelOverwrite(channel, verifiedRole.role.id)
|
|
1004
|
+
: null;
|
|
1005
|
+
return {
|
|
1006
|
+
targetId: channel.id,
|
|
1007
|
+
targetType: verificationAccessTargetType(channel),
|
|
1008
|
+
action: current === true
|
|
1009
|
+
? "unchanged"
|
|
1010
|
+
: "set_verified_view",
|
|
1011
|
+
};
|
|
1012
|
+
}));
|
|
1013
|
+
const missingPermissions = verificationSetupMissingPermissions({
|
|
1014
|
+
inspection,
|
|
1015
|
+
me,
|
|
1016
|
+
verificationChannel,
|
|
1017
|
+
needsManageChannels: verificationChannel.action === "create" ||
|
|
1018
|
+
accessOverwrites.some(({ action }) => action !== "unchanged"),
|
|
1019
|
+
});
|
|
1020
|
+
const unsigned = {
|
|
1021
|
+
inspection,
|
|
1022
|
+
missingPermissions,
|
|
1023
|
+
verifiedRole: {
|
|
1024
|
+
action: verifiedRole.action,
|
|
1025
|
+
name: verifiedRole.name,
|
|
1026
|
+
...(verifiedRole.role ? { roleId: verifiedRole.role.id } : {}),
|
|
1027
|
+
manageable: verifiedRole.manageable,
|
|
1028
|
+
},
|
|
1029
|
+
verificationChannel: {
|
|
1030
|
+
action: verificationChannel.action,
|
|
1031
|
+
name: verificationChannel.name,
|
|
1032
|
+
...(verificationChannel.channel
|
|
1033
|
+
? { channelId: verificationChannel.channel.id }
|
|
1034
|
+
: {}),
|
|
1035
|
+
usable: verificationChannel.usable,
|
|
1036
|
+
},
|
|
1037
|
+
accessOverwrites,
|
|
1038
|
+
};
|
|
1039
|
+
return {
|
|
1040
|
+
fingerprint: verificationSetupFingerprint(input, unsigned),
|
|
1041
|
+
...unsigned,
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
async apply(input) {
|
|
1045
|
+
if (input.confirmed !== true)
|
|
1046
|
+
throw new Error("Verification setup requires explicit confirmation");
|
|
1047
|
+
if (!/^[a-f0-9]{64}$/u.test(input.previewFingerprint))
|
|
1048
|
+
throw new Error("Verification setup preview fingerprint is invalid");
|
|
1049
|
+
return this.#withGuildLock(input.guildId, async () => {
|
|
1050
|
+
const request = {
|
|
1051
|
+
guildId: input.guildId,
|
|
1052
|
+
verifiedRole: input.verifiedRole,
|
|
1053
|
+
verificationChannel: input.verificationChannel,
|
|
1054
|
+
accessTargetIds: input.accessTargetIds,
|
|
1055
|
+
};
|
|
1056
|
+
const preview = await this.preview(request);
|
|
1057
|
+
if (preview.fingerprint !== input.previewFingerprint)
|
|
1058
|
+
throw new Error("Verification setup changed after preview; review the new plan");
|
|
1059
|
+
if (preview.missingPermissions.length > 0)
|
|
1060
|
+
throw new Error(`Verification setup is missing permissions: ${preview.missingPermissions.join(", ")}`);
|
|
1061
|
+
if (!preview.verifiedRole.manageable)
|
|
1062
|
+
throw new Error("The verified role cannot be managed safely by Helyx");
|
|
1063
|
+
if (!preview.verificationChannel.usable)
|
|
1064
|
+
throw new Error("The verification channel is not usable by Helyx");
|
|
1065
|
+
const guild = await this.client.guilds.fetch(input.guildId);
|
|
1066
|
+
const reason = "Helyx Verification: confirmed setup plan";
|
|
1067
|
+
const leaseOwner = `hosted-bot:${randomUUID()}`;
|
|
1068
|
+
for (let claimAttempt = 0; claimAttempt < 2; claimAttempt += 1) {
|
|
1069
|
+
const claim = await this.persistence.begin({
|
|
1070
|
+
guildId: input.guildId,
|
|
1071
|
+
fingerprint: input.previewFingerprint,
|
|
1072
|
+
selectedTargetIds: input.accessTargetIds,
|
|
1073
|
+
leaseOwner,
|
|
1074
|
+
leaseMs: verificationSetupLeaseMs,
|
|
1075
|
+
});
|
|
1076
|
+
if (claim.kind === "applied")
|
|
1077
|
+
return claim.result;
|
|
1078
|
+
if (claim.kind === "busy")
|
|
1079
|
+
throw new Error("Another Verification setup operation is in progress");
|
|
1080
|
+
if (claim.kind === "recover") {
|
|
1081
|
+
await this.#recoverSetup(guild, claim.state, reason);
|
|
1082
|
+
await this.persistence.abort(claim.state);
|
|
1083
|
+
continue;
|
|
1084
|
+
}
|
|
1085
|
+
return this.#applyClaimedSetup(guild, preview, claim.state, reason);
|
|
1086
|
+
}
|
|
1087
|
+
throw new Error("Verification setup recovery did not converge");
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
async #applyClaimedSetup(guild, preview, initialState, reason) {
|
|
1091
|
+
let state = initialState;
|
|
1092
|
+
let uncheckpointedRole = null;
|
|
1093
|
+
let uncheckpointedChannel = null;
|
|
1094
|
+
try {
|
|
1095
|
+
let role = state.roleId ? await guild.roles.fetch(state.roleId) : null;
|
|
1096
|
+
if (!role) {
|
|
1097
|
+
if (preview.verifiedRole.action === "create") {
|
|
1098
|
+
role = await findPendingVerificationRole(guild, state.requestedFingerprint);
|
|
1099
|
+
if (!role)
|
|
1100
|
+
try {
|
|
1101
|
+
role = await guild.roles.create({
|
|
1102
|
+
name: verificationPendingRoleName(state.requestedFingerprint),
|
|
1103
|
+
permissions: [],
|
|
1104
|
+
reason,
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
catch (error) {
|
|
1108
|
+
role = await findPendingVerificationRole(guild, state.requestedFingerprint);
|
|
1109
|
+
if (!role)
|
|
1110
|
+
throw error;
|
|
1111
|
+
}
|
|
1112
|
+
uncheckpointedRole = role;
|
|
1113
|
+
state = await this.persistence.checkpointResources(state, { roleId: role.id, createdRole: true }, verificationSetupLeaseMs);
|
|
1114
|
+
uncheckpointedRole = null;
|
|
1115
|
+
if (role.name !== preview.verifiedRole.name)
|
|
1116
|
+
role = await role.setName(preview.verifiedRole.name, reason);
|
|
1117
|
+
}
|
|
1118
|
+
else {
|
|
1119
|
+
role = await guild.roles.fetch(preview.verifiedRole.roleId);
|
|
1120
|
+
if (role)
|
|
1121
|
+
state = await this.persistence.checkpointResources(state, { roleId: role.id, createdRole: false }, verificationSetupLeaseMs);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
if (!role || role.guild.id !== guild.id)
|
|
1125
|
+
throw new Error("The verified role became unavailable during setup");
|
|
1126
|
+
let channel = state.channelId
|
|
1127
|
+
? await fetchVerificationTextChannel(guild, state.channelId)
|
|
1128
|
+
: null;
|
|
1129
|
+
if (!channel) {
|
|
1130
|
+
if (preview.verificationChannel.action === "create") {
|
|
1131
|
+
channel = await findPendingVerificationChannel(guild, state.requestedFingerprint);
|
|
1132
|
+
if (!channel)
|
|
1133
|
+
try {
|
|
1134
|
+
channel = await guild.channels.create({
|
|
1135
|
+
name: verificationPendingChannelName(state.requestedFingerprint),
|
|
1136
|
+
type: ChannelType.GuildText,
|
|
1137
|
+
reason,
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
catch (error) {
|
|
1141
|
+
channel = await findPendingVerificationChannel(guild, state.requestedFingerprint);
|
|
1142
|
+
if (!channel)
|
|
1143
|
+
throw error;
|
|
1144
|
+
}
|
|
1145
|
+
uncheckpointedChannel = channel;
|
|
1146
|
+
state = await this.persistence.checkpointResources(state, { channelId: channel.id, createdChannel: true }, verificationSetupLeaseMs);
|
|
1147
|
+
uncheckpointedChannel = null;
|
|
1148
|
+
if (channel.name !== preview.verificationChannel.name)
|
|
1149
|
+
channel = await channel.setName(preview.verificationChannel.name, reason);
|
|
1150
|
+
}
|
|
1151
|
+
else {
|
|
1152
|
+
channel = await fetchVerificationTextChannel(guild, preview.verificationChannel.channelId);
|
|
1153
|
+
if (channel)
|
|
1154
|
+
state = await this.persistence.checkpointResources(state, { channelId: channel.id, createdChannel: false }, verificationSetupLeaseMs);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
if (!channel || channel.guild.id !== guild.id)
|
|
1158
|
+
throw new Error("The verification channel became unavailable during setup");
|
|
1159
|
+
const desiredKeys = new Set(state.selectedTargetIds.map((targetId) => verificationManagedOverwriteKey(targetId, role.id)));
|
|
1160
|
+
const removesPreviousManagedOverwrite = state.managedOverwrites.some((overwrite) => !desiredKeys.has(verificationManagedOverwriteKey(overwrite.targetId, overwrite.roleId)));
|
|
1161
|
+
await assertVerificationSetupStillSafe(guild, role, channel, {
|
|
1162
|
+
needsManageChannels: state.createdChannel ||
|
|
1163
|
+
removesPreviousManagedOverwrite ||
|
|
1164
|
+
preview.accessOverwrites.some(({ action }) => action !== "unchanged"),
|
|
1165
|
+
});
|
|
1166
|
+
for (const overwrite of [...state.managedOverwrites]) {
|
|
1167
|
+
const key = verificationManagedOverwriteKey(overwrite.targetId, overwrite.roleId);
|
|
1168
|
+
if (desiredKeys.has(key))
|
|
1169
|
+
continue;
|
|
1170
|
+
state = await this.#checkpointOverwriteState(state, {
|
|
1171
|
+
...overwrite,
|
|
1172
|
+
status: "restoring",
|
|
1173
|
+
});
|
|
1174
|
+
await restoreVerificationViewOverwrite(guild, overwrite, reason);
|
|
1175
|
+
state = await this.#removeCheckpointedOverwrite(state, key);
|
|
1176
|
+
}
|
|
1177
|
+
for (const targetId of state.selectedTargetIds) {
|
|
1178
|
+
const target = await fetchVerificationAccessTarget(guild, targetId);
|
|
1179
|
+
const key = verificationManagedOverwriteKey(target.id, role.id);
|
|
1180
|
+
let tracked = state.managedOverwrites.find((overwrite) => verificationManagedOverwriteKey(overwrite.targetId, overwrite.roleId) === key);
|
|
1181
|
+
const current = viewChannelOverwrite(target, role.id);
|
|
1182
|
+
if (!tracked && current === true)
|
|
1183
|
+
continue;
|
|
1184
|
+
if (!tracked) {
|
|
1185
|
+
tracked = {
|
|
1186
|
+
targetId: target.id,
|
|
1187
|
+
roleId: role.id,
|
|
1188
|
+
previousView: current,
|
|
1189
|
+
status: "planned",
|
|
1190
|
+
};
|
|
1191
|
+
state = await this.#checkpointOverwriteState(state, tracked);
|
|
1192
|
+
}
|
|
1193
|
+
else if (tracked.status !== "planned" && current !== true) {
|
|
1194
|
+
tracked = { ...tracked, status: "planned" };
|
|
1195
|
+
state = await this.#checkpointOverwriteState(state, tracked);
|
|
1196
|
+
}
|
|
1197
|
+
if (current !== true) {
|
|
1198
|
+
await target.permissionOverwrites.edit(role, { ViewChannel: true }, { reason });
|
|
1199
|
+
}
|
|
1200
|
+
tracked = { ...tracked, status: "applied" };
|
|
1201
|
+
state = await this.#checkpointOverwriteState(state, tracked, current === true ? undefined : target.id);
|
|
1202
|
+
}
|
|
1203
|
+
return this.persistence.complete(state, {
|
|
1204
|
+
verifiedRoleId: role.id,
|
|
1205
|
+
verificationChannelId: channel.id,
|
|
1206
|
+
createdRole: state.createdRole,
|
|
1207
|
+
createdChannel: state.createdChannel,
|
|
1208
|
+
changedAccessTargetIds: state.changedTargetIds,
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
catch (error) {
|
|
1212
|
+
if (error instanceof VerificationSetupLeaseLostError)
|
|
1213
|
+
throw error;
|
|
1214
|
+
const rollbackFailures = [];
|
|
1215
|
+
for (const resource of [uncheckpointedChannel, uncheckpointedRole]) {
|
|
1216
|
+
if (!resource)
|
|
1217
|
+
continue;
|
|
1218
|
+
try {
|
|
1219
|
+
await resource.delete(`${reason} rollback`);
|
|
1220
|
+
}
|
|
1221
|
+
catch (rollbackError) {
|
|
1222
|
+
rollbackFailures.push(rollbackError);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
try {
|
|
1226
|
+
await this.#recoverSetup(guild, state, `${reason} rollback`);
|
|
1227
|
+
await this.persistence.abort(state);
|
|
1228
|
+
}
|
|
1229
|
+
catch (rollbackError) {
|
|
1230
|
+
rollbackFailures.push(rollbackError);
|
|
1231
|
+
}
|
|
1232
|
+
if (rollbackFailures.length > 0)
|
|
1233
|
+
throw new AggregateError(rollbackFailures, "Verification setup failed and rollback was incomplete", { cause: error });
|
|
1234
|
+
throw error;
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
async #recoverSetup(guild, state, reason) {
|
|
1238
|
+
const applied = new Map(state.appliedManagedOverwrites.map((overwrite) => [
|
|
1239
|
+
verificationManagedOverwriteKey(overwrite.targetId, overwrite.roleId),
|
|
1240
|
+
overwrite,
|
|
1241
|
+
]));
|
|
1242
|
+
for (const pending of state.managedOverwrites) {
|
|
1243
|
+
const key = verificationManagedOverwriteKey(pending.targetId, pending.roleId);
|
|
1244
|
+
if (applied.has(key)) {
|
|
1245
|
+
await setVerificationViewOverwrite(guild, pending.targetId, pending.roleId, true, reason);
|
|
1246
|
+
applied.delete(key);
|
|
1247
|
+
}
|
|
1248
|
+
else {
|
|
1249
|
+
await restoreVerificationViewOverwrite(guild, pending, reason);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
for (const previous of applied.values()) {
|
|
1253
|
+
await setVerificationViewOverwrite(guild, previous.targetId, previous.roleId, true, reason);
|
|
1254
|
+
}
|
|
1255
|
+
const appliedResult = state.appliedResult;
|
|
1256
|
+
if (state.createdChannel &&
|
|
1257
|
+
state.channelId &&
|
|
1258
|
+
state.channelId !== appliedResult?.verificationChannelId) {
|
|
1259
|
+
const createdChannel = await guild.channels.fetch(state.channelId);
|
|
1260
|
+
if (createdChannel)
|
|
1261
|
+
await createdChannel.delete(reason);
|
|
1262
|
+
}
|
|
1263
|
+
if (state.createdRole &&
|
|
1264
|
+
state.roleId &&
|
|
1265
|
+
state.roleId !== appliedResult?.verifiedRoleId) {
|
|
1266
|
+
const createdRole = await guild.roles.fetch(state.roleId);
|
|
1267
|
+
if (createdRole)
|
|
1268
|
+
await createdRole.delete(reason);
|
|
1269
|
+
}
|
|
1270
|
+
const pendingChannel = await findPendingVerificationChannel(guild, state.requestedFingerprint);
|
|
1271
|
+
if (pendingChannel &&
|
|
1272
|
+
pendingChannel.id !== appliedResult?.verificationChannelId)
|
|
1273
|
+
await pendingChannel.delete(reason);
|
|
1274
|
+
const pendingRole = await findPendingVerificationRole(guild, state.requestedFingerprint);
|
|
1275
|
+
if (pendingRole && pendingRole.id !== appliedResult?.verifiedRoleId)
|
|
1276
|
+
await pendingRole.delete(reason);
|
|
1277
|
+
}
|
|
1278
|
+
async #checkpointOverwriteState(state, overwrite, changedTargetId) {
|
|
1279
|
+
const key = verificationManagedOverwriteKey(overwrite.targetId, overwrite.roleId);
|
|
1280
|
+
const next = state.managedOverwrites.filter((existing) => verificationManagedOverwriteKey(existing.targetId, existing.roleId) !==
|
|
1281
|
+
key);
|
|
1282
|
+
next.push(overwrite);
|
|
1283
|
+
const changed = changedTargetId
|
|
1284
|
+
? [...new Set([...state.changedTargetIds, changedTargetId])]
|
|
1285
|
+
: state.changedTargetIds;
|
|
1286
|
+
return this.persistence.checkpointOverwrites(state, next, changed, verificationSetupLeaseMs);
|
|
1287
|
+
}
|
|
1288
|
+
#removeCheckpointedOverwrite(state, key) {
|
|
1289
|
+
return this.persistence.checkpointOverwrites(state, state.managedOverwrites.filter((overwrite) => verificationManagedOverwriteKey(overwrite.targetId, overwrite.roleId) !== key), state.changedTargetIds, verificationSetupLeaseMs);
|
|
1290
|
+
}
|
|
1291
|
+
async hideVerificationChannelForVerifiedMember(input) {
|
|
1292
|
+
validateMutationMetadata({
|
|
1293
|
+
guildId: input.guildId,
|
|
1294
|
+
memberUserId: input.memberUserId,
|
|
1295
|
+
roleId: input.verifiedRoleId,
|
|
1296
|
+
moduleId: input.moduleId,
|
|
1297
|
+
reason: input.reason,
|
|
1298
|
+
});
|
|
1299
|
+
validateSnowflake(input.channelId, "channel");
|
|
1300
|
+
const guild = await this.client.guilds.fetch(input.guildId);
|
|
1301
|
+
const [member, role, fetchedChannel] = await Promise.all([
|
|
1302
|
+
guild.members.fetch(input.memberUserId),
|
|
1303
|
+
guild.roles.fetch(input.verifiedRoleId),
|
|
1304
|
+
guild.channels.fetch(input.channelId),
|
|
1305
|
+
]);
|
|
1306
|
+
if (!role || role.guild.id !== guild.id)
|
|
1307
|
+
throw new Error("The verified role is unavailable");
|
|
1308
|
+
if (!fetchedChannel ||
|
|
1309
|
+
fetchedChannel.guild.id !== guild.id ||
|
|
1310
|
+
fetchedChannel.type !== ChannelType.GuildText)
|
|
1311
|
+
throw new Error("The verification channel is unavailable");
|
|
1312
|
+
if (!member.roles.cache.has(role.id))
|
|
1313
|
+
throw new Error("The member does not hold the verified role");
|
|
1314
|
+
const me = guild.members.me ?? (await guild.members.fetchMe());
|
|
1315
|
+
if (!me.permissions.has(PermissionFlagsBits.ManageChannels))
|
|
1316
|
+
throw new Error("Helyx requires Manage Channels to hide this channel");
|
|
1317
|
+
if (!verificationRoleManageable(role, guild, me))
|
|
1318
|
+
throw new Error("The verified role cannot be managed safely by Helyx");
|
|
1319
|
+
if (viewChannelOverwrite(fetchedChannel, role.id) === false)
|
|
1320
|
+
return { changed: false };
|
|
1321
|
+
await fetchedChannel.permissionOverwrites.edit(role, { ViewChannel: false }, { reason: discordAuditReason(input) });
|
|
1322
|
+
return { changed: true };
|
|
1323
|
+
}
|
|
1324
|
+
async #withGuildLock(guildId, operation) {
|
|
1325
|
+
validateSnowflake(guildId, "guild");
|
|
1326
|
+
const previous = this.#guildLocks.get(guildId) ?? Promise.resolve();
|
|
1327
|
+
let release;
|
|
1328
|
+
const gate = new Promise((resolve) => {
|
|
1329
|
+
release = resolve;
|
|
1330
|
+
});
|
|
1331
|
+
const current = previous.catch(() => undefined).then(() => gate);
|
|
1332
|
+
this.#guildLocks.set(guildId, current);
|
|
1333
|
+
await previous.catch(() => undefined);
|
|
1334
|
+
try {
|
|
1335
|
+
return await operation();
|
|
1336
|
+
}
|
|
1337
|
+
finally {
|
|
1338
|
+
release();
|
|
1339
|
+
if (this.#guildLocks.get(guildId) === current)
|
|
1340
|
+
this.#guildLocks.delete(guildId);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
async function resolveSetupRole(guild, me, selection) {
|
|
1345
|
+
if (selection.mode === "create") {
|
|
1346
|
+
const role = selection.trackedRoleId
|
|
1347
|
+
? await guild.roles.fetch(selection.trackedRoleId)
|
|
1348
|
+
: null;
|
|
1349
|
+
if (!role)
|
|
1350
|
+
return {
|
|
1351
|
+
action: "create",
|
|
1352
|
+
name: selection.name,
|
|
1353
|
+
role: null,
|
|
1354
|
+
manageable: me.permissions.has(PermissionFlagsBits.ManageRoles),
|
|
1355
|
+
};
|
|
1356
|
+
return {
|
|
1357
|
+
action: "reuse",
|
|
1358
|
+
name: role.name,
|
|
1359
|
+
role,
|
|
1360
|
+
manageable: verificationRoleManageable(role, guild, me),
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
const role = await guild.roles.fetch(selection.roleId);
|
|
1364
|
+
if (!role || role.guild.id !== guild.id)
|
|
1365
|
+
throw new Error("The selected verified role is unavailable");
|
|
1366
|
+
return {
|
|
1367
|
+
action: "select",
|
|
1368
|
+
name: role.name,
|
|
1369
|
+
role,
|
|
1370
|
+
manageable: verificationRoleManageable(role, guild, me),
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
async function resolveSetupChannel(guild, me, selection) {
|
|
1374
|
+
if (selection.mode === "create") {
|
|
1375
|
+
const fetched = selection.trackedChannelId
|
|
1376
|
+
? await guild.channels.fetch(selection.trackedChannelId)
|
|
1377
|
+
: null;
|
|
1378
|
+
if (!fetched)
|
|
1379
|
+
return {
|
|
1380
|
+
action: "create",
|
|
1381
|
+
name: selection.name,
|
|
1382
|
+
channel: null,
|
|
1383
|
+
usable: me.permissions.has(PermissionFlagsBits.ViewChannel) &&
|
|
1384
|
+
me.permissions.has(PermissionFlagsBits.SendMessages) &&
|
|
1385
|
+
me.permissions.has(PermissionFlagsBits.EmbedLinks) &&
|
|
1386
|
+
me.permissions.has(PermissionFlagsBits.ManageChannels),
|
|
1387
|
+
};
|
|
1388
|
+
if (fetched.type !== ChannelType.GuildText)
|
|
1389
|
+
throw new Error("The tracked verification channel is not a text channel");
|
|
1390
|
+
return {
|
|
1391
|
+
action: "reuse",
|
|
1392
|
+
name: fetched.name,
|
|
1393
|
+
channel: fetched,
|
|
1394
|
+
usable: verificationChannelUsable(fetched, me),
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
const fetched = await guild.channels.fetch(selection.channelId);
|
|
1398
|
+
if (!fetched || fetched.guild.id !== guild.id)
|
|
1399
|
+
throw new Error("The selected verification channel is unavailable");
|
|
1400
|
+
if (fetched.type !== ChannelType.GuildText)
|
|
1401
|
+
throw new Error("The selected verification channel is not a text channel");
|
|
1402
|
+
return {
|
|
1403
|
+
action: "select",
|
|
1404
|
+
name: fetched.name,
|
|
1405
|
+
channel: fetched,
|
|
1406
|
+
usable: verificationChannelUsable(fetched, me),
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
function verificationChannelUsable(channel, me) {
|
|
1410
|
+
const effective = channel.permissionsFor(me);
|
|
1411
|
+
return Boolean(effective?.has(PermissionFlagsBits.ViewChannel) &&
|
|
1412
|
+
effective.has(PermissionFlagsBits.SendMessages) &&
|
|
1413
|
+
effective.has(PermissionFlagsBits.EmbedLinks));
|
|
1414
|
+
}
|
|
1415
|
+
function verificationRoleManageable(role, guild, me) {
|
|
1416
|
+
return (role.guild.id === guild.id &&
|
|
1417
|
+
role.id !== guild.id &&
|
|
1418
|
+
!role.managed &&
|
|
1419
|
+
!role.permissions.has(PermissionFlagsBits.Administrator) &&
|
|
1420
|
+
me.permissions.has(PermissionFlagsBits.ManageRoles) &&
|
|
1421
|
+
role.comparePositionTo(me.roles.highest) < 0);
|
|
1422
|
+
}
|
|
1423
|
+
async function fetchVerificationAccessTarget(guild, targetId) {
|
|
1424
|
+
const channel = await guild.channels.fetch(targetId);
|
|
1425
|
+
if (!channel || channel.guild.id !== guild.id)
|
|
1426
|
+
throw new Error("A selected verification access target is unavailable");
|
|
1427
|
+
if (channel.type !== ChannelType.GuildText &&
|
|
1428
|
+
channel.type !== ChannelType.GuildAnnouncement &&
|
|
1429
|
+
channel.type !== ChannelType.GuildCategory)
|
|
1430
|
+
throw new Error("Verification access targets must be text channels or categories");
|
|
1431
|
+
return channel;
|
|
1432
|
+
}
|
|
1433
|
+
async function fetchVerificationTextChannel(guild, channelId) {
|
|
1434
|
+
const channel = await guild.channels.fetch(channelId);
|
|
1435
|
+
return channel?.guild.id === guild.id &&
|
|
1436
|
+
channel.type === ChannelType.GuildText
|
|
1437
|
+
? channel
|
|
1438
|
+
: null;
|
|
1439
|
+
}
|
|
1440
|
+
function verificationPendingRoleName(fingerprint) {
|
|
1441
|
+
return `Helyx verify pending ${fingerprint.slice(0, 12)}`;
|
|
1442
|
+
}
|
|
1443
|
+
function verificationPendingChannelName(fingerprint) {
|
|
1444
|
+
return `helyx-verify-pending-${fingerprint.slice(0, 12)}`;
|
|
1445
|
+
}
|
|
1446
|
+
async function findPendingVerificationRole(guild, fingerprint) {
|
|
1447
|
+
const expectedName = verificationPendingRoleName(fingerprint);
|
|
1448
|
+
const roles = await guild.roles.fetch();
|
|
1449
|
+
return roles.find((role) => role.name === expectedName) ?? null;
|
|
1450
|
+
}
|
|
1451
|
+
async function findPendingVerificationChannel(guild, fingerprint) {
|
|
1452
|
+
const expectedName = verificationPendingChannelName(fingerprint);
|
|
1453
|
+
const channels = await guild.channels.fetch();
|
|
1454
|
+
const channel = channels.find((candidate) => candidate !== null &&
|
|
1455
|
+
candidate.type === ChannelType.GuildText &&
|
|
1456
|
+
candidate.name === expectedName);
|
|
1457
|
+
return channel?.type === ChannelType.GuildText ? channel : null;
|
|
1458
|
+
}
|
|
1459
|
+
function verificationManagedOverwriteKey(targetId, roleId) {
|
|
1460
|
+
return `${targetId}:${roleId}`;
|
|
1461
|
+
}
|
|
1462
|
+
async function restoreVerificationViewOverwrite(guild, overwrite, reason) {
|
|
1463
|
+
await setVerificationViewOverwrite(guild, overwrite.targetId, overwrite.roleId, overwrite.previousView, reason);
|
|
1464
|
+
}
|
|
1465
|
+
async function setVerificationViewOverwrite(guild, targetId, roleId, viewChannel, reason) {
|
|
1466
|
+
const [fetched, role] = await Promise.all([
|
|
1467
|
+
guild.channels.fetch(targetId),
|
|
1468
|
+
guild.roles.fetch(roleId),
|
|
1469
|
+
]);
|
|
1470
|
+
if (!fetched || !role)
|
|
1471
|
+
return;
|
|
1472
|
+
if (fetched.guild.id !== guild.id ||
|
|
1473
|
+
(fetched.type !== ChannelType.GuildText &&
|
|
1474
|
+
fetched.type !== ChannelType.GuildAnnouncement &&
|
|
1475
|
+
fetched.type !== ChannelType.GuildCategory))
|
|
1476
|
+
throw new Error("A tracked verification access target is invalid");
|
|
1477
|
+
if (viewChannelOverwrite(fetched, roleId) === viewChannel)
|
|
1478
|
+
return;
|
|
1479
|
+
await fetched.permissionOverwrites.edit(roleId, { ViewChannel: viewChannel }, { reason });
|
|
1480
|
+
}
|
|
1481
|
+
function verificationAccessTargetType(channel) {
|
|
1482
|
+
if (channel.type === ChannelType.GuildCategory)
|
|
1483
|
+
return "category";
|
|
1484
|
+
if (channel.type === ChannelType.GuildAnnouncement)
|
|
1485
|
+
return "announcement";
|
|
1486
|
+
return "text";
|
|
1487
|
+
}
|
|
1488
|
+
function viewChannelOverwrite(channel, roleId) {
|
|
1489
|
+
const overwrite = channel.permissionOverwrites.cache.get(roleId);
|
|
1490
|
+
if (!overwrite)
|
|
1491
|
+
return null;
|
|
1492
|
+
if (overwrite.allow.has(PermissionFlagsBits.ViewChannel))
|
|
1493
|
+
return true;
|
|
1494
|
+
if (overwrite.deny.has(PermissionFlagsBits.ViewChannel))
|
|
1495
|
+
return false;
|
|
1496
|
+
return null;
|
|
1497
|
+
}
|
|
1498
|
+
function verificationSetupMissingPermissions(input) {
|
|
1499
|
+
const missing = [];
|
|
1500
|
+
const effective = input.verificationChannel.channel?.permissionsFor(input.me);
|
|
1501
|
+
const has = (permission, guildFallback) => effective ? effective.has(permission) : guildFallback;
|
|
1502
|
+
if (!has(PermissionFlagsBits.ViewChannel, input.inspection.permissions.viewChannel))
|
|
1503
|
+
missing.push("ViewChannel");
|
|
1504
|
+
if (!has(PermissionFlagsBits.SendMessages, input.inspection.permissions.sendMessages))
|
|
1505
|
+
missing.push("SendMessages");
|
|
1506
|
+
if (!has(PermissionFlagsBits.EmbedLinks, input.inspection.permissions.embedLinks))
|
|
1507
|
+
missing.push("EmbedLinks");
|
|
1508
|
+
if (!input.inspection.permissions.manageRoles)
|
|
1509
|
+
missing.push("ManageRoles");
|
|
1510
|
+
if (input.needsManageChannels && !input.inspection.permissions.manageChannels)
|
|
1511
|
+
missing.push("ManageChannels");
|
|
1512
|
+
return missing;
|
|
1513
|
+
}
|
|
1514
|
+
async function assertVerificationSetupStillSafe(guild, role, channel, input) {
|
|
1515
|
+
const me = guild.members.me ?? (await guild.members.fetchMe());
|
|
1516
|
+
if (!verificationRoleManageable(role, guild, me))
|
|
1517
|
+
throw new Error("The verified role is no longer manageable by Helyx");
|
|
1518
|
+
if (!verificationChannelUsable(channel, me))
|
|
1519
|
+
throw new Error("The verification channel is no longer usable by Helyx");
|
|
1520
|
+
if (input.needsManageChannels &&
|
|
1521
|
+
!me.permissions.has(PermissionFlagsBits.ManageChannels))
|
|
1522
|
+
throw new Error("Helyx no longer has Manage Channels");
|
|
1523
|
+
}
|
|
1524
|
+
function validateVerificationSetupRequest(input) {
|
|
1525
|
+
validateSnowflake(input.guildId, "guild");
|
|
1526
|
+
if (input.verifiedRole.mode === "existing")
|
|
1527
|
+
validateSnowflake(input.verifiedRole.roleId, "verified role");
|
|
1528
|
+
else {
|
|
1529
|
+
validateVerificationRoleName(input.verifiedRole.name);
|
|
1530
|
+
if (input.verifiedRole.trackedRoleId)
|
|
1531
|
+
validateSnowflake(input.verifiedRole.trackedRoleId, "tracked role");
|
|
1532
|
+
}
|
|
1533
|
+
if (input.verificationChannel.mode === "existing")
|
|
1534
|
+
validateSnowflake(input.verificationChannel.channelId, "verification channel");
|
|
1535
|
+
else {
|
|
1536
|
+
if (!verificationChannelNamePattern.test(input.verificationChannel.name))
|
|
1537
|
+
throw new Error("Verification channel name is invalid");
|
|
1538
|
+
if (input.verificationChannel.trackedChannelId)
|
|
1539
|
+
validateSnowflake(input.verificationChannel.trackedChannelId, "tracked channel");
|
|
1540
|
+
}
|
|
1541
|
+
if (input.accessTargetIds.length > verificationSetupMaximumAccessTargets ||
|
|
1542
|
+
new Set(input.accessTargetIds).size !== input.accessTargetIds.length)
|
|
1543
|
+
throw new Error("Verification access targets must be unique and bounded");
|
|
1544
|
+
for (const targetId of input.accessTargetIds)
|
|
1545
|
+
validateSnowflake(targetId, "access target");
|
|
1546
|
+
}
|
|
1547
|
+
function validateVerificationRoleName(name) {
|
|
1548
|
+
const normalized = name.trim();
|
|
1549
|
+
if (normalized.length === 0 ||
|
|
1550
|
+
normalized.length > verificationRoleNameMaximum ||
|
|
1551
|
+
/[\r\n]/u.test(normalized) ||
|
|
1552
|
+
normalized === "@everyone" ||
|
|
1553
|
+
normalized === "@here")
|
|
1554
|
+
throw new Error("Verification role name is invalid");
|
|
1555
|
+
}
|
|
1556
|
+
function verificationSetupFingerprint(request, preview) {
|
|
1557
|
+
return createHash("sha256")
|
|
1558
|
+
.update(stableVerificationSetupJson({
|
|
1559
|
+
protocol: "helyx-verification-setup-v1",
|
|
1560
|
+
request,
|
|
1561
|
+
preview,
|
|
1562
|
+
}))
|
|
1563
|
+
.digest("hex");
|
|
1564
|
+
}
|
|
1565
|
+
function stableVerificationSetupJson(value) {
|
|
1566
|
+
if (value === null || typeof value !== "object")
|
|
1567
|
+
return JSON.stringify(value);
|
|
1568
|
+
if (Array.isArray(value))
|
|
1569
|
+
return `[${value.map(stableVerificationSetupJson).join(",")}]`;
|
|
1570
|
+
const record = value;
|
|
1571
|
+
return `{${Object.keys(record)
|
|
1572
|
+
.sort((left, right) => left.localeCompare(right, "en"))
|
|
1573
|
+
.map((key) => `${JSON.stringify(key)}:${stableVerificationSetupJson(record[key])}`)
|
|
1574
|
+
.join(",")}}`;
|
|
1575
|
+
}
|
|
408
1576
|
export class DiscordMemberRoleService {
|
|
409
1577
|
client;
|
|
410
1578
|
constructor(client) {
|
|
411
1579
|
this.client = client;
|
|
412
1580
|
}
|
|
1581
|
+
async createApprovedRole(input) {
|
|
1582
|
+
validateRoleCreationMetadata(input);
|
|
1583
|
+
const guild = await this.client.guilds.fetch(input.guildId);
|
|
1584
|
+
if (guild.id !== input.guildId)
|
|
1585
|
+
throw new Error("Discord guild validation failed");
|
|
1586
|
+
const me = guild.members.me ?? (await guild.members.fetchMe());
|
|
1587
|
+
if (!me.permissions.has(PermissionFlagsBits.ManageRoles))
|
|
1588
|
+
throw new Error("Helyx requires Manage Roles to create this reward role");
|
|
1589
|
+
const role = await guild.roles.create({
|
|
1590
|
+
name: input.name,
|
|
1591
|
+
permissions: [],
|
|
1592
|
+
reason: discordAuditReason(input),
|
|
1593
|
+
});
|
|
1594
|
+
if (role.guild.id !== guild.id)
|
|
1595
|
+
throw new Error("Created Discord role did not belong to this guild");
|
|
1596
|
+
await fetchSafeRole(guild, role.id);
|
|
1597
|
+
return { roleId: role.id, name: role.name };
|
|
1598
|
+
}
|
|
413
1599
|
async getCurrentRoleIds(input) {
|
|
414
1600
|
const { member } = await this.fetchGuildAndMember(input);
|
|
415
1601
|
return [...member.roles.cache.keys()];
|
|
@@ -452,10 +1638,10 @@ export class DiscordMemberRoleService {
|
|
|
452
1638
|
const addedRoles = addedIds.map((roleId) => approvedRoles.get(roleId));
|
|
453
1639
|
const removedRoles = removedIds.map((roleId) => approvedRoles.get(roleId));
|
|
454
1640
|
const reason = discordAuditReason(input);
|
|
455
|
-
if (removedRoles.length > 0)
|
|
456
|
-
await member.roles.remove(removedRoles, reason);
|
|
457
1641
|
if (addedRoles.length > 0)
|
|
458
1642
|
await member.roles.add(addedRoles, reason);
|
|
1643
|
+
if (removedRoles.length > 0)
|
|
1644
|
+
await member.roles.remove(removedRoles, reason);
|
|
459
1645
|
return { added: addedIds, removed: removedIds };
|
|
460
1646
|
}
|
|
461
1647
|
async fetchGuildAndMember(input) {
|
|
@@ -475,6 +1661,32 @@ export class DiscordResourceInspection {
|
|
|
475
1661
|
constructor(client) {
|
|
476
1662
|
this.client = client;
|
|
477
1663
|
}
|
|
1664
|
+
async inspectGuildCapabilities(input) {
|
|
1665
|
+
validateSnowflake(input.guildId, "guild");
|
|
1666
|
+
const guild = await this.client.guilds.fetch(input.guildId);
|
|
1667
|
+
if (guild.id !== input.guildId)
|
|
1668
|
+
throw new Error("Discord guild validation failed");
|
|
1669
|
+
const me = guild.members.me ?? (await guild.members.fetchMe());
|
|
1670
|
+
const effectivePermissionNames = me.permissions
|
|
1671
|
+
.toArray()
|
|
1672
|
+
.map(normalizePermissionName)
|
|
1673
|
+
.sort();
|
|
1674
|
+
return {
|
|
1675
|
+
guildId: guild.id,
|
|
1676
|
+
effectivePermissionNames,
|
|
1677
|
+
manageRoles: me.permissions.has(PermissionFlagsBits.ManageRoles),
|
|
1678
|
+
kickMembers: me.permissions.has(PermissionFlagsBits.KickMembers),
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
inspectRuntimeCapabilities() {
|
|
1682
|
+
const intents = this.client.options.intents;
|
|
1683
|
+
return Promise.resolve({
|
|
1684
|
+
guildMembers: intents.has(GatewayIntentBits.GuildMembers),
|
|
1685
|
+
guildMessages: intents.has(GatewayIntentBits.GuildMessages),
|
|
1686
|
+
guildMessageReactions: intents.has(GatewayIntentBits.GuildMessageReactions),
|
|
1687
|
+
messageContent: intents.has(GatewayIntentBits.MessageContent),
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
478
1690
|
async resolveMemberSnapshots(input) {
|
|
479
1691
|
const guild = await this.#resolveMemberGuild(input);
|
|
480
1692
|
return Promise.all(input.userIds.map(async (userId) => {
|
|
@@ -502,12 +1714,41 @@ export class DiscordResourceInspection {
|
|
|
502
1714
|
return null;
|
|
503
1715
|
throw error;
|
|
504
1716
|
});
|
|
1717
|
+
const avatarUrl = memberAvatarUrl(member);
|
|
505
1718
|
return {
|
|
506
1719
|
userId,
|
|
507
1720
|
displayName: member?.displayName ?? null,
|
|
1721
|
+
...(avatarUrl ? { avatarUrl } : {}),
|
|
508
1722
|
};
|
|
509
1723
|
}));
|
|
510
1724
|
}
|
|
1725
|
+
async searchMembers(input) {
|
|
1726
|
+
validateSnowflake(input.guildId, "guild");
|
|
1727
|
+
const query = input.query.trim();
|
|
1728
|
+
if (query.length < 1 || query.length > 100)
|
|
1729
|
+
throw new Error("Discord member search query is invalid");
|
|
1730
|
+
if (!Number.isSafeInteger(input.limit) ||
|
|
1731
|
+
input.limit < 1 ||
|
|
1732
|
+
input.limit > 25)
|
|
1733
|
+
throw new Error("Discord member search limit is invalid");
|
|
1734
|
+
const guild = await this.client.guilds.fetch(input.guildId);
|
|
1735
|
+
if (guild.id !== input.guildId)
|
|
1736
|
+
throw new Error("Discord guild validation failed");
|
|
1737
|
+
const members = await guild.members.search({
|
|
1738
|
+
query,
|
|
1739
|
+
limit: input.limit,
|
|
1740
|
+
cache: false,
|
|
1741
|
+
});
|
|
1742
|
+
return [...members.values()].map((member) => {
|
|
1743
|
+
const avatarUrl = memberAvatarUrl(member);
|
|
1744
|
+
return {
|
|
1745
|
+
userId: member.id,
|
|
1746
|
+
displayName: member.displayName,
|
|
1747
|
+
...(avatarUrl ? { avatarUrl } : {}),
|
|
1748
|
+
isBot: member.user.bot,
|
|
1749
|
+
};
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
511
1752
|
async #resolveMemberGuild(input) {
|
|
512
1753
|
validateSnowflake(input.guildId, "guild");
|
|
513
1754
|
if (input.userIds.length > 50 ||
|
|
@@ -640,6 +1881,7 @@ export class DiscordResourceInspection {
|
|
|
640
1881
|
sendMessages: false,
|
|
641
1882
|
addReactions: false,
|
|
642
1883
|
readMessageHistory: false,
|
|
1884
|
+
manageMessages: false,
|
|
643
1885
|
};
|
|
644
1886
|
const guildMatches = channel.guild.id === guild.id;
|
|
645
1887
|
const me = guild.members.me ?? (await guild.members.fetchMe());
|
|
@@ -665,8 +1907,198 @@ export class DiscordResourceInspection {
|
|
|
665
1907
|
(effective?.has(PermissionFlagsBits.AddReactions) ?? false),
|
|
666
1908
|
readMessageHistory: guildMatches &&
|
|
667
1909
|
(effective?.has(PermissionFlagsBits.ReadMessageHistory) ?? false),
|
|
1910
|
+
manageMessages: guildMatches &&
|
|
1911
|
+
(effective?.has(PermissionFlagsBits.ManageMessages) ?? false),
|
|
668
1912
|
};
|
|
669
1913
|
}
|
|
1914
|
+
async inspectMessage(input) {
|
|
1915
|
+
validateSnowflake(input.guildId, "guild");
|
|
1916
|
+
validateSnowflake(input.channelId, "channel");
|
|
1917
|
+
validateSnowflake(input.messageId, "message");
|
|
1918
|
+
const guild = await this.client.guilds.fetch(input.guildId);
|
|
1919
|
+
if (guild.id !== input.guildId)
|
|
1920
|
+
throw new Error("Discord guild validation failed");
|
|
1921
|
+
const channel = await guild.channels.fetch(input.channelId);
|
|
1922
|
+
if (!channel ||
|
|
1923
|
+
channel.guild.id !== guild.id ||
|
|
1924
|
+
!channel.isTextBased() ||
|
|
1925
|
+
channel.isDMBased()) {
|
|
1926
|
+
return missingMessageInspection(input, false);
|
|
1927
|
+
}
|
|
1928
|
+
const message = await channel.messages
|
|
1929
|
+
.fetch(input.messageId)
|
|
1930
|
+
.catch((error) => {
|
|
1931
|
+
if (isUnknownMessage(error))
|
|
1932
|
+
return null;
|
|
1933
|
+
throw error;
|
|
1934
|
+
});
|
|
1935
|
+
if (!message)
|
|
1936
|
+
return missingMessageInspection(input, true);
|
|
1937
|
+
const guildMatches = message.guildId === guild.id;
|
|
1938
|
+
const channelMatches = message.channelId === channel.id;
|
|
1939
|
+
return {
|
|
1940
|
+
guildId: guild.id,
|
|
1941
|
+
channelId: channel.id,
|
|
1942
|
+
messageId: message.id,
|
|
1943
|
+
exists: guildMatches && channelMatches && message.id === input.messageId,
|
|
1944
|
+
guildMatches,
|
|
1945
|
+
channelMatches,
|
|
1946
|
+
authorUserId: message.author.id,
|
|
1947
|
+
};
|
|
1948
|
+
}
|
|
1949
|
+
async inspectChannelAudience(input) {
|
|
1950
|
+
validateSnowflake(input.guildId, "guild");
|
|
1951
|
+
validateSnowflake(input.channelId, "channel");
|
|
1952
|
+
validateBoundedRoleSet(input.roleIds, "audience");
|
|
1953
|
+
const guild = await this.client.guilds.fetch(input.guildId);
|
|
1954
|
+
if (guild.id !== input.guildId)
|
|
1955
|
+
throw new Error("Discord guild validation failed");
|
|
1956
|
+
const roles = await Promise.all(input.roleIds.map(async (roleId) => ({
|
|
1957
|
+
roleId,
|
|
1958
|
+
role: await guild.roles.fetch(roleId),
|
|
1959
|
+
})));
|
|
1960
|
+
const channel = await guild.channels.fetch(input.channelId);
|
|
1961
|
+
if (!channel) {
|
|
1962
|
+
return {
|
|
1963
|
+
guildId: guild.id,
|
|
1964
|
+
channelId: input.channelId,
|
|
1965
|
+
exists: false,
|
|
1966
|
+
guildMatches: false,
|
|
1967
|
+
everyoneCanView: false,
|
|
1968
|
+
roles: roles.map(({ roleId, role }) => ({
|
|
1969
|
+
roleId,
|
|
1970
|
+
exists: role !== null && role.guild.id === guild.id,
|
|
1971
|
+
canView: false,
|
|
1972
|
+
})),
|
|
1973
|
+
};
|
|
1974
|
+
}
|
|
1975
|
+
const guildMatches = channel.guild.id === guild.id;
|
|
1976
|
+
return {
|
|
1977
|
+
guildId: guild.id,
|
|
1978
|
+
channelId: channel.id,
|
|
1979
|
+
exists: true,
|
|
1980
|
+
guildMatches,
|
|
1981
|
+
everyoneCanView: guildMatches &&
|
|
1982
|
+
(channel
|
|
1983
|
+
.permissionsFor(guild.roles.everyone)
|
|
1984
|
+
?.has(PermissionFlagsBits.ViewChannel) ??
|
|
1985
|
+
false),
|
|
1986
|
+
roles: roles.map(({ roleId, role }) => {
|
|
1987
|
+
const exists = role !== null && role.guild.id === guild.id;
|
|
1988
|
+
return {
|
|
1989
|
+
roleId,
|
|
1990
|
+
exists,
|
|
1991
|
+
canView: guildMatches &&
|
|
1992
|
+
role !== null &&
|
|
1993
|
+
role.guild.id === guild.id &&
|
|
1994
|
+
(channel
|
|
1995
|
+
.permissionsFor(role)
|
|
1996
|
+
?.has(PermissionFlagsBits.ViewChannel) ??
|
|
1997
|
+
false),
|
|
1998
|
+
};
|
|
1999
|
+
}),
|
|
2000
|
+
};
|
|
2001
|
+
}
|
|
2002
|
+
async inspectGuildChannelAudience(input) {
|
|
2003
|
+
validateSnowflake(input.guildId, "guild");
|
|
2004
|
+
validateBoundedRoleSet(input.roleIds, "audience");
|
|
2005
|
+
const guild = await this.client.guilds.fetch(input.guildId);
|
|
2006
|
+
if (guild.id !== input.guildId)
|
|
2007
|
+
throw new Error("Discord guild validation failed");
|
|
2008
|
+
const resolvedRoles = await Promise.all(input.roleIds.map(async (roleId) => ({
|
|
2009
|
+
roleId,
|
|
2010
|
+
role: await guild.roles.fetch(roleId),
|
|
2011
|
+
})));
|
|
2012
|
+
const missingRoleIds = resolvedRoles
|
|
2013
|
+
.filter(({ role }) => role === null || role.guild.id !== guild.id)
|
|
2014
|
+
.map(({ roleId }) => roleId);
|
|
2015
|
+
const fetched = await guild.channels.fetch();
|
|
2016
|
+
const relevant = [];
|
|
2017
|
+
for (const channel of fetched.values()) {
|
|
2018
|
+
if (channel &&
|
|
2019
|
+
(channel.type === ChannelType.GuildText ||
|
|
2020
|
+
channel.type === ChannelType.GuildAnnouncement)) {
|
|
2021
|
+
if (channel.guild.id !== guild.id)
|
|
2022
|
+
throw new Error("Discord channel guild validation failed");
|
|
2023
|
+
relevant.push(channel);
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
relevant.sort((left, right) => left.id.localeCompare(right.id, "en"));
|
|
2027
|
+
const truncated = relevant.length > guildChannelAudienceInspectionLimit;
|
|
2028
|
+
const channels = relevant
|
|
2029
|
+
.slice(0, guildChannelAudienceInspectionLimit)
|
|
2030
|
+
.map((channel) => {
|
|
2031
|
+
const everyoneCanView = channel
|
|
2032
|
+
.permissionsFor(guild.roles.everyone)
|
|
2033
|
+
?.has(PermissionFlagsBits.ViewChannel) ?? false;
|
|
2034
|
+
const visibleRoleIds = resolvedRoles
|
|
2035
|
+
.filter(({ role }) => role !== null &&
|
|
2036
|
+
role.guild.id === guild.id &&
|
|
2037
|
+
(channel
|
|
2038
|
+
.permissionsFor(role)
|
|
2039
|
+
?.has(PermissionFlagsBits.ViewChannel) ??
|
|
2040
|
+
false))
|
|
2041
|
+
.map(({ roleId }) => roleId);
|
|
2042
|
+
return {
|
|
2043
|
+
channelId: channel.id,
|
|
2044
|
+
type: channel.type === ChannelType.GuildText
|
|
2045
|
+
? "guild_text"
|
|
2046
|
+
: "guild_announcement",
|
|
2047
|
+
everyoneCanView,
|
|
2048
|
+
visibleRoleIds,
|
|
2049
|
+
};
|
|
2050
|
+
})
|
|
2051
|
+
.filter(({ everyoneCanView, visibleRoleIds }) => everyoneCanView || visibleRoleIds.length > 0);
|
|
2052
|
+
return {
|
|
2053
|
+
guildId: guild.id,
|
|
2054
|
+
channels,
|
|
2055
|
+
missingRoleIds,
|
|
2056
|
+
truncated,
|
|
2057
|
+
};
|
|
2058
|
+
}
|
|
2059
|
+
async seedMessageReaction(input) {
|
|
2060
|
+
validateSnowflake(input.guildId, "guild");
|
|
2061
|
+
validateSnowflake(input.channelId, "channel");
|
|
2062
|
+
validateSnowflake(input.messageId, "message");
|
|
2063
|
+
const guild = await this.client.guilds.fetch(input.guildId);
|
|
2064
|
+
if (guild.id !== input.guildId)
|
|
2065
|
+
throw new Error("Discord guild validation failed");
|
|
2066
|
+
const channel = await guild.channels.fetch(input.channelId);
|
|
2067
|
+
if (!channel ||
|
|
2068
|
+
channel.guild.id !== guild.id ||
|
|
2069
|
+
!channel.isTextBased() ||
|
|
2070
|
+
channel.isDMBased()) {
|
|
2071
|
+
throw new Error("Discord reaction message channel is unavailable");
|
|
2072
|
+
}
|
|
2073
|
+
const message = await channel.messages.fetch(input.messageId);
|
|
2074
|
+
if (message.id !== input.messageId ||
|
|
2075
|
+
message.guildId !== guild.id ||
|
|
2076
|
+
message.channelId !== channel.id) {
|
|
2077
|
+
throw new Error("Discord reaction message validation failed");
|
|
2078
|
+
}
|
|
2079
|
+
const emoji = await this.inspectEmoji({
|
|
2080
|
+
guildId: guild.id,
|
|
2081
|
+
emojiIdentifier: input.emojiIdentifier,
|
|
2082
|
+
});
|
|
2083
|
+
if (!emoji.exists || !emoji.guildMatches || !emoji.usable) {
|
|
2084
|
+
throw new Error("Discord reaction emoji is unavailable");
|
|
2085
|
+
}
|
|
2086
|
+
const cacheKey = emoji.kind === "custom" ? emoji.emojiId : (emoji.name ?? "");
|
|
2087
|
+
if (message.reactions.cache.get(cacheKey)?.me)
|
|
2088
|
+
return { added: false };
|
|
2089
|
+
const resolvedEmoji = emoji.kind === "custom"
|
|
2090
|
+
? await guild.emojis.fetch(emoji.emojiId)
|
|
2091
|
+
: emoji.name;
|
|
2092
|
+
if (!resolvedEmoji)
|
|
2093
|
+
throw new Error("Discord reaction emoji is unavailable");
|
|
2094
|
+
await message.react(resolvedEmoji);
|
|
2095
|
+
return { added: true };
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
function memberAvatarUrl(member) {
|
|
2099
|
+
return member && typeof member.displayAvatarURL === "function"
|
|
2100
|
+
? member.displayAvatarURL({ extension: "webp", size: 64 })
|
|
2101
|
+
: "";
|
|
670
2102
|
}
|
|
671
2103
|
async function fetchSafeRole(guild, roleId) {
|
|
672
2104
|
validateSnowflake(roleId, "role");
|
|
@@ -710,6 +2142,24 @@ function missingRoleInspection(input, hierarchy) {
|
|
|
710
2142
|
dangerousPermissionNames: [],
|
|
711
2143
|
};
|
|
712
2144
|
}
|
|
2145
|
+
function missingMessageInspection(input, channelMatches) {
|
|
2146
|
+
return {
|
|
2147
|
+
...input,
|
|
2148
|
+
exists: false,
|
|
2149
|
+
guildMatches: channelMatches,
|
|
2150
|
+
channelMatches,
|
|
2151
|
+
};
|
|
2152
|
+
}
|
|
2153
|
+
function validateEnforcementMetadata(input) {
|
|
2154
|
+
validateSnowflake(input.guildId, "guild");
|
|
2155
|
+
validateSnowflake(input.memberUserId, "member");
|
|
2156
|
+
if (input.moduleId.length > 100 || !moduleIdentifier.test(input.moduleId))
|
|
2157
|
+
throw new Error("Member enforcement requires a valid Helyx module ID");
|
|
2158
|
+
if (input.reason.trim().length === 0 ||
|
|
2159
|
+
input.reason.length > 400 ||
|
|
2160
|
+
/[\r\n]/u.test(input.reason))
|
|
2161
|
+
throw new Error("Member enforcement requires a bounded audit reason");
|
|
2162
|
+
}
|
|
713
2163
|
function validateMutationMetadata(input) {
|
|
714
2164
|
validateSnowflake(input.guildId, "guild");
|
|
715
2165
|
validateSnowflake(input.memberUserId, "member");
|
|
@@ -722,6 +2172,22 @@ function validateMutationMetadata(input) {
|
|
|
722
2172
|
/[\r\n]/u.test(input.reason))
|
|
723
2173
|
throw new Error("Role mutation requires a bounded audit reason");
|
|
724
2174
|
}
|
|
2175
|
+
function validateRoleCreationMetadata(input) {
|
|
2176
|
+
validateSnowflake(input.guildId, "guild");
|
|
2177
|
+
if (input.name.trim() !== input.name ||
|
|
2178
|
+
input.name.length < 1 ||
|
|
2179
|
+
input.name.length > 100 ||
|
|
2180
|
+
/[\r\n\0]/u.test(input.name) ||
|
|
2181
|
+
input.name.toLowerCase() === "@everyone" ||
|
|
2182
|
+
input.name.toLowerCase() === "@here")
|
|
2183
|
+
throw new Error("Role creation requires a safe name from 1 to 100 characters");
|
|
2184
|
+
if (input.moduleId.length > 100 || !moduleIdentifier.test(input.moduleId))
|
|
2185
|
+
throw new Error("Role creation requires a valid Helyx module ID");
|
|
2186
|
+
if (input.reason.trim().length === 0 ||
|
|
2187
|
+
input.reason.length > 400 ||
|
|
2188
|
+
/[\r\n]/u.test(input.reason))
|
|
2189
|
+
throw new Error("Role creation requires a bounded audit reason");
|
|
2190
|
+
}
|
|
725
2191
|
async function fetchExactMember(guild, memberUserId, label) {
|
|
726
2192
|
validateSnowflake(memberUserId, label);
|
|
727
2193
|
const member = await guild.members.fetch(memberUserId);
|
|
@@ -735,6 +2201,18 @@ function isUnknownMember(error) {
|
|
|
735
2201
|
"code" in error &&
|
|
736
2202
|
error.code === RESTJSONErrorCodes.UnknownMember);
|
|
737
2203
|
}
|
|
2204
|
+
function isUnknownMessage(error) {
|
|
2205
|
+
return (typeof error === "object" &&
|
|
2206
|
+
error !== null &&
|
|
2207
|
+
"code" in error &&
|
|
2208
|
+
error.code === RESTJSONErrorCodes.UnknownMessage);
|
|
2209
|
+
}
|
|
2210
|
+
function isClosedDirectMessage(error) {
|
|
2211
|
+
return (typeof error === "object" &&
|
|
2212
|
+
error !== null &&
|
|
2213
|
+
"code" in error &&
|
|
2214
|
+
error.code === RESTJSONErrorCodes.CannotSendMessagesToThisUser);
|
|
2215
|
+
}
|
|
738
2216
|
function validateBoundedRoleSet(roleIds, label) {
|
|
739
2217
|
if (roleIds.length > 100 || new Set(roleIds).size !== roleIds.length)
|
|
740
2218
|
throw new Error(`${label} role IDs must be unique and bounded`);
|