Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 11 additions & 10 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,24 +40,15 @@ class App {
@Schedule("*/5 * * * *")
async reportHealth(): Promise<void> {
await axios.get(process.env.HEALTH_CHECK_URL!);
return;
}

async init(): Promise<void> {
this.client.once("ready", async () => {
await this.client.initApplicationCommands();
});

await DirectoryUtils.getFilesInDirectory(
`${__dirname}/${getConfigValue<string>("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<string>("handlers_directory")}`,
DirectoryUtils.appendFileExtension("Handler")
Expand All @@ -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<string>("PRODUCTION_ENV")) {
const channelSnowflake = getConfigValue<Snowflake>("AUTHENTICATION_MESSAGE_CHANNEL");
const messageSnowflake = getConfigValue<Snowflake>("AUTHENTICATION_MESSAGE_ID");
Expand Down
116 changes: 116 additions & 0 deletions src/commands/ReportToModsCommand.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const logChannelId: string = getConfigValue<string>("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<string>("MOD_ROLE")}>`);

embed.setColor(getConfigValue<GenericObject<ColorResolvable>>("EMBED_COLOURS").DEFAULT);

const row = new ActionRowBuilder<ButtonBuilder>().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<void> {
const modRoleId = getConfigValue<string>("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;
8 changes: 4 additions & 4 deletions src/config.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
6 changes: 4 additions & 2 deletions test/appTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -115,4 +117,4 @@ describe("App", () => {
afterEach(() => {
sandbox.restore();
});
});
});
178 changes: 178 additions & 0 deletions test/commands/ReportToModsCommandTest.ts
Original file line number Diff line number Diff line change
@@ -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;
});
});
});