@helyx/bot 0.4.11 → 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 +33 -0
- package/dist/control-server.d.ts +38 -3
- package/dist/control-server.js +124 -5
- package/dist/events.d.ts +29 -1
- package/dist/events.js +786 -2
- package/dist/guilds.d.ts +20 -1
- package/dist/guilds.js +149 -9
- package/dist/index.js +21 -5
- package/dist/interactions.js +22 -6
- package/dist/management-module.d.ts +1 -1
- package/dist/shard-contracts.d.ts +324 -0
- package/dist/shard-contracts.js +47 -2
- package/dist/shard-router.d.ts +3 -0
- package/dist/shard-router.js +42 -0
- package/dist/shard-supervisor.d.ts +2 -0
- package/dist/shard-supervisor.js +54 -6
- package/dist/shard-worker.d.ts +6 -0
- package/dist/shard-worker.js +23 -6
- 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,8 @@
|
|
|
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";
|
|
4
|
-
import { createHash } from "node:crypto";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
6
|
import { classifyGuildMessage, unavailableGuildMessageClassification, } from "./message-classification.js";
|
|
6
7
|
export function installModuleEvents(client, modules, logger, services) {
|
|
7
8
|
const privilegedMessageContributions = modules.flatMap((module) => (module.events?.privilegedMessageCreate ?? []).map((contribution) => ({
|
|
@@ -758,6 +759,44 @@ export class DiscordMemberEnforcementService {
|
|
|
758
759
|
return { outcome: "failed", directMessage };
|
|
759
760
|
}
|
|
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
|
+
}
|
|
761
800
|
async #sendBestEffortDirectMessage(memberUserId, response) {
|
|
762
801
|
const attempt = async () => {
|
|
763
802
|
try {
|
|
@@ -789,6 +828,751 @@ export class DiscordMemberEnforcementService {
|
|
|
789
828
|
});
|
|
790
829
|
}
|
|
791
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
|
+
}
|
|
792
1576
|
export class DiscordMemberRoleService {
|
|
793
1577
|
client;
|
|
794
1578
|
constructor(client) {
|