-
Notifications
You must be signed in to change notification settings - Fork 0
Feature/moderation #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
26 commits
Select commit
Hold shift + click to select a range
86b529d
feat: /purge
MorganVonBrylan ad13f1b
feat: /delete-and-warn
MorganVonBrylan 545ad09
feat: give delete-and-warn modal a placeholder and max length
MorganVonBrylan fd2ccd2
feat: adds a modalInput helper
MorganVonBrylan dfc1130
feat: delete-and-warn can timeout the offender
MorganVonBrylan f09f2c8
feat: basic logging
MorganVonBrylan 2815def
fix[logging]: slight style discrepancy
MorganVonBrylan 4e9979c
refactor: fix default export names
MorganVonBrylan 7eef1fd
fix[logging]: command permissions
MorganVonBrylan f24a2c8
feat[logging]: whitelisting roles so their messages are not logged
MorganVonBrylan c14500c
doc: some doc for the logging system
MorganVonBrylan 41a1cb0
feat[logging]: purge command to remove logs from a given user
MorganVonBrylan 90e615d
fix[logging]: check for partial messages
MorganVonBrylan e71e6b7
fix[logging]: "0 messages bulk-deleted"
MorganVonBrylan 4b9682f
refactor: shorten /clear handler
MorganVonBrylan 5ebf5ad
Update djs-fsrouter
MorganVonBrylan d5fa314
Update djs-fsrouter
MorganVonBrylan 686027c
Merge branch 'main' into feature/moderation
MorganVonBrylan 6c1a108
[logging] Remove all commands
MorganVonBrylan 98d770e
fix: LoggingWhitelist primary key
MorganVonBrylan c55013c
ref: Biome complained
MorganVonBrylan 3fe433e
fix: restore wrongly deleted command
MorganVonBrylan 47e223b
refactor[logging]: remove unecessary caching
MorganVonBrylan 6c10f39
fix: restore /logging description and permissions
MorganVonBrylan b3bbca7
refactor[logging]: move type defs to the types folder
MorganVonBrylan ac44fed
refactor[logging]: use LogMode type in the schema
MorganVonBrylan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.`)) | ||
|
MorganVonBrylan marked this conversation as resolved.
|
||
| .catch((error) => { | ||
| console.error(error); | ||
| reply(`Error: ${error.message}.\nSee the console for details.`); | ||
| }); | ||
| }, | ||
| }; | ||
| export default Purge; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}\`\`\``, | ||
|
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; | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.