diff --git a/src/app.ts b/src/app.ts index 3681ffa..661e808 100644 --- a/src/app.ts +++ b/src/app.ts @@ -40,24 +40,15 @@ class App { @Schedule("*/5 * * * *") async reportHealth(): Promise { await axios.get(process.env.HEALTH_CHECK_URL!); + return; } async init(): Promise { - this.client.once("ready", async () => { - await this.client.initApplicationCommands(); - }); - await DirectoryUtils.getFilesInDirectory( `${__dirname}/${getConfigValue("commands_directory")}`, DirectoryUtils.appendFileExtension("Command") ); - this.client.on("interactionCreate", interaction => { - this.client.executeInteraction(interaction); - }); - - await this.client.login(process.env.DISCORD_TOKEN!); - const handlerFiles = await DirectoryUtils.getFilesInDirectory( `${__dirname}/${getConfigValue("handlers_directory")}`, DirectoryUtils.appendFileExtension("Handler") @@ -71,6 +62,16 @@ class App { this.client.on(handlerInstance.getEvent(), handlerInstance.handle); }); + this.client.once("ready", async () => { + await this.client.initApplicationCommands(); + }); + + this.client.on("interactionCreate", interaction => { + this.client.executeInteraction(interaction); + }); + + await this.client.login(process.env.DISCORD_TOKEN!); + if (process.env.NODE_ENV === getConfigValue("PRODUCTION_ENV")) { const channelSnowflake = getConfigValue("AUTHENTICATION_MESSAGE_CHANNEL"); const messageSnowflake = getConfigValue("AUTHENTICATION_MESSAGE_ID"); diff --git a/src/commands/ReportToModsCommand.ts b/src/commands/ReportToModsCommand.ts new file mode 100644 index 0000000..284569d --- /dev/null +++ b/src/commands/ReportToModsCommand.ts @@ -0,0 +1,116 @@ +import {ApplicationCommandType, EmbedBuilder, MessageContextMenuCommandInteraction, ChannelType, TextChannel, ActionRowBuilder, ButtonBuilder, ButtonStyle, ButtonInteraction, ColorResolvable } from "discord.js"; +import { ContextMenu, Discord, ButtonComponent } from "discordx"; +import getConfigValue from "../utils/getConfigValue"; +import GenericObject from "../interfaces/GenericObject"; +// Import GenericObject from "../interfaces/GenericObject"; +// Import App from "../app"; +// Import { log } from "console"; +// Import { channel } from "diagnostics_channel"; + +@Discord() +class ReportToMods { + @ContextMenu({name: "Flag to moderators", type: ApplicationCommandType.Message, guilds: ["1213170850015084594"]}) + async onContext(interaction: MessageContextMenuCommandInteraction): Promise { + const logChannelId: string = getConfigValue("LOG_CHANNEL_ID"); + + const logChannelRaw = await interaction.client.channels.fetch(logChannelId); + + if (!logChannelRaw || logChannelRaw.type !== ChannelType.GuildText) { + console.error("Invalid logChannel instance"); + return; + } + + const logChannel = logChannelRaw as TextChannel; + + const reportedMessage = interaction.targetMessage; + const messageLink = `https://discord.com/channels/${reportedMessage.guildId}/${reportedMessage.channelId}/${reportedMessage.id}`; + + const rawContent = reportedMessage.content.replace(/[*_~`]/g, "\\$&") || "[*No message content*]"; + + const truncatedContent = + rawContent.length > 100 + ? rawContent.slice(0, 100) + "…" + : rawContent; + + const embed = new EmbedBuilder() + .setTitle("Message flagged to moderators") + .setDescription(truncatedContent) + .addFields([ + { name: "Author", value: `<@${reportedMessage.author.id}>`, inline: true }, + { name: "Channel", value: `<#${reportedMessage.channelId}>`, inline: true }, + { name: "Message Link", value: messageLink, inline: true }, + { name: "The person reporting", value: `<@${interaction.user.id}>`} + ] + ); + + await logChannel.send(`<@&${getConfigValue("MOD_ROLE")}>`); + + embed.setColor(getConfigValue>("EMBED_COLOURS").DEFAULT); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`report:approve:${reportedMessage.channelId}:${reportedMessage.id}`) + .setLabel("Approve") + .setStyle(ButtonStyle.Success), + + new ButtonBuilder() + .setCustomId(`report:reject:${reportedMessage.channelId}:${reportedMessage.id}`) + .setLabel("Reject") + .setStyle(ButtonStyle.Danger) + ); + + await logChannel.send({ embeds: [embed], components: [row] }); + + await interaction.reply({ + content: "Message flagged to moderators. They will review the reported content as soon as possible.", + ephemeral: true + }); + } + + @ButtonComponent({ id: /^report:(approve|reject):\d+:\d+$/ }) + async onButton(interaction: ButtonInteraction): Promise { + const modRoleId = getConfigValue("MOD_ROLE"); + + const roles = interaction.member?.roles; + + const hasRole = Array.isArray(roles) + ? roles?.includes(modRoleId) + : roles?.cache.has(modRoleId); + + if (!hasRole) { + await interaction.reply({ + content: "You do not have permission to perform this action.", + ephemeral: true + }); + return; + } + + const [, action, channelId, messageId] = interaction.customId.split(":"); + + if (action === "approve") { + const channel = await interaction.client.channels.fetch(channelId); + + if (!channel || !channel.isTextBased()) { + await interaction.reply({ + content: "Cannot resolve channel.", + ephemeral: true + }); + return; + } + + const message = await channel.messages.fetch(messageId); + + await message.delete(); + } + + await interaction.update({ + content: + action === "approve" + ? `✅ Report approved by <@${interaction.user.id}>` + : `❌ Report rejected by <@${interaction.user.id}>`, + components: [] + }); + } +} + +export default ReportToMods; diff --git a/src/config.json b/src/config.json index 2c52ee0..61e1b33 100644 --- a/src/config.json +++ b/src/config.json @@ -1,10 +1,10 @@ { - "GUILD_ID": "240880736851329024", - "BOT_ID": "545281816026677258", + "GUILD_ID": "1213170850015084594", + "BOT_ID": "1097888956244316242", "COMMAND_PREFIX": "?", "MEMBER_ROLE": "592088198746996768", "REGULAR_ROLE": "700614448846733402", - "MOD_ROLE": "490594428549857281", + "MOD_ROLE": "1255503137851179008", "REGULAR_ROLE_CHANGE_CHANNEL": "1241698863664988182", "SHOWCASE_CHANNEL_ID": "240892912186032129", "AUTHENTICATION_MESSAGE_ID": "592316062796873738", @@ -21,7 +21,7 @@ "DEVELOPMENT_ENV": "dev", "commands_directory": "commands", "handlers_directory": "event/handlers", - "LOG_CHANNEL_ID": "405068878151024640", + "LOG_CHANNEL_ID": "1213170850740834336", "MOD_CHANNEL_ID": "495713774205141022", "GENERAL_CHANNEL_ID": "518817917438001152", "ANTISPAM_CHANNEL_ID": "1416375771727138929", diff --git a/test/appTest.ts b/test/appTest.ts index e8d007d..0d03dcb 100644 --- a/test/appTest.ts +++ b/test/appTest.ts @@ -21,7 +21,9 @@ describe("App", () => { sandbox = createSandbox(); // @ts-ignore - (axios as unknown as AxiosCacheInstance).defaults.cache = undefined; + const axiosCache = axios as unknown as AxiosCacheInstance; + + axiosCache.defaults.cache = undefined; loginStub = sandbox.stub(Client.prototype, "login"); getStub = sandbox.stub(axios, "get").resolves(); @@ -115,4 +117,4 @@ describe("App", () => { afterEach(() => { sandbox.restore(); }); -}); +}); \ No newline at end of file diff --git a/test/commands/ReportToModsCommandTest.ts b/test/commands/ReportToModsCommandTest.ts new file mode 100644 index 0000000..53130ff --- /dev/null +++ b/test/commands/ReportToModsCommandTest.ts @@ -0,0 +1,178 @@ +import { expect } from "chai"; +import { createSandbox, SinonSandbox } from "sinon"; +import ReportToMods from "../../src/commands/ReportToModsCommand"; +import { ChannelType } from "discord.js"; +import * as config from "../../src/utils/getConfigValue"; + +describe("ReportToMods", () => { + let sandbox: SinonSandbox; + let command: ReportToMods; + let getConfigStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = createSandbox(); + command = new ReportToMods(); + + // ✅ SINGLE stub for getConfigValue + getConfigStub = sandbox.stub(config, "default"); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe("onContext()", () => { + it("sends a role ping, an embed to the log channel, and replies ephemerally", async () => { + const sendStub = sandbox.stub().resolves(); + const replyStub = sandbox.stub().resolves(); + + getConfigStub.withArgs("LOG_CHANNEL_ID").returns("LOG_CHANNEL_ID"); + getConfigStub.withArgs("MOD_ROLE").returns("MOD_ROLE_ID"); + getConfigStub.withArgs("EMBED_COLOURS").returns({ DEFAULT: "#ffffff" }); + + const interaction: any = { + targetMessage: { + id: "456", + channelId: "123", + guildId: "789", + content: "Test *message*", + author: { id: "111" } + }, + client: { + channels: { + fetch: sandbox.stub().resolves({ + type: ChannelType.GuildText, + send: sendStub + }) + } + }, + user: { + id: "190" + }, + reply: replyStub + }; + + await command.onContext(interaction); + + // 🔹 send called twice now + expect(sendStub.callCount).to.equal(2); + + // 🔹 first call = role ping + expect(sendStub.getCall(0).args[0]).to.equal("<@&MOD_ROLE_ID>"); + + // 🔹 second call = embed payload + const embed = sendStub.getCall(1).args[0].embeds[0]; + + expect(embed.data.title).to.equal("Message flagged to moderators"); + expect(embed.data.description).to.equal("Test \\*message\\*"); + + expect(replyStub.calledOnce).to.be.true; + }); + + it("returns early if log channel is invalid", async () => { + getConfigStub.withArgs("LOG_CHANNEL_ID").returns("LOG_CHANNEL_ID"); + getConfigStub.withArgs("MOD_ROLE").returns("MOD_ROLE_ID"); + getConfigStub.withArgs("EMBED_COLOURS").returns({ DEFAULT: "#ffffff" }); + + const fetchStub = sandbox.stub().resolves(null); + + const interaction: any = { + targetMessage: {}, + client: { + channels: { + fetch: fetchStub + } + }, + user: { + id: "190" + }, + reply: sandbox.stub() + }; + + await command.onContext(interaction); + + expect(fetchStub.calledOnce).to.be.true; + expect(interaction.reply.called).to.be.false; + }); + }); + + describe("onButton()", () => { + it("rejects users without the mod role", async () => { + getConfigStub.withArgs("MOD_ROLE").returns("MOD_ROLE_ID"); + + const replyStub = sandbox.stub().resolves(); + + const interaction: any = { + customId: "report:approve:123:456", + user: { id: "999" }, + member: { + roles: { + cache: new Map() + } + }, + reply: replyStub + }; + + await command.onButton(interaction); + + expect(replyStub.calledOnce).to.be.true; + expect(replyStub.getCall(0).args[0].ephemeral).to.be.true; + }); + + it("deletes the message when approved by a mod", async () => { + getConfigStub.withArgs("MOD_ROLE").returns("MOD_ROLE_ID"); + + const deleteStub = sandbox.stub().resolves(); + const fetchMessageStub = sandbox.stub().resolves({ delete: deleteStub }); + const updateStub = sandbox.stub().resolves(); + + const interaction: any = { + customId: "report:approve:123:456", + user: { id: "999" }, + member: { + roles: { + cache: new Map([["MOD_ROLE_ID", true]]) + } + }, + client: { + channels: { + fetch: sandbox.stub().resolves({ + isTextBased: () => true, + messages: { + fetch: fetchMessageStub + } + }) + } + }, + update: updateStub + }; + + await command.onButton(interaction); + + expect(fetchMessageStub.calledOnceWith("456")).to.be.true; + expect(deleteStub.calledOnce).to.be.true; + expect(updateStub.calledOnce).to.be.true; + }); + + it("does not delete the message when rejected", async () => { + getConfigStub.withArgs("MOD_ROLE").returns("MOD_ROLE_ID"); + + const updateStub = sandbox.stub().resolves(); + + const interaction: any = { + customId: "report:reject:123:456", + user: { id: "999" }, + member: { + roles: { + cache: new Map([["MOD_ROLE_ID", true]]) + } + }, + update: updateStub + }; + + await command.onButton(interaction); + + expect(updateStub.calledOnce).to.be.true; + }); + }); +});