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 { GuildMember, Message, PermissionFlagsBits, Snowflake, TextChannel } from "discord.js"; |
21 |
import Service from "../core/Service"; |
22 |
import { GuildConfig } from "../types/GuildConfigSchema"; |
23 |
import { log, logError } from "../utils/logger"; |
24 |
import { isImmuneToAutoMod, isTextableChannel } from "../utils/utils"; |
25 |
|
26 |
interface SpamUserInfo { |
27 |
timestamps: number[]; |
28 |
timeout?: NodeJS.Timeout; |
29 |
} |
30 |
|
31 |
interface SimilarMessageSpamInfo { |
32 |
content?: string; |
33 |
count: number; |
34 |
timeout?: NodeJS.Timeout; |
35 |
} |
36 |
|
37 |
export const name = "antispam"; |
38 |
|
39 |
export default class Antispam extends Service { |
40 |
protected readonly map: Record<`${Snowflake}_${Snowflake}`, SpamUserInfo | undefined> = {}; |
41 |
protected readonly similarMessageSpamMap: Record<`${Snowflake}_${Snowflake}`, SimilarMessageSpamInfo | undefined> = {}; |
42 |
|
43 |
async muteUser(message: Message, antispam: GuildConfig["antispam"]) { |
44 |
this.client.infractionManager |
45 |
.createMemberMute(message.member as GuildMember, { |
46 |
guild: message.guild!, |
47 |
moderator: this.client.user!, |
48 |
bulkDeleteReason: "The system has detected spam messages from this user", |
49 |
duration: antispam?.mute_duration && antispam?.mute_duration > 0 ? antispam?.mute_duration : 1000 * 60 * 60, |
50 |
messageChannel: |
51 |
antispam?.action === "mute_clear" || antispam?.action === "auto" |
52 |
? (message.channel! as TextChannel) |
53 |
: undefined, |
54 |
notifyUser: true, |
55 |
reason: "Spam detected", |
56 |
sendLog: true, |
57 |
autoRemoveQueue: true |
58 |
}) |
59 |
.catch(logError); |
60 |
} |
61 |
|
62 |
async warnUser(message: Message, antispam: GuildConfig["antispam"]) { |
63 |
this.client.infractionManager |
64 |
.createMemberWarn(message.member as GuildMember, { |
65 |
guild: message.guild!, |
66 |
moderator: this.client.user!, |
67 |
notifyUser: true, |
68 |
reason: `Spam detected.${ |
69 |
antispam?.action === "auto" ? " If you continue to send spam messages, you might get muted." : "" |
70 |
}`, |
71 |
sendLog: true |
72 |
}) |
73 |
.catch(logError); |
74 |
} |
75 |
|
76 |
async verballyWarnUser(message: Message) { |
77 |
await message.channel |
78 |
.send({ |
79 |
content: `Hey ${message.author.toString()}, don't spam here!` |
80 |
}) |
81 |
.catch(logError); |
82 |
} |
83 |
|
84 |
async takeAction(message: Message) { |
85 |
log("Triggered"); |
86 |
|
87 |
const config = this.client.configManager.config[message.guildId!]; |
88 |
|
89 |
if (!config) return; |
90 |
|
91 |
const { antispam } = config; |
92 |
|
93 |
if (antispam?.action === "mute_clear" || antispam?.action === "mute") { |
94 |
await this.muteUser(message, antispam); |
95 |
} else if (antispam?.action === "warn") { |
96 |
await this.warnUser(message, antispam); |
97 |
} else if (antispam?.action === "verbal_warn") { |
98 |
await this.verballyWarnUser(message); |
99 |
} else if (antispam?.action === "auto") { |
100 |
let record = await this.client.prisma.spamRecord.findFirst({ |
101 |
where: { |
102 |
guild_id: message.guildId!, |
103 |
user_id: message.author.id |
104 |
} |
105 |
}); |
106 |
|
107 |
if (!record) { |
108 |
record = await this.client.prisma.spamRecord.create({ |
109 |
data: { |
110 |
guild_id: message.guildId!, |
111 |
user_id: message.author.id, |
112 |
level: 1 |
113 |
} |
114 |
}); |
115 |
} else { |
116 |
await this.client.prisma.spamRecord.update({ |
117 |
data: { |
118 |
level: { |
119 |
increment: 1 |
120 |
} |
121 |
}, |
122 |
where: { |
123 |
id: record.id |
124 |
} |
125 |
}); |
126 |
} |
127 |
|
128 |
if (record.level === 1) { |
129 |
await this.verballyWarnUser(message); |
130 |
} else if (record.level === 2) { |
131 |
await this.warnUser(message, antispam); |
132 |
} else { |
133 |
await this.muteUser(message, antispam); |
134 |
} |
135 |
} |
136 |
} |
137 |
|
138 |
async checkForSimilarMessages(message: Message, config: GuildConfig) { |
139 |
if ( |
140 |
!config.antispam?.similar_messages?.max || |
141 |
config.antispam?.similar_messages?.max < 0 || |
142 |
message.content.trim() === "" |
143 |
) { |
144 |
return false; |
145 |
} |
146 |
|
147 |
const channels = config.antispam?.similar_messages?.channels; |
148 |
|
149 |
if (typeof channels === "boolean" && !channels) { |
150 |
return false; |
151 |
} |
152 |
|
153 |
if (channels !== true && !channels?.includes(message.channelId)) { |
154 |
return false; |
155 |
} |
156 |
|
157 |
const lastMessageInfo = this.similarMessageSpamMap[`${message.guildId!}_${message.author.id}`]; |
158 |
|
159 |
if (!lastMessageInfo) { |
160 |
this.similarMessageSpamMap[`${message.guildId!}_${message.author.id}`] = { |
161 |
count: 0, |
162 |
content: message.content, |
163 |
timeout: setTimeout(() => { |
164 |
const lastMessageInfo = this.similarMessageSpamMap[`${message.guildId!}_${message.author.id}`]; |
165 |
const max = config.antispam?.similar_messages?.max; |
166 |
|
167 |
if (lastMessageInfo && max && lastMessageInfo.count >= max) { |
168 |
lastMessageInfo.count = 0; |
169 |
this.takeAction(message).catch(console.error); |
170 |
} |
171 |
|
172 |
this.similarMessageSpamMap[`${message.guildId!}_${message.author.id}`] = undefined; |
173 |
}, config.antispam.similar_messages?.timeframe ?? config.antispam.timeframe) |
174 |
}; |
175 |
|
176 |
return false; |
177 |
} |
178 |
|
179 |
if (message.content === lastMessageInfo.content) { |
180 |
log("Similar message found"); |
181 |
lastMessageInfo.count++; |
182 |
} else { |
183 |
log("Similar message count reset"); |
184 |
lastMessageInfo.count = 0; |
185 |
} |
186 |
|
187 |
this.similarMessageSpamMap[`${message.guildId!}_${message.author.id}`] = lastMessageInfo; |
188 |
return false; |
189 |
} |
190 |
|
191 |
async onMessageCreate(message: Message) { |
192 |
if (!isTextableChannel(message.channel)) return; |
193 |
|
194 |
const config = this.client.configManager.config[message.guildId!]; |
195 |
|
196 |
if ( |
197 |
!config?.antispam?.enabled || |
198 |
!config?.antispam.limit || |
199 |
!config?.antispam.timeframe || |
200 |
config.antispam.limit < 1 || |
201 |
config.antispam.timeframe < 1 || |
202 |
config.antispam.disabled_channels.includes(message.channelId!) |
203 |
) { |
204 |
return; |
205 |
} |
206 |
|
207 |
if (await isImmuneToAutoMod(this.client, message.member!, PermissionFlagsBits.ManageMessages)) { |
208 |
return; |
209 |
} |
210 |
|
211 |
const result = await this.checkForSimilarMessages(message, config); |
212 |
|
213 |
if (result) { |
214 |
return; |
215 |
} |
216 |
|
217 |
const info = this.map[`${message.guildId!}_${message.author.id}`] ?? ({} as SpamUserInfo); |
218 |
|
219 |
info.timestamps ??= []; |
220 |
info.timestamps.push(Date.now()); |
221 |
|
222 |
log("Pushed"); |
223 |
|
224 |
if (!info.timeout) { |
225 |
log("Timeout set"); |
226 |
|
227 |
info.timeout = setTimeout(() => { |
228 |
const delayedInfo = this.map[`${message.guildId!}_${message.author.id}`] ?? ({} as SpamUserInfo); |
229 |
const timestamps = delayedInfo.timestamps.filter( |
230 |
timestamp => config.antispam?.timeframe! + timestamp >= Date.now() |
231 |
); |
232 |
|
233 |
if (timestamps.length >= config.antispam?.limit!) { |
234 |
this.takeAction(message).catch(console.error); |
235 |
} |
236 |
|
237 |
this.map[`${message.guildId!}_${message.author.id}`] = undefined; |
238 |
log("Popped"); |
239 |
}, config.antispam.timeframe); |
240 |
} |
241 |
|
242 |
this.map[`${message.guildId!}_${message.author.id}`] = info; |
243 |
} |
244 |
} |