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 { PermissionRole, Snippet } from "@prisma/client"; |
21 |
import { AxiosError } from "axios"; |
22 |
import { Attachment, Collection, GuildMember, Message, MessageCreateOptions, PermissionFlagsBits, Snowflake } from "discord.js"; |
23 |
import { existsSync } from "fs"; |
24 |
import fs from "fs/promises"; |
25 |
import { basename } from "path"; |
26 |
import Command, { CommandMessage } from "../core/Command"; |
27 |
import Service from "../core/Service"; |
28 |
import { downloadFile } from "../utils/download"; |
29 |
import { LogLevel, log, logError, logInfo, logWithLevel } from "../utils/logger"; |
30 |
import { sudoPrefix } from "../utils/utils"; |
31 |
|
32 |
export const name = "snippetManager"; |
33 |
|
34 |
type SnippetWithPermissions = Snippet & { permission_roles: PermissionRole[] }; |
35 |
|
36 |
export default class SnippetManager extends Service { |
37 |
public readonly snippets = new Collection<`${Snowflake}_${string}`, SnippetWithPermissions>(); |
38 |
|
39 |
async boot() { |
40 |
const snippets = await this.client.prisma.snippet.findMany({ |
41 |
include: { |
42 |
permission_roles: true |
43 |
} |
44 |
}); |
45 |
|
46 |
for (const snippet of snippets) { |
47 |
this.snippets.set(`${snippet.guild_id}_${snippet.name}`, snippet); |
48 |
} |
49 |
} |
50 |
|
51 |
async removeFiles(fileNames: string[], guildId: string) { |
52 |
for (const name of fileNames) { |
53 |
const filePath = `${sudoPrefix(`storage/snippet_attachments/${guildId}`, true)}/${name}`; |
54 |
logInfo("Attempting to remove file: " + filePath); |
55 |
await fs.rm(filePath).catch(logError); |
56 |
} |
57 |
} |
58 |
|
59 |
async createSnippet({ |
60 |
name, |
61 |
content, |
62 |
attachments, |
63 |
guildId, |
64 |
userId, |
65 |
roles, |
66 |
channels, |
67 |
users, |
68 |
randomize |
69 |
}: CreateSnippetOptions) { |
70 |
if (!content && (!attachments || attachments.length === 0)) { |
71 |
return { error: "Content or attachment is required to create a snippet" }; |
72 |
} |
73 |
|
74 |
if (this.snippets.has(`${guildId}_${name}`)) { |
75 |
return { error: "A snippet with the same name already exists" }; |
76 |
} |
77 |
|
78 |
if (this.client.commands.has(name)) { |
79 |
return { error: "Sorry, there's a built-in internal command already with that name" }; |
80 |
} |
81 |
|
82 |
const filesDownloaded: string[] = []; |
83 |
|
84 |
log(attachments); |
85 |
log(this.client.configManager.systemConfig.snippets?.save_attachments); |
86 |
|
87 |
if (attachments && this.client.configManager.systemConfig.snippets?.save_attachments) { |
88 |
for (const attachment of attachments) { |
89 |
const { error, name } = await this.downloadAttachment({ |
90 |
guildId, |
91 |
proxyURL: attachment.proxyURL |
92 |
}); |
93 |
|
94 |
if (error) { |
95 |
await this.removeFiles(filesDownloaded, guildId); |
96 |
|
97 |
if (error instanceof Error && error.message.startsWith("HTTP error")) { |
98 |
return { error: error.message }; |
99 |
} |
100 |
|
101 |
return { error: "Could not save attachments: An internal error has occurred" }; |
102 |
} |
103 |
|
104 |
filesDownloaded.push(name); |
105 |
} |
106 |
} |
107 |
|
108 |
const snippet = await this.client.prisma.snippet.create({ |
109 |
data: { |
110 |
content: content ? [content] : undefined, |
111 |
guild_id: guildId, |
112 |
user_id: userId, |
113 |
attachments: filesDownloaded, |
114 |
channels, |
115 |
roles, |
116 |
users, |
117 |
name, |
118 |
randomize |
119 |
}, |
120 |
include: { |
121 |
permission_roles: true |
122 |
} |
123 |
}); |
124 |
|
125 |
this.snippets.set(`${guildId}_${snippet.name}`, snippet); |
126 |
|
127 |
return { snippet }; |
128 |
} |
129 |
|
130 |
async downloadAttachment({ guildId, proxyURL }: { proxyURL: string; guildId: string }) { |
131 |
try { |
132 |
const splittedName = basename(proxyURL).split("."); |
133 |
const extension = splittedName.pop()!; |
134 |
splittedName.push(`_${Date.now()}_${Math.random() * 10000000}`); |
135 |
splittedName.push(extension); |
136 |
const fileName = splittedName.join(".").replace(/\//g, "_"); |
137 |
const path = sudoPrefix(`storage/snippet_attachments/${guildId}`, true); |
138 |
|
139 |
await downloadFile({ |
140 |
url: proxyURL, |
141 |
path, |
142 |
name: fileName |
143 |
}); |
144 |
|
145 |
return { path, name: fileName }; |
146 |
} catch (e) { |
147 |
logError(e); |
148 |
return { error: e as Error | AxiosError }; |
149 |
} |
150 |
} |
151 |
|
152 |
async deleteSnippet({ name, guildId }: CommonSnippetActionOptions) { |
153 |
if (!this.snippets.has(`${guildId}_${name}`)) { |
154 |
return { error: "No snippet found with that name" }; |
155 |
} |
156 |
|
157 |
await this.client.prisma.snippet.deleteMany({ |
158 |
where: { |
159 |
guild_id: guildId, |
160 |
name |
161 |
} |
162 |
}); |
163 |
|
164 |
await this.removeFiles(this.snippets.get(`${guildId}_${name}`)!.attachments, guildId); |
165 |
this.snippets.delete(`${guildId}_${name}`); |
166 |
|
167 |
return { success: true }; |
168 |
} |
169 |
|
170 |
async renameSnippet({ name, guildId, newName }: CommonSnippetActionOptions & { newName: string }) { |
171 |
if (!this.snippets.has(`${guildId}_${name}`)) { |
172 |
return { error: "No snippet found with that name" }; |
173 |
} |
174 |
|
175 |
if (this.client.commands.has(newName)) { |
176 |
return { error: "Sorry, there's a built-in internal command already with that name" }; |
177 |
} |
178 |
|
179 |
await this.client.prisma.snippet.updateMany({ |
180 |
where: { |
181 |
name, |
182 |
guild_id: guildId |
183 |
}, |
184 |
data: { |
185 |
name: newName |
186 |
} |
187 |
}); |
188 |
|
189 |
const snippet = this.snippets.get(`${guildId}_${name}`)!; |
190 |
snippet.name = newName; |
191 |
this.snippets.set(`${guildId}_${newName}`, snippet); |
192 |
this.snippets.delete(`${guildId}_${name}`); |
193 |
|
194 |
return { success: true, snippet }; |
195 |
} |
196 |
|
197 |
checkPermissions(snippet: SnippetWithPermissions, member: GuildMember, guildId: string, channelId?: string) { |
198 |
if (member.permissions.has(PermissionFlagsBits.Administrator, true)) return true; |
199 |
|
200 |
if ( |
201 |
(snippet.channels.length > 0 && channelId && !snippet.channels.includes(channelId)) || |
202 |
(snippet.users.length > 0 && !snippet.users.includes(member.user.id)) || |
203 |
(snippet.roles.length > 0 && !member.roles.cache.hasAll(...snippet.roles)) |
204 |
) { |
205 |
log("Channel/user doesn't have permission to run this snippet."); |
206 |
return false; |
207 |
} |
208 |
|
209 |
const guildUsesPermissionLevels = this.client.configManager.config[guildId]?.permissions.mode === "levels"; |
210 |
const memberPermissions = this.client.permissionManager.getMemberPermissions(member, true); |
211 |
|
212 |
const snippetPermissions = |
213 |
guildUsesPermissionLevels && typeof snippet.level === "number" |
214 |
? this.client.permissionManager.levels[snippet.level] ?? ["Administrator"] |
215 |
: this.client.permissionManager.getPermissionsFromPermissionRoles(snippet.permission_roles, guildId); |
216 |
|
217 |
for (const permission of snippetPermissions) { |
218 |
if (!memberPermissions.has(permission)) { |
219 |
log("User doesn't have enough permission to run this snippet. (hybrid permission system)"); |
220 |
return false; |
221 |
} |
222 |
} |
223 |
|
224 |
return true; |
225 |
} |
226 |
|
227 |
async createMessageOptionsFromSnippet({ |
228 |
name, |
229 |
guildId, |
230 |
channelId, |
231 |
member |
232 |
}: CommonSnippetActionOptions & { |
233 |
channelId: string; |
234 |
member: GuildMember; |
235 |
}) { |
236 |
if (!this.snippets?.has(`${guildId}_${name}`)) { |
237 |
return { error: "No snippet found with that name", found: false }; |
238 |
} |
239 |
|
240 |
const snippet = this.snippets.get(`${guildId}_${name}`)!; |
241 |
|
242 |
if (!snippet.content && snippet.attachments.length === 0) |
243 |
throw new Error("Corrupted database: snippet attachment and content both are unusable"); |
244 |
|
245 |
if (!this.checkPermissions(snippet, member, guildId, channelId)) { |
246 |
return { |
247 |
options: undefined |
248 |
}; |
249 |
} |
250 |
|
251 |
const files = []; |
252 |
|
253 |
if (snippet.randomize && snippet.attachments.length > 0) { |
254 |
const randomAttachment = snippet.attachments[Math.floor(Math.random() * snippet.attachments.length)]; |
255 |
|
256 |
const file = sudoPrefix(`storage/snippet_attachments/${guildId}/${randomAttachment}`, false); |
257 |
|
258 |
if (!existsSync(file)) { |
259 |
logWithLevel(LogLevel.CRITICAL, `Could find attachment: ${file}`); |
260 |
} else { |
261 |
files.push(file); |
262 |
} |
263 |
} else { |
264 |
for (const attachment of snippet.attachments) { |
265 |
const file = sudoPrefix(`storage/snippet_attachments/${guildId}/${attachment}`, false); |
266 |
|
267 |
if (!existsSync(file)) { |
268 |
logWithLevel(LogLevel.CRITICAL, `Could find attachment: ${file}`); |
269 |
continue; |
270 |
} |
271 |
|
272 |
files.push(file); |
273 |
} |
274 |
} |
275 |
|
276 |
return { |
277 |
options: { |
278 |
content: snippet.content[snippet.randomize ? Math.floor(Math.random() * snippet.content.length) : 0] ?? undefined, |
279 |
files |
280 |
} as MessageCreateOptions, |
281 |
found: true |
282 |
}; |
283 |
} |
284 |
|
285 |
async onMessageCreate(message: Message, commandName: string) { |
286 |
const { options, found } = await this.createMessageOptionsFromSnippet({ |
287 |
name: commandName, |
288 |
guildId: message.guildId!, |
289 |
channelId: message.channelId!, |
290 |
member: message.member! as GuildMember |
291 |
}); |
292 |
|
293 |
if (!found || !options) { |
294 |
log("Snippet not found or permission error"); |
295 |
return false; |
296 |
} |
297 |
|
298 |
await message.channel.send(options).catch(logError); |
299 |
return true; |
300 |
} |
301 |
|
302 |
async toggleRandomization({ guildId, name }: CommonSnippetActionOptions) { |
303 |
if (!this.snippets.has(`${guildId}_${name}`)) return { error: "Snippet does not exist" }; |
304 |
|
305 |
const snippet = this.snippets.get(`${guildId}_${name}`); |
306 |
|
307 |
if (!snippet) { |
308 |
return { error: "Snippet does not exist" }; |
309 |
} |
310 |
|
311 |
await this.client.prisma.snippet.update({ |
312 |
where: { |
313 |
id: snippet.id |
314 |
}, |
315 |
data: { |
316 |
randomize: !snippet.randomize |
317 |
} |
318 |
}); |
319 |
|
320 |
const localSnippet = this.snippets.get(`${guildId}_${name}`)!; |
321 |
localSnippet.randomize = !snippet.randomize; |
322 |
this.snippets.set(`${guildId}_${name}`, localSnippet); |
323 |
|
324 |
return { randomization: !snippet.randomize }; |
325 |
} |
326 |
|
327 |
async pushFile({ files, guildId, name }: CommonSnippetActionOptions & { files: string[] }) { |
328 |
if (!this.snippets.has(`${guildId}_${name}`)) return { error: "Snippet does not exist" }; |
329 |
|
330 |
const filesDownloaded = []; |
331 |
|
332 |
for (const file of files) { |
333 |
const { error, name } = await this.downloadAttachment({ |
334 |
guildId, |
335 |
proxyURL: file |
336 |
}); |
337 |
|
338 |
if (error) { |
339 |
await this.removeFiles(filesDownloaded, guildId); |
340 |
|
341 |
if (error instanceof Error && error.message.startsWith("HTTP error")) { |
342 |
return { error: error.message }; |
343 |
} |
344 |
|
345 |
return { error: "Could not save attachments: An internal error has occurred" }; |
346 |
} |
347 |
|
348 |
filesDownloaded.push(name); |
349 |
} |
350 |
|
351 |
const { count } = await this.client.prisma.snippet.updateMany({ |
352 |
where: { |
353 |
name, |
354 |
guild_id: guildId |
355 |
}, |
356 |
data: { |
357 |
attachments: { |
358 |
push: filesDownloaded |
359 |
} |
360 |
} |
361 |
}); |
362 |
|
363 |
if (count === 0) return { error: "Snippet does not exist" }; |
364 |
|
365 |
const snippet = this.snippets.get(`${guildId}_${name}`)!; |
366 |
snippet.attachments.push(...filesDownloaded); |
367 |
this.snippets.set(`${guildId}_${name}`, snippet); |
368 |
|
369 |
return { count }; |
370 |
} |
371 |
|
372 |
async checkPermissionInSnippetCommands(name: string, message: CommandMessage, command: Command) { |
373 |
const snippet = this.snippets.get(`${message.guildId!}_${name}`); |
374 |
|
375 |
if (!snippet) { |
376 |
await command.error(message, "No snippet found with that name!"); |
377 |
return false; |
378 |
} |
379 |
|
380 |
if (!this.client.snippetManager.checkPermissions(snippet, message.member! as GuildMember, message.guildId!)) { |
381 |
await command.error(message, "You don't have permission to modify this snippet!"); |
382 |
return false; |
383 |
} |
384 |
|
385 |
return true; |
386 |
} |
387 |
|
388 |
async updateSnippet({ |
389 |
channels, |
390 |
content, |
391 |
guildId, |
392 |
name, |
393 |
randomize, |
394 |
roles, |
395 |
users, |
396 |
permissionRoleName, |
397 |
level |
398 |
}: Partial<Omit<CreateSnippetOptions, "attachments" | "userId">> & |
399 |
CommonSnippetActionOptions & { permissionRoleName?: string; level?: number }) { |
400 |
if (!this.snippets.has(`${guildId}_${name}`)) { |
401 |
return { error: "No snippet found with that name" }; |
402 |
} |
403 |
|
404 |
const snippet = this.snippets.get(`${guildId}_${name}`)!; |
405 |
|
406 |
let permissionRole: PermissionRole | null = null; |
407 |
|
408 |
if (permissionRoleName) { |
409 |
permissionRole = await this.client.prisma.permissionRole.findFirst({ |
410 |
where: { |
411 |
guild_id: guildId, |
412 |
name: permissionRoleName |
413 |
} |
414 |
}); |
415 |
|
416 |
if (!permissionRole) { |
417 |
return { error: "No named permission role found with the given name!" }; |
418 |
} |
419 |
} |
420 |
|
421 |
const updatedSnippet = await this.client.prisma.snippet.update({ |
422 |
where: { |
423 |
id: snippet.id |
424 |
}, |
425 |
data: { |
426 |
content: content ? [content] : undefined, |
427 |
channels, |
428 |
roles, |
429 |
randomize, |
430 |
users, |
431 |
permission_roles: permissionRole |
432 |
? { |
433 |
connect: { |
434 |
id: permissionRole.id |
435 |
} |
436 |
} |
437 |
: undefined, |
438 |
level |
439 |
}, |
440 |
include: { |
441 |
permission_roles: true |
442 |
} |
443 |
}); |
444 |
|
445 |
this.snippets.set(`${guildId}_${name}`, updatedSnippet); |
446 |
|
447 |
return { |
448 |
updatedSnippet |
449 |
}; |
450 |
} |
451 |
} |
452 |
|
453 |
interface CommonSnippetActionOptions { |
454 |
name: string; |
455 |
guildId: string; |
456 |
} |
457 |
|
458 |
interface CreateSnippetOptions extends CommonSnippetActionOptions { |
459 |
content?: string; |
460 |
attachments?: Attachment[]; |
461 |
userId: string; |
462 |
roles: string[]; |
463 |
users: string[]; |
464 |
channels: string[]; |
465 |
randomize?: boolean; |
466 |
} |