import { ButtonStyle, CommandInteraction, ComponentType, GuildMember, Message, MessageComponentInteraction } from "discord.js"; import type Discord from "discord.js"; import { Collection, MongoClient } from "mongodb"; import config from "../config/main.js"; import client from "../utils/client.js"; import * as crypto from "crypto"; import _ from "lodash"; import defaultData from "../config/default.js"; let username, password; if ("username" in config.mongoOptions) username = encodeURIComponent(config.mongoOptions.username as string); if ("password" in config.mongoOptions) password = encodeURIComponent(config.mongoOptions.password as string); const mongoClient = new MongoClient( username ? `mongodb://${username}:${password}@${config.mongoOptions.host}?authMechanism=DEFAULT&authSource=${config.mongoOptions.authSource}` : `mongodb://${config.mongoOptions.host}` ); await mongoClient.connect(); const database = mongoClient.db(); const collectionOptions = { authdb: config.mongoOptions.authSource, w: "majority" }; const getIV = () => crypto.randomBytes(16); export class Guilds { guilds: Collection; oldGuilds: Collection; defaultData: GuildConfig; constructor() { this.guilds = database.collection("guilds"); this.defaultData = defaultData; this.oldGuilds = database.collection("oldGuilds"); } async readOld(guild: string): Promise> { // console.log("Guild read") const entry = await this.oldGuilds.findOne({ id: guild }); return entry ?? {}; } async updateAllGuilds() { const guilds = await this.guilds.find().toArray(); for (const guild of guilds) { let guildObj; try { guildObj = await client.guilds.fetch(guild.id); } catch (e) { guildObj = null; } if (!guildObj) await this.delete(guild.id); } } async read(guild: string): Promise { // console.log("Guild read") const entry = await this.guilds.findOne({ id: guild }); const data = _.cloneDeep(this.defaultData); return _.merge(data, entry ?? {}); } async write(guild: string, set: object | null, unset: string[] | string = []) { // console.log("Guild write") // eslint-disable-next-line @typescript-eslint/no-explicit-any const uo: Record = {}; if (!Array.isArray(unset)) unset = [unset]; for (const key of unset) { uo[key] = null; } const out = { $set: {}, $unset: {} }; if (set) out.$set = set; if (unset.length) out.$unset = uo; await this.guilds.updateOne({ id: guild }, out, { upsert: true }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any async append(guild: string, key: string, value: any) { // console.log("Guild append") if (Array.isArray(value)) { await this.guilds.updateOne( { id: guild }, { $addToSet: { [key]: { $each: value } } }, { upsert: true } ); } else { await this.guilds.updateOne( { id: guild }, { $addToSet: { [key]: value } }, { upsert: true } ); } } async remove( guild: string, key: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any value: any, innerKey?: string | null ) { // console.log("Guild remove") if (innerKey) { await this.guilds.updateOne( { id: guild }, { $pull: { [key]: { [innerKey]: { $eq: value } } } }, { upsert: true } ); } else if (Array.isArray(value)) { await this.guilds.updateOne( { id: guild }, { $pullAll: { [key]: value } }, { upsert: true } ); } else { await this.guilds.updateOne( { id: guild }, { $pullAll: { [key]: [value] } }, { upsert: true } ); } } async delete(guild: string) { // console.log("Guild delete") await this.guilds.deleteOne({ id: guild }); } async staffChannels(): Promise { const entries = (await this.guilds .find( { "logging.staff.channel": { $exists: true } }, { projection: { "logging.staff.channel": 1, _id: 0 } } ) .toArray()).map((e) => e.logging.staff.channel); const out: string[] = []; for (const entry of entries) { if (entry) out.push(entry); } return out; } } interface TranscriptEmbed { title?: string; description?: string; fields?: { name: string; value: string; inline: boolean; }[]; footer?: { text: string; iconURL?: string; }; color?: number; timestamp?: string; author?: { name: string; iconURL?: string; url?: string; }; } interface TranscriptComponent { type: number; style?: ButtonStyle; label?: string; description?: string; placeholder?: string; emojiURL?: string; } interface TranscriptAuthor { username: string; discriminator: number; nickname?: string; id: string; iconURL?: string; topRole: { color: number; badgeURL?: string; }; bot: boolean; } interface TranscriptAttachment { url: string; filename: string; size: number; log?: string; } interface TranscriptMessage { id: string; author: TranscriptAuthor; content?: string; embeds?: TranscriptEmbed[]; components?: TranscriptComponent[][]; editedTimestamp?: number; createdTimestamp: number; flags?: string[]; attachments?: TranscriptAttachment[]; stickerURLs?: string[]; referencedMessage?: string | [string, string, string]; // the message id, the channel id, the guild id } interface TranscriptSchema { code: string; for: TranscriptAuthor; type: "ticket" | "purge"; guild: string; channel: string; messages: TranscriptMessage[]; createdTimestamp: number; createdBy: TranscriptAuthor; } interface findDocSchema { channelID: string; messageID: string; code: string; } export class Transcript { transcripts: Collection; messageToTranscript: Collection; constructor() { this.transcripts = database.collection("transcripts"); this.messageToTranscript = database.collection("messageToTranscript"); } async upload(data: findDocSchema) { // console.log("Transcript upload") await this.messageToTranscript.insertOne(data); } async create(transcript: Omit) { // console.log("Transcript create") let code; do { code = crypto.randomBytes(64).toString("base64").replace(/=/g, "").replace(/\//g, "_").replace(/\+/g, "-"); } while (await this.transcripts.findOne({ code: code })); const key = crypto .randomBytes(32 ** 2) .toString("base64") .replace(/=/g, "") .replace(/\//g, "_") .replace(/\+/g, "-") .substring(0, 32); const iv = getIV() .toString("base64") .substring(0, 16) .replace(/=/g, "") .replace(/\//g, "_") .replace(/\+/g, "-"); for (const message of transcript.messages) { if (message.content) { const encCipher = crypto.createCipheriv("AES-256-CBC", key, iv); message.content = encCipher.update(message.content, "utf8", "base64") + encCipher.final("base64"); } } const doc = await this.transcripts.insertOne(Object.assign(transcript, { code: code }), collectionOptions); if (doc.acknowledged) { await client.database.eventScheduler.schedule( "deleteTranscript", (Date.now() + 1000 * 60 * 60 * 24 * 7).toString(), { guild: transcript.guild, code: code, iv: iv, key: key } ); return [code, key, iv]; } else return [null, null, null]; } async delete(code: string) { // console.log("Transcript delete") await this.transcripts.deleteOne({ code: code }); } async deleteAll(guild: string) { // console.log("Transcript delete") const filteredDocs = await this.transcripts.find({ guild: guild }).toArray(); for (const doc of filteredDocs) { await this.transcripts.deleteOne({ code: doc.code }); } } async readEncrypted(code: string) { // console.log("Transcript read") let doc: TranscriptSchema | null = await this.transcripts.findOne({ code: code }); let findDoc: findDocSchema | null = null; if (!doc) findDoc = await this.messageToTranscript.findOne({ transcript: code }); if (findDoc) { const message = await ( client.channels.cache.get(findDoc.channelID) as Discord.TextBasedChannel | null )?.messages.fetch(findDoc.messageID); if (!message) return null; const attachment = message.attachments.first(); if (!attachment) return null; const transcript = (await fetch(attachment.url)).body; if (!transcript) return null; const reader = transcript.getReader(); let data: Uint8Array | null = null; let allPacketsReceived = false; while (!allPacketsReceived) { const { value, done } = await reader.read(); if (done) { allPacketsReceived = true; continue; } if (!data) { data = value; } else { data = new Uint8Array(Buffer.concat([data, value])); } } if (!data) return null; doc = JSON.parse(Buffer.from(data).toString()) as TranscriptSchema; } if (!doc) return null; return doc; } async read(code: string, key: string, iv: string) { let doc: TranscriptSchema | null = await this.transcripts.findOne({ code: code }); let findDoc: findDocSchema | null = null; if (!doc) findDoc = await this.messageToTranscript.findOne({ transcript: code }); if (findDoc) { const message = await ( client.channels.cache.get(findDoc.channelID) as Discord.TextBasedChannel | null )?.messages.fetch(findDoc.messageID); if (!message) return null; const attachment = message.attachments.first(); if (!attachment) return null; const transcript = (await fetch(attachment.url)).body; if (!transcript) return null; const reader = transcript.getReader(); let data: Uint8Array | null = null; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, no-constant-condition while (true) { const { value, done } = await reader.read(); if (done) break; if (!data) { data = value; } else { data = new Uint8Array(Buffer.concat([data, value])); } } if (!data) return null; doc = JSON.parse(Buffer.from(data).toString()) as TranscriptSchema; } if (!doc) return null; for (const message of doc.messages) { if (message.content) { const decCipher = crypto.createDecipheriv("AES-256-CBC", key, iv); message.content = decCipher.update(message.content, "base64", "utf8") + decCipher.final("utf8"); } } return doc; } async createTranscript( type: "ticket" | "purge", messages: Message[], interaction: MessageComponentInteraction | CommandInteraction, member: GuildMember ) { const interactionMember = await interaction.guild?.members.fetch(interaction.user.id); const newOut: Omit = { type: type, for: { username: member!.user.username, discriminator: parseInt(member!.user.discriminator), id: member!.user.id, topRole: { color: member!.roles.highest.color }, iconURL: member!.user.displayAvatarURL({ forceStatic: true }), bot: member!.user.bot }, guild: interaction.guild!.id, channel: interaction.channel!.id, messages: [], createdTimestamp: Date.now(), createdBy: { username: interaction.user.username, discriminator: parseInt(interaction.user.discriminator), id: interaction.user.id, topRole: { color: interactionMember?.roles.highest.color ?? 0x000000 }, iconURL: interaction.user.displayAvatarURL({ forceStatic: true }), bot: interaction.user.bot } }; if (member.nickname) newOut.for.nickname = member.nickname; if (interactionMember?.roles.icon) newOut.createdBy.topRole.badgeURL = interactionMember.roles.icon.iconURL()!; messages.reverse().forEach((message) => { const msg: TranscriptMessage = { id: message.id, author: { username: message.author.username, discriminator: parseInt(message.author.discriminator), id: message.author.id, topRole: { color: message.member ? message.member.roles.highest.color : 0x000000 }, iconURL: (message.member?.user ?? message.author).displayAvatarURL({ forceStatic: true }), bot: message.author.bot || false }, createdTimestamp: message.createdTimestamp }; if (message.member?.nickname) msg.author.nickname = message.member.nickname; if (message.member?.roles.icon) msg.author.topRole.badgeURL = message.member!.roles.icon.iconURL()!; if (message.content) msg.content = message.content; if (message.embeds.length > 0) msg.embeds = message.embeds.map((embed) => { const obj: TranscriptEmbed = {}; if (embed.title) obj.title = embed.title; if (embed.description) obj.description = embed.description; if (embed.fields.length > 0) obj.fields = embed.fields.map((field) => { return { name: field.name, value: field.value, inline: field.inline ?? false }; }); if (embed.color) obj.color = embed.color; if (embed.timestamp) obj.timestamp = embed.timestamp; if (embed.footer) obj.footer = { text: embed.footer.text }; if (embed.footer?.iconURL) obj.footer!.iconURL = embed.footer.iconURL; if (embed.author) obj.author = { name: embed.author.name }; if (embed.author?.iconURL) obj.author!.iconURL = embed.author.iconURL; if (embed.author?.url) obj.author!.url = embed.author.url; return obj; }); if (message.components.length > 0) msg.components = message.components.map((component) => component.components.map((child) => { const obj: TranscriptComponent = { type: child.type }; if (child.type === ComponentType.Button) { obj.style = child.style; obj.label = child.label ?? ""; } else if (child.type > 2) { obj.placeholder = child.placeholder ?? ""; } return obj; }) ); if (message.editedTimestamp) msg.editedTimestamp = message.editedTimestamp; msg.flags = message.flags.toArray(); if (message.stickers.size > 0) msg.stickerURLs = message.stickers.map((sticker) => sticker.url); if (message.reference) msg.referencedMessage = [ message.reference.guildId ?? "", message.reference.channelId, message.reference.messageId ?? "" ]; newOut.messages.push(msg); }); return newOut; } toHumanReadable(transcript: Omit): string { let out = ""; for (const message of transcript.messages) { if (message.referencedMessage) { if (Array.isArray(message.referencedMessage)) { out += `> [Crosspost From] ${message.referencedMessage[0]} in ${message.referencedMessage[1]} in ${message.referencedMessage[2]}\n`; } else out += `> [Reply To] ${message.referencedMessage}\n`; } out += `${message.author.nickname ?? message.author.username}#${message.author.discriminator} (${ message.author.id }) (${message.id})`; out += ` [${new Date(message.createdTimestamp).toISOString()}]`; if (message.editedTimestamp) out += ` [Edited: ${new Date(message.editedTimestamp).toISOString()}]`; out += "\n"; if (message.content) out += `[Content]\n${message.content}\n\n`; if (message.embeds) { for (const embed of message.embeds) { out += `[Embed]\n`; if (embed.title) out += `| Title: ${embed.title}\n`; if (embed.description) out += `| Description: ${embed.description}\n`; if (embed.fields) { for (const field of embed.fields) { out += `| Field: ${field.name} - ${field.value}\n`; } } if (embed.footer) { out += `|Footer: ${embed.footer.text}\n`; } out += "\n"; } } if (message.components) { for (const component of message.components) { out += `[Component]\n`; for (const button of component) { out += `| Button: ${button.label ?? button.description}\n`; } out += "\n"; } } if (message.attachments) { for (const attachment of message.attachments) { out += `[Attachment] ${attachment.filename} (${attachment.size} bytes) ${attachment.url}\n`; } } out += "\n\n"; } return out; } } export class History { histories: Collection; constructor() { this.histories = database.collection("history"); } async create( type: string, guild: string, user: Discord.User, moderator: Discord.User | null, reason: string | null, before?: string | null, after?: string | null, amount?: string | null ) { // console.log("History create"); await this.histories.insertOne( { type: type, guild: guild, user: user.id, moderator: moderator ? moderator.id : null, reason: reason, occurredAt: new Date(), before: before ?? null, after: after ?? null, amount: amount ?? null }, collectionOptions ); } async read(guild: string, user: string, year: number) { // console.log("History read"); const entry = (await this.histories .find({ guild: guild, user: user, occurredAt: { $gte: new Date(year - 1, 11, 31, 23, 59, 59), $lt: new Date(year + 1, 0, 1, 0, 0, 0) } }) .toArray()) as HistorySchema[]; return entry; } async delete(guild: string) { // console.log("History delete"); await this.histories.deleteMany({ guild: guild }); } } interface ScanCacheSchema { addedAt: Date; hash: string; nsfw?: boolean; malware?: boolean; bad_link?: boolean; tags?: string[]; } export class ScanCache { scanCache: Collection; constructor() { this.scanCache = database.collection("scanCache"); } async read(hash: string) { return await this.scanCache.findOne({ hash: hash }); } async write(hash: string, type: "nsfw" | "malware" | "bad_link", data: boolean, tags?: string[]) { await this.scanCache.updateOne( { hash: hash }, { $set: (() => { switch (type) { case "nsfw": { return { nsfw: data, addedAt: new Date() }; } case "malware": { return { malware: data, addedAt: new Date() }; } case "bad_link": { return { bad_link: data, tags: tags ?? [], addedAt: new Date() }; } default: { throw new Error("Invalid type"); } } })() // No you can't just do { [type]: data }, yes it's a typescript error, no I don't know how to fix it // cleanly, yes it would be marginally more elegant, no it's not essential, yes I'd be happy to review // PRs that did improve this snippet }, Object.assign({ upsert: true }, collectionOptions) ); } async cleanup() { // console.log("ScanCache cleanup"); await this.scanCache.deleteMany({ addedAt: { $lt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 31) }, hash: { $not$text: "http" } }); } } export class PerformanceTest { performanceData: Collection; constructor() { this.performanceData = database.collection("performance"); } async record(data: PerformanceDataSchema) { // console.log("PerformanceTest record"); data.timestamp = new Date(); await this.performanceData.insertOne(data, collectionOptions); } async read() { // console.log("PerformanceTest read"); return await this.performanceData.find({}).toArray(); } } export interface PerformanceDataSchema { timestamp?: Date; discord: number; databaseRead: number; resources: { cpu: number; memory: number; temperature: number; }; } export class ModNotes { modNotes: Collection; constructor() { this.modNotes = database.collection("modNotes"); } async create(guild: string, user: string, note: string | null) { // console.log("ModNotes create"); await this.modNotes.updateOne({ guild: guild, user: user }, { $set: { note: note } }, { upsert: true }); } async read(guild: string, user: string) { // console.log("ModNotes read"); const entry = await this.modNotes.findOne({ guild: guild, user: user }); return entry?.note ?? null; } async delete(guild: string) { // console.log("ModNotes delete"); await this.modNotes.deleteMany({ guild: guild }); } } export class Premium { premium: Collection; cache: Map; // Date indicates the time one hour after it was created cacheTimeout = 1000 * 60 * 60; // 1 hour constructor() { this.premium = database.collection("premium"); this.cache = new Map(); } async updateUser(user: string, level: number) { // console.log("Premium updateUser"); if (!(await this.userExists(user))) await this.createUser(user, level); await this.premium.updateOne({ user: user }, { $set: { level: level } }, { upsert: true }); } async userExists(user: string): Promise { // console.log("Premium userExists"); const entry = await this.premium.findOne({ user: user }); return entry ? true : false; } async createUser(user: string, level: number) { // console.log("Premium createUser"); await this.premium.insertOne({ user: user, appliesTo: [], level: level }, collectionOptions); } async hasPremium(guild: string): Promise<[boolean, string, number, boolean] | null> { // console.log("Premium hasPremium"); // [Has premium, user giving premium, level, is mod: if given automatically] const cached = this.cache.get(guild); if (cached && cached[4].getTime() < Date.now()) return [cached[0], cached[1], cached[2], cached[3]]; const entries = await this.premium.find({}).toArray(); const members = (await client.guilds.fetch(guild)).members.cache; for (const { user } of entries) { const member = members.get(user); if (member) { //TODO: Notify user if they've given premium to a server that has since gotten premium via a mod. const modPerms = //TODO: Create list in config for perms member.permissions.has("Administrator") || member.permissions.has("ManageChannels") || member.permissions.has("ManageRoles") || member.permissions.has("ManageEmojisAndStickers") || member.permissions.has("ManageWebhooks") || member.permissions.has("ManageGuild") || member.permissions.has("KickMembers") || member.permissions.has("BanMembers") || member.permissions.has("ManageEvents") || member.permissions.has("ManageMessages") || member.permissions.has("ManageThreads"); const entry = entries.find((e) => e.user === member.id); if (entry && entry.level === 3 && modPerms) { this.cache.set(guild, [ true, member.id, entry.level, true, new Date(Date.now() + this.cacheTimeout) ]); return [true, member.id, entry.level, true]; } } } const entry = await this.premium.findOne({ appliesTo: { $elemMatch: { $eq: guild } } }); this.cache.set(guild, [ entry ? true : false, entry?.user ?? "", entry?.level ?? 0, false, new Date(Date.now() + this.cacheTimeout) ]); return entry ? [true, entry.user, entry.level, false] : null; } async fetchUser(user: string): Promise { // console.log("Premium fetchUser"); const entry = await this.premium.findOne({ user: user }); if (!entry) return null; return entry; } async checkAllPremium(member?: GuildMember) { // console.log("Premium checkAllPremium"); const entries = await this.premium.find({}).toArray(); if (member) { const entry = entries.find((e) => e.user === member.id); if (entry) { const expiresAt = entry.expiresAt; if (expiresAt) expiresAt < Date.now() ? await this.premium.deleteOne({ user: member.id }) : null; } const roles = member.roles; let level = 0; if (roles.cache.has("1066468879309750313")) { level = 99; } else if (roles.cache.has("1066465491713003520")) { level = 1; } else if (roles.cache.has("1066439526496604194")) { level = 2; } else if (roles.cache.has("1066464134322978912")) { level = 3; } await this.updateUser(member.id, level); if (level > 0) { await this.premium.updateOne({ user: member.id }, { $unset: { expiresAt: "" } }); } else { await this.premium.updateOne( { user: member.id }, { $set: { expiresAt: Date.now() + 1000 * 60 * 60 * 24 * 3 } } ); } } else { const members = await (await client.guilds.fetch("684492926528651336")).members.fetch(); for (const { roles, id } of members.values()) { const entry = entries.find((e) => e.user === id); if (entry) { const expiresAt = entry.expiresAt; if (expiresAt) expiresAt < Date.now() ? await this.premium.deleteOne({ user: id }) : null; } let level: number = 0; if (roles.cache.has("1066468879309750313")) { level = 99; } else if (roles.cache.has("1066465491713003520")) { level = 1; } else if (roles.cache.has("1066439526496604194")) { level = 2; } else if (roles.cache.has("1066464134322978912")) { level = 3; } await this.updateUser(id, level); if (level > 0) { await this.premium.updateOne({ user: id }, { $unset: { expiresAt: "" } }); } else { await this.premium.updateOne( { user: id }, { $set: { expiresAt: Date.now() + 1000 * 60 * 60 * 24 * 3 } } ); } } } } async addPremium(user: string, guild: string) { // console.log("Premium addPremium"); const { level } = (await this.fetchUser(user))!; this.cache.set(guild, [true, user, level, false, new Date(Date.now() + this.cacheTimeout)]); return this.premium.updateOne({ user: user }, { $addToSet: { appliesTo: guild } }, { upsert: true }); } async removePremium(user: string, guild: string) { // console.log("Premium removePremium"); this.cache.set(guild, [false, "", 0, false, new Date(Date.now() + this.cacheTimeout)]); return await this.premium.updateOne({ user: user }, { $pull: { appliesTo: guild } }); } } // export class Plugins {} export interface GuildConfig { id: string; version: number; singleEventNotifications: Record; filters: { images: { NSFW: boolean; size: boolean; }; malware: boolean; wordFilter: { enabled: boolean; words: { strict: string[]; loose: string[]; }; allowed: { users: string[]; roles: string[]; channels: string[]; }; }; invite: { enabled: boolean; allowed: { channels: string[]; roles: string[]; users: string[]; }; }; pings: { mass: number; everyone: boolean; roles: boolean; allowed: { roles: string[]; rolesToMention: string[]; users: string[]; channels: string[]; }; }; clean: { channels: string[]; allowed: { users: string[]; roles: string[]; }; }; }; autoPublish: { enabled: boolean; channels: string[]; }; welcome: { enabled: boolean; role: string | null; ping: string | null; channel: string | null; message: string | null; }; stats: Record; logging: { logs: { enabled: boolean; channel: string | null; toLog: string; }; staff: { channel: string | null; }; attachments: { channel: string | null; saved: Record; }; }; verify: { enabled: boolean; role: string | null; }; tickets: { enabled: boolean; category: string | null; types: string; customTypes: string[] | null; useCustom: boolean; supportRole: string | null; maxTickets: number; }; moderation: { mute: { timeout: boolean; role: string | null; text: string | null; link: string | null; }; kick: { text: string | null; link: string | null; }; ban: { text: string | null; link: string | null; }; softban: { text: string | null; link: string | null; }; warn: { text: string | null; link: string | null; }; role: { role: string | null; text: null; link: null; }; nick: { text: string | null; link: string | null; }; }; tracks: { name: string; retainPrevious: boolean; nullable: boolean; track: string[]; manageableBy: string[]; }[]; roleMenu: { enabled: boolean; allowWebUI: boolean; options: { name: string; description: string; min: number; max: number; options: { name: string; description: string | null; role: string; }[]; }[]; }; tags: Record; } export interface HistorySchema { type: string; guild: string; user: string; moderator: string | null; reason: string | null; occurredAt: Date; before: string | null; after: string | null; amount: string | null; } export interface ModNoteSchema { guild: string; user: string; note: string | null; } export interface PremiumSchema { user: string; level: number; appliesTo: string[]; expiresAt?: number; }