diff --git a/package.json b/package.json index 22f76ae..c9d1060 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "bufferutil": "^4.0.8", "cheerio": "^1.0.0-rc.12", "discord.js": "^14.14.1", - "djs-fsrouter": "^0.0.10", + "djs-fsrouter": "^0.0.12", "drizzle-orm": "^0.29.2", "entities-decode": "^2.0.0", "erlpack": "^0.1.4", diff --git a/src/commands/delete-and-warn.ts b/src/commands/delete-and-warn.ts new file mode 100644 index 0000000..a137ffd --- /dev/null +++ b/src/commands/delete-and-warn.ts @@ -0,0 +1,64 @@ +import { + PermissionFlagsBits, + ApplicationCommandType, + TextInputStyle, +} from "discord.js"; +const { ManageMessages, ModerateMembers } = PermissionFlagsBits; +import { modalInput } from "../components.ts"; +import type { MessageCommand } from "djs-fsrouter"; + +export const customId = "delAndWarn"; + +const DeleteAndWarn: MessageCommand = { + type: ApplicationCommandType.Message, + defaultMemberPermissions: ManageMessages | ModerateMembers, + dmPermission: false, + run: async (interaction) => { + const { channel, targetMessage } = interaction; + if (!channel) + return interaction.reply({ + ephemeral: true, + content: "Error: could not fetch the channel", + }); + if (channel.isDMBased()) + return interaction.reply({ + ephemeral: true, + content: "Cannot use this command in DMs", + }); + + if (!targetMessage.deletable) { + return interaction.reply({ + ephemeral: true, + content: + "I do not have the permission to delete messages in this channel.", + }); + } + const { id, author } = targetMessage; + // targetMessage.member is always null for some reason + const member = await channel.guild.members.fetch(author); + + interaction.showModal({ + title: `Deleting ${author.displayName}'s message`, + customId: `${author.id}_${id}_${customId}`, + components: [ + modalInput({ + customId: "deletionReason", + label: "Reason", + placeholder: "This will be sent in DM to the author.", + maxLength: 512, + style: TextInputStyle.Short, + }), + modalInput({ + customId: "timeout", + required: false, + label: "Timeout", + placeholder: member.moderatable + ? "e.g 30m, 3h, 1d" + : "This will have no effet; I do not have the permission to time this member out", + style: TextInputStyle.Short, + }), + ], + }); + }, +}; +export default DeleteAndWarn; diff --git a/src/commands/logging/$info.js b/src/commands/logging/$info.js new file mode 100644 index 0000000..8a43f03 --- /dev/null +++ b/src/commands/logging/$info.js @@ -0,0 +1,5 @@ +export default { + description: "Handles logging deleted & edited messages", + dmPermission: false, + defaultMemberPermissions: "0", +}; \ No newline at end of file diff --git a/src/commands/logging/clear.ts b/src/commands/logging/clear.ts new file mode 100644 index 0000000..7cf31fc --- /dev/null +++ b/src/commands/logging/clear.ts @@ -0,0 +1,112 @@ +import { getConfig } from "../../logging.ts"; + +import { + ApplicationCommandOptionType, + ApplicationCommandType, + GuildMessageManager, + Message, + User, +} from "discord.js"; +import type { Command } from "djs-fsrouter"; +import { deleteColor, editColor } from "../../listeners/logging.ts"; + +const _14_DAYS = 1209500000; + +export const type = ApplicationCommandType.ChatInput; +const Config: Command = { + description: "Remove all logged messages of a given user up to 14 days", + options: [ + { + name: "user", + required: true, + type: ApplicationCommandOptionType.User, + description: "The user to purge from our records", + }, + ], + async run(interaction) { + const { guild } = interaction; + if (!guild) return; + + await interaction.deferReply().catch(console.error); + const { channel } = getConfig(guild) || {}; + if (!channel) + return interaction + .editReply("Error: No logging channel has been set.") + .catch(console.error); + const logs = await guild.channels.fetch(channel); + if (!logs || !logs.isTextBased()) + return interaction + .editReply("Error: Could not retrieve the logging channel.") + .catch(console.error); + + const target = interaction.options.getUser("user", true); + const targetMention = target.toString(); + const me = await guild.members.fetchMe(); + const promises = []; + const toDelete: Message[] = []; + let bulkPurges = 0; + + for await (const chunk of fetchTill14days(logs.messages)) { + const targetLogs = chunk.filter((message) => { + const { member, embeds } = message; + if (member !== me || !embeds.length) return false; + + const [{ description: embed, color }] = embeds; + if (!embed || (color !== deleteColor && color !== editColor)) + return false; + if (embeds.length > 1) { + bulkPurges++; + promises.push(purgeBulk(target, message)); + return false; + } + const mentionPos = embed.indexOf(targetMention); + if (mentionPos !== -1 && mentionPos < embed.indexOf("\n")) return true; + }); + + if (targetLogs.size) toDelete.push(...targetLogs.values()); + } + + for (let i = 0; i < toDelete.length; i += 100) + promises.push(logs.bulkDelete(toDelete.slice(i, i + 100))); + + await Promise.allSettled(promises); + interaction + .editReply( + `Erased ${toDelete.length} logs and purged ${bulkPurges} bulk logs.`, + ) + .catch(console.error); + }, +}; +export default Config; + +async function* fetchTill14days(messageManager: GuildMessageManager) { + const now = Date.now(); + let chunk = await messageManager.fetch({ limit: 100, cache: false }); + let last = chunk.last(); + while (last) { + yield chunk.filter( + ({ createdTimestamp }) => now - createdTimestamp < _14_DAYS, + ); + + if (now - last.createdTimestamp > _14_DAYS) return; + + chunk = await messageManager.fetch({ + before: last.id, + limit: 100, + cache: false, + }); + last = chunk.last(); + } +} + +/** + * Purges logs of a given user from a bulk log + * @param user The user to erase + * @param message The bulk log + * @returns The number of logs removed + */ +function purgeBulk({ tag }: User, message: Message) { + const embeds = message.embeds.filter(({ author }) => author?.name !== tag); + if (embeds.length !== message.embeds.length) + return embeds.length ? message.edit({ embeds }) : message.delete(); +} diff --git a/src/commands/mdn.ts b/src/commands/mdn.ts index 9adfc7d..c13e3a4 100644 --- a/src/commands/mdn.ts +++ b/src/commands/mdn.ts @@ -11,7 +11,7 @@ type SearchResult = { snippet: string; }; -const Info: Command = { +const Mdn: Command = { description: "Search the Modzilla Developer Network", options: [ { @@ -97,7 +97,7 @@ const Info: Command = { }).catch(console.error); }, }; -export default Info; +export default Mdn; const BASE_URL = process.env.CSE_KEY && process.env.CSE_CSX diff --git a/src/commands/purge.ts b/src/commands/purge.ts new file mode 100644 index 0000000..eeae24c --- /dev/null +++ b/src/commands/purge.ts @@ -0,0 +1,73 @@ +import { ApplicationCommandOptionType, PermissionFlagsBits } from "discord.js"; +const { ManageMessages } = PermissionFlagsBits; +import type { Command } from "djs-fsrouter"; + +const Purge: Command = { + defaultMemberPermissions: ManageMessages, + dmPermission: false, + description: "Bulk delete messages in the current channel", + options: [ + { + name: "number", + required: true, + type: ApplicationCommandOptionType.Number, + minValue: 2, + maxValue: 100, + description: "How many messages to purge", + }, + { + name: "author", + type: ApplicationCommandOptionType.User, + description: "If set, only messages from this user will be deleted", + }, + ], + + async run(interaction) { + const number = interaction.options.getNumber("number", true); + const author = interaction.options.getMember("author"); + const { channel } = interaction; + + function reply(content: string) { + return interaction.reply({ ephemeral: true, content }); + } + + if (!channel) { + return reply("Error: could not fetch the channel"); + } + if (channel.isDMBased()) { + return reply("Error: can't do that in DMs!"); + } + const { + guild: { members }, + } = channel; + const myself = members.me || (await members.fetchMe()); + if (!channel.permissionsFor(myself).has(ManageMessages)) { + return reply( + "Error: I do not have the permission to delete messages in this channel.", + ); + } + + let messages = Array.from( + ( + await channel.messages.fetch({ + limit: number && !author ? number : 100, + cache: false, + }) + ).values(), + ); + + if (author) { + messages = messages.filter(({ member }) => member === author); + if (messages.length > number) messages.length = number; + } + + channel + .bulkDelete(messages, true) + .then(({ size }) => reply(`Deleted ${size} messages.`)) + .catch((error) => { + console.error(error); + reply(`Error: ${error.message}.\nSee the console for details.`); + }); + }, +}; +export default Purge; diff --git a/src/components.ts b/src/components.ts index c19fcbf..ec0cd6d 100644 --- a/src/components.ts +++ b/src/components.ts @@ -1,5 +1,11 @@ -import { ComponentType, ButtonStyle } from "discord.js"; -const { ActionRow, StringSelect, Button } = ComponentType; +import { + ComponentType, + ButtonStyle, + type ActionRowData, + type TextInputComponentData, + type ModalActionRowComponentData, +} from "discord.js"; +const { ActionRow, StringSelect, Button, TextInput } = ComponentType; import type { APIActionRowComponent, @@ -44,3 +50,17 @@ export function buttonRow( })), }; } + +export function modalInput( + input: Omit, +): ActionRowData { + return { + type: ActionRow, + components: [ + { + ...input, + type: TextInput, + }, + ], + }; +} diff --git a/src/listeners/delete-and-warn.ts b/src/listeners/delete-and-warn.ts new file mode 100644 index 0000000..323d653 --- /dev/null +++ b/src/listeners/delete-and-warn.ts @@ -0,0 +1,65 @@ +import type { Listener } from "../types/listener.ts"; +import { customId } from "../commands/delete-and-warn.ts"; + +export default [ + { + event: "interactionCreate", + async handler(interaction) { + if ( + !interaction.isModalSubmit() || + !interaction.customId.endsWith(customId) || + !interaction.guild + ) + return; + + const [targetId, messageId] = interaction.customId.split("_", 2); + const target = await interaction.guild.members.fetch(targetId); + const reason = interaction.fields.getField("deletionReason").value; + const targetMessage = await interaction.channel?.messages.fetch( + messageId, + ); + targetMessage?.delete().catch(console.error); + if (target.moderatable) { + const timeout = parseTime(interaction.fields.getField("timeout").value); + if (timeout > 0) target.timeout(timeout, reason).catch(console.error); + } + target + .send( + `Your message in ${interaction.channel} was deleted for the following reason:\n\`\`\`${reason}\`\`\``, + ) + .then(() => { + interaction + .reply({ + ephemeral: true, + content: "Reason sent.", + }) + .catch(console.error); + }) + .catch(() => { + interaction + .reply({ + ephemeral: true, + content: `The reason could not be sent to ${target}; they proabably blocked me or disabled DMs from server members.\n\`\`\`${reason}\`\`\``, + }) + .catch(console.error); + }); + }, + }, +] as Listener[]; + +const units: Record = { + s: 1_000, + m: 60_000, + h: 3600_000, + d: 24 * 3600_000, +}; +function parseTime(time: string) { + const parts = time.matchAll(/([0-9]+)([A-z])/g); + let ms = 0; + for (const [, value, unit] of parts) { + if (+value && unit in units) { + ms += +value * units[unit]; + } + } + return ms; +} diff --git a/src/listeners/logging.ts b/src/listeners/logging.ts new file mode 100644 index 0000000..1608d53 --- /dev/null +++ b/src/listeners/logging.ts @@ -0,0 +1,143 @@ +import { type Guild, Message, type APIEmbed } from "discord.js"; +import { getConfig, getWhitelist, unwhitelistRole } from "../logging.ts"; +import { LogMode } from "../types/logging.ts"; +import type { Listener } from "../types/listener.ts"; + +export const deleteColor = 0xdd4444; +export const editColor = 0xdd6d0c; + +export default [ + { + event: "messageDelete", + async handler(message) { + if (message.partial || shouldIgnore(message)) return; + const channel = await getLogChannel(message.guild, LogMode.DELETES); + if (!channel?.isTextBased()) return; + + const embed = { + ...msgDeletionEmbed(message), + description: + `**🗑️ Message from ${message.author} deleted in ${message.channel}**\n\n${message.content}`.substring( + 0, + 2056, + ), + timestamp: new Date().toISOString(), + }; + channel.send({ embeds: [embed] }).catch(console.error); + }, + }, + { + event: "messageDeleteBulk", + async handler(messages) { + const first = messages.first(); + const channel = await getLogChannel( + first?.guild || null, + LogMode.DELETES, + ); + if (!channel?.isTextBased()) return; + + const embeds = messages + .filter( + (message): message is Message => + !message.partial && !shouldIgnore(message), + ) + .map(msgDeletionEmbed); + + if (!embeds.length) return; + + channel + .send({ + embeds: [ + { + color: deleteColor, + description: `**🗑️ ${embeds.length} messages bulk-deleted in ${channel}**`, + timestamp: new Date().toISOString(), + }, + ...embeds.slice(0, 9), + ], + }) + .catch(console.error); + + for (let i = 9; i < embeds.length; i += 10) + channel.send({ embeds: embeds.slice(i, i + 10) }).catch(console.error); + }, + }, + { + event: "messageUpdate", + async handler(oldMessage, newMessage) { + if (oldMessage.partial || shouldIgnore(oldMessage)) return; + const channel = await getLogChannel(oldMessage.guild, LogMode.EDITS); + if (!channel?.isTextBased()) return; + + const { author, content: oldContent } = oldMessage; + channel + .send({ + embeds: [ + { + color: editColor, + author: { + name: author.tag, + icon_url: author.avatarURL() || undefined, + }, + description: + `**✏️ ${author} edited a [message](${newMessage.url}) in ${channel}\nPrevious message:**\n\n${oldContent}`.substring( + 0, + 2056, + ), + timestamp: new Date().toISOString(), + }, + ], + }) + .catch(console.error); + }, + }, + { + event: "roleDelete", + handler: unwhitelistRole, + }, +] as Listener[]; + +/** + * Tells if the logging system should ignore the given message. + * @param message The message to analyse + * @returns + */ +function shouldIgnore({ system, author, member }: Message) { + if (!member || author.bot || system) return true; + const whitelist = getWhitelist(member.guild); + return member.roles.cache.some((_, id) => whitelist.includes(id)); +} + +/** + * Gets the logging channel conditionally to a logging mode. + * @param guild + * @param requiredMode + * @returns null if the required mode isn't met, otherwise a Promise resolving to the channel + */ +function getLogChannel(guild: Guild | null, requiredMode: LogMode) { + if (!guild) return null; + const config = getConfig(guild); + if (!config?.channel || !(config.mode & requiredMode)) return null; + + return guild.channels.fetch(config.channel); +} + +/** + * Generates a deletion log for the provided message + * @param message The message to log + * @returns The embed + */ +function msgDeletionEmbed({ content, author, attachments }: Message): APIEmbed { + let attachmentN = 0; + return { + color: deleteColor, + author: { name: author.tag, icon_url: author.avatarURL() || undefined }, + description: content, + fields: attachments.size + ? attachments.map(({ url }) => ({ + name: `Attachment ${++attachmentN}`, + value: url, + })) + : undefined, + }; +} diff --git a/src/logging.ts b/src/logging.ts new file mode 100644 index 0000000..5980178 --- /dev/null +++ b/src/logging.ts @@ -0,0 +1,76 @@ +import db from "./db.ts"; +import { LogMode, type LogConfig } from "./types/logging.ts"; +import { Config } from "./schemas/config.ts"; +import { LoggingWhitelist } from "./schemas/loggingWhitelist.ts"; +import type { TextChannel, Guild, Role } from "discord.js"; +import { eq, and, sql } from "drizzle-orm"; +const { placeholder } = sql; + +export function setLogging( + { id }: Guild, + config: LogMode.NONE | { mode: LogMode; channel: TextChannel }, +) { + let loggingMode = LogMode.NONE; + let loggingChannel = null; + if (config) { + loggingMode = config.mode; + loggingChannel = config.channel.id; + } + + return db + .insert(Config) + .values({ id, loggingMode, loggingChannel }) + .onConflictDoUpdate({ + target: Config.id, + set: { loggingMode, loggingChannel }, + }) + .returning(); +} + +export function getConfig({ id }: { id: string }): LogConfig { + const { mode, channel } = selectConfig.all({ guildId: id })[0]; + return channel ? { mode, channel } : { mode: LogMode.NONE, channel }; +} + +export function getWhitelist({ id: guildId }: Guild) { + return whitelistRoles.all({ guildId }).map(({ id }) => id); +} + +export function whitelistRole({ id, guild: { id: guildId } }: Role) { + return whitelistRole_add.execute({ guildId, id }); +} + +// For some reason, simply returning the execute makes the roleDelete listener not work +export async function unwhitelistRole({ id, guild: { id: guildId } }: Role) { + return await whitelistRole_remove.execute({ guildId, id }); +} + +const selectConfig = db + .select({ mode: Config.loggingMode, channel: Config.loggingChannel }) + .from(Config) + .where(eq(Config.id, placeholder("guildId"))) + .prepare(); + +const whitelistRoles = db + .select({ id: LoggingWhitelist.roleId }) + .from(LoggingWhitelist) + .where(eq(LoggingWhitelist.guildId, placeholder("guildId"))) + .prepare(); + +const whitelistRole_add = db + .insert(LoggingWhitelist) + .values({ guildId: placeholder("guildId"), roleId: placeholder("id") }) + .onConflictDoNothing() + .returning() + .prepare(); + +const whitelistRole_remove = db + .delete(LoggingWhitelist) + .where( + and( + eq(LoggingWhitelist.guildId, placeholder("guildId")), + eq(LoggingWhitelist.roleId, placeholder("id")), + ), + ) + .returning() + .prepare(); diff --git a/src/schemas/config.ts b/src/schemas/config.ts index d503fb7..fb6e685 100644 --- a/src/schemas/config.ts +++ b/src/schemas/config.ts @@ -1,5 +1,6 @@ -import { sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; import type { InferInsertModel, InferSelectModel } from "drizzle-orm"; +import { LogMode } from "../types/logging.ts"; export const Config = sqliteTable("guildConfig", { id: text("guildId").primaryKey().notNull(), @@ -11,6 +12,12 @@ export const Config = sqliteTable("guildConfig", { gatewayLeaveTitle: text("gatewayLeaveTitle"), gatewayLeaveContent: text("gatewayLeaveContent"), + loggingMode: integer("loggingMode", { mode: "number" }) + .$type() + .notNull() + .default(LogMode.NONE), + loggingChannel: text("loggingChannel").default(""), + suggestionChannel: text("suggestionChannel"), suggestionManagerRole: text("suggestionManagerRole"), suggestionUpvoteEmoji: text("suggestionUpvoteEmoji",).default('👍'), diff --git a/src/schemas/loggingWhitelist.ts b/src/schemas/loggingWhitelist.ts new file mode 100644 index 0000000..90de356 --- /dev/null +++ b/src/schemas/loggingWhitelist.ts @@ -0,0 +1,15 @@ +import { sqliteTable, text, primaryKey } from "drizzle-orm/sqlite-core"; +import type { InferInsertModel, InferSelectModel } from "drizzle-orm"; + +export const LoggingWhitelist = sqliteTable( + "LoggingWhitelist", + { + guildId: text("guildId"), + roleId: text("roleId"), + }, + (table) => ({ + pk: primaryKey(table.guildId, table.roleId), + }), +); +export type LoggingWhitelistSelect = InferSelectModel; +export type LoggingWhitelistInsert = InferInsertModel; diff --git a/src/types/logging.ts b/src/types/logging.ts new file mode 100644 index 0000000..e72fb84 --- /dev/null +++ b/src/types/logging.ts @@ -0,0 +1,18 @@ +import type { TextChannel } from "discord.js"; + +export enum LogMode { + NONE = 0, + DELETES = 1 << 0, + EDITS = 1 << 1, + "DELETES & EDITS" = DELETES | EDITS, +} + +export type LogConfig = + | { + mode: LogMode.NONE; + channel: null; + } + | { + mode: LogMode; + channel: TextChannel["id"]; + };