/[sudobot]/branches/8.x/src/automod/VerificationService.ts
ViewVC logotype

Contents of /branches/8.x/src/automod/VerificationService.ts

Parent Directory Parent Directory | Revision Log Revision Log


Revision 577 - (show annotations)
Mon Jul 29 18:52:37 2024 UTC (8 months ago) by rakinar2
File MIME type: application/typescript
File size: 16097 byte(s)
chore: add old version archive branches (2.x to 9.x-dev)
1 /**
2 * This file is part of SudoBot.
3 *
4 * Copyright (C) 2021-2023 OSN Developers.
5 *
6 * SudoBot is free software; you can redistribute it and/or modify it
7 * under the terms of the GNU Affero General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
10 *
11 * SudoBot is distributed in the hope that it will be useful, but
12 * WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU Affero General Public License for more details.
15 *
16 * You should have received a copy of the GNU Affero General Public License
17 * along with SudoBot. If not, see <https://www.gnu.org/licenses/>.
18 */
19
20 import { VerificationEntry } from "@prisma/client";
21 import bcrypt from "bcrypt";
22 import { formatDistanceToNowStrict } from "date-fns";
23 import {
24 APIEmbed,
25 ActionRowBuilder,
26 ButtonBuilder,
27 ButtonStyle,
28 Colors,
29 Guild,
30 GuildMember,
31 PartialGuildMember,
32 escapeMarkdown,
33 time
34 } from "discord.js";
35 import jwt from "jsonwebtoken";
36 import Service from "../core/Service";
37 import { HasEventListeners } from "../types/HasEventListeners";
38 import { logError } from "../utils/Logger";
39 import { userInfo } from "../utils/embed";
40 import { safeChannelFetch, safeMemberFetch } from "../utils/fetch";
41
42 export const name = "verification";
43
44 export default class VerificationService extends Service implements HasEventListeners {
45 // FIXME: do not create doubled entries if the user leaves and rejoins
46 async onGuildMemberAdd(member: GuildMember) {
47 if (member.user.bot) {
48 return;
49 }
50
51 const config = this.client.configManager.config[member.guild.id]?.verification;
52
53 if (!config?.enabled || !this.requiresVerification(member)) {
54 return;
55 }
56
57 if (config.unverified_roles.length > 0) {
58 await member.roles.add(config.unverified_roles);
59 }
60
61 const { token } = await this.createDatabaseEntry(member);
62
63 try {
64 await this.sendVerificationDMToMember(member, token);
65 } catch (error) {
66 logError(error);
67
68 await this.sendLog(member.guild, {
69 author: {
70 name: member.user.username,
71 icon_url: member.user.displayAvatarURL()
72 },
73 title: "Failed to send verification DM",
74 color: Colors.Red,
75 description:
76 "This user will not be able to verify and will remain unverified unless an admin manually verifies them.",
77 fields: [
78 {
79 name: "User",
80 value: userInfo(member.user)
81 }
82 ],
83 footer: {
84 text: "Failed"
85 },
86 timestamp: new Date().toISOString()
87 });
88
89 return;
90 }
91
92 await this.sendLog(member.guild, {
93 author: {
94 name: member.user.username,
95 icon_url: member.user.displayAvatarURL()
96 },
97 title: "Verification Initiated",
98 color: Colors.Gold,
99 fields: [
100 {
101 name: "User",
102 value: userInfo(member.user)
103 }
104 ],
105 footer: {
106 text: "Initiated"
107 },
108 timestamp: new Date().toISOString()
109 });
110 }
111
112 async onGuildMemberRemove(member: GuildMember | PartialGuildMember) {
113 await this.client.prisma.verificationEntry.deleteMany({
114 where: {
115 userId: member.user.id,
116 guildId: member.guild.id
117 }
118 });
119 }
120
121 async onMemberVerificationFail(member: GuildMember, { attempts, guildId }: VerificationEntry, remainingTime: number) {
122 const config = this.client.configManager.config[guildId]?.verification;
123
124 if (!config) {
125 return;
126 }
127
128 if ((config.max_attempts === 0 || attempts < config.max_attempts) && remainingTime > 0) {
129 return;
130 }
131
132 switch (config.action_on_fail?.type) {
133 case "ban":
134 if (member.bannable) {
135 await this.client.infractionManager.createUserBan(member.user, {
136 guild: member.guild,
137 moderator: this.client.user!,
138 autoRemoveQueue: true,
139 notifyUser: true,
140 sendLog: true,
141 reason: "Failed verification"
142 });
143 }
144
145 break;
146
147 case "kick":
148 if (member.kickable) {
149 await this.client.infractionManager.createMemberKick(member, {
150 guild: member.guild,
151 moderator: this.client.user!,
152 notifyUser: true,
153 sendLog: true,
154 reason: "Failed verification"
155 });
156 }
157
158 break;
159
160 case "mute":
161 if (member.manageable || member.moderatable) {
162 await this.client.infractionManager.createMemberMute(member, {
163 guild: member.guild,
164 moderator: this.client.user!,
165 notifyUser: true,
166 sendLog: true,
167 reason: "Failed verification",
168 autoRemoveQueue: true
169 });
170 }
171
172 break;
173
174 case "role":
175 if (member.manageable) {
176 const methodName = config.action_on_fail!.mode === "give" ? "add" : "remove";
177 await member.roles[methodName](config.action_on_fail!.roles).catch(logError);
178 }
179
180 break;
181 }
182 }
183
184 requiresVerification(member: GuildMember) {
185 const config = this.client.configManager.config[member.guild.id]?.verification;
186
187 return (
188 config?.parameters?.always ||
189 (typeof config?.parameters?.age_less_than === "number" &&
190 Date.now() - member.user.createdAt.getTime() < config?.parameters?.age_less_than) ||
191 (config?.parameters?.no_avatar && member.user.avatar === null)
192 );
193 }
194
195 async createDatabaseEntry(member: GuildMember) {
196 const config = this.client.configManager.config[member.guild.id]?.verification;
197
198 const seed = await bcrypt.hash((Math.random() * 100000000).toString(), await bcrypt.genSalt());
199 const token = jwt.sign(
200 {
201 seed,
202 userId: member.user.id
203 },
204 process.env.JWT_SECRET!,
205 {
206 expiresIn: config?.max_time === 0 ? undefined : config?.max_time,
207 issuer: "SudoBot",
208 subject: "Verification Token"
209 }
210 );
211
212 return this.client.prisma.verificationEntry.create({
213 data: {
214 userId: member.user.id,
215 token,
216 guildId: member.guild.id
217 }
218 });
219 }
220
221 async sendLog(guild: Guild, embed: APIEmbed) {
222 const config = this.client.configManager.config[guild.id]?.verification;
223
224 if (!config?.logging.enabled) {
225 return;
226 }
227
228 const channelId = config.logging.channel ?? this.client.configManager.config[guild.id]?.logging?.primary_channel;
229
230 if (!channelId) {
231 return;
232 }
233
234 const channel = await safeChannelFetch(guild, channelId);
235
236 if (!channel || !channel.isTextBased()) {
237 return;
238 }
239
240 return channel
241 .send({
242 embeds: [embed]
243 })
244 .catch(logError);
245 }
246
247 sendVerificationDMToMember(member: GuildMember, token: string) {
248 const url = `${process.env.FRONTEND_URL}/challenge/verify?t=${encodeURIComponent(token)}&u=${member.id}&g=${
249 member.guild.id
250 }&n=${encodeURIComponent(member.guild.name)}`;
251
252 return member.send({
253 embeds: [
254 {
255 author: {
256 icon_url: member.guild.iconURL() ?? undefined,
257 name: "Verification Required"
258 },
259 color: Colors.Gold,
260 description: `
261 Hello **${escapeMarkdown(member.user.username)}**,\n
262 [${member.guild.name}](https://discord.com/channels/${
263 member.guild.id
264 }) requires you to verify to continue. Click on the button below to complete verification. Alternatively, you can copy-paste this link into your browser:\n
265 ${url}\n
266 You might be asked to solve a captcha.\n
267 Sincerely,
268 **The Staff of ${member.guild.name}**
269 `.replace(/(\r\n|\n)\t+/, "\n"),
270 footer: {
271 text: `You have ${formatDistanceToNowStrict(
272 Date.now() - (this.client.configManager.config[member.guild.id]?.verification?.max_time ?? 0)
273 )} to verify`
274 },
275 timestamp: new Date().toISOString()
276 }
277 ],
278 components: [
279 new ActionRowBuilder<ButtonBuilder>().addComponents(
280 new ButtonBuilder().setStyle(ButtonStyle.Link).setURL(url).setLabel("Verify")
281 )
282 ]
283 });
284 }
285
286 async sendVerificationSuccessDMToMember(member: GuildMember) {
287 return member.send({
288 embeds: [
289 {
290 author: {
291 icon_url: member.guild.iconURL() ?? undefined,
292 name: "Verification Completed"
293 },
294 color: Colors.Green,
295 description: `
296 Hello **${escapeMarkdown(member.user.username)}**,
297 You have successfully verified yourself. You've been granted access to the server now.\n
298 Cheers,
299 **The Staff of ${member.guild.name}**
300 `.replace(/(\r\n|\n)\t+/, "\n"),
301 footer: {
302 text: "Completed"
303 },
304 timestamp: new Date().toISOString()
305 }
306 ]
307 });
308 }
309
310 async attemptToVerifyUserByToken(userId: string, token: string, method: string) {
311 const entry = await this.client.prisma.verificationEntry.findFirst({
312 where: {
313 userId,
314 token
315 }
316 });
317
318 if (!entry) {
319 return null;
320 }
321
322 const config = this.client.configManager.config[entry.guildId]?.verification;
323 const guild = this.client.guilds.cache.get(entry.guildId);
324
325 if (!guild || !config) {
326 return null;
327 }
328
329 const member = await safeMemberFetch(guild, entry.userId);
330
331 if (!member) {
332 return null;
333 }
334
335 let userIdFromPayload: string | undefined;
336
337 try {
338 let { payload } = jwt.verify(entry.token, process.env.JWT_SECRET!, {
339 complete: true,
340 issuer: "SudoBot",
341 subject: "Verification Token"
342 });
343
344 if (typeof payload === "string") {
345 payload = JSON.parse(payload);
346 }
347
348 userIdFromPayload = (payload as { [key: string]: string }).userId;
349 } catch (error) {
350 logError(error);
351 }
352
353 const maxAttemptsExcceded =
354 typeof config?.max_attempts === "number" && config?.max_attempts > 0 && entry.attempts > config?.max_attempts;
355
356 if (entry.token !== token || userIdFromPayload !== userId || maxAttemptsExcceded) {
357 const remainingTime =
358 config.max_time === 0
359 ? Number.POSITIVE_INFINITY
360 : Math.max(entry.createdAt.getTime() + config.max_time - Date.now(), 0);
361
362 await this.sendLog(guild, {
363 author: {
364 name: member?.user.username ?? "Unknown",
365 icon_url: member?.user.displayAvatarURL()
366 },
367 title: "Failed Verification Attempt",
368 color: Colors.Red,
369 fields: [
370 {
371 name: "User",
372 value: member ? userInfo(member.user) : entry.userId
373 },
374 {
375 name: "Attempts",
376 value: `${maxAttemptsExcceded ? "More than " : ""}${entry.attempts} times ${
377 typeof config?.max_attempts === "number" && config?.max_attempts > 0
378 ? `(${config?.max_attempts} max)`
379 : ""
380 }`
381 },
382 {
383 name: "Verification Initiated At",
384 value: `${time(entry.createdAt, "R")} (${
385 remainingTime === 0
386 ? "Session expired"
387 : Number.isFinite(remainingTime)
388 ? `${formatDistanceToNowStrict(new Date(Date.now() - remainingTime))} remaining`
389 : "Session never expires"
390 })`
391 },
392 {
393 name: "Method",
394 value: method
395 }
396 ],
397 footer: {
398 text: "Failed"
399 },
400 timestamp: new Date().toISOString()
401 });
402
403 if (!maxAttemptsExcceded) {
404 await this.client.prisma.verificationEntry.update({
405 where: {
406 id: entry.id
407 },
408 data: {
409 attempts: {
410 increment: 1
411 }
412 }
413 });
414 }
415
416 await this.onMemberVerificationFail(member, entry, remainingTime);
417 return null;
418 }
419
420 if (member) {
421 await this.sendVerificationSuccessDMToMember(member).catch(logError);
422 }
423
424 await this.sendLog(guild, {
425 author: {
426 name: member?.user.username ?? "Unknown",
427 icon_url: member?.user.displayAvatarURL()
428 },
429 title: "Successfully Verified Member",
430 color: Colors.Green,
431 fields: [
432 {
433 name: "User",
434 value: member ? userInfo(member.user) : entry.userId
435 },
436 {
437 name: "Method",
438 value: method
439 }
440 ],
441 footer: {
442 text: "Verified"
443 },
444 timestamp: new Date().toISOString()
445 });
446
447 await this.onMemberVerify(member).catch(logError);
448
449 await this.client.prisma.verificationEntry.delete({
450 where: {
451 id: entry.id
452 }
453 });
454
455 return {
456 id: entry.userId,
457 token
458 };
459 }
460
461 async onMemberVerify(member: GuildMember) {
462 const config = this.client.configManager.config[member.guild.id]?.verification;
463
464 if (config?.unverified_roles?.length) {
465 await member.roles.remove(config?.unverified_roles);
466 }
467
468 if (config?.verified_roles?.length) {
469 await member.roles.add(config?.verified_roles);
470 }
471 }
472 }

[email protected]
ViewVC Help
Powered by ViewVC 1.1.26