|
| 1 | +import { Inject } from "@framework/container/Inject"; |
| 2 | +import { GatewayEventListener } from "@framework/events/GatewayEventListener"; |
| 3 | +import { Name } from "@framework/services/Name"; |
| 4 | +import { Service } from "@framework/services/Service"; |
| 5 | +import { HasEventListeners } from "@framework/types/HasEventListeners"; |
| 6 | +import { GuildConfig } from "@main/schemas/GuildConfigSchema"; |
| 7 | +import { LogEventType } from "@main/schemas/LoggingSchema"; |
| 8 | +import type ConfigurationManager from "@main/services/ConfigurationManager"; |
| 9 | +import { assertUnreachable } from "@main/utils/utils"; |
| 10 | +import { Collection, Guild, GuildMember, Snowflake } from "discord.js"; |
| 11 | + |
| 12 | +@Name("raidProtectionService") |
| 13 | +class RaidProtectionService extends Service implements HasEventListeners { |
| 14 | + @Inject("configManager") |
| 15 | + private readonly configManager!: ConfigurationManager; |
| 16 | + |
| 17 | + private readonly cache = new Collection< |
| 18 | + Snowflake, |
| 19 | + { |
| 20 | + count: number; |
| 21 | + lastUpdate: number; |
| 22 | + actionTaken: boolean; |
| 23 | + } |
| 24 | + >(); |
| 25 | + |
| 26 | + private _timeout?: ReturnType<typeof setTimeout>; |
| 27 | + |
| 28 | + @GatewayEventListener("guildMemberAdd") |
| 29 | + public async onGuildMemberAdd(member: GuildMember) { |
| 30 | + const config = this.configManager.config[member.guild.id]?.raid_protection; |
| 31 | + |
| 32 | + if (!config?.enabled) { |
| 33 | + return; |
| 34 | + } |
| 35 | + |
| 36 | + const now = Date.now(); |
| 37 | + const entry = this.cache.get(member.guild.id); |
| 38 | + |
| 39 | + this._timeout ??= setTimeout(() => { |
| 40 | + for (const [guildId, entry] of this.cache) { |
| 41 | + const config = this.configManager.config[guildId]?.raid_protection; |
| 42 | + |
| 43 | + if (!config?.enabled || entry.lastUpdate + config.timeframe < now) { |
| 44 | + this.cache.delete(guildId); |
| 45 | + } |
| 46 | + } |
| 47 | + |
| 48 | + this._timeout = undefined; |
| 49 | + }, 60_000); |
| 50 | + |
| 51 | + if (!entry) { |
| 52 | + this.cache.set(member.guild.id, { |
| 53 | + count: 1, |
| 54 | + lastUpdate: now, |
| 55 | + actionTaken: false |
| 56 | + }); |
| 57 | + |
| 58 | + return; |
| 59 | + } |
| 60 | + |
| 61 | + if (entry.actionTaken) { |
| 62 | + return; |
| 63 | + } |
| 64 | + |
| 65 | + if (entry.lastUpdate + config.timeframe < now) { |
| 66 | + entry.count = 1; |
| 67 | + entry.lastUpdate = now; |
| 68 | + return; |
| 69 | + } |
| 70 | + |
| 71 | + const { lastUpdate } = entry; |
| 72 | + |
| 73 | + entry.count++; |
| 74 | + entry.lastUpdate = now; |
| 75 | + |
| 76 | + if (entry.count <= config.threshold) { |
| 77 | + return; |
| 78 | + } |
| 79 | + |
| 80 | + await Promise.all([ |
| 81 | + this.takeAction(member.guild, config, entry.count, now - lastUpdate), |
| 82 | + this.takeMemberAction(member, config) |
| 83 | + ]); |
| 84 | + |
| 85 | + entry.actionTaken = true; |
| 86 | + } |
| 87 | + |
| 88 | + private async takeAction( |
| 89 | + guild: Guild, |
| 90 | + config: RaidProtectionConfig, |
| 91 | + count: number, |
| 92 | + duration: number |
| 93 | + ) { |
| 94 | + const action = |
| 95 | + config.action === "auto" |
| 96 | + ? duration < 60_000 && count >= 15 |
| 97 | + ? "lock_and_antijoin" |
| 98 | + : "lock" |
| 99 | + : config.action; |
| 100 | + |
| 101 | + if (!this.configManager.config[guild.id]) { |
| 102 | + return; |
| 103 | + } |
| 104 | + |
| 105 | + switch (action) { |
| 106 | + case "antijoin": |
| 107 | + await this.enableAntiJoin(guild); |
| 108 | + break; |
| 109 | + case "lock": |
| 110 | + await this.lockGuild(guild, config); |
| 111 | + break; |
| 112 | + case "lock_and_antijoin": |
| 113 | + await this.lockGuild(guild, config); |
| 114 | + await this.enableAntiJoin(guild); |
| 115 | + break; |
| 116 | + case "none": |
| 117 | + break; |
| 118 | + default: |
| 119 | + assertUnreachable(action); |
| 120 | + } |
| 121 | + |
| 122 | + await this.application |
| 123 | + .service("auditLoggingService") |
| 124 | + .emitLogEvent(guild.id, LogEventType.RaidAlert, { |
| 125 | + actions: config.member_actions, |
| 126 | + duration, |
| 127 | + guild, |
| 128 | + membersJoined: count, |
| 129 | + serverAction: action |
| 130 | + }); |
| 131 | + } |
| 132 | + |
| 133 | + private async enableAntiJoin(guild: Guild) { |
| 134 | + this.configManager.config[guild.id]!.anti_member_join ??= { |
| 135 | + enabled: true, |
| 136 | + behavior: "kick", |
| 137 | + custom_reason: "Automatic: This server is not accepting new members at the moment.", |
| 138 | + ignore_bots: true |
| 139 | + }; |
| 140 | + |
| 141 | + await this.configManager.write({ guild: true, system: false }); |
| 142 | + } |
| 143 | + |
| 144 | + private async lockGuild(guild: Guild, { channel_mode, channels }: RaidProtectionConfig) { |
| 145 | + const channelsToLock = guild.channels.cache.filter( |
| 146 | + channel_mode === "exclude" |
| 147 | + ? channel => !channels.includes(channel.id) |
| 148 | + : channel => channels.includes(channel.id) |
| 149 | + ); |
| 150 | + |
| 151 | + return await this.application |
| 152 | + .service("channelLockManager") |
| 153 | + .lockAll(guild, channelsToLock.values()); |
| 154 | + } |
| 155 | + |
| 156 | + private async takeMemberAction(member: GuildMember, config: RaidProtectionConfig) { |
| 157 | + if (config.member_actions.length === 0) { |
| 158 | + return; |
| 159 | + } |
| 160 | + |
| 161 | + await this.application |
| 162 | + .service("moderationActionService") |
| 163 | + .takeActions(member.guild, member, config.member_actions); |
| 164 | + } |
| 165 | +} |
| 166 | + |
| 167 | +type RaidProtectionConfig = NonNullable<GuildConfig["raid_protection"]>; |
| 168 | + |
| 169 | +export default RaidProtectionService; |
0 commit comments