Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
86b529d
feat: /purge
MorganVonBrylan Aug 31, 2023
ad13f1b
feat: /delete-and-warn
MorganVonBrylan Sep 2, 2023
545ad09
feat: give delete-and-warn modal a placeholder and max length
MorganVonBrylan Sep 13, 2023
fd2ccd2
feat: adds a modalInput helper
MorganVonBrylan Sep 13, 2023
dfc1130
feat: delete-and-warn can timeout the offender
MorganVonBrylan Sep 13, 2023
f09f2c8
feat: basic logging
MorganVonBrylan Jan 7, 2024
2815def
fix[logging]: slight style discrepancy
MorganVonBrylan Jan 7, 2024
4e9979c
refactor: fix default export names
MorganVonBrylan Jan 8, 2024
7eef1fd
fix[logging]: command permissions
MorganVonBrylan Jan 8, 2024
f24a2c8
feat[logging]: whitelisting roles so their messages are not logged
MorganVonBrylan Jan 8, 2024
c14500c
doc: some doc for the logging system
MorganVonBrylan Jan 17, 2024
41a1cb0
feat[logging]: purge command to remove logs from a given user
MorganVonBrylan Jan 17, 2024
90e615d
fix[logging]: check for partial messages
MorganVonBrylan Jan 18, 2024
e71e6b7
fix[logging]: "0 messages bulk-deleted"
MorganVonBrylan Jan 18, 2024
4b9682f
refactor: shorten /clear handler
MorganVonBrylan Jan 18, 2024
5ebf5ad
Update djs-fsrouter
MorganVonBrylan Jan 19, 2024
d5fa314
Update djs-fsrouter
MorganVonBrylan Jan 23, 2024
686027c
Merge branch 'main' into feature/moderation
MorganVonBrylan Jan 23, 2024
6c1a108
[logging] Remove all commands
MorganVonBrylan Feb 5, 2024
98d770e
fix: LoggingWhitelist primary key
MorganVonBrylan Feb 8, 2024
c55013c
ref: Biome complained
MorganVonBrylan Feb 8, 2024
3fe433e
fix: restore wrongly deleted command
MorganVonBrylan Feb 8, 2024
47e223b
refactor[logging]: remove unecessary caching
MorganVonBrylan Feb 8, 2024
6c10f39
fix: restore /logging description and permissions
MorganVonBrylan Feb 8, 2024
b3bbca7
refactor[logging]: move type defs to the types folder
MorganVonBrylan Feb 8, 2024
ac44fed
refactor[logging]: use LogMode type in the schema
MorganVonBrylan Feb 8, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
64 changes: 64 additions & 0 deletions src/commands/delete-and-warn.ts
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 5 additions & 0 deletions src/commands/logging/$info.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default {
description: "Handles logging deleted & edited messages",
dmPermission: false,
defaultMemberPermissions: "0",
};
112 changes: 112 additions & 0 deletions src/commands/logging/clear.ts
Original file line number Diff line number Diff line change
@@ -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();
}
4 changes: 2 additions & 2 deletions src/commands/mdn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ type SearchResult = {
snippet: string;
};

const Info: Command = {
const Mdn: Command = {
Comment thread
MorganVonBrylan marked this conversation as resolved.
description: "Search the Modzilla Developer Network",
options: [
{
Expand Down Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions src/commands/purge.ts
Original file line number Diff line number Diff line change
@@ -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.`))
Comment thread
MorganVonBrylan marked this conversation as resolved.
.catch((error) => {
console.error(error);
reply(`Error: ${error.message}.\nSee the console for details.`);
});
},
};
export default Purge;
24 changes: 22 additions & 2 deletions src/components.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -44,3 +50,17 @@ export function buttonRow(
})),
};
}

export function modalInput(
input: Omit<TextInputComponentData, "type">,
): ActionRowData<ModalActionRowComponentData> {
return {
type: ActionRow,
components: [
{
...input,
type: TextInput,
},
],
};
}
65 changes: 65 additions & 0 deletions src/listeners/delete-and-warn.ts
Original file line number Diff line number Diff line change
@@ -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}\`\`\``,
Comment thread
MorganVonBrylan marked this conversation as resolved.
)
.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<string, number> = {
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;
}
Loading