@helyx/bot 0.4.2 → 0.4.5
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 +35 -0
- package/README.md +13 -0
- package/dist/cloud-connector.d.ts +7 -0
- package/dist/cloud-connector.js +118 -1
- package/dist/cloud-control-dispatcher.js +84 -0
- package/dist/control-server.d.ts +187 -5
- package/dist/control-server.js +673 -6
- package/dist/developer-operations-module.js +8 -0
- package/dist/discord-owned-message-service.d.ts +39 -0
- package/dist/discord-owned-message-service.js +138 -0
- package/dist/events.d.ts +70 -1
- package/dist/events.js +557 -5
- package/dist/index.js +61 -13
- package/dist/interactions.d.ts +3 -2
- package/dist/interactions.js +66 -31
- package/dist/module-logging.d.ts +1 -0
- package/dist/module-logging.js +19 -1
- package/dist/modules.d.ts +9 -0
- package/dist/modules.js +45 -0
- package/dist/resource-authorization.d.ts +7 -0
- package/dist/resource-authorization.js +12 -0
- package/dist/scheduled-task-runner.d.ts +130 -0
- package/dist/scheduled-task-runner.js +417 -0
- package/dist/shard-cloud-proxy.d.ts +4 -0
- package/dist/shard-cloud-proxy.js +34 -0
- package/dist/shard-contracts.d.ts +574 -23
- package/dist/shard-contracts.js +346 -0
- package/dist/shard-coordinator.d.ts +5 -3
- package/dist/shard-coordinator.js +85 -2
- package/dist/shard-router.d.ts +10 -0
- package/dist/shard-router.js +58 -0
- package/dist/shard-supervisor.d.ts +2 -1
- package/dist/shard-supervisor.js +187 -5
- package/dist/shard-worker.d.ts +9 -3
- package/dist/shard-worker.js +97 -16
- package/dist/support-module.d.ts +9 -0
- package/dist/support-module.js +172 -0
- package/package.json +9 -9
package/dist/events.js
CHANGED
|
@@ -1,23 +1,77 @@
|
|
|
1
1
|
import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
|
|
2
|
-
import { Events, } from "discord.js";
|
|
2
|
+
import { ChannelType, Events, PermissionFlagsBits, RESTJSONErrorCodes, } from "discord.js";
|
|
3
3
|
import { toReplyOptions } from "./interactions.js";
|
|
4
4
|
export function installModuleEvents(client, modules, logger, services) {
|
|
5
5
|
const reactionContributions = modules.flatMap((module) => (module.events?.messageReactionAdd ?? []).map((contribution) => ({
|
|
6
6
|
moduleId: module.manifest.id,
|
|
7
7
|
contribution,
|
|
8
8
|
})));
|
|
9
|
+
const reactionRemoveContributions = modules.flatMap((module) => (module.events?.messageReactionRemove ?? []).map((contribution) => ({
|
|
10
|
+
moduleId: module.manifest.id,
|
|
11
|
+
contribution,
|
|
12
|
+
})));
|
|
13
|
+
const messageDeleteContributions = modules.flatMap((module) => (module.events?.messageDelete ?? []).map((contribution) => ({
|
|
14
|
+
moduleId: module.manifest.id,
|
|
15
|
+
contribution,
|
|
16
|
+
})));
|
|
9
17
|
const memberAddContributions = modules.flatMap((module) => (module.events?.guildMemberAdd ?? []).map((contribution) => ({
|
|
10
18
|
moduleId: module.manifest.id,
|
|
11
19
|
contribution,
|
|
12
20
|
})));
|
|
13
|
-
|
|
21
|
+
const guildRoleDeleteContributions = modules.flatMap((module) => (module.events?.guildRoleDelete ?? []).map((contribution) => ({
|
|
22
|
+
moduleId: module.manifest.id,
|
|
23
|
+
contribution,
|
|
24
|
+
})));
|
|
25
|
+
const guildRoleUpdateContributions = modules.flatMap((module) => (module.events?.guildRoleUpdate ?? []).map((contribution) => ({
|
|
26
|
+
moduleId: module.manifest.id,
|
|
27
|
+
contribution,
|
|
28
|
+
})));
|
|
29
|
+
const channelDeleteContributions = modules.flatMap((module) => (module.events?.channelDelete ?? []).map((contribution) => ({
|
|
30
|
+
moduleId: module.manifest.id,
|
|
31
|
+
contribution,
|
|
32
|
+
})));
|
|
33
|
+
const channelUpdateContributions = modules.flatMap((module) => (module.events?.channelUpdate ?? []).map((contribution) => ({
|
|
34
|
+
moduleId: module.manifest.id,
|
|
35
|
+
contribution,
|
|
36
|
+
})));
|
|
37
|
+
const guildEmojiDeleteContributions = modules.flatMap((module) => (module.events?.guildEmojiDelete ?? []).map((contribution) => ({
|
|
38
|
+
moduleId: module.manifest.id,
|
|
39
|
+
contribution,
|
|
40
|
+
})));
|
|
41
|
+
const guildEmojiUpdateContributions = modules.flatMap((module) => (module.events?.guildEmojiUpdate ?? []).map((contribution) => ({
|
|
42
|
+
moduleId: module.manifest.id,
|
|
43
|
+
contribution,
|
|
44
|
+
})));
|
|
45
|
+
if (reactionContributions.length === 0 &&
|
|
46
|
+
reactionRemoveContributions.length === 0 &&
|
|
47
|
+
messageDeleteContributions.length === 0 &&
|
|
48
|
+
memberAddContributions.length === 0 &&
|
|
49
|
+
guildRoleDeleteContributions.length === 0 &&
|
|
50
|
+
guildRoleUpdateContributions.length === 0 &&
|
|
51
|
+
channelDeleteContributions.length === 0 &&
|
|
52
|
+
channelUpdateContributions.length === 0 &&
|
|
53
|
+
guildEmojiDeleteContributions.length === 0 &&
|
|
54
|
+
guildEmojiUpdateContributions.length === 0)
|
|
14
55
|
return { stop: () => Promise.resolve(), queueDepth: () => 0 };
|
|
15
56
|
const queue = new BoundedGuildEventQueue(logger);
|
|
16
57
|
if (reactionContributions.length > 0) {
|
|
17
|
-
client.on(Events.MessageReactionAdd, (reaction, user) => {
|
|
58
|
+
client.on(Events.MessageReactionAdd, (reaction, user, details) => {
|
|
18
59
|
const guildId = reaction.message.guildId;
|
|
19
60
|
if (guildId)
|
|
20
|
-
queue.enqueue(guildId, () => handleReaction(reaction, user));
|
|
61
|
+
queue.enqueue(guildId, () => handleReaction(reaction, user, details.burst));
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (reactionRemoveContributions.length > 0) {
|
|
65
|
+
client.on(Events.MessageReactionRemove, (reaction, user, details) => {
|
|
66
|
+
const guildId = reaction.message.guildId;
|
|
67
|
+
if (guildId)
|
|
68
|
+
queue.enqueue(guildId, () => handleReactionRemove(reaction, user, details.burst));
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
if (messageDeleteContributions.length > 0) {
|
|
72
|
+
client.on(Events.MessageDelete, (message) => {
|
|
73
|
+
if (message.guildId)
|
|
74
|
+
queue.enqueue(message.guildId, () => handleMessageDelete(message));
|
|
21
75
|
});
|
|
22
76
|
}
|
|
23
77
|
if (memberAddContributions.length > 0) {
|
|
@@ -25,7 +79,65 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
25
79
|
queue.enqueue(member.guild.id, () => handleMemberAdd(member));
|
|
26
80
|
});
|
|
27
81
|
}
|
|
28
|
-
|
|
82
|
+
if (guildRoleDeleteContributions.length > 0) {
|
|
83
|
+
client.on(Events.GuildRoleDelete, (role) => {
|
|
84
|
+
queue.enqueue(role.guild.id, () => handleResourceLifecycle(guildRoleDeleteContributions, role.guild.id, "roleId", role.id, "guild_role_delete"));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
if (guildRoleUpdateContributions.length > 0) {
|
|
88
|
+
client.on(Events.GuildRoleUpdate, (_oldRole, role) => {
|
|
89
|
+
queue.enqueue(role.guild.id, () => handleResourceLifecycle(guildRoleUpdateContributions, role.guild.id, "roleId", role.id, "guild_role_update"));
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (channelDeleteContributions.length > 0) {
|
|
93
|
+
client.on(Events.ChannelDelete, (channel) => {
|
|
94
|
+
if (channel.isDMBased())
|
|
95
|
+
return;
|
|
96
|
+
queue.enqueue(channel.guild.id, () => handleResourceLifecycle(channelDeleteContributions, channel.guild.id, "channelId", channel.id, "channel_delete"));
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (channelUpdateContributions.length > 0) {
|
|
100
|
+
client.on(Events.ChannelUpdate, (_oldChannel, channel) => {
|
|
101
|
+
if (channel.isDMBased())
|
|
102
|
+
return;
|
|
103
|
+
queue.enqueue(channel.guild.id, () => handleResourceLifecycle(channelUpdateContributions, channel.guild.id, "channelId", channel.id, "channel_update"));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
if (guildEmojiDeleteContributions.length > 0) {
|
|
107
|
+
client.on(Events.GuildEmojiDelete, (emoji) => {
|
|
108
|
+
queue.enqueue(emoji.guild.id, () => handleResourceLifecycle(guildEmojiDeleteContributions, emoji.guild.id, "emojiId", emoji.id, "guild_emoji_delete"));
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (guildEmojiUpdateContributions.length > 0) {
|
|
112
|
+
client.on(Events.GuildEmojiUpdate, (_oldEmoji, emoji) => {
|
|
113
|
+
queue.enqueue(emoji.guild.id, () => handleResourceLifecycle(guildEmojiUpdateContributions, emoji.guild.id, "emojiId", emoji.id, "guild_emoji_update"));
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
async function handleResourceLifecycle(contributions, serverId, identifier, resourceId, eventName) {
|
|
117
|
+
const installations = services.get(HELYX_SERVICE_NAMES.installations);
|
|
118
|
+
for (const { moduleId, contribution } of contributions) {
|
|
119
|
+
try {
|
|
120
|
+
if (!(await installations.isModuleEnabled(serverId, moduleId))) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
await contribution.execute({
|
|
124
|
+
serverId,
|
|
125
|
+
[identifier]: resourceId,
|
|
126
|
+
services,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
logger.warn({
|
|
131
|
+
event: `module.${eventName}_failed`,
|
|
132
|
+
moduleId,
|
|
133
|
+
error: safeError(error),
|
|
134
|
+
guildId: serverId,
|
|
135
|
+
[identifier]: resourceId,
|
|
136
|
+
}, "Module resource lifecycle event failed");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function handleReaction(receivedReaction, receivedUser, burst) {
|
|
29
141
|
try {
|
|
30
142
|
const user = receivedUser.partial
|
|
31
143
|
? await receivedUser.fetch()
|
|
@@ -55,6 +167,7 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
55
167
|
userId: receivedUser.id,
|
|
56
168
|
userRoleIds,
|
|
57
169
|
emojiKey: reactionEmojiKey(reaction),
|
|
170
|
+
reactionType: burst ? "burst" : "normal",
|
|
58
171
|
services,
|
|
59
172
|
removeAddedReaction: () => reaction.users.remove(user.id).then(() => undefined),
|
|
60
173
|
removeUserReaction: async (emojiIdentifier) => {
|
|
@@ -112,6 +225,74 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
112
225
|
}
|
|
113
226
|
}
|
|
114
227
|
}
|
|
228
|
+
async function handleReactionRemove(receivedReaction, receivedUser, burst) {
|
|
229
|
+
try {
|
|
230
|
+
if (receivedUser.bot)
|
|
231
|
+
return;
|
|
232
|
+
const message = receivedReaction.message;
|
|
233
|
+
if (!message.guildId || !message.channelId)
|
|
234
|
+
return;
|
|
235
|
+
const installations = services.get(HELYX_SERVICE_NAMES.installations);
|
|
236
|
+
for (const { moduleId, contribution } of reactionRemoveContributions) {
|
|
237
|
+
if (!(await installations.isModuleEnabled(message.guildId, moduleId))) {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
await contribution.execute({
|
|
242
|
+
serverId: message.guildId,
|
|
243
|
+
channelId: message.channelId,
|
|
244
|
+
messageId: message.id,
|
|
245
|
+
userId: receivedUser.id,
|
|
246
|
+
emojiKey: reactionEmojiKey(receivedReaction),
|
|
247
|
+
reactionType: burst ? "burst" : "normal",
|
|
248
|
+
services,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
logger.warn({
|
|
253
|
+
event: "module.reaction_remove_failed",
|
|
254
|
+
moduleId,
|
|
255
|
+
error: safeError(error),
|
|
256
|
+
userId: receivedUser.id,
|
|
257
|
+
}, "Module reaction-remove event failed");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
logger.warn({
|
|
263
|
+
event: "module.reaction_remove_failed",
|
|
264
|
+
error: safeError(error),
|
|
265
|
+
userId: receivedUser.id,
|
|
266
|
+
}, "Module reaction-remove event failed");
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
async function handleMessageDelete(message) {
|
|
270
|
+
if (!message.guildId || !message.channelId)
|
|
271
|
+
return;
|
|
272
|
+
const installations = services.get(HELYX_SERVICE_NAMES.installations);
|
|
273
|
+
for (const { moduleId, contribution } of messageDeleteContributions) {
|
|
274
|
+
try {
|
|
275
|
+
if (!(await installations.isModuleEnabled(message.guildId, moduleId))) {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
await contribution.execute({
|
|
279
|
+
serverId: message.guildId,
|
|
280
|
+
channelId: message.channelId,
|
|
281
|
+
messageId: message.id,
|
|
282
|
+
services,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
logger.warn({
|
|
287
|
+
event: "module.message_delete_failed",
|
|
288
|
+
moduleId,
|
|
289
|
+
error: safeError(error),
|
|
290
|
+
messageId: message.id,
|
|
291
|
+
guildId: message.guildId,
|
|
292
|
+
}, "Module message-delete event failed");
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
115
296
|
return { stop: () => queue.stop(), queueDepth: () => queue.queueDepth() };
|
|
116
297
|
}
|
|
117
298
|
class BoundedGuildEventQueue {
|
|
@@ -201,6 +382,377 @@ export function reactionCacheKey(emojiIdentifier) {
|
|
|
201
382
|
const custom = /^(?:[A-Za-z0-9_]{2,32}):(\d{17,20})$/u.exec(emojiIdentifier);
|
|
202
383
|
return custom?.[1] ?? emojiIdentifier;
|
|
203
384
|
}
|
|
385
|
+
const discordSnowflake = /^\d{17,20}$/u;
|
|
386
|
+
const moduleIdentifier = /^helyx\.[a-z0-9][a-z0-9-]*$/u;
|
|
387
|
+
const customEmojiIdentifier = /^(?:<a?:([A-Za-z0-9_]{2,32}):(\d{17,20})>|([A-Za-z0-9_]{2,32}):(\d{17,20}))$/u;
|
|
388
|
+
const dangerousRolePermissionNames = new Set([
|
|
389
|
+
"ADMINISTRATOR",
|
|
390
|
+
"BAN_MEMBERS",
|
|
391
|
+
"DEAFEN_MEMBERS",
|
|
392
|
+
"KICK_MEMBERS",
|
|
393
|
+
"MANAGE_CHANNELS",
|
|
394
|
+
"MANAGE_EVENTS",
|
|
395
|
+
"MANAGE_GUILD",
|
|
396
|
+
"MANAGE_GUILD_EXPRESSIONS",
|
|
397
|
+
"MANAGE_MESSAGES",
|
|
398
|
+
"MANAGE_NICKNAMES",
|
|
399
|
+
"MANAGE_ROLES",
|
|
400
|
+
"MANAGE_THREADS",
|
|
401
|
+
"MANAGE_WEBHOOKS",
|
|
402
|
+
"MENTION_EVERYONE",
|
|
403
|
+
"MODERATE_MEMBERS",
|
|
404
|
+
"MOVE_MEMBERS",
|
|
405
|
+
"MUTE_MEMBERS",
|
|
406
|
+
"VIEW_AUDIT_LOG",
|
|
407
|
+
]);
|
|
408
|
+
export class DiscordMemberRoleService {
|
|
409
|
+
client;
|
|
410
|
+
constructor(client) {
|
|
411
|
+
this.client = client;
|
|
412
|
+
}
|
|
413
|
+
async getCurrentRoleIds(input) {
|
|
414
|
+
const { member } = await this.fetchGuildAndMember(input);
|
|
415
|
+
return [...member.roles.cache.keys()];
|
|
416
|
+
}
|
|
417
|
+
async addApprovedRole(input) {
|
|
418
|
+
validateMutationMetadata(input);
|
|
419
|
+
const { guild, member } = await this.fetchGuildAndMember(input);
|
|
420
|
+
const role = await fetchSafeRole(guild, input.roleId);
|
|
421
|
+
if (member.roles.cache.has(role.id))
|
|
422
|
+
return { changed: false };
|
|
423
|
+
await member.roles.add(role, discordAuditReason(input));
|
|
424
|
+
return { changed: true };
|
|
425
|
+
}
|
|
426
|
+
async removeApprovedRole(input) {
|
|
427
|
+
validateMutationMetadata(input);
|
|
428
|
+
const { guild, member } = await this.fetchGuildAndMember(input);
|
|
429
|
+
const role = await fetchSafeRole(guild, input.roleId);
|
|
430
|
+
if (!member.roles.cache.has(role.id))
|
|
431
|
+
return { changed: false };
|
|
432
|
+
await member.roles.remove(role, discordAuditReason(input));
|
|
433
|
+
return { changed: true };
|
|
434
|
+
}
|
|
435
|
+
async reconcileApprovedRoles(input) {
|
|
436
|
+
validateMutationMetadata({
|
|
437
|
+
...input,
|
|
438
|
+
roleId: input.allowedRoleIds[0] ?? "",
|
|
439
|
+
});
|
|
440
|
+
validateBoundedRoleSet(input.allowedRoleIds, "allowed");
|
|
441
|
+
validateBoundedRoleSet(input.desiredRoleIds, "desired");
|
|
442
|
+
const allowed = new Set(input.allowedRoleIds);
|
|
443
|
+
if (input.desiredRoleIds.some((roleId) => !allowed.has(roleId))) {
|
|
444
|
+
throw new Error("Desired roles must be within the approved role set");
|
|
445
|
+
}
|
|
446
|
+
const { guild, member } = await this.fetchGuildAndMember(input);
|
|
447
|
+
const current = member.roles.cache;
|
|
448
|
+
const desired = new Set(input.desiredRoleIds);
|
|
449
|
+
const approvedRoles = new Map(await Promise.all(input.allowedRoleIds.map(async (roleId) => [roleId, await fetchSafeRole(guild, roleId)])));
|
|
450
|
+
const addedIds = input.desiredRoleIds.filter((roleId) => !current.has(roleId));
|
|
451
|
+
const removedIds = input.allowedRoleIds.filter((roleId) => current.has(roleId) && !desired.has(roleId));
|
|
452
|
+
const addedRoles = addedIds.map((roleId) => approvedRoles.get(roleId));
|
|
453
|
+
const removedRoles = removedIds.map((roleId) => approvedRoles.get(roleId));
|
|
454
|
+
const reason = discordAuditReason(input);
|
|
455
|
+
if (removedRoles.length > 0)
|
|
456
|
+
await member.roles.remove(removedRoles, reason);
|
|
457
|
+
if (addedRoles.length > 0)
|
|
458
|
+
await member.roles.add(addedRoles, reason);
|
|
459
|
+
return { added: addedIds, removed: removedIds };
|
|
460
|
+
}
|
|
461
|
+
async fetchGuildAndMember(input) {
|
|
462
|
+
validateSnowflake(input.guildId, "guild");
|
|
463
|
+
validateSnowflake(input.memberUserId, "member");
|
|
464
|
+
const guild = await this.client.guilds.fetch(input.guildId);
|
|
465
|
+
if (guild.id !== input.guildId)
|
|
466
|
+
throw new Error("Discord guild validation failed");
|
|
467
|
+
const member = await guild.members.fetch(input.memberUserId);
|
|
468
|
+
if (member.id !== input.memberUserId || member.guild.id !== guild.id)
|
|
469
|
+
throw new Error("Discord member validation failed");
|
|
470
|
+
return { guild, member };
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
export class DiscordResourceInspection {
|
|
474
|
+
client;
|
|
475
|
+
constructor(client) {
|
|
476
|
+
this.client = client;
|
|
477
|
+
}
|
|
478
|
+
async resolveMemberSnapshots(input) {
|
|
479
|
+
const guild = await this.#resolveMemberGuild(input);
|
|
480
|
+
return Promise.all(input.userIds.map(async (userId) => {
|
|
481
|
+
const member = await fetchExactMember(guild, userId, "member").catch((error) => {
|
|
482
|
+
if (isUnknownMember(error))
|
|
483
|
+
return null;
|
|
484
|
+
throw error;
|
|
485
|
+
});
|
|
486
|
+
return {
|
|
487
|
+
userId,
|
|
488
|
+
exists: member !== null,
|
|
489
|
+
isBot: member?.user.bot ?? false,
|
|
490
|
+
roleIds: member
|
|
491
|
+
? [...member.roles.cache.keys()].sort((left, right) => left.localeCompare(right, "en"))
|
|
492
|
+
: [],
|
|
493
|
+
joinedAt: member?.joinedAt ?? null,
|
|
494
|
+
};
|
|
495
|
+
}));
|
|
496
|
+
}
|
|
497
|
+
async resolveMembers(input) {
|
|
498
|
+
const guild = await this.#resolveMemberGuild(input);
|
|
499
|
+
return Promise.all(input.userIds.map(async (userId) => {
|
|
500
|
+
const member = await fetchExactMember(guild, userId, "member").catch((error) => {
|
|
501
|
+
if (isUnknownMember(error))
|
|
502
|
+
return null;
|
|
503
|
+
throw error;
|
|
504
|
+
});
|
|
505
|
+
return {
|
|
506
|
+
userId,
|
|
507
|
+
displayName: member?.displayName ?? null,
|
|
508
|
+
};
|
|
509
|
+
}));
|
|
510
|
+
}
|
|
511
|
+
async #resolveMemberGuild(input) {
|
|
512
|
+
validateSnowflake(input.guildId, "guild");
|
|
513
|
+
if (input.userIds.length > 50 ||
|
|
514
|
+
new Set(input.userIds).size !== input.userIds.length)
|
|
515
|
+
throw new Error("Discord member IDs must be unique and bounded");
|
|
516
|
+
for (const userId of input.userIds)
|
|
517
|
+
validateSnowflake(userId, "member");
|
|
518
|
+
const guild = await this.client.guilds.fetch(input.guildId);
|
|
519
|
+
if (guild.id !== input.guildId)
|
|
520
|
+
throw new Error("Discord guild validation failed");
|
|
521
|
+
return guild;
|
|
522
|
+
}
|
|
523
|
+
async inspectRole(input) {
|
|
524
|
+
validateSnowflake(input.guildId, "guild");
|
|
525
|
+
validateSnowflake(input.roleId, "role");
|
|
526
|
+
const guild = await this.client.guilds.fetch(input.guildId);
|
|
527
|
+
if (guild.id !== input.guildId)
|
|
528
|
+
throw new Error("Discord guild validation failed");
|
|
529
|
+
const role = await guild.roles.fetch(input.roleId);
|
|
530
|
+
if (!role)
|
|
531
|
+
return missingRoleInspection(input, "missing");
|
|
532
|
+
if (role.guild.id !== guild.id)
|
|
533
|
+
return missingRoleInspection(input, "guild-mismatch");
|
|
534
|
+
const me = guild.members.me ?? (await guild.members.fetchMe());
|
|
535
|
+
const actor = input.actorUserId
|
|
536
|
+
? await fetchExactMember(guild, input.actorUserId, "actor")
|
|
537
|
+
: undefined;
|
|
538
|
+
const permissions = role.permissions
|
|
539
|
+
.toArray()
|
|
540
|
+
.map(normalizePermissionName)
|
|
541
|
+
.sort();
|
|
542
|
+
const dangerous = permissions.filter((permission) => dangerousRolePermissionNames.has(permission));
|
|
543
|
+
const botHierarchy = roleHierarchy(role, guild, me);
|
|
544
|
+
const actorAssignable = actor === undefined
|
|
545
|
+
? botHierarchy === "assignable"
|
|
546
|
+
: botHierarchy === "assignable" &&
|
|
547
|
+
(guild.ownerId === actor.id ||
|
|
548
|
+
role.comparePositionTo(actor.roles.highest) < 0);
|
|
549
|
+
const hierarchy = botHierarchy === "assignable" && !actorAssignable
|
|
550
|
+
? "above-actor"
|
|
551
|
+
: botHierarchy;
|
|
552
|
+
return {
|
|
553
|
+
guildId: guild.id,
|
|
554
|
+
roleId: role.id,
|
|
555
|
+
exists: true,
|
|
556
|
+
guildMatches: true,
|
|
557
|
+
name: role.name,
|
|
558
|
+
managed: role.managed,
|
|
559
|
+
everyone: role.id === guild.id,
|
|
560
|
+
assignable: hierarchy === "assignable" && actorAssignable && dangerous.length === 0,
|
|
561
|
+
actorAssignable,
|
|
562
|
+
hierarchy,
|
|
563
|
+
permissionNames: permissions,
|
|
564
|
+
dangerousPermissionNames: dangerous,
|
|
565
|
+
helyxHighestRoleId: me.roles.highest.id,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
async inspectEmoji(input) {
|
|
569
|
+
validateSnowflake(input.guildId, "guild");
|
|
570
|
+
if (input.emojiIdentifier.length === 0 ||
|
|
571
|
+
input.emojiIdentifier.length > 128 ||
|
|
572
|
+
/[\r\n]/u.test(input.emojiIdentifier))
|
|
573
|
+
throw new Error("Invalid bounded Discord emoji identifier");
|
|
574
|
+
const guild = await this.client.guilds.fetch(input.guildId);
|
|
575
|
+
if (guild.id !== input.guildId)
|
|
576
|
+
throw new Error("Discord guild validation failed");
|
|
577
|
+
const custom = customEmojiIdentifier.exec(input.emojiIdentifier);
|
|
578
|
+
if (!custom) {
|
|
579
|
+
const normalized = input.emojiIdentifier.normalize("NFKC").trim();
|
|
580
|
+
const valid = isSingleUnicodeEmoji(normalized);
|
|
581
|
+
return {
|
|
582
|
+
guildId: guild.id,
|
|
583
|
+
emojiIdentifier: input.emojiIdentifier,
|
|
584
|
+
exists: valid,
|
|
585
|
+
guildMatches: true,
|
|
586
|
+
usable: valid,
|
|
587
|
+
kind: "unicode",
|
|
588
|
+
...(valid
|
|
589
|
+
? { canonicalEmojiKey: `unicode:${normalized}`, name: normalized }
|
|
590
|
+
: {}),
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
const emojiId = custom[2] ?? custom[4];
|
|
594
|
+
const emoji = await guild.emojis.fetch(emojiId).catch(() => null);
|
|
595
|
+
if (!emoji)
|
|
596
|
+
return {
|
|
597
|
+
guildId: guild.id,
|
|
598
|
+
emojiIdentifier: input.emojiIdentifier,
|
|
599
|
+
exists: false,
|
|
600
|
+
guildMatches: false,
|
|
601
|
+
usable: false,
|
|
602
|
+
kind: "custom",
|
|
603
|
+
emojiId,
|
|
604
|
+
};
|
|
605
|
+
const guildMatches = emoji.guild.id === guild.id;
|
|
606
|
+
return {
|
|
607
|
+
guildId: guild.id,
|
|
608
|
+
emojiIdentifier: input.emojiIdentifier,
|
|
609
|
+
exists: true,
|
|
610
|
+
guildMatches,
|
|
611
|
+
usable: guildMatches && emoji.available === true,
|
|
612
|
+
kind: "custom",
|
|
613
|
+
canonicalEmojiKey: `custom:${emoji.id}`,
|
|
614
|
+
emojiId: emoji.id,
|
|
615
|
+
name: emoji.name ?? custom[1] ?? custom[3] ?? "emoji",
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
async inspectChannel(input) {
|
|
619
|
+
validateSnowflake(input.guildId, "guild");
|
|
620
|
+
validateSnowflake(input.channelId, "channel");
|
|
621
|
+
const guild = await this.client.guilds.fetch(input.guildId);
|
|
622
|
+
if (guild.id !== input.guildId)
|
|
623
|
+
throw new Error("Discord guild validation failed");
|
|
624
|
+
const channel = await guild.channels.fetch(input.channelId);
|
|
625
|
+
if (!channel)
|
|
626
|
+
return {
|
|
627
|
+
guildId: guild.id,
|
|
628
|
+
channelId: input.channelId,
|
|
629
|
+
exists: false,
|
|
630
|
+
guildMatches: false,
|
|
631
|
+
type: "missing",
|
|
632
|
+
effectivePermissionNames: [],
|
|
633
|
+
viewable: false,
|
|
634
|
+
sendMessages: false,
|
|
635
|
+
addReactions: false,
|
|
636
|
+
readMessageHistory: false,
|
|
637
|
+
};
|
|
638
|
+
const guildMatches = channel.guild.id === guild.id;
|
|
639
|
+
const me = guild.members.me ?? (await guild.members.fetchMe());
|
|
640
|
+
const effective = channel.permissionsFor(me);
|
|
641
|
+
const permissionNames = effective?.toArray().map(normalizePermissionName).sort() ?? [];
|
|
642
|
+
return {
|
|
643
|
+
guildId: guild.id,
|
|
644
|
+
channelId: channel.id,
|
|
645
|
+
exists: true,
|
|
646
|
+
guildMatches,
|
|
647
|
+
name: channel.name,
|
|
648
|
+
type: channel.type === ChannelType.GuildText
|
|
649
|
+
? "guild_text"
|
|
650
|
+
: channel.type === ChannelType.GuildAnnouncement
|
|
651
|
+
? "guild_announcement"
|
|
652
|
+
: "other",
|
|
653
|
+
effectivePermissionNames: permissionNames,
|
|
654
|
+
viewable: guildMatches &&
|
|
655
|
+
(effective?.has(PermissionFlagsBits.ViewChannel) ?? false),
|
|
656
|
+
sendMessages: guildMatches &&
|
|
657
|
+
(effective?.has(PermissionFlagsBits.SendMessages) ?? false),
|
|
658
|
+
addReactions: guildMatches &&
|
|
659
|
+
(effective?.has(PermissionFlagsBits.AddReactions) ?? false),
|
|
660
|
+
readMessageHistory: guildMatches &&
|
|
661
|
+
(effective?.has(PermissionFlagsBits.ReadMessageHistory) ?? false),
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
async function fetchSafeRole(guild, roleId) {
|
|
666
|
+
validateSnowflake(roleId, "role");
|
|
667
|
+
const role = await guild.roles.fetch(roleId);
|
|
668
|
+
if (!role || role.guild.id !== guild.id)
|
|
669
|
+
throw new Error("Approved Discord role was not found in this guild");
|
|
670
|
+
const me = guild.members.me ?? (await guild.members.fetchMe());
|
|
671
|
+
const hierarchy = roleHierarchy(role, guild, me);
|
|
672
|
+
if (hierarchy !== "assignable")
|
|
673
|
+
throw new Error("Approved Discord role cannot be managed by Helyx");
|
|
674
|
+
if (role.permissions
|
|
675
|
+
.toArray()
|
|
676
|
+
.map(normalizePermissionName)
|
|
677
|
+
.some((permission) => dangerousRolePermissionNames.has(permission)))
|
|
678
|
+
throw new Error("Approved Discord role has dangerous permissions");
|
|
679
|
+
return role;
|
|
680
|
+
}
|
|
681
|
+
function roleHierarchy(role, guild, me) {
|
|
682
|
+
if (role.id === guild.id)
|
|
683
|
+
return "everyone";
|
|
684
|
+
if (role.managed)
|
|
685
|
+
return "managed";
|
|
686
|
+
if (!me.permissions.has(PermissionFlagsBits.ManageRoles))
|
|
687
|
+
return "missing-manage-roles";
|
|
688
|
+
if (role.comparePositionTo(me.roles.highest) >= 0)
|
|
689
|
+
return "above-helyx";
|
|
690
|
+
return "assignable";
|
|
691
|
+
}
|
|
692
|
+
function missingRoleInspection(input, hierarchy) {
|
|
693
|
+
return {
|
|
694
|
+
guildId: input.guildId,
|
|
695
|
+
roleId: input.roleId,
|
|
696
|
+
exists: false,
|
|
697
|
+
guildMatches: false,
|
|
698
|
+
managed: false,
|
|
699
|
+
everyone: false,
|
|
700
|
+
assignable: false,
|
|
701
|
+
actorAssignable: false,
|
|
702
|
+
hierarchy,
|
|
703
|
+
permissionNames: [],
|
|
704
|
+
dangerousPermissionNames: [],
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
function validateMutationMetadata(input) {
|
|
708
|
+
validateSnowflake(input.guildId, "guild");
|
|
709
|
+
validateSnowflake(input.memberUserId, "member");
|
|
710
|
+
if (input.roleId)
|
|
711
|
+
validateSnowflake(input.roleId, "role");
|
|
712
|
+
if (input.moduleId.length > 100 || !moduleIdentifier.test(input.moduleId))
|
|
713
|
+
throw new Error("Role mutation requires a valid Helyx module ID");
|
|
714
|
+
if (input.reason.trim().length === 0 ||
|
|
715
|
+
input.reason.length > 400 ||
|
|
716
|
+
/[\r\n]/u.test(input.reason))
|
|
717
|
+
throw new Error("Role mutation requires a bounded audit reason");
|
|
718
|
+
}
|
|
719
|
+
async function fetchExactMember(guild, memberUserId, label) {
|
|
720
|
+
validateSnowflake(memberUserId, label);
|
|
721
|
+
const member = await guild.members.fetch(memberUserId);
|
|
722
|
+
if (member.id !== memberUserId || member.guild.id !== guild.id)
|
|
723
|
+
throw new Error(`Discord ${label} validation failed`);
|
|
724
|
+
return member;
|
|
725
|
+
}
|
|
726
|
+
function isUnknownMember(error) {
|
|
727
|
+
return (typeof error === "object" &&
|
|
728
|
+
error !== null &&
|
|
729
|
+
"code" in error &&
|
|
730
|
+
error.code === RESTJSONErrorCodes.UnknownMember);
|
|
731
|
+
}
|
|
732
|
+
function validateBoundedRoleSet(roleIds, label) {
|
|
733
|
+
if (roleIds.length > 100 || new Set(roleIds).size !== roleIds.length)
|
|
734
|
+
throw new Error(`${label} role IDs must be unique and bounded`);
|
|
735
|
+
for (const roleId of roleIds)
|
|
736
|
+
validateSnowflake(roleId, `${label} role`);
|
|
737
|
+
}
|
|
738
|
+
function validateSnowflake(value, label) {
|
|
739
|
+
if (!discordSnowflake.test(value))
|
|
740
|
+
throw new Error(`Invalid Discord ${label} ID`);
|
|
741
|
+
}
|
|
742
|
+
function discordAuditReason(input) {
|
|
743
|
+
return `Helyx ${input.moduleId}: ${input.reason.trim()}`;
|
|
744
|
+
}
|
|
745
|
+
function normalizePermissionName(permission) {
|
|
746
|
+
return permission.replaceAll(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase();
|
|
747
|
+
}
|
|
748
|
+
function isSingleUnicodeEmoji(value) {
|
|
749
|
+
if (!value || !/\p{Emoji}/u.test(value))
|
|
750
|
+
return false;
|
|
751
|
+
const graphemes = [
|
|
752
|
+
...new Intl.Segmenter("en", { granularity: "grapheme" }).segment(value),
|
|
753
|
+
];
|
|
754
|
+
return graphemes.length === 1;
|
|
755
|
+
}
|
|
204
756
|
function safeError(error) {
|
|
205
757
|
const value = error instanceof Error ? error : new Error(String(error));
|
|
206
758
|
return { name: value.name, message: value.message };
|