Skip to content

Commit 38bc9a5

Browse files
feat: raid protection
1 parent cde98cc commit 38bc9a5

File tree

9 files changed

+333
-74
lines changed

9 files changed

+333
-74
lines changed

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"espree",
4545
"estree",
4646
"kickable",
47+
"Lockdown",
4748
"manageafks",
4849
"MASSBAN",
4950
"MASSKICK",
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
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;

src/main/typescript/core/DiscordKernel.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ class DiscordKernel extends Kernel {
7878
"@services/ModerationActionService",
7979
"@automod/SpamModerationService",
8080
"@automod/RuleModerationService",
81+
"@automod/RaidProtectionService",
8182
"@services/ChannelLockManager",
8283
"@services/ReactionRoleService",
8384
"@services/AFKService",

src/main/typescript/schemas/GuildConfigSchema.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,21 @@ export const GuildConfigSchema = z.object({
186186
.optional()
187187
})
188188
.optional(),
189-
survey_system: SurveySystemConfigSchema.optional()
189+
survey_system: SurveySystemConfigSchema.optional(),
190+
raid_protection: z
191+
.object({
192+
enabled: z.boolean().optional().default(false),
193+
threshold: z.number().int().default(10),
194+
timeframe: z.number().int().default(60_000),
195+
action: z
196+
.enum(["auto", "lock", "antijoin", "lock_and_antijoin", "none"])
197+
.default("auto"),
198+
member_actions: z.array(ModerationActionSchema).default([]),
199+
send_log: z.boolean().optional().default(true),
200+
channels: z.array(zSnowflake).default([]),
201+
channel_mode: z.enum(["exclude", "include"]).default("exclude")
202+
})
203+
.optional()
190204
/*
191205
quickmute: z
192206
.object({

src/main/typescript/schemas/LoggingSchema.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type Duration from "@framework/datetime/Duration";
22
import type { RuleExecResult } from "@main/contracts/ModerationRuleHandlerContract";
33
import type { MessageRuleType } from "@main/schemas/MessageRuleSchema";
4+
import type { ModerationActionType } from "@main/schemas/ModerationActionSchema";
45
import { zSnowflake } from "@main/schemas/SnowflakeSchema";
56
import type {
67
Collection,
@@ -33,7 +34,8 @@ export enum LogEventType {
3334
UserNoteAdd = "user_note_add",
3435
MemberRoleModification = "member_role_modification",
3536
SystemAutoModRuleModeration = "system_automod_rule_moderation",
36-
SystemUserMessageSave = "system_user_message_save"
37+
SystemUserMessageSave = "system_user_message_save",
38+
RaidAlert = "raid_alert"
3739
}
3840

3941
const LogEventSchema = z.enum(
@@ -67,6 +69,15 @@ export type LogEventArgs = {
6769
[LogEventType.UserNoteAdd]: [payload: LogUserNoteAddPayload];
6870
[LogEventType.MemberRoleModification]: [payload: LogMemberRoleModificationPayload];
6971
[LogEventType.SystemUserMessageSave]: [message: Message, moderator: User];
72+
[LogEventType.RaidAlert]: [payload: LogRaidAlertPayload];
73+
};
74+
75+
export type LogRaidAlertPayload = {
76+
guild: Guild;
77+
membersJoined: number;
78+
duration: number;
79+
actions: ModerationActionType[];
80+
serverAction: "none" | "lock" | "lock_and_antijoin" | "antijoin";
7081
};
7182

7283
type LogModerationActionCommonPayload = {

0 commit comments

Comments
 (0)